자바스크립트는 프로토타입(prototype) 기반 언어이다.
클래스 기반 언어에서는 '상속'을 사용하지만 프로토타입 기반 언어에서는 어떤 객체를 원형(prototype)으로 삼고 이를 복제(참조)함으로써 상속과 비슷한 효과를 얻을 수 있다.
❗ 편의를 위해 __proto__ 를 사용하지만 실무에서는 Object.getPrototypeof()나 Object.create()등을 이용하도록 합시다.
요 그림만 이해하면 프로토타입은 끝이라고 합니다. 참쉽죠?? 🤔
var instance = new Constructor();
위 그림은 이 코드를 추상화 한 것인데, 하나하나 살펴보자면
Constructor(생성자 함수)를 new 연산자와 함께 호출하면 instance가 생성되는데, 이 instance에는 __proto__ 라는 프로퍼티가 자동으로 부여되고, 이 __proto__ 프로퍼티는 Constructor의 prototype이라는 프로퍼티를 참조한다는 뜻이다.
prototype이라는 프로퍼티와 __proto__ 라는 프로퍼티와의 관계가 프로토타입 개념의 핵심이다.
prototype이 객체이므로 이를 참조하는 __proto__ 역시 객체이고, __proto__ 는 prototype 객체 내부에 있는 메서드들에 접근 할 수 있다.
이를 예시와 함께 Araboza.
var Person = function (name) {
this._name = name;
};
Person.prototype.getName = function () {
return this._name;
};
Person이라는 생성자 함수의 prototype에 getName이라는 메서드를 지정했다고 해보자.
이제 Person의 인스턴스는 __proto__ 프로퍼티를 통해 getName을 호출할 수 있다.
var suzi = new Person('Suzi');
suzi.__proto__.getName(); // undefined
이러면 결과가 Suzi가 나올 것 같은데, undefined가 나온다!
일단 결과로 undefined가 나온 점에서 getName 함수가 실제로 실행됐음을 알 수 있다. 그러면 함수 내부에서 뭔가가 의도와는 다르게 실행이 된 것이라고 유추해볼 수 있는데, getName은 this._name 값을 리턴하므로 this값이 생각과는 다르게 할당되어 있을 가능성이 있다.
어떤 함수를 '메서드'로서 호출할 때는 메서드명 바로 앞의 객체가 곧 this가 된다.
그래서 suzi.__proto__.getName();
는 suzi
가 아니라suzi.__proto__
가 this가 되는 것이다. 이 객체 안에는 _name 프로퍼티가 없기 때문에 undefined가 반환된 것이다.
그럼 __proto__ 객체에 _name 프로퍼티가 있으면 어떨까?
var suzi = new Person('Suzi');
suzi.__proto__._name = 'SUZI__proto__';
suzi.__proto__.getName(); // SUZI__proto__
예상대로 SUZI__proto__ 가 잘 출력된다. 관건은 this였다!
this가 인스턴스를 보면 좋을텐데.. 그 방법은 __proto__ 없이 인스턴스에서 곧바로 메서드를 쓰는 것이다.
suzi.getName(); // Suzi;
__proto__없이 인스턴스(suzi)에서 곧바로 메서드를 쓰면 인스턴스를 this로 할 수 있다.
근데 왜 __proto__를 빼도 prototype 메서드가 호출될까?? 그것은 바로 __proto__가 생략 가능한 프로퍼티이기 때문이다!
'생략 가능한 프로퍼티'라는 개념은 브랜든 아이크씨의 머리에서 나온 아이디어이기 때문에 그냥 아묻따 외우면 된다.
__proto__ 프로퍼티는 생략 가능하기 때문에 생성자 함수의 prototype에 어떤 메서드나 프로퍼티가 있다면 인스턴스에서도 마치 자기 것처럼 해당 메서드나 프로퍼티에 접근할 수 있게 된다!
console.dir(Person)
한 결과
console.dir(suzi)
한 결과
생성자 함수의 인스턴스는 해당 생성자 함수의 이름을 표기함으로써 해당 함수의 인스턴스임을 표기하고 있다.
Person의 prototype과 suzi의 prototype이 동일한 내용으로 구성돼 있음을 확인할 수 있다.
대표적인 내장 생성자 함수인 Array를 바탕으로 다시 한번 Araboza.
var arr = [1, 2];
console.dir(arr);
console.dir(Array);
왼쪽은 arr, 오른쪽은 Array를 출력한 결과이다.
arr 인스턴스의 __proto__는 Array.prototype을 참조하기 때문에 Array의 내부 메서드들을 사용할 수 있다.
하지만 isArray, from 같은 정적 메서드는 직접 호출할 수 없다. 이들은 Array 생성자 함수에서 직접 접근해야 실행 가능하다.
var arr = [1, 2];
arr.forEach(function () {}); // O
Array.isArray(arr); // O
arr.isArray(); // X TypeError
var arr = [1,2];
Array.prototype.constructor === Array; // true
arr.__proto__.constructor === Array; // true
arr.constructor === Array; // true
var arr2 = new arr.constructor(3, 4);
console.log(arr2); // [3,4];
prototype 내부에는 constructor 프로퍼티가 있고, __proto__ 객체 내부에도 마찬가지이다.
이는 인스턴스로부터 그 원형이 무엇인지 알 수 있는 수단이 된다.
하지만 어떤 인스턴스의 생성자 정보를 알아내기 위해 constructor 프로퍼티에 의존하는게 안전하지만은 않다.
var NewConstructor = function(){
console.log('this is new constructor');
};
var dataTypes = [
1, // Number & false
'text', // String & false
true, // Boolean & false
{}, // NewConstructor & false ...
[], // 이하 같음
function(){},
/text/,
new Number(),
new Date(),
new Error()
];
dataTypes.forEach(function(d){
d.constructor = NewConstructor;
console.log(d.constructor.name, '&', d instanceof NewConstructor);
});
constructor은 기본형 리터럴 변수(number, string, boolean)을 제외하고 값을 바꿀 수 있다.
dataTypes의 첫번째~세번째 까지는 기본형 리터럴 변수이기 때문에 constructor의 이름이 변경되지 않았지만 아래의 데이터들은 NewConstructor로 이름이 변경되었다.
하지만 참조하는 대상이 변경됐을 뿐 인스턴스의 원형이 바뀐 것은 아니기 때문에 d instanceof NewConstructor
부분에서 false라는 결과가 나온 것이다.
만약 인스턴스가 prototype과 동일한 이름의 프로퍼티나 메서드를 갖고있으면 어떨까?
var Person = function (name) {
this.name = name;
};
Person.prototype.getName = function () {
return this.name;
};
var sora = new Person('소라');
sora.getName = function () {
return '나는 ' + this.name;
};
console.log(sora.getName()); // 나는 소라
sora.__proto__.getName이 아닌 sora 객체에 있는 getName 메서드가 호출됐다.
여기서 일어난 현상을 메서드 오버라이드라고 한다.
자바스크립트 엔진이 메서드를 찾는 방식은 가장 가까운 대상인 자신의 프로퍼티를 검색하고, 없으면 그 다음으로 가까운 대상인 __proto__를 검색하는 순서로 진행된다.
메서드 오버라이드는 메서드 위에 메서드를 덮어씌운 것이기 때문에 원본에 접근할 수 있다.
그러면 prototype에 있는 메서드에 접근하려면 어떻게 해야할까?
Person.prototype.name = 'Sora';
console.log(sora.__proto__.getName()); // Sora, this가 prototype을 보고 있음
console.log(sora.__proto__.getName.call(sora)); // 소라, this가 인스턴스를 보고 있음
prototype 객체는 '객체'이기 때문에 모든 객체의 __proto__에는 Object.prototype이 연결된다.
var arr = [1, 2]; 를 console.dir로 찍어보면
prototype안에 prototype이 Object로 되어있는 걸 알 수 있다.
따라서 arr가 Array.prototype 내부의 메서드를 실행할 수 있는 것 처럼 Object.prototype 내부의 메서드도 실행할 수 있는 것이다.
ex) arr.hasOwnProperty(2); // true
어떤 데이터의 __proto__ 프로퍼티 내부에 다시 __proto__ 프로퍼티가 연쇄적으로 이어진 것을 프로토타입 체인이라 하고, 이 체인을 따라가며 검색하는 것을 프로토타입 체이닝이라고 한다.
var arr = [1, 2];
Array.prototype.toString.call(arr); // 1,2
Object.prototype.toString.call(arr); // [object Array]
arr.toString(); // 1,2
arr.toString은 Array.prototype.toString이 실행되는걸 알 수 있다!
어떤 생성자 함수이든 prototype은 객체이기 때문에 Object.prototype이 언제나 프로토타입 체인의 최상단에 위치하게 된다.
따라서 객체에서만 사용할 메서드를 프로토타입 내부에 정의한다면 다른 데이터 타입도 해당 메서드를 사용할 수 있기 때문에 객체 전용 메서드들은 스태틱 메서드(static method)로 부여되어 있다.
ex) Object.freeze(instance), Object.getPrototypeOf(instance)
반대로 toString, hasOwnProperty, valueOf, isPrototypeOf 같이 어떤 데이터에서도 활용할 수 있는 범용적인 메서드들도 존재한다.
프로토타입 체인은 반드시 2단계로 이루어지는 것이 아니라 무한대의 단계를 생성할 수 있다.
var Grade = function () {
var args = Array.prototype.slice.call(arguments);
for (var i = 0; i < args.length; i++) {
this[i] = args[i];
}
this.length = args.length;
};
var g = new Grade(100, 80);
변수 g는 유사배열객체라서 배열 메서드를 쓸 수 없다.
배열 메서드를 직접 쓰게 하기 위해서는 g.__proto__, 즉 Grade.prototype이 배열의 인스턴스를 바라보게 하면 된다.
Grade.prototype = [];
그러면 이제 g에서 직접 배열 메서드를 사용할 수 있다!
g.pop();
g.push(90);
Grade의 prototype이 Array가 된 것을 확인할 수 있다.