JavaScript 키워드 프로토타입 Part 2

📝 이전 글 간단 정리

  • 객체지향 프로그래밍이란?
    • 어플리케이션이 제공할 기능을 나눠서 적절하게 객체에 나눠주고, 해당 객체들이 본인의 책임을 다하고 서로 협동하면서 서비스를 제공하게 만드는 것
  • 객체
    • 어떠한 특징이나 성질을 나타내는 상태들, 그리고 그러한 속성들을 이용할 수 있는 행동들을 가지고 있는 복합적인 자료구조
    • 객체의 상태를 프로퍼티, 동작을 메서드라고 한다.
  • 프로토타입 객체
    • 어떤 객체의 상위 객체 역할을 하는 객체로서 다른 객체에 공유 프로퍼티를 제공하기 위해 존재한다.
    • 객체가 생성될시 생성 방식에 따라서 자동으로 내부 슬롯([[Prototyep]])에 프로토타입의 참조 값이 생긴다.
      • 객체 리터럴()에 의해 생성된 객체의 프로토타입은 Object.prototype
      • 생성자 함수에 의해 생성된 객체의 프로토타입은 생성자 함수의 prototype 프로퍼티에 바인딩된 객체
  • __proto__ 접근자 프로퍼티
    • 모든 객체는 __proto__ 접근자 프로퍼티를 통해 자신의 프로토 타입, 즉 [[Prototype]] 내부 슬롯이 가리키는 프로토타입에 간접적으로 접근할 수 있다.
    • 순환 참조 프로토타입 체인이 생기는 것을 방지하기 위해서, 그리고 아무런 체크 없이 무조건적으로 프로토타입을 교체할 수 없도록 __proto__ 접근자 프로퍼티를 통해 접근 및 교체를 구현한 것이다.
  • prototype 프로퍼티
    • 생성자 함수가 생성할 인스턴스들의 프로토타입에 접근하기 위해서 사용하는 프로퍼티이다.

🤷🏻‍♂️ 이번에는 무엇을 알아볼까?

Part 1 글의 마지막에도 적어둔 것 처럼 이번에는 이어지는 내용을 알아볼 예정이다.

  • 프로토타입이 언제 연결될까?
  • 프로토타입은 어떻게 결정되는 것일까?
  • 프로토타입 체인에 대해서 더 알아보자

이렇게 3가지 내용을 알아보려고 한다. 레츠 고!


🤔 리터럴 표기법의 생성자 함수와 프로토타입

