치춘짱베리굿나이스

자바스크립트에서의 객체지향 (2) 프로토타입 본문

Javascript + Typescript/이론과 문법

자바스크립트에서의 객체지향 (2) 프로토타입

치춘 2022. 7. 27. 00:28

프로토타입 기반 언어

프로토타입 기반 언어?

자바스크립트에 클래스… 문법이 있긴 하지만 이건 ES6에 와서야 생긴 문법이고, 근본적으로 자바스크립트에는 클래스 개념이 없다고 보아야 맞다

대신 자바스크립트에는 프로토타입 개념이 있어 이를 이용하여 클래스를 흉내낼 수 있다

프로토타입 기반 언어는 객체의 원형인 프로토타입을 선언하고, 이 프로토타입 객체를 이용하여 새로운 객체를 만들어낸다 = 클래스랑 비슷한 방식으로 동작하는 것이다!

자바스크립트에서 함수는 객체인가요?

자바스크립트의 대부분 요소는 객체인가요?

 

자바스크립트의 대부분 요소는 객체인가요?

자바스크립트의 대부분 요소는 객체인가요? 전혀 관계없는 거 (함수 관련) 검색하다가 함수는 객체라는 글을 보고 꽂혀서 배열도? 클래스도? 하면서 찾아보니까 다 객체였다 오죽하면 저 문장이

blog.chichoon.com

에 대해 짧막하게 기록해보았다

자바스크립트에서 함수는 객체라는 사실을 알고 아래의 개념을 공부해보도록 하자

객체 생성자

객체를 생성하는 방법

// 객체 리터럴
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.from() - JavaScript | MDN

Array.from() 메서드는 유사 배열 객체(array-like object)나 반복 가능한 객체(iterable object)를 얕게 복사해 새로운Array 객체를 만듭니다.

developer.mozilla.org

Array.prototype.slice() - JavaScript | MDN

 

Array.prototype.slice() - JavaScript | MDN

slice() 메서드는 어떤 배열의 begin부터 end까지(end 미포함)에 대한 얕은 복사본을 새로운 배열 객체로 반환합니다. 원본 배열은 바뀌지 않습니다.

developer.mozilla.org

위의 링크에서도 볼 수 있듯 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__ 프로퍼티가 가리키는 링크를 통해 올라갈 수 있다
  • 위의 예제에서 CatMammal을 상속받고, MammalAnimal을, AnimalCreature를 상속받으므로 __proto__ 프로퍼티를 통해 프로토타입 체이닝으로 상위 객체를 거슬러올라갈 수 있고, 최종적으론 Object에 도달한다

 

Cat.prototype = new Mammal("cat"); // Cat은 Mammal로부터 상속받음
Cat.prototype.constructor = Cat;
  • 여기서 [객체명].prototype.constructor = [객체명] 으로 재지정을 해준 이유는
    • prototype.constructor 프로퍼티는 해당 인스턴스의 프로토타입의 생성자를 의미한다
    • 원래는 자기자신의 이름을 가리키는 것이 맞다
      • Cat.prototypeCat의 자식 객체에게 전달하는 프로퍼티의 모음이다
      • 따라서 Cat.prototype.constructorCat의 생성자가 아니라, Cat의 자식들의 생성자이다
    • Object.create 또는 new 키워드를 사용하여 상속을 받으면 자기자신을 생성자로써 호출하여 만든 인스턴스가 아니므로 [객체명].prototype.constructor가 제대로 설정되지 않는다
    • 따라서 위에서 prototype.constructor가 제대로 설정된 객체는 Animal (생성자는 Creature) 밖에 없다
      • Cat.prototype.constructorMammal.prototype.constructor프로토타입 체이닝을 통해 거슬러올라가 Animal.prototype.constructorCreature를 가져오게 된다
      • 그렇기 때문에 출력해보면 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 객체를 생성할 시점에 CatprototypeMammal을 가리키고 있었지만, 중간에 Catprototype 속성을 Cute로 바꾼 후, domesticCat 객체를 생성하였다 (> < 귀여워~~)

tiger 객체와 domesticCat 객체는 서로 다른 프로토타입 (__proto__) 을 가리키고 있는 것을 볼 수 있다

이처럼 프로토타입 객체를 바꾸고 싶다면 생성자 함수의 프로토타입을 바꿔주면 된다

여담

엄청나게 길었다.. 다른 사람들이 다들 평하는 것과 마찬가지로 __proto__prototype 접근자의 차이가 제일 헷갈렸는데 직접 코드를 짜보면서 연습하니 이제 어느정도 개념을 알 것 같다

다만 객체지향에 완전 꽂혀서 이거 관련해서만 오늘 12시간째 정리하고 있는건 조금 슬프다 하하

객체지향을 어물쩡 넘겨버려서 천벌받는거라 생각해야겠다


참고 자료

객체와 프로퍼티,메소드

It is said that all Javascript objects have a prototype property, but I only see foo.prototype if foo is a function?

[Javascript] 프로토타입과 프로토타입 체인

Object prototypes - Web 개발 학습하기 | MDN

Object.create()와 constructor

Object.create() - JavaScript | MDN

Object.create()

[JavaScript] 상속(Inheritance)

Inheritance and the prototype chain - JavaScript | MDN

Prototype | PoiemaWeb

ZeroCho Blog

Comments