[JavaScript] class 고급이론

mechaniccoder·2020년 12월 26일
0

prototype과 class

자바스크립트의 classs는 앞서 배운 prototype에 기반을 두고 있습니다. 먼저 코드를 소개하고 설명을 해보죠.

class Rabbit {
  // 생성자 함수 (Rabbit 함수의 함수 본문에 해당합니다.)
  constructor() {
    this.age = 8;
  }
	
	// Rabbit.prototype프로퍼티로 설정됩니다.
  sayHi() {
    console.log("깡총깡총");
  }
}

// 클래스 함수의 특수 프로퍼티로 인해 new와 함께 호출해야합니다.
const rabbit = new Rabbit();

Rabbit은 함수이며 생성자 함수인 constructor는 Rabbit 함수의 본문에 해당됩니다. 그리고 sayHi 메서드는 Rabbit의 prototype이 가르키는 객체인 Rabbit.prototype 객체의 프로퍼티로 할당됩니다. 즉, 다이어그램으로 나타내면 아래와 같습니다.

생성자 함수와 클래스로 만든 함수의 차이는 클래스 함수에 특수 프로퍼티인 [[FunctionKind]]:"classConstructor"가 존재한다는 점입니다. 이 프로퍼티가 있으면 호출 시에 new 키워드와 함께 호출해야만 합니다.


메서드 오버라이딩

class Animal {
  constructor(name) {
    this.name = name;
    this.speed = 0;
  }

  run(speed) {
    this.speed = speed;
    console.log(`${this.speed}로 달립니다.`);
  }

  stop() {
    this.speed = 0;
    console.log("멈춥니다.");
  }
}

class Rabbit extends Animal {
  // 자체 생성자가 없기 때문에 아래와 같이 자동으로 만들어집니다.
  // constructor(...args) {
  //   super(...args); 부모클래스의 생성자 함수가 호출됩니다.
  // }

  hide() {
    console.log("hide");
  }
  stop() {
    super.stop(); // 부모클래스의 메서드를 호출하는 방법입니다.
    this.hide();
  }
}

여기서 가져가야할 것은 자식클래스가 생성자 함수를 가지지 않을때의 작동원리입니다. Rabbit이 생성자 함수를 가지지 않기 때문에 자동으로 부모클래스의 생성자 함수를 호출하며 인자도 넘겨줍니다.

부모클래스의 메서드를 호출하기 위해서는 super 키워드를 사용합니다.


생성자 오버라이딩

위에서 배운 super 키워드를 활용하면 생성자도 오버라이딩할 수 있습니다. 그 전에 먼저 아래 코드를 확인해보죠.

class Rabbit extends Animal {
  constructor(name, earLength) {
    // 틀렸습니다. 상속받는 클래스는 this에 빈 객체를 할당하는 작업을 부모클래스가 해주길 원합니다. 따라서 super 키워드를 반드시 호출해야합니다.
    this.speed = 0;
    this.name = name;
    this.earLength = earLength;
  }

  // ...
}

상속받는 자식 클래스에서는 this 키워드를 사용하기 전에 반드시 super 키워드를 사용해서 부모클래스의 생성자 함수를 호출해줘야 합니다.

why? 🤔

이유를 알기 위해서는 상속을 받지않는 기본 클래스와 상속을 받는 클래스의 작동원리 차이점을 알아야합니다.

→ 기본 클래스는 prototype 섹션에서 설명한 것 처럼 prototype과 똑같이 작동합니다. new 연산자를 만나면 빈 객체를 만들고 이 객체를 this에 할당하죠.

그러나 상속을 받는 클래스는 자신이 이 작업을 직접하지 않고 부모클래스가 이를 대신해주기를 바랍니다. 즉, Animal클래스의 생성자함수에서 빈 객체를 생성하고 이를 this에 할당하기전까지 자식클래스에서는 this키워드를 사용할 수가 없겠죠.

이것이 자식클래스 Rabbit의 생성자 함수에서 this 사용 이전에 super를 호출해줘야하는 이유입니다. 그럼 아래 코드와 같이 사용하면 되겠죠?

