프로토타입

심현인·2021년 7월 9일
0
post-custom-banner

JS는 프로토타입 기반 언어다.
클래스 기반 언어에서는 상속을 사용하지만, JS는 그와 비슷한 개념으로 원형(프로토타입)을 복제(참조)를 함으로써 상속과 비슷한 기능을 한다.
따라서 프로토타입은 다른 OOP들과 차별점을 갖게 해준다.
프로토타입을 제대로 이해하는 것만으로도 JS의 숙련자레벨에 도달할 수 있는 시야를 갖게된다.
(참고로 이 책의 저자는 프로토타입을 이해하는데 1년이 걸렸다..!!)

constructor, prototype, instance

let instance = new Constructor()

위의 코드를 그림으로 표현하자면 이렇다.

  • 어떤 생성자 함수를 new 연산자와 함께 호출하면(Constructor)
  • Constructor에서 정의된 내용을 바탕으로 새로운 인스턴스가 생성된다
  • 이때 instance에는 __proto__ 라는 프로퍼티가 자동으로 부여되는데
  • 이 프로퍼티는 Constructor의 prototype이라는 프로퍼티를 참조한다.

사실 이게 전부다.
다시 한 번 코드로 살펴보자.

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

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

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

var potato = new Person('감자')
potato.__proto__.getName() //undefined

왜냐하면 instance의 __proto__가 Constructor의 prototype 프로퍼티를 참조하기 때문에 둘이 같은 객체를 바라보기 때문이다.

Person.prototype ===potato.__proto__ //true

두 번째 코드의 결과가 undefined가 나왔다. '감자'가 나오지 않은 것보다 왜 에러가 발생하지 않았을까?
어떤 변수를 실행해 undefined가 나왔다는 것은 이 변수가 '호출할 수 있는 함수'에 해당한다는 것을 의미한다. 만약 함수가 아닌 다른 데이터 타입이 었다면 TypeError가 나왔을 것이다. 그런데 에러가 아닌 다른 값이 나왔으니 getName이 실제로 실행된 것임을 알 수 있고, 이로부터 getName이 함수라는 것이 입증됐다.

다음은 함수 내부로구터 어떤 값을 반환하는지를 살펴보자. this.name 값을 리턴하도록 되어있는데, 그렇다면 this에 원래의 의도와는 다른 값이 할당된 것이 아닐까? 라는 의문을 갖게 된다. 앞에서 배운것을 생각해보면 this의 바인딩된 대상이 잘못 지정된 것을 알 수 있다. 어떤 함수를 '메소드'로써 호출 할때에는 메소드명 바로 앞의 객체가 곧 this가 된다. 즉 potato.__proto__.getName() 에서 getName함수 내부에서의 this는 potato.__proto__라는 객체가 되는 것이다. 따라서 찾고자하는 식별자가 정의돼 있지 않을 경우Error대신 undefined를 반환한다 라는 JS의 규약에 의해 undefined가 리턴 된것이다.

그렇다면 만약 __proto__객체에 name 프로퍼티가 있다면 어떨까?

var potato = new Person('감자')
potato.__proto__._name = '감자__proto__';
potato.__proto__.getName(); // 감자__proto__

예상(?)했던 대로 감자__proto__가 잘 출력된다! 따라서 관건은 'this'다. this를 인스턴스에서 바로 사용하고 싶다면 __proto__없이 인스턴스에서 곧바로 메소드를 사용하는 것이다.

var potato = new Person('감자', 30)
potato.getName() //감자
var onion = new Person('양파', 26)
onion.getName() //양파

이렇게 __proto__를 빼면 this가 가르키는 것이 instance가 맞지만, 이대로 메소드가 호출되고 심지어 원하는 값이 나오는건 좀 이상하다. 하지만 이건 정상이다. 그 이유는 __proto__가 생략이 가능한 프로퍼티이기 때문이다. 이유는 없다. 그냥 그런줄 알아라. JS를 만든 브랜든 아이크의 생각이니 그냥 그런가보다 하고 넘어가자.

