본문 바로가기

프로그래밍/자바스크립트(javascript)

[자바스크립트/javascript] 상속 (프로토타입 체인, 생성자 훔치기, 기생 상속)

상속은 객체지향 프로그래밍과 관련하여 가장 자주 설명하는 개념입니다.

대부분의 객체지향 언어는 메서드 시그너치만을 상속하는 인터페이스 상속과 실제 메서드를 상속하는 구현 상속 두 가지 타입을 지원합니다.

 

 

프로토타입 체인

 

 

프로토타입 체인의 기본 아이디어는 프로토타입 개념을 이용해 두 가지 참조 타입 사이에서 프로퍼티와 메서드를 상속한다는 것입니다.

모든 생성자에는 생성자 자신을 가리키는 프로토타입 객체가 있으며 인스턴스는 프로토타입을 가리키는 내부 포인터가 있습니다.

 

프로토 타입 체인을 구현하려면 다음 코드 패턴을 사용합니다.

 

function SuperType() {
	this.property = true;
}

SuperType.prototype.getSuperValue = function() {
	return this.property;
}

function SubType() {
	this.subproperty = false;
}

SubType.prototype = new SuperType();
SubType.prototype.getSubValue = function() {
	return this.subproperty;
}

var instance = new SubType();

console.log(instance.getSuperValue());

 

 

 

SuperType과 SubType 두 가지 타입을 정의하는데 각 타입에는 프로퍼티와 메서드가 단 한 개씩만 있습니다.

두 타입의 주요한 차이는 SubType이 SuperType의 새 인스턴스를 생성하여 SuperType을 상속하며 이를 SubType.prototype에 할당한다는 점입니다.

 

 

기본 프로토타입

 

 

현실에서는 프로토타입 체인에 한 단계가 더 존재합니다. 모든 참조 타입은 기본적으로 프로토타입 체인을 통해 Object를 상속합니다.

함수의 기본 프로토타입은 Object의 인스턴스이므로 함수의 내부 프로토타입 포인터는 Object.prototype를 가리킵니다. 커스텀 타입은 이런 방식으로 toString()이나 valueOf() 같은 기본 메서드를 상속합니다.

 

 

프로토타입과 인스턴스 사이의 관계

 

 

프로토타입과 인스턴스 사이의 관계는 두 가지 방법으로 알아볼 수 있습니다.

첫 번째 방법은 instanceof 연산자입니다. 연산자는 다음과 같이 인스턴스 생성자가 프로토타입 체인에 존재할 때 true를 반환합니다.

 

 

function SuperType() {
	this.property = true;
}

SuperType.prototype.getSuperValue = function() {
	return this.property;
}

function SubType() {
	this.subproperty = false;
}

SubType.prototype = new SuperType();
SubType.prototype.getSubValue = function() {
	return this.subproperty;
}

var instance = new SubType();

console.log(instance instanceof Object);
console.log(instance instanceof SuperType);
console.log(instance instanceof SubType);

 

 

 

instance 객체는 프로토타입 체인 관계에 의해 Obejct와 SuperType, SubType 모두의 인스턴스입니다. 따라서 instanceof는 세 가지 생성자에서 모두 true를 반환합니다.

 

두 번째 방법은 isPrototypeOf() 메서드입니다. 체인에 존재하는 각 프로토타입은 모두 isPrototypeOf() 메서드를 호출할 수 있는데 이 메서드는 다음과 같이 체인에 존재하는 인스턴스에 true를 반환합니다.

 

 

function SuperType() {
	this.property = true;
}

SuperType.prototype.getSuperValue = function() {
	return this.property;
}

function SubType() {
	this.subproperty = false;
}

SubType.prototype = new SuperType();
SubType.prototype.getSubValue = function() {
	return this.subproperty;
}

var instance = new SubType();

console.log(Object.prototype.isPrototypeOf(instance));
console.log(SuperType.prototype.isPrototypeOf(instance));
console.log(SubType.prototype.isPrototypeOf(instance));

 

 

 

