ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 프로토타입 체인(feat. __proto__ 무한 꼬리물기)
    코어 자바스크립트(정재남 지음) 스터디 모임 공부 2021. 7. 11. 20:49

    ** 본 페이지는 자바스크립트 스터디에서 공부하는 코어자바스크립트 책의 일부 내용을 정리했습니다
    스코프 체인에 이어 나오는 또 다른 프로토타입 체인.
    자바스크립트는 줄줄이 비엔나 소시지 같이 딸려 나오는게 많은 친구인가보다.
    프로토타입 체인을 이해하면 자바스크립트가 클래스를 구현하는 방식도 이해할 수 있다고 하니 잘 배워보자!

     

    1. 메서드 오버라이드

    프로토타입 체인을 배우기에 앞서, '같은 이름'으로 인해 생길 수 있는 문제가 있는지 알아보자. 
    호출한 메서드나 속성이 인스턴스에 있는지 먼저 살피고 있으면 그걸 쓰고 없으면 생성자 함수의 prototype에 있는 메서드나 프로퍼티를 자기것처럼 쓰는 것인데, 근데 인스턴스와 생성자 함수의 prototype에 같은 이름의 메서드나 프로퍼티가 있다면?

     

    prototype 객체를 참조하는 __proto__를 생략해도 인스턴스는 생성자함수의 prototype에 정의된 프로퍼티, 메서드를 자기 것처럼 쓸 수 있다. 그런데 만약 인스턴스에도, 생성자함수의 prototype 두 쪽에 모두 같은 이름의 프로퍼티나 메서드가 있으면 어떻게 될까?

    let Person = function(name) {
      this.name = name;
    };
    Person.prototype.getName = function() {
      return this.name;
    };
    let iu = new Person('지금');
    
    iu.getName = function() {
      return `바로 ${this.name}`;
    };
    console.log(iu.getName()); 
    // 바로 지금이 출력됨. 왜냐면 iu 인스턴스안에 getName이란 메서드가 실행된거니까

    iu 인스턴스에는 원래 getName이라는 메서드가 없었지만 이후 추가해줬다. 그래서 같은 이름의 메서드가 생성자함수의 prototype에 있다고 하더라도, 자바스크립트는 같은 이름의 메서드나 프로퍼티를 인스턴스에 먼저 검색하고 있다면 실행하고 없다면 그 다음 생성자함수의 prototype를 검색하는 식의 순서이므로 인스턴스 자체의 메서드인 getName이 실행되는 것이다. 이 원리로 일어난 현상을 메서드 오버라이드라고 한다.  

     

    그런데 메서드 오버라이드가 일어나고 있는 상태에서 __proto__를 이용해 생성자함수의 prototype에 접근하려고 한다면?

    console.log(iu.__proto__.getName()); 
    // undefined 출력

    this는 .앞에 있는 객체의 프로퍼티를 가리킨다. iu.__proto__는 Person의 생성자함수의 prototype을 가리키는데 거기에는 name이란 프로퍼티가 정의 되어 있지 않다. 그래서 undefined가 나온다.

     

    그래서 다음과 같이 생성자함수의 prototype에 name 프로퍼티를 정의해주면 그 값을 출력한다.

    Person.prototype.name = '아하하';
    console.log(iu.__proto__.getName()); 
    // 아하하를 출력

    이렇게 해두면 this는 prototype을 가리키므로 this가 인스턴스를 가리키게 하려면 다음과 같이 해준다.

    console.log(iu.__proto__.getName.call(iu)); 
    // 지금이 출력

    이렇게 하면 오버라이드가 생기는 경우에도 prototype의 메서드나 프로퍼티에 접근이 가능하다.

     

    2. 프로토타입 체인

    객체의 내부 구조를 먼저 살펴보자.

    다음으로 배열의 내부 구조를 보자

    객체와 배열은 서로 데이터타입이 다르다. 그런데 배열의 내부구조의 맨 밑에 __proto__가 또 있어서 열어보면 객체의 __proto__안에 있는 메서드들은 객체의 __proto__ 안에 있는 메서드들과 같음을 알 수 있다. 이렇게 되는 이유는 prototype 객체도 '객체'이고 모든 객체의 __proto__는 Object.prototype이 연결되기 때문이다. 개념도로 표현하면 다음과 같다.

    이렇게 데이터의 __proto__ 프로퍼티 내부에 다시 __proto__ 프로퍼티가 연쇄적으로 이어진 것을 프로토타입 체인이라고 한다. 이 체인을 따라가면서 검색하는 것을 프로토타입 체이닝이라고 부른다.

     

    프로토타입 체이닝은 앞서 설명한 오버라이딩과 같은 개념이다. 어떤 메서드를 호출하면 자바스크립트 엔진은 데이터 자신의 프로퍼티들을 검색해서 원하는 메서드가 있으면 그걸 실행한다. 하지만 메서드가 없다면 __proto__를 검색해서 그 메서드가 거기 있다면 실행하고 여기도 없다면 거기의 __proto__를 검색해서 있으면 실행하고 없으면... 을 반복한다.

     

    다음 예시는 메서드 오버라이딩과 프로토타입 체이닝의 예다.

    let arr2 = [1, 2];
    Array.prototype.toString.call(arr2); // "1, 2"
    Object.prototype.toString.call(arr2); // [object Array];
    
    arr2.toString(); 
    // '1, 2' : Array.prototype.toString.call(arr2)과 같다.
    // 왜냐면 __proto__는 생략가능하므로.
    
    // arr 배열객체 자체에 toString 메서드 부여
    arr2.toString = function() {
      return this.join('_');
    };
    
    // 메서드 오버라이딩으로 인해서 아래 값 출력.
    arr2.toString(); // 1_2

     

    자바스크립트의 모든 데이터는 타입에 관계없이 위에서 예로든 배열의 프로토타입 체인 구조와 같은 형태의 프로토타입 체인 구조를 지닌다.

    이 프로토타입 체인의 구조에는 두가지 큰 특징이 있다

    1. 위쪽 삼각형 우측 꼭지점에는 항상 무조건 Object.prototype이 있다. 왜냐면 모든 prototype 객체는 '객체'니까

    2. 삼각형은 꼭 2개만 연결되는게 아니다.

     

    위의 프로토타입 체인의 개념도는 instance를 시작점으로 __proto__만  따라가면서 그린 개념도이지만, 실제로 접근가능한 모든 경우를 포함한 개념도는 다음처럼 복잡하다.

    이렇게 복잡한 구조가 생기는 이유는, 생성자 함수도 함수이기 때문에 Function 생성자 함수의 prototype과 연관되기 때문 되고 Function 생성자 함수도 함수니까 Function 생성자함수의 prototype과 연계되고.... 이런 식으로 끝없이 재귀적으로 반복하게 되는 과정도 추가되기 때문이다. 

     

    이 원리에 의해 콘솔창에서도 __proto__가 계속 나오고 이를 꼬리 물듯이 계속 들어갈 수 있지만, 의미는 없다. 왜냐면 인스턴스와 직접적으로 연관된 삼각형에만 관련된 프로토타입 체이닝만 이해하면 되기 때문이다.

     

    3. 객체 전용 메서드의 예외사항

    어느 생성자함수든 prototype은 객체이므로 Object.prototype이 언제나 프로토타입 체이닝의 최상단에 위치한다. 그러므로 객체에서만 사용할 메서드는 다른 데이터 타입처럼 프로토타입 객체 내부에 저장할 수 없다. 왜냐면 객체에서만 사용할 메서드를 Object.prototype 내부에 정의한다면 다른 데이터 타입도 해당 메서드를 사용할 수 있기 때문이다. 

     

    그런 경우가 생기는 코드의 예는 다음과 같다.

    Object.prototype.getEntries = function() {
      var res = [];
      for (var prop in this) {
        if (this.hasOwnProperty(prop)) {
          res.push([prop, this[prop]]);
        }
      }
      return res;
    }
    
    var data = [
      ['object', {a:1, b:2, c:3}],
      ['number', 345],
      ['string','abc'],
      ['boolean', false],
      ['func', function() {}],
      ['array', [1,2,3]],
    ];
    data.forEach(function(datum) {
      console.log(datum[1].getEntries());
    })

    첫줄에 객체에서만 사용할 메서드로 getEntries를 지정해줬다. 그런데 var data라는 리스트에 있는 원소들은 모두 저 메서드가 적용이 되어서 오류 없이 결과값을 반환하고 있다. 왜냐면 어느 데이터타입이든 프로토타입 체이닝을 통해서 getEntries에 접근이 가능하기 때문에 데이터타입이 객체가 아니어도 오류가 나지 않고 결과값을 문제없이 반환하기 때문이다.

     

    그러므로 객체 인스턴스에만 작동할 객체 전용 메서드는 Object.prototype이 아니라 Object에 스태틱 메서드로 부여 할 수밖에 없다. 그렇게 하는 방법은 다음 그림과 같다

    위 콘솔창에서처럼 객체의 경우, Object.prototype.method_name = function () {} 이런식으로 메서드를 부여하는 것이 아니라 instance.method_name = function() {} 이런식으로 특정 instance에서만 쓸 수 있는 메서드를 만드는식으로 해야 한다. 

     

    그리고 객체에 관한 프로토타입 체이닝의 이런 특성 때문에 Object.prototype에는 어느 데이터에서든 사용할 수 있는 범용적인 메서드들만 있다. 

     

    4. 다중 프로토타입 체인

    자바스크립트에서는 사용자가 대각선의 __proto__를 연결해나가기만 하면 무한대로 체인을 이어갈 수 있다. 이 원리를 이용해서 만든 것이 자바스크립트의 클래스이다. 

     

    대각선의 __proto__를 연결하는 방법은, 생성자함수의 prototype이 연결하고자 하는 상위 생성자 함수의 인스턴스를 __proto__가 가리키게 해주면 된다.

     

    예제를 통해 알아보자. 아래 코드에서는 Grade라는 생성자함수와 그걸로 만든 g라는 인스턴스가 있다.

    var Grade = function() {
      var args = Array.prototype.slice.call(arguments);
      for (var i=0; i<args.length; i++) {
        this[i] = args[i]; // 인덱스로 접근하기
      }
      this.length = args.length;
    }
    
    var g = new Grade(100, 80);
    // slice.call(arguments)에 의해서
    // args = [100, 80] 
    // this[0] = 100, this[1] = 80
    // this.lenght = 2
    console.log(g); // Grade { '0': 100, '1': 80, length: 2 } // 유사배열객체

    ** 둘째줄의 var args = Array.prototype.slice.call(arguments); 는 MDN페이지를 보고 이해하자.

    g는 생성자함수 Grade 내부의 규칙에 의해 생긴 유사배열객체 인스턴스다. 이 유사배열객체인 인스턴스 g에다가 배열메서드를 쓰게 하고 싶다면 다음과 같이 g.__proto__와 같은 것을 참조하는 Grade.prototype이 배열의 인스턴스(= [1,2] 같은 실제 배열)를 바라보게 하면 된다. 

    var Grade = function() {
      var args = Array.prototype.slice.call(arguments);
      for (var i=0; i<args.length; i++) {
        this[i] = args[i]; // 인덱스로 접근하기
      }
      this.length = args.length;
    }
    Grade.prototype = []; // Grade.prototype이 배열 인스턴스를 바라보게 함
    
    var g = new Grade(100, 80);
    // slice.call(arguments)에 의해서
    // args = [100, 80] 
    // this[0] = 100, this[1] = 80
    // this.lenght = 2
    console.log(g); // Grade { '0': 100, '1': 80, length: 2 } // 유사배열객체
    
    g.pop()
    console.log(g) // Array { '0': 100, length: 1 } : 배열처럼 pop() 메서드가 적용됨
    console.log()
    
    g.push(90);
    console.log(g); // Array { '0': 100, '1': 90, length: 2 } : 배열처럼 push() 메서드가 적용됨

    Grade.prototype = []; 이라는 명령으로써 아래 개념도와 같이 Array 생성자 함수와 Array.protype이 연결된 프로토타입 체인이 생성된다. 이 프로토타입 체인은 2단계가 아니라 3단계이므로, 2단계 보다 많은 다중 프로토체인도 가능하다는 것을 알 수 있다. 이 프로토타입 체인을 해석하면, 인스턴스 g는 프로토타입 체인에 따라서 올라가면서 인스턴스 g 자신만의 메서드 + Grade prototype에 있는 메서드 +  Array.prototype에 있는 메서드 + Object.prototype에 있는 메서드에 접근해 사용할 수 있게 된다. 그러므로 유사배열객체이던 인스턴스 g는 Array.prototype을 참조할 수 있게 되므로 Array.prototype에 정의된 pop(), push() 메서드를 사용할 수 있게 된다. 

     

    댓글

금손이 프론트엔드 개발자가 되고자 오늘도 존버중