Class

김상연·2022년 12월 9일

JavaScript

목록 보기
13/19

내가 드디어 클래스를 다루는구나!

내가 지금까지 크고 작은 JS프로젝트만 10개도 넘게 만들었다만, 객체지향이니 클래스니 이런거 한번도 제대로 파보지 않았다. 왜냐? 없어도 만들고 싶은거 만들 수 있었으니까.

물론 내 기준에 조금 복잡한 웹 어플리케이션을 만들어보면서, 디자인 패턴이 왜 필요한지 또는 그게 뭔지 감이 오긴 했다. 객체지향이나 절차지향, 또 뭔지도 잘 모르지만 함수형 프로그래밍 같은 것들을 공부 할 필요성도.

뭔가를 만들어낼 수 있는지 뿐만 아니라, 만드는 방식 자체도 고민 해 볼 때가 된 것이다.

객체를 찍어내는 틀로 new 연산자를 활용한 constuctor Function이 존재하지만, 클래스는 객체지향 프로그래밍에 유용한, 더 진화 된 컨셉이다.

1. Class basic syntax

class MyClass {
  constructor() { ... }
  method1() { ... }
  method2() { ... }
  method3() { ... }
  ...
}

이것은 사실 생성자 함수를 만들어낸다. 그 함수의 코드는 classconstructor로 만들어진다.

그 밖에 다른 메소드들은 이렇게 만들어진 생성자 함수의 프로토타입이 된다.

그렇다면 차이점은 없는걸까? 단지 'syntatic sugar'에 불과한걸까? 아니다.

차이점

  • 클래스는 클래스만의 특별한 라벨이 붙는다. [[IsClassConstructor]] : true라는 속성이다. 이 속성 덕분에 new 연산자 없이 클래스를 선언하면 오류가 난다.
  • 클래스의 메소드들은 descriptor enumerable flag가 전부 false이다. 그래서 for ..in 루프에 잡히지 않는다.
  • constructor 속성을 정의하는 코드블럭은 언제나 use strict 모드이다.

프로토타입이 아니라 그냥 클래스 객체 자체의 속성을 만들수도 있다. 이것을 class fields라 부른다.

class MyClass {
	name = "kim"
	...
}

console.log(new MyClass().name)		//	kim

한편, 일전에 다뤘듯이 어떤 메소드가 콜백함수로서 사용되면 this를 잃어버린다. 클래스에서도 마찬가지다.

ex)

let user = {
  firstName: "Kim",
  sayHi() {
    console.log(`Hello, ${this.firstName}!`);
  }
};

class Button {
  constructor(value) {
    this.value = value;
  }

  click() {
    console.log(this.value);
  }
}

let button = new Button("hello");
setTimeout(button.click, 1000); 	// undefined

user.sayHi()	//	Hello, Kim!
setTimeout(user.sayHi, 1000); 	// Hello, undefined!

이런 경우에 Class fieldarrow function이 좋은 해결책이 될 수 있다.

class Button {
  constructor(value) {
    this.value = value;
  }

  click = () => {
    console.log(this.value);
  }
}

let button = new Button("hello");
setTimeout(button.click, 1000); 	// hello

2. Class inheritance

클래스 상속은 특정 클래스를 확장하는 방법이다. 예를들어 동물 클래스를 사자 클래스로 구체화 하는 경우다.

class Child extends Parent {...}

부모 클래스의 프로토타입이 자식 클래스의 프로토타입의 프로토타입이 된다.

extends 뒤에는 표현식이 올 수도 있다. 이를 통해 조건에 따라 다양한 클래스로부터 상속 받을 수도 있다.

ex)
function makeClass(name) {
	return class {
    	sayHi() { console.log('Hi, ' + name) }
    }
}

class Person extends makeClass('Kim') {
	...
}

new Person.sayHi();		//	Hi, Kim

3. Overriding a method

클로져에서 변수를 찾아 올라가듯, 클래스도 속성에 접근하면 현재 위치(lexical environment)에서 상위 프로토타입으로 옮겨가며 해당 이름의 속성을 찾는다. 상위 프로토타입에 있는 메소드를 더 먼저 접근하는 위치에 정의해버리면 어떻게 될까? 당연히 먼저 접근할 수 있는 메소드를 실행하고 만다.

