딥다이브 스터디 25 (클래스)

김영현·2023년 10월 26일
2
post-thumbnail

클래스

자바스크립트는 프로토타입 기반 객체지향언어이다.
이는 곧 클래스가 필요 없는 객체지향언어임을 의미한다.

var Person = (function(){
	function Person(name){
    	this.name = name;
    }
  	Person.prototype.sayHi = function()  {
    	console.log("Hi my name is" + this.name)
    }
  	return Person;
}());

var me = new Person('lee');
me.sayHi() // Hi my name is lee

하지만, 사람이 있어야 프로그래밍 언어도 있는 법이다.
클래스에 익숙했던 프로그래머들은 프로토타입 기반 프로그래밍 방식에 혼란과 어려움을 느꼈다.
이를 위해 ES6에서 클래스가 도입되었다. 기반은 사실 프로토타입이다.
결국 문법적 설탕(syntatic sugar)이라고 볼 수 도있다.
정확히 똑같이 동작하는건 아니다. 클래스가 더 엄격하게 동작한다!

  • new연산자 없이 클래스 호출시 에러
  • 상속을 지원하는 extends, super키워드를 지원
  • 클래스는 TDZ를 지닌 호이스팅
  • 클래스 내부 모든코드는 암묵적으로 strict-mode가 걸려있고 해제 불가능
  • 클래스의 contructor, 프로토타입 메서드, 정적 메서드는 모두 [[Enumerable]]의 값이 false

생성자 함수와 클래스는 프로토타입 객체지향을 구현했다.
하지만 클래스가 더 견고하고 명료하다. extends, super키워드만 봐도!
클래스는 결국 문법적 설탕이라기보단 새로운 객체 생성 메커니즘으로 보는 것이 합당하다.

클래스 정의

class키워드를 사용하여 파스칼케이스(무방하다)로 작명한다.
클래스도 표현식을 이용하여 정의할 수 있다.

const my = class MyClass{};

표현식으로 선언 가능하다는건, 으로 사용할수 있는 일급 객체라는 소리다.
=> 그래서 내 노션클로닝프로젝트에서 라우팅부분 객체의 밸류로 할당할 수 있었구나!

내부 메서드는 총 3종류다.

class Person{
  //메소드 1. constructor
	constructor(name){
    	this.name = name;
    }
  // 메소드 2. 프로토타입
  sayHi(){
  	console.log("Hi my name is" + this.name)
  }
  // 메소드 3. 정적 메서드
  static sayHelllo(){
  	console.log("hi");
  }
}

const me = new Person("kim");

me.name // kim
me.sayHi() // Hi my name is kim
Person.sayHello() // hi
//정적 메서드는 인스턴스에서 호출 불가능하다

클래스 호이스팅

클래스함수다. 생성자함수가 원본이니, 그렇다고 봐줄만하다.

class Person
typeof Person // function

모든 선언문은 런타임 이전에 평가된다. 따라서 클래스도 호이스팅 된다.
또한, 함수타입이기에 런타임 이전에 함수 객체가 생성된다(constructor).
=> constructor프로토타입은 쌍으로 존재하기에 프로토타입도 생성된다.

클래스가 함수면, 함수 선언문처럼 나중에 선언하고 먼저 사용할 수 있을까?
안된다. let,const처럼 TDZ가 있어 선언문 작성 이전시점에 호출 불가능하다.

메서드

아까 메서드가 3종류라 하였다. 찬찬히 하나씩 살펴보자.

constructor

인스턴스를 생성하고 초기화하기위한 특별한 메서드
잠깐 클래스의 내부를 들여다보자.


함수니까, 함수객체 고유 프로퍼티를 모두 가진다.
또한, prototypecontructor는 자기자신(클래스)이다.
이는 클래스생성자 함수라는 것을 의미한다.

인스턴스가 생성되어 프로퍼티가 추가되었다.
이는 즉 constructor내부 this는 생성자함수처럼 생성될 인스턴스를 가리킨다.

흥미로운것은 인스턴스내부에는 constructor메소드가 보이지 않는다.
=> 클래스 정의가 평가되면, constructor의 기술된 동작을 하는 함수 객체가 생성된다.

최대 한개만 존재가능.
생략 가능(빈 cosntructor 암묵 정의).
프로퍼티가 추가되어 초기화된 인스턴스 생성(내부에서 그냥 할당)도 가능하다.

....
constructor(){
	this.name = "kim"
}

인스턴스를 초기화하려면 constructor를 생략하지 말자!
또한 내부 return문은 사용하지말자.

