프로토타입

1. 프로토타입 상속

개발을 하다보면 기존에 있는 기능을 가져와 확장을 해야하는 경우가 생긴다.

이는 자바스크립트의 고유 기능인 포로토타입 상속(Prototypal Inheritance)를 이용하면 실현할 수 있다.

[[Prototype]]

자바스크립트의 객체는 [[Prototype]]이라는 숨김 프로퍼티를 갖는다. 이 숨김 프로퍼티는 null이거나, 다른 객체에 대한 참조가 되는데 (그 외의 자료형은 무시된다.), 이것이 참조하는 대상을 **프로토타입(prototype)**이라고 부른다.

JS는 객체에서 프로퍼티를 찾다가, 해당 프로퍼티가 없으면 자동으로 프로토타입에서 프로퍼티를 찾는다. 프로그래밍에서는 이런 동작 방식을 프로토타입 상속이라 부른다.

[[Prototype]] 프로퍼티는 내부 프로퍼티면서 숨김 프로퍼티지만, 다양한 방법을 사용해 개발자가 값을 설정할 수 있다.

그 중 하나는, __proto__를 사용해 값을 설정하는 것이다.

참고로, __proto__[[Prototype]]용 getter / setter라는 점을 이해하자. 요즘에는 __proto__를 직접 쓰는 경우는 드물고, Object.getPrototypeOfObject.setPrototypeOf를 써서 프로토타입을 획득 혹은 설정한다. 왜 __proto__를 요즘은 쓰지 않는지에 대해서는 추후에 다루자.

obj.hasOwnProperty(key)는 해당 객체의 key에 해당하는 프로퍼티가 상속받은 것이 아닌, 직접 구현된 프로퍼티일 경우 true를 반환한다. 프로토타입으로 부터의 프로퍼티인지를 체크하는 역할을 한다고 보면 되겠다.

2. prototype 프로퍼티

new F()와 같은 생성자 함수를 사용하면 새로운 객체를 만들 수 있다는 것을 앞서 배웠다. 그런데, 이 F.prototype이 객체라면, new 연산자는 F.prototype을 사용해 새롭게 생성된 객체의 [[Prototype]]을 설정한다.

과거엔 프로토타입에 직접 접근할 방법이 없었고, 그나마 믿고 사용할 수 있는 방법이 해당 방법 뿐이었다. 여전히 이 문법이 남아있는 이유다.

여기서 F.prototype은 그저 일반 프로퍼티라는 점에 주의해야 한다.

let animal = {
  eats: true,
};

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

Rabbit.prototype = animal;

let rabbit = new Rabbit('White Rabbit'); //  rabbit.__proto__ == animal

alert(rabbit.eats); // true

contructor 프로퍼티

사실, 개발자가 따로 할당하지 않더라도, 모든 함수는 prototype 프로퍼티를 갖는다. 기본 프로퍼티인 prototypeconstructor 프로퍼티 하나만 있는 객체를 가리키는데, 이 constructor 프로퍼티는 함수 자신을 가리킨다. 이 관계를 코드로 나타내면 다음과 같다.

function Rabbit() {}

/* 기본 prototype
Rabbit.prototype = { constructor: Rabbit };
*/
function Rabbit() {}
// 기본 prototype:
// Rabbit.prototype = { constructor: Rabbit }

let rabbit = new Rabbit(); // {constructor: Rabbit}을 상속받음

alert(rabbit.constructor == Rabbit); // true (프로토타입을 거쳐 접근함)

constructor프로퍼티를 사용하면, 기존에 있던 객체의 constructor를 사용해서 새로운 객체를 만들 수 있다.

function Rabbit(name) {
  this.name = name;
  alert(name);
}

let rabbit = new Rabbit('White Rabbit');

let rabbit2 = new rabbit.constructor('Black Rabbit');

