프로토타입과 프로토타입 상속

Ayaan·2022년 4월 19일
0
post-thumbnail

1. 프로토타입 상속


개발을 하다 보면 기존에 있는 기능을 가져와 확장해야 하는 경우가 생긴다
예를 들어 user라는 객체가 있는데 이 user와 굉장히 유사하지만 약간의 차이가 있는 객체를 만들어야 된다고 생각해보면 어떤 방식이 가장 효율적일까?

그 방식 중 하나가 바로 프로토타입 상속(prototypal inheritance)이다

[[prototype]]

자바스크립트의 객체는 [[prototype]]이라는 숨김 프로퍼티를 갖는다
이 값은 null이거나 다른 객체에 대한 참조가 되는데 다른 객체를 잠초하는 경우 참조대상을 '프로토타입(prototype)'이라 부른다

프로토타입에 재밌는 점은 위의 그림에서 object에서 프로퍼티를 읽으려고 하는데 해당 프로퍼티가 없으면 자동으로 프로토타입에서 프로퍼티를 찾는다는 점이다
프로그래밍에서 이런 동작 방식을 prototypal inheritance라고 한다

[[prototype]]은 숨겨져 있는 프로퍼티이지만 다양한 방법으로 개발자가 값을 설정할 수 있는데 이를 위해 아래와 같이 __proto__를 사용한다

let animal = {
  eats: true
};
let rabbit = {
  jumps: true
};

rabbit.__proto__ = animal;

__proto__[[prototype]]용 getter-setter다
즉, 값을 얻어오거나 변경할 수 있을 뿐 __proto__ === [[prototype]]이라는 말이 아니다
최근에는 __proto__대신 Object.getPrototypeOf로 값을 얻어오고
Object.setPrototypeOf을 사용하여 값을 설정한다
(단, 호환성 문제로 여전히 __proto__를 사용하는 경우도 많다)

위의 예시에서 rabbit에는 eats: true가 없지만 프로토타입으로 animal로 설정했기 때문에 해당 객체에서 프로퍼티를 가져올 수 있다

let animal = {
  eats: true
};
let rabbit = {
  jumps: true
};

rabbit.__proto__ = animal; // (*)

// 프로퍼티 eats과 jumps를 rabbit에서도 사용할 수 있게 되었습니다.
alert( rabbit.eats ); // true (**)
alert( rabbit.jumps ); // true

이러한 방식은 메서드에도 적용이 가능하다

let animal = {
  eats: true,
  walk() {
    alert("동물이 걷습니다.");
  }
};

let rabbit = {
  jumps: true,
  __proto__: animal
};

// 메서드 walk는 rabbit의 프로토타입인 animal에서 상속받았습니다.
rabbit.walk(); // 동물이 걷습니다.

프로토타입의 깊이(depth)는 무한히 깊어질 수 있지만 이를 권장하지는 않는다

let animal = {
  eats: true,
  walk() {
    alert("동물이 걷습니다.");
  }
};

let rabbit = {
  jumps: true,
  __proto__: animal
};

let longEar = {
  earLength: 10,
  __proto__: rabbit
};

// 메서드 walk는 프로토타입 체인을 통해 상속받았습니다.
longEar.walk(); // 동물이 걷습니다.
alert(longEar.jumps); // true (rabbit에서 상속받음)

프로토타입 체이닝에는 제약사항이 있는데

  1. 순환 참조(circular reference)를 허용하지 않는다
    즉, 닫힌 형태로 다른 객체를 참조하면 에러가 발생한다
  2. __proto__의 값은 객체나 null만 가능하며 다른 자료형은 무시된다
  3. 객체엔 오직 하나의 [[prototype]]만 있을 수 있다
    즉, 객체는 두 개의 객체를 동시에 상속받지 못한다
const animal = {
    walk: true
}
const human = {
    talk: true
}
const ayaan = {
    eat: true
}

ayaan.__proto__ = animal;
console.log(ayaan.walk); // true

// 하나의 객체만 상속받을 수 있음
ayaan.__proto__ = human;
console.log(ayaan.walk); // undefined
console.log(ayaan.talk); // true

// 순환 참조
human.__proto__ = animal;
animal.__proto__ = ayaan;
// VM570:1 Uncaught TypeError: Cyclic __proto__ value

"프로토타입은 읽기 전용이다" 이 말을 꼭 명심하도록 하자
프로토타입은 프로퍼티를 읽을 때만 사용한다
즉, 프로퍼티를 추가, 수정하거나 지우는 연산은 객체에 직접 해야한다

그리고 프로토타입은 상속받는 객체에 해당 프로퍼티가 없을 때에만 상속을 해준다

let animal = {
  eats: true,
  walk() {
    /* rabbit은 이제 이 메서드를 사용하지 않습니다. */
  }
};

let rabbit = {
  __proto__: animal
};

rabbit.walk = function() {
  alert("토끼가 깡충깡충 뜁니다.");
};

rabbit.walk(); // 토끼가 깡충깡충 뜁니다.

하지만 접근자 프로퍼티(accessor property)의 경우 조금 다르게 작동한다

let user = {
  name: "John",
  surname: "Smith",

  set fullName(value) {
    [this.name, this.surname] = value.split(" ");
  },

  get fullName() {
    return `${this.name} ${this.surname}`;
  }
};

let admin = {
  __proto__: user,
  isAdmin: true
};

alert(admin.fullName); // John Smith (*)

// setter 함수가 실행됩니다!
admin.fullName = "Alice Cooper"; // (**)

alert(admin.fullName); // Alice Cooper, setter에 의해 추가된 admin의 프로퍼티(name, surname)에서 값을 가져옴
alert(user.fullName); // John Smith, 본래 user에 있었던 프로퍼티 값

위 코드를 이해하기 위해서는 아무래도 this에 대한 이해가 더 필요할 것 같다

2. this


위 예시에서 this에는 어떤 값이 들어갈까?
결론부터 말하면 this는 프로토타입이 영향을 받지 않는다
메서드를 객체에서 호출했든 프로토타입에서 호출했든 상관없이 this는 언제나 .앞에 있는 객체를 의미한다

예를 들어 위에서 admin.fullName = ...으로 setter함수를 호출할 때 thisadmin이다

이러한 특성은 거대한 객체를 하나 만들고 여기서 많은 메서드들을 상속받게 하는 경우 용이하게 사용된다

예를 들어 메서드 저장소 역할을 하는 객체 animalrabbit이 상속받는다고 한다면

// animal엔 다양한 메서드가 있습니다.
let animal = {
  walk() {
    if (!this.isSleeping) {
      alert(`동물이 걸어갑니다.`);
    }
  },
  sleep() {
    this.isSleeping = true;
  }
};

let rabbit = {
  name: "하얀 토끼",
  __proto__: animal
};

// rabbit에 새로운 프로퍼티 isSleeping을 추가하고 그 값을 true로 변경합니다.
rabbit.sleep();

alert(rabbit.isSleeping); // true
alert(animal.isSleeping); // undefined (프로토타입에는 isSleeping이라는 프로퍼티가 없습니다.)

'메서드는 공유되지만 객체의 상태는 공유되지 않게 하려면 this를 사용하자'

for...in반복문을 사용하게 되면 상속받은 프로퍼티도 순회대상에 포함된다

let animal = {
  eats: true
};

let rabbit = {
  jumps: true,
  __proto__: animal
};

// Object.keys는 객체 자신의 키만 반환합니다.
alert(Object.keys(rabbit)); // jumps

// for..in은 객체 자신의 키와 상속 프로퍼티의 키 모두를 순회합니다.
for(let prop in rabbit) alert(prop); // jumps, eats

