[JavaScript] Prototype 핵심내용 이해하기

Dico·2021년 3월 12일
0

[JavaScript]

목록 보기
19/21

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

자바스크립트는 흔히 프로토타입 기반 언어(prototype-based language)라 불린다. 객체들이 메소드와 속성들을 상속받기 위한 템플릿으로써 프로토타입 객체(prototype object)를 가진다는 의미다.
프로토타입 객체도 다시 상위 프로토타입으로부터 상속받을 수 있고, 그 상위 프로토타입 객체도 마찬가지이므로 프로토타입 체인(prototype chain)이라 부른다.

사실 생성자함수는 인스턴스에 메소드와 속성을 상속해준 뒤에도 여전히 해당 메소드와 속성을 가지고 있다. 따라서 "상속" 보다는 "behavior delegation"혹은 "위임"의 개념에 더욱 가깝다.

상속되는 속성과 메소드들은 각 객체에 정의되어 있는 것이 아니라,
생성자함수의 prototype이라는 속성에 정의되어 있다.
즉, 객체 인스턴스가 생성자함수와 연결되는 것이 아니라 생성자함수의 prototype과 연결되는 것이다!


Constructor, prototype, instance

Constructor와 prototype, 그리고 객체 instance의 관계를 도식화하면 아래와 같다.

  • 어떤 생성자 함수(Constructor)new 연산자와 함께 호출하면
  • Constructor에서 정의된 내용을 바탕으로 새로운 인스턴스(instance)가 생성된다.
  • 이때 instance에서는 __proto__라는 프로퍼티가 자동으로 부여되는데,
  • 이 프로퍼티는 Constructor의 prototype이라는 프로퍼티를 참조한다.
  • __proto__프로퍼티는 생략 가능하도록 구현돼 있기 때문에 생성자 함수의 prototype에 어떤 메서드나 프로퍼티가 있다면 인스턴스에서도 마치 자신의 것처럼 해당 메서드나 프로퍼티에 접근할 수 있게 된다.
//ex.
const arr = [1, 2]

arr는 생성자함수 Array()의 instance이다. 그리고 Object()는 Array()의 constructor이다.


Dunder proto

__proto__: Dunder proto; exposes the value of the internal [[Prototype]] of an object. For objects created using an object literal, this value is Object.prototype. For objects created using array literals, this value is Array.prototype. For functions, this value is Function.prototype.
-MDN

ES5.1 명세에는 __proto__가 아니라 [[prototype]]이라는 명칭으로 정의돼 있습니다. __proto__라는 프로퍼티는 사실 브라우저들이 [[prototype]]을 구현한 대상에 지나지 않았습니다. ... ES6에서는 이를 브라우저에서 동작하는 레거시 코드에 대한 호환성 유지 차원에서 정식으로 인정하기 시작했습니다. 다만 어디까지나 브라우저에서의 호환성을 고려한 지원일 뿐 권장되는 방식은 아니며, 브라우저가 아닌 다른 환경에서는 얼마든지 이 방식이 지원되지 않을 가능성이 열려있습니다.
-코어자바스크립트

Dunder proto(__proto__) 프로퍼티는 생성자 함수의 프로퍼티를 가리킨다.
생성자 함수의 인스턴스가 생성될 때마다 다른 프로퍼티 & 메소드와 함께 dunder proto 도 인스턴스에 복사된다.
학습목적으로 이해는 하되, 실무에서 사용하지는 말 것❗️
대신에 Object.getPrototypeOf() 혹은 Object.create()를 이용하기.

//만일 'john'객체가 생성자함수'Person'의 인스턴스라면, 
john.__proto__ === Person.prototype      //true 

프로퍼티 위임(상속)

본격적으로 자바스트립트에서 프로퍼티를 위임하는 방법을 알아보자!

new 생성자함수();

첫번째는new키워드를 이용해 인스턴스를 생성하는 방법이다.

new constructor[([arguments])]

아래의 예시를 통해 이를 시각화 해보고자 한다.

//생성자함수 Person
const Person = function (name, yearOfBirth, job) {
  this.name = name;
  this.yearOfBirth = yearOfBirth;
  this.job = job;
};

Person.prototype.calculateAge = function () {
  console.log(2016 - this.yearOfBirth);
};

Person.prototype.lastName = "Smith";

//Person의 인스턴스 john
const john = new Person("John", 1990, "teacher");

위 예시에서는 new 키워드를 이용해 인스턴스인 john을 생성했다.
아래와 같이 개발자도구의 console은 prototype 연쇄를 보다 직관적으로 보여준다.

