타입스크립트에서 클래스와 인터페이스

Jin·2022년 3월 1일
0

Typescript

목록 보기
4/4

체스 엔진을 만드는 예를 들어보겠습니다.

먼저 가장 기본적인 타입부터 정의하여야 합니다.

class Game {} // 체스 게임
class Piece {} // 체스 말
class Position {} // 체스 말의 좌표 집합

체스에는 여섯 가지의 말이 있습니다.

class King extends Piece {}
class Queen extends Piece {}
class Bishop extends Piece {}
class Knight extends Piece {}
class Rook extends Piece {}
class Pawn extends Piece {}

체스 말에는 색깔로 상대를 구분하고 체스판의 좌표는 x축이 왼쪽에서 오른쪽으로 A부터 H까지이고, y축이 아래에서 위로 1부터 8까지입니다.

이것을 타입으로 정의하면,

type Color = 'Black' | 'White'
type File = 'A' | 'B' | 'C' | 'D' | 'E' | 'F' | 'G' | 'H'
type Rank = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8

위와 같이 정의할 수 있습니다.

타입으로 올 수 있는 값이 많지 않을 때는 이처럼 직접 열거할 수 있습니다.

위의 타입들은 모두 같은 타입의 값들이 열거되어 있으므로 타입 안전성 또한 확보할 수 있습니다.

이제 정의한 타입을 바탕으로 클래스를 정의해보겠습니다.

class Piece {
    protected position: Position
    constructor(
        private readonly color:Color,
        file:File,
        rank:Rank
    ){
        this.position = new Position(file, rank)
    }
}
class Position {
    constructor(
        private file: File,
        private rank: Rank
    ){}
}

Piece와 Position은 위처럼 정의할 수 있을 것입니다.

생성자의 private 접근 한정자는 자동으로 매개변수를 this에 할당합니다.

따라서 여기서 file은 this.file이, rank는 this.rank가 됩니다.

Position 인스턴스 안의 코드는 이 매개변수를 읽고 쓸 수 있지만 Position 인스턴스 외부에서는 접근할 수 없습니다. 하지만 Position 인스턴스끼리는 다른 인스턴스의 private 멤버에도 접근할 수 있습니다.

protected도 private처럼 프로퍼티를 this에 할당하지만 private과 달리 인스턴스와 서브클래스의 인스턴스 모두에 접근을 허용합니다. Piece에서 position을 선언하면서 할당은 하지 않았으므로 생성자에서는 값을 할당하거나 그렇지 않다면 position의 타입을 Position | undefined로 바꾸어야 합니다.

  • public: 어디에서나 접근 가능, 디폴트 접근 수준
  • protected: 이 클래스와 서브클래스의 인스턴스에서만 접근 가능
  • private: 이 클래스의 인스턴스에서만 접근 가능

만약, 사용자가 Piece 인스턴스를 직접 생성하지 못하게 막고 대신 Queen이나 Bishop 등 Piece 클래스를 상속받은 클래스를 통해서만 인스턴스화할 수 있도록 허용하고 싶다면 abstract 키워드를 사용하면 됩니다.

abstract class Piece {
    protected position: Position
    constructor(
        private readonly color:Color,
        file:File,
        rank:Rank
    ){
        this.position = new Position(file, rank)
    }
    abstract canMoveTo(position: Position): boolean
}

이렇게 정의하면 Piece를 직접 인스턴스화하려고 시도하면 타입스크립트 (이하 TS)가 에러를 발생시킵니다.

또한, 추상 클래스를 구현할 때는 추상 메서드도 반드시 함께 구현하여야 합니다. (여기서는 canMoveTo 함수)

class Position {
    constructor(
        private file: File,
        private rank: Rank
    ){}

    distanceFrom(position: Position) {
        return {
            rank: Math.abs(position.rank - this.rank),
            file: Math.abs(position.file.charCodeAt(0) - this.file.charCodeAt(0))
        }
    }
} 