프로토타입 메서드

생성자함수처럼 번잡하게 작성하지 않아도 프로토타입체인의 일원이 된다.

class Person{
  //메소드 1. constructor
	constructor(name){
    	this.name = name;
    }
  // 메소드 2. 프로토타입
  sayHi(){
  	console.log("Hi my name is" + this.name)
  }
}

const me = new Person('kim');

Object.getPrototype(me) === Person.prototype // true
Object.getPrototype(Person.prototype) === Object.prototype // true
me.constructor === Person // true

me.prototype => Person.prototype => Object.prototype
클래스에서 정의한 메서드는 인스턴스의 프로토타입에 존재하는 메서드가 된다.
=> 인스턴스클래스의 메서드를 사용할 수 있는 이유다

정적 메서드

인스턴스를 생성하지 않아도 호출가능한 메서드.
클래스에 바인딩 되었다. 이는 클래스가 함수객체이기에 가능한 일.
평가과정에 이미 함수객체가 되므로, 따로 생성과정이 필요없다.
=> 생성하지 않아도 호출가능한 이유

클래스에 바인딩 되었다는 건, 인스턴스의 프로토타입 체인에 존재하지 않는다는 걸 뜻함.
=> 인스턴스에서는 사용 불가능한 이유

정적메서드 vs 프로토타입 메서드

무엇이 다를까?

  • 속해있는 프로토타입 체인이 다르다
  • 정적메서드는 클래스로, 프로토타입 메서드는 인스턴스로 호출한다.
  • 정적메서드 => 인스턴스 프로퍼티 참조불가, 프로토타입 메서드 => 인스턴스 프로퍼티 참조 가능.
class Square{
	constructor(width, height){
    	this.width = width;
        this.height = height;
    }
  	area(){
    	return this.width * this.heigth;
    }
  	static area(width, height){
    	return width * heigth;
    }
}

Square.area(10,10) // 100
const square = new Square(20,20)
square.area() // 400

이처럼 내부 프로퍼티, 즉 this를 사용하면 프로토타입 메서드를 사용하고
사용하지 않는다면 정적 메서드로 선언하는게 좋다.
=> 정적 메서드this는 쩜 표기법 앞에 기술된 객체, 즉 Square 클래스에 바인딩된다.

표준빌트인 객체들 역시 다양한 정적 메서드를 지니고있다. 전역에서 호출하기 위함이다.

Math.max
Number.isNaN
JSON.stringify
Obejct.is
Reflect.has 등

이처럼 클래스/생성자함수를 하나의 네임 스페이스로 사용하여 정적메서드를 모아놓으면
이름 충돌 가능성을 줄여주고, 관련 함수들을 구조화할수 있다.
=> 정적메서드는 전역에서 사용할 함수를 전역함수로 놓지않고, 구조화할때 유용하다.

참고로 클래스에서 정의한 메서드의 특징은 이렇다.

  • function 키워드 생략한 메서드 축약 표현사용
  • 메서드 축약표현은 non-constructor(내부슬롯없음)다.
  • 메서드 정의할때는 콤마필요x
  • 암묵적으로 엄-격 모드로 실행
  • 열거 불가능이다.

인스턴스 생성 과정

생성자 함수의 인스턴스 생성과정과 일치한다.

  1. 빈 객체(인스턴스)를 생성하여 this 바인딩. 이때 빈 객체의 프로토타입에 클래스의 prototype이 가리키는 객체가 설정된다.
  2. 인스턴스를 초기화한다. 클래스에 constructor가 존재할때만.
  3. 인스턴스가 바인딩 된 this가 반환된다.

프로퍼티

인스턴스의 프로퍼티는 클래스의 constructor내부에 정의.
this에 추가한 프로퍼티는 언제나 인스턴스의 프로퍼티가 됨.
=> 언제나 public한 프로퍼티라 위험. But, es2019에서 private필드 추가됨

접근자 프로퍼티

자체적으로 값을 갖지 않고, 다른 데이터의 값을 읽거나 저장할때 자동으로 쓰여지는 접근자 함수.

const person = {
	firstName = "youngheon",
  	lastName = "Kim",
  	get fullName(){
    	return `${this.lastName} ${this.firstName}`
    }
	set fullName(name){
    	[this.lastName, this.firstName] = name.split(" ");
    }
}
person.fullName // Kim youngheon
person.fullName = "Lee Kangin" // person.fullName에 들어감

마치 미들웨어같은 기능이다! 저장, 할당할때 get,set프로퍼티를 따로 선언하여 함수를 만들어놓으면
미들웨어처럼 동작하겠꾼.
참고로 호출하는 것이 아닌 참조 형식이다.

