[JS] 프로토타입
프로토타입이란?
프로토 타입이란 instance의 원형이 되는 객체로 이를 참조함으로써 상속과 비슷한 효과를 얻습니다. 아래 코드를 보면 Constructor라는 생성자 함수에 new 연산자를 붙여 instance를 만들고 있습니다.
var instance = new Constructor();
이 때 instance엔 __proto__
라는 프로퍼티가 자동으로 부여되는데, 이 프로퍼티는 Constructor.prototype 객체를 참조합니다.
즉, prototype 객체엔 인스턴스가 사용할 메서드를 저장하고, instance는 __proto__
속성을 통해 메서드에 접근할 수 있습니다. 만약 Fruit이란 생성자 함수의 prototype에 getPrice라는 메서드가 있고 인스턴스 apple에서 해당 메소드를 사용한다면 __proto__
속성을 통해 getPrice를 호출할 수 있습니다.
function Fruit(price) {
this.price = price;
}
Fruit.prototype.getPrice = function () {
return this.price;
};
const apple = new Fruit(1000);
Fruit.prototype === apple.__proto__; // true
const applePrice = apple.__proto__.getPrice();
그럼 이제 applePrice를 콘솔창에 찍어보면 1000이 나올까요? 답은 ‘아니오’입니다. 이유는 단순합니다. getPrice는 메서드고 메서드의 this는 자신을 호출한 대상입니다. 여기선 apple이 아니라 apple.__proto__
가 호출을 했는데 apple.__proto__
엔 price 값이 없기에 undefined가 뜹니다. 만약 이부분을 수정한다면 apple.__proto__
에 직접 price값을 넣어줄 수도 있겠지만 그보다 __proto__
부분을 지우고 직접 인스턴스에서 메서드를 호출하는 게 간단합니다.
const applePrice = apple.getPrice();
이게 가능한 이유는 __proto__
가 생략가능한 프로퍼티이기 때문입니다.
덕분에, 인스턴스가 마치 자신의 속성인 것처럼 생성자함수의 메서드와 프로퍼티에 접근할 수 있습니다.
주의 : __proto__
직접 접근 지양
ES5 명세에는 아예 instance에서 직접 __proto__
에 접근을 허용하지 않는다고 쓰여있습니다. 사실 ES5는 __proto__
가 아니라 [[prototype]]이라는 명칭을 썼고, __proto__
는 브라우저가 [[prototype]]를 구현한 대상에 불과했습니다. 하지만 호환성을 고려해서 ES6부터 __proto__
에 직접 접근하는 방식을 지원하긴 합니다. 다만 브라우저 한정이고 다른 환경에선 어떨지 알 수 없어 권장하지 않습니다. 대신 Object.getPrototypeOf()/Object.create()
를 사용하는 게 좋습니다.
생성자 함수와 프로토타입의 파일 구조
그렇다면 생성자 함수, 프로토 타입, 인스턴스의 파일 구조는 어떻게 돼 있을까요?
function Constructor(name) {
this.name = name;
}
Constructor.prototype.getName = function () {
return this.name;
};
Constructor.prototype.property = "PrototypeProperty";
const instance = new Constructor("instance");
console.dir(Constructor);
console.dir(instance);
먼저 Constructor의 파일 구조입니다.
Constructor 함수의 prototype엔 직접 입력해준 getName 메서드, property 속성이 들어가 있습니다. 그리고 instance의 파일 안 [[prototype]]을 열어보면 Constructor의 prototype 값들이 들어 있는 걸 알 수 있습니다.
[[prototype]]은 __proto__
로 접근이 가능한 내부 슬롯으로 마치 class의 상속과 같은 느낌을 줍니다.
또 instance가 Constructor라고 출력되는데 이는 어떤 생성자 함수로 만들어졌는지 알려주는 역할을 합니다.
또 다른 예) Array 함수와 인스턴스
배열 arr과, Array 함수의 파일구조를 출력하면 아래와 같습니다.
const arr = [1, 2];
console.dir(arr);
console.dir(Array);
arr는 Array(2)라고 표시되는데, 이는 리터럴 방식도 내부적으로는 생성자함수를 사용해서 배열을 만들기 때문입니다. 즉, new Array(1,2)를 사용해 만든 인스턴스고 length가 2임을 알려줍니다.
[[prototype]]을 열어보면 생성자 함수의 prototype이 들어있습니다. Array의 메서드들을 볼 수 있는데, 덕분에 배열 인스턴스에서 바로 메서드에 접근할 수 있습니다.
한편 Array 함수를 찍어보면, 다음과 같은 결과를 볼 수 있습니다.
Array 함수의 원형객체 prototype엔 아까 arr의 __proto__
로 접근 가능한 슬롯[[Prototype]]에서 봤던 내용이 들어있습니다. 그런데 빨간 색 부분에도 메서드가 있습니다. 이 메서드는 prototype 외부에 있기에 __proto__
로 접근할 수 없습니다. 따라서 아래 코드는 가능하지만
arr.every((element) => element === 1); // false
아래코드는 불가능합니다.
arr.isArray(); //error
만약 prototype에 정의되지 않은 Array의 메서드를 사용하고 싶다면 인스턴스가 아니라 Array 함수를 사용해 접근해야 합니다.
Array.isArray(arr);
constructor 프로퍼티
생성자 함수의 prototype 안에는 constructor 프로퍼티가 있습니다.
이는 인스턴스의 __proto__
로 접근 가능한 [[Prototype]] 슬롯에도 있는 속성으로 생성자 함수 자신을 참조하고 있습니다.
자신의 정보를 뭐하러 prototype에 넣어두는 걸까요?
클래스 상속 흉내
인스턴스는 constructor를 사용해서 생성자 정보를 알아냄
이 부분은 다음주 자바스크립트 클래스를 배울 때 자세히 알아볼 예정이므로 간단히 짚고 넘어가겠습니다. 일단 아래 코드를 보면 __proto__
를 생략할 수 있기에 인스턴스에서 바로 constructor를 사용해 또 다른 인스턴스를 생성하고 있습니다.
function Constructor(name) {
this.name = name;
}
Constructor.prototype.property = "프로퍼티";
const instance = new Constructor("instance");
const instance2 = new instance.constructor("instance2");
console.dir(instance);
console.dir(instance2);
그리고 파일 구조를 찍어보면 instance.constructor로 만든 instance2에도 Constructor 함수의 prototype이 잘 담긴 것을 볼 수 있습니다.
또, constructor에 읽기전용 속성이 부여된( 기본형 리터럴 변수 number, string, boolean)의 경우를 제외하고 값을 바꿀 수도 있습니다.
function Constructor() {
console.log("Constructor");
}
const data = [
1, // 읽기전용 Number
"string", // 읽기전용 String
true, // 읽기전용 Boolean
{},
[],
function () {},
/regex/,
new Number(),
new String(),
new Boolean(),
new Object(),
new Array(),
new Function(),
new RegExp(),
new Date(),
new Error(),
];
data.forEach((element) => {
element.constructor = Constructor;
console.log(element.constructor.name, "&", element instanceof Constructor);
});
위 코드의 출력 결과를 보면 읽기 전용인 리터럴 기본형 변수는 생성자가 변동이 없지만 나머지는 element.constructor = Constructor
의 영향으로 Constructor가 출력됩니다. 하지만 constructor만 바꿨을 뿐 인스턴스의 원형을 바꾼 건 아니기 때문에 element instanceof Constructor
는 전부 false입니다.
Constructor 다양한 표현
위 사실과 아까 __proto__
대신 Object.getPrototypeOf()를 사용해야 한다는 말을 기억하신다면 다음 표현이 모두 Constructor함수라는 걸 알 수 있습니다.
[Constructor];
[instance].__proto__.constructor;
[instance].constructor;
Object.getPrototypeOf([instance]).constructor;
[Constructor].prototype.constructor;
그리고 아래는 모두 프로토타입을 가리킵니다.
[Constructor].prototype;
[instance].__proto__;
[instance];
Object.getPrototypeOf([instance]);
메서드 오버라이딩
메서드 위에 메서드 덮어씌우기
아래 코드를 보면 생성자함수도, 인스턴스도 같은 이름 getName이란 메서드를 갖고 있습니다. instance.__proto__.getName
생성자 함수의 getName을 뜻하지만 __proto__
가 생략된 instance.getName
은 어떨까요?
function Constructor(name) {
this.name = name;
}
Constructor.prototype.getName = function () {
return this.name + " 생성자가 반환";
};
const instance = new Constructor("이름");
instance.getName = function () {
return this.name + " 인스턴스가 반환";
};
Constructor.prototype.name = "이름";
console.log(instance.__proto__.getName()); // 생성자함수 메서드
console.log(instance.getName());
전엔 instance.getName
가 생성자의 getName을 가리켰지만 이번엔 인스턴스의 getName을 가리킵니다. 이를 메서드 오버라이딩이라고 합니다. 원본을 제거하고 다른 대상으로 교체하는 게 아니라 원본이 존재하는 상태에서 다른 대상으로 덮어씌운다는 뜻입니다. 그렇다면 오버라이딩이 일어난 후 프로토타입의 메서드에 접근하려면 어떻게 할까요
Constructor.prototype.name = "이름";
console.log(instance.__proto__.getName()); // 생성자함수 메서드
일단 메서드를 호출하는 __proto__
의 this에 name을 할당하고, __proto__
로 직접 프로토타입의 메서드를 호출하는 방법이 있습니다. 이 방법의 경우 인스턴스의 this.name이 아니기 때문에 this를 활용하고자 한다면 직접 함수의 프로토타입에 name을 주입하는 대신 아래처럼 call함수에 인스턴스를 넣어주면 됩니다.
console.log(instance.__proto__.getName.call(instance));
프로토타입 체인
__proto__
로 상위 프로토타입 거슬러 올라가기
const arr = [1, 2];
console.dir(arr);
배열의 파일 구조를 보면 아래처럼 [[Prototype]] 안에 또 [[Prototype]]이 있습니다.
첫 번째 프로토타입은 Array의 prototype으로 Array의 메서드가 담겨있습니다. 그리고 그 안의 [[Prototype]]은 Object의 prototype, 메서드가 있습니다.
기본적으로 모든 객체의 __proto__
엔 Object.prototype이 연결됩니다. __proto__
은 생략 가능하기에 __proto__.__proto__
또한 생략 가능합니다. 이런식으로 상위 프로토타입까지 참조할 수 있는데 이를 프로토타입 체이닝이라고 합니다.
해당 스코프에 변수가 없다면 상위 스코프의 값을 참조하는 스코프체이닝처럼 프로토타입 체이닝은 instance.메서드명
이런식으로 썼을 때 메서드가 인스턴스에 존재하지 않는다면 프로토타입을 돌면서 가장 가까운 프로토타입의 메서드를 참조합니다.
프로토타입 체이닝 연결 경로 여러개, 메모리는?
연결경로 여러개라도 메모리 차지는 함수 하나
이전에 언급했듯 프로토타입에 접근하는 방법은 어러개입니다. constructor의 prototype으로 접근,prototype으로 접근,.. 등 많은 방법이 있어서 참조해야 할 대상이 많아 보입니다. console.dir을 찍어봤을 때도 [[Prototype]]안에 재귀적으로 프로토타입이 반복돼 나타납니다. 그러나 같은 함수를 가리키고 있기에 메모리 낭비가 되지 않습니다.
객체 전용 메서드의 예외 사항
프로토타입 체이닝으로 상단 프로토타입을 거슬러 올라가다 보면 언제나 최상단엔 Object의 프로토타입이 위치하게 됩니다. Object.create(null)로 생성한 객체를 제외하면 말입니다. 따라서 객체에만 사용할 메서드를 그대로 프로토타입에 넣어버리면 다른 데이터 타입도 객체의 메서드를 사용해버리는 문제가 발생합니다.
Object.prototype.getEntries = function () {
var res = {};
for (var prop in this) {
if (this.hasOwnProperty(prop)) {
res.push([prop, this[prop]]);
}
}
return res;
};
예로 위 getEntries 메서드는 객체에서만 사용할 용도로 만들어 졌습니다. 하지만 아래 다른 데이터 타입 역시 프로토타입 체이닝을 통해 getEntries메서드를 사용할 수 있습니다.
var data = [
["Object", { a: 1, b: 2 }], // [["a",1],["b",2]]
["number", 1], // []
["string", "ab"], // [["0","a"],["1","b"]]
["boolean", false], //[]
["func", function () {}], //[]
["Array", [1, 2]], // [["0",1],["1",2]]
];
data.forEach(function (datum) {
console.log(datum[1].getEntries());
});
이런 이유로 객체 전용 메서드들은 Object.prototype이 아닌 Object의 static 메서드로 부여할 수밖에 없습니다. 또 생성자 함수인 Object와 인스턴스인 객체 리터럴 사이에 this를 통한 연결이 불가능하기에 메서드의 호출 주체인 인스턴스가 곧 메서드의 this가 되는 방식 대신 인스턴스 인자로 받아 사용합니다.
const obj = { a: 1, b: 2 };
console.log(obj.keys()); //Error
console.log(Object.keys(obj)); //[a,b]
그래서 위의 경우 인스턴스 obj는 keys() 메서드를 호출할 수 없고 Object.keys()의 인자로 들어가야 제대로 keys()메서드가 실행됩니다.
다중 프로토타입 체인
사용자가 새롭게 만든 경우
기본 내장 데이터 타입들은 프로토타입 체인이 최대 2단계지만 사용자가 더 추가한다면 그 이상도 가능합니다. 이를 통해 무한대 체인 관계를 이어나갈 수 있습니다. 예로 다음 코드를 보면 Grade는 인자를 받아 인덱싱해서 저장하고 length 프로퍼티가 있긴하지만 배열의 메서드느 사용할 수 없는 유사배열 객체입니다.
const Grade = function () {
const args = Array.prototype.slice.call(arguments);
for (let i = 0; i < args.length; i++) {
this[i] = args[i];
}
this.length = args.length;
};
const grade = new Grade(100, 80);
call,apply를 사용해서 배열의 메서드를 적용할 수도 있지만 인스턴스가 직접 배열 메서드를 쓸 수 있게 하려면 어떻게 해야 할까요?
Grade.prototype = [];
답은 간단합니다. Grade의 prototype이 Array면 됩니다. 그러면 아래 와같았던 프로토타입 체인에 Array 함수가 추가됩니다.
그리고 기존 Grade.prototype은 Array의 인스턴스가 되서 Array의 메서드에 접근할 수 있습니다.
grade.push(90);
console.log(grade); // [100,80,90]
grade.shift();
console.log(grade); // [80,90]
퀴즈
1. 인스턴스와 생성자의 프로토타입에 같은 이름의 메서드가 있다면 프로토타입의 메서드는 사용할 수 없다. (o,x)
2. 콘솔의 출력 결과는?
function Constructor(type) {
this.type = type;
}
const data = ["string", {}, /regex/, new Number()];
data.forEach((element) => {
element.constructor = Constructor;
console.log(element.constructor.name);
});
댓글남기기