Javascript

코어 자바스크립트 - 클래스

이 챕터를 읽고 난 후 다음의 질문에 답할 수 있어야 한다.

  • 클래스와 인스턴스란
  • 스태틱 및 인스턴스 멤버 in JS
  • 상위 클래스의 인스턴스를 부여함으로써 상속을 할 때 발생할 수 있는 문제는
  • ES6 이전의 상속
  • ES6 클래스 상속

 

클래스

클래스는 계급, 집단, 집합 등으로 번역된다. 객체지향 프로그래밍에서의 클래스도 동일한 의미를 갖고 있다. 클래스는 상대적이고 추상적인 개념이다. 음식이라는 카테고리가 있다면, 그 하위 분류로는 중식, 양식, 한식 등이 있을 것이다. 여기서 음식은 상위 클래스이고 중식, 양식, 한식은 각각의 서브 클래스이다. 더 구체적으로 짜장면, 짬뽕 등과 같은 각각의 개체는 인스턴스라고 할 수 있다. 인스턴스란 특정 클래스의 속성을 지니는 구체적인 예시 또는 개체를 의미한다. 범주와 개체에 있어서 범주는 클래스고 개체는 인스턴스다. 어떤 개체는 또 다른 범주가 되어 더 구체적인 인스턴스를 포함할 수 있다.

 

상속

상속이란 인스턴스가 클래스의 속성을 참조(복사)하는 것을 말한다. 자바스크립트는 프로토타입 기반 언어로서 '상속'이란 개념이 존재하지 않는다. 다만 프로토타입 체이닝을 통해 클래스의 상속을 구현할 수 있다.

 

인스턴스의 상속 여부에 따라 스태틱 멤버와 인스턴스 멤버로 구분할 수 있다. 다만 자바스크립트에서는 인스턴스도 메서드를 직접 정의할 수 있기 때문에 용어의 혼란을 방지하고자 프로토타입 멤버라고 표현한다.

 

예를 들어 Array의 인스턴스는 Array의 프로토타입을 참조하기 때문에 배열 메서드를 이용할 수 있다. 그러나 prototype 외부에 정의되어 있는 isArray, from 등은 이용할 수 없다.

 

이처럼 인스턴스에 의해 참조되는 속성이나 메서드를 프로토타입 프로퍼티 또는 프로토타입 메서드라고 한다. 반면에 인스턴스에 의해 참조되지 않는 속성이나 메서드는 스태틱 프로퍼티, 스태틱 메서드라 한다.

// 생성자
const Rectangle = function(width, height){
   this.width = width;
   this.height = height;
}

// 프로토타입 메서드
Rectangle.prototype.getArea = function(){
  return this.width * this.height;
}

// 스태틱 메서드
Rectangle.isRectangle = function (instance) {
  return instance instanceof Rectangle && instance.width > 0 && instance.height > 0;
}

const rect1 = new Rectangle(3,4);

console.log(rect1.getArea()); 
// 12
console.log(rect1.isRectangle(rect1)); 
// TypeError: rect1.isRectangle is not a function
console.log(Rectangle.isRectangle(rect1)); 
// true 

 

상속에서 발생할 수 있는 문제

const Grade = function(){
  let args = Array.prototype.slice.call(arguments);
  for (let i = 0; i < args.length; i++){
    this[i] = args[i]
  }
  this.length = args.length;
};

const g1 = new Grade(100, 80);

console.log(g1); // Grade {0: 100, 1: 80, length: 2}

g1.push(90); // Uncaught TypeError: g1.push is not a function

Grade.prototype = [];

const g2 = new Grade(100, 80);

console.log(g2) // Array { '0': 100, '1': 80, length: 2 }

g2.push(90);

console.log(g2) //Array { '0': 100, '1': 80, '2': 90, length: 3 }

프로토타입 챕터에서 다룬 내용이다. 클래스의 프로토타입이 배열의 인스턴스를 참조함으로써 클래스의 인스턴스가 배열 메서드를 이용할 수 있다. 이를 다중 프로토타입 체이닝이라고 한다.

 