class Animal {
	constructor(name) {
		this.name = name;
	}
}

class Rabbit {
  constructor(name, earLength) {
	// super키워드와 함께 빈 객체가 생성되고 this에 할당합니다.
    super(name);
	// 이제부턴 this를 사용할 수 있습니다.
    this.earLength = earLength;
  }
}

클래스필드 오버라이딩

class Animal {
  name = 'animal'

  constructor() {
    alert(this.name); // (*)
  }
}

class Rabbit extends Animal {
  name = 'rabbit';
}

new Animal(); // animal
new Rabbit(); // animal 잉..? 

Rabbit 자식클래스는 생성자 함수가 없으므로 super(...args)와 함께 부모 생성자함수를 호출합니다. 여기서 기본 클래스와 상속 클래스간 클래스필드의 작동순서에 차이가 있습니다.

  • 기본 클래스의 경우 클래스필드는 super가 호출되기 전에 초기화됩니다.
  • 상속받는 자식클래스의 클래스필드는 super가 호출된 후에 초기화됩니다.

따라서 new Rabbit의 경우 super가 먼저 호출될 것이고 이때 클래스 필드는 아직 name='animal'이므로 'rabbit'이 아닌 'animal'이 호출되는 것입니다.


super와 [[HomeObject]]

클래스에서서의 super는 [[HomeObject]]에 기반으로 부모 프로토타입과 메서드를 검색합니다. 그렇다면 [[HomeObject]]이 뭔지부터 이해해야겠죠?

[[HomeObject]]는 클래스 혹은 객체의 메서드가 가진 프로퍼티로서 객체를 저장합니다. 동적으로 변하는 this랑은 차이점이 있죠. 이 프로퍼티는 함수가 생성될때 정해지고 이후엔 절대 변하지 않는 프로퍼티입니다.

그럼 어떻게 이 프로퍼티를 활용할 수 있을까요? 코드를 살펴봅시다.

this를 사용한 예시

const animal = {
  name: "동물",
  eat() {
    console.log(`${this.name}이 먹이를 먹습니다.`);
  },
};

const rabbit = {
  __proto__: animal,
  name: "rabbit",
  eat() {
    this.__proto__.eat.call(this); // this는 rabbit이 아닌 longEar가 됩니다. 무한 루프에 빠지겠죠?
  },
};

const longEar = {
  __proto__: rabbit,
  eat() {
    this.__proto__.eat.call(this); // rabbit의 eat메서드에 this=longEar로 할당합니다
  },
};

rabbit.eat();

super를 사용한 예시

const animal = {
  name: "동물",
  eat() {
    console.log(`${this.name}이 먹이를 먹습니다.`);
  },
};

const rabbit = {
  __proto__: animal,
  name: "rabbit",
  eat() {
    super.eat();
  },
};

const longEar = {
  __proto__: rabbit,
  eat() {
    super.eat();
  },
};

longEar.eat(); 
// animal의 eat메서드에서 this = lonagEar가 됩니다.

정적메소드와 정적프로퍼티

지금까지 우리는 클래스필드, 생성자 함수를 사용해 클래스 인스턴스의 프로퍼티를 설정했고, 클래스 멤버를 통해서 프로토타입의 프로퍼티를 설정했습니다.

static키워드를 사용한 정적메서드와 프로퍼티는 인스턴스, 프로토타입이 아닌 클래스 자체에 프로퍼티를 설정합니다. 자바스크립트에서는 함수도 객체이기 때문에 함수에 프로퍼티를 설정해줄 수 있죠. 바로 이 특성을 이용하는 것입니다.

중요한점은 정적메서드, 프로퍼티 또한 상속이 가능하다는 점인데요. 이를 다이어그램으로 그려보면 다음과 같습니다.

즉, extends키워드를 활용한 상속은 두 개의 프로토타입 참조를 만들어내는 것이죠.

  • 프로토타입간의 상속
  • 클래스간의 상속

여지껏 프로토타입간의 상속만 되는줄 알았는데 이번 기회를 통해서 클래스간의 상속이 되는 것을 알게됐네요. 이를 정적메서드, 프로퍼티 상속과 연결시키면 좀 더 기억하기 용이할 것 같습니다.


public, private, protected

객체지향프로그래밍에서 메서드와 프로퍼티는 두 가지로 나뉩니다.

  • 내부인터페이스
  • 외부인터페이스

말 그대로 내부인터페이스는 클래스내에서만 접근이 가능하며, 외부인터페이스는 외부에서도 접근가능합니다. 이렇게 나눈 이유는 두 가지라고 생각합니다.

  • 외부에서 변경할 수 없도록 만들어 안정성을 높인다.
  • 복잡한 로직을 내부에 숨겨놓고, 외부에서는 간단하게 사용하도록 한다.

이런 관점에서 봤을때 public은 외부에서 접근 가능하며, private은 클래스 내부에서만 접근할 수 있고, protected는 클래스 내부와, 자손 클래스에서도 접근할 수 있습니다. 하지만 자바스크립트에서 protected는 지원하지 않기 때문에 트릭을 걸어 사용합니다.

그럼 이 세가지 필드를 코드로 써먹어 봅시다. 제가 데스티니 가디언즈라는 게임을 좋아하는데 이를 코드에 녹여보겠습니다.(ㅎㅎ...)

class Hunter {
  level = 1;

  constructor(dex) {
    this.dex = dex;
    console.log(`민첩이 ${dex}인 헌터를 만듭니다.`);
  }
}

const hunter = new Hunter(10);

자 이렇게 헌터라는 클래스의 민첩 능력치를 부여해서 캐릭터를 만들었는데 문제가 있습니다. 외부에서 level과 dex에 접근할 수 있기 때문에 유저들이 임의로 캐릭터의 능력치를 올릴 수가 있죠. 따라서 다음과 같이 트릭을 걸어 protected필드로 만들어 봅시다.

class Hunter {
  _level = 1;

  constructor(dex) {
    this._dex = dex;
    console.log(`민첩이 ${dex}인 헌터를 만듭니다.`);
  }
}

const hunter = new Hunter(10);
hunter.dex = 20 // 에러가 나겠죠?

멤버앞에 를 붙여서 protected필드라는 것을 표현합니다. 유저에게 dex수치를 보여주기만 하기위해 getter를 활용하여 읽기전용 프로퍼티를 만들어 줍시다.

class Hunter {
  _level = 1;

  constructor(dex) {
    this._dex = dex;
    console.log(`민첩이 ${dex}인 헌터를 만듭니다.`);
  }

  get dex() {
    return this._dex;
  }
}

const hunter = new Hunter(10);
hunter.dex // setter가 설정되어있지 않아서 에러가 발생합니다.

private

private문법은 자바스크립트에 등록되지 얼마되지 않은 최신문법입니다. 멤버 변수 앞에 # 을 붙여주면 됩니다.

class Hunter {
  _level = 1;
  #levelLimit = 10;

  constructor(dex) {
    this._dex = dex;

    console.log(`민첩이 ${dex}인 헌터를 만듭니다.`);
  }

  get dex() {
    return this._dex;
  }
}

const hunter = new Hunter(10);

hunter.#levelLimit; 
// Private field '#levelLimit' must be declared in an enclosing class

위의 코드처럼 외부에서 접근할 수가 없죠. 자손 클래스에서 접근할 수 있는지 확인해보죠. 헌터의 하위직업 공허헌터 클래스를 만들어서 거기서 접근해보겠습니다.