하위 타입에서 상위 타입의 메서드를 오버라이드하거나 상위 타입에 존재하지 않는 메서드를 정희해야 할 떄가 많습니다.

이렇게 하려면 반드시 프로토타입이 할당된 다음 필요한 메서드를 프로토타입에 추가해야합니다.

 

function SuperType() {
	this.property = true;
}

SuperType.prototype.getSuperValue = function() {
	return this.property;
}

function SubType() {
	this.subproperty = false;
}

SubType.prototype = new SuperType();

SubType.prototype.getSubValue = function() {
	return this.subproperty;
}

SubType.prototype.getSuperValue = function() {
	return false;
}

var instance = new SubType();

console.log(instance.getSuperValue());

 

 

첫 번째 메서드인 getSubValue()는 SubType에 추가한 메서드입니다. 두 번쨰 메서드인 getSuperValue()는 프로토타입 체인에 이미 존재하지만 instance에서 기존 메서드를 가렸습니다. SubType의 인스턴스에서 getSuperValue()를 호출하면 instance에 있는 메서드를 호출하지만 SuperType의 인스턴스에서는 원래 메서드를 호출합니다.

 

중요한 점은 두메서드가 모두 프로토타입이 SuperType의 인스턴스로 할당된 다음 정의되었다는 점입니다.

 

한 가지 중요한 점이 더 있는데, 객체 리터럴을 써서 프로토타입 메서드를 만들면 체인을 덮어쓰는 결과가 되므로 프로토타입 체인과 함께 사용할 수는 없습니다.

 

function SuperType() {
	this.property = true;
}

SuperType.prototype.getSuperValue = function() {
	return this.property;
}

function SubType() {
	this.subproperty = false;
}

SubType.prototype = new SuperType();

SubType.prototype = {
	getSubValue : function() {
    	return this.subproperty;
    },
    
    someOtherMethod : function() {
    	return false;
    }
};

var instance = new SubType();

console.log(instance.getSuperValue());  // 에러

 

 

 

프로토타입에 SuperType의 인스턴스를 할당한 다음 즉시 객체리터럴로 덮어썼습니다. 이제 프로토타입에는 SuperType의 인스턴스가 아니라 Object의 인스턴스가 들어 있으므로 프로토타입 체인이 끊어져서 SubType과 SuperType사이에는 아무 관계도 없습니다.

 

 

 

프로토타입 체인의 문제

 

 

프로토타입 체인은 강력한 상속 방법이지만 문제도 있습니다. 주요 문제는 참조 값을 포함한 프로토타입과 관련이 있습니다.

프로토타입 프로퍼티에 들어 있는 참조 값이 모든 인스턴스에서 공유된다는 사실을 상기합시오. 이 때문에 프로퍼티는 일반적으로 프로토타입 대신 생성자에 정의합니다. 프로토타입으로 상속을 구현하면 프로토타입이 다른 타입의 인스턴스가 되므로 처음에 인스턴스 프로퍼티였던 것들이 프로토타입 프로토타입 프로퍼티로 바뀝니다.

 

 

function SuperType() {
	this.colors = ["red", "blue", "green"];
}

function SubType() {
}

SubType.prototype = new SuperType();

var instance1 = new SubType();
instance1.colors.push("black");
console.log(instance1.colors);

var instance2 = new SubType();
console.log(instance2.colors);

 

 

 

SubType이 프로토타입 체인을 통해 SuperType을 상속하면 SubType.prototype은 SuperType의 인스턴스가 되므로 고유한 colors 프로퍼티를 갖는데, 이는 SubType.prototype.colors을 명시적으로 생성한 것이나 마찬가지입니다.

결국 SubType의 모든 인스턴스에서 colors 프로퍼티를 공유하게 됩니다. 따라서 instance1.colors 수정하면 instance2.colors에도 반영합니다.

 

 