하지만 위 방식에는 몇 가지 문제가 있다. 기본적으로 배열의 length 속성은 configurable 속성이 false라서 삭제가 불가능하다. 하지만 예제와 같이ㅡ 배열 메서드를 상속받았지만 여전히 일반 객체의 성질을 갖고 있는 인스턴스의 경우에는 삭제가 가능하다.

Grade.prototype = [];

const g2 = new Grade(100, 80);

console.log(g2) // Array { '0': 100, '1': 80, length: 2 }

g2.push(90);

console.log(g2) // Array { '0': 100, '1': 80, '2': 90, length: 3 }

delete g2.length;

console.log(g2); // Array { '0': 100, '1': 80, '2': 90 }         

g2.push(70);

console.log(g2); // Array { '0': 70, '1': 80, '2': 90, length: 1 }

length를 삭제하고 나니, 원하는 대로 데이터를 조작할 수 없다...

 

직접 정의한 두 클래스 사이에서의 상속관계를 통해 좀 더 알아보자.

const Rectangle = function(width, height){
   this.width = width;
   this.height = height;
}

Rectangle.prototype.getArea = function(){
  return this.width * this.height;
}

const rect = new Rectangle(3,4);

console.log(rect.getArea()); // 12

const Square = function(width){
  Rectangle.call(this, width, width);
}

Square.prototype = new Rectangle();

Square.prototype.getArea = function(){
  return this.width * this.height;
}

const sq = new Square(5);

console.log(sq.getArea()); // 25

console.dir(sq);

우선 예상대로 잘 작동하는 것으로 보인다.

그러나 여기에는 두 가지 문제가 있다.

  1. 프로토타입에 width, height 속성이 존재한다. 만약 누군가가 해당 속성에 값을 부여하고, 인스턴스의 width, height을 지워버린다면 프로토타입 체인에 의해 엉뚱한 값을 참조하게 되는 셈이다.
  2. 프로토타입 속성의 constructor가 여전히 Rectangle을 참조한다. 이는 하위 클래스 생성에 영향을 미치는 등 구조적인 불안정을 야기할 수 있다.

이런 문제들을 해결하기 위해선 프로토타입의 구체적인 데이터를 삭제하고 constructor의 참조를 변경해야 한다.

 

ES6 이전의 상속

위에서 언급했던 문제를 해결하기 위해서 속성을 지우고 constructor의 참조를 변경했다.

그리고 freeze를 써서 더는 새로운 속성을 추가할 수 없도록 했다.

delete Square.prototype.width;
delete Square.prototype.height;

Square.prototype.constructor = Square;

Object.freeze(Square.prototype);

위 과정을 포함시켜 클래스를 상속하는 함수를 만들어 보자.

const extendClass1 = function(SuperClass, SubClass, subMethods){
  SubClass.prototype = new SuperClass();
    SubClass.prototype.constructor = SubClass; // 이 부분이 원하는 대로 작동하지 않는다.
    // 아래 결과에서 constructor가 여전히 'Rectangle'을 가리키고 있다.
  for (let prop in SubClass.prototype){
    if (SubClass.prototype.hasOwnProperty(prop)){
      delete SubClass.prototype[prop];
    }
  }

  if (subMethods) {
    for (let method in subMethods){
      SubClass.prototype[method] = subMethods[method];
    }
  }

  Object.freeze(SubClass.prototype);

  return SubClass;
};

const Square = extendClass1(Rectangle, function (width){
  Rectangle.call(this, width, width);
})

const sq = new Square(5);

console.log(sq.getArea()); // 25

console.dir(sq);

결과:

constructor가 여전히 Rectangle을 가리키고 있다.

 

더글라스 크락포드가 제시한 좀 더 쉬운 방법이 있다. 브릿지를 거치는 방법이다.

