클래스는 프로토타입의 문법적 설탕인가?

자바스크립트는 프로토타입 기반 객체지향 언어다.
프로토타입 기반 객체지향 언어는 클래스가 필요 없는 객체지향 프로그래밍 언어다.
생성자 함수와 프로토타입을 통해 객체지향 언어의 상속을 구현할 수 있다.

하지만 클래스 기반 언어에 익숙한 프로그래머들은 프로토타입 기반 프로그래밍 방식에 혼란을 느낄 수 있다.
ES6에서 도입된 클래스는 기존 프로토타입 기반 객체지향 프로그래밍보다 클래스 기반 객체지향 프로그래밍 언어와 매우 흡사한 객체 생성 메커니즘을 제시.

클래스는 함수이며 기존 프로토타입 기반 패턴을 클래스 기반 패턴처럼 사용할 수 있도록 하는 문법적 설탕이라 볼 수도 있다.
클래스, 생성자 함수 모두 프로토타입 기반의 인스턴스를 생성하지만 정확히 동일하게 동작하지는 않음.
클래스는 생성자 함수보다 엄격하며 생성자 함수에서 제공하지 않는 기능도 제공.

1. 클래스 new 연산자 없이 호출 => 에러 발생
2. 클래스는 상속을 지원하는 extends 와 super 키워드를 지원
3. 클래스는 호이스팅이 발생하지 않는 것처럼 동작.
4. 클래스 내의 모든 코드에는 암묵적으로 strict mode가 지정되어 실행된다. 해제 불가능.
5. 클래스의 constructor, 프로토타입 메서드, 정적 메서드는 모두 프로퍼티 어트리뷰트 
[[Enumerable]]의 값이 false. 열거되지 않는다.

문법적 설탕이라기보다는 새로운 객체 생성 메커니즘으로 보는 것이 합당.

클래스 정의

클래스는 class 키워드를 사용하여 정의한다.
클래스 이름은 생성자 함수와 마찬가지로 파스칼 케이스 사용이 일반적.
클래스를 표현식으로 정의 가능하다.

// 클래스 선언문
class Person {};

// 익명 클래스 표현식
const Person = class {};

// 기명 클래스 표현식
const Person = class Myclass {};

클래스를 표현식으로 정의할 수 있다는 것은 클래스가 값으로 사용할 수 있는 일급 객체라는 것을 의미한다.

무명의 리터럴로 생성할 수 있다. 즉 런타임에 생성이 가능하다.
변수나 자료구조에 저장할 수 있다.
함수의 매개변수에게 전달할 수 있다.
함수의 반환값으로 사용할 수 있다.

클래스는 함수다.
클래스는 값처럼 사용할 수 있는 일급 객체다.
클래스 몸체에는 0개 이상의 메서드만 정의할 수 있다.
클래스 몸체에서 정의할 수 있는 메서드는 constructor, 프로토타입 메서드, 정적 메서드 세 가지가 있다.

// 클래스 선언문
class Person {
  // 생성자
  constructor(name) {
    // 인스턴스 생성 및 초기화
    this.name = name;
  }
  
  // 프로토타입 메서드
  sayHi () {
    console.log(`Hi! My name is ${this.name}`);
  }
  
  // 정적 메서드
  static sayHello () {
    console.log('Hello!');
  }
}

// 인스턴스 생성
const me = new Person('Lee');

클래스 호이스팅

클래스 선언문으로 정의한 클래스는 함수 선언문과 같이 소스코드 평가 과정, 즉 런타임 이전에 먼저 평가되어 함수 객체를 생성한다.
이때 클래스가 평가되어 생성된 함수 객체는 생성자 함수로서 호출할 수 있는 함수, 즉 constructor다.
생성자 함수로서 호출할 수 있는 함수는 함수 정의가 평가되어 함수 객체를 생성하는 시점에 프로토타입도 더불어 생성된다.
프로토타입과 생성자 함수는 단독으로 존재할 수 없고 언제나 쌍으로 존재하기 때문.

클래스 선언문도 변수 선언, 함수 정의와 마찬가지로 호이스팅이 발생.
단 클래스는 let, const 키워드로 선언한 변수처럼 호이스팅이 된다.
클래스 선언문 이전에 일시적 사각지대에 빠지기 때문에 호이스팅이 발생하지 않는 것처럼 동작한다.