인스턴스는 부모객체 혹은 생성자함수의 prototype 객체에 접근할 수 있기 때문에
일치하는 프로퍼티명이 나올 때까지, 혹은 연쇄가 끝날 때까지 prototype 연쇄를 따라 올라간다.

/*lastName은 Person.prototype에 위치한 프로퍼티이다.*/
console.log(john.lastName);       //Smith

최상위 prototype 연쇄는 내장객체인 Object.prototype이다.
따라서 연쇄가 끝나는 지점 또한 Object.prototype이다.
+PLUS:Object.prototype.__proto__;null을 반환한다.
만일 연쇄 끝에 이르러서도 원하는 프로퍼티가 발견되지 않으면 undefined를 반환한다.

console.log(john.age);            //undefined 

john객체는 Object.prototypehasOwnProperty() 메소드 또한 사용할 수 있는데,
이것은 특정 프로퍼티가 john객체 내부에 존재하는지, 아니면 상위 prototype 객체에 존재하는지를 구분한다.

/*job은 john내부에 위치한 프로퍼티이다.*/
john.hasOwnProperty('job');       //true

/*lastName은 Person.prototype에 위치한 프로퍼티이다.*/
john.hasOwnProperty('lastName');  //false

Object.create();

prototype을 상속받는 방법에는 new키워드를 이용해 인스턴스를 생성하는 방법 이외에도, Object.create()를 이용하는 방법이 있다.

Object.create(proto[, propertiesObject])

new키워드를 이용하면 생성자함수의 prototype 프로퍼티를 그대로 상속받지만, Object.create()를 이용해 객체 생성 시,
1) 빈 객체{}를 반환한다.
2) Object.create()의 인자로 들어오는 것이 생성되는 빈 객체의 prototype이 된다.
즉, 어떤 객체가 prototype이 되어야 하는 지 정확히 지정하는 것이 가능하다!

복잡하게 얽힌 prototype 연쇄도 보다 간단하게 만들 수 있다는 장점이 있어 가장 많이 쓰이는 방법이다.

const personProto = {
  calculateAge: function () {
    console.log(2016 - this.yearOfBirth);
  },
};

/*객체 john 생성*/
const john = Object.create(personProto);
john.name = "John";
john.yearOfBirth = 1990;
john.job = "teacher";

/*객체 jane 생성 - 두번째 매개변수 주목!*/
const jane = Object.create(personProto, {
  name: { value: "Jane" },
  yearOfBirth: { value: 1969 },
  job: { value: "designer" },
});

johnObject.create()의 첫번째 인자인 personProto에서 직접적으로 prototype을 상속받는다.
그리고 나머지 프로퍼티들은 동적으로 생성하여 추가하였다.

반면,jane필요한 프로퍼티들을 가진 객체를 Object.create()의 두번째 인자로 전달받았다.
이 때 주의할 점은, 값을 {}으로 감싸야한다는 점이다.

console 창을 통해 확인해보면 두 객체 모두 같은 동일한 prototype을 가진 객체가 되었음을 알 수 있다.

john.calculateAge();       //26
jane.calculateAge();       //47

prototype은 가족이다?! 👨‍👩‍👧

프로토타입을 설명하는 자료는 다양하게 있지만,
그 중에서도 가장 기억에 남으면서도 이해가 쏙쏙 되었던 것이 'Family-oriented Prototype' 컨셉이라, 여기서 잠깐 되짚어보고 가려고 한다!

Prototype을 가족관계에 비유하면,

  • 모든 생성자함수는 아내이다. prototype(남편)의 입장에서constructor메소드는 아내를 가리킨다.
  • 모든 prototype객체는 남편이다. 인스턴스(자녀)의 입장에서 __proto__는 아빠를 가리킨다.
  • 남편과 아내는 1:1로 매칭되어있다.
  • 생성자함수로 만들어진 인스턴스는 자녀다.
  • 모든 자녀는 아빠(prototype객체)로부터 속성을 빌려쓸 수 있다.

예를 들어 아래와 같이Dog이라는 생성자 함수가 있다고 하자.

//생성자함수 = 엄마
function Dog() {
  this.name = "Freddie";
  this.color = "brown";
  this.numLegs =4; 
}

//Dog의 인스턴스 = 자녀 
let hound = new Dog();

생성자함수인 Dog엄마가된다.
엄마로 인해 만들어진 hound인스턴스는 자녀가 된다.
그리고 Dog과 동시에 생겨난 Dog.prototypeDog남편이자 hound아빠가 되는 것이다.

만일 hound가 원하는 속성을 자기 내부에 가지고 있지 않다면, 아빠인 Dog.prototypehound에게 자신의 속성들을 빌려줄 것이다!


prototype 지정과 constructor

생성자함수명.prototype = {constructor: 생성자함수명}