그런데 실생활에선 상속 된 부모 클래스의 메소드들을 약간 수정, 추가해서 사용하는 것이 보통이다.(그만큼 연관 된 것들을 상속하는 것이 당연지사) 어떻게 해야 완전히 대체 해 버리는 것이 아니라 수정, 확장 등 효과적으로 이용 할 수 있을까?

답은 super 키워드에 있다.

super.method()는 같은 이름을 가졌더라도 부모 메소드를 실행한다.

ex)
class Animal {

  constructor(name) {
    this.speed = 0;
    this.name = name;
  }

  run(speed) {
    this.speed = speed;
    console.log(`${this.name} runs with speed ${this.speed}.`);
  }

  stop() {
    this.speed = 0;
    console.log(`${this.name} stands still.`);
  }

}

class Rabbit extends Animal {
  hide() {
    console.log(`${this.name} hides!`);
  }

  stop() {
    super.stop(); // call parent stop
    this.hide(); // and then hide
  }
}

let rabbit = new Rabbit("White Rabbit");

rabbit.run(5); // White Rabbit runs with speed 5.
rabbit.stop(); // White Rabbit stands still. White Rabbit hides!

단, 화살표 함수에는 super 키워드를 쓸 수 없다.

4. Overriding constructor

super 키워드는 부모 클래스의 constructor도 실행시킬 수 있다.super(...argv)의 형식이다. 만약 자식 클래스가 constructor 속성 없이 선언됐다면 자기가 알아서 다음과 같이 부모 클래스의 constructor를 실행시킨다.

class Rabbit extends Animal {
  constructor(...args) {
    super(...args);
  }
  ...
}

반대로 자식 클래스가 constructor를 따로 정의 할 때는 주의 해야한다. 일단 잘못된 예시와 올바른 예시를 보자.

class Animal {
  constructor(name) {
    this.speed = 0;
    this.name = name;
  }
  
  // ...
}

class Rabbit extends Animal {
  //	잘못 된 예시
  constructor(name, earLength) {
    this.speed = 0;
    this.name = name;
    this.earLength = earLength;
  }
  //	올바른 예시
  constructor(name, earLength) {
    super(name);
    this.earLength = earLenght;
  }
  
  // ...
}

// 	잘못된 예시의 경우 consturctor가 실행되면 this가 undefined라는 에러가 난다.
let rabbit = new Rabbit("White Rabbit", 10); // Error: this is not defined.

왜일까?

new 연산자와 함께 함수를 실행하면 내부적으로 다음의 두 가지가 실행된다고 배웠다. 꼭 생성자 함수가 아니라도 그렇다.

  1. this를 선언하고 빈 객체를 할당한다.
  2. 이것 저것 담긴 this를 마지막에 return한다.

클래스도 마찬가지로 new로 실행되는(반드시 그래야 한다.) 함수이고, constructor를 실행하며 위 2단계의 과정이 보이지 않게 일어나는 것도 마찬가지다.

이때, 다른 보통의 함수들과 달리 어떤 클래스로부터 상속 받는 constructorderived constructor라고 불리며, 특별한 라벨(숨겨진 속성)이 붙는다. [[ConstructorKind]]:"derived" 이것은 new 연산자를 조금 다르게 동작하도록 만들어준다.

그것은 this를 생성하고 빈 객체를 할당하는 일을 해당 constructor가 아니라 부모 클래스의 constructor가 하도록 만든다. 그래서 위의 예시에서 보듯 super(...argv)를 실행하여 부모 클래스의 constructor를 실행시켜준 뒤에만 자식 클래스의 constructor에서도 this에 접근 할 수 있는 것이다.

5. Overriding class fields

class Animal {
  name = 'animal';

  constructor() {
    console.log(this.name);
  }
}

class Rabbit extends Animal {
  name = 'rabbit';
}

new Animal(); // animal
new Rabbit(); // animal

위 예시에서 마지막 라인은 왜 'rabbit'이 출력되지 않을까? class fields이기 때문에 조금 다르다. 만약 overriden method였다면 기대한 대로 작동했을 것이다.

class fields에는 언제 initialized(각 속성들에 값을 할당) 되는지가 이 현상을 이해하는 핵심이다.

  1. 부모 class의 경우 : constructor 실행 직전
  2. derivced class의 경우 : super(...argvs) 가 실행된 직후