모든 선언문은 런타임 이전에 먼저 실행되기 때문에 호이스팅 된다.

인스턴스 생성

클래스는 생성자 함수이며 new 연산자와 함께 호출되어 인스턴스를 생성한다.
함수는 사용 여부에 따라 일반 함수 호출 또는 인스턴스 생성을 위한 생성자 함수로 호출

클래스를 가리키는 식별자를 사용해야 한다.
기명 클래스 표현식의 클래스 이름은 외부 코드에서 접근이 불가하다.

메서드

클래스 몸체에서 정의할 수 있는 메서드는 constructor(생성자), 프로토타입 메서드, 정적 메서드 세 가지다

1. constructor

class Person
  // 생성자
  constructor(name) {
    // 인스턴스 생성 및 초기화
    this.name = name;
  }

클래스는 인스턴스를 생성하기 위한 생성자 함수
클래스는 평가되어 함수 객체가 된다.
클래스도 함수 객체 고유의 프로퍼티를 모두 갖고 있다.
함수와 동일하게 프로토타입과 연결되어 있으며 자신의 스코프 체인을 구성한다.
모든 함수 객체가 가지고 있는 prototype 프로퍼티가 가르키는 프로토타입 객체의 constructor 프로퍼티는
클래스 자신을 가리키고 있다.
이는 클래스가 인스턴스를 생성하는 생성자 함수라는 것을 의미.

const me = new Person('Lee');

Person 클래스의 constructor 내부에서 this에 추가한 name 프로퍼티가 클래스가 생성한 인스턴스의
프로퍼티로 추가된 것을 확인 가능.

클래스 몸체에서 정의한 constructor는 단순한 메서드가 아니다.
메서드로 해석되는 것이라 아니라 클래스가 평가되어 생성한 함수 객체 코드의 일부가 된다.
클래스 정의가 평가되면 constructor의 기술된 동작을 하는 함수 객체가 생성된다.

클래스의 constructor 메서드와 프로토타입 constructor 프로퍼티는 직접적인 관련이 없다.
프로토타입 constructor 프로퍼티는 모든 프로토타입이 가지고 있는 프로퍼티다.
생성자 함수를 가르킨다.

constructor는 클래스 내에 최대 한 개만 존재할 수 있다.
constructor는 생략할 수 있다.
생략하면 클래스에 다음과 같이 빈 constructor가 암묵적으로 정의됨.
constructor를 생략한 클래스는 빈 constructor에 의해 빈 객체를 생성한다.

프로퍼티가 추가되어 초기화된 인스턴스를 생성하려먄 constructor 내부에서 this에 인스턴스 프로퍼티를 추가한다.

class Person {
  constructor() {
    // 고정값으로 인스턴스 초기화
    this.name = 'Lee';
    this.address = 'Seoul';
  }
}

// 인스턴스 프로퍼티가 추가된다.
const me = new Person();

인스턴스를 생성할 때 클래스 외부에서 인스턴스 프로퍼티의 초기값을 전달하려면
constructor에 매개변수를 선언하고 인스턴스를 생성할 때 초기값을 전달한다.

class Person {
  constructor(name, address) {
    this.name = name;
    this.address = address;
  }
}
// 인수로 초기값을 전달한다. 초기값은 constructor에 전달된다.
const me = new Person('Lee', 'Seoul');

constructor 내에서 인스턴스를 생성과 동시에 인스턴스 프로퍼티 추가를 통해 인스턴스의 초기화를 실행.
constructor는 별도의 반환문을 갖지 않는다.
new 연산자와 함께 클래스가 호출되면 생성자 함수화 동일하게 암묵적으로 this, 즉 인스턴스를 반환하기 때문.
만약 this가 아닌 다른 객체를 명시적으로 반환하면 this, 즉 인스턴스가 반환되지 못하고 return문에 명시한 객체가 반환된다. 클래스의 기본 동작을 훼손하기에 constructor 내부에서 return 문은 반드시 생략해야 한다.

2. 프로토타입 메서드

생성자 함수를 사용하여 인스턴스를 생성하는 경우 프로토타입 메서드를 생성하기 위해 명시적으로 프로토타입에 메서드를 추가해야 한다.

