자바스크립트는 프로토타입(prototype)기반 언어이다.
클래스 기반 언어에서는 '상속'을 사용하지만, 프로토타입 기반 언어에서는 어떤 객체를 원형(prototype)으로 삼고 이를 복제(참조)함으로써 상속과 비슷한 효과를 얻는다.
let instance = new Constructor();
이를 바탕으로 좀 더 구체적인 형태로 바꾸면 다음과 같다.
prototype이라는 프로퍼티와 proto라는 프로퍼티가 새로 등장했다. 이 둘의 관계가 프로토타입 개념의 핵심이다. prototype은 객체이다. 당연히 이를 참조하는 Proto도 객체일 것이다. prototype 객체 내부에는 인스턴스가 사용할 메소드를 저장한다. 그러면 인스턴스에도 숨겨진프로퍼티인 proto를 통해 이 메소드들에 접근 할 수 있게 된다!
예를 들어 Person이라는 생성자 함수의 prototype에 getName이라는 지정했다고 가정해보자.
Pesrson.prototype
let Person = function(name) {
this.name = name;
};
Person.prototype.getName = function() {
return this._name;
};
이제 Person의 인스턴스는 proto 프로퍼티를 통해 getName을 호출할 수 있다.
let suzi = new Person('Suzi');
suzi.__proto__.getName(); // undefined
instancedml proto가 constructor의 prototype 프로퍼티를 참조하므로 결국 둘은 같은 객체를 바라본다.
Person.prototype === suzi.__proto__ // true
메소드 호출 결과로 undefined가 나온 것
함수 내부에서 어떤 값을 반환하는지 살펴보자
어떤 함수를 '메소드로서' 호출할 떄는 메소드명 바로 앞의 객체가 곧 this가 된다
즉, thomas.__proto__.getName()
에서 getName 함수 내부에서의 this는 thomas가 아니라, thomas.__proto__
라는 객체가 되는 것이다. 이 객체 내부에는 name 프로퍼티가 없으므로 '찾고자 하는 식별자가 정의돼 있지 않을 떄는 Error 대신 undefined를 반환한다'라는 자바스크립트 규칙에 의해 undefined가 반환된 것이다.
그럼 만약 __proto__
객체에 name 프로퍼티가 있다면?
let suzi = new Person('Suzi');
suzi.__proto__.name = 'SUZI__proto__';
suzi.__proto__.getName(); // SUZI__proto__
호우! 예상대로 SUZIproto가 잘 출력된다!즉 관건은 this이다. this를 인스턴트로 할 수 있다면 좋겠다. 그 방법은 __proto__
없이 인스턴스에서 곧바로 메소드를 쓰는 것이다.
let suzi = new Person('Suzi', 28);
suzi.getName(); // Suzi
let iu = new Person('jieun', 28);
iu.getName(); // Jieun
__proto__
를 빼면 this는 instance가 되는게 맞지만, 이대로 메소드가 호출되고 심지어 원하는 값이 나오는 건 이상하다..! 마치 업무중에 에러를 만나면 '이게 왜 안되지?', 에러없이 잘 실행되면 '이게 왜 되지?'라는 그런 느낌적 느낌이다.
하지만 이것은 정상입니다.
이유는__proto__
가 생략가능한 프로퍼티이기 때문이다.
원래부터 생략 가능하도록 정의가 되어있다. 이 정의를 바탕으로 자바스크립트의 전체 구조가 구성됐다고 해도 과언이 아니다..! 즉, '생략 가능한 프로퍼티'라는 개념은 언어를 만든 브랜든 아이크의 머리에서 나온 것이니 그냥 '그런가보다'하고 넘어가자.
suzi.__proto__.getName
=> suzi(.__proto__).getName
=> suzi.getName
__proto__
를 생략하지 않으면 this는suzi.__proto__
가리키지만, 이를 생략하면 suzi 가리킨다.suzi.__proto__
에 있는 메소드인 getName을 실행하지만 this는 suzi를 바라보게 할 수 있게된 것이다.
이제부터 프로토타입을 보는 순간 위의 그림을 떠올려보자
__proto__
는 constructor의 prototype을 참조한다.조금 더 깊게 들어가보자
자바스크립트는 함수에 자동으로 객체인 prototype 프로퍼티를 생성해 놓는데, 해당 함수를 생성자 함수로서 사용할 경우, 즉 new 연산자와 함께 함수를 호출할 경우, 그로부터 생성된 인스턴스에는 숨겨진 프로퍼티인 __proto__
가 자동으로 생성되며, 이 프로퍼티는 생성자 함수의 prototype프로퍼티를 참조한다.
__proto__
프로퍼티는 생략 가능하도록 구현돼 있기 때문에 생성자 함수의 prototype에 어떤 메소드나 프로퍼티가 있다면 인스턴스에도 마치 자신의 것 처럼 해당 메소드나 프로퍼티에 접근할 수 있게 된다.
let constructor = function(name) {
this.name = name;
};
constructor.prototype.method1 = function() {};
constructor.prototype.property1 = 'constructor prototype property';
let instance = new constructor('인스턴스');
console.dir(constructor)
console.dir(instance)
console.dir(constructor)
출력결과의 첫 줄에는 함수라는 의미의 f와 함수 이름인 constructor, 인자 name이 보인다.
그 내부에는 옅은 색의 arguments, caller, length, name, prototype, proto 등의 프로퍼티들이 보인다.
다시 prototype을 열어보면 예제의 4, 5번째 줄에서 추가한 method1, propery1 등의 값이 짙은 색으로 보이고, constructor, proto 등의 옅은 색으로 보인다.
이런 색상의 차이는 { enumerable: false } 속성이 부여된 프로퍼티인지 여부에 따라 다르다. 짙은 색은 enumerable, 즉 열거 가능한 프로퍼티임을 의미하고, 옅은 색은 innumerable,즉 열거할 수 없는 프로퍼티임을 의미한다. for in 등으로 객체의 프로퍼티 전체에 접근하고자 할 떄 접근 가능 여부를 색상으로 구분지어 표기하는 것!
9번째 줄에서는 instance의 디렉터리 구조를 출력하라고 했다. 그런데 출력 결과에는 constructor가 나오고 있다. 어떤 생성자 함수의 인스턴스는 해당 생성자 함수의 이름을 표기함으로써 해당 함수의 인스턴스임을 표기하고 있다.
constructor를 열어보면 name 프로퍼티가 짙은 색으로 보이고, proto 프로퍼티가 옅은색으로 보인다.
다시 proto를 열어보니 method1, property1, constructor, proto 등이 보이는 것으로 봐서 constructor의 prototype과 동일한 내용으로 구성돼 있음을 확인된다.
let arr = [1, 2];
console.dir(arr);
console.dir(Array);
console.dir(arr);
__proto__
가 옅은 색상으로 표기된다.__proto__
를 열어보니 옅은 색상의 다양한 메소드들이 길게 펼쳐진다. 여기에는 push, pop, shift 등등 우리가 배열에 사용하는 메소드들이 거의 들어있다.
console.dir(Array);
__proto__
와 완전히 동일한 내용으로 구성되어 있다.__proto__
는 Array.prototype을 참조하는데, __proto__
가 생략 가능하도록 설계돼 있기 때문에 인스턴스가 push, pop, forEach 등의 메소드를 마치 자신의 것 처럼 호출할 수 있다.let arr = [1, 2];
arr.forEach(function() {}); // (0)
Array.isAraay(arr); // (0) true
arr.isArray(); // (x) TypeError: arr.isArray is not a function
생성자 함수의 프로퍼티인 prototype 객체 내부에는 constructor라는 프로퍼티가 있다.
인스턴스의 __proto__
객체 내부에도 마찬가지다. 이 프로퍼티는 단어 그대로 원래. 생성자 함수(자기 자신)을 참조한다. 왜??
인스턴스와의 관계에 있어 필요한 정보다. 인스턴스로부터 그 원형이 무엇인지를 알 수 있는 수단이기 때문이다.
let arr = [1, 2];
Array.prototype.constructor == Array // true
arr.__proto__.constructor == Array // true
arr.constructor == Array // true
let arr2 = new arr.constructor(3, 4);
console.log(arr2); // [3, 4]
인스턴스의 __proto__
가 생성자 함수의 prototype프로퍼티를 참조하여 __proto__
가 생략 가능하기 때문에 인스턴트에서 직접 constructor에 접근할 수 있는 수단이 생긴 것이다.
즉 6번째 줄과 같은 명령도 오류 없이 동작하게 된다.
한편 constructor는 읽기 전용 속성이 부여된 예외적인 경우를 제외하고는 값을 바꿀 수 있다.
let NewConstructor = function() {
console.log('this is new constructor!');
};
let dataTypes = [
1, // Number & false
'test', // String & false
true, // Boolean & false
{}, // NewConstructor & false
[], // NewConstructor & false
function () {}, // NewConstructor & false
/test/, // NewConstructor & false
new Number(), // NewConstructor & false
new String(), // NewConstructor & false
new Boolean, // NewConstructor & false
new Object(), // NewConstructor & false
new Array(), // NewConstructor & false
new Function(), // NewConstructor & false
new RegExp(), // NewConstructor & false
new Date(), // NewConstructor & false
new Error() // NewConstructor & false
];
dataTypes.forEach(function(d) {
d.constructor = NewConstructor;
console.log(d.constructor.name, '&', d instanceof NewConstructor);
});
비록 어떤 인스턴스로부터 생성자 정보를 알아내는 유일한 수단인 constructor가 항상 안전하지는 않지만 오히려 그렇기 때문에 클래스 상속을 흉내 내는 등이 가능해진 측면도 있다.
let Person = function(name) {
this.name = name;
};
let p1 = new Person('사람1'); // { name: "사람1" } true
let p1Proto = Object.getPrototypeOf(p1);
let p2 = new Person.prototype.constructor('사람2'); // { name: "사람2" } true
let p3 = new p1Proto.constructor('사람3'); // { name: "사람3" } true
let p4 = new p1.__proto__.constructor('사람4'); // { name: "사람4" } true
let p5 = new p1.constructor('사람5'); // { name: "사람5" } true
[p1, p2, p3, p4, p5].forEach(function(p) {
console.log(p, p instanceof Person);
});
[Constructor]
[instance].__proto__.constructor
[instance].constructor
Object.getPrototypeOf([instance]).constructor
[Constructor].prototype.constructor
[constructor].prototype
[instance].__proto__
[instance]
Object.getPrototypeOf([instance])
let Person = function(name) {
this.name = name;
};
Person.prototype.getName = function() {
return this.name;
};
let iu = new Person('지금');
iu.getName = function() {
return '바로 ' + this.name;
};
console.log(iu.getName()); // 바로 지금
iu.__proto__getName
이 아닌 iu 객체에 있는 getName 메소드가 호출됐다. 당연한 결과인 것 같지만 혼란스러울 수 있다. 여기서 일어난 현상을 메소드 오버라이드라고 한다.
메소드 위에 메소드를 덮어씌웠다는 표현! 원본을 제거하고 다른 대상으로 교체하는 것이 아니라 원본이 그대로 있는 상태에서 다른 대상을 그 위에 얹는 이미지를 떠올리면 정확하다.
자바스크립트 엔진이 getName이라는 메소드를 찾는 방식은
1. 가장 가까운 대상인 자신의 프로퍼티를 검색하고,
2. 없으면 그 다음으로 가까운 대상인 __proto__
를 검색하는 순서로 진행된다.
즉, __proto__
에 있는 메소드는 자신에게 있는 메소드보다 검색 순서에서 밀려 호출되지 않는 것이다. 앞 문단에서 '교체'가 아니라 '얹는' 이미지라고 언급했는데, 이 둘을 구분할 필요가 있다!
교체하는 형태라면 원본에는 접근할 수 없는 형태가 되겠지만 얹는 형태라면 원본이 아래에 유지되고 있으니 원본에 접근할 수 있는 방법도 있다. 그렇다면 메소드 오버라이딩이 이뤄져 있는 상황에서 prototype에 있는 메소드에 접근하려면 어떻게 해야할까?
console.log(iu.__proto__.getName()); // undefined
iu.__proto__.getName
을 호출했더니 undefiend가 출력됐다. this가 prototype 객체(iu.__proto__
)를 가리키는데 prototype 상에는 name 프로퍼티가 없기 때문이다. 만약 prototype에 name 프로퍼티가 있다면 그 값을 출력할 것이다.
Person.prototype.name = '이지금';
console.log(ui.__proto__.getName()); // 이지금
원하는 메소드(prototype에 있는 getName)가 호출되고 있다는게 학계의 정설이 되었다.
다만 this가 Prototype을 바라보고 있는데 이걸 인스턴스를 바라보도록 바꿔주면 된다. call이나 apply로 해결이 가능하다.
console.log(iu.__proto__.getName.call(iu)); // 지금
일반적으로 메소드가 오버라이드된 경우에는 자신으로부터 가장 가까운 메소드에만 접근할 수 있지만, 그 다음으로 가까운 __proto__
의 메소드도 우회적인 방법을 통해서 접근이 가능하다.
객체의 내부 구조를 한번 살펴보자
console.dir({a: 1});
__proto__
의 내부에는 hasOwnProperty, isPrototypeOf 등의 메소드가 보인다.이번에는 다시 한 번 배열의 구조를 살펴보자.
__proto__
안에 다시 __proto__
가 있다. 뭐지?
바로 prototype 객체가 '객체'이기 때문이다! 기본적으로 모든 __proto__
에는 Object.prototype이 연결된다. prototype객체도 예외가 아니다.
__proto__
는 생략 가능하다!
그렇기 때문에 배열이 Array.prototype 내부의 메소드를 마치 자신의 것처럼 실행할 수 있다. 마찬가지로 Object.prototype 내부의 메소드도 자신의 것 처럼 실행할 수 있다.
let arr = [1, 2];
arr(.__proto__).push(3);
arr(.__proto__)(.__proto__).hasOwnProperty(2); // true
어떤 데이터의
__proto__
프로퍼티 내부에 다시__proto__
프로퍼티가 연쇄적으로 이어진 것을 프로토타입 체인이라고 하고, 이 체인을 따라가며 검색하는 것을 프로토타입 체이닝이라고 한다.
프로토타입 체이닝은 메소드 오버라이드와 동일한 맥락이다. 어떤 메소드를 호출하면 자바스크립트 엔진은 데이터 자신의 프로퍼티들을 검색해서 원하는 메소드가 있으면 그 메소드를 실행하고, 없으면 __proto__
를 검색해서 있으면 그 메소드를 실행하고, 없으면 다시 __proto__
를 검색해서 실행하는 식으로 진행한다.
let arr = [1, 2];
Array.prototype.toString.call(arr); // 1, 2
Object.prototype.toString.call(arr); // [object Array]
arr.toString(); // 1, 2
arr.toString = function() {
return this.join('_');
};
arr.toString(); // 1_2
arr.__proto__
는 Array.prototype을 참조하고, Array.prototype은 객체이므로 Array.prototype.__proto__
는 Object.prototype을 참조할 것이다. toString이라는 이름을 가진 메소드는 Array.prototype 뿐 아니라 Object.prototype에도 있다.배열만이 아니라, 자바스크립트 데이터는 모두 아래와 같은 형태의 프로토타입 체인 구조를 가지고 있다.
어떤 생성자 함수이든 prototype은 반드시 객체이기 때문에 Object.prototype이 언제나 프로토타입 체인의 최상단에 존재하게 된다. 따라서 객체에서만 사용할 메소드는 다른 여느 데이터 타입처럼 프로토타입 객체 안에서 정의할 수 없다. 객체에서만 사용할 메소드를 Object.prototype 내부에 정의한다면 다른 데이터타입도 해당 메소드를 사용할 수 있게 되기 때문이다.
Object.prototype에 추가한 메소드에의 접근
Object.prototype.getEntries = function() {
let res = [];
for (let prop in this) {
if (this.hasOwnProperty(prop)) {
res.push([prop, this[prop]]);
}
}
return res;
};
let 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())
});
ex)
Object.freeze(instance)의 경우 instance.freeze()처럼 표현할 수 있었을 것
즉instance.__proto__
에 freeze라는 메소드가 있었을 것
Ojbect.getPrototypeOf(instance)의 경우에도 instance.getPrototype() 정도로 충분했을 것이다.
객체 한정 메소드들을 Object.prototype이 아닌 Object에 직접 부여할 수 밖에 없었던 이유
Object.prototype이 여타 참조형 데이터 뿐 아니라 기본형 데이터조차
__proto__
에 반복 접근함으로써 도달할 수 있는 최상위 존재이기 때문이다.
반대로 같은 이유에서 Object.prototype에는 어떤 데이터에서도 활용할 수 있는 범용적인 메소드들만 있다. toString, hasOwnProperty, valueOf, isPrototypeOf 등은 모든 변수가 마치 자신의 메소드인 것 처럼 호출할 수 있다!
'프로토타입 체인상 가장 마지막에는 언제나 Object.prototype이 있다'라고 했는데, 예외적으로 Object.create를 이용하면 Object.prototype에 접근할 수 없는 경우가 있다.
Object.create(null)
은__proto__
가 없는 객체를 생성한다.let _proto = Object.create(null); _proto.getValue = function(key) { return this[key]; }; let obj = Object.create(_proto); obj.a = 1; console.log(obj.getValue('a')); // 1 console.dir(obj);
_proto
에는 ```proto프로퍼티가 없는 객체를 할당했다.- 다시 obj는 앞서 만든
_proto
를__proto__
로 하는 객체를 할당했다.- obj를 출력해보면,
__proto__
에는 오직 getValue 메소드만 존재하며,__proto__
및 constructor프로퍼티 등은 보이지 않는다.- 이 방식으로 만든 객체는 일반적인 데이터에서 반드시 존재하던 내장 메소드 및 프로퍼티들이 제거됨으로써 기본 기능 제약이 생긴 대신, 객체 자체의 무게가 가벼워짐으로써 성능상 이점을 가진다.
자바스크립트의 기본 내장 데이터 타입들은 모두 프로토타입 체인이 1단계(객체)이거나 2단계(나머지)로 끝나는 경우만 있었지만 사용자가 새롭게 만드는 경우에는 그 이상도 얼마든지 가능하다. 대각선의 __proto__
를 연결해나가기만 하면 무한대로 체인 관계를 이어나갈 수 있다. 이 방법으로부터 다른 언어의 클래스와 비슷하게 동작하는 구조를 만들 수 있다.
대각선의 __proto__
를 연결하는 방법은 __proto__
가 가리키는 대상, 즉 생성자 함수의 prototype이 연결하고자 하는 상위 생성자 함수의 인스턴스를 바라보게 해주면 된다.
let Grade = function() {
let args = Array.prototype.slice.call(arguments);
for(let i = 0; i < args.length; i++) {
this[i] = args[i];
}
this.length = args.length;
};
let g = new Grade(100, 80);
유사배열객체에 배열 메소드를 적용하는 방법으로 call/apply가 있지만, 기왕 생성자 함수를 직접 만든 김에 인스턴스에서 배열 메소드를 직접 쓸 수 있게끔 해보자. g.__proto__
즉, Grade.prototype이 배열의 인스턴스를 바라보게 해보자.
console.log(g); // Grade(2) [100, 80]
g.pop()
console.log(g) // Grade(1) [100]
g.push(90)
console.log(g) // Grade(2) [100, 90]
-g 인스턴스의 입장에서는 프로토타입 체인에 따라 g 객체 자신이 지니는 멤버, Grade의 prototype에 있는 멤버, Array.prototype에 있는 멤버, 끝으로 Object.prototype에 있는 멤버까지 접근할 수 있게 되었다.
__proto__
라는, constructor의 prototype프로퍼티를 참조하는 프로퍼티가 자동으로 부여된다. __proto__
는 생략 가능한 속성이라, 인스턴스는 constructor.prototype의 메소드를 마치 자신의 메소드 인 것 처럼 호출할 수 있다.__proto__
방향을 계속 찾아가면 최종적으로 Object.prototype에 도착한다. 이런식으로 __proto__
안에 다시 __proto__
를 찾아가는 과정을 프로토타입 체이닝이라고 하며, 이 프로토타입 체이닝을 통해 각 프로토타입 메소드를 자신의 것처럼 호출할 수 있다. 이때 접근 방식은 자신으로부터 가장 가까운 대상부터 점차 먼 대상으로 나아가며, 원하는 값을 찾으면 검색을 중단한다.