Prototype

김병엽·2024년 7월 24일

"어떤 객체의 메서드를 복사하거나 다시 구현하지 않고 해당 객체에 약간의 기능을 얹어 또 다른 객체를 만들 수 있지 않을까?" 라는 문제점 제기.

자바스크립트 언어의 고유 기능인 프로토타입 상속(prototypal inheritance) 을 이용하면 위와 같은 생각을 실현할 수 있다.


프로토타입

자바스크립트의 객체는 [[Prototype]]이라는 숨김 프로퍼티를 갖는다.

이 숨김 프로퍼티 값은 null이거나 다른 객체에 대한 참조가 되는데, 다른 객체를 참조하는 경우 참조 대상을 '프로토타입(prototype)'이라 부른다.

프토로타입의 동작 방식은 해당 프로퍼티가 없으면 자바스크립트는 자동으로 프로토타입에서 프로퍼티를 찾는다.

__proto__을 사용하면 값을 설정할 수 있다.

__proto__[[Prototype]]getter · setter이다.

let animal = {
  eats: true
};
let rabbit = {
  jumps: true
};

rabbit.__proto__ = animal; // 1

let rabbit = {
  jumps: true,
  __proto__: animal
}; // 2

// 프로퍼티 eats과 jumps를 rabbit에서도 사용할 수 있다.
alert( rabbit.eats ); // true 
alert( rabbit.jumps ); // true

"rabbit은 animal을 상속받는다."


프로토타입 체인

프로토타입 체이닝엔 두 가지 제약사항이 있다.

  1. 순환 참조(circular reference)는 허용되지 않는다. __proto__를 이용해 닫힌 형태로 다른 객체를 참조하면 에러가 발생한다.

  2. __proto__의 값은 객체나 null만 가능하다. 다른 자료형은 무시된다.
    객체엔 오직 하나의 [[Prototype]]만 있을 수 있다는 당연한 제약도 있다. 객체는 두 개의 객체를 상속받지 못합니다.

let animal = {
  eats: true,
  walk() {
    alert("동물이 걷습니다.");
  }
};

let rabbit = {
  jumps: true,
  __proto__: animal
};

let longEar = {
  earLength: 10,
  __proto__: rabbit
};

// 메서드 walk는 프로토타입 체인을 통해 상속받았다.
longEar.walk(); // 동물이 걷습니다.
alert(longEar.jumps); // true (rabbit에서 상속받음)


프로토타입은 읽기 전용이다

프로토타입은 프로퍼티를 읽을 때만 사용한다.

프로퍼티를 추가, 수정하거나 지우는 연산은 객체에 직접 해야한다.

let animal = {
  eats: true,
  walk() {
    /* rabbit은 이제 이 메서드를 사용하지 않습니다. */
  }
};

let rabbit = {
  __proto__: animal
};

rabbit.walk = function() {
  alert("토끼가 깡충깡충 뜁니다.");
};

rabbit.walk(); // 토끼가 깡충깡충 뜁니다.


# 접근자 프로퍼티

접근자 프로퍼티에 값을 할당하면 다르게 동작한다.

let user = {
  name: "John",
  surname: "Smith",

  set fullName(value) {
    [this.name, this.surname] = value.split(" ");
  },

  get fullName() {
    return `${this.name} ${this.surname}`;
  }
};

let admin = {
  __proto__: user,
  isAdmin: true
};

alert(admin.fullName); // John Smith (*)

// setter 함수가 실행된다!
admin.fullName = "Alice Cooper"; // (**)

alert(admin.fullName); // Alice Cooper, setter에 의해 추가된 admin의 프로퍼티(name, surname)에서 값을 가져온다.
alert(user.fullName); // John Smith, 본래 user에 있었던 프로퍼티 값.

프로토타입에 이미 setter 함수(set fullName)가 정의되어 있기 때문에 (**)로 표시한 줄의 할당 연산이 실행되면 객체 user에 프로퍼티가 추가되는게 아니라 프로토타입에 있는 setter 함수가 호출된다.


# this가 나타내는 것

메서드를 객체에서 호출했든 프로토타입에서 호출했든 상관없이 this는 언제나 . 앞에 있는 객체이다.