obj.hasOwnProperty(key)를 사용해 boolean value로 상속받은 프로퍼티인지 자신의 프로퍼티인지를 확인할 수 있다

let animal = {
  eats: true
};

let rabbit = {
  jumps: true,
  __proto__: animal
};

for(let prop in rabbit) {
  let isOwn = rabbit.hasOwnProperty(prop);

  if (isOwn) {
    alert(`객체 자신의 프로퍼티: ${prop}`); // 객체 자신의 프로퍼티: jumps
  } else {
    alert(`상속 프로퍼티: ${prop}`); // 상속 프로퍼티: eats
  }
}

여기서 재밌는 점은 animal은 객체 리터럴 방식으로 선언되었기 때문에 Object.prototype을 상속받는다

그렇기 때문에 rabbitrabbit.hasOwnProperty(key)를 갖고 있을 수 있는 것이다

MDN documentation을 보면 Object.prototype.someMethod()와 같은 형식으로 나열되어 있는데 그러한 이유가 Object.prototype을 상속받기 때문이다

그런데 이상하지 않는가? hasOwnProperty메서드는 for...in으로 열거할 수 없었는데 그 이유가 무엇일까?

hasOwnProperty)같은 메서드는 Property flags and descriptors에서 알아봤던 enumerablefalse이기 때문이다

3. 생성자 함수에서의 prototype


위에서 리터럴로 객체를 생성하게 되면 Object.prototype을 상속받는다고 했는데 그렇다면 new F()와 같이 생성자 함수를 통해 생성된 객체의 프로토타입은 어떨까?

생성자 함수(F)의 프로토타입을 의미하는 F.prototype에서 prototypeF에 정의된 일반 프로퍼티라는 점이 중요하다
F.prototype에서 prototype은 바로 앞에서 배운 [[Prototype]]과 비슷하게 들리겠지만 이름만 같을 뿐 실제론 다르다

let animal = {
  eats: true
};

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

Rabbit.prototype = animal;

let rabbit = new Rabbit("흰 토끼"); //  rabbit.__proto__ == animal

alert(rabbit.eats); // true
console.dir(rabbit);

console.dir(Rabbit);

생성자 함수와 생성자 함수를 통해 생성된 인스턴스의 구조적 차이를 console.dir()를 통해 알 수 있는데 이 차이를 그림으로 보면 다음과 같다

함수의 prototype은 사실 특별히 할당하지 않더라도 모든 함수가 가지고 있는 프로퍼티다
default는 constructor이며, constructor는 함수 자신을 가리킵니다

특별한 조작을 가하지 않았다면 new Rabbit을 실행해 만든 토끼 객체 모두에서 constructor프로퍼티를 사용할 수 있는데 이때 [[Prototype]]을 거친다

function Rabbit() {}
// 디폴트 prototype:
// Rabbit.prototype = { constructor: Rabbit }

let rabbit = new Rabbit(); // {constructor: Rabbit}을 상속받음

alert(rabbit.constructor == Rabbit); // true ([[Prototype]]을 거쳐 접근함)

constructor를 사용하기 위해서 처음과 같이 완전히 새로운 객체를 할당하기 보다는 prototype에 원하는 프로퍼티를 추가, 제거하는 것이 좋다

function Rabbit() {}

// Rabbit.prototype 전체를 덮어쓰지 말고
// 원하는 프로퍼티가 있으면 그냥 추가합니다.
Rabbit.prototype.jumps = true
// 이렇게 하면 디폴트 프로퍼티 Rabbit.prototype.constructor가 유지됩니다.

만약 실수로 constructor를 제거하였다면 수동으로 다시 만들어 줄 수 있으며 이는 처음 constructor의 특징을 그대로 보장한다

Rabbit.prototype = {
  jumps: true,
  constructor: Rabbit
};

(작성중)







*References

profile
2022.04.01. 첫직장에 입사한 신입 FE개발자입니다🔥

0개의 댓글