JavaScript 키워드 프로토타입 Part 3
📝 이전 글 간단 정리
- 리터럴 표기법의 생성자 함수와 프로토타입
- 생성자 함수의 경우 프로토타입
- 인스턴스의 __proto__ 접근자 프로퍼티를 통해서 프로토타입 객체에 접근 가능
- 프로토타입 객체의 constructor 프로퍼티를 통해서 생성자 함수에 접근 가능
- 생성자 함수의 prototype 프로퍼티를 통해서 프로토타입 객체에 접근 가능 -리터럴 표기법의 경우 프로토타입
- OrdinaryObjectCreate를 호출하여 빈 객체를 생성하고 프로퍼티를 추가한다.
- 이때 추상연산 OrdinaryObjectCreate의 인수로 프로토타입을 주입한다.
- 리터럴 표기법에 의해 생성된 객체도 가상적인 생성자 함수를 가진다.
- 프로토타입은 생성자 함수와 더불어 생성되며 prototype, constructor 프로퍼티에 의해 연결되어 있다.
- 프로토타입과 생성자 함수는 단독으로 존재할 수 없고 언제나 쌍으로 존재한다.
- 생성자 함수의 경우 프로토타입
- 프로토타입의 생성 시점
- constructor는 함수 정의가 평가되어 함수 객체를 생성하는 시점에 프로토타입과 더불어 생성된다.
- 사용자 정의 생성자 함수
- 생성자 함수 자신이 평가되어 함수 객체로 생성되는 시점에 프로토타입도 더불어 생성되며, 생성된 프로토타입의 프로토타입은 언젠나 Object.prototype이다.
- 빌트인 생성자 함수
- Object, String, Number, Function, Array, RegExp, Date, Promise 등이 있다.
- 전역 객체(코드가 실생되기 이전 단계에 자바스크립트 엔진에 의해 생성되는 특수한 객체)가 생성되는 시점에 생성된다.
- 빌트인 생성자 함수는 전역 객체가 생성되는 시점에 생성된다. 이렇게 생성된 프로토타입은 빌트인 생성자 함수의 prototype 프로퍼티에 바인딩된다.
- 프로토타입의 생성 시점은 객체가 생성되기 이전에 생성자 함수와 프로토타입은 이미 객체화되어 존재한다.
- 객체 생성 방식과 프로토타입의 결정
- 객체 리터럴, Object생성자 함수, 생성자 함수, Object.create 메서드, 클래스 등의 생성 방식이 있다.
- 모두 공통적으로 OrdinaryObjectCreate에 의해 생성된다.
-
- OrdinaryObjectCreate 함수에 프로토타입을 인수로, 프로퍼티는 옵션으로 전달한다.
-
- OrdinaryObjectCreate가 빈 객체를 생성한다.
-
- 프로퍼티 목록이 있으면 우선 객체에 추가한다.
-
- 생성한 객체의 [[Prototype]] 내부 슬롯에 인수로 전달받은 프로토타입을 할당한다.
-
- 완성된 객체를 전달한다.
-
- 객체 리터럴 방식
- 인수로 프로토타입 Object.prototype을 전달해준다.
- 생성자 함수 방식
- 인수로 생성자 함수의 prototype 프로퍼티에 바인딩되어 있는 객체이다.
- Object 생성자 함수 방식
- 인수로 프로토타입 Object.prototype을 전달해준다.
🤷🏻♂️ 이번에는 무엇을 알아볼까?
Part 2 글의 마지막에도 적어둔 것 처럼 이번에는 프로토타입 체인관련 글을 적고 마무리를 해보려고 한다.
- 프로토타입 체인을 더 알아보자
- 오버라이딩이란?
- 직접 상속이란?
⛓ 프로토타입 체인이란?
이전에 Part 1 글에서 잠시 언급한 적이 있는데 그때의 말을 잠시 가져오면
프로토타입체인은 검색을 순서대로 할 수 있게 만든 단방향 링크드 리스트이다.
어떤 객체에 접근해서 특정 프로퍼티를 찾는데 없다면 JavaScript엔진은 __proto__ 접근자 프로퍼티가 가리키는 참조를 따라서 자신의 부모역할을 하는 프로토타입의 프로퍼티를 순차적으로 접근한다.
대략적으로 단방향 링크드 리스트이고 객체의 프로토타입을 연결해 둔 것이라고 생각할 수 있다.
코드로 한번 알아보자
function Person(name) {
this.name = name;
}
Person.prototype.sayHello = function () = {
console.log(`Hi my name is ${this.name}`);
};
const me = new Person('Yoo');
console.log(me.hasOwnProperty('name')); // true
우리는 hasOwnProperty라는 메서드를 추가한 적이 없는데 잘 동작하는 것을 볼 수 있다. 이 hasOwnProperty는 Object.prototype의 메서드이다. 언제 이 프로토타입이 연결된 것일까?
위 코드의 구조를 한번 확인해보면 다음과 같다.
(출처: 모던 자바스크립트 Deep Dive 그림 19-18 프로토타입 체인)
앞전까지는 Person 생성자 함수, Person.prototype, 인스턴스 이렇게 3개까지만 있었는데 Function.prototype, Object.prototype 이 두 개는 어느 타임에 생긴 것일까?
우선 Person 생성자 함수가 평가되는 과정에서 Person.prototype이 생성되고 prototype, constructor 프로퍼티에 서로 연결이 됬을 것이다.
Function.prototype은 언제 연결되었을까? 한번 천천히 생각해본다.
- 함수 리터럴을 통해 생성자 함수를 생성했다.
- 리터럴 표기법에 의해서 생성된 객체도 생성자 함수와 프로토타입이 존재한다.
- 함수 리터럴의 경우는 Function 생성자 함수, Function.prototype이 연결된다.
위와 같은 과정으로 연결이 되었을 것이다. 그러면 Person.prototype과 Object.prototype은 언제 연결이 되었을까?
- 생성자 함수가 평가될 시점에 생성자 함수의 프로토타입이 생성된다.
- 프로토타입은 객체다. 이 말은 이 객체의 생성자와 프로토타입이 연결되었을 것이다.
- 일반적인 객체이니 Object.prototype이 연결되었을 것이다.
마지막으로 볼 것은 Function.prototype과 Object.prototype의 연결인데 이는 어떻게 연결이 되었을지 유추해본다. 이 부분은 정말 유추를 하는 부분으로 오류가 있거나 추가해주고 싶은 내용이 있으면 부탁드린다.
- Function과 Object가 빌트인 객체이다.
- 빌트인 생성자 함수가 생성되면서 프로토타입이 생성될 것이다.
- 이때 Function.prototype이 일반적인 객체이니 Object.prototype에 연결되었을 것이다.
이렇게 정리를 하면 위 그림이 어떻게 연결된 것인지 대략적으로 이해할 수 있다.
그러면 Object.prototype의 프로토타입은 없는 것일까?
정답은 '없다' 이다. (정확하게 말하면 [[Prototype]] 내부 슬롯이 null 이다.)
프로토타입의 최상위에 위치하는 객체는 언제나 Object.prototype이다. 따라서 모든 객체는 Object.prototype을 상속받는다고 생각하면 된다. 이러한 이유 때문에 Object.prototype을 프로토타입 체인의 종점이라고 한다.
구조 설명으로 인해서 조금 말이 내려왔는데 이제 프로토타입 체인에 대해서 알아보자.
자바스크립트는 객체의 프로퍼티, 메서드에 접근하려고 할 때 해당 객체에 접근하려는 프로퍼티가 없다면 [[Prototype]] 내부 슬롯의 참조를 따라 자신의 부모 역할을 하는 프로토타입의 프로퍼티를 순차적으로 검색한다. 이를 프로토타입 체인이라고 한다.
me.hasOwnProperty('name')이 어떠한 과정을 거쳐서 이루어지는 것인지 확인해보자
-
- 먼저 me 객체에서 hasOwnProperty 메서드를 찾는다. => 없다.
-
- 없기 때문에 프로토타입 체인을 따라 [[Prototype]] 내부 슬롯에 저장된 Person.prototype으로 이동해서 hasOwnProperty 메서드를 찾는다. => 없다.
-
- 없기 때문에 동일하게 체인을 따라서 Object.prototype으로 이동해서 hasOwnProperty를 검색한다. => 있다.
-
- 해당 프로퍼티를 호출한다. 이때 Object.prototype.hasOwnProperty 메서드의 this에는 me 객체가 바인딩 된다.
Object.prototype.hasOwnProperty.call(me, 'name');
💁🏻♂️ 오버라이딩이란?
오버라이딩이란 무엇일까? 일반적으로 오버라이딩이 무엇인지 알아보자
오버라이딩이란 상위 클래스가 가지고 있는 메서드를 하위 클래스가 재정의해서 사용하는 방식이다.
코드를 통해서 확인해보자
const Person = (function () {
function Person(name) {
this.name = name;
}
Person.prototype.sayHello = function () {
// 1번
console.log(`Hi my name is ${this.name}`);
};
return Person;
})();
const me = new Person('Yoo');
me.sayHello = function () {
// 2번
console.log(`Ha Ha Ha I'm ${this.name}`);
};
me.sayHello(); // Ha Ha Ha I'm Yoo
왜 me.sayHello()의 결과가 Ha Ha Ha 인지 생각해보면 오버라이딩이 어떻게 이루어진 것인지 알 수 있다. 한번 확인해보자
-
- 1번 코드로 인해서 Person.prototype에 sayHello 라는 메서드가 생성됬을 것이다.
-
- 2번 코드로 인해서 me 인스턴스에 sayHello 라는 메서드가 생성됬을 것이다.
-
- me.sayHello() 가 실행되면 우선 me 인스턴스 내부에 메서드가 있는지 확인한다. => 있다.
-
- 프로토타입 체인을 타지 않고 me 인스턴스 내부의 sayHello 메서드를 실행한다.
이유는 단순하다. 프로토타입 체인을 올라가지 않아도 이미 메서드가 있어서 해당 메서드를 실행한 것이다.
이처럼 프로토타입을 거슬러 올라가지 않게 하위 객체에서 메서드를 재정의 하는 것을 오버라이딩이라 하며, 상속 관계에 의해 프로퍼티가 가려지는 현상을 프로퍼티 섀도잉이라 한다.
🫢 instanceof 는 어떻게 동작하는 것일까?
우리는 instanceof 연산자가 있다는 것을 알고있다.
작성자 본인은 그냥 단순하게 우변의 생성자 함수를 통해 좌변의 인스턴스를 생성한건지에 대한 유무를 체크하는 연산자인줄 알았다. 하지만 정의를 보면 뭔가 다르다.
instanceof는 우변의 생성자 함수의 prototype에 바인딩된 객체가 좌변의 객체의 프로토타입 체인상에 존재하면 true, 아니면 false를 반환한다.
말이 조금 어려운데 정리하면 생성자 함수를 체크하는 것이 아니라 프로토타입을 체크하는 것이다. 생성자 함수와 쌍을 이룬 프로토타입 객체가 인스턴스의 프로토타입 체인상에 존재하는지를 체크한다는 것이다. 코드로 살펴보자
function Person(name) {
this.name = name;
}
const me = new Person('Yoo');
const parent = {};
// me의 prototype을 parent로 바꿔준다.
Object.setPrototypeOf(me, parent);
// 연결이 다 끊어진 모습을 볼 수 있다.
console.log(Person.prototype === parent); // false
console.log(parent.constructor === Person); // false
console.log(me instanceof Person); // false 4번
console.log(me instanceof Object); // true 5번
위 코드는 지금 me 객체의 프로토타입을 교체해주고 결과를 확인한 코드이다.
내가 처음 생각한 instanceof 의 개념으로는 4번 코드의 결과는 true 여야 할 것인데 false가 나왔다.
이는 Person.prototype이 me 객체의 프로토타입 체인 상에서 존재하지 않기 때문이다.
me 객체 입장에서 프로토타입 체인을 생각해보면 me => parent => Object.prototype 이 될 것이다. 이렇기 때문에 5번 코드의 결과가 true가 나온 것이다.
👶🏻 직접 상속
자바스크립트에서 객체를 생성하는 방식이 여러가지가 있던 것을 확인했고 그중 아직 확인하지 못한 방법 중 Object.create 메서드를 한번 알아보자
Object.create 메서드는 명시적으로 프로토타입을 지정하여 새로운 객체를 생성한다. 이 방식도 OrdinaryObjectCreate를 호출해서 내부적으로 생성한다는 것은 동일하다.
Object.create 메서드의 첫 번째 매개변수에는 생성할 객체의 프로토타입으로 지정할 객체를 전달한다. 두 번째 매개변수에는 생성할 객체의 프로퍼티 키와 프로퍼티 디스크립터 객체로 이루어진 객체를 전달한다. 두 번째 매개변수는 옵션이니 뭐지 싶어도 걱정하지 않아도 된다. 코드로 알아보자
// 1번
let obj = Object.create(null);
console.log(Object.getPrototype(obj) === null); // true
// Object.prototype을 상속받지 못해서 에러가 발생한다.
console.log(obj.toString()); // TypeError
// 2번
const myProto = { x: 10 };
obj = Object.create(myProto);
// 객체 리터럴을 이용해 생성한 객체도 상속이 가능하다.
console.log(obj.x); // 10
console.log(Object.getPrototypeOf(obj) === myProto); // true
// 옵션
// 두 번째 인자를 사용한다면 다음과 같이 프로퍼티 디스크립터 객체를 사용한 객체로 전달해야한다.
obj = Object.create(Object.prototype, {
x: { value: 1, writalbe: true, enumerable: true, configurable: true },
});
1번 코드는 프로토타입에 null 값을 주었을 경우 생성된 객체는 프로토타입이 없는 것을 보여준다. 이렇게 생성된 객체는 프로토타입 체인의 종점에 위치하게 된다.
2번 코드는 객체 리터럴을 이용해서 생성한 객체를 프로토타입으로 전달해서 객체를 만드는 모습을 보여준다.
이처럼 Object.create 메서드는 첫 번째 매개변수에 전달한 객체의 프로토타입 체인에 속하는 객체를 생성한다. 이러한 방법을 객체를 생성하면서 직접적으로 상속을 구현하는 직접 상속이라고 한다. 이 메서드의 장점은 위에서도 조금 봤지만
- new 연산자가 없이도 객체를 생성할 수 있다.
- 프로토타입을 지정하면서 객체를 생성할 수 있다.
- 객체 리터럴에 의해 생성된 객체도 상속받을 수 있다.
이러한 장점들이 있다고 할 수 있다.
하지만 단점들도 있다. 바로 프로퍼티를 정의하는 방법이 너무 귀찮은 것이다. 그러면 간단하게 하는 방법이 있을까?
객체 리터럴 내부에서 __proto__에 의한 직접 상속 방법이 있다.
const myProto = { x: 10 };
const obj = {
y: 20,
__proto__: myProto,
};
console.log(obj.x, obj.y); // 10 20
console.log(Object.getPrototypeOf(obj) === myProto); // true
다음과 같이 __proto__ 접근자 프로퍼티를 이용해서 직접 상속을 하는 방법이 있다.
😃 프로토타입 글 끝!
이렇게 길게 글을 적게될 줄 몰랐는데... 3개나 썼다.
더 놀라운 것은 이렇게 3개의 글을 적었지만 내가 모든 프로토타입 관련 지식을 알게된 것도 아니라는 것이다.
또 기회가 된다면 더 깊게 파서 글을 적어보려고 한다.
처음에는 글을 쓰면서 학습을 할 때 '뭔말이여 이게?!?' 하는 내용들이 많기도 했고 내가 가지고 있는 배경지식이 짧아서 해당 내용을 학습하는데에도 시간이 오래 걸렸다.
그렇지만 이렇게 나름은 내 스스로의 기준에 만족스러운 정도로 글을 마무리하니 뿌듯하다.
다음 이렇게 긴 글은 아마 실행 컨텍스트 관련 글을 작성할 때 나오지 않을까 싶다.
여기까지 읽어주시는 분이 계실까도 모르겠지만 만약 있으시다면 보잘 것 없는 글을 읽어줘서 감사하다는 말을 전하고 싶다.