클래스 몸체에서 정의한 메서드는 생성자 함수에 의한 객체 생성 방식과는 다르게 클래스의 prototype 프로퍼티에 메서드를 추가하지 않아도 기본적으로 프토토타입 메서드가 된다.
생성자 함수와 마찬가지로 클래스가 생성한 인스턴스는 프로토타입 체인의 일원이 된다.

class Person {
  constructor(name) {
    this.name = name;
  }
  
  // 프로토타입 메서드
  sayHi() {
    console.log(`Hi! My name is ${this.name}`);
  }
}
const me = new Person('Lee');

Object.getPrototypeOf(Person.prototype) === Object.prototype;

클래스 몸체에서 정의한 메서드는 인스턴스의 프로토타입에 존재하는 프로토타입 메서드가 된다.
인스턴스는 프로토타입 메서드를 상속받아 사용할 수 있다.

프로토타입 체인은 기존의 모든 객체 생성 방식 뿐만 아니라 클래스에 의해 생성된 인스턴스에도 동일하게 적용.
생성자 함수의 역할을 클래스가 할 뿐.

클래스는 생성자 함수와 같이 인스턴스를 생성하는 생성자 함수다.
클래스는 프로토타입 기반의 객체 생성 메커니즘이다.

3. 정적 메서드

정적 메서드는 인스턴스를 생성하지 않아도 호출할 수 있는 메서드를 말한다.
클래스에서는 메서드에 static 키워드를 붙이면 정적 메서드(클래스 메서드)가 된다.

정적 메서드는 클래스에 바인딩된 메서드가 된다.
클래스는 함수 객체로 평가되므로 자신의 프로퍼티/메서드를 소유할 수 없다.
클래스는 클래스 정의(클래스 선언문이나 클래스 표현식)가 평가되는 시점에 함수 객체가 되므로 인스턴스와 달리 별다른 생성 과정이 필요 없다.
따라서 정적 메서드는 클래스 정의 이후 인스턴스를 생성하지 않아도 호출할 수 있다.

정적 메서드는 인스턴스로 호출할 수 없다.
정적 메서드가 바인딩된 클래스는 인스턴스의 프로토타입 체인 상에 존재하지 않기 때문.
인스턴스로 클래스의 메서드를 상속받을 수 없다.

4. 정적 메서드와 프로토타입 메서드의 차이

1. 정적 메서드와 프로토타입 메서드는 자신이 속해 있는 프로토타입 체인이 다르다.
2. 정적 메서드는 클래스로 호출하고 프로토타입 메서드는 인스턴스로 호출한다.
3. 정적 메서드는 인스턴스 프로퍼티를 참조할 수 없지만 프로토토압 메서드는 인스턴스 프로퍼티를 참조할 수 있다.

메서드 내부의 this는 메서드를 소유한 객체가 아니라 메서드를 호출한 객체, 즉 메서드 이름 앞의 마침표 연산자 앞에 기술한 객체에 바인딩된다.

물론 메서드 내부에서 this를 사용하지 않더라도 프로토타입 메서드를 정의할 수 있다.
하지만 인스턴스를 생성한 다음 인스턴스를 호출해야 하므로 this를 사용하지 않는 메서드는 정적 메서드로 정의하는 것이 좋다.
클래스 또는 생성자 함수를 하나의 네임스페이스로 사용하여 정적 메서드를 모아 놓이면 이름 충돌 가능성을 줄여주고 관련 함수들을 구조화 하는 효과가 있다.
정적 메서드는 애플리케이션 전역에서 사용할 유틸리티 함수를 전역 함수로 정의하지 않고 메서드로 구조화할 때 유용하다.

클래스에서 정의한 메서드의 특징

1. function 키워드를 생략한 메서드 축약 표현을 사용한다.
2. 객체 리터럴과는 다르게 클래스에서 메서드를 정의할 때 콤마가 필요없다.
3. 암묵적으로 strict mode로 실행된다.
4. for ...in 문이나 Object.keys 메서드 등으로 열겨 불가다.
5. 내부 메서드 [[Construct]]를 갖지 않는 non-constructor 다. 따라서 new 연산자와 함께 호출 불가하다.

상속에 의한 클래스 확장

프로토타입 기반 상속은 프로토타입 체인을 통해 다른 객체의 자산을 상속받는 개념
상속에 의한 클래스 확장은 기존 클래스를 상속받아 새로운 클래스를 확장하여 정의하는 것.

클래스는 상속을 통해 기존 클래스를 확장할 수 있는 문법이 기본적으로 제공되지만 생성자 함수는 그렇지 않다