생성자 훔치기

 

 

프로토타입과 참조 값에 얽힌 상속 문제를 해결하고자 개발자들은 생성자 훔치기 라는 테크닉을 쓰기 시작했습니다.

하위타입 생성자 안에서 상위 타입 생성자를 호출하는 겁니다.

 

함수는 단순히 코드를 특정 컨텍스트에서 실행하는 객체일 뿐임을 염두에 두면 다음과 같이 새로 생성한 객체에서 apply() 와 call() 메서드를 통해 생성자를 실행할 수 있음이 이해될 겁니다.

 

function SuperType() {
	this.colors = ["red", "blue", "green"];
}

function SubType() {
	SuperType.call(this);
}

var instance1 = new SubType();
instance1.colors.push("black");

console.log(instance1.colors);

var instance2 = new SubType();
console.log(instance2.colors);

 

 

 

call() 메서드를 사용해서 SuperType 생성자를 새로 생성한 SubType의 인스턴스 컨텍스트에서 호출했습니다.

이 코드에는 SuperType() 함수에 들어 있는 객체 초기화 코드 전체를 SubType 객체에서 실행하는 효과가 있습니다.

결과적으로 모든 인스턴스가 자신만의 colors 프로퍼티를 갖게됩니다.

 

 

매개변수 전달

 

 

생성자 훔치기 패턴은 하위 타입의 생성자 안에 상위 타입의 생성자에 매개변수를 전달할 수 있는데 이는 생성자 훔치기 패턴이 프로토타입 체인보다 나은 점 중 하나입니다.

 

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

function SubType() {
	SuperType.call(this, "Nicholas");
    this.age = 29;
}

var instance = new SubType();

console.log(instance.name);
console.log(instance.age);

 

 

이 코드에서 SuperType 생성자는 매개변수로 name 하나를 받고 단순히 프로퍼티에 할당하기만 합니다. SubType 생성자 내에서 SuperType 생성자 내에서 SuperType 생성자를 호출할 때 값을 전달할 수 있는데, 이는 SubType 인스턴스의 name 프로퍼티를 지정하는 결과가 됩니다. SubType 생성자가 이들 프로퍼티를 덮어쓰지 않도록, 상위 타입 생성자를 호출한 뒤에 하위 타입에 프로퍼티를 더 정의할 수 있습니다.

 

 

생성자 훔치기 패턴의 문제

 

생성자 훔치기 패턴만 사용하면 커스텀 타입에 생성자 패턴을 쓸 때와 같은 문제가 발생합니다. 메서드를 생성자 내부에서만 정의해야 하므로 함수 재사용 불가능해집니다. 게다가 상위 타입의 프로토타입에 정의된 메서드는 하위 타입에서 접근할 수 없는 문제도 있습니다.

이런 문제 때문에 생성자 훔치기만 단독으로 쓰는 경우는 드뭅니다.

 

 

조합 상속

 

조합 상속은 프로토타입 체인과 생성자 훔치기 패턴을 조합해 두 패턴의 장점만을 취하려는 접근법입니다.

프로토타입 체인을 써서 프로토타입에 존재하는 프로퍼티와 메서드를 상속하고 생성자 훔치기 패턴으로 인스턴스 프로퍼티를 상속하는 것입니다. 이렇게 하면 프로토타입에 메서드를 정의해서 함수를 재사용할 수 있고 각 인스턴스가 고유한 프로퍼티를 가질 수도 있습니다.

 

 

function SuperType(name) {
	this.name = name;
    this.colors = ["red", "blue", "green"];
}

SuperType.prototype.sayName = function() {
	console.log(this.name);
}

function SubType(name, age) {
	SuperType.call(this, name);
    
    this.age = age;
}

SubType.prototype = new SuperType();
SubType.prototype.sayAge = function() {
	console.log(this.age);
}

var instance1 = new SubType("Nicholas" ,29);
instance1.colors.push("black");
console.log(instance1.colors);
instance1.sayName();
instance1.sayAge();