이에 따라 위 예시의 동작을 차근차근 밟아가 보면, new Rabbit()으로 클래스를 객체를 생성하여 부모 클래스의 constructor가 실행되는 때에도 initialized 된 this.name부모 클래스의 class fields 뿐임을 알 수 있다.

6. static methods and properties

  • static methods

어떤 클래스를 통해 만들어낸 개별 객체 단위의 메소드가 아니라, 그 클래스로 만들어낸 모든 객체들에 대한 메소드가 필요할 때 static 키워드를 사용 할 수 있다.

예시)

class Article {
  constructor(title, date) {
    this.title = title;
    this.date = date;
  }

  static compare(articleA, articleB) {
    return articleA.date - articleB.date;
  }
}

// usage
let articles = [
  new Article("HTML", new Date(2019, 1, 1)),
  new Article("CSS", new Date(2019, 0, 1)),
  new Article("JavaScript", new Date(2019, 11, 1))
];

articles.sort(Article.compare);

console.log( articles[0].title ); // CSS

static method 내부의 this는 클래스 자체가 된다.

클래스로 생성된 개별 인스턴스에서는 static method를 사용 할 수 없다.

  • static properties

걍 똑같다. 클래스로 만든 객체 말고 클래스 자체에서 접근 할 수 있는 속성이다.

이놈들도 extends 키워드를 통해 부모로부터 자식으로 상속 된다.

class fields와의 차이점은 무엇일까?

class fields는 생성된 인스턴스가 사용한다. static 요소들은 class 차체에서 사용하구.

7. private, protected properties and methods

객체지향 프로그래밍을 위해 클래스를 배울 때가 되면 알아둬야하는 사실이 있다. 객체의 속성과 메소드는 두가지 타입이 있다는 것이다.

  • public : 어디에서든 접근 가능하다. 흔히 쓰는 것.
  • private : 현재 클래스 내부에서만 접근 가능하다.

비공식적이지만 매우 유용한 추가분도 있다.

  • protect : 현재 클래스 뿐 아니라 상속 받는 자식 클래스들도 사용 할 수 있다.

참고로, internal interfave 변수의 이름은 underbar('_')로 시작하는 관습이 있다. 갖다 쓰긴 해도 바꾸지 말란 소리도 된다. private의 경우 '#'을 붙여준다.

커피머신을 예로 들자면, 커피를 마시려는 사람은 전기 코드나 꼽고 두세가지 버튼이나 눌러주면 된다. 그 속을 자세히 들여다보면 물을 적당한 온도로 끓이는 원리도 있고 여러 기능이 유기적으로 연결되는 인터페이스도 존재할테지만, 사용자는 그런건 몰라도 된다.

8. no static inheritance in built-ins

클래스 상속이 일어나면 static이든 아니든 속성이나 메소드가 상속되기 마련이다. 그러나 ObjectArray 등 built-ins는 예외다.

예시)
Array.key()		//	function이 아니라는 오류가 난다. Array의 부모격인 Object에 
				//	static key 메소드가 있음에도 불구하고.

9. mixin

mixin이란 상속 받지 않은 다른 클래스도 사용할 수 있는 메소드를 가진 클래스다. 다시 말하면 단독으로 쓰이기보단 다른 클래스의 메소드에 decorator 역할을 하는 메소드를 가진다는 것이다. 그게 아니면 뭐하러 존재하겠나?

mixin을 만드는 방법은 간단하다. 프로토타입에 추가 해 주면 된다. extends 키워드가 프로토타입을 차지 해 버린다면, 그냥 덧붙일 mixin을 프로토타입에 추가 해 주면 된다.

예시)
let sayMixin = {
  say(phrase) {
    console.log(phrase);
  }
};

let sayHiMixin = {
  __proto__: sayMixin, 	//	or use Object.setPrototypeOf

  sayHi() {
    super.say(`Hello ${this.name}`);
  },
  sayBye() {
    super.say(`Bye ${this.name}`);
  }
};

class User {
  constructor(name) {
    this.name = name;
  }
}

Object.assign(User.prototype, sayHiMixin);	// 프로토타입에 mixin 얹기

new User("Dude").sayHi(); // Hello Dude!
profile
리눅스와 컴퓨터 프로그래밍

0개의 댓글