let animal = {
  walk() {
    if (!this.isSleeping) {
      alert(`동물이 걸어갑니다.`);
    }
  },
  sleep() {
    this.isSleeping = true;
  }
};

let rabbit = {
  name: "하얀 토끼",
  __proto__: animal
};

// rabbit에 새로운 프로퍼티 isSleeping을 추가하고 그 값을 true로 변경한다.
rabbit.sleep();

alert(rabbit.isSleeping); // true
alert(animal.isSleeping); // undefined (프로토타입에는 isSleeping이라는 프로퍼티가 없다.)


함수의 prototype 프로퍼티

생성자 함수로 객체를 만들었을 때 리터럴 방식과 다른점은 생성자 함수의 프로토타입이 객체인 경우에 new 연산자를 사용해 만든 객체는 생성자 함수의 프로토타입 정보를 사용해 [[Prototype]]을 설정한다는 것이다.

let animal = {
  eats: true
};

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

Rabbit.prototype = animal;

let rabbit = new Rabbit("흰 토끼"); //  rabbit.__proto__ == animal

alert( rabbit.eats ); // true

Rabbit.prototype = animalnew Rabbit을 호출해 만든 새로운 객체의 [[Prototype]]animal로 설정하라."는 것을 의미한다.

가로 화살표는 일반 프로퍼티인 "prototype"을, 세로 화살표는 [[Prototype]]을 나타낸다.

F.prototype은 new F를 호출할 때만 사용됩니다.


함수의 디폴트 프로퍼티 prototype과 constructor 프로퍼티

개발자가 특별히 할당하지 않더라도 모든 함수는 기본적으로 "prototype" 프로퍼티를 갖는다.

디폴트 프로퍼티 "prototype"constructor 프로퍼티 하나만 있는 객체를 가리키는데, 여기서 constructor 프로퍼티는 함수 자신을 가리킨다.

function Rabbit() {}
// 디폴트 prototype:
// Rabbit.prototype = { constructor: Rabbit }

let rabbit = new Rabbit(); // {constructor: Rabbit}을 상속받음

alert(Rabbit.prototype.constructor == Rabbit ); // true
alert(rabbit.constructor == Rabbit); // true ([[Prototype]]을 거쳐 접근함)

function Rabbit(name) {
  this.name = name;
  alert(name);
}

let rabbit = new Rabbit("흰 토끼");

let rabbit2 = new rabbit.constructor("검정 토끼");

constructor 프로퍼티는 기존에 있던 객체의 constructor를 사용해 새로운 객체를 만들때 사용할 수 있다.

constructor"를 이야기 할 때 가장 중요한 점은
자바스크립트는 알맞은 "constructor" 값을 보장하지 않는다.

# 예시

function Rabbit() {}
Rabbit.prototype = {
  jumps: true
};

let rabbit = new Rabbit();
alert(rabbit.constructor === Rabbit); // false

함수에 기본으로 설정되는 "prototype" 프로퍼티 값을 다른 객체로 바꾸고 new를 사용해 객체를 만들었지만 이 객체에 "constructor"가 없는 것을 확인할 수 있다.

이런 상황을 방지하고 constructor의 기본 성질을 제대로 활용하려면 "prototype"에 뭔가를 하고 싶을 때 "prototype" 전체를 덮어쓰지 말고 디폴트 "prototype"에 원하는 프로퍼티를 추가, 제거해야 한다.

function Rabbit() {}

// Rabbit.prototype 전체를 덮어쓰지 말고
// 원하는 프로퍼티가 있으면 그냥 추가한다.
Rabbit.prototype.jumps = true
// 이렇게 하면 디폴트 프로퍼티 Rabbit.prototype.constructor가 유지된다.

실수로 "prototype"을 덮어썼다 하더라도 constructor 프로퍼티를 수동으로 다시 만들어주면 constructor를 다시 사용할 수 있다.

Rabbit.prototype = {
  jumps: true,
  constructor: Rabbit
};

// 수동으로 constructor를 추가해 주었기 때문에 우리가 알고 있던 constructor의 특징을 그대로 사용할 수 있다.

내장 객체의 프로토타입

prototype 프로퍼티는 자바스크립트 내부에서도 광범위하게 사용된다.

모든 내장 생성자 함수에서 prototype 프로퍼티를 사용한다.