person.fullName() //x
person.fullName // o

접근자 프로퍼티 역시 프로토타입의 프로퍼티 된다.

클래스 필드 정의

클래스 필드란?
클래스가 생성할 인스턴스의 프로퍼티를 가리키는 용어이다.

이전 Simple TodoApp강화 과제에서 모든 컴포넌트는 하나의 부모 클래스Component를 상속받았다.
이때 Component 메소드에서 state라는 프로퍼티를 조작해야했다.
하지만 로직상 상속받은 자식들이 따로 state를 선언해서 사용해야 했기에, Component에서 this.state를 선언하지못하여, state프로퍼티를 참조하는 메서드를 선언하지 못하였다.
어떻게 할까 찾아보던 와중, 클래스 필드 라는걸 알게 되었다.

class Component{
	state //이렇게 아무런 const,let,var등 변수선언없이 선언해버린다.
  	constructor(...){
    	...
    }
    setState(nextState){
    	this.state = nextState;
      	this.render();
    }
}

이런식으로 활용이 가능했다. 경험적인 부분은 여기까지만 하고 다시 돌아가보자.

인스턴스 프로퍼티를 선언-초기화 하려면 무조건 constructor 내부에서 this를 사용해야한다.
하지만 클래스 기반 객체지향의 대표주자인 자바this를 생략해도 클래스 필드를 참조할수 있다.
=> 자바에서 this클래스 필드생성자, 메서드 매개변수의 이름과 동일할때 클래스 필드임을 명확히 하기 위해 사용한다.

class Person {
  //값을 할당하지 않으면 undefined가 할당된다.
	name = 'lee';
	//this.name = ""; 이렇게 선언은 불가능하다. contructor와 메서드 내에서만 this사용이 가능하다.
	constructor(){
    	console.log(this.name) // 자바에서는 this를 떼도 호출되지만, js는 무조건 this를 붙여야한다.
    }
}
const me = new Person();
me.name // lee

함수도 할당이 가능하다. 이는 함수가 일급 객체이기 때문이다. 이는 곧 값이다.

class Person{
	name = "lee";
	//클래스 필드에 함수를 할당했다. 메소드가 아니다.
	getName = function(){
    	return this.name;
    }
}

하지만 클래스필드인스턴스의 프로퍼티기 때문에 프로토타입 메서드가 되지 않는다.
=> 권장되지 않는다.

다만 화살표 함수를 할당하여 this바인딩인스턴스를 가리키게 하는 방식이 있다.

class Button {
  constructor(value) {
    this.value = value;
  }
  click = () => {
    alert(this.value);
  }
}

let button = new Button("안녕하세요.");

setTimeout(button.click, 1000); // 안녕하세요.

이 또한 메모리 손해와 프로토타입 메서드에 등록되지 않아 상속이 안되는이슈가 있다.
=> 특정 컨텍스트에 this바인딩하려는 목적 빼고는 권장하지 않는다.

private 필드

이 역시 es2019에 추가된 문법이다.

class Person{
	#name = "";
    constructor(name){
    	this.#name = name;
    }
}
const me = new Person('lee');
me.#name // SyntaxError:
// private field '#name' must be declared in an enclosing class

정확히 본인 클래스 내부에서만 참조 가능한 친구다.
접근하고 싶다면 접근자 프로퍼티를 사용하자.

class Person{
	#name = "";
    constructor(name){
    	this.#name = name;
    }
	get name(){
    	return this.#name;
    }
}
const me = new Person('lee');
me.name // lee

constructor내부에는 private필드를 정의할 수 없다.

static 필드

clas MyMath {
	static PI = 22/7;
  	//static한 private 필드. 인스턴스 생성 전에 사용할 수있으며, 본인 만 사용 가능하다.
  	//static은 상속이 된다.
  	static #num = 10;
    static increment(){
    	return ++MyMath.#num;(this는 쓸수없다. 정적 메서드/필드는 인스턴스 선언 전에 사용할수 있는 기능이다)
    }
}
MyMath.PI // 3.14...
MyMath.increment() // 11

상속에 의한 클래스 확장

프로토타입의 상속과 다른 개념이다.
=> 프로토타입 상속은 체인을 통해 객체의 자산을 상속. 클래스 상속은 기존 클래스를 상속받아 클래스를 확장.
(생성자 함수를 확장한다고 보면됨)

