[es6for3] Class 생성기

es6for3 시리즈

ES6는 새로운 언어철학을 제시하고 편리한 기능을 제공합니다. 하지만 크롬(52버전까지)조차 별도로 플래그를 활성화해야 만 쓸 수 있는 상황입니다.
이에 업계는 바벨 등의 번역기를 이용하여 ES5로 번역시키는 수를 사용하고 있습니다. 결국 바벨이 번역한 코드는 바벨라이브러리를 사용하는 ES5코드로 번역됩니다. ES6에 대한 깊은 이해는 ES6의 코드 방식을 하위 ES3.1로 번역하는걸 가능하게 하죠. ES6 기능에 대한 정확한 이해와 나만의 바벨을 만들기 위한 첫 단계로 ES6의 각 기능을 ES3.1로 번역해보죠.

프로토타입과 다형성

객체지향 개발론에 자주 등장하는 클래스는 사실 객체지향 프로그래밍의 핵심 요소는 아닙니다. 객체지향 프로그래밍이 달성해야하는 여러가지 항목이 있지만 이 포스팅은 그에 대한 글이 아니므로 그 중 다형성에 대해서만 살짝 짚어보죠. 다형성이라는 목적을 달성하려면 적어도 두 가지를 충족해야합니다.

  1. 대체가능성(substitution) – 어떤 형을 요구한다면 그 형의 자식형으로 그 자리를 대신할 수 있다.
  2. 내적동질성(internal identity) – 객체는 그 객체를 참조하는 방식에 따라 변화하지 않는다. 즉 업다운캐스팅해도 여전히 최조 생성한 그 객체라는 것입니다.

이 두 가지를 달성하는 방법 중 전통적인 클래스구조를 사용하는 것은 사실 참조포인터를 다수 생성하여 포인터간 복잡한 링크드리스트 연산을 시키게 됩니다. 이를 실현할 수 다른 대안이 여럿 제시되어왔습니다만 그 중 프로토타입시스템이 있습니다. 브렌든 아이크는 이 시스템을 차용하기로 맘먹었습니다. 해당 키를 체이닝으로 찾아가는 프로토타입체인은 다음과 같은 방법으로 대체가능성과 내적동질성을 확보합니다.

var Dog = function(){};
Dog.prototype.bark = function(){
  return 'woof';
};

var Spitz = function(){};
Spitz.prototype = new Dog();
Spitz.prototype.bark = function(){
   return 'woof woof'; //더 시끄러움.
};

var dog = new Spitz();
console.log(dog instanceof Dog); //true 대체가능성
console.log(dog.bark()); //woof woof 내적동질성

프로토타입체인의 명쾌한 점은 우선 본인의 키를 찾고 없으면 체이닝을 통해서 찾는다는 점입니다.

  1. 따라서 하위 클래스의 인스턴스는 우선적으로 자신의 키를 찾고 그 이후에나 가서 부모의 키를 찾게 됩니다.
  2. 이 단순한 원리와 함께 함수의 this는 호출되는 형태에 의존한다라는 점이 결합되어

내적동질성을 만들어내게 됩니다. 이는 내부를 위임하는 복잡한 형태의 내적동질성 응용에서도 잘 작동합니다.

var Dog = function(){};
Dog.prototype.prepare = function(){
  return 'woof';
};
Dog.prototype.bark = function(){
  return this.prepare();
};

var Spitz = function(){};
Spitz.prototype = new Dog();
Spitz.prototype.prepare = function(){
   return 'woof woof';
};

var dog = new Spitz();
dog.bark(); //woof woof

위의 예에서 dog.bark()가 작동하는 순서를 차근차근 짚어보죠.

  1. dog.bark가 존재하지 않으므로 dog.prototype.bark를 찾는다.
  2. dog.prototype.bark도 존재하지 않으므로 dog.prototype.prototype.bark를 찾는다.
  3. Dog.prototype.bark가 존재하므로 이 함수가 작동한다.
  4. 함수 내부에서 this.prepare()를 호출한다.
  5. this는 dog.bark()로 호출된 시점에 결정되었으므로 new Spitz상태를 유지한다(내적동질성)
  6. 따라서 this.prepare()는 Spitz.prototype.prepare를 찾아서 호출하게 된다(내적동질성)

