프로토타입에 대하여

박찬욱·2023년 8월 6일
0

Basic JavaScript

목록 보기
5/13
post-thumbnail

클래스를 사용하면서 생성자 함수와 프로토타입이라는 개념에 관심을 덜 준 것 같다. 결국에 클래스의 내부 동작도 프로토타입으로 이루어져있기 때문에 꼭 알아야되는 개념이라고 생각하고 이번 기회에 제대로 정리를 해보고자 한다.

프로토타입 기반 상속

function Circle(radius) {
  this.radius = radius;
}

Circle.prototype.getArea = function () {
  return Math.PI * this.radius ** 2;
};

const circle1 = new Circle(1);
const circle2 = new Circle(2);

console.log(circle1.getArea());
console.log(circle2.getArea());

딥다이브 책에 나온 예제이다. 책에 그림으로 상속에 대한 관계를 잘 설명했으니 참고하면 되겠다.

말로 설명하자면 Circle 생성자 함수의 prototype 프로퍼티를 통해 getArea라는 메서드를 설정했다. 이렇게되면 생성자함수를 통해 만든 모든 인스턴스들은 생성자 함수의 prototype을 상속하게 된다. 따라서 circle1과 circle2는 getArea 메서드를 사용할 수 있게 되는 것이다.

자바스크립트는 프로토타입을 기반으로 상속을 구현한다. 즉, 프로토타입 프로퍼티는 상속을 위해 사용되어진다.

프로토타입 객체

모든 객체는 [[Prototype]] 이라는 내부 슬롯을 가지게 된다. 객체가 생성될 때 객체 생성 방식에 따라 프로토타입이 결정되고 해당 슬롯에 저장된다.

객체가 자신의 프로토타입에 접근하기 위해서는 __proto__ 라는 접근자 프로퍼티를 통해 간접적으로 접근이 가능하다.

반대로 prototype 프로퍼티는 생성자 함수가 생성할 인스턴스의 프로토타입을 가리킨다. 해당 프로퍼티는 생성자 함수가 만들어낼 인스턴스의 프로토타입을 가리키므로 non-constructor (화살표 함수, es6의 메서드 축약표현)은 해당 프로퍼티를 가질 수 없다는 것이 특징이다.

결국 __proto__prototype은 동일한 프로토타입을 가리키게 된다. 이들을 사용하는 주체만 다를뿐이다.

쉽게 말하면 자식 객체가 자신의 프로토타입에 접근하기 위해서는 __proto__라는 프로퍼티를 이용하고 부모 생성자 함수가 프로토타입에 접근하기 위해서는 prototype을 사용한다.

프로토타입의 결정

프로토타입은 생성자 함수가 생성되는 시점에 같이 생성이 된다. 프로토타입과 생성자 함수는 단독으로 존재할 수 없고 쌍으로 존재하기 때문이다.

const obj = { x: 1 };

console.log(obj.constructor === Object); // true
console.log(obj.hasOwnProperty("x")); // true

재밌었던 점은 객체 리터럴에 의해 생성된 객체도 Object.prototype을 프로토타입으로 가지게 된다는 것이다. 그리고 프로토타입 객체의 constructor에는 Object 생성자 함수가 연결되어있다.

이유는 객체 리터럴이 평가되면 추상 연산에 의해 프로토타입 연결이 만들어지기 때문이다.

const obj = new Object();
obj.x = 1;

console.log(obj.constructor === Object); // true
console.log(obj.hasOwnProperty("x")); // true

Object 생성자 함수에 의해 생성된 객체는 당연히 Object.prototype을 프로토타입 객체로 가지게 되며 해당 constructor 프로퍼티에는 Object 생성자 함수가 바인딩이 되어있다. 너무나 당연한 얘기이다.

function Person(name) {
  this.name = name;
}

const me = new Person('Lee');

생성자 함수로 만든 객체인 경우에는 조금 다르다. 하지만 추상 연산이 호출되는 것은 동일하다. 이때 me는 프로토타입으로 Person.prototype을 가지게 된다. Person.prototype은 constructor 프로퍼티만을 가지며 생성자 함수에 바인딩이 되어있다.
프로토타입도 객체이기 때문에 Person.prototype에 다양한 프로퍼티와 메서드를 추가 및 삭제할 수 있다. 이렇게 되면 상속받은 모든 객체는 지꺼마냥 사용할 수 있게된다.

Person.prototype도 객체라고 했다. 모든 객체는 프로토타입을 가지게된다. 따라서 프로토타입 객체도 Object.prototype이라는 프로토타입을 가지게된다. 그래서 me 에서 hasOwnProperty와 같은 메서드를 사용할 수 있는 것이다.

프로토타입 체인

자바스크립트는 객체의 프로퍼티(메서드 포함)에 접근하려고 할 때 해당 객체에 접근하려는 프로퍼티가 없다면 내부 슬롯의 참조를 따라 자신의 부모 역할을 하는 프로토타입의 프로퍼티를 순차적으로 검색한다.

me.hasOwnProperty('name');

위의 코드를 실행시키려면 우선 스코프 체인에서 me를 검색한다. 그리고 나서 프로토타입 체인을 통해 hasOwnProperty 메서드를 실행시키는 것이다. 두 체이닝 개념은 서로 협력하는 관계이다.


오버라이딩과 프로퍼티 섀도잉

오버라이딩이란 상위 클래스가 가지고 있는 메서드를 하위 클래스가 재정의하여 사용하는 방식이다.

