JavaScript

[코어자바스크립트] 프로토타입

공쥬쥬 2022. 12. 14. 18:28

 

 

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

프로토타입 기반 언어에서는 어떤 객체를 원형으로 삼고 이를 복제함으로써 상속과 비슷한 효과를 얻는다.

 

 

프로토타입의 개념과 이해

 

var instance = new Constructor();

  • constructor(생성자 함수)를 new와 함께 호출한다.
  • constructor에 정의된 내용을 바탕으로 새로운 인스턴스가 생성된다.
  • 이때 instance에는 __proto__라는 프로퍼티가 자동으로 부여된다.
  • __proto__는 constructor의 prototype(객체)이라는 프로퍼티를 참조한다.

prototype은 객체다. prototype을 참조하는 __proto__도 객체이다. 

prototype 객체 내부에는 인스턴스가 사용할 메서드를 저장한다. 인스턴스에서도 __proto__를 통해 이 메서드에 접근할 수 있다.

 

 

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

Person.prototype.getName = function() {
	return this._name;
 }
 
 var suzi = new Person('Suzi');
 suzi.__proto__.getName();      //undefined
 // undefined가 출력된 것은 함수를 실행했음을 의미-> getName은 함수이며 실행되었음
 // this 바인딩이 잘못되어 undefined가 출력됨
 
 Person.prototype === suzi.__proto__     //true

 

위 예시의 함수는 this.name 값을 리턴한다. 

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

따라서 getName함수가 바라보는 this는 곧 suzi.__proto__ 라는 객체가 되는 것이며, 객체 내부에 name이라는 프로퍼티가 없으므로 undefined가 반환된다. 

 

 

 

this를 인스턴스로 사용하고 싶다면 __proto__를 생략하면 된다. 원래부터 생략가능하다.

suzi.__proto__.getName
=> suzi(.__proto__).getName
=> suzi.getName

 

 

프로토타입의 개념은

 

자바스크립트 함수에 자동으로 객체인 prototype 프로퍼티를 생성해 놓는데, 해당 함수를 생성자 함수로서 사용할 경우(new 연산자), 그로부터 생성된 인스턴스에는 숨겨진 프로퍼티인 __proto__ 가 자동으로 생성되며, 이 프로퍼티는 생성자 함수의 prototype 프로퍼티를 참조한다.

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

 

 

하지만 아래의 경우는 생성자 함수에서 직접 접근해야 실행이 가능하다

var arr = [1, 2];
arr.forEach(function() {}); // (0)
Array.isArray(arr); // (0) true
arr.isArray(); // (x) TypeError: arr.isArray is not a function

prototype 프로퍼티 내부에 있지 않은 메서드들은 인스턴스가 직접호출을 할 수 없기 때문이다 !

 

 

constructor 프로퍼티

생성자 함수의 프로퍼티인 prototype 객체 내부에는 constuctor라는 프로퍼티가 있다. 인스턴스의 __proto__ 객체 내부에도 마찬가지이다. 원래의 생성자 함수(자기자신)를 참조하는데, 인스턴스로부터 그 원형이 무엇인지 알 수 있는 수단이 된다. 

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]

 

constructor는 읽기 전용 속성이 부여된 예외적인 경우를 제외하고는 값을 바꿀 수 있다.

var NewConstructor = function() {
  console.log('this is new constructor!');
};
var dataTypes = [
  1, // Number & false
  'test', // String & false
  true, // Boolean & false
  {}, // NewConstructor & false
  [], // NewConstructor & false
  function () {}, // NewConstructor & false
  /test/, // NewConstructor & false
  new Number(), // NewConstructor & false
  new String(), // NewConstructor & false
  new Boolean, // NewConstructor & false
  new Object(), // NewConstructor & false
  new Array(), // NewConstructor & false
  new Function(), // NewConstructor & false
  new RegExp(), // NewConstructor & false
  new Date(), // NewConstructor & false
  new Error() // NewConstructor & false
];

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

모든 데이터가 d instanceof Newconstructor 명령에 대해 false를 반환한다. 

이로부터 constructor를 변경하더라도 참조하는 대상이 변경될 뿐 이미 만들어진 인스턴스의 원형이 바뀐다거나 데이터 타입이 변하는 것은 아님을 알 수 있다. 어떤 인스턴스의 생성자 정보를 알아내기 위해 constructor 프로퍼티에 의존하는 것이 항상 안전하지 않다는 것을 알 수 있다. 

 

 

프로토타입 체인

메서드 오버라이드

인스턴스가 동일한 이름의 프로퍼티 또는 메서드를 가지고 있으면 메소드 오버라이드가 일어난다. 

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

var iu = new Person('지금');
iu.getName = function() {
  return '바로 ' + this.name;
};
console.log(iu.getName()); // 바로 지금

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

원본을 제거하고 다른 대상으로 교체하는 것이 아니라 원본이 그대로 있는 상태에서 다른 대상을 그 위에 얹는 이미지를 생각하면 쉽다.

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

 

 

만약 인스턴스가 prototype을 바라보게 바꾸고 싶다면 call, apply를 사용하면된다.

console.log(iu.__proto__.getName.call(iu)); // 지금

 

 

프로토타입 체인

console.dir([1, 2]);

 

 

배열의 내부 구조는 __proto__ 안에 다시 __proto__ 가 있다. ( __proto__ 와 동일한 내용으로)