var instance2 = new SubType("Greg", 27);
console.log(instance2.colors);
instance2.sayName();
instance2.sayAge();

 

 

 

이 패턴에서는 SubType의 인스턴스들이 colors 프로퍼티를 포함해 자신만의 고유 프로퍼티를 가지면서도 메서드는 공유하게 만들 수 있습니다.

프로토타입 체인과 생성자 훔치기 패턴의 단점을 모두 해결한 조합 상속은 자바스크립트에서 가장 자주 쓰이는 상속 패턴입니다. 조합 상속은 instanceif() 와 isPrototypeOf()에서도 올바른 결과를 반환합니다.

 

 

프로토타입 상속

 

이 방법은 프로토타입을 써서 새 객체를 생성할 때 반드시 커스텀 타입을 정의할 필요는 없다는 데서 출발합니다.

 

function object(o) {
	function F() {}
    F.prototype = o;
    return new F();
}

 

object() 함수는 임시 생성자를 만들어 주어진 객체를 생성자의 프로토타입으로 할당한 다음 임시 생성자의 인스턴스를 반환합니다.

 

 

var person = {
	name : "Nicholas",
    friends: ["Shelby", "Court", "Van"]
};

function object(o) {
	function F() {}
    F.prototype = o;
    return new F();
}

var anotherPerson = object(person);
anotherPerson.name = "Greg";
anotherPerson.friends.push("Rob");

var yetAnotherPerson = object(person);
yetAnotherPerson.name = "Linda";
yetAnotherPerson.friends.push("Barbie");

console.log(person.friends);

 

 

 

 

이 방법에서는 일단 다른 객체의 기반이 될 객체를 만듭니다. 기반 객체를 object()에 넘긴 다음 결과 객체를 적절히 수정해야 합니다.

 

ES5에서는 프로토타입 상속의 개념을 공식적으로 수용하여 Object.create() 메서드를 추가했습니다. 이 메서드는 매개변수를 두 개 받는데 하나는 다른 객체의 프로토타입이 될 객체이며, 옵션인 다른 하나는 새 객체에 추가할 프로퍼티를 담은 객체입니다. 매개변수를 하나만 쓰면 Object.creat() 는 object() 메서드와 똑같이 동작합니다.

 

 

var person = {
	name : "Nicholas",
    friends: ["Shelby", "Court", "Van"]
};

var anotherPerson = Object.create(person);
anotherPerson.name = "Greg";
anotherPerson.friends.push("Rob");

var yetAnotherPerson = Object.create(person);
yetAnotherPerson.name = "Linda";
yetAnotherPerson.friends.push("Barbie");

console.log(person.friends);

 

 

 

Object.create() 두 번째 매개변수는 Object.definedProperties()의 두 번째 매개변수와 같은 형식입니다. 즉, 추가할 프로퍼티마다 서술자와 함께 정의하는 형태입니다. 이런 식으로 추가한 프로퍼티는 모두 프로토타입 객체에 있는 같은 이름의 프로퍼티를 가립니다.

 

var person = {
	name: "Nicholas",
    friends: ["Shelby", "Court", "Van"]
};

var anotherPerson = Object.create(person, {
	name: {
    	value: "Greg"
    }
});

console.log(anotherPerson.name);

 

 

 

기생 상속

 

 

기생 상속의 기본 아이디어는 기생 생성자나 팩터리 패턴과 비슷합니다. 상속을 담당할 함수를 만들고, 어떤 식으로든 객체를 확장해서 반환한다는 겁니다.

 

function createAnother(original) {
	var clone = object(original);
    clone.sayHi = function() {
    	console.log("hi");
    }
    return clone;
}

 

createAnother() 함수는 다른 객체의 기반이 될 객체 하나만 매개변수로 받습니다. 기반 객체 original을 object() 함수에 넘긴 결과를 clone에 할당합니다. 다음으로 clone 객체에 sayHi()라는 메서드를 추가합니다. 마지막으로 객체를 반환합니다.

 