위 비유에서 쓰인 예제를 통해 설명을 이어가자면,
Dog의 인스턴스인 hound는 새로운 빈 객체로 생겨나는 동시에 Dog내부에서 this.__로 정의된 속성들을 할당받게 된다.

앞서 언급되었던 것처럼 아내와 남편은 1:1매칭이므로, 모든 함수는 prototype객체를 내장한 채로 태어난다.
생성자함수 Dog도 prototype객체(남편)를 갖고 있으며,hound는 자신의 아빠인 Dog.prototype에 접근할 수 있게 된다.

우리가 Dog의 모든 인스턴스들이 사용할 수 있도록 생성자함수에 속성을 추가하고자 할 때,

Dog.prototype.bark = "wuff";

간단히 위와 같이 추가할 수도 있지만,
여러 개의 속성을 한꺼번에 추가할 때에는 새로운 객체를 prototype에 할당하는 방법도 있다.

Dog.prototype = {
  hasTail: "Yes", 
  eat: function() {
    console.log("nom nom nom");
  },
  describe: function() {
    console.log("My name is " + this.name);
  }
};

그런데 사실 이 방법은 아래와 같이 기존 constructor속성을 지워버리는 부작용이 있다.

Dog.prototype.constructor;   //function Object(){...} (Uh-oh❗️🤭)

prototype.constructor라면 prototype객체의 아내를 가리키게 되는 격인데, 반환값은 Dog이 아니라 Object(){...}가 되어있다!
이건 명백히 옳지않은 반환값이므로 객체 내부에 constructor: 생성자함수명 이라는 코드를 추가해야지만 constructor 속성의 의도치 않은 변화를 바로잡을 수가 있다.

객체를 prototype에 할당할 때에는, constructor: 생성자함수명;코드를 포함시킨다.

Dog.prototype = {
  constructor: Dog,  //<--***************다시 원래대로 돌려놓기*****************
  hasTail: "Yes", 
  eat: function() {
    console.log("nom nom nom");
  },
  describe: function() {
    console.log("My name is " + this.name);
  }
};

Dog.prototype.constructor;    //function Dog(){...}   (😊)

subtype.prototype = Object.create(supertype.prototype); 그리고 subtype.prototype.constructor = subtype;

subtype자식이 되는 객체를 가리킨다.
supertype부모가 되는 객체를 가리킨다.

아래와 같이 subtype.prototype = Object.create(supertype.prototype);를 통해 객체들을 prototype chain(프로토타입 연쇄; 여기서는 부모-자식객체 간 프로퍼티 위임을 받을 수 있는 관계성을 말함)으로 연결해줄 수 있다.

/*supertype:Animal, subtype:Dog*/
Dog.prototype = Object.create(Animal.prototype);

/*supertype:Dog, subtype:beagle*/
let beagle = new Dog();

특이한 점은,
한 객체가 다른 객체에게 prototype을 위임할 때 supertype객체의 constructor속성이 함께 위임된다는 것이다.
따라서 override 된 constructor속성을 subtype.prototype.constructor = subtype;으로 반드시 정정해줘야 한다.⭐️⭐️⭐️

그래서 현재상태에서는 beagle인스턴스가 constructorDog생성자함수의 상위객체인 Animal 을 가리키고 있는 것을 확인할 수 있다.

console.log(beagle.constructor);    //function Animal(){...}

여기서 다시 family-oriented prototype이론을 적용해보자면, beagleDog의 자녀가 된다.
그리고 DogAnimal.prototype의 자녀가 된다.

beagleconstructor라는 속성이 없기 때문에 beagle.constructor가 값을 반환할 수 있는 이유는 beagle이 아빠인 Dog.prototype객체로부터 속성을 빌려올 수 있기 때문이다.

따라서 원래대로라면 beagle.constructor외할머니(Animal)가 아닌 엄마(Dog)를 반환하는 것이 맞다!!

그러니 인위적이지만 우리는 다시 Dog.prototype의 아내을 Dog으로 되돌려줄 필요가 있다❗️

/*prototype.constructor에 올바른 값 할당하기*/
Dog.prototype.constructor = Dog;    
console.log(beagle.constructor);    //function Dog(){...}  짜잔!원래대로 돌아왔다👍

prototype 과 this

생성자함수명.call(this);

하위계층의 객체들이 공통의 속성을 갖고 있으면서도 각각의 고유한 속성을 갖고 있는 경우, 생성자함수명.call(this);로 중복되는 코드를 대체할 수 있다.

예를 들어,

function Carrier (name){
  this.name = name;                           //<-name
}

Carrier.prototype.move = function () {        //<-move
  console.log("move");
};