//상속된 클래스를 슈퍼 또는 부모클래스라고 불린다.
class Animal{
	constructor(age, weight){
      this.age = age;
      this.weight = weight;
    }
  	eat(){return "eat";}
  	move(){return "move";}
}
//확장된 클래스를 서브클래스 또는 자식클래스 라고 부른다
class Dog extends Animal{
	run(){return "run"};
}
class Bird extends Animal{
	constructor(age ,weight, wingColor){
      super(age,weight)
      this.wingColor = wingColor;
    }
  	fly() {return "fly";}
}

const dog = new Dog(2, 5kg); // dog {age:2, weight:"5kg"}
const bird = new Bird(1, 100g, blue); // bird {age:1, weight:"100g", wingColor:"blue"}
dog.move() // move
dog.run() // run
bird.move() // move;
bird.fly() //fly

이처럼 객체의 자산을 받는 것이 아닌, 클래스 끼리의 상속이다.
또한 상속받을때의 프로퍼티가 동일하다면 super키워드를 사용하지 않아도 된다.
=> constructor(...args){ super(...args)}가 암묵적으로 서브클래스에 정의된다.
Bird클래스처럼 다른 프로퍼티를 사용한다면 super키워드를 사용하여야 한다.

js는 생성자함수를 사용하여 클래스를 흉내내려는 시도를 권장하지 않는다.
다만 의사 클래스 상속 패턴을 사용하여 클래스의 상속을 흉내내기도 하였다.
=> 이제는 사용하지 않는다.

extends로 상속받을 수 있는 건 클래스 뿐이 아니다.
내부 [[Construct]]메서드를 갖는 함수객체로 평가될 수 있는 모든 표현식이 가능하다.

//생성자함수를 상속
function Base0(a){
	this.a = a;
}
class Derived0 extends Base0{}
const derived0 = new Derived0(1);
derived0.a //1

//그냥 함수와 클래스를 상속
function Base1(){}
class Base2{}
let condition = false;
//이처럼 조건에 따라 상속받을 부모를 정할 수 있다.
class Derived1 extends (conditon ? Base1 : Base2){}

super 키워드

위에서 설명한 super키워드에 대해 조금만 더 알아보자.
기본적으로 슈퍼(부모)클래스의 constructor을 호출하는 기능이다.
호출할때의 주의사항은 다음과 같다.

  • 서브클래스에서 constructor를 생략하지 않는다면 super호출해야함
  • 서브클래스에서 super호출 전에 this참조 불가능
  • 서브클래스 외에서 super호출 불가능.

또한 메서드 내부에서 super참조가 가능하다!

class Base {
	constructor(name){
    	this.name = name;
    }
  	sayHi(){
    	return `Hi My name is${nthis.name}`
    }
}
class Derived extends Base{
	sayHi(){return `${super.sayHi()}. How are you?`}
}
const derived = new Derived("kim");
derived.sayHi() // Hi My name is kim. How are you?

요건 첨 보는 용법이라 신기했다. 어떻게 가능한 일일까?
=> super키워드가 슈퍼 클래스의 prototype프로퍼티에 바인딩된 프로토타입을 참조하고 있다

class Base {
	constructor(name){
    	this.name = name;
    }
  	sayHi(){
    	return `Hi My name is${nthis.name}`
    }
}
class Derived extends Base{
	sayHi(){
      /*
      	1. Derived.prototype은 곧 Base.prototype이다. 
        	즉, Derived로 생성될 인스턴스의 프로토타입은 정확히 Base로 생성된 인스턴스의 프로토타입과 같다.
        2. super는 자신을 참조하고 있는 메서드가 바인딩 되어있는 객체의 프로토타입을 가리킨다.
        	Derived가 바인딩되어있는 객체(Derived.prototype)의 프로토타입 (Base.prototype)을 가리킨다.
         	따라서 __super.sayHi는 Base.prototype.sayHi를 가리킨다.
        3. Base.prototype.sayHi내부의 this.는 Base.prototype을 가리킨다.
        	그렇기에 this바인딩을 생성될 인스턴스로 수동으로 돌려주어야 한다. 
      */
    	const __suepr = Object.getPrototypeOf(Derived.prototype);
		return `${__super.sayHi.call(this)}. how are you?`
    }
}
const derived = new Derived("kim");
derived.sayHi() // Hi My name is kim. How are you?

결국 super참조가 동작하기 위해서는 메서드 내부에 자신을 바인딩 하고있는 객체를 가리키는 무언가가 있어야한다.
=> 모든 메서드(축약표현만)[[HomeObject]]슬롯을 가진다.

