nhyunzi
[모던 자바스크립트 Deep Dive] 19 프로토타입 본문
자바스크립트는 객체 기반의 프로그래밍 언어이며 자바스크립트를 이루는 거의 모든것이 객체다.
(원시 타입을 제외한 함수, 배열, 정규 표현식등은 전부 객체이다.)
C++이나 자바같은 클래스 기반 객체지향 프로그래밍 언어의 특징 클래스와 상속, 캡슐화를 위한 키워드public,private,protected 등이 없어 객체지향 언어가 아니라고 오해하는 경우도 있지만,자바스크립트는 클래스 기반
객체지향 프로그래밍 언어보다 효율적이며 더 강력한 프로토타입 기반의 객체지향 프로그래밍 언어이다.
ES6에서 클래스가 도입되었지만 ES6의 클래스도 기존 프로토타입 기반 객체지향 모델을 폐지하고 새로운 객체지향 모델을 제공하지는 않는다. 사실 클래스도 함수이며, 기존 프로토타입 기반 패턴의 문법적 설탕이라 볼 수 있다.
사실 단순한 문법적인 설탕으로 보기엔 생성자 함수와 정확히 동일하게 행동하지는 않기 때문에 새로운 객체 생성 메커니즘으로 보는것이 더 합당하다.
19.1 객체지향 프로그래밍
객체지향 프로그래밍이란 프로그램을 명령어 또는 함수의 목록으로 보는 전통적 명령형 프로그래밍의 절차지향적 관점에서 벗어나 여러 개의 독립적 단위, 즉 객체의 집합으로 프로그램을 표현하려는 프로그래밍 패러다임이다.
객체지향 프로그래밍은 신세계의 실체(사물이나 개념)를 인식하는 철학적 사고를 프로그래밍에 접목하려는 시도에서 시작되는데, 실체는 특징이나 성질을 나타내는 속성을 가지고 있고, 이를 통해 실체를 인식하거나 구별할 수 있다.
다양한 속성중 프로그램에 필요한 속성만 간추려 내어 표현하는 추상화를 구현할 수 있다.
const person = {
name : 'Lee',
address:'Seoul'
};
이때 우리는 이름과 주소 속성으로 표현된 객체를 다른 객체와 구별하여 인식할 수 있다.
19.2 상속과 프로토타입
상속은 객체지향 프로그래밍의 핵심 개념으로, 어떤 객체의 프로퍼티 또는 메서드를 다른 객체가 상속받아 그대로 사용할 수 있는것을 말한다. 자바스크립트는 프로토타입을 기반으로 상속을 구현해 불필요한 중복을 제거하는데, 중복을 제거하는 방법은 기존의 코드를 적극적으로 재사용하는 것이다.
unction Circle(radius){
this.radius = radius;
this.getArea = function(){
return Math.PI*this.radius**2;
}
}
const circle1 = new Circle(1);
const circle2 = new Circle(2);
//Circle 생성자 함수는 인스턴스를 생성할 때마다 동일한 동작을하는
//getArea 메서드를 중복 생성하고 모든 인스턴스가 중복 소유
//getArea 메서드는 하나만 생성해 모든 인스턴스가 공유해 사용하는 것이 바람직함.
console.log(circle1.getArea === circle2.getArea); //false
Circle 생성자 함수는 인스턴스를 생성할 때마다 동일한 동작 하는 getArea 메서드를 중복 생성하고 모든 인스턴스가 중복 소유하게 된다. 따라서 getArea 메서드는 하나만 생성해 모든 인스턴스가 공유해 사용하는 것이 바람직한데, 이때 프로토 타입을 통해 이를 해결할 수 있다.
function Circle(radious){
this.radius = radius;
}
Circle.prototype.getArea = function(){
return Math.PI*this.radious**2;
}
const circle1 = new Circle(1);
const circle2 = new Circle(2);
//Circle 생성자 함수가 생성한 모든 인스턴스는 부모 객체의 역할을 하는
//프로토타입 Circle.prototype로부터 getArea를 상속받는다.
//따라서 Circle 생성자 함수가 생성하는 모든 인스턴스는 하나의 getArea 메서드를 공유한다.
console.log(circle1.getArea === circle2.getArea); //true
상속은 코드의 재사용이란 관점에서 매우 유용하다. 생성자 함수가 생성할 모든 인스턴스가 공통적으로 사용할 프로퍼티나 메서드를 프로토타입에 미리 구현해 두면 생성자 함수가 생성할 모든 인스턴스는 별도의 구현 없이 부모객체인 프로토타입의 자산을 공유하여 사용할 수 있다.
19.3 프로토타입 객체
프로토타입 객체는 객체간 상속을 구현하기 위해 사용된다.
프로토타입은 어떤 객체의 부모 역할을 하는 객체로 다른 객체에 공유 프로퍼티(메소드 포함)를 제공한다. 프로토타입을 상속받은 자식 객체는 부모 객체의 프로퍼티를 자신의 프로퍼티처럼 자유롭게 사용 가능하다.
모든 객체는 내부 슬롯을 가지며, 이 내부 슬롯의 값은 프로토타입의 참조이다.
[[Prototype]]에 저장되는 프로토타입은 객체를 생성할 때 생성 방식에 의해 결정된다.
모든 객체는 하나의 프로토타입을 갖고, 모든 프로토타입은 생성자 함수와 연결되어 있다.
객체에서 [[Prototype]] 내부 슬롯에는 직접 접근할 수 없지만, __proto__ 접근자 프로퍼티를 통해 자신의 프로토타입에 간접적으로 접근할 수 있다. 그리고 프로토타입은 자신의 constructor 프로퍼티를 통해 생성자 함수에 접근할 수 있고, 생성자 함수는 자신의 prototype 프로퍼티를 통해 프로토타입에 접근할 수 있다.
__proto__접근자 프로퍼티
모든 객체는 __proto__접근자 프로퍼티를 통해 자신의 프로토타입, [[Prototype]] 내부 슬롯에 간접적으로 접근할 수 있다.
_proto__는 접근자 프로퍼티다.
내부 슬롯은 프로퍼티가 아니다. 따라서 내부 슬롯과 내부 메서드에 직접적으로 접근하거나 호출할 수 있는 방법이 없다. 하지만, 간접적으로 __proto__를 통해 [[Prototype]]에 접근할 수 있다.
__proto__ 접근자 프로퍼티를 통해 프로토타입에 접근하면 내부적으로 [[Get]]이 호출되며,
__proto__를 통해 새로운 프로토타입을 할당하게 되면 [[Set]]이 호출된다.
const obj = {};
const parent = {x:1};
obj.__proto__;
obj.__proto__ = parent; //obj의 set __proto__가 호출되며 obj 객체의 프로토타입 교체
console.log(obj.x); //1
__proto__ 접근자 프로퍼티는 상속을 통해 사용된다.
__proto__ 접근자 프로퍼티는 객체가 직접 소유하는 프로퍼티가 아니라 Object.prototype의 프로퍼티이다. 모든 객체는 상속을 통해 Object.prototype.__proto__ 접근자 프로퍼티를 사용할 수 있다.
const person = {name:'Lee'};
//person 객체 자체가 __proto__ 프로퍼티를 소유하지는 않는다.
console.log(person.hasOwnProperty('__proto__')); //false
// __proto__ 프로퍼티는 모든 객체의 프로토타입 객체인 Object.prototype의 접근자 프로퍼티다.
console.log(Object.getOwnPropertyDescriptor(Object.prototype,'__proto__'));
//{enumerable: false, configurable: true, get: ƒ, set: ƒ}
// 모든 객체는 Object.prototype의 __prototype__를 상속받아 사용한다.
console.log({}.__proto__ === Object.prototype);//true
console.log(person.__proto__ === Object.prototype);//true
__proto__ 접근자 프로퍼티를 통해 프로토타입에 접근하는 이유
상호 참조에 의해 프로토타입 체인이 생성되는 것을 방지하기 위해서
const parent = {};
const child = {};
//child의 프로토타입을 parent로 설정
child.__proto__ = parent;
//parent의 프로토타입을 child로 설정
parent.__proto__ = child; //TypeError: Cyclic__proto__value
위 예제에서 처럼 서로가 자신의 프로토타입이 되는 비정상적인 체인이 생성되는 경우 __proto__ 접근자 프로퍼티는 에러를 발생시킨다. 프로토타입 체인은 단방향 링크드리스트로 구현되어야한다. = 검색 방향이 한쪽 방향으로만 흘러가야한다. 만약 체인이 만들어지게 되면 종점이 존재하지 않아 무한 루프에 빠지게 되기 때문에 __proto__ 접근자 프로퍼티를 통해 프로토타입에 접근하고 교체하도록 구현되어있다.
하지만 모든 객체가 __proto__를 사용할 수 있는것은 아니기 때문에 직접 사용은 권장하지 않는다.
(직접 상속을 통해 Object.prototype를 상속받지 않는 객체를 생성할 수도 있어 사용할 수 없는 경우가 있다.)
//obj는 프로토타입 체인의 종점이기 때문에 Object.__proto__ 상속 불가.
const obj = Objcet.create(null);
// Object.__proto__ 상속 불가
console.log(obj.__proto__); //undefined
// __proto__보다 Objcet.getPrototypeOf 메서드를 사용하는게 좋음.
console.log(Objcet.getPrototypeOf(obj)); //null
Object.getPrototypeOf와 Object.setPrototypeOf는
get Object.prototype.__proto__와 set Object.prototype.__proto__의 처리 내용과 일치한다. 그렇기 때문에 __proto__대신 소개한 메서드를 사용하길 권장한다.
const obj = {};
const parent = {x:1};
Object.getPrototypeOf(obj); //obj.__proto__;
Object.setPrototypeOf(obj,parent); //obj.__proto__ = parent;
console.log(obj.x); //1
함수 객체의 prototype 프로퍼티
함수 객체만 소유하고 있는 Prototype 프로퍼티는 생성자 함수가 생성할 인스턴스의 프로토타입을 가리킨다.
prototype 프로퍼티는 생성자 함수가 생성할 객체의 프로토타입을 가리기 때문에
non-constructor(화살표 함수, 메서드 축약 표현으로 정의한 메서드)는 prototype 프로퍼티를 소유하지 않으며 프로토타입도 생성하지 않는다. 생성자 함수로 호출하기 위해 정의하지 않은 (함수 선언문, 함수 표현식으로 만든)일반 함수도 prototype를 소유하지만 객체를 생성하지 않는다면 prototype 프로퍼티는 아무 의미가 없다.
모든 객체가 Object.prototype으로부터 상속받은 __proto__접근자 프로퍼티와 함수 객체만 갖는 prototype 프로퍼티는 결국 동일한 프로토타입을 가리킨다. 하지만 사용 주체가 다른다.
//함수 객체는 prototype 프로퍼티를 소유하지 않는다.
(function(){}).hasOwnProperty('prototype');//true
//일반 객체는 prototype 프로퍼티를 소유하지 않는다.
({}).hasOwnProperty('prototype');//false
프로토타입의 constructor 프로퍼티와 생성자 함수
모든 프로토타입은 constructor 프로퍼티를 가진다.
이 constructor 프로퍼티는 prototype 프로퍼티로 자신을 참조하고있는 생성자 함수를 가리킨다.
이 연결은 생성자 함수가 생성될 때 이뤄진다.
function Person(name){
this.name = name;
}
const me = new Person('Lee');
console.log(me.constructor === Person); //true
위 예제에서 Person 생성자 함수는 me 객체를 생성하는데, me 객체는 프로토타입의 constructor 프로퍼티를 통해 생성자함수와 연결된다. 이때, me 객체에는 constructor 프로퍼티가 없지만 me 객체의 프로토타입인 Person.prototype에는 constructor 프로퍼티가 있기 때문에 me 객체는 Person.prototype의 constructor 프로퍼티를 상속받아 사용할 수 있다.
19.4 리터럴 표기법에 의해 생성된 객체의 생성자 함수와 프로토타입
생성자 함수에 의해 생성된 인스턴스는 프로토타입의 constructor 프로퍼티에 의해 생성자 함수와 연결된다. 이때, constructor 프로퍼티가 가리키는 생성자 함수는 인스턴스를 생성한 생성자 함수이다.하지만 리터럴 표기법에 의한 객체 생성 방식과 같이 명시적으로 new 연산자와 함께 생성자 함수를 호출해 인스턴스를 생성하지 않는 방식도 있다.
//객체 리터럴
const obj = {};
//함수 리터럴
const add = function(a,b){return a+b};
//배열 리터럴
const arr = [1,2,3];
//정규 표현식 리터럴
const regexp = /is/ig;
리터럴 표기법에 의해 생성된 객체도 물론 프로토타입이 존재한다. 하지만 이 경우 프로토타입의 constructor 프로퍼티가 가리키는 생성자 함수가 반드시 객체를 생성한 생성자 함수라고 단정할 수 없다.
const obj = {};
console.log(obj.constructor === Object);//ture
하지만 위는 객체 리터럴로 생성한 객체 obj와 Object 생성자 함수와 constructor 프로퍼티로 연결되어있음을 볼 수 있다.
Object 생성자 함수
Object 생성자 함수에 인수를 전달하지 않거나 undefined 혹은 null을 인수로 전달해 호출하면 내부적으로는 추상 연산 OrdinaryObjectCreate를 호출해 Object.prototype을 프로토타입으로 갖는 빈 객체를 생성한다.
//2. Object 생성자 함수에 의한 객체 생성
//인수가 전달되지 않았을 때 추상 연산 OrdinaryObjectCreate를 호출해 빈 객체 생성
let obj = new Object();
console.log(obj); //{}
//1. new.target이 undefined or Object가 아닌 경우
// 인스턴스 -> Foo.prototype -> Object.prototype 순으로 프로토타입 체인 생성
class Foo extends Object{}
new Foo(); // Foo {}
// 3. 인수가 전달된 경우 인수를 객체로 변환
// Number 객체 생성
obj = new Object(123);
console.log(obj); //Number {123}
//String 객체 생성
obj = new Object("123");
console.log(obj); //String {"123"}
객체 리터럴
Object 생성자 함수와 OrdinaryObjectCreate를 호출해 빈 객체를 생성하는 점에서는 동일하지만 new.target의 확인이나 프로퍼티를 추가하는 처리 등 세부내용은 다르다.
그렇기 때문에 객체 리터럴에 의해 생성된 객체는 Object 생성자 함수가 생성한 객체가 아니다.
함수 객체의 경우 차이가 더 명확한데, Function 생성자 함수를 호출해 생성한 함수는 렉시컬 스코프를 만들지 않고 전역 함수인 것처럼 스코프를 생성하며 클로저도 만들지 않는다. 따라서 함수 선언문과 함수 표현식을 평가해 함수 객체를 생성한 것은 Function 생성자 함수가 아니다.
//함수 선언문으로 생성
function foo(){}
console.log(foo.constructor === Function); //true
하지만 위의 예시를 보면 foo 함수의 생성자 함수가 Function 생성자 함수임을 확인할 수 있다.
리터럴 표기법에 의해 생성된 객체도 상속을 위해 프로토타입은 필요하다. 따라서 리터럴 표기법에 의해 생성된 객체도 가상적인 생성자 함수를 갖는다. 프로토타입은 생성자 함수와 더불어 생성되며 prototype, constructor 프로퍼티에 의해 연결되어있다. 프로토타입과 생성자 함수는 단독으로 존재할 수 없고 언제나 쌍으로 존재한다.
리터럴 표기법에 의해 생성된 객체는 생성자 함수에 의해 생성된 객체는 아니다. 하지만 큰 틀에서 보면 결국 생성자 함수로 생성한 객체와 본질적인 면에서 큰 차이는 없다. 결론적으로, 프로토타입의 constructor 프로퍼티를 통해 연결되어 있는 생성자 함수를 리터럴 표기법으로 생성한 객체를 생성한 생성자 함수로 생각해도 크게 무리는 없다.