class Animal {
  constructor(age, weight) {
    this.age = age;
    this.weight = weight;
  }
  
  eat() { return 'eat'; }
  move() { return 'move'; }
}

// 상속을 통해 Animal 클래스를 확장한 Bird 클래스
class Bird extends Animal {
  fly() {
    return 'fly';
  }
}

const bird = new Bird(1, 5);


클래스는 상속을 통해 다른 클래스를 확장할 수 있는 문법인 extends 키워드가 기본적으로 제공된다.
extneds 키워드를 사용한 클래스 확장은 간편하고 직관적이다.
하지만 생성자 함수는 클래스와 같이 상속을 통해 다른 생성자 함수를 확장할 수 있는 문법이 제공되지 않음.

생성자 함수를 사용하여 의사 클래스 상속 패턴을 사용한 클래스 확장 모방 패턴

Function.prototype.apply, Function.prototype.call 메서드는 this로 사용할 객체와 인수 리스트를 인수로 전달받아 함수를 호출한다.
apply와 call 메서드의 본질저인 기능은 함수를 호출하는 것.
함수를 호출하면서 첫 번째 인수로 전달한 특정 객체를 호출한 함수의 this에 바인딩한다.

function getThisBinding() {
  console.log(arguments);
  return this;
}

const thisArg = { a: 1 };

console.log(getThisBinding());
console.log(getThisBinding.apply(thisArg, [1, 2, 3, 4]));
console.log(getThisBinding.call(thisArg, 10, 11, 12, 13));
// 의사 클래스 상속 패턴
var Animal = (function() {
  function Animal(age, weight) {
    this.age = age;
    this.weight = weight;
  }
  
  Animal.prototype.eat = function() {
    return  'eat';
  }
  Animal.prototype.move = function() {
    return 'move';
  }
}());

// Animal 생성자 함수를 상속하여 확장한 Bird 생성자 함수
var Bird = (function() {
  function Bird () {
    // animal 생성자 함수에게 this와 인수를 전달하면서 호출
    // Bird 인스턴스는 Animal 인스턴스의 프로퍼티를 상속하게 된다.
    Animal.apply(this, arguments);
  }
  
  // Bird.prototype을 Animal.prototype을 프로토타입으로 갖는 객체
  Bird.prototype = Object.create(Animal.prototype); // 1
  
  // Bird.prototype.constructor를 Animal에서 Bird로 교체
  Bird.prototype.constructor = Bird;
  
  Bird.prototype.fly = function() {
    return 'move';
  }
}());

var bird = new Bird(1,5);

1.

Bird.prototype = Animal.prototype;와 같이 하면 Bird와 Animal이 서로 같은 프로토타입을 참조하게 됩니다. 이 경우, Bird.prototype과 Animal.prototype은 같은 객체를 가리키므로, Bird.prototype의 프로퍼티를 변경하면 Animal.prototype의 프로퍼티도 함께 변경되어 버립니다. 즉, Bird와 Animal이 같은 프로토타입을 공유하게 되므로, 상속 관계를 구현하는 의미가 없어집니다.

반면, Bird.prototype = Object.create(Animal.prototype);와 같이 하면 Animal.prototype을 프로토타입으로 갖는 새로운 객체가 생성됩니다. 이렇게 하면 Bird.prototype과 Animal.prototype은 서로 다른 객체를 가리키므로, Bird.prototype의 프로퍼티를 변경하더라도 Animal.prototype의 프로퍼티는 변경되지 않습니다. 이를 통해, Bird는 Animal의 기능을 상속받으면서도 자신만의 프로퍼티를 추가하거나, Animal의 메소드를 오버라이드할 수 있게 됩니다.

따라서, Object.create를 사용하여 새로운 객체를 생성함으로써, Bird와 Animal이 서로 다른 프로토타입 체인을 갖도록 만들어야 합니다.

extends 키워드

상속을 통해 클래스를 확장하려면 extends 키워드를 사용하여 상속받을 클래스를 정의한다.

class Rectangle {
  constructor(width, height) {
    this.width = width;
    this.height = height;
  }

  getArea() {
    return this.width * this.height;
  }

  toString() {
    return `width = ${this.width}, height = ${this.height}`;
  }
}

class ColorRectangle {
  constructor(width, height, color) {
    super(width, height);
    this.color = color;
  }