위에서 5, 6번이 바로 내적동질성을 보여줍니다. 그 중심에는 this가 결정되는 원리와 가까운 키부터 찾아들어가는 체이닝 시스템이 있습니다.
이러한 면에서 프로토타입체이닝 시스템은 다형성을 구현하는데 런타임에 최소한의 필요한 참조포인터를 유지하는 좋은 시스템입니다.

클래스를 만들려는 시도와 ES6

언어 초창기야 아이크옹이 좋아라하면서 프로토타입을 사용했으니 그걸 그대로 잘 쓰려는 패턴이 많았습니다…랄까 제대로 이 프로토타입을 이해하는 개발자가 많지 않았던 거죠. 이후 self언어에 대한 연구와 js상에서의 응용패턴이 널리 알려지자 new키워드에 대한 비판과 자바스러워보일 필요없다는 기조를 이루게 됩니다. 그렇습니다, 당시 잘나가던 크록포트옹이죠. 이 사람 잘 나갈 때는 결국 ES4도 파기시키고 거의 자기가 만든 라이브러리를 ES5 스펙으로 올려버릴만큼 영향력이 대단했습니다(야후가 망해서 머 별볼일 없어졌지만)
해서 ES5의 공식적인 클래스 생성법은 new가 아닙니다.

var proto = {
  method1:function(){},
  method2:function(){}
};
var property = {
  a:{value:1, writable:true},
  b:{value:1, writable:true}
};
var initializer = function(obj, a, b){
  obj.a = a;
  obj.b = b;
  return obj;
}
var instance = Object.create(proto, property);
instance = initializer(instance, 3, 4);
console.log(instance.a, instance.b); // 3, 4

기존 new가 하는 일이 내부에 프로토타입체이닝이 될 대상을 지정하는 정도이므로 이를 대체하기 위해 Object.create를 만들었고 더 이상 함수는 필요없게 된거죠. 하지만 아이러니하게도

  1. 속성지정자를 보내야 기초적인 속성을 지정해줄 수 있고
  2. 결국 생성자처럼 최초 인스턴스를 초기화할 함수가 필요하긴 함

머 이런거죠. 오히려 귀찮고 얻는 득도 별로 없어서 실무적으로 여전히 ES3.1문법이 선호되는 지경입니다.

ES6에서는 이 점을 아예 공식적으로 수용하고 new 키워드를 적극적으로 쓰되 ES5의 프로토타입체이닝만 수정하는 기능을 추가로 제공하기로 수정했습니다(역시 크록포드가 짜져서 가능해진..)

  1. proto 를 공식화하여 Object의 정적 메소드 도움없이도 손쉽게 변경
  2. Object.setPrototypeOf로 아예 직접적으로 지정하는 메소드를 제공하여 create의 사실상 무력화

즉 기존의 Object.create패턴은 사실상 완전히 무력화되었습니다. 하지만 여기서 더 나아가 ES6는 Class구문을 확정시키고 내부적인 프로토타입시스템을 인식하지 않는 객체지향문법을 제안했습니다. 실제 구현이 어떻게 한 것이냐가 문제가 아니라 런타임안정성을 대폭 확장하고 extends, super의 개념을 도입하는 등 문법적으로 프로토타입의 복잡성을 은닉하고 보다 추상화된 OOP환경을 제공하는 것이죠.

해서 개인적으로 판단하기에 ES6에서는 class를 사용하지 않는 모든 클래스 생성방식을 지양하고 class 시스템에 맞게 언어적인 접근 방법을 바꾸는 편이 훨씬 이득입니다.

ES6의 class

ES6의 class는 기존의 프로토타입으로 구현할 수는 있지만 굉장히 복잡한 기능을 은닉해줍니다. 특히 super키워드와 extends키워드로 은닉되는 코드의 양이 상당합니다. 이 중 먼저 super에 대해서 살펴보죠.

const Parent = class{
  static testA(){
    return 'parent::testA';
  }
  constructor(){
    this.a = 1;
  }
  print(){
    return this.a;
  }
}
const Child = class extends Parent{
  static testA(){
    return 'child::' + super.testA(); //1
  }
  constructor(){
    super(); //2
    this.b = 2;
  }
  print(){
    return this.b + super.print(); //3
  }
}