const Person = (function () {
  function Person(name) {
    this.name = name;
  }

  Person.prototype.sayHello = function () {
    console.log(`Hi! My name is ${this.name}`);
  };

  return Person;
})();

const me = new Person("Lee");

me.sayHello = function () {
  console.log(`이건 인스턴스에서 섀도잉했어! My name is ${this.name}`);
};

me.sayHello();

delete me.sayHello;
me.sayHello();

delete me.sayHello; // 삭제 ❌, 하위 객체를 통해 프로토타입에 get은 가능하지만 set은 불가능
me.sayHello();

// delete Person.prototype.sayHello;
// me.sayHello();

sayHello는 Person의 프로토타입 프로퍼티에 정의되어있고 이것을 인스턴스인 me에서 다시 재정의했다. 재정의하는 과정을 오버라이딩이라고 하고 이렇게 함으로써 me에서 sayHello를 호출하면 프로토타입에 정의되어있는 함수가 아닌 me에서 재정의한 함수가 호출된다. 프로토타입의 메서드가 가려진 것이다. 이것을 섀도잉이라고 한다.

또한 하위 객체에서 프로토타입에 get은 가능하지만 set은 불가능하다. 즉, me라는 인스턴스에서 프로토타입에 정의되어있는 sayHello를 삭제하는 것은 불가능하다는 것이다. 수정도 사실 프로토타입에 있는 메서드를 수정하는 것이 아니라 재정의하고 덮어 씌우는 것 뿐이다. 프로토타입이 수정되는 것은 아니다.


프로토타입의 교체 - 직접 상속

생성자 함수의 프로토타입 프로퍼티와 setPrototypeOf 메서드를 통해 인스턴스에서 프로토타입을 동적으로 교체할 수 있었다. 다만 과정이 번거롭기 때문에 더 안전하고 편리한 직접 상속에 대해 살펴보겠다. 이 또한 클래스를 사용하면 훨씬 더 직관적이고 간편하게 상속관계를 구현할 수 있다.

function Animal(name, emoji) {
  this.name = name;
  this.emoji = emoji;
}

Animal.prototype.printName = function () {
  console.log(`printName : ${this.name} ${this.emoji}`);
};

function Dog(name, emoji, owner) {
  Animal.call(this, name, emoji);
  // 생성자 함수 내부의 this를 첫번째 인수로 전달한 this(Dog가 생성할 인스턴스)에 바인딩
  // name, emoji는 Animal 생성자 함수 인수에 전달할 것들
  this.owner = owner;
}

Dog.prototype = Object.create(Animal.prototype);

Dog.prototype.play = () => {
  console.log("같이 놀아용!");
};

const dog1 = new Dog("멍멍", "🐕");
dog1.printName();
dog1.play();

function Tiger(name, emoji) {
  Animal.call(this, name, emoji);
}

Tiger.prototype = Object.create(Animal.prototype);
const tiger1 = new Tiger("어흥", "🐯");
tiger1.printName();

console.log(dog1 instanceof Dog);
console.log(dog1 instanceof Animal);
console.log(dog1 instanceof Tiger);

Object.create를 사용하여 생성할 객체의 프로토타입으로 지정할 객체를 전달해줄 수 있다. 자바스크립트는 프로토타입 기반의 상속을 제공한다. 따라서 상속받고 싶은 객체의 프로토타입에 상속할 객체의 프로토타입을 해당 메서드를 사용해서 대입하면 된다.

ES6에서는 객체 리터럴 내부에서 __proto__ 접근자 프로퍼티를 사용하여 직접 상속을 구현할 수 있다.

const myProto = { x: 10 };

const obj = {
  y: 20,
  __proto__: myProto
};

console.log(Object.getPrototypeOf(obj) === myProto); // true

assign을 통한 mixin

객체는 하나의 프로토타입만을 가질 수 있다. 즉, 다중 상속을 지원하지 않는다는 것이다. 프로토타입 체인으로 연달아 연결되어있을뿐 실질적으로 연결되어있는 프로토타입은 단 한개이다.

이것은 클래스에서도 마찬가지로 적용된다. 클래스의 상속도 다중 상속을 지원하지 않는다.

하지만 우리가 여러개의 메서드를 상속받고 싶으면 어떻게 해야할까?
프로토타입 객체도 객체이기 때문에 Object.assign을 통해 객체를 합친다면 객체 안의 다양한 메서드를 상속받아 사용할 수 있게된다.

const talk = {
  talk: function () {
    console.log(`${this.name}은 말한다`);
  },
};

const walk = {
  walk: function () {
    console.log(`${this.name}은 걷는다`);
  },
};

function Person(name) {
  this.name = name;
}

Object.assign(Person.prototype, talk, walk); // Person.prototype = talk + walk
const chanuk = new Person("찬욱");
console.log(chanuk);
chanuk.talk();
chanuk.walk();

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

Object.assign(Tiger.prototype, talk, walk);
const tiger = new Tiger("어흥");
tiger.talk();
tiger.walk();

비단 생성자함수 뿐만 아니라 클래스 또한 내부 로직은 프로토타입 기반 상속이다. 따라서 클래스의 프로토타입에 열거 가능한 객체의 속성들을 합칠 수 있다.
클래스로 구현한 인스턴스는 클래스의 프로토타입을 상속받기 때문에 프로토타입에 정의된 메서드들을 모두 사용할 수 있게된다.

profile
대체불가능한 사람이다

0개의 댓글