var person = {
	name: "Nicholas",
    friends: ["Shelby", "Court", "Van"]
};

function createAnother(original) {
	var clone = Object(original);
    clone.sayHi = function() {
    	console.log("hi");
    }
    return clone;
}

var anotherPerson = createAnother(person);
anotherPerson.sayHi();

 

 

 

기생 상속 패턴은 객체를 주로 고려할 때 사용할 패턴이지 커스텀 타입과 생성자에 어울리는 패턴은 아닙니다. 기생 상속에 꼭 object() 메서드가 필요한 건 아닙니다. 객체를 반환하는 함수는 모두 이 패턴에 쓸 수 있습니다.

 

 

기생 조합 상속

 

조합 상속은 자바스크립트에서 가장 자주 쓰이는 상속 패턴이지만 비효율적인 면도 있습니다. 이 패턴에서 가장 비효율적인 부분은 상위 타입 생성자가 항상 두 번 호출된다는 점입니다. 한 번은 하위 타입의 프로토타입을 생성하기 위해, 다른 한번은 하위 타입 생성자 내부에서입니다. 하위 타입 생성자가 실행되는 순간 이를 모두 덮어쓰므로 별 의미가 없습니다.

 

function SuperType(name) {
	this.name = name;
    this.colors = ["red", "blue", "green"];
}

SuperType.prototype.sayName = function() {
	console.log(this.name);
}

function SubType(name, age) {
	SuperType.call(this, name);
    
    this.age = age;
}

SubType.prototype = new SuperType();
SubType.prototype.constructor = SubType;
SubType.prototype.sayAge = function() {
	console.log(this.age);
};

 

name과 colors 프로퍼티는 인스턴스에도 존재하고 SubType 프로토타입에도 존재합니다. 이는 SubType생성자를  두 번 호출한 결과입니다. 다행히 이 문제를 해결할 수 있는 방법이 있습니다.

 

기생 조합 상속은 생성자 훔치기를 통해 프로퍼티 상속을 구현하지만 메서드 상속에는 프로토타입 체인을 혼용합니다. 

하위 타입의 프로토타입을 할당하기 위해 상위 타입의 생성자를 호출할 필요는 없습니다. 필요한 건 상위 타입의 프로토타입 뿐입니다. 간단히 말해 기생 상속을 써서 상위 타입의 프로토타입 뿐입니다. 간단히 말해 기생 상속을 써서 상위 타입의 프로토타입으로부터 상속한 다음 결과를 하위 타입의 프로토타입에 할당합니다.

 

 

function inheritPrototype(subType, superType) {
	var prototype = Object(superType.prototype);
    prototype.constructor = subType;
    subType.prototype = prototype;
}

 

 

다음과 같이 inheritPrototype() 을 호출하면 이전 예제의 하위 타입 프로토타입 할당을 대체할 수 있습니다.

 

function inheritPrototype(subType, superType) {
	var prototype = Object(superType.prototype);
    prototype.constructor = subType;
    subType.prototype = prototype;
}

function SuperType(name) {
	this.name = name;
    this.colors = ["red", "blue", "green"];
}

SuperType.prototype.sayName = function() {
	console.log(this.name);
};

function SubType(name, age) {
	SuperType.call(this, name);
    
    this.age = age;
}

inheritPrototype(SubType, SuperType);

SubType.prototype.sayAge = function() {
	console.log(this.age);
}

 

SuperType 생성자를 단 한번만 호출하므로 SubType.prototype에 불 필요하고 사용하지 않는 프로퍼티를 만들지 않는다는 점에서 효과적입니다. 또한 프로토타입 체인이 온전히 유지되므로 instanceof와 isPrototypeOf() 메서드도 정상 작동합니다. 기생 조합 상속은 참조 타입에서 가장 효율적인 상속 패러다임으로 평가받습니다.