프로토타입

정수·2023년 3월 14일
0

JavaScript

목록 보기
9/15
post-thumbnail

프로토타입(prototype) 기반 언어에선 클래스를 기반으로 하여 '상속'을 사용하는 클래스 기반 언어와는 달리 프로토타입 기반 언어에서는 객체를 원형(prototype)으로 삼고 이를 복제(참조)한으로써 상속과 비슷한 효과를 얻습니다.

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

Constructornew 연산자로 호출하여 instance를 생성하였습니다. 해당 instance에서 기존 Constructor의 정보들을 호출하기 위해선

var jungsoo = new Person('Jungsoo');
jungsoo.__proto__.getName(); // undefined

여기서 호출값이 undefined인 것은 중요하지 않습니다. getName is not defined라는 오류가 나지 않은 것, 즉 실행 가능한 메소드라는 사실이 중요합니다.

Person.prototype === jungsoo.__proto__ // true

그럼 호출값은 왜 undefined가 나왔을까요? 그 이유는 this에게 있습니다. jungsoo.__proto__._namethisjungsoo.__proto__이지만 new 연산자로 호출한 생성자의 인자의 thisjungsoo이기 때문에 값이 없었던 것입니다.

jungsoo.__proto__._name = 'Jungsoo__proto__';
jungsoo.__proto__.getName(); // 'Jungsoo__proto__'

물론 위와 같이 값을 직접 지정해줄 수 있지만 우리들은 instance 생성과 함께 지정한 name을 호출하기를 원합니다. 그럼 어떻게 해야 할까요? 놀랍게도 __proto__는 생략 가능하도록 설계된 프로퍼티입니다. 그럼 아래와 같이 name을 호출할 수 있습니다.

jungsoo.getName(); // 'Jungsoo' (__proro__ 생략 가능)

조금 더 상세하게 설명하면

  • Javascript는 function에 자동으로 객체인 prototype 프로퍼티를 생성합니다.
  • function을 생성자로 사용할 경우 생성된 인스턴스에는 숨겨진 프로퍼티인 __proto__가 자동으로 생성됩니다.
  • 이는 생성자 함수의 프로퍼티인 prototype를 참조하기 때문에 어떤 메서드나 프로퍼티가 있다면 인스턴스에서도 마치 자신의 것처럼 접근할 수 있게 됩니다.
var Constructor = function (name) {
  this.name = name;
};
Constructor.prototype.method1 = function () {};
Constructor.prototype.property1 = 'Constructor Prototype Property';

var instance = new Constructor('instance');
console.dir(Constructor);
console.dir(instance);

출력된 코드를 확인해보면 Constructorprototype 구조가 instance__proto__ 구조와 동일한 것을 확인할 수 있습니다. 또한 instance의 출력 결과에선 Constructor을 출력함으로 해당 객체가 어떤 함수의 인스턴스인지 알려주고 있습니다.

콘솔 창의 비밀 - 색상 편

콘솔 창에서 프로퍼티를 살펴보면 짙은 색과 옅은 색의 차이가 존재합니다. 이는 enumerable 속성 차이이며 열거 가능한 프로퍼티, 즉 true 값을 가질 때는 짙은 색, false 값을 가질 때는 옅은 색으로 표시됩니다.

예제를 하나 더 살펴보겠습니다.

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

결과를 확인해보기 전에 var arr = new Array(1, 2);라는 생성자를 이용한 것이 아니라 리터럴 방식의 변수 선언 방식으로 데이터를 할당하였습니다. 이 둘의 차이는 추후 별도의 게시글을 작성하여 설명하도록 하겠습니다.

그럼 다시 돌아와서 둘의 코드를 비교해보면 이전과 동일하게 Arrayprototype과 배열 리터럴로 생성된 인스턴스인 [1, 2]__proto__는 완벽하게 일치합니다. 다만 prototype 내부에 있지 않은 from, isArray 등의 메서드들은 인스턴스가 직접 호출할 수 없습니다.

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

// 이해가 되지 않는 부분...
// 직접 method를 지정하는 것과 차이가 무엇인가? 왜 굳이 prototype을 지정해서 해야하나?
// 생성자 전용 함수(상속되지 않는 함수, 정적 메서드)는 어떻게 지정하는 것인가?
// 우선 넘겨보자
var Constructor = function (name) {
  this.name = name;
  this.method1 = function(){ console.log(this); };
};
Constructor.prototype.method1 = function(){ console.log(this); };

var test = new Constructor('hihi');

test.__proto__.method1()
test.method1()

console.dir(Constructor);
console.dir(test);

constructor

위 결과들을 확인해보면 생성자 함수의 프로퍼티인 prototype 객체 내부에 constructor라는 프로퍼티가 있습니다. 인스턴스의 __proto__ 객체 내부에도 동일하겠죠. 이 프로퍼티는 단어 그대로 원래의 생성자 함수(자기 자신)를 참조합니다. 이 덕분에 인스턴스로부터 그 원형이 무엇인지 알 수 있습니다.

constructor는 읽기 전용 속성이 부여된 예외적인 경우(기본형 리터럴 변수 - number, string, boolean)를 제외하고는 값을 변경할 수 있습니다.

var NewConstructor = function () {
  console.log('NEW!!');
};
var dataTypes = [
  1, // Number & false
  'test', // String & false
  true, // Boolean & false
  {}, [], function () {}, new Number(), new String(), ... // NewConstructor & false
];
  
dataTypes.forEach(function (d) {
  d.constructor = NewConstructor;
  console.log(d.constructor.name, '&', d instanceOf NewConstructor);
});