const extendClass2 = (function(){
  const Bridge = function (){};
  return function(SuperClass, SubClass, subMethods){
    Bridge.prototype = SuperClass.prototype;
    SubClass.prototype = new Bridge();
    SubClass.prototype.constructor = SubClass; // 이 부분이 원하는 대로 작동하지 않는다.
        // 아래 결과에서 constructor가 'Rectangle'을 가리키고 있다.
    if (subMethods){
      for (var method in subMethods){
        console.log(method);
        SubClass.prototype[method] = subMethods[method];
      }
    }
    Object.freeze(SubClass.prototype);
    return SubClass;
  }
})();

const Square = extendClass2(Rectangle, function (width){
  Rectangle.call(this, width, width);
})

const sq = new Square(5);

console.log(sq.getArea()); // 25

console.dir(sq);

결과:

여전히 constructor가 여전히 Rectangle을 가리키고 있다.

위 두 방법 모두 constructor가 원하는 대로 변경되지 않았다. 책에서는 기대한 바와 같이, constructor가 Square를 가리키지 않는다.  내가 아직 이해가 부족하여 어떤 실수를 했을지도 모른다.

 

ES5에서 도입된 Object.create를 이용할 수도 있다. 이 방법은 실습에서 원하는 결과를 보여주었다.

const Rectangle = function(width, height){
   this.width = width;
   this.height = height;
}

Rectangle.prototype.getArea = function(){
  return this.width * this.height;
}

const Square = function(width){
  Rectangle.call(this, width, width);
}

Square.prototype = Object.create(Rectangle.prototype);
Square.prototype.constructor = Square;
Object.freeze(Square.prototype);

const sq = new Square(5);

console.log(sq.getArea()); // 25

console.dir(sq);

결과:

원하는 결과가 나왔다

constructor가 Square를 가리키고 있다.

 

ES6 클래스 문법

ES6에서 클래스 문법이 본격적으로 도입되었다.

ES5의 문법과 비교해보면서 얼마나 쉽게 상속이 가능한지 확인해보자.

const ES5 = function(name){
  this.name = name;
};

ES5.staticMethod = function(){
  return this.name + ' staticMethod';
}

ES5.prototype.method = function(){
  return this.name + ' method';
}

const es5Instance = new ES5('es5');

console.log(ES5.staticMethod()); // ES5 staticMethod
console.log(es5Instance.method()); // es5 method

const ES6 = class{
  constructor (name){
    this.name = name;
  }
  static staticMethod(){
    return this.name + ' staticMethod';
  }
  method(){
    return this.name + ' method';
  }
}

const es6Instance = new ES6('es6');

console.log(ES6.staticMethod()); // ES6 staticMethod
console.log(es6Instance.method()); // es6 method

아까 그 예제로 돌아가서,

const Rectangle = class{
  constructor (width, height){
    this.width = width;
    this.height = height;
  }
  getArea(){
    return this.width * this.height;
  }
};

const Square = class extends Rectangle{
  constructor(width){
    super(width, width);
  }
  getArea(){
    console.log('size is :', super.getArea());
  }
};

const sq = new Square(5);

sq.getArea(); // size is: 25

console.dir(sq);

결과:

매우 훌륭하다..

이전에 했던 방식과 다르게 매우 편리하고 간결하게 클래스 상속을 구현할 수 있다.

클래스 문법에는 몇 가지 규칙이 있다.

  • class 명령어 뒤에 중괄호 { } 내부가 클래스의 본문 영역이다.
  • 클래스 본문에서는 function 키워드를 생략해도 메서드로 인식한다.
  • 메서드와 다음 메서드 사이에는 콤마로 구분하지 않는다.
  • static이란 키워드는 해당 메서드가 스태틱 메서드임을 나타낸다.
  • method는 해당 메서드가 프로토타입 메서드임을 나타낸다.
  • 'class extends SuperClass'를 추가하여 상속을 구현할 수 있다.
  • super라는 키워드를 함수처럼 사용하면 superClass의 constructor를 실행한다.
  • super.method()와 같이 super를 객체처럼 사용할 수 있다.
  • sub에서 호출한 super의 this는 super가 아닌 sub를 가리킨다.