[JS] 프로토타입

박먼지·2022년 12월 20일
0
post-thumbnail

자바스크립트는 프로토타입(prototype) 기반 언어이다.

클래스 기반 언어에서는 '상속'을 사용하지만 프로토타입 기반 언어에서는 어떤 객체를 원형(prototype)으로 삼고 이를 복제(참조)함으로써 상속과 비슷한 효과를 얻을 수 있다.

❗ 편의를 위해 __proto__ 를 사용하지만 실무에서는 Object.getPrototypeof()나 Object.create()등을 이용하도록 합시다.

1. 프로토타입의 개념 이해

1-1. constructor, prototype, instance

요 그림만 이해하면 프로토타입은 끝이라고 합니다. 참쉽죠?? 🤔

var instance = new Constructor();

위 그림은 이 코드를 추상화 한 것인데, 하나하나 살펴보자면

Constructor(생성자 함수)를 new 연산자와 함께 호출하면 instance가 생성되는데, 이 instance에는 __proto__ 라는 프로퍼티가 자동으로 부여되고, 이 __proto__ 프로퍼티는 Constructor의 prototype이라는 프로퍼티를 참조한다는 뜻이다.

prototype이라는 프로퍼티와 __proto__ 라는 프로퍼티와의 관계가 프로토타입 개념의 핵심이다.

prototype이 객체이므로 이를 참조하는 __proto__ 역시 객체이고, __proto__ 는 prototype 객체 내부에 있는 메서드들에 접근 할 수 있다.

이를 예시와 함께 Araboza.

var Person = function (name) {
  this._name = name;
};
Person.prototype.getName = function () {
  return this._name;
};

Person이라는 생성자 함수의 prototype에 getName이라는 메서드를 지정했다고 해보자.

이제 Person의 인스턴스는 __proto__ 프로퍼티를 통해 getName을 호출할 수 있다.

var suzi = new Person('Suzi');
suzi.__proto__.getName(); // undefined

이러면 결과가 Suzi가 나올 것 같은데, undefined가 나온다!

일단 결과로 undefined가 나온 점에서 getName 함수가 실제로 실행됐음을 알 수 있다. 그러면 함수 내부에서 뭔가가 의도와는 다르게 실행이 된 것이라고 유추해볼 수 있는데, getName은 this._name 값을 리턴하므로 this값이 생각과는 다르게 할당되어 있을 가능성이 있다.

어떤 함수를 '메서드'로서 호출할 때는 메서드명 바로 앞의 객체가 곧 this가 된다.

그래서 suzi.__proto__.getName();suzi가 아니라suzi.__proto__가 this가 되는 것이다. 이 객체 안에는 _name 프로퍼티가 없기 때문에 undefined가 반환된 것이다.

그럼 __proto__ 객체에 _name 프로퍼티가 있으면 어떨까?

var suzi = new Person('Suzi');
suzi.__proto__._name = 'SUZI__proto__';
suzi.__proto__.getName(); // SUZI__proto__

예상대로 SUZI__proto__ 가 잘 출력된다. 관건은 this였다!
this가 인스턴스를 보면 좋을텐데.. 그 방법은 __proto__ 없이 인스턴스에서 곧바로 메서드를 쓰는 것이다.

suzi.getName(); // Suzi;

__proto__없이 인스턴스(suzi)에서 곧바로 메서드를 쓰면 인스턴스를 this로 할 수 있다.

근데 왜 __proto__를 빼도 prototype 메서드가 호출될까?? 그것은 바로 __proto__가 생략 가능한 프로퍼티이기 때문이다!

'생략 가능한 프로퍼티'라는 개념은 브랜든 아이크씨의 머리에서 나온 아이디어이기 때문에 그냥 아묻따 외우면 된다.

__proto__ 프로퍼티는 생략 가능하기 때문에 생성자 함수의 prototype에 어떤 메서드나 프로퍼티가 있다면 인스턴스에서도 마치 자기 것처럼 해당 메서드나 프로퍼티에 접근할 수 있게 된다!

console.dir(Person)한 결과

console.dir(suzi)한 결과

생성자 함수의 인스턴스는 해당 생성자 함수의 이름을 표기함으로써 해당 함수의 인스턴스임을 표기하고 있다.

Person의 prototype과 suzi의 prototype이 동일한 내용으로 구성돼 있음을 확인할 수 있다.

대표적인 내장 생성자 함수인 Array를 바탕으로 다시 한번 Araboza.

var arr = [1, 2];
console.dir(arr);
console.dir(Array);

왼쪽은 arr, 오른쪽은 Array를 출력한 결과이다.
arr 인스턴스의 __proto__는 Array.prototype을 참조하기 때문에 Array의 내부 메서드들을 사용할 수 있다.

하지만 isArray, from 같은 정적 메서드는 직접 호출할 수 없다. 이들은 Array 생성자 함수에서 직접 접근해야 실행 가능하다.

var arr = [1, 2];
arr.forEach(function () {});  // O
Array.isArray(arr);           // O
arr.isArray();                // X TypeError

1-2. constructor 프로퍼티

var arr = [1,2];
Array.prototype.constructor === Array; // true
arr.__proto__.constructor === Array;   // true
arr.constructor === Array;             // true

var arr2 = new arr.constructor(3, 4);
console.log(arr2); // [3,4];