모든 데이터가 d instanceOf NewConstructor에 대해 false 값을 반환합니다. 이로 인해 이미 만들어진 인스턴스의 원형이나 데이터 타입이 변하는 것은 아닙니다. 인스턴스의 생성자 정보를 알아내기 위해 constuctor 프로퍼티에 의존하는 것이 항상 안전하진 않은 것입니다.

하지만 오히려 그렇기 때문에 클래스 상속을 흉내 내는 등이 가능해진 측면도 있습니다. 이 역시 추후 관련 게시글을 추가로 작성하며 다루겠습니다.

메서드 오버라이드

var Person = function (name) {
  this.name = name;
  this.getName = function () {
    return '바로 ' + this.name;
  };
};

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

var iu = new Person('지금');
console.log(iu.getName()); // 바로 지금

Javascript Engine이 getName 이라는 메서드를 찾는 방식은 가장 가까운 대상인 자신의 프로퍼티를 검색하고, 없으면 그다음으로 가까운 대상인 __proto__를 검색하는 순서로 진행됩니다. 즉, 2개의 메서드는 교체되는 것이 아니라 얹어지는 것이며 정의하는 순서에 상관없이 무조건 자신의 프로퍼티에 해당 메서드가 있다면 해당 메서드를 실행시킵니다.

또한 얹어지는 것이므로 prototype에 있는 메서드에 접근하는 것도 가능합니다.

console.log(iu.__proto__.getName()); // undefined

iu.__proto__.getName()에서의 thisiu.__proto__ 인데 해당 객체의 prototype 상에서는 name 프로퍼티가 없기 때문에 undefined가 출려되었습니다.

Person.prototype.name = '이지금';
console.log(iu.__proto__.getName()); // 이지금

생성자 함수의 prototypename을 변경해주니 잘 호출되는 모습을 확인할 수 있습니다. 잘 참조하고 있군요. thisprototype을 바라보고 있는데 이것만 잘 변경해주면 인스턴스의 name을 불러올 수 있습니다.

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

프로토타입 체인

객체의 내부 구조를 우선 살펴봅시다.

console.dir({ a: 1 });
// Object
//   a: 1
//   __proto__:
//     constructor: f Object()
//     ...
//     (Object의 prototype)
//     ...

console.dir([1, 2]);
// Array(2)
//   0: 1
//   1: 2
//   __proto__: Array(0)
//     concat: f concat()
//     ...
//     (Array의 prototype)
//     ...
//     __proto__:
//       constructor: f Object()
//       ...
//       (Object의 prototype)
//       ...

위 코드의 구조를 확인해보면 배열 리터럴의 __proto__ 안에 또다시 __proto__가 등장합니다. 구조를 보아하니 Object__proto__와 동일한 내용으로 이뤄져 있네요. 이는 prototype 또한 객체이기 때문입니다. 그렇기에 기본적으로 prototype을 포함한 모든 객체의 __proto__ 에는 Object.prototype이 연결됩니다.

console.dir([1, 2].__proto__.constructor) // f Array()
console.dir([1, 2].__proto__.__proto__.constructor) // f Object()

이렇듯 어떤 데이터의 __proto__ 프로퍼티 내부에 다시 __proto__ 프로퍼티가 연쇄적으로 이어진 것을 프로토타입 체인(prototype chain)이라고 하며 이 체인을 따라 검색하는 것을 프로토타입 체이닝(prototype chaining)이라고 합니다.

프로토타입 체이닝 또한 위 메서드 오버라이드의 getName의 예시와 동일하게 거리가 가까울 수록 우선순위가 높습니다.

더 나아가...

모든 prototypeObject의 인스턴스이고 ArrayFunction의 인스턴스입니다. Function은 또 다른 Function의 인스턴스이죠. 이런 식으로 __proto__constructor__proto__constructor 를 재귀적으로 끝없이 찾아갈 수 있습니다. 하지만 결국은 같은 Function을 가리키고 있기 때문에 그렇게 파고들 이유는 없습니다.

실제 메모리 상에서 데이터를 무한대의 구조 전체를 들고 있는 것은 아닙니다. 다만 사용자가 이런 루트를 통해 접근하고자 할 때 비로소 해당 정보를 얻을 수 있을 뿐입니다.

예외사항

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

그 이유는 Object.prototype이 참조형 데이터뿐 아니라 기본형 데이터조차 __proto__에 반복 접근함으로써 도달할 수 있는 최상위 존재이기 때문입니다.

반대로 Object.prototype에는 어떤 데이터에서도 활용할 수 있는 범용적인 메서드들만 있습니다.

prototype에 접근할 수 없는 Object

Object.create(null)을 이용하여 객체를 생성할 경우 constructor 등과 같은 프로퍼티가 참조되지 않습니다. 기본 기능에 제약이 생긴 대신, 객체 자체의 무게가 구벼워짐으로써 성능상 이점을 가집니다.

다중 프로포타입 체인

Javascript 기본 내장 데이터 타입들은 모두 프로토타입 체인인 1단계이거나 2단계로 끝나는 경우만 있었지만 그 이상을 만든 것도 얼마든지 가능합니다.

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

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

Grade의 인스턴스는 여러 개의 인자를 받아 각각 순서대로 인덱싱해서 저장하고 length프로퍼티가 존재하는 등으로 배열의 형태를 지니지만, 배열의 메서드를 사용할 수 없는 유사배열객체입니다. 그럼 어떻게 인스턴스에서 배열 메서드를 직접 쓸 수 있게끔 할 수 있을까요?

Grade.prototype = [];

생각보다 단순합니다. Grade.prototype이 배열 인스턴스를 바라보게 할당해주면 됩니다.

profile
해피한하루

0개의 댓글