참고로 super참조는 클래스 전유물이 아니고 리터럴에서도 사용 가능하다.

const base = {
	name : "kim",
  	sayHi(){return`Hi ${this.name}`};
}
const derived = {
	__proto__: base, //상속받았다.
  	sayHi(){
    	return `${super.sayHi()}. how are you?`
    }
}
//정적 메서드도 수퍼클래스의 정적메서드를 가리킨다.
class Base {
	static sayHi(){return "hi"}
}
class Derived extends Base{
	static sayHi(){return `${super.sayHi()}. how are you?`} // hi. how are you?
}

상속 클래스의 인스턴스 생성과정 자세히

//수퍼 클래스
class Rectangle {
	constructor(width, height){
    	this.width = width;
      	this.heigth = height;
    }
  getArea(){
  	return this.width*this.height;
  }
  toString(){
  	return`width = ${this.width}, height = ${this.height}`
  }
}
//서브 클래스
class ColorRectangle extends Rectangle{
	constructor(width, height, color){
    	super(width, height);
      	this.color = color;
    }
  	//오버라이딩
  	toString(){
    	return super.toString() + `, color = ${this.color}`;
    }
}
const colorRectangle = new ColorRectangle(2,4,'red');

//상속 메서드
colorRectangle.getArea(); // 8
//오버라이딩된 메서드
colorRectangle.toString() // width = 2, height = 4, color = red
  1. 서브 클래스의 super호출
    서브-수퍼클래스 구분을 위해 내부슬롯 [[ConstructorKind]]를 갖는다.
    이를 통해 수퍼-서브클래스를 new로 호출하였을때의 동작이 구분됨.
    다른 클래스를 상속받지 않는 클래스(생성자함수)는 암묵적으로 빈 객체 생성후 this바인딩함.
    But, 서브클래스는 수퍼 클래스에게 인스턴스 생성을 위임.
    => 이때문에 서브클래스에서 super키워드 호출해야함.

  2. 수퍼클래스의 인스턴스 생성과 바인딩
    서브클래스 consturcotr에서 super키워드가 호출됐다면, 제어권이 여기로 넘어옴.
    빈객체 생성 후 this바인딩 되는데, new.target이 서브클래스기에 서브클래스에 바인딩.
    => 이래서 생성된 객체의 프로토타입이 ColorRectangle.prototype이 된다.

  3. 수퍼클래스의 인스턴스 초기화
    이제 수퍼클래스 내부 constructor로 넘어옴. 여기서 this바인딩 되어있는 인스턴스를 초기화 및 프로퍼티 추가작업.

  4. 서브클래스의 constructor로 제어권 복귀
    이제 super호출이 끝났으니 복귀. 이때 super가 반환한 인스턴스가 this에 바인딩.
    별도의 인스턴스 없이 super가 반환한 this를 바인딩하여 그대로 사용한다.
    => super가 없으면 this를 사용하지 못하는 이유.

  5. 서브클래스의 인스턴스 초기화
    서브 클래스 내부 constructor에 기술되어있는 인스턴스 초기화 및 프로퍼티 추가...

  6. 인스턴스 반환
    짜잔~ 서브클래스의 인스턴스 가 바인딩된 this가 반환된다.


표준 빌트인 생성자함수를 확장해보자

표준 빌트인 객체Array, Object, Number...[[Construct]]슬롯을 갖는 생성자 함수.
따라서 extends로 확장하여 사용할 수도 있다.

class MyArray extends Array {
	unique(){
    	return this.filter((v, i, self) => self.indexOf(v) === i);
    }
  	average(){
    	return this.reduce((pre, cur) => pre + cur, 0) / this.length;
    }
}
const myArray = new MyArray(1,1,2,3);
myArray // MyArray(4) [1, 1, 2, 3]
myArray.unique() // [1,2,3]
myArray.average() // 1.75

주의해야할 점은 Array의 인스턴스가 아니라 MyArray의 인스턴스다.
symbol species를 사용하여 강제적으로 Array의 인스턴스라고 반환할 수있으나
보안에 취약해 권장되지 않으며 삭제예정을 고민중이라고 한다


느낀점

JS만의 클래스에 대해 더 자세히 알게되었다. 또한 프로토타입prototype의 차이에 대해 보다 정확히 알게되었으며 super키워드가 왜 필요한지, 서브클래스는 수퍼클래스를 어떻게 상속받는지에 대해 잘 알게되었다.

앞으로 생성자 함수를 쓸 일은 없다.
클래스로 가즈아!

profile
모르는 것을 모른다고 하기

0개의 댓글