class King extends Piece {
    canMoveTo(position: Position) {
        let distance = this.position.distanceFrom(position)
        return distance.rank < 2 && distance.file < 2
    }
}

Position 클래스와 King 클래스에 메서드를 추가하고

class Game {
    private pieces = Game.makePieces()
    private static makePieces() {
        return {
            new King("White", "E", 1),
            new King("Black", "E", 8),
            new Queen("White", "D", 1),
            new Queen("Black", "D", 9),
            ...
        }
    }
}

새 게임을 만들 때 자동으로 보드와 말을 만들도록 코드를 추가합니다.

일일이 모든 메서드를 구현하지는 않았지만 TS에서 클래스가 어떻게 동작하는지 감을 잡을 수 있을 것입니다.

요약하자면 다음과 같습니다.

  • class 키워드로 클래스 선언 후 extends 키워드로 다른 클래스 상속 가능
  • 클래스는 구체 클래스와 추상 클래스로 분류. 추상 클래스는 추상 메서드와 추상 프로퍼티를 가질 수 있음
  • private, protected, public 중 한 가지 한정자를 가질 수 있고 기본값은 public
  • 메서드는 인스턴스 메서드와 정적 메서드 두 가지로 구분
  • 클래스는 인스턴스 프로퍼티도 가질 수 있고 생성자의 매개변수나 프로퍼티 초기자에도 접근 한정자 사용 가능
  • 인스턴스 프로퍼티 선언 시 readonly 사용 가능

super

자식 클래스가 부모 클래스에 정의된 메서드를 오버라이드 하면 자식 인스턴스는 super를 이용하여 부모 버전의 메서드를 호출할 수 있습니다.

자식 클래스에 생성자 함수가 있다면 super()를 호출해야 부모 클래스와 정상적으로 연결됩니다.

super로 부모 클래스의 메서드에만 접근할 수 있고 프로퍼티에는 접근할 수 없습니다.

this를 반환 타입으로

this는 값뿐만 아니라 타입으로도 사용할 수 있습니다. 클래스를 정의할 때라면 메서드의 반환 타입을 지정할 때 this 타입을 활용할 수 있습니다.

class Set {
has(value: number): boolean {

}

add(value: number): this {
    
}

}
이렇게 메서드의 반환 타입을 this로 지정하면 만약 다른 클래스가 Set을 상속한 경우에도 Set의 모든 메서드를 오버라이딩할 필요가 없어집니다.

인터페이스

타입 별칭과 인터페이스의 차이점부터 정리하고 넘어가겠습니다.

타입 별칭과 인터페이스는 문법만 다를 뿐 거의 같은 기능을 수행합니다.

type Sushi = {
    calories: number
    salty: boolean
    tasty: boolean
}

interface Sushi {
    calories: number
    salty: boolean
    tasty: boolean
}

Sushi 타입 별칭을 사용한 모든 곳에 Sushi 인터페이스를 대신 사용할 수 있습니다.

type Food = {
    calories: number
    tasty: boolean
}

type Sushi = Food & {
    salty: boolean
}

인터페이스의 특징은 다음과 같습니다.

  • 타입 별칭은 더 일반적이어서 타입 별칭의 오른편에는 타입 표현식을 포함한 모든 타입이 등장할 수 있습니다. 반면에 인스턴스의 오른편에는 반드시 형태가 나와야 합니다.
  • 인터페이스를 상속할 때 TS는 상속받는 인터페이스의 타입에 상위 인터페이스를 할당할 수 있는지를 확인합니다.
  • 이름과 범위가 같은 인터페이스가 여러 개 있다면 이들은 자동으로 합쳐집니다
interface User {
    name: string
}

interface User {
    age: number
}

const a: User = {
    name: "jimmy",
    age: 27
}

위에서 User 인터페이스를 따로 정의하였다면 결과적으로 합쳐진 상태가 되어 2개의 User 인터페이스의 멤버를 사용할 수 있습니다.

이를 '선언 합침'이라고 하며 인터페이스끼리 충돌하면 에러가 발생합니다.