prototype 내부에는 constructor 프로퍼티가 있고, __proto__ 객체 내부에도 마찬가지이다.

이는 인스턴스로부터 그 원형이 무엇인지 알 수 있는 수단이 된다.

하지만 어떤 인스턴스의 생성자 정보를 알아내기 위해 constructor 프로퍼티에 의존하는게 안전하지만은 않다.

var NewConstructor = function(){
    console.log('this is new constructor');
};

var dataTypes = [
    1,            // Number & false
    'text',       // String & false
    true,         // Boolean & false
    {},           // NewConstructor & false ...
    [],           // 이하 같음
    function(){}, 
    /text/,       
    new Number(),
    new Date(),
    new Error()
];

dataTypes.forEach(function(d){
    d.constructor = NewConstructor;
    console.log(d.constructor.name, '&', d instanceof NewConstructor);
});

constructor은 기본형 리터럴 변수(number, string, boolean)을 제외하고 값을 바꿀 수 있다.

dataTypes의 첫번째~세번째 까지는 기본형 리터럴 변수이기 때문에 constructor의 이름이 변경되지 않았지만 아래의 데이터들은 NewConstructor로 이름이 변경되었다.

하지만 참조하는 대상이 변경됐을 뿐 인스턴스의 원형이 바뀐 것은 아니기 때문에 d instanceof NewConstructor부분에서 false라는 결과가 나온 것이다.

2. 프로토타입 체인

2-1. 메서드 오버라이드

만약 인스턴스가 prototype과 동일한 이름의 프로퍼티나 메서드를 갖고있으면 어떨까?

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

Person.prototype.getName = function () {
  return this.name;
};

var sora = new Person('소라');
sora.getName = function () {
  return '나는 ' + this.name;
};
console.log(sora.getName());  // 나는 소라

sora.__proto__.getName이 아닌 sora 객체에 있는 getName 메서드가 호출됐다.

여기서 일어난 현상을 메서드 오버라이드라고 한다.

자바스크립트 엔진이 메서드를 찾는 방식은 가장 가까운 대상인 자신의 프로퍼티를 검색하고, 없으면 그 다음으로 가까운 대상인 __proto__를 검색하는 순서로 진행된다.

메서드 오버라이드는 메서드 위에 메서드를 덮어씌운 것이기 때문에 원본에 접근할 수 있다.

그러면 prototype에 있는 메서드에 접근하려면 어떻게 해야할까?

Person.prototype.name = 'Sora';
console.log(sora.__proto__.getName()); // Sora, this가 prototype을 보고 있음
console.log(sora.__proto__.getName.call(sora)); // 소라, this가 인스턴스를 보고 있음

2-2. 프로토타입 체인

prototype 객체는 '객체'이기 때문에 모든 객체의 __proto__에는 Object.prototype이 연결된다.

var arr = [1, 2]; 를 console.dir로 찍어보면
prototype안에 prototype이 Object로 되어있는 걸 알 수 있다.

따라서 arr가 Array.prototype 내부의 메서드를 실행할 수 있는 것 처럼 Object.prototype 내부의 메서드도 실행할 수 있는 것이다.
ex) arr.hasOwnProperty(2); // true

어떤 데이터의 __proto__ 프로퍼티 내부에 다시 __proto__ 프로퍼티가 연쇄적으로 이어진 것을 프로토타입 체인이라 하고, 이 체인을 따라가며 검색하는 것을 프로토타입 체이닝이라고 한다.

var arr = [1, 2];
Array.prototype.toString.call(arr); // 1,2
Object.prototype.toString.call(arr); // [object Array]
arr.toString(); // 1,2

arr.toString은 Array.prototype.toString이 실행되는걸 알 수 있다!

2-3. 객체 전용 메서드의 예외사항

어떤 생성자 함수이든 prototype은 객체이기 때문에 Object.prototype이 언제나 프로토타입 체인의 최상단에 위치하게 된다.

따라서 객체에서만 사용할 메서드를 프로토타입 내부에 정의한다면 다른 데이터 타입도 해당 메서드를 사용할 수 있기 때문에 객체 전용 메서드들은 스태틱 메서드(static method)로 부여되어 있다.

ex) Object.freeze(instance), Object.getPrototypeOf(instance)

반대로 toString, hasOwnProperty, valueOf, isPrototypeOf 같이 어떤 데이터에서도 활용할 수 있는 범용적인 메서드들도 존재한다.

2-4. 다중 프로토타입 체인

프로토타입 체인은 반드시 2단계로 이루어지는 것이 아니라 무한대의 단계를 생성할 수 있다.

var Grade = function () {
  var args = Array.prototype.slice.call(arguments);
  for (var i = 0; i < args.length; i++) {
    this[i] = args[i];
  }
  this.length = args.length;
};
var g = new Grade(100, 80);

변수 g는 유사배열객체라서 배열 메서드를 쓸 수 없다.
배열 메서드를 직접 쓰게 하기 위해서는 g.__proto__, 즉 Grade.prototype이 배열의 인스턴스를 바라보게 하면 된다.

Grade.prototype = [];

그러면 이제 g에서 직접 배열 메서드를 사용할 수 있다!

g.pop();
g.push(90);

Grade의 prototype이 Array가 된 것을 확인할 수 있다.

profile
개발괴발

0개의 댓글