(출처: https://poiemaweb.com/es6-class) 이전의 글에서 생성자 함수로 객체를 생성할 경우 프로토타입이 위
그림처럼 연결된다는 것을 알아보았다.

위 그림의 연결을 한번 글로 이야기해보자

  • 인스턴스의 __proto__ 접근자 프로퍼티를 통해서 프로토타입 객체에 접근 가능하다.
  • 프로토타입 객체의 constructor 프로퍼티를 통해서 생성자 함수에 접근 가능하다.
  • 생성자 함수의 prototype 프로퍼티를 통해서 프로토타입 객체에 접근 가능하다.
function Person(name) {
  this.name = name;
}

const me = new Person('Yoo');
console.log(me.constructor === Person); // true
// me에서 constructor를 찾음 => 없음 => __proto__ 로 상위 객체 접근
// => Person.prototype의 constructor 발견 => 생성자 함수 접근
// 해당 생성자 함수가 Person이니 true가 된 것이다.

위와 같은 연결이 서로 존재하기 때문에 위 코드와 같은 현상이 발생하는 것이다.

그러면 의문이 있다.
과연 리터럴 표기법의 생성자 함수와 프로토타입 연결이란게 있는가? 있다면 어떻게 연결된 것인가?

const obj = {};
// 함수도 객체다
const add = function (a, b) {
  return a + b;
};
// 배열도 객체다
const arr = [1, 2, 3];
// 정규표현식도 객체다
const regexp = /is/gi;

위에 적힌 객체, 함수, 배열, 정규표현식은 모두 리터럴 표기법으로 만드는 객체이다. 이 모든 애들의 생성자 함수와 프로토타입이 있는 것인가?

일단 결론은 프로토타입이 존재한다!
하지만 리터럴 표기법에 의해 생성된 객체의 경우 프로토타입의 constructor 프로퍼티가 가리키는 생성자 함수가 반드시 객체를 생성한 생성자 함수라고 단정할 수는 없다.

이게 무슨 말일까?

// 객체 리터럴 방식
const obj = {};
console.log(obj.constructor === Object); // true

객체 리터럴 방식을 사용했는데 Object 생성자 함수라고 하는 결과를 보면 의아하다.

한번 생각을 해보면 객체 리터럴 방식으로 생성된 객체는 Object.prototype와 연결되어있고, Object.prototype은 Object 생성자 함수와 연결이 되었기 때문에 해당 결과가 나오는 것 같다.

그러면 여기서 합리적 의심을 할 수 있는 것이, 그냥 객체 리터럴 방식을 사용해서 생성하는 것이 내부적으로는 Object 생성자 함수로 생성되는 것이 아닐까?..?
한번 ECMAScript 사양을 살펴보자

Object([value]) When the Object function is called with optional argument value, the following steps are taken:

  1. If NewTarget is neither undefined nor active function, then a Return ?
    OrdinaryCreateFromConstructor
    (NewTarget,"%Object.prototype%").
  2. If value is undefined or null, return OrdinaryObjectCreate
    ("%Object.prototype%").
  3. Return ! ToObject(value). The "length" property of the Object constructor function is 1.

2번을 보면 Object 생성자 함수에 인수를 전달하지 않거나 undefined 또는 null을 인수로 전달하면서 호출하면 내부적으로는 추상연산(ECMAScript 내부 알고리즘이자 의사코드) OrdinaryObjectCreate를 호출하여 Object.prototype을 프로토타입으로 가지는 빈 객체를 생성한다.

객체 리터럴이 평가될 때는 OrdinaryObjectCreate를 호출하여 빈 객체를 생성하고 프로퍼티를 추가하도록 정의되어 있다.

한마디로 Object 생성자 함수 호출과 객체 리터럴의 평가는 추상 연산 OrdinaryObjectCreate를 호출하여 빈 객체를 생성하는 점에서는 동일하나 new.target의 확인이나 프로퍼티를 추가하는 세부 내용이 다르다.
따라서 정확하게는 객체 리터럴에 의해 생성된 객체는 Object 생성자 함수가 생성한 객체가 아니다.

정리를 하자면 다음과 같다.

  • 객체 리터럴의 경우에는 OrdinaryObjectCreate를 호출하여 빈 객체를 생성하고 프로퍼티를 추가한다.
  • 정확하게는 객체 리터럴에 의해서 생성된 객체는 Object 생성자 함수가 생성한 객체가 아니다.
  • 리터럴 표기법에 의해 생성된 객체도 가상적인 생성자 함수를 가진다.
  • 프로토타입은 생성자 함수와 더불어 생성되며 prototype, constructor 프로퍼티에 의해 연결되어 있다.
  • 프로토타입과 생성자 함수는 단독으로 존재할 수 없고 언제나 쌍으로 존재한다.

이처럼 리터럴 방식으로 생성된 객체는 가상적인 생성자 함수를 가지고 프로토타입이 연결된다.
세부적인 차이가 존재하기는 하지만 크게 그냥 두루뭉술하게 보면 연결되어 있는 생성자 함수를 리터럴 표기법으로 생성한 객체를 생성한 생성자 함수로 생각해도 괜찮다.


👶🏻 프로토타입의 생성 시점

위에서 잠깐 나왔지만 프로토타입은 생성자 함수가 생성되는 시점에 더불어 생성이 된다.
이는 프로토타입과 생성자 함수가 단독으로 존재할 수 없고 쌍으로 존재하기 때문이다.

그러면 생성자 함수의 종류에 따라서 프로토타입 생성 시점이 다를 것이다.
생성자 함수는 크게 본인이 만든 사용자 정의 생성자 함수와 자바스크립트가 제공하는 빌트인 생성자 함수로 구분할 수 있다. 두 케이스를 한번 확인해보자

사용자 정의 생성자 함수

생성자 함수로서 호출할 수 있는 함수, 즉 constructor는 함수 정의가 평가되어 함수 객체를 생성하는 시점에 프로토타입도 더불어 생성된다.

console.log(Person.prototype); // {constructor: f}

function Person(name) {
  this.name = name;
}

// 이렇게 표현식을 통한 생성자 함수 선언은 console.log가 안나올 것이다.
// 왜냐? 함수 정의가 평가되는 시점이 런타임시 이 변수에 도달했을 때이기 때문이다.
const Person = function (name) {
  this.name = name;
};

이 경우가 왜 동작하는 것일까?
함수 선언문은 런타임 이전에 자바스크립트 엔진에 의해서 미리 평가된다. 이때 함수가 평가되면서 함수 객체가 생기고 이 시점에 프로토타입이 같이 생성이 된다.

이때 생성된 프로토타입은 오직 constructor 프로퍼티만을 가지는 객체이다.
여기서 잠시 생각해 볼 것은 프로토타입도 객체인데 프로토타입 객체의 프로토타입도 있는건가?
당연하다. 이때 프로토타입 객체의 프로토타입은 Object.prototype이다.

정리하자면
빌트인 생성자가 아닌 사용자 정의 생성자 함수는 자신이 평가되어 함수 객체로 생성되는 시점에 프로토타입도 더불어 생성되며, 생성된 프로토타입의 프로토타입은 언제나 Object.prototype이다.

빌트인 생성자 함수

빌트인 생성자 함수 종류 Object, String, Number, Function, Array, RegExp, Date, Promise 등이 있다.

전역객체란? 코드가 실행되기 이전 단계에 자바스크립트 엔진에 의해 생성되는 특수한 객체다. 전역 객체는 클라이언트 사이드 환경(브라우저)에서는 window, 서버 사이드 환경(Node.js)에서는 global 객체를 의미한다.

빌트인 생성자 함수도 일반 함수와 마찬가지로 빌트인 생성자 함수가 생성되는 시점에 프로토타입이 결정된다.
이 빌트인 생성자 함수는 우리들이 만드는 것도 아닌데 언제 생성이 되는 것인가?
바로 전역 객체가 생성되는 시점에 생성된다. 이렇게 생성된 프로토타입은 빌트인 생성자 함수의 prototype 프로퍼티에 바인딩된다.

프로토타입의 생성 시점은 이처럼 객체가 생성되기 이전에 생성자 함수와 프로토타입은 이미 객체화되어 존재한다.
이후 생성자 함수 또는 리터럴 표기법으로 객체를 생성하면 프로토타입은 생성된 객체의 [[Prototype]] 내부 슬롯에 할당된다.


👨🏻‍🏫 객체 생성 방식과 프로토타입의 결정

자바스크립트라는 사랑스러운(?) 언어는 객체를 생성하는 다양한 방법이 있다.
객체 리터럴, Object 생성자 함수, 생성자 함수, Object.create 메서드, 클래스 이렇게 존재한다.
이렇게 생성된 모든 객체는 앞서 살펴본 것 처럼 OrdinaryObjectCreate에 의해 생성된다는 공통점이 있다.

추상 연산 OrdinaryObjectCreate는 필수적으로 자신이 생성할 객체의 프로토타입을 인수로 전달받는다.
그리고 자신이 생성할 객체에 추가할 프로퍼티 목록을 옵션으로 전달할 수 있다.
OrdinaryObjectCreate는 빈 객체를 생성한 후, 객체에 추가할 프로퍼티 목록이 인수로 전달된 경우 프로퍼티를 객체에 추가한다. 그리고 인수로 전달받은 프로토타입을 자신이 생성한 객체의 [[Prototype]] 내부 슬롯에 할당한 다음 생성한 객체를 반환한다.

정리하면

  1. OrdinaryObjectCreate + 프로토타입(인수로 전달) + 프로퍼티 목록(옵션)
  2. 빈 객체를 생성한다.
  3. 프로퍼티 목록이 있으면 객체에 추가한다.
  4. 생성한 객체의 [[Prototype]] 내부 슬롯에 인수로 전달받은 프로토타입을 할당한다.
  5. 주문하신 객체 나왔습니다!

자 그러면 저 다양한 생성 방법은 어디가 다른 것일까?
바로 인수에서 차이가 난다. 한번 확인해보자

객체 리터럴 방식

이거는 앞에서 살펴본 방식이다.
자바스크립트 엔진이 객체 리터럴을 평가할때 추상연산 OrdinaryObjectCreate를 호출한다.
이때 인수로 전달되는 프로토타입은 Object.prototype이다.

const obj = { x: 1 };
console.log(obj.constructor === Object);
console.log(obj.hasOwnProperty('x'));

Object 생성자 함수

Object 생성자 함수를 인수 없이 호출하면 빈 객체가 생성된다.
Object 생성자 함수를 호출하면 객체 리터럴과 마찬가지로 추상연산 OrdinaryObjectCreate를 호출한다.
이때 인수로 전달되는 프로토타입은 Object.prototype이다.

const obj = new Object();
obj.x = 1;
console.log(obj.constructor === Object);
console.log(obj.hasOwnProperty('x'));

그러면 어떠한 차이가 있는 것일까?
바로 프로퍼티를 추가하는 방식이 다르다.
객체 리터럴 방식은 객체 리터럴 내부에 프로퍼티를 추가하지만 Object 생성자 함수 방식은 빈 객체를 생성한 후 이후 프로퍼티를 추가해야 한다.

생성자 함수

new 연산자와 함께 생성자 함수를 호출하여 인스턴스를 생성하면 동일하게 추상연산 OrdinaryObjectCreate가 호출된다.
이때 전달되는 프로토타입이 다른데 생성자 함수의 prototype 프로퍼티에 바인딩되어 있는 객체이다.
즉, 생성자 함수에 의해 생성되는 객체의 프로토타입은 생성자 함수의 prototype 프로퍼티에 바인딩되어 있는 객체이다.

function Person(name) {
  this.name = name;
}

Person.prototype.hi = function () {
  console.log(`Hi! I'm ${this.name}`);
};

const yoo = new Person('Yoo');
const ji = new Person('Ji');

yoo.hi();
ji.hi();

🥲 다음편에 이어질 내용...

프로토타입 체인도 다뤄보려 했는데 양이 너무 많다...
그리고 프로토타입 체인에 대해서 다루면서 상속, 오버라이딩 같은 내용을 한번에 다루면 좋지 않을까? 하는 생각에 파트를 나눠서 적어보기로 했다.

이번 파트의 내용을 한번 정리해보자

  • 리터럴 표기법() 으로 객체를 생성하면 OrdinaryObjectCreate를 통해 빈 객체가 생성되고 프로퍼티가 추가된다.
  • 이때 OrdinaryObjectCreate에 프로토타입 인수로 Object.prototype이 제공된다.
  • 리터럴 표기법으로 객체를 생성하면 가상 생성자가 들어가고 이는 세부적으로는 다른점이 존재하지만 크게 고려할 부분은 아니다.
  • 프로토타입은 생성자 함수가 평가될 때 생성되고 항상 쌍으로 존재한다고 생각하면 된다.
    • 사용자 생성자 함수는 함수가 평가받는 시점이다.
    • 빌트인 생성자 함수도 동일하나 빌트인의 경우에는 전역객체가 생성되는 시점에 생성된다.
  • 모든 객체는 OrdinaryObjectCreate를 통해서 생성되나 이때 제공되는 인수의 프로토타입이 무엇인지에 따라서 해당 객체의 프로토타입이 결정된다.
    • 이는 생성자 함수의 경우 생성자 함수의 prototype 프로퍼티에 바인딩된 객체를 인수로 제공하고 해당 프로토타입 객체로 프로토타입이 결정된다.

다음 글에서는 어떠한 내용을 다뤄볼지 생각해보면

  • 프로토타입 체인을 더 알아보자
  • 오버라이딩이란?
  • 직접 상속이란?

이렇게 3가지를 알아보고 아마 프로토타입 관련 정리를 마무리할까 싶다...