class VoidHunter extends Hunter {
  getLevelLimit() {
    console.log(this.#levelLimit);
  }
}

const voidHunter = new VoidHunter(10);

voidHunter.getLevelLimit();
// Private field '#levelLimit' must be declared in an enclosing class

같은 에러가 발생하네요. private과 protected의 차이점을 알겠네요!


내장 클래스

내장 클래스를 상속받을 수도 있습니다. 아래의 예시를 보죠.

class Rabbit extends Array {
  isRabbit() {
    return console.log(true);
  }
}

const array = new Rabbit(1, 2, 3, 4, 5);

array.isRabbit(); // (1) true 

// Array가 아닌 array.constructor를 기반으로 객체가 형성됨
const filteredArray = array.filter((item) => item > 3); 
filteredArray.isRabbit(); // (2) true

(1)은 Rabbit의 인스턴스이기 때문에 [[Prototype]]으로 isRabbit 메서드를 호출할 수 있습니다. 그런데 (2)은 어떻게 가능한걸까요? Arrayfilter 내장메서드를 활용하면 Array의 인스턴스가 생성되기 때문에 Rabbit.prototype에 접근할 수 없을텐데요.

바로, array.filter를 호출할때 array.constructor(Rabbit)를 기반으로 배열이 생성되고 이로 인해 RabbitisRabbit메서드에 접근할 수 있는 것입니다.

이터러블 객체를 만들기 위해 [Symbol.iterator]를 사용한 것처럼 내장메서드가 일반 배열을 반환하도록 하기위해 특수 정적 메서드 [Symbol.species]를 활용할 수도 있습니다.

class Rabbit extends Array {
  isRabbit() {
    return console.log(true);
  }

  static get [Symbol.species]() {
    return Array;
  }
}

const array = new Rabbit(1, 2, 3, 4, 5);

array.isRabbit();

const filteredArray = array.filter((item) => item > 3); 
filteredArray.isRabbit(); // Error: filteredArray.isRabbit is not a function

그리고 내장객체에 대해서 하나 더 알면 좋은 개념이 있습니다. extends 키워드를 이용한 상속과 내장 객체간의 상속은 다른 점이 있죠.

extends 키워드는 프로토타입, 클래스 총 두 가지의 [[Prototype]]체인이 형성되는데(위에서 설명했죠?) 내장객체는 [[Prototype]]간의 상속만 생깁니다. 아래처럼요.

그래서 Object의 정적메서드 (keys)와 같은 메서드를 Date에서 사용할 수가 없습니다.


instanceof

instanceof 키워드를 활용해서 프로토타입 체인을 타고 생성자의 인스턴스가 맞는지 아닌지 확인할 수 있습니다.

class Rabbit {}

const rabbit = new Rabbit;

console.log(rabbit instanceof Rabbit); // true

[Symbol.hasinstance] 정적 메서드를 활용하면 이를 커스텀할 수 있죠.

class Rabbit {
  static [Symbol.hasInstance](obj) {
    if (obj.isEat) {
      return true;
    }
  }
}

const rabbit = { isEat: true };

console.log(rabbit instanceof Rabbit); // true

기억하세요. intanceof는 기본적으로 프로토타입 체인을 타고 올라갑니다.


믹스인

믹스인은 어떤 클래스에 행동을 추가해주는 용도로 사용됩니다. 자바스크립트에서는 객체를 만들어 믹스인을 구현합니다. 아래 코드를 확인하세요.

const sayMixin = {
  sayHi() {
    console.log("hi");
  },
  sayBye() {
    console.log("bye");
  },
};

class User {
  constructor(name) {
    this.name = name;
  }
}

Object.assign(User.prototype, sayMixin); // 프로퍼티를 복사합니다.

new User("seung").sayHi(); // hi
new User("seung").sayBye(); // bye

믹스인들끼리 상속도 가능합니다.

const sayHiMixin = {
  sayHi() {
    console.log("hi");
  },
  sayBye() {
    console.log("bye");
  },
};

const sayMixin = {
  __proto__: sayHiMixin,
  say() {
    super.sayHi();
    super.sayBye();
  },
};

class User {
  constructor(name) {
    this.name = name;
  }
}

Object.assign(User.prototype, sayMixin);

new User("seung").say();

여기서 superUser.[[Prototype]]이 아니라 sayHiMixin인 이유느 뭘까요? 앞에서 배웠던 [[HomeObject]]를 떠올리면 됩니다. sayMixin이생성되는 순간 [[HomeObject]]sayMixin으로 고정되고 supersayMixin.[[Prototype]]이 되죠. 그래서 sayHi, sayBye를 호출할 수 있는겁니다.

profile
세계 최고 수준을 향해 달려가는 개발자입니다.

0개의 댓글