  // 메서드 오버라이딩
  toString() {
    return super.toString() + `color = ${this.color}`;
  }
}

서브클래스 ColorRectangle이 new 연산자와 함께 호출되면 다음 과정을 통해 인스턴스를 생성한다.

1. 서브클래스 super 호출

자바스크립트 엔진은 클래스를 평가할 때 수퍼클래스와 서브클래스를 구분하기 위해 [[constructorKind]]를 갖는다.
다른 클래스를 상속받지 않는 클레스(그리고 생성자 함수)는 내부 슬롯 [[constructorKind]]의 값이 'base'로 설정되지만 다른 클래스를 상속받는 서브클레스는 내부 [[constructorKind]]의 값이 'derived'로 설정된다.
이를 통해 수퍼클래스와 서브클래스는 new 연산자와 함께 호출되었을 때의 동작이 구분된다.
다른 클래스를 상속받지 않는 클래스, 생성자 함수는 new 연산자와 함께 호출되었을 때 암묵적으로 빈 객체, 즉 인스턴스를 생성하고 이를 this에 바인딩한다.
서브클래스는 자신이 직접 인스턴스를 생성하지 않고 수퍼클래스에게 인스턴스 생성을 위임한다.
서브클래스의 constructor에서 반드시 super를 호출해야 한다

2. 수퍼클래스의 인스턴스 생성과 this 바인딩

수퍼클래스의 constructor 내부의 코드가 실행되기 이전에 암묵적으로 빈 객체 생성.
이 객체가 클래스가 생성한 인스턴스.
인스턴스는 new.target이 가리키는 서브클래스가 생성한 것으로 처리된다.
따라서 생성된 인스턴스의 프로토타입은 서브클래스의 prototype프로퍼티가 가리키는 객체다.

3. 수퍼클래스의 인스턴스 초기화

수퍼클래스의 constructor가 실행되어 this에 바인딩되어 있는 인스턴스를 초기화한다.
즉 this에 바인딩되어 있는 인스턴스에 프로퍼티를 추가하고 constructor가 인수로 전달받은 초기값으로 인스턴스의 프로퍼티를 초기화한다.

4. 서브클래스 constructor로의 복귀와 this 바인딩

super의 호출이 종료되고 제어 흐름이 서브클래스의 constructor로 돌아온다
이때 super가 반환한 인스턴스가 this에 바인딩된다.
서브클래스는 별도의 인스턴스를 생성하지 않고 super가 반환한 인스턴스를 this에 바인딩하여 그대로 사용.
super가 호출되지 않으면 인스턴스가 생성되지 않으며, this도 바인딩할 수 없다.
서브클래스의 constructor에서 super를 호출하기 전에는 this를 참조할 수 없는 이유다.

5. 서브클래스의 인스턴스 초기화

super 호출 이후, 서브클래스의 constructor에 기술되어 있는 인스턴스 초기화가 실행된다.

6. 인스턴스 반환

클래스의 모든 처리가 끝나면 완성된 인스턴스가 바인딩된 this가 암묵적으로 반환된다.

class MyArray extends Array {
  // 모든 클래스가 Array 타입의 인스턴스를 반환하도록 한다.
  static get [Symbol.species]() {
    return Array;
  }
  
  // 중복된 배열 요소를 제거하고 반환한다
  uniq() {
    // this
    return this.filter((v, i, self)=>self.indexOf(v) === i);
  }
}

uniq 메서드는 Array 인스턴스를 반환하므로 average 메서드를 호출할 수 없다.

위의 코드 중 return this.filter((v, i, self)=>self.indexOf(v) === i);
이 부분을 좀더 자세하게 설명하고자 한다.
먼저 self의 자리는 this를 의미하며 콘솔을 찍어보면 메서드를 호출한 인스턴스가 찍히게 된다.
값을 하나씩 v로 꺼내와 인스턴스에 값이 있는지 확인한다.
당연히 있을 것이고, 그 값의 인덱스값을 가져오는데 값을 꺼내올 때의 인덱스 값을 갖고 있는 i와 비교한다.
만약에 중복된 값이면 indexOf는 값의 최초 인덱스 값을 리턴할거고 값이 다르니 false가 리턴되어 나중에
리턴되는 배열에서 누락되게 된다.

profile
프론트 공부중입니다!

0개의 댓글