constructor는 객체가 있는데, 이 객체를 만드는데 어떤 생성자가 사용되었는지 알수 없는 경우에 사용된다. 단, 가장 중요한점은 JS가 알맞은 constructor값을 보장하진 않는다는 점이다. 함수에 기본으로 prototype값이 설정되지만 그것이 전부다. constructor에 벌어지는 모든 일은 전적으로 개발자에게 맡겨지며, 만약 함수의 기본 prototype값을 다른 객체로 바꾼다면 이 객체엔 constructor가 없어진다.

이를 방지하고 알맞은 constructor를 유지하기 위해서는 prototype 전체를 덮어쓰지 말고 기본 prototype에 원하는 프로퍼티를 추가/제거해야 한다. (참조 관계를 끊지 않기 위해서)

function Rabbit() {}

// Rabbit.prototype 전체를 덮어쓰지 말고
// 원하는 프로퍼티는 그냥 추가하세요.
Rabbit.prototype.jumps = true;
// 이렇게 하면 기본 Rabbit.prototype.constructor가 유지됩니다.

수동으로 constructor 프로퍼티를 다시 만들어주는 것도 대안이 된다.

Rabbit.prototype = {
  jumps: true,
  constructor: Rabbit,
};

// 수동으로 추가해 주었기 때문에 알맞은 constructor가 유지됩니다.

3. 네이티브 프로토타입

prototype 프로퍼티는 JS 내부에서도 광범위하게 사용되는데, 모든 내장 생성자 함수에서 prototype 프로퍼티를 사용한다.

Object.prototype

let obj = {};
alert(obj); // "[object Object]" ?

여기서 "[object Object]"를 생성하는 코드는 대체 어디에 있을까?? obj는 비어있는데.

참고로 obj = {}obj = new Object()를 줄인 것이다. 여기서 Object는 내장 객체 생성자 함수인데, 이 객체의 prototypetoString을 비롯해 다양한 메서드들이 구현된 거대한 객체를 참조한다. 따라서 obj.toString()을 호출하면 Object.prototype에서 해당 메서드를 찾아 가져오게 된다.

let obj = {};

alert(obj.__proto__ === Object.prototype); // true

alert(obj.toString === obj.__proto__.toString); //true
alert(obj.toString === Object.prototype.toString); //true

단, 이때 Object.prototype 위에는 그 이상의 [[Prototype]]이 존재하지 않는다는 점을 주의하자.

alert(Object.prototype.__proto__); // null

모든 것은 객체를 상속받는다

Array, Date, Function을 비롯한 내장 객체들 역시 프로토타입에 메서드를 저장해놓는다.

명세서 상에서는 모든 내장 프로토타입의 꼭대기에는 Object.prototype이 있어야 한다고 규정한다. 이 때문에 모든 것은 객체를 상속받는다는 말을 하기도 한다.

체인 상의 프로토타입에는 중복 메서드가 있을 수도 있는데, 이 경우, 체인 상에서 가까운 메서드를 사용하며, Array의 경우, Array.prototype의 메서드가 Object.prototype의 메서드보다 가깝기 때문에 해당 메서드가 사용된다.

원시값(Primitive Value)

그럼 원시값은요?? 이들을 프로토타입을 통해 다루는 것은 상당히 까다롭다.

문자열과 숫자, 불린은 객체가 아니다. 그런데 이런 원시값들의 프로퍼티에 접근하려고 하면 내장 생성자 String, Number, Boolean을 사용하는 임시 래퍼(Wrapper) 객체가 생성된다. 이 래퍼 객체는 해당 메서드만 제공하고 나면 사라진다.

래퍼 객체는 보이지 않는 곳에서 만들어지고, 엔진에 의해 최적화된다.

참고로 nullundefined에 대응하는 래퍼 객체는 없다. 떄문에 메서드와 프로퍼티는 물론, 당연히 프로토타입도 사용할 수 없다.

네이티브 프로토타입 변경

이런 네이티브 프로토타입을 직접 변경할 수도 있다.