이는 prototype 객체가 객체이기 때문이다.

 

 

__proto__는 생략 가능하기 때문에, 배열은 Array.prototype 내부의 메서드를 자신의 것처럼 사용할 수 있다. == 객체 메서드로 실행가능

var arr = [1, 2];
arr(.__proto__).push(3);
arr(.__proto__)(.__proto__).hasOwnProperty(2); // true
  • 프로토타입 체인 : 어떤 데이터의 __proto__ 프로퍼티 내부에 다시 __proto__프로퍼티가 연쇄적으로 이어진 것
  • 프로토타입 체이닝 : 프로토타입 체인을 따라가며 검색하는 것

 

프로토타입 체이닝은 메서드 오버라이드와 동일한 맥락이다. 어떤 메서드를 호출하면 자바스크립트 엔진은 데이터 자신의 프로퍼티들을 검색해서 원하는 메서드가 있으면 그 메서드를 실행하고, 없으면 __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 = function() {
  return this.join('_');
};
arr.toString(); // 1_2

 

객체 전용 메서드의 예외사항

어떤 생성자 함수든 prototype은 반드시 객체이기 때문에 Object.prototype이 언제나 프로토타입 체인의 최상단에 존재하게 된다. 따라서 객체에서만 사용할 메서드는 다른 여느 데이터 타입처럼 프로토타입 객체 안에 정의 할 수가 없다. 객체에서만 사용할 메서드를 Object.prototype 내부에 정의한다면 다른 데이터 타입도 해당 메서드를 사용할 수 있게 되기 때문이다. 

Object.prototype.getEntries = function() {
var res = [];
  for (let prop in this) {
    if (this.hasOwnProperty(prop)) {
      res.push([prop, this[prop]]);
    }
  }
  return res;
};
let data = [
  ['object', { a: 1, b: 2, c: 3 }], //[["a",1], ["b",2],["c",3]]
  ['number', 345], // []
  ['string', 'abc'], //[["0","a"], ["1","b"], ["2","c"]]
  ['boolean', false], //[]
  ['func', function () {}], //[]
  ['array', [1, 2, 3]]
 // [["0", 1], ["1", 2], ["2", 3]]
  ];
data.forEach(function(datum) {
  console.log(datum[1].getEntries())
});

모든 데이터가 오류 없이 결과를 반환한다. 원래 의도대로라면 객체가 아닌 다른 데이터 타입에 대해서는 오류를 던져야 하는데, 어떤 데이터 타입이건 대부분 프로토타입 체이닝을 통해 getEntries 메서드에 접근할 수 있으니 그렇게 동작하지 않는다.

 

이러한 이유로 객체만을 대상으로 동작하는 메서드들은 Object.prototype이 아닌 Object에 스태틱 메서드(static method)로 부여할 수 밖에 없다. 또한 생성자 함수인 Object와 인스턴스인 객체 리터럴 사이에는 this를 통한 연결이 불가능 하기 때문에 여느 전용 메서드처럼 "메서드 명 앞의 대상이 곧 this"가 되는 방식 대신 this의 사용을 포기하고 대상 인스턴스를 인자로 직접 주입해야하는 방식으로 구현 돼 있다.

 

같은 이우에서 object.prototype에는 어떤 데이터에서도 활용할 수 있는 범용적인 메서드들만 있다. 

toString,  hasOwnProperty,  valueOf,  isPrototypeOf 등은 변수가 마치 자신의 메서드 인 것 처럼 호출 할 수 있다.

 

 

프로토타입 체인상 가장 마지막에는 언제나 Object.prototype이 있다고 했는데, 예외적으로 Object.create를 이용하면 Object.prototype에 접근할 수 없는 경우가 있다. Object.create(null)은 __proto__가 없는 객체를 생성한다

var _proto = Object.create(null);
_proto.getValue = function(key) {
  return this[key];
};
var obj = Object.create(_proto);
obj.a = 1;
console.log(obj.getValue('a')); // 1
console.dir(obj);

 

 

다중 프로토타입 체인

대각선의 __proto__ 를 연결해 나가기만 하면 무한대로 체인 관계를 만들 수 있는데, 이 방식으로는 다른언어의 클래스와 비슷하게 동작하는 구조를 만들 수 있다.

대각선의 __proto__를 연결하는 방법은 __proto__ 가 가리키는 대상, 즉 생성자 함수의 prototype이 연결하고자 하는 상위 생성자 함수의 인스턴스를 바라보게끔 해주면 된다. 

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

변수 g는 Grade의 인스턴스를 바라본다. Grade의 인스턴스는 여러 개의 인자를 받아 각각 순서대로 인덱싱해서 저장하고 length 프로퍼티가 존재하는 등 배열의 형태를 지니지만 배열 메서드를 사용할 수 없는 유사배열객체이다.

이 인스턴스에서 배열 메소드를 직접 쓸 수 있게끔하고 싶으면 g.__proto__ , 즉 Grade.prototype이 배열의 인스턴스를 바라보게 하면된다

Grade.prototype = [];

바라보게 되면 Grade의 인스턴스인 g에서 직접 배열의 메서드를 사용할 수 있다.

console.log(g); // Grade(2) [100, 80]
g.pop()
console.log(g) // Grade(1) [100]
g.push(90)
console.log(g) // Grade(2) [100, 90]