치춘짱베리굿나이스
자바스크립트에서의 객체지향 (2) 프로토타입 본문
프로토타입 기반 언어
프로토타입 기반 언어?
자바스크립트에 클래스… 문법이 있긴 하지만 이건 ES6에 와서야 생긴 문법이고, 근본적으로 자바스크립트에는 클래스 개념이 없다고 보아야 맞다
대신 자바스크립트에는 프로토타입 개념이 있어 이를 이용하여 클래스를 흉내낼 수 있다
프로토타입 기반 언어는 객체의 원형인 프로토타입을 선언하고, 이 프로토타입 객체를 이용하여 새로운 객체를 만들어낸다 = 클래스랑 비슷한 방식으로 동작하는 것이다!
자바스크립트에서 함수는 객체인가요?
에 대해 짧막하게 기록해보았다
자바스크립트에서 함수는 객체라는 사실을 알고 아래의 개념을 공부해보도록 하자
객체 생성자
객체를 생성하는 방법
// 객체 리터럴
const obj1 = {
name: 'chichoon',
sayHello: () => console.log(`Hi! My name is ${this.name}`);
}
// 생성자 함수
const obj2 = new Foo();
// Object() 생성자 함수
const obj3 = new Object();
객체를 생성하는 방법은 총 세가지가 있다
이 중에서 객체 리터럴로 생성하는 방법과 Object()
생성자 함수는 사실상 같은 방식이며, 자바스크립트 엔진 내부에서 객체 리터럴을 만나면 Object()
생성자 함수를 작동시켜 객체를 생성한다
따라서 아래의 글을 보면 알겠지만, 객체 리터럴과 Object()
생성자로 생성한 함수의 __proto__
프로퍼티는 같은 프로토타입 객체인 Object.prototype
을 가리킨다
그렇다면 남은 하나의 방법인 ‘객체 생성자' 를 알아보자
객체 생성자
const date = new Date();
위에서 new 키워드를 붙여 호출된 Date
는 사실 객체이다
객체를 함수처럼 호출할 수 있다고? 싶은데 사실 우리가 new 키워드를 붙여 호출한 것은 객체 생성자 함수이다
객체 생성자 함수는 말 그대로 호출될 때 객체를 생성하며, 클래스의 생성자와 비슷한 역할을 한다
따라서 new를 붙여 이런 객체 생성자 함수를 호출하면 객체 (인스턴스) 를 생성할 수 있다
객체 생성자 함수는 여타 함수와 다르게 주로 파스칼케이스로 작성된다 (파스칼케이스로 작성하지 않는다고 오류가 나는 건 아니다)
VSCode같은 경우 함수 안에 this
키워드가 들어있으면 ‘클래스 선언으로 변경할 수 있다' 고 일러주긴 한다
function Babo(name) {
this.name = name;
this.introduce = () => console.log(`하이 나는 ${this.name}`);
}
const chichoon = new Babo('chichoon');
const nongdamgom = new Babo('nongdamgom');
chichoon.introduce();
nongdamgom.introduce();
Babo
객체생성자를 이용하여 chichoon
, nongdamgom
인스턴스 객체를 만들었다
두 객체는 같은 메서드 (introduce
) 를 가지고 있다
객체생성자 내부에서 this
를 이용하여 내부 변수와 메서드를 생성하거나 접근하는 것을 볼 수 있는데, 여기서 this
는 생성자 함수 자기 자신을 가리킨다 (클래스 기반 언어에서 this
를 사용해서 자기 자신을 가리키는 것과 비슷하다)
따라서 생성자 내부에서 this를 이용하여 변수와 메서드를 생성하면, 객체 생성자를 호출하여 객체를 만들 때 해당 객체들에 변수와 메서드가 모두 적용된다
function Cat(name) {
this.name = name;
this.meow = 'meow';
}
const siamese = new Cat('siamese');
const bengal = new Cat('bengal');
const abyssinian = new Cat('abyssinian');
이번엔 이런 객체 생성자로 객체들을 생성했다고 해 보자
Cat
생성자 내부의 meow
프로퍼티는 ‘meow’
로 고정되어 있으므로, siamese
, bengal
, abyssinian
모두 meow
프로퍼티는 같은 값을 갖게 된다
하지만 각각의 객체는 메모리에서 서로 다른 영역을 할당받으므로 결국 meow
라는 내용의 변수가 메모리상 3번이나 할당되는 건데, 이것은 메모리 낭비가 아닐 수 없다
특히 지금은 객체가 세 개밖에 없지만, 나중에 객체를 몇백 개씩 만든다고 하면 몇백 개의 객체가 서로 다른 meow
를 할당할 것이다…
이 문제를 해결할 수 있는 것이 프로토타입 객체이다
프로토타입
모든 객체는 프로퍼티와 메서드를 상속받기 위한 템플릿으로 프로토타입 객체를 가진다
프로토타입이란 자신의 부모 역할을 담당하는 객체로, 이 프로토타입을 가지고 새로운 객체를 만들어내는 것이 프로토타입 기반 객체지향 프로그래밍이다
prototype, [[Prototype]]
function Cat(name) {
this.name = name;
}
Cat.prototype.meow = "meow"; // 해당 함수 생성자의 prototype 객체에 프로퍼티 추가
const siamese = new Cat("siamese");
console.log(siamese);
console.log(siamese.__proto__); // [[ prototype ]] 슬롯에 접근
prototype
프로퍼티는
- 함수 객체만 가지고 있다
- 함수 객체가 생성자로 사용될 때 (객체 생성자), 이 객체를 통해 생성되는 객체들의 부모 역할을 하게 된다
- 따라서
prototype
프로퍼티의 객체는 어떠한 객체의 모태가 되는 객체이고, 하위 객체들에게 물려줄 프로퍼티나 메서드가 들어있는 유전자같은 거라고 보면 된다
[[Prototype]]
슬롯은
- 모든 객체가 가지고 있다 (함수 포함)
- 자신의 부모 역할을 하는 Prototype 객체를 가리킨다
- 위의 함수 객체 생성자를 통해 만들어진 객체가
__proto__
프로퍼티를 통해 접근할 수 있다
- 위의 함수 객체 생성자를 통해 만들어진 객체가
위의 예시를 보면 Cat
생성자 함수의 prototype
이라는 프로퍼티 안에 meow
라는 프로퍼티를 넣어주고 있다
siamese
인스턴스는 Cat
생성자를 통해 생성된 객체로, Cat
생성자에 정의된 프로퍼티를 모두 가지고 있다 (name
) 하지만 prototype
내에 선언한 프로퍼티는 인스턴스 내에서 바로 확인할 수 없다
대신 siamese.__proto__
와 같이 프로토타입 슬롯에 접근하면, Cat
생성자 함수의 prototype
프로퍼티 안에 정의한 프로퍼티들을 볼 수 있다
function Cat(name) {
this.name = name;
}
Cat.prototype.meow = "meow";
const siamese = new Cat("siamese");
const abyssinian = new Cat("abyssinian");
console.log(siamese);
console.log(abyssinian);
console.log("======= prototype.meow before =======");
console.log(siamese.__proto__);
console.log(abyssinian.__proto__);
Cat.prototype.meow = "purr";
console.log("======= prototype.meow after =======");
console.log(siamese.__proto__);
console.log(abyssinian.__proto__);
이번에는 생성자를 통해 두 개의 객체를 생성하고, 중간에 프로토타입 프로퍼티 안의 값을 바꿔보았다
siamese
, abyssinian
모두 공통의 부모 객체 (Cat
의 프로토타입) 를 가리키고 있기 때문에 (같은 값 참조) 값을 바꾸면 siamese
, abyssinian
의 __proto__
를 출력했을 때 바뀐 값이 출력된다
console.log(abyssinian.meow);
이렇게 prototype
객체에 의해 상속받은 프로퍼티는 굳이 __proto__
를 거치지 않아도 접근할 수 있다
function Cat(name) {
this.name = name;
}
const siamese = new Cat("siamese"); // siamese는 Cat으로부터 생성된 객체
siamese.__proto__.cry = "meow";
console.log(siamese.cry);
console.log(Cat.prototype);
siamese.cry = "purr";
console.log(siamese.cry);
console.log(Cat.prototype);
siamese.__proto__.cry = "hello?";
console.log(siamese.cry);
console.log(Cat.prototype);
자식 객체의 __proto__
프로퍼티로 접근하면 부모 객체의 prototype
객체 내부 값을 변경할 수도 있다
단 __proto__
프로퍼티의 하위 프로퍼티로 값을 지정해주어야 값이 정상적으로 바뀌거나 추가되고, __proto__
명시 없이 값을 넣으면 prototype
객체 내부 값이 아닌 자식 객체의 자체 프로퍼티로 추가된다
위의 예시에서도 siamese.cry
로 __proto__
를 생략하고 바로 접근을 시도하니 prototype
객체가 아닌 siamese
자체 프로퍼티로 저장되었고, 이후 호출 때도 prototype 객체를 통한 상속값이 아니라 자체 프로퍼티로 덮어씌워져 호출된다
부모 객체의 모든 값이 다 상속되나요?
Array.from({length: 8}, (_) => 0);
const arr = new Array();
arr.slice(0, 3); // 작동
arr.from({length: 8}, (_) => 0); // 오류
아니다! 오직 prototype
객체 안에 정의된 값만 상속된다
slice()
메서드는 Array.prototype
안에 정의된 메서드이기 때문에 arr
변수도 상속받아 사용할 수 있지만, from()
메서드는 Array
생성자함수 자체에 선언된 메서드라 arr
변수가 접근할 수 없다
Array.from() - JavaScript | MDN
Array.prototype.slice() - JavaScript | MDN
위의 링크에서도 볼 수 있듯 slice()
메서드는 prototype
안에 정의되어 있고 (Array.prototype
의 메서드), from()
메서드는 Array
안에 직접 정의되어 있다 (Array
의 메서드)
prototype
프로퍼티에 하위 프로퍼티를 넣을지, 함수 안에 넣을 지에 따라 상속받는 객체들이 접근할 수 있는 값이 달라진다고 생각하자
MDN에서는 이것을 소개하면서 prototype
객체를 버킷 (양동이, 그냥 보관함 정도라고 생각하자) 이라고 묘사하고 있다
prototype
객체에 프로퍼티나 메서드를 담아 자식 객체들에 넘겨줄 수 있으므로 틀린 말은 아닌 것 같다
constructor 프로퍼티
function Cat(name) {
this.name = name;
}
const siamese = new Cat("siamese");
console.log(siamese);
console.log(siamese.__proto__.constructor);
Cat
생성자에 의해 생성된 siamese
객체의 __proto__
프로퍼티의 constructor
프로퍼티에 접근해 보자 (사실 __proto__
는 생략해도 무방하나, prototype
객체에서 가져왔다는 것을 명확하게 하기 위해 적어주었다)
[ Function: Cat ]
이라고 한다
이 Function: Cat
은 생성된 객체 (siamese
) 인스턴스 입장에서 자신을 생성한 생성자함수 (Cat
) 를 가리킨다
이때 siamese.__proto__
는 Cat.prototype
이므로 (같은 객체를 참조하므로) siamese.__proto__.constructor
= Cat.prototype.constructor
= Cat
와 같다
이 부분을 헷갈려서 한시간 넘게 고민하게 만든 문제가 아래에 있으니 기억해두자
function Cat(name) {
this.name = name;
}
const siamese = new Cat("siamese");
console.log(siamese.constructor);
사실 위에 적었듯 __proto__
프로퍼티를 거치지 않아도 siamese.constructor
으로 생성자 함수에 바로 접근할 수 있다
자바스크립트에서 함수가 정의될 때 이루어지는 일
function dog() {
console.log("woof");
}
console.log("constructor call");
const corgi = new dog();
console.log("constructor called");
console.log(corgi);
- 함수에게 생성자가 될 수 있는 자격이 부여된다
- 함수 이름 첫 글자를 대문자로 쓰지 않아도,
this
를 내부에서 쓰지 않아도 모든 함수들은 생성자가 될 자격이 있다 - 위의 예시에서
dog
은 내부에this
키워드도 없고, 이름 첫 글자가 대문자도 아닌 그냥 평범한 함수이다 - 하지만
dog
함수를 생성자로 사용하여corgi
객체를 만들어냈다! dog
함수 내의console.log
는 함수가 생성자로 호출될 시점에 같이 호출된다 (생성자를 호출한 것과 마찬가지이므로)
- 함수 이름 첫 글자를 대문자로 쓰지 않아도,
function dog() {
console.log("woof");
}
console.log(dog.prototype);
- 해당 함수에게
Prototype
객체 프로퍼티가 생성되고, 연결된다- 따로 별다른 조작을 하지 않아도, 해당 함수의
prototype
객체에 자연스럽게 접근이 가능하다
- 따로 별다른 조작을 하지 않아도, 해당 함수의
- 이 두 가지 특성 덕에, 모든 함수는 생성자로 활용하여 자식 객체를 만들 수 있다
프로토타입 체인
프로토타입 객체는 상위의 프로토타입 객체로부터 메서드와 프로퍼티를 상속받을 수 있다
그 상위의 프로토타입 객체도 더 상위의 객체로부터 상속을… 이처럼 위아래 프로토타입 객체가 서로 상속받을 수 있고, 자신이 상속받은 상위 프로토타입 객체의 프로퍼티나 메서드에 접근할 수 있다
이를 이용하여 특정 객체의 프로퍼티나 메서드를 탐색하는 중 해당 프로퍼티가 존재하지 않을 때, 상위 프로토타입 객체로 계속해서 올라가 해당 프로퍼티나 메서드를 검색하는 것을 프로토타입 체이닝이라고 한다
const obj = {};
const obj = new Object();
위의 코드는 아래의 코드와 같다
따라서 우리가 객체를 선언하고 힙에 할당해주는 것은, Object()
생성자를 통해 객체를 생성하는 것이므로 Object.prototype
을 그대로 상속받는다
- 모든 객체는
Object.prototype
을 상속받았으므로, 프로토타입 체이닝을 통해 가장 최상단까지 올라가면Object.prototype
에 도달한다 - 엄밀히 말하면 하위 객체는 상위 객체의 프로토타입 프로퍼티를 상속받는 것이 아닌, 상위 객체의 프로퍼티를 공유하는 것이므로 상위 객체의 프로퍼티가 변경되면 하위 객체 프로퍼티도 변경된다
- 더 엄밀히 말하자면 이러한 프로토타입 프로퍼티는 객체 내부에 정의된 것이 아니라, 프로토타입 객체라는 별도의 공간에 정의되어 있고 이 프로토타입 객체에 접근하는 모든 객체들이 값을 공유한다
function Creature() {
this.is = "Creature";
}
function Animal() {
this.type = "Animal";
}
Animal.prototype = new Creature(); // Animal은 Creature로부터 상속받음
Animal.prototype.constructor = Animal;
function Mammal(type) {
this.subType = "Mammal";
}
Mammal.prototype = new Animal(); // Mammal은 Animal로부터 상속받음
Mammal.prototype.constructor = Mammal;
function Cat(name) {
this.name = name;
}
Cat.prototype = new Mammal("cat"); // Cat은 Mammal로부터 상속받음
Cat.prototype.constructor = Cat;
const siamese = new Cat("siamese"); // siamese는 Cat으로부터 생성된 객체
console.log(siamese);
console.log(siamese.__proto__);
console.log(siamese.__proto__.__proto__);
console.log(siamese.__proto__.__proto__.__proto__);
console.log(siamese.__proto__.__proto__.__proto__.__proto__);
- 프로토타입 체이닝은
__proto__
프로퍼티가 가리키는 링크를 통해 올라갈 수 있다 - 위의 예제에서
Cat
은Mammal
을 상속받고,Mammal
은Animal
을,Animal
은Creature
를 상속받으므로__proto__
프로퍼티를 통해 프로토타입 체이닝으로 상위 객체를 거슬러올라갈 수 있고, 최종적으론Object
에 도달한다
Cat.prototype = new Mammal("cat"); // Cat은 Mammal로부터 상속받음
Cat.prototype.constructor = Cat;
- 여기서
[객체명].prototype.constructor = [객체명]
으로 재지정을 해준 이유는prototype.constructor
프로퍼티는 해당 인스턴스의 프로토타입의 생성자를 의미한다- 원래는 자기자신의 이름을 가리키는 것이 맞다
Cat.prototype
은Cat
의 자식 객체에게 전달하는 프로퍼티의 모음이다- 따라서
Cat.prototype.constructor
은Cat
의 생성자가 아니라,Cat
의 자식들의 생성자이다
Object.create
또는new
키워드를 사용하여 상속을 받으면 자기자신을 생성자로써 호출하여 만든 인스턴스가 아니므로[객체명].prototype.constructor
가 제대로 설정되지 않는다- 따라서 위에서
prototype.constructor
가 제대로 설정된 객체는 Animal (생성자는 Creature) 밖에 없다Cat.prototype.constructor
와Mammal.prototype.constructor
는 프로토타입 체이닝을 통해 거슬러올라가Animal.prototype.constructor
인Creature
를 가져오게 된다- 그렇기 때문에 출력해보면
Mammal
,Animal
이 뜨는 것이 아니라Creature
,Creature
가 출력된다
- 따라서 자식 객체가 생성자로 직계 부모 객체를 제대로 가리키게 하기 위해 참조를 재지정해주는 것이다
- (이것때문에 매우매우매우 헤맸다…)
프로토타입을 이용한 원시 타입의 확장
const str = "hello";
const n = 1;
const bool = true;
위의 세 값은 모두 자바스크립트에서의 ‘원시 타입' 이다
원시 타입에는 숫자 (number
), 문자열 (string
), boolean
, null
, undefined
가 있으며, 자바스크립트에서 이 타입을 제외한 모든 요소들은 객체이다
const str = "hello";
const n = 1;
const bool = true;
console.log(str.slice(0, 3)); // "he"
console.log(n.toString()); // 1;
분명 원시 타입이니까 메서드가 없어야 할텐데 이상하게 메서드를 사용가능하다
그 이유는 원시 타입으로 프로퍼티나 메서드를 호출하게 되면 일시적으로 해당 원시 타입과 연관된 객체로 변환되며, 해당 객체의 메서드들을 공유하게 된다
당연하게도 원시 타입은 (임시로 변환되어 메서드를 호출할 때를 제외하고) 객체가 아니므로 메서드나 프로퍼티를 추가할 수는 없고, 해당 원시 타입에 래핑되어 있는 객체의 메서드만을 사용할 수 있다
const str = "hello";
String.prototype.foo = () => console.log("wow!");
str.foo(); // 이건 가능
하지만 String
객체의 prototype
에 직접 메서드를 추가하면, string
원시 타입은 String
객체를 상속받는 형태이기 때문에 지정한 메서드가 사용가능하다
당연하게도 number
, boolean
등 다 그렇다
프로토타입 객체 바꿔버리기
function Mammal(type) {
this.subType = "Mammal";
}
function Cute() {
this.is = "Cute!!!!!"
}
function Cat(name) {
this.name = name;
}
Cat.prototype = new Mammal("cat"); // Cat은 Mammal로부터 상속받음
const tiger = new Cat("tiger");
console.log(tiger);
Cat.prototype = new Cute();
const domesticCat = new Cat("domestic");
console.log(domesticCat);
tiger
객체를 생성할 시점에 Cat
의 prototype
은 Mammal
을 가리키고 있었지만, 중간에 Cat
의 prototype
속성을 Cute
로 바꾼 후, domesticCat
객체를 생성하였다 (> < 귀여워~~)
tiger
객체와 domesticCat
객체는 서로 다른 프로토타입 (__proto__
) 을 가리키고 있는 것을 볼 수 있다
이처럼 프로토타입 객체를 바꾸고 싶다면 생성자 함수의 프로토타입을 바꿔주면 된다
여담
엄청나게 길었다.. 다른 사람들이 다들 평하는 것과 마찬가지로 __proto__
와 prototype
접근자의 차이가 제일 헷갈렸는데 직접 코드를 짜보면서 연습하니 이제 어느정도 개념을 알 것 같다
다만 객체지향에 완전 꽂혀서 이거 관련해서만 오늘 12시간째 정리하고 있는건 조금 슬프다 하하
객체지향을 어물쩡 넘겨버려서 천벌받는거라 생각해야겠다
참고 자료
Object prototypes - Web 개발 학습하기 | MDN
Object.create() - JavaScript | MDN
'Javascript + Typescript > 이론과 문법' 카테고리의 다른 글
Set, Map (0) | 2022.07.30 |
---|---|
자바스크립트에서의 함수형 프로그래밍 (0) | 2022.07.30 |
자바스크립트에서의 객체지향 (1) 객체지향 기본 (0) | 2022.07.26 |
자바스크립트의 대부분 요소는 객체인가요? (0) | 2022.07.26 |
require, import, export (0) | 2022.07.25 |