따라서 __proto__를 생략하지 않으면 this는 potato.__proto__를 가르키지만 이를 생략하면 potato를 가르킨다. 즉 potato.__proto__에 있는 메소드인 getName을 실행하지만 this는 potato를 바라보게 할 수 있게 된것이다.

new 연산자로 Constructor를 호출하면 instance가 만들어지는데, 이 instance의 생략가능한 프로퍼티인 __proto__는 Constructor의 prototype을 참조한다!
라고 이해하면 어느정도 프로토타입을 이해한것이다!!

좀 더 상세히 설명하면 JS는 함수에 자동으로 객체인 prototype 프로퍼티를 생성해 놓는데, 해당 함수를 생성자 함수로 사용할 경우(new 연산자와 함께 함수를 호출할 경우) 그로부터 생성된 인스턴스에는 숨겨진 프로퍼티인 __proto__가 자동으로 생성되며, 이 프로퍼티는 생성자 함수의 prototype 프로퍼티를 참조한다. __proto__프로퍼티는 생략 가능하도로고 구현돼있기 때문에
생성자 함수의 prototype에 어떤 메소드나 프로퍼티가 있다면 인스턴스에서도 마치 자신의 것처럼 해당 메소드나 프로퍼티에 접근할 수 있게 된다.

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

결과

위는 arr변수를 출력한 결과고 아래는 생성자 함수인 Array를 출력한 결과다.
위에 있는 arr변수는 Array라는 생성자 함수를 원형으로 생성되었고, 길이가 3이다. 인덱스인 0,1,2가 짙은 색상으로, length와 __proto__가 옅은 생삭으로 표기된다. 그 안에 옅은색상의 배열 메소드(push, pop, reduce, map등..)이 들어있다.
아래에는 첫 줄에 함수라는 표시의 f가 있고, 둘째줄 부터 함수의 기본 프로퍼티인 arguments, caller, length, name등이 옅은색으로 있다. 또한 Array함수의 정적 메소드인 from, isArray등도 있다.
(색상의 차이는 {enumerable: false}속성이 부여된 프로퍼티 여부에 따르는데, 짙은색이면 열거가 가능하고, 아니면 불가능하다. for in을 사용해서 접근유무를 알 수 있다.)
따라서 이 둘은 instance로 만들어진 [1,2]의 __proto__는 Array.prototype을 참조하고있고, __proto__는 생략이 가능하기 때문에 push, pop등의 메소들을 마치 자신의 것처럼 호출 할 수 있는것이다. 그러나 from, isArray등의 메소드들은 instance가 직접 호출을 할 수없고, Array 생성자 함수에서 직접 접근해야 실행이 가능합니다.

constructor 프로퍼티

생성자 함수의 프로퍼티인 prototype 객체 내부에는 constructor라는 프로퍼티가 있다. 인스턴스의 __proto__에서도 마찬가지이다. 이 프로퍼티는 단어 그대로 생성자함수(자기자신)을 참조한다.(..왜?)
왜 굳이 자신을 참조하는 프로퍼티를 갖고있나 하겠지만 인스턴스와의 관계에 있어서 필요한 정보고, 인스턴스로부터 그 원형이 무엇인지 알 수 있는 수단이기 때문이다.

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

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

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

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

var dataType = [
  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
];

dataType.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
};

var p1 = new Person('감자'); // {name:"감자"} true
var p1Proto = Object.getPrototypeOf(p1);
var p2 = new Person.prototype.constructor('배추'); // {name:"배추"} true
var p3 = new p1Proto.constructor('버섯'); // {name:"버섯"} true
var p4 = new p1.__proto__.constructor('양파'); // {name:"양파"} true
var p5 = new p1.constructor('고추'); // {name:"고추"} true

[p1, p2, p3, p4, p5].forEach(function(p){
  console.log(p, p instanceof Person)
})

p1부터 p5까지는 Person의 인스턴스이다.
따라서 Person의 prototype을 상속받고, 그 확인은 각 인스턴스__proto__로 확인이 가능하다

profile
가로
post-custom-banner

0개의 댓글