프로토타입(prototype) 기반 언어에선 클래스를 기반으로 하여 '상속'을 사용하는 클래스 기반 언어와는 달리 프로토타입 기반 언어에서는 객체를 원형(prototype)으로 삼고 이를 복제(참조)한으로써 상속과 비슷한 효과를 얻습니다.
var Person = function (name) {
this._name = name;
};
Person.prototype.getName = function () {
return this._name;
};
Constructor
를 new
연산자로 호출하여 instance
를 생성하였습니다. 해당 instance
에서 기존 Constructor
의 정보들을 호출하기 위해선
var jungsoo = new Person('Jungsoo');
jungsoo.__proto__.getName(); // undefined
여기서 호출값이 undefined
인 것은 중요하지 않습니다. getName is not defined
라는 오류가 나지 않은 것, 즉 실행 가능한 메소드라는 사실이 중요합니다.
Person.prototype === jungsoo.__proto__ // true
그럼 호출값은 왜 undefined
가 나왔을까요? 그 이유는 this
에게 있습니다. jungsoo.__proto__._name
의 this
는 jungsoo.__proto__
이지만 new
연산자로 호출한 생성자의 인자의 this
는 jungsoo
이기 때문에 값이 없었던 것입니다.
jungsoo.__proto__._name = 'Jungsoo__proto__';
jungsoo.__proto__.getName(); // 'Jungsoo__proto__'
물론 위와 같이 값을 직접 지정해줄 수 있지만 우리들은 instance
생성과 함께 지정한 name
을 호출하기를 원합니다. 그럼 어떻게 해야 할까요? 놀랍게도 __proto__
는 생략 가능하도록 설계된 프로퍼티입니다. 그럼 아래와 같이 name
을 호출할 수 있습니다.
jungsoo.getName(); // 'Jungsoo' (__proro__ 생략 가능)
조금 더 상세하게 설명하면
prototype
프로퍼티를 생성합니다.__proto__
가 자동으로 생성됩니다.prototype
를 참조하기 때문에 어떤 메서드나 프로퍼티가 있다면 인스턴스에서도 마치 자신의 것처럼 접근할 수 있게 됩니다.var Constructor = function (name) {
this.name = name;
};
Constructor.prototype.method1 = function () {};
Constructor.prototype.property1 = 'Constructor Prototype Property';
var instance = new Constructor('instance');
console.dir(Constructor);
console.dir(instance);
출력된 코드를 확인해보면 Constructor
의 prototype
구조가 instance
의 __proto__
구조와 동일한 것을 확인할 수 있습니다. 또한 instance
의 출력 결과에선 Constructor
을 출력함으로 해당 객체가 어떤 함수의 인스턴스인지 알려주고 있습니다.
콘솔 창의 비밀 - 색상 편
콘솔 창에서 프로퍼티를 살펴보면 짙은 색과 옅은 색의 차이가 존재합니다. 이는
enumerable
속성 차이이며 열거 가능한 프로퍼티, 즉true
값을 가질 때는 짙은 색,false
값을 가질 때는 옅은 색으로 표시됩니다.
예제를 하나 더 살펴보겠습니다.
var arr = [1, 2];
console.dir(Array);
console.dir(arr);
결과를 확인해보기 전에 var arr = new Array(1, 2);
라는 생성자를 이용한 것이 아니라 리터럴 방식의 변수 선언 방식으로 데이터를 할당하였습니다. 이 둘의 차이는 추후 별도의 게시글을 작성하여 설명하도록 하겠습니다.
그럼 다시 돌아와서 둘의 코드를 비교해보면 이전과 동일하게 Array
의 prototype
과 배열 리터럴로 생성된 인스턴스인 [1, 2]
의 __proto__
는 완벽하게 일치합니다. 다만 prototype
내부에 있지 않은 from
, isArray
등의 메서드들은 인스턴스가 직접 호출할 수 없습니다.
var arr = [1, 2];
arr.forEach(function (){});
Array.isArray(arr); // true
arr.isArray(); // arr.isArray is not a function
// 이해가 되지 않는 부분...
// 직접 method를 지정하는 것과 차이가 무엇인가? 왜 굳이 prototype을 지정해서 해야하나?
// 생성자 전용 함수(상속되지 않는 함수, 정적 메서드)는 어떻게 지정하는 것인가?
// 우선 넘겨보자
var Constructor = function (name) {
this.name = name;
this.method1 = function(){ console.log(this); };
};
Constructor.prototype.method1 = function(){ console.log(this); };
var test = new Constructor('hihi');
test.__proto__.method1()
test.method1()
console.dir(Constructor);
console.dir(test);
위 결과들을 확인해보면 생성자 함수의 프로퍼티인 prototype
객체 내부에 constructor
라는 프로퍼티가 있습니다. 인스턴스의 __proto__
객체 내부에도 동일하겠죠. 이 프로퍼티는 단어 그대로 원래의 생성자 함수(자기 자신)를 참조합니다. 이 덕분에 인스턴스로부터 그 원형이 무엇인지 알 수 있습니다.
constructor
는 읽기 전용 속성이 부여된 예외적인 경우(기본형 리터럴 변수 - number, string, boolean)를 제외하고는 값을 변경할 수 있습니다.
var NewConstructor = function () {
console.log('NEW!!');
};
var dataTypes = [
1, // Number & false
'test', // String & false
true, // Boolean & false
{}, [], function () {}, new Number(), new String(), ... // NewConstructor & false
];
dataTypes.forEach(function (d) {
d.constructor = NewConstructor;
console.log(d.constructor.name, '&', d instanceOf NewConstructor);
});
모든 데이터가 d instanceOf NewConstructor
에 대해 false
값을 반환합니다. 이로 인해 이미 만들어진 인스턴스의 원형이나 데이터 타입이 변하는 것은 아닙니다. 인스턴스의 생성자 정보를 알아내기 위해 constuctor 프로퍼티에 의존하는 것이 항상 안전하진 않은 것입니다.
하지만 오히려 그렇기 때문에 클래스 상속을 흉내 내는 등이 가능해진 측면도 있습니다. 이 역시 추후 관련 게시글을 추가로 작성하며 다루겠습니다.
var Person = function (name) {
this.name = name;
this.getName = function () {
return '바로 ' + this.name;
};
};
Person.prototype.getName = function () {
return this.name;
};
var iu = new Person('지금');
console.log(iu.getName()); // 바로 지금
Javascript Engine이 getName
이라는 메서드를 찾는 방식은 가장 가까운 대상인 자신의 프로퍼티를 검색하고, 없으면 그다음으로 가까운 대상인 __proto__
를 검색하는 순서로 진행됩니다. 즉, 2개의 메서드는 교체되는 것이 아니라 얹어지는 것이며 정의하는 순서에 상관없이 무조건 자신의 프로퍼티에 해당 메서드가 있다면 해당 메서드를 실행시킵니다.
또한 얹어지는 것이므로 prototype
에 있는 메서드에 접근하는 것도 가능합니다.
console.log(iu.__proto__.getName()); // undefined
iu.__proto__.getName()
에서의 this
는 iu.__proto__
인데 해당 객체의 prototype
상에서는 name
프로퍼티가 없기 때문에 undefined
가 출려되었습니다.
Person.prototype.name = '이지금';
console.log(iu.__proto__.getName()); // 이지금
생성자 함수의 prototype
의 name
을 변경해주니 잘 호출되는 모습을 확인할 수 있습니다. 잘 참조하고 있군요. this
가 prototype
을 바라보고 있는데 이것만 잘 변경해주면 인스턴스의 name
을 불러올 수 있습니다.
console.log(iu.__proto__.getName.call(iu)); // 지금
객체의 내부 구조를 우선 살펴봅시다.
console.dir({ a: 1 });
// Object
// a: 1
// __proto__:
// constructor: f Object()
// ...
// (Object의 prototype)
// ...
console.dir([1, 2]);
// Array(2)
// 0: 1
// 1: 2
// __proto__: Array(0)
// concat: f concat()
// ...
// (Array의 prototype)
// ...
// __proto__:
// constructor: f Object()
// ...
// (Object의 prototype)
// ...
위 코드의 구조를 확인해보면 배열 리터럴의 __proto__
안에 또다시 __proto__
가 등장합니다. 구조를 보아하니 Object
의 __proto__
와 동일한 내용으로 이뤄져 있네요. 이는 prototype
또한 객체이기 때문입니다. 그렇기에 기본적으로 prototype
을 포함한 모든 객체의 __proto__
에는 Object.prototype
이 연결됩니다.
console.dir([1, 2].__proto__.constructor) // f Array()
console.dir([1, 2].__proto__.__proto__.constructor) // f Object()
이렇듯 어떤 데이터의 __proto__
프로퍼티 내부에 다시 __proto__
프로퍼티가 연쇄적으로 이어진 것을 프로토타입 체인(prototype chain)이라고 하며 이 체인을 따라 검색하는 것을 프로토타입 체이닝(prototype chaining)이라고 합니다.
프로토타입 체이닝 또한 위 메서드 오버라이드의 getName
의 예시와 동일하게 거리가 가까울 수록 우선순위가 높습니다.
더 나아가...
모든
prototype
은Object
의 인스턴스이고Array
는Function
의 인스턴스입니다.Function
은 또 다른Function
의 인스턴스이죠. 이런 식으로__proto__
의constructor
의__proto__
의constructor
를 재귀적으로 끝없이 찾아갈 수 있습니다. 하지만 결국은 같은Function
을 가리키고 있기 때문에 그렇게 파고들 이유는 없습니다.실제 메모리 상에서 데이터를 무한대의 구조 전체를 들고 있는 것은 아닙니다. 다만 사용자가 이런 루트를 통해 접근하고자 할 때 비로소 해당 정보를 얻을 수 있을 뿐입니다.
객체만을 대상으로 동작하는 객체 전용 메서드들은 부득이 Object.prototype
이 아닌 Object
에 정적 메서드(static method)로 부여할 수 밖에 없었습니다. 또한 생성자 함수와 인스턴스 사이에는 this
를 통한 연결이 불가능하기 때문에 여느 전용 메서드처럼 "메서드명 앞의 대상이 곧 this
"가 되는 방식 대신 this
의 사용을 포기하고 대상 인스턴스를 인자로 직접 주입해야 하는 방식으로 구현돼 있습니다.
그 이유는 Object.prototype
이 참조형 데이터뿐 아니라 기본형 데이터조차 __proto__
에 반복 접근함으로써 도달할 수 있는 최상위 존재이기 때문입니다.
반대로 Object.prototype
에는 어떤 데이터에서도 활용할 수 있는 범용적인 메서드들만 있습니다.
prototype
에 접근할 수 없는 Object
Object.create(null)
을 이용하여 객체를 생성할 경우constructor
등과 같은 프로퍼티가 참조되지 않습니다. 기본 기능에 제약이 생긴 대신, 객체 자체의 무게가 구벼워짐으로써 성능상 이점을 가집니다.
Javascript 기본 내장 데이터 타입들은 모두 프로토타입 체인인 1단계이거나 2단계로 끝나는 경우만 있었지만 그 이상을 만든 것도 얼마든지 가능합니다.
대각선의 __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 = arg.length;
};
var g = new Grade(100, 80);
Grade
의 인스턴스는 여러 개의 인자를 받아 각각 순서대로 인덱싱해서 저장하고 length
프로퍼티가 존재하는 등으로 배열의 형태를 지니지만, 배열의 메서드를 사용할 수 없는 유사배열객체입니다. 그럼 어떻게 인스턴스에서 배열 메서드를 직접 쓸 수 있게끔 할 수 있을까요?
Grade.prototype = [];
생각보다 단순합니다. Grade.prototype
이 배열 인스턴스를 바라보게 할당해주면 됩니다.