체스 엔진을 만드는 예를 들어보겠습니다.
먼저 가장 기본적인 타입부터 정의하여야 합니다.
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 메서드로 인스턴스를 반환하게 하면 됩니다.
'개발 언어 > Typescript' 카테고리의 다른 글
타입스크립트에서 함수란 (0) | 2022.01.24 |
---|---|
타입스크립트에서 타입의 모든 것 (0) | 2022.01.09 |
타입스크립트란 (0) | 2022.01.07 |