String.prototype.show = function () {
  alert(this);
};

'BOOM!'.show(); // BOOM!

다만, 이는 좋은 생각이 아닌데, 기본적으로 네이티브 프로토타입은 전역으로 영향을 미치기 때문이다. 때문에 이런식으로 네이티브 프로토타입을 수정하게 되면 다른 라이브러리의 메서드와 충돌할 가능성이 크다.

네이티브 프로토타입 변경이 허용되는 유일한 경우는 딱 하나인데, 바로 폴리필을 만들 때다.

폴리필은 JS 명세서에는 정의되어 있으나 특정 JS 엔진에서 해당 기능이 구현되지 않았을 경우 만들어 사용한다.

프로토타입에서 빌려오기

네이티브 프로토타입에 구현된 메서드를 빌려와서 사용할 수도 있다.

다음은 객체 objArrayjoin 메서드를 구현하는 내용이다.

let obj = {
  0: 'Hello',
  1: 'world!',
  length: 2,
};

obj.join = Array.prototype.join;

alert(obj.join(',')); // Hello,world!

모던하게 프로토타입을 다루기

__proto__는 브라우저를 대상으로 개발한다면 구식의 방법이기에 더는 사용하지 않는다.

이를 대체할 아래의 모던한 메서드들이 있다.

Object.create(proto, [descriptors]) – [[Prototype]]이 proto를 참조하는 빈 객체를 만든다. 이때 프로퍼티 설명자({ value, enumarable, ...})를 추가로 넘길 수 있다. Object.getPrototypeOf(obj) – obj의 [[Prototype]]을 반환한다. Object.setPrototypeOf(obj, proto) – obj의 [[Prototype]]이 proto가 되도록 설정한다.

let animal = {
  eats: true,
};

// 프로토타입이 animal인 새로운 객체를 생성합니다.
let rabbit = Object.create(animal);

alert(rabbit.eats); // true

alert(Object.getPrototypeOf(rabbit) === animal); // true

Object.setPrototypeOf(rabbit, {}); // rabbit의 프로토타입을 {}으로 바꿉니다.

앞서 말한것처럼 프로퍼티 설명자를 선택적으로 전달할 수도 있다.

let animal = {
  eats: true,
};

let rabbit = Object.create(animal, {
  jumps: {
    value: true,
  },
});

alert(rabbit.jumps); // true

Object.create를 통해 객체를 효율적으로 (얕게) 복제할 수도 있다. 아래 코드는 obj의 모든 프로퍼티를 포함한 완벽한 사본을 만든다.

let clone = Object.create(
  Object.getPrototypeOf(obj),
  Object.getOwnPropertyDescriptors(obj),
);

주의해야 할 점은, 앞선 메서드들로 객체의 [[Prototype]]을 수정하는데 기술적인 문제는 전혀 없으나, 이는 권장되는 사항이 아니다. 이는 객체 프로퍼티 접근 관련 최적화를 망치기 때문에, JS 엔진의 속도를 매우 느리게 한다. 때문에 [[Prototype]]은 객체를 처음 생성할 때만 설정하는 것이 일반적이다.

아주 단순한(Very plain) 객체

Object.create()는 인자의 [[Prototype]]을 상속받은 객체를 생성한다. 이 때, 상속받는 객체 자체가 없다면 어떻게 될까??

Object.create(null)__proto__를 상속받지 않는다. 때문에 __proto__가 키 값이 되어도 일반 데이터 프로퍼티처럼 처리하므로 버그가 발생하지 않는다.

이런 객체는 아주 단순한(Very plain), 혹은 순수 사전식(Pure dictionary) 객체라고 부른다. 일반 객체 {...}보다도 훨씬 단순하기 때문이다.

단, 이 단순한 객체는 프로토타입 자체가 없기 때문에 내장 메서드조차 없다.

let obj = Object.create(null);

alert(obj); // Error: Cannot convert object to primitive value (toString이 없음)