자바스크립트는 프로토타입 기반 언어이다.
클래스 기반 언어에서는 '상속'을 사용하지만 프로토타입 기반 언어에서는 어떤 객체를 원형(prototype)으로 삼고 이를 복제(참조) 함으로써 상속과 비슷한 효과를 얻습니다.
프로토타입의 개념 이해
6-1-1 constructor, prototype, instance
이 그림만 이해하면 프로토타입은 끝이다. 이 그림으로부터 전체 구조를 파악할 수 있고,
전체 구조로부터 이 그림을 도출해낼 수 있으면 된다.
위 그림은 다음 코드의 내용을 추상화한 것이다.
var instance = new Constructor();
이를 바탕으로 좀 더 구체적인 형태로 바꿔보면 다음과 같다.
위 그림의 윗변(실선)의 왼쪽 꼭짓점에는 Constructor(생성자 함수)를, 오른쪽 꼭짓점에는 Constructor.protoype이라는 프로퍼티를 위치시켰다.
왼쪽 꼭짓점으로부터 아래를 향한 화살표 중간에 new가 있고, 화살표의 종점에는 instance가 있다.
오른쪽 꼭짓점으로부터 대각선 아래로 향하는 화살표의 종점에는 instance.__proto__이라는 프로퍼티를 위치시켰다.
- 어떤 생성자 함수를 new 연산자와 함께 호출하면
- Constructor에서 정의된 내용을 바탕으로 새로운 인스턴스가 생성된다.
- 이때 instance에는 __proto__라는 프로퍼티가 자동으로 부여된다.
- 이 프로퍼티는 Constructor라는 prototype이라는 프로퍼티를 참조한다.
prototype라는 프로퍼티와 __proto__라는 프로퍼티의 관계가 프로토타입의 핵심이다.
prototype은 객체이다.
이를 참조하는 __proto__역시 객체이다.
prototype 객체 내부에는 인스턴스가 사용할 메서드를 저장한다.
그러면 인스턴스에서도 숨겨진 프로퍼티인 __proto__를 통해 이 메서드들에 접근할 수 있게 된다.
예를 들어 Person이라는 생성자 함수의 prototype에 getName이라는 메서드를 지정했다고 하자.
var Person = function (name) {
this_name = name;
};
Person.prototype.getName = function() {
return.this_name;
};
이제 Person의 인스턴스는 __proto__ 프로퍼티를 통해 getName을 호출할 수 있다.
var suzi = new Person('Suzi);
suzi.__proto__.getName(); //undefined
왜냐하면 instance의 __proto__가 Constructor의 prototype 프로퍼티를 참조하기 때문에
결국 둘은 같은 객체를 바라본다.
Person.prototype === suzi.__proto__ //true
메서드 호출 결과로 undefined가 나온 점에 주목하자.
'Suzi'라는 값이 나오지 않은 것보다 에러가 발생하지 않았다는 점이 우선이다.
어떤 변수를 실행해 undefined가 나왔다는 것은 이 변수가 '호출할 수 있는 함수'에 해당한다는 것을 의미한다.
만약 실행할 수 없거나 함수가 아닌 다른 데이터 타입이였다면 TypeError가 발생했을 것이다.
그러나 값이 에러가 아닌 다른 값이 나왔으므로 getName이 실행됐고, 이로 getName이 함수라는 것이 입증됐다.
__proto__ 객체에 name 프로퍼티가 있다면?
var suzi = new Person('Suzi');
suzi.__proto__.getName(); //SUZI__proto__
예상대로 SUZI__proto__가 잘 출력된다.
this를 인스턴스로 하는 방법은 __proto__없이 인스턴스에서 곧바로 메서드를 쓰는 것이다.
var suzi = new Person('Suzi, 28);
suzi.getName(); //Suzi
var iu = new Person('Jieun',28);
iu.getName(); //Jieun
__proto__가 생략 가능한 프로퍼티이기 때문에 동작이 잘된다.
'생략 가능한 프로퍼티'라는 개념은 이해의 영역이 아니므로 그냥 그런가보다 하는 수밖에 없다.
우리는 __proto__가 생략 가능하다는 점만 기억하자.
suzi.__proto__.getName
-> suzi(.__proto__).getName
-> suzi.getName
__proto__를 생략하지 않으면 this는 suzi.__proto__를 가리키지만, 이를 생략하면 suzi를 가리킨다.
suzi.__proto__에 있는 메서드인 getName을 실행하지만 this는 suzi를 바라보게 할 수 있게 된 것이다.
이제부터 프로토타입을 보는 순간 삼각형을 떠올리고, 각 꼭짓점에 글자들을 떠올리고, 그로부터 문장을 만들어보는 연습을 해보자.
"new 연산자로 Constructor를 호출하면 instance가 만들어지는데, 이 instance의 생략 가능한 프로퍼티인 __proto__는 Constructor의 prototype를 참조한다"
>> 여기까지 이해했다면 프로토타입을 거의 다 이해한 것이다!
프로토타입의 개념을 조금 더 상세히 설명해보자.
자바스크립트에는 함수에 자동으로 객체인 prototype 프로퍼티를 생성해 놓는데, 해당 함수를 생성자 함수로서 사용할 경우,
즉 new 연산자와 함께 함수를 호출할 경우, 그로부터 생성된 인스턴스에는 숨겨진 프로퍼티인 __proto__가 자동으로 생성되며,
이 프로퍼티는 생성자 함수의 prototype 프로퍼티를 참조한다.
__proto__프로퍼티는 생략 가능하도록 구현돼 있기 떄문에 생성자 함수의 prototype에 어떤 메서드나 프로퍼티가 있다면 인스턴스에서도 마치 자신의 것처럼 해당 메서드나 프로퍼티에 접근할 수 있게 된다.
왼쪽은 arr 변수를 출력한 결과이고, 오른쪽은 생성자 함수인 Array를 출력한 결과이다.
위 출력 결과를 바탕으로 그림의 도식을 더욱 구체화하면 다음과 같다.
이제 생성자 함수와 prototype, 인스턴스 사이의 관계가 명확히 보이는 것 같다.
Array를 new 연산자와 함께 호출해서 인스턴스를 생성하든, 그냥 배열 리터럴을 생성하든, instance[1,2]가 만들어진다.
이 인스턴스의 __proto__은 Array.prototype을 참조하는데 __proto__가 생략 가능하도록 설계되어있기 때문에
인스턴스가 push, pop, forEach등의 메서드를 마치 자신의 것처럼 호출할 수 있다.
한편 Array의 prototype 프로퍼티 내부에 있지 않은 from, isArray등의 메서드들은 인스턴스가 직접 호출할 수 없다.
이들은 Array 생성자 함수에서 직접 접근해야 실행이 가능하다.
6-1-2 constructor 프로퍼티
생성자 함수의 프로퍼티인 prototype 객체 내부에는 constructor 라는 프로퍼티가 있다.
인스턴스의 __proto__ 객체 내부에도 마찬가지이다.
이 프로퍼티는 원래의 생성자 함수(자기 자신)을 참조한다.
-constructor 프로퍼티
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]
인스턴스의 __proto__가 생성자 함수의 prototype 프로퍼티를 참조하며 __proto__가 생략 가능하기 때문에
인스턴스에서 직접 consturctor에 접근할 수 있는 수단이 생겼다.
한편 constructor 는 읽기 전용 속성이 부여된 예외적인 경우(기본형 리터럴 변수 number, string, boolean)를 제외하고는 값을 바꿀 수 있다.
-constructor 변경
var NewConstructor = function () {
console.log('this is new constructor!');
};
var dataTypes = [
1, // Number & false
'test', // String & false
{}, // NewConstructor & false
[], // NewConstructor & false
function () {}, // NewConstructor & false
/test/, // NewConstructor & false
new Number(), // NewConstructor & false
new String(), // NewConstructor & flase
new Boolean, // NewConstructor & flase
new Object(), // NewConstructor & flase
new Array(), // NewConstructor & flase
new Function(), // NewConstructor & flase
new RegExp(), // NewConstructor & flase
new Date(), // NewConstructor & flase
new Error(), // NewConstructor & flase
];
dataTypes.forEach(function (d) {
d.constructor = NewConstructor;
console.log(d.constructor.name,'&',d instanceof NewConstructor);
});
모든 데이터가 d instanceof NewConstructor 명령에 대해 false를 반환한다.
constructor를 변경하더라도 참조하는 대상이 변경될 뿐 이미 만들어진 인스턴스의 원형이 바뀐다거나 데이터 타입이 변하는 것은 아니다.
어떤 인스턴스의 생성자 정보를 알아내기 위해 constructor 프로퍼티에 의존하는 게 항상 안전하지는 않다.
오히려 그렇기 때문에 클래스 상속을 흉내내는 등이 가능해진 측면도 있다.
- 다양한 constructor 접근 방법
var Person = funtion (name) {
this.name = name;
};
var p1 = new Person('사람1'); // { name: "사람1" } true
var p1Proto = Object.getPrototypeOf(p1);
var p2 = new Person.prototype.constructor('사람2') // { name: "사람2" } true
var p3 = new p1Proto.constructor('사람3') // { name: "사람3" } true
var p4 = new p1.__proto__.contructor('사람4') // { name: "사람4" } true
var p5 = p1.constructor('사람5') // { name: "사람5" } true
[p1,p2,p3,p4,p5].forEach(function(p) {
console.log(p, p instanceof Person);
});
p1 부터 p5까지는 모두 Person의 인스턴스이다.
따라서 다음 두 공식이 성립한다.
2. 프로토타입 체인
6-2-1 메서드 오버라이드
prototype 객체를 참조하는 __proto__를 생략하면 인스턴스는 prototype에 정의 된 프로퍼티나 메서드를 마치 자신의 것처럼 사용할 수 있다.
만약 인스턴스가 동일한 이름의 프로퍼티 또는 메서드를 가지고 있는 상황이라면 어떨까?
-메서드 오버라이드
var Person = function (name) {
this.name = name;
};
Person.prototype.getName = function () {
return this.name;
};
var iu = new Person('지금');
iu.getName = function () {
return '바로' + this.name;
};
console.log(iu.getName()); // 바로 지금
iu.__proto__.getName이 아닌 iu 객체에 있는 getName 메서드가 호출됐다.
여기서 일어난 현상을 메서드 오버 라이드 라고 한다.
메서드 위에 메서드를 덮어씌웠다는 표현이다.
원본을 제거하고 다른 대상으로 교체하는 것이 아니라 원본이 그대로 있는 상태에서 다른 대상을 그 위에 얹는 이미지라고 생각하면 된다.
자바스크립트 엔진이 getName이라는 메서드를 찾는 방식은 가장 가까운 대상인 자신의 프로퍼티를 검색하고,
없으면 그 다음으로 가까운 대상인 __proto__를 검색하는 순서로 진행된다.
즉 __proto__에 있는 메서드는 자신에게 있는 메서드보다 검색 순서에서 밀려 호출되지 않은 것이다.
교체하는 형태라면 원본에는 접근할 수 없는 형태가 되겠지만 얹는 형태라면 원본이 아래에 유지되고 있으니
원본에서 접근할 수 있는 방법도 있을 것이다.
그렇다면 메서드 오버라이딩이 이뤄져 있는 상황에서 prototype에 있는 메서드에 접근하려면 어떻게 하면 될까?
console.log(iu.__proto__.getName()); //undefined
iu.__proto__.getName 을 호출했더니 undefined가 출력됐다.
this 가 prototype 객체인 (iu.__proto__)를 가리키는데 prototype 상에는 name 프로퍼티가 없기 때문이다.
만약 prototype에 name 프로퍼티가 있다면 그 값을 출력할 것이다.
Person.prototype.name = '이지금';
console.log(iu.__proto__.getName()); //이지금
원하는 메서드(prototype에 있는 getName)가 호출되고 있다.
다만 this 가 prototype을 바라보고 있는데 이걸 인스턴스를 바라보도록 바꿔주면 된다.
이건 call 이나 apply로 해결 가능하다.
console.log(iu.__proto__.getName.call(iu)); //지금
일반적으로 메서드가 오버라이드 된 경우에는 자신으로부터 가장 가까운 메서드에서만 접근할 수 있지만
그 다음으로 가까운 __proto__메서드도 접근이 불가능하지는 않다.
6-2-2 프로토타입 체인
프로토타입 체인을 설명하기에 앞서 이번에는 객체의 내부구조를 살펴보자.
console.dir({ a:1 });
첫 줄을 통해 Object의 인스턴스임을 알 수 있고, 프로퍼티의 a의 값 1이 보이고,
__proto__ 내부에는 hasOwnProperty , isPrototypeOf, toLocalString, toString, valueOf 등의 메서드가 보인다.
constructor는 생성자 함수인 Object를 가리키고 있다.
이번에는 배열의 구조를 살펴보자.
이 __proto__안에는 또다시 __proto__가 등장한다.
열어보니 그림 6-7에서 살펴본 객체의 __proto__와 동일한 내용으로 이루어져 있다.
바로 prototype 객체가 '객체' 이기 때문이다.
기본적으로 모든 객체의 __proto__에는 Object.prototype이 연결된다.
prototype객체도 예외가 아니다.
이를 그림으로 표현하면 다음과 같다.
__proto__는 생략이 가능하기 때문에 배열이 Array.prototype 내부의 메서드를 마치 자신의 것처럼 실행할 수 있다.
마찬가지로 Object.prototype 내부의 메서드도 자신의 것처럼 실행할 수 있다.
생략 가능한 __proto__를 한 번 더 따라가면 Object.prototype을 참조할 수 있기 때문이다.
어떤 데이터의 __proto__ 프로퍼티 내부에 다시 __proto__ 프로퍼티가 연쇄적으로 이어진 것을 프로토타입 체인 이라고 하고,
이 체인을 따라가며 검색하는 것을 프로토타입 체이닝 이라고 한다.
프로토타입 체이닝은 메서드 오버라이드와 동일한 맥락이다
어떤 메서드를 호출하면 자바스크립트 엔진은 데이터 자신의 프로퍼티들을 검색해서 원하는 메서드가 있으면 그 메서드를 실행하고,
없으면 __proto__를 검색해서 실행하는 식으로 진행된다.
var arr = [1,2];
Array.prototype.toString.call(arr); //1,2
Object.prototype.toString.call(arr); //[object Array]
arr.toString();
arr.toString = function () {
return this.join('_');
};
arr.toString(); // 1_2
arr 변수는 배열이므로 arr.__proto__는 Array.prototype은 객체이므로 Array.prototype.__proto__는 Object.prototype 을 참조 할 것이다.
toString 이라는 이름을 가진 메서드는 Array.prototype뿐 아니라 Object.prototype에도 있다.
이 둘 중 어떤 값이 출력되는지를 확인하기 위해 2,3번째 줄에서 Array,Object의 각 프로토타입에 있는 toString 메서드를 arr에 적용했을 때의 출력값을 미리 확인해봤다.
4번째 줄에서 arr.toString을 실행했더니 결과가 Array.prototype.toString을 적용한 것과 동일하다.
6번째 줄에서는 arr에 직접 toString 메서드를 부여했다.
이제 9번째 줄에서는 Array.prototype.toString이 아닌 arr.toString이 바로 실행될 것이다.
자바스크립트 데이터는 두 아래 그림과 같이 동일한 형태의 프로토타입 체인 구조를 지닌다.
이 식의 삼각형들은 오직 instance를 중심으로 __proto__를 따라가는 루트만 표기했는데,
접근 가능한 모든 경우를 표기하면 이런 복잡한 구조가 된다.
각 생성자 함수는 모두 함수이기 때문에 Function 생성자 함수의 prototype과 연결된다.
Function 생성자 함수 역시 함수이므로 다시 Function 생성자 함수의 prototype과 연결된다.
우리는 일반적으로 인스턴스와 직접적인 연관이 있는 삼각형에만 주목하면 된다.
6-2-3 객체 전용 메서드의 예외사항
어떤 생성자 함수이든 prototype은 반드시 객체이기 때문에 Object.prototype이 언제나 프로토타입 체인의 최상단에 존재한다.
따라서 객체에서만 사용할 메서드는 다른 여느 데이터 타입처럼 프로토타입 객체 안에 정의할 수가 없다.
객체에서만 사용할 메서드를 Object.prototype 내부에 정의한다면 다른 데이터타입도 해당 메서드를 사용할 수 있기 때문이다.
Object.prototype.getEntries = function() {
var res = [];
for (var prop in this) {
if (this.hasOwnProPerty(prop)) {
res.push(prop, this[prop]));
}
}
return res;
};
var data = [
['object', { a: 1, b: 2, c: 3}], // [["a",1], ["b",2], ["c",3]]
['number',345], //[]
['string','abc'], //[["0","a"], ["1","b"], ["2","c"]]
['boolean',false], //[]
['func',function () {}] //[]
['array',[1,2,3]] //[["0",1],["1",2],["2",3]]
};
data.forEach(function (datum) {
console.log(datum[1].getEntries());
}));
위에서 객체에서만 사용할 의도로 getEntries 라는 메서드를 만들었다.
각 데이터마다 getEntries를 실행해보니, 모든 데이터가 오류 없이 결과를 반환하고 있다.
어느 데이터 타입이건 거의 무조건 프로토타입 체이닝을 통해 getEntries 메서드에 접근할 수 있기 때문에 그렇게 동작하지 않는 것이다.
이러한 이유로 객체만을 대상으로 동작하는 객체 전용 메서드들은 Object.prototype이 아닌 Object에 스태틱 메서드로 부여할 수 밖에 없다.
또한 생성자 함수인 Object와 인스턴스인 객체 리터럴 사이에는 this를 통한 연결이 불가능하기 때문에 여느 전용 메서드처럼 메서드명 앞의 대상이 곧 this가 되는 방식 대신 this의 사용을 포기하고 대상 인스턴스를 인자로 직접 주입해야 하는 방식으로 구현되어 있다.
객체 한정 메서드들을 Object.prototype이 아닌 Object에 직접 부여할 수 밖에 없었던 이유는 Object.prototype이 참조형 데이터 뿐 아니라 기본형 데이터 조차 __proto__에 반복 접근함으로써 도달할 수 있는 최상위 존재이기 때문이다.
6-2-4 다중 프로토타입 체인
자바스크립트의 기본 내장 데이터 타입들은 모두 프로토타입 체인이 1단계(객체)이거나 2단계 (나머지)로 끝나는 경우만 있었지만
사용자가 새롭게 만드는 경우에는 그 이상도 얼마든지 가능하다.
대각선의 __proto__를 연결해나가기만 하면 무한대로 체인 관계를 이어나갈 수 있다.
대각선의 __proto__를 연결하는 방법은 __proto__가 가리키는 대상, 즉 생성자 함수의 prototype이 연결하고자 하는 상위 생성자 함수의 인스턴스를 바라보게끔 해주면 된다.
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는 Grade의 인스턴스를 바라본다
Grade의 인스턴스는 여러 개의 인자를 받아 각각 순서대로 인덱싱해서 저장하고 length 프로퍼티가 존재하는 등으로
배열의 형태를 지니지만, 배열의 메서드를 사용할 수 없는 유사배열 객체이다.
이 명령에 의해 서로 별개로 분리돼 있던 데이터가 연결되어
아래와 같이 하나의 프로토타입 체인 형태를 띠게 된다.
프로토타입 체인은 반드시 2단계로만 이뤄지는 것이 아니라 무한대의 단계를 생성할 수 있다.