위의 코드에서 총 3가지 super가 나옵니다. 각각의 굉징히 다르게 작동하고 이는 ES6의 해석기가 미리 처리해주기 때문에 가능한 것입니다. 코드상의 번호대로 설명해보죠.

  1. 정적 메소드에서의 super는 부모 클래스 그 자체를 가리킴. 따라서 super.testA()는 결국 Parent.testA()와 동일한 표현으로 처리됨.
  2. 생성자에서의 super는 부모클래스의 생성자를 가리키는데 그냥 super()로 호출하고 있다는 점에서 Parent.bind(this) 상태의 함수라 할 수 있음.
  3. 메소드내에서의 super는 Parent.prototype에 해당되고 이 때 print도 Parent.prototype.print() 라고 생각하기 쉽지만 this는 현재의 this이므로 무려 Parent.prototype.print.bind(this) 상태라고 볼 수 있음.

super 키워드가 이러한 작업을 미리 해주기 때문에 복잡한 후처리를 생각하지 않고 편리하게 사용할 수 있습니다. 이러한 관계는 클래스 생성 당시에 확정되어야하기 때문에 부모가 될 클래스를 즉시 알아야합니다. 따라서 extends 키워드에 생성시점부터 부모클래스를 넘기는건 매우 자연스럽다고 할 수 있습니다.

이 외에도 기존의 생성자보호패턴 등 자주 사용되던 자바스크립트 런타입보호 패턴이 아예 내장되어있습니다.

const Parent = class{
  static testA(){
    return 'parent::testA';
  }
  constructor(){
    this.a = 1;
  }
  print(){
    return this.a;
  }
}

Parent(); //1. TypeError
new Parent.testA(); //2. TypeError
new Parent.prototype.print(); //3. TypeError
  1. 생성자를 함수처럼 호출하면 에러다!
  2. 정적메소드를 생성자처럼 쓰면 에러다!
  3. 메소드를 생성자처럼 쓰면 에러다!

기본적으로 런타임에 잘못된 참조편법으로 사용하는 걸 막는 기능이 내장되어있습니다.

마지막으로 제네레이터메소드에게 자동으로 this바인딩이 확정되는 기능이 있습니다.

const Child = class{
  constructor(limit = 10){
    this.limit = limit;
  }
  check(){
    return this.limit--;
  }
  *iterator(){
    const v = this.check();
    if(v) yield v;
  }
}
const test = new Child(5);
for(const v of test.iterator()) console.log(v); //4,3,2,1,0

마지막에 생성된 이터레이터가 실제 작동할 때 분명 제네레이터 호출 후엔 Child와의 연결점이 없을텐데도 코드 내부의 this는 test인스턴스를 가리키고 있습니다. 심지어 제네레이터 내부의 super도 정적제네레이터는 부모클래스를, 메소드제네레이터의 super는 부모프로토타입의 제네레이터를 가리키고 있습니다.
이게 기존에 구현한 제네레이터에 추가적인 상태를 보내줘야하는 이유가 됩니다.

여하튼 언어적표준이 있다는 것은 좋은 것입니다. 기존에 js에서 클래스를 만드는 법은 라이브러리마다 제각각이었지만 이젠 다소 API모양이 달라도 ES6의 철학에 부합되게 만들어내는걸 클래스라고 부를 수 있게 된 점만 해도 행복한 상황입니다.

Class함수 제작

이제 이를 거의 비슷하게 만들어내는 Class라는 함수를 제작할 것입니다. 다음과 같은 형태로 정의하는 API를 생각해 봤습니다.

var Parent = Class({
  'static testA':function(){
     return 'parent::testA';
  },
  constructor:function(){
     this.a = 1;
  },
  print:function(){
    return this.a;
  }
});
var Child = Class(Parent, {
  'static testA':function(){
    return 'child::' + super.testA();
  },
  constructor:function(b){
    Super();
    this.b = b || 5;
  },
  print:function(){
    return this.b + super.print();
  },
  '*iterator':function(ec){
    if(this.b--) Yield(this.b);
  }
});