function Vehicle (name, wheels) {
  this.name = name;                           //<-name 반복 
  this.numOfWheels = wheels;
}

Vehicle.prototype.move = function () {        //<-move 반복
  console.log("move");
}

Vehicle.prototype.hasWheels = function () {
  console.log("Yes, it has wheels.");
}

var helicopter = new Carrier("Black Hawk");
var car = new Vehicle("Hummer", 4);

이런 코드가 있다고 하자.
생성자함수인 CarrierVehicle은 각자 helicoptercar라는 인스턴스를 가지고 있다.
아래 그림처럼 두 인스턴스는 모두 namemove라는 속성을 가지고 있다. (편의상 move도 가지고 있다고 하였다. 정확히 말하면 move 는 가지고 있지 않지만 사용할 수 있다.)

하지만 현재는namemove를 정의하는 코드가 반복되고 있다.
자세히보면 Carrier가 가지고 있는 모든 속성들은 Vehicle이 가지고 있기때문에, VehicleCarrier로부터 필요한 속성을 상속받으면 된다!

Carrier(상위객체) > Vehicle(하위객체)

중복되는 코드 this.name = name;Carrier.call(this, name);로 변경한다.
Carrier.call(this, name); 내부의 this를 알기 위해서는 이 코드를 가지고 있는Vehicle이 실행된 방법을 찾아야한다. new키워드로 실행된 Vehicle의 this는 Vehicle의 인스턴스인 car가 된다.

그리고 move의 경우, 게시글 상단의 Object.create() 부분에서 설명했던 것처럼
1)Vehicle.prototype.move를 삭제하고Vehicle.prototype = Object.create(Carrier.prototype);Vehicle의 prototype을 Carrier.prototype으로 지정해준다.
2) prototype 객체가 대체되면서 사라진 constructor속성은 다시 Vehicle.prototype.constructor = Vehicle;로 재할당 해준다.
이 방법으로는 move속성을 위임할 수 있다.

위 방법들을 적용해 코드를 정정하면 다음과 같다:

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

Carrier.prototype.move = function () {
  console.log("move");
};

function Vehicle (name, wheels) {
  Carrier.call(this, name);                
  //.call(this); 로 대체 
  this.numOfWheels = wheels;
}

Vehicle.prototype = Object.create(Carrier.prototype);
Vehicle.prototype.constructor = Vehicle;
//Carrier.prototype을 vehicle.prototype의 prototype으로 지정해주기!!
//바뀐 constructor 속성은 올바르게 재할당해주기!!

Vehicle.prototype.hasWheels = function () {
  console.log("Yes, it has wheels.");
}

var helicopter = new Carrier("Black Hawk");
var car = new Vehicle("Hummer", 4);

결과적으로 중복되는 코드를 없앴음에도helicoptercar는 전부 정정 전과 동일하게 생성이 되었다!!


Remember...

prototype위임 프로세스를 알아보는 과정에서 constructor속성을 다루기는 했지만, 가급적 constructor메소드나 __proto__사용하지 않아야 한다.(사용하지 말 것!)
앞서 설명한 것처럼 의도치않은 변경으로 혼선을 야기할 가능성도 있을 뿐더러, 이제는 type 확인에Array.isArray()typeof등의 메소드로 대체할 수 있기 때문이다.
무분별한 사용은 독이 된다!!


Reference

*본 포스팅은 아래 사이트들을 참고 및 인용하여 작성되었습니다.
학습단계로 잘못된 정보가 있을 수 있습니다. 잘못된 부분에 대해 알려주시면 곧바로 정정하도록 하겠습니다 😊
https://velog.io/@jakeseo_me/2019-05-03-1005-%EC%9E%91%EC%84%B1%EB%90%A8-evjv7dy8vh
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/proto
https://developer.mozilla.org/ko/docs/Learn/JavaScript/Objects/Object_prototypes
https://www.freecodecamp.org/learn/javascript-algorithms-and-data-structures/object-oriented-programming/remember-to-set-the-constructor-property-when-changing-the-prototype
정재남, 『코어 자바스크립트 - 핵심 개념과 동작 원리로 이해하는 자바스크립트 프로그래밍』, 위키북스(2019)

profile
프린이의 코묻은 코드가 쌓이는 공간

4개의 댓글

comment-user-thumbnail
2023년 1월 16일

깔끔하게 정리 잘해주셨네요 감사합니다 잘 읽었어요 :)

1개의 답글
comment-user-thumbnail
2023년 5월 12일

안녕하세요 ^^ 타고타다 프로토타입에대한 개미굴을해매다 이미지이해가 훅 와닿았습니다 혹시..실례가 안된다면... 이미지좀 퍼가도 될까요??

1개의 답글