Class pt.1

심현인·2021년 7월 13일
0
post-custom-banner

JS는 프로토타입 기반 언어라서 '상속'이는 개념이 없었다. 이는 다른 클래스 기반언어를 다루다가 온 사람들에게 당혹스러웠는데, 이 징징이들을 위해 클래스와 비슷하게 동작하게끔 여러 기법들이 탄생을 했고, 그러다 2016년 ES6에서 클래스 문법이 탄생하게 된다. 다만 여기서도 프로토타입을 활용을 하기 때문에 프로토타입을 공부하는 건 전혀 뻘 짓이 아니다..!

클래스와 인스턴스의 개념 이해

클래스는.. 뭐라 설명하기가 어렵다..
인스턴스는 클래스에 속성을 지니는 실존하는 개체
현실에서 클래스는 공통 요소를 지니는 집단을 분류하기 위한 개념이라는 의미를 갖고, 이 클래스는 인스턴스들의 공통점을 발견해서 정의를 하지만 프로그래밍적 측면에서는 클래스가 먼저 정의되어야만 인스턴스들을 생성할 수 있다.

자바스크립트의 클래스

다시 한 번 얘기하지만 JS는 프로토타입 언어 기반이라 클래스의 개념이 존재하지 않는다. 그러나 프로토타입을 일반적인 의미에서 클래스 관점에서 접근 해보면 비슷한 요소들이 존재한다.

let arr = new Array(1,2,3)

생성자 함수를 new 연산자와 함께 호출하면 인트턴스가 생기는데, 이때 이 Array를 일종의 클래스라고 할 수있고, Array의 prototype객체 내부 요소들이 상속된다고 볼 수 있다. 엄밀히 말하면 프로토타입 체이닝에의한 참조이지만 결과적으론 동일하게 움직이므로 같은 개념이라고 봐도 된다. 그러나 Array 내부 프로퍼티중에 prototype을 제외한 나머지는 인스턴스에 상속되지 않는다.

인스턴스에 상속여부에 따라 스태틱 멤버와 인스턴스 멤버로 나뉜다. 이것은 클래스 입장에서 사용대상에 따라 구분한 것이다. 그런데 여느 클래스 기반 언어와는 달리 JS에서는 인스턴스에서도 직접 메소드를 정의할 수 있다. 따라서 '인스턴스 메소드'라는 명칭은 프로토타입에 정의한 메소드를 정의하는 것인지 인스턴스에 정의한 메소들을 지칭하는 것에 대해 혼란을 야기할 수 있다. 따라서 이 명칭 대신에 JS의 특징을 살려 프로토타입 메소드라고 부르자.

/******생성자 *******/
let 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
  );
};
/****** 출력 *******/
let rect1 = new Rectangle(3, 4);
console.log(rect1.getArea()); // 12
console.log(rect1.isRectangle(rect1)); // Error
console.log(Rectangle.isRectangle(rect1)); // true

위 예제는 전형적인 생성자 함수와 인스턴시다.
Rectangle 함수를 new 키워드를 통해 호출하여 생성된 인스턴스를 rect1에 할당했다. 이 인스턴스는 width, height 프로퍼티에 3,4의 값이 할당돼있다.

프로토타입 객체에 할당한 메소드는 인스턴스가 마치 자신의 것처럼 호출 할 수 있으니 출력 주석이 밑에 있는getArea()는 __proto__로 연결되어 있고 따라서 3 * 4 = 12가 나온다. 이처럼 인스턴스에서 바로 호출 할 수 있는 메소드를 프로토타입 메소드라고 한다.

그 밑에있는 코드는 rect1 인스턴스에서 isRectangle이라는 메소드에 접근하려고 하는데 __proto__로 연결이 되어있지도 않고 그 상위 prototype(Object.prototype)에도 해당 메소드가 없으니 undefined을 실행하라는 것인데 undefined는 함수가 아니므로 에러가 난다. 이렇게 인스턴스에서 직접 접근할 수 없는 메소드를 스태틱 메소드라고 하한다. 스태틱 메소드는 마지막 줄처럼 호출해야한다.
프로그래밍 언어에서의 클래스는 사용하기에 따라 추상적일수도 있고 구체적인 개체가 될 수도 있다.
즉 구체적인 인스턴스가 사용할 메소드를 정의한 '틀'의 역할을 담당하는 목적을 가질때의 클래스는 추상적인 개념이지만, 클래스 자체를 this로 해서 직접 접근해야하는 스태틱 메소드를 호출할때의 클래스는 그 자체가 하나의 개체로서 취급된다.

클래스 상속

클래스 상속은 객체지향에서 가장 중요한 요소 중 하나이다. 앞서 배웠던 prototype-chain으로 클래스를 흉내내보자.

let 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;
};
Grade.prototype = [];
let g = new Grade(100, 80);


ES5까지의 JS에는 클래스가 없다. 즉 프로토타입으로 기반으로 클래스 상속을 구현해야만 했는데 이 것은 결국 프로토타입을 체이닝을 잘 연결한 것으로 이해하면 된다.
다만 기본적으로 그렇다는거지, 세부적으로 완벽하게 superclass와 subclass의 구현이 이뤄진건 아니다.
위의 토드는 몇가지 문제가 있다. length프로퍼티가 삭제가능하다는 점과, Grade.prototype에 빈 배열을 참조시켰다는 점이다.

문제점중 length에 대해 살펴보자..!

g.push(90)
console.log(g) // Grade [100, 80, 90] length:3

delete g.length;
g.push(70)
console.log(g) // Grade [70, 80, 90] length:1

위는 원하는 대로 결과가 잘 나왔는데, length프로퍼티를 삭제를 하고 다시 push를 하니 push한 값이 0번째 인덱스로 들어갔고, length가 1이 되어버렸습니다. 내장객체인 배열 인스턴스의 length프로퍼티는 삭제가능한(configurable) 속성이 false라 삭제가 불가능 하지만, Grade 클래스의 인스턴스는 배열 메소드를 상속하지만 기본적으로는 일반 객체의 성질을 그대로 지니므로 삭제가 가능해서 문제가 된다.

그런데 push를 했을 때 0번째 인덱스에 70이 들어가고 length가 다시 1이 될 수 있었던 이유는 무엇일까?
바로 g.__proto__, 즉 Grade.prototype이 빈 배열을 참조하고 있기 때문이다. push 명령에 의해 JS엔진이 g.length를 읽으려 하는데 g.length가 없으므로 프로토타입 체이닝을 타고 g.__proto__.length를 읽어온 것이다. 빈 배열의 length가 0이므로 여기에 값을 할당하고 length는 1만큼 증가시키라는 명령이 문제없디 동작할 수 있었던 것이다.

그렇다면 Grade.prototype에 요소를 포함하는 배열을 매칭시켰다면 어땠을까??

Grade.prototype = ['a', 'b', 'c', 'd'];
let g = new Grade(100,80)

g.push(90)
console.log(g) // Grade(3) [100, 80, 90] length:3

delete g.length;
g.push(70)
console.log(g) // Grade(5) [100, 80, 90, empty, 70] length: 5

Grade.prototype에 길이가 4인 배열을 할당을 해봤다. 90을 추가했을 때는 위와 마찬가지로 정상적으로 출력됐으나, length 프로퍼티를 지우고, 70을 추가를 한 후 출력을 하니 좀 다르게 동작하는 것을 알 수가 있다. g.length가 없으니까 g.__proto__.length를 찾고, 값이 4이므로 인덱스4에 70을 넣고 다시 g.length에 5를 부여하는 순서로 동작을 한것이다.

이처럼 클래스에 있는 값이 인스턴스의 동작에 영향을 주어선 안된다.
이런 영향을 줄 수 있다는 자체가 이미 클래스의 추상성을 해치는 것이다. 인스턴스의 관계에서는 구체적인 데이터를 지니지 않고 오직 인스턴스가 사용할 메소드만을 지니는 추상적인 '틀'로서만 작용하게끔 작성하지 않는다면 언제 어디에서 예기치 않은 오류가 발생할 가능성이 있다는 것이다.

이번에는 사용자가 정으한 두 클래스 사이에서 상속관계를 구현해봤다.
정사각형과 직사각형 클래스를 만들고, 각 클래스에는 넓이를 구하는 getArea라는 메소드를 추가했다.

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

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

let rect = new Rectangle(3, 4);
console.log(rect.getArea()); // 12

let Square = function (width) {
  this.width = width;
};
Square.prototype.getArea = function () {
  return this.width * this.width;
};
let sq = new Square(5);
console.log(sq.getArea()); // 25

여기서 Rectangle과 Square 클래스의 공통요소인 width 프로퍼티가 있다.
만약 Square에서 width를 쓰지 않고 height프로퍼티에 width의 값을 부여하는 형태가 된다면 getArea도 동일하게 고칠 수 있을 것이다.

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

원래 정사각형은 직사각형에 네번의 길이가 모두 같다는 구체적인 조건이 하나 추가 된것이다.
소스상으로도 Square를 Rectangle의 하위 클래스로 삼을 수 있을 것 같다. getArea는 동일한 동작을 하므로 상위 클래스에서만 정의하고 하위클래스에서는 해당 메소드를 상속하면서 height대신 width를 넣어주면 된다.

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

Square.prototype = new Rectangle()

Square의 생성자 함수 내부에서 Rectangle의 생성자 함수를 함수로써 호출했다. 이때 인자 height 자리에 width를 전달했다. 그리고 메소드를 상속하기 위해 Square의 프로토타입 객체에 Rectangle의 인스턴스를 부여했다. 그러면 원하는대로 동작은 할것이다.
그러나 위 코드만으로 완벽한 클래스 체계가 구축 됐다고는 볼 수 없다..!
왜냐하면 앞에서 소개했던 것처럼 클래스에 있는 값이 인스턴스에 영향을 줄 수 있는 구조이기 때문이다.

console.dir(sq)


sq의 구조를 출력해보면 첫 줄에서 Square의 인스턴스임을 표시하고 있고, width, height에 5가 잘 들어있다. __proto__는 Rectangle의 인스턴스임을 표시하고 있는데, 이어서 width, height가 모두 undefined임을 확인할 수 있다. 그 이유는 Square.prototype에 값이 존재해서이다. 만약 이후에 임의로 Square.prototype.width(|| height)에 값을 부여하고 sq.width(|| height)의 값을 지워버린다면 프로토타입 체이닝에 의해 엄한 결과가 나오게 될 것이다.
게다가 constructor가 여전히 Rectangle을 바라보고 있는 문제도 있따. sq.constructor로 접근하면 프로토타입 체이닝을 따라 sq.__proto__.__proto__즉, Rectangle.prototype에서 찾게되며, 이는 Rectangle을 가르키고 있기 때문이다.

let rect2 = new sq.constructor(2,3)
console.log(rect2) // Rectangle {width: 2, height: 3}

이처럼 하위클래스로 삼을 생성자 함수의 prototype에 상위 클래서의 인스턴스를 부여하는 것만으로 메소드 상속은 가능하지만 다양한 문제가 발생할 여지가 있어서 구조적으로 안정성이 떨어진다..!!
그 이유는 다음에 알아보자..

profile
가로
post-custom-banner

0개의 댓글