거의 ES6와 문법적으로 흡사하여 큰 무리 없이 이해할 수 있습니다.

기본적인 Class함수의 구조

일단 함수의 시그니처를 검토해보죠.

  1. 우선 인자로 (부모, 객체) 또는 (객체) 두가지 형식으로 받아들여 상속여부를 결정합니다.
  2. 객체에 들어갈 수 있는 인자는 다음과 같습니다.
    • ‘constructor’ – 생성자를 만들어낸다.
    • ‘static *xxx’ – 정적 제네레이터를 만들어낸다.
    • ‘static xxx’ – 정적 메소드를 만들어낸다.
    • ‘xxx’ – 메소드를 만들어낸다.
    • ‘*xxx’ – 제네레이터 메소드를 만들어낸다.

간단합니다. 우선 상속과 생성자 부분을 처리해보죠.

var Class = function(){
  var parent, prop, cstrt; //생성자는 키워드일 수 있다..

  //첫번째 인자가 함수면 부모다!
  if(typeof arguments[0] == 'function') parent = arguments[0];

  //부모가 없으면 0번째 있으면 1번째가 인자객체다(없어도 괜찮고)
  prop = arguments[parent ? 1 : 0];

  //prop.constructor가 생성자다.
  if(prop && prop['constructor']) cstrt = prop['constructor'];

  //위의 정보로 실제 클래스 생성..
}

기본 와꾸가 나옵니다. 인자를 적절히 판단하여 부모와 생성자를 추려냈습니다. 이제 생성자 함수를 포함하여 실제 클래스가 될 함수를 정의합니다.
헌데 부모가 있는 경우 생성할 클래스의 프로토타입에 new Parent()를 할당해야합니다. 이 경우엔 객체를 생성한게 아니라 그저 상속체이닝을 위한 생성입니다. 이 경우를 분리할 가장 좋은 방법은 ES5를 사용하는 것입니다.

cls.prototype = Object.create(parent.prototype);

하지만 본 프로젝트는 ES3.1을 노리고 있으므로 다른 방법을 강구해야합니다.

var protoInit = {};
cls.prototype = new parent(protoInit);

이런 식으로 내부에서만 알 수 있는 인자를 보내면 생성자 내부에서 무시하는 식으로 대처할 수 있습니다.
이를 반영하여 실제 클래스를 생성해보죠.

var protoInit = {};
var Class = function(){
  //상동
  var parent, prop, cstrt;
  if(typeof arguments[0] == 'function') parent = arguments[0];
  prop = arguments[parent ? 1 : 0];
  if(prop && prop['constructor']) cstrt = prop['constructor'];
  //실제 클래스
  var cls = function(v){

    //그저 상속체인을 위한 경우는 건너뛴다.
    if(v === protoInit) return;

    //일반적인 함수호출은 예외로처리함
    if(!(this instanceof cls)) throw new TypeError();

    //생성자가 있으면 Super처리후 apply한다.
    if(cstrt){
      var self = this, prev = window.Super;
      window.Super = function(){
        if(parent) parent.apply(self, arguments);
      };
      cstrt.apply(self, arguments);
      window.Super = prev;
    }
  }
  //부모가 있으면 체이닝한다.
  if(parent) cls.prototype = new parent(protoInit);

  //최종클래스 반환
  return cls;
};

간단하므로 주석으로 설명을 대신합니다. 위의 코드를 통해

  1. new가 아닌 호출시엔 예외가 발생
  2. 생성자를 넘기지 않아도 잘 작동
  3. 생성자 내부에서 Super()를 ES6와 흡사하게 사용가능

을 달성합니다. 여기까지만 해도 다음의 코드는 성립하게 되죠.

var Parent = Class({
  constructor:function(a){
    this.a = a;
  }
});
var Child = Class(Parent, {
  constructor:function(a, b){
    Super(a);
    this.b = b;
  }
});

var test = new Child(1, 2);

console.log(test.a, test.b); // 1, 2
console.log(test instanceof Parent); //true
console.log(test instanceof Child); //true

Parent(); //TypeError
Child(); //TypeError