타입 별칭에서는 불가능한 기능입니다.

구현

클래스를 선언할 때는 implements 키워드를 통해 인터페이스와의 구현 관계를 선언할 수 있습니다.

interface Animal {
    eat(food: string): void
    sleep(hours: number): void
}

class Human implements Animal {
    eat(food: string) {
        ...
    }

    sleep(hours: number) {
        con
    }
}

여기서 Human은 Animal이 선언하는 모든 메서드를 구현해야 하며, 필요하다면 메서드나 프로퍼티를 추가로 구현할 수 있습니다.

프로퍼티를 선언할 때에 private, protected, public, static 키워드를 사용할 수 없습니다.

인스턴스 프로퍼티는 readonly로 설정할 수 있습니다.

interface Animal {
    readonly name: string
    eat(food: string): void
    sleep(hours: number): void
}

클래스는 둘 이상의 인터페이스를 동시에 구현할 수도 있습니다.

interface Animal {
    readonly name: string
    eat(food: string): void
    sleep(hours: number): void
}

interface Act {
    talk(): void
}

class Human implements Animal, Act {
    eat(food: string) {
        ...
    }

    sleep(hours: number) {
        con
    }

    talk() {
        ...
    }
}

이 모든 기능은 완전한 타입 안정성을 제공합니다. 프로퍼티를 놓치거나 구현에 문제가 있으면 TS가 바로 에러를 발생시킵니다.

인터페이스 구현은 추상 클래스 상속과 매우 비슷합니다. 그러나 인터페이스가 더 범용으로 쓰이며 가벼운 반면, 추상 클래스는 특별한 목적과 풍부한 기능을 갖습니다.

여러 클래스에서 공유하는 구현이라면 추상 클래스를 사용하고, 가볍게 '이 클래스는 T다'라고 말하는 것이 목적이라면 인터페이스를 사용하는 것이 좋습니다.

클래스: 값과 타입

클래스는 값이 될 수도 있고 타입이 될 수도 있습니다.

class C {}
let c:C = new C;

enum E {F, G}
let e:E = E.F;

클래스뿐만 아니라 열거형 (enum)도 마찬가지입니다.

다형성

클래스와 인터페이스에도 제네릭을 사용할 수 있습니다.

제네릭 타입의 범위는 클래스나 인터페이스 전체가 되게 할 수도 있고 특정 메서드로 한정할 수도 있습니다.

class MyMap<K, V> {
    constructor(key: K, value: V) {
        ...
    }

    get(key: K): V {
        ,,,
    }

    set(key: K, value: V): void {
        ...
    }

    static of<K, V>(key: K, value: V): MyMap<K, V> {
        ...
    }
}
  • class와 함께 제네릭을 선언했으므로 클래스 전체에서 타입을 사용할 수 있습니다.
  • MyMap의 모든 인스턴스 메서드와 프로퍼티에서 K와 V를 사용할 수 있습니다.
  • constructor에는 제네릭 타입을 선언할 수 없습니다. class 선언 시 사용해야 합니다.
  • 정적 메서드는 자신만의 제네릭을 선언하여 사용해야 합니다.
  • 인터페이스에서도 제네릭을 사용할 수 있습니다.
  • 함수와 마찬가지로 제네릭에 구체 타입을 명시하거나 TS가 타입으로 추론하도록 할 수 있습니다.

final 클래스

final 키워드는 클래스나 메서드를 확장하거나 오버라이드할 수 없게 만드는 기능입니다.

TS에서는 private constructor로 final 클래스를 흉내 낼 수 있습니다.

생성자를 private으로 선언하면 new로 인스턴스를 생성하거나 클래스를 확장할 수 없게 됩니다.

만약 상속만 막고 인스턴스화는 정상적으로 하게 하려면

class Message {
    private constructor(private messages: string[]) {

    }

    static create(messages: string[]) {
        return new Message(messages);
    }
}

위와 같이 static 메서드로 인스턴스를 반환하게 하면 됩니다.

profile
배워서 공유하기

0개의 댓글