Object.prototype

let obj = {};
alert( obj ); // "[object Object]" ?

"[object Object]" 문자열을 생성하는 코드는 어디에 있을까?

obj = new Object()를 줄이면 obj = {}가 된다.

Object는 내장 객체 생성자 함수인데, 이 생성자 함수의 prototypetoString을 비롯한 다양한 메서드가 구현되어있는 거대한 객체를 참조한다.

new Object()를 호출하거나 리터럴 문법 {...}을 사용해 객체를 만들 때, 새롭게 생성된 객체의 [[Prototype]]Object.prototype을 참조한다.

따라서 obj.toString()을 호출하면 Object.prototype에서 해당 메서드를 가져오게 된다.

let obj = {};

alert(obj.__proto__ === Object.prototype); // true

alert(obj.toString === obj.__proto__.toString); //true
alert(obj.toString === Object.prototype.toString); //true
alert(Object.prototype.__proto__); // null

Object.prototype 위쪽엔 [[Prototype]] 체인이 없다는 점을 주의해야 한다.


다양한 내장 객체의 프로토타입

Array, Date, Function을 비롯한 내장 객체들 역시 프로토타입에 메서드를 저장해 놓는다.

배열 [1, 2, 3]을 만들면 new Array()의 디폴트 생성자가 내부에서 동작하여 Array.prototype이 배열 [1, 2, 3]의 프로토타입이 되고 개발자는 Array.prototype을 통해 배열 메서드를 사용할 수 있다.

let arr = [1, 2, 3];

// arr은 Array.prototype을 상속받았나요?
alert(arr.__proto__ === Array.prototype); // true

// arr은 Object.prototype을 상속받았나요?
alert(arr.__proto__.__proto__ === Object.prototype); // true

// 체인 맨 위엔 null이 있다.
alert(arr.__proto__.__proto__.__proto__); // null

let arr = [1, 2, 3]
alert(arr); // 1,2,3 <-- Array.prototype.toString의 결과

업로드중..

중복 메서드가 있을 때는 체인 상에서 가까운 곳에 있는 메서드가 사용된다.


원시값

문자열과 숫자, 불린값은 객체가 아니다.

이런 원시 타입 값의 프로퍼티에 접근하려고 하면 내장 생성자 String, Number, Boolean을 사용하는 임시 래퍼(wrapper) 객체가 생성된다.

임시 래퍼 객체는 이런 메서드를 제공하고 난 후에 사라진다.

nullundefined에 대응하는 래퍼 객체는 없습니다.


네이티브 프로토타입 변경하기

네이티브 프로토타입은 수정할 수 있다.

String.prototype에 메서드를 하나 추가하면 모든 문자열에서 해당 메서드를 사용할 수 있다.

String.prototype.show = function() {
  alert(this);
};

"BOOM!".show(); // BOOM!

❗ 프로토타입은 전역으로 영향을 미치기 때문에 프로토타입을 조작하면 기존 코드와 충돌이 날 가능성이 큽니다.

두 라이브러리에서 동시에 String.prototype.show 메서드를 추가하면 한 라이브러리의 메서드가 다른 라이브러리의 메서드를 덮어씁니다.

이런 이유로 네이티브 프로토타입을 수정하는 것은 추천하지 않습니다.


프로토타입에서 메서드 빌려오기

한 객체의 메서드를 다른 객체로 복사할 때 이 기법이 사용된다.

let obj = {
  0: "Hello",
  1: "world!",
  length: 2,
};

obj.join = Array.prototype.join;

alert( obj.join(',') ); // Hello,world!

메서드 빌리기 말고도 obj.__proto__Array.prototype으로 설정해 배열 메서드를 상속받는 방법이 있다. 이렇게 하면 obj에서 모든 Array 메서드를 사용할 수 있다.

그런데 자바스크립트는 단일 상속만 허용하기 때문에 이 방법은 obj가 다른 객체를 상속받고 있을 때는 사용할 수 없다.

메서드 빌리기는 여러 객체에서 필요한 기능을 가져와 섞는 것을 가능하게 해주기 때문에 유연한 개발을 가능하게 한다.


Reference

ko.javascript.info

profile
선한 영향력을 줄 수 있는 개발자가 되자, 되고싶다.

0개의 댓글