벌써 많이 했습니다. 이제 정적 메소드와 그냥 메소드를 추가해 보겠습니다. 걍 cls에 대충 끼워넣으면 될 것도 같지만 super문제로 쉽지도 않습니다.
우선 정적 메소드는 Super가 가리키는 것인 그저 부모 클래스이므로 어렵지 않습니다.

var protoInit = {};
var Class = function(){
  //상동
  var parent, prop, cstrt;
  if(typeof arguments[0] == 'function') parent = arguments[0];
  prop = arguments[parent ? 1 : 0];
  if(prop && prop['constructor']) cstrt = prop['constructor'];
  var cls = function(v){
    if(v === protoInit) return;
    if(!(this instanceof cls)) throw new TypeError();
    if(cstrt){
      var self = this, prev = window.Super;
      window.Super = function(){
        if(parent) parent.apply(self, arguments);
      };
      cstrt.apply(self, arguments);
      window.Super = prev;
    }
  }
  if(parent) cls.prototype = new parent(protoInit);
  var k;

  //1. 부모가 있으면 메소드용 super객체를 만들어야한다.
  var methodSuper = {self:null};
  if(parent){

    //부모의 프로토타입을 순회한다.
    var proto = parent.prototype;
    for(k in proto) if(proto.hasOwnProperty(k)){

      //각 메소드는 methodSuper의 self키에 할당된 컨텍스트를 this로 바인딩하게 재작성됨.
      //methodSuper의 self를 각 메소드 호출에서 this로 할당해줌.
      methodSuper[k] =(function(f){
         return function(){
           return f.apply(methodSuper.self, arguements);
         };
      })(proto[k]);
  }
 
  for(k in prop) if(k != 'constructor' && prop.hasOwnProperty(k)){

    //2. 우선 정적 메소드를 골라낸다.
    if(k.substr(0, 7) == 'static '){

      //정적메소드이므로 클래스에 넣어주면 되지만 Super를 정의해야함
      cls[k.substr(7)] = (function(f){
        return function(){
          var result;
          var prev = window.Super; //스택관리

          //super는 부모가 된다. 아니면 Object로 처리
          window.Super = parent || {}; 

          //정적메소드의 this는 클래스다.
          result = f.apply(cls, arguments); 

          //스택복귀
          window.Super = prev;

          return result;
        };
      })(prop[k]);

    //3. 아니면 메소드다
    }else{ 
      cls.prototype[k] = (function(f){
        return function(){
          var result, prev = W.Super;
          //위에서 생성한 메소드용 super에 this를 설정한다.
          methodSuper.self = this;

          //이제 super는 메소드용 super고 이때 this는 현재 인스턴스가 된다.
          window.Super = methodSuper;

          result = f.apply(this, arguments);
          window.Super = prev;

          return result;
        };
      })(prop[k]);

    }
  }

  return cls;
};

분량이 좀 됩니다. 우선 메소드안에서 호출하는 super는 굉장히 특이합니다.

  1. super를 통해 접근하는 속성은 반드시 parent.prototype에 정의된 메소드여야하고
  2. 그렇다고 해도 이때 this는 현재 자신입니다.

이를 해결하기 위해 동적으로 this를 바인딩할 계획으로 클래스당 한 개의 메소드용 슈퍼객체를 작성합니다.
당연히 이 슈퍼 객체의 키에는 parent.prototype의 키가 전부 할당됩니다. 단점이라면 이렇게 설정이 끝난 후에 다시 parent쪽에 메소드를 추가하면 그것이 반영되지 않는다는 것입니다(이러한 동적연결은 현재로서는 마땅히 방법이 없습니다. 이를 해소하려면 Proxy의 trap을 쓰면 되지만 이것 자체도 ES6라서 동적 바인딩 반영을 포기했습니다) 여기까지의 코드가 주석상 1번입니다.

이제 for in으로 인자로 받은 prop를 순회하면서 정적메소드와 메소드를 구분지어 반영시킵니다.

정적메소드는 cls의 속성으로 반영하면 되고 일반 메소드는 cls.prototype에 반영하면 됩니다. 하지만 이 때도 Super의 처리가 제각각이기 때문에 이를 처리하기 위해 함수를 감싼 함수를 만들어 할당해야합니다.

  1. 정적메소드인 2번 주석부분을 보면 각 메소드에 대한 래퍼가 Super는 Parent로 만들어주고 실행하는 식으로 래핑합니다.
  2. 이에 비해 일반 메소드인 3번 주석부분에서는 미리 작성했던 methodSuper의 self를 this로 만들어서 Super로 methodSuper를 던져주는 식으로 래핑합니다.

이를 통해 메소드와 정적 메소드에서 ES6와 동일한 개념으로 Super를 사용할 수 있게 됩니다.
여기까지의 코드를 통해 다음의 예제가 성립합니다.

var Parent = Class({
  constructor:function(a){
    this.a = a;
  },
  'static testA':function(){
    return 1;
  },
  testB:function(){
    return 1;
  }
});
var Child = Class(Parent, {
  constructor:function(a, b){
    Super(a);
    this.b = b;
  }
  'static testA':function(){
    return 2 + Super.testA();
  },
  testB:function(){
    return 2 + Super.test();
  }
});

var test = new Child(1, 2);

console.log(Child.testA()); //3
console.log(test.testB()); //3

정적메소드의 Super체인과 일반 메소드의 Super체인이 부드럽게 이어지는걸 확인할 수 있습니다.

결론

ES6클래스 구문에 대해 보다 깊이 이해하고 언어차원에서 무엇을 제공하는지 확인해봤습니다. 이를 바탕으로 ES3.1에서도 동일한 서비스를 제공하는 래퍼를 만들 수 있습니다. 여기까지의 전체 코드는 다음과 같습니다. 위의 코드에서는 익명함수호출이 잦으므로 간단히 함수를 생성해주는 함수를 바깥으로 빼내서 정리합니다.

var Class = (function(){
	var protoInit = {};
	var SC = (function(){ //생성자용 super처리
		var ext, self, sc = function(){ext.apply(self, arguments);}, root = function(){};
		return function(s, e){window.Super = e ? (self = s, ext = e, sc) : root;};
	})();
	//methodSuper용 메소드래퍼
	var mkSM = function(sm, f){return function(){return f.apply(sm['^self^'], arguments);};};
	//정적메소드 생성기
	var mkS = function(c, ext, f){
		return function(){
			var r, prev = window.Super;
			window.Super = parent, r = f.apply(c, arguments), window.Super = prev;
			return r;
		};
	};
	//일반메소드 생성기
	var mkM = function(f, sm){
		return function(){
			var r, prev = window.Super;
			return sm['^self^'] = this, window.Super = sm, r = f.apply(this, arguments), window.Super = prev, r;
		};
	};
	return function(){
		var a = arguments, parent = typeof a[0] == 'function' ? a[0] : null, prop = a[ext ? 1 :0];
		var cstrt = prop && prop ['constructor'], cls, methodSuper, fn, k, proto;
		cls = function(v){
			var prev;
			if(v === protoInit) return;
			if(!(this instanceof cls)) throw 'only new';
			if(cstrt){
				prev = window.Super, SC(this, parent);
				cstrt.apply(this, arguments);
				window.Super = prev;
			}
		};
		methodSuper = {'^self^':null};
		if(parent){
			proto = parent.prototype;
			for(k in proto) if(proto.hasOwnProperty(k)) methodSuper[k] = mkSM(methodSuper, proto[k]);
			cls.prototype = new parent(protoInit);
		}
		fn = cls.prototype;
		if(prop) for(k in prop) if(k != 'constructor' && prop.hasOwnProperty(k)){
			if(k.substr(0, 7) == 'static ') cls[k.substr(7)] = mkS(cls, parent, prop[k]);
			else fn[k] = mkM(prop[k], methodSuper);
		}
		return c;	
	};
})();

하지만 아직도 클래스 내의 제네레이터를 처리하지 않았습니다. 클래스의 추가사항과 제네레이터 처리는 난이도가 더 높아지므로 생략합니다 ^^;(혹시 요청이 있으시면 나중에 번외편으로 다뤄보죠)

제네레이터를 포함하여 완전히 동작하는 코드의 예제는 여기에 있습니다.

%d 블로거가 이것을 좋아합니다: