자바스크립트는 흔히 프로토타입 기반 언어(prototype-based language)라 불린다. 객체들이 메소드와 속성들을 상속받기 위한 템플릿으로써 프로토타입 객체(prototype object)를 가진다는 의미다.
프로토타입 객체도 다시 상위 프로토타입으로부터 상속받을 수 있고, 그 상위 프로토타입 객체도 마찬가지이므로 프로토타입 체인(prototype chain)이라 부른다.
사실 생성자함수는 인스턴스에 메소드와 속성을 상속해준 뒤에도 여전히 해당 메소드와 속성을 가지고 있다. 따라서 "상속" 보다는 "behavior delegation"혹은 "위임"의 개념에 더욱 가깝다.
상속되는 속성과 메소드들은 각 객체에 정의되어 있는 것이 아니라,
생성자함수의 prototype이라는 속성에 정의되어 있다.
즉, 객체 인스턴스가 생성자함수와 연결되는 것이 아니라 생성자함수의 prototype과 연결되는 것이다!
Constructor와 prototype, 그리고 객체 instance의 관계를 도식화하면 아래와 같다.
- 어떤 생성자 함수(Constructor)를
new
연산자와 함께 호출하면- Constructor에서 정의된 내용을 바탕으로 새로운 인스턴스(instance)가 생성된다.
- 이때 instance에서는
__proto__
라는 프로퍼티가 자동으로 부여되는데,- 이 프로퍼티는 Constructor의 prototype이라는 프로퍼티를 참조한다.
__proto__
프로퍼티는 생략 가능하도록 구현돼 있기 때문에 생성자 함수의 prototype에 어떤 메서드나 프로퍼티가 있다면 인스턴스에서도 마치 자신의 것처럼 해당 메서드나 프로퍼티에 접근할 수 있게 된다.
//ex.
const arr = [1, 2]
arr는 생성자함수 Array()의 instance이다. 그리고 Object()는 Array()의 constructor이다.
__proto__
: Dunder proto; exposes the value of the internal [[Prototype]] of an object. For objects created using an object literal, this value isObject.prototype
. For objects created using array literals, this value isArray.prototype
. For functions, this value isFunction.prototype
.
-MDNES5.1 명세에는
__proto__
가 아니라[[prototype]]
이라는 명칭으로 정의돼 있습니다.__proto__
라는 프로퍼티는 사실 브라우저들이[[prototype]]
을 구현한 대상에 지나지 않았습니다. ... ES6에서는 이를 브라우저에서 동작하는 레거시 코드에 대한 호환성 유지 차원에서 정식으로 인정하기 시작했습니다. 다만 어디까지나 브라우저에서의 호환성을 고려한 지원일 뿐 권장되는 방식은 아니며, 브라우저가 아닌 다른 환경에서는 얼마든지 이 방식이 지원되지 않을 가능성이 열려있습니다.
-코어자바스크립트
Dunder proto(__proto__
) 프로퍼티는 생성자 함수의 프로퍼티를 가리킨다.
생성자 함수의 인스턴스가 생성될 때마다 다른 프로퍼티 & 메소드와 함께 dunder proto 도 인스턴스에 복사된다.
학습목적으로 이해는 하되, 실무에서 사용하지는 말 것❗️
대신에 Object.getPrototypeOf()
혹은 Object.create()
를 이용하기.
//만일 'john'객체가 생성자함수'Person'의 인스턴스라면,
john.__proto__ === Person.prototype //true
본격적으로 자바스트립트에서 프로퍼티를 위임하는 방법을 알아보자!
첫번째는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.prototype
의 hasOwnProperty()
메소드 또한 사용할 수 있는데,
이것은 특정 프로퍼티가 john
객체 내부에 존재하는지, 아니면 상위 prototype 객체에 존재하는지를 구분한다.
/*job은 john내부에 위치한 프로퍼티이다.*/
john.hasOwnProperty('job'); //true
/*lastName은 Person.prototype에 위치한 프로퍼티이다.*/
john.hasOwnProperty('lastName'); //false
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" },
});
john
은 Object.create()
의 첫번째 인자인 personProto
에서 직접적으로 prototype을 상속받는다.
그리고 나머지 프로퍼티들은 동적으로 생성하여 추가하였다.
반면,jane
은 필요한 프로퍼티들을 가진 객체를 Object.create()
의 두번째 인자로 전달받았다.
이 때 주의할 점은, 값을 {}
으로 감싸야한다는 점이다.
console 창을 통해 확인해보면 두 객체 모두 같은 동일한 prototype을 가진 객체가 되었음을 알 수 있다.
john.calculateAge(); //26
jane.calculateAge(); //47
프로토타입을 설명하는 자료는 다양하게 있지만,
그 중에서도 가장 기억에 남으면서도 이해가 쏙쏙 되었던 것이 '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.prototype
은 Dog
의 남편이자 hound
의 아빠가 되는 것이다.
만일 hound
가 원하는 속성을 자기 내부에 가지고 있지 않다면, 아빠인 Dog.prototype
은 hound
에게 자신의 속성들을 빌려줄 것이다!
위 비유에서 쓰인 예제를 통해 설명을 이어가자면,
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
은 자식이 되는 객체를 가리킨다.
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
인스턴스가 constructor
로 Dog
생성자함수의 상위객체인 Animal
을 가리키고 있는 것을 확인할 수 있다.
console.log(beagle.constructor); //function Animal(){...}
여기서 다시 family-oriented prototype이론을 적용해보자면, beagle
은 Dog
의 자녀가 된다.
그리고 Dog
은 Animal.prototype
의 자녀가 된다.
beagle
은 constructor
라는 속성이 없기 때문에 beagle.constructor
가 값을 반환할 수 있는 이유는 beagle
이 아빠인 Dog.prototype
객체로부터 속성을 빌려올 수 있기 때문이다.
따라서 원래대로라면 beagle.constructor
는 외할머니(Animal
)가 아닌 엄마(Dog
)를 반환하는 것이 맞다!!
그러니 인위적이지만 우리는 다시 Dog.prototype
의 아내을 Dog
으로 되돌려줄 필요가 있다❗️
/*prototype.constructor에 올바른 값 할당하기*/
Dog.prototype.constructor = Dog;
console.log(beagle.constructor); //function Dog(){...} 짜잔!원래대로 돌아왔다👍
하위계층의 객체들이 공통의 속성을 갖고 있으면서도 각각의 고유한 속성을 갖고 있는 경우,
생성자함수명.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);
이런 코드가 있다고 하자.
생성자함수인 Carrier
와Vehicle
은 각자 helicopter
와 car
라는 인스턴스를 가지고 있다.
아래 그림처럼 두 인스턴스는 모두 name
과 move
라는 속성을 가지고 있다. (편의상 move
도 가지고 있다고 하였다. 정확히 말하면 move
는 가지고 있지 않지만 사용할 수 있다.)
하지만 현재는name
과 move
를 정의하는 코드가 반복되고 있다.
자세히보면 Carrier
가 가지고 있는 모든 속성들은 Vehicle
이 가지고 있기때문에, Vehicle
은 Carrier
로부터 필요한 속성을 상속받으면 된다!
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);
결과적으로 중복되는 코드를 없앴음에도helicopter
와 car
는 전부 정정 전과 동일하게 생성이 되었다!!
prototype위임 프로세스를 알아보는 과정에서 constructor
속성을 다루기는 했지만, 가급적 constructor
메소드나 __proto__
는 사용하지 않아야 한다.(사용하지 말 것!)
앞서 설명한 것처럼 의도치않은 변경으로 혼선을 야기할 가능성도 있을 뿐더러, 이제는 type 확인에Array.isArray()
나 typeof
등의 메소드로 대체할 수 있기 때문이다.
무분별한 사용은 독이 된다!!
*본 포스팅은 아래 사이트들을 참고 및 인용하여 작성되었습니다.
학습단계로 잘못된 정보가 있을 수 있습니다. 잘못된 부분에 대해 알려주시면 곧바로 정정하도록 하겠습니다 😊
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)
깔끔하게 정리 잘해주셨네요 감사합니다 잘 읽었어요 :)