게으른개발너D

Classes and Interfaces - classes, interfaces, polymorphism 본문

개발/TypeScript

Classes and Interfaces - classes, interfaces, polymorphism

lazyhysong 2023. 4. 4. 00:28

✨ Classes ✨

 

타입스크립트는 객체지향 코드를 더 안전하고 좋게 만들도록 도와주는 기능을 제공한다.

 

타입스크립트로 객체지향 코드를 어떻게 작성하는지 살펴보자. 그리고 typescript가 많은 양의 반복되는 코드들을 쓰지 않도록 어떻게 막아주는지도 알아보자.

 

 

 

Player class를 만들자. Player 엔 몇몇 property들이 있을 것이다.

보통 JS에는 constructor 함수를 만들고 그 안에 this.firstName = firstName 또는 this.lastName = lastName 같은 코드를 넣어줄 것이다. 타입스크립트에서는 그런 코드는 안 넣어줘도 된다.

파라미터를 써주기만 하면 typescript가 알아서 constructor 함수를 만들어준다.

class Player {
  cosntructor(
    private firstName: string,
    private lastName: string,
    public nickName: string
  ) {}
}


class Player {
  constructor(firstName, lastName, nickName) {
    this.firstName = firstName;
    this.lastName = lastName;
    this.nickName = nickName;
  }
}

위와 같은 타입스크립트 코드를 작성하면 아래와같은 js코드로 컴파일된다.

 

private 코드는 typescript에서 우리를 보호해주기 위해 쓸 수 있지만 js 에선 쓰이지 않는다.

 

const hwayeon = new Player("hwayeon", "song", "화연");

hwayeon.firstName

위와같이 정의해 주고 firstName을 부르면 firstName은 private이기 때문에 타입스크립트는 에러를 띄울 것이다.

 


타입스크립트로 class를 만들때의 장점 중 하나는 (Abstract Class)추상 클래스이다.

abstract class User {
  constructor(
    private firstName: string,
    private lastName: string,
    public nickName: string
  ) {}
}

class Player extends User {}

Player에 있던 construtor를 User class로 옮기고, Player가 User를 상속하도록 하자.

 

추상클래스가 뭐냐?! 추상클래스는 다른 클래스가 상속받을 수 있는 클래스이다. (다른 곳에서 상속 받을 수만 있는 클래스)

하지만 이 클래스는 직접 새로운 인스턴스를 만들 수는 없다.

예를 들어 우리가 new User를 쓰면 이건 작동할 수 없다. 왜냐하면 Typescript가 추상클래스의 인스턴스를 만들 수 없다고 경고하기 때문이다.

 

 

 

추상클래스 안의 메소드와 추상메소드(abstrack method)에 대해 알아보자.

User 추상 class 안에 getFullName이라는 메소드를 만들어보자.

 

abstract class User {
  constructor(
    private firstName: string,
    private lastName: string,
    public nickName: string
  ) {}
  
  getFullName() {
    return `${this.firstName} ${this.lastName}`;
  }
}

Player는 User로부터 이 메소드를 상속받았으므로 이걸 그냥 쓸 수 있다.

메소드 정의 앞에 private을 붙여서 private으로 만들 수도 있다.


추상클래스 안에서는 추상 메소드를 만들 수 있다.

하지만 메소드를 구현하여서는 안되고 대신에 메소드의 call signature만 적어줘야한다.

 

예를들어 말이 안되지만 User abstract class 안에 getLastName이라는 추상 메소드가 있다고 하자

abstract class User {
  constructor(
    private firstName: string,
    private lastName: string,
    public nickName: string
  ) {}
  
  abstract getLastName(): void
  
  getFullName() {
    return `${this.firstName} ${this.lastName}`;
  }
}

이렇게 추상 메소드는 call signature로 적어준다.

그럼 타입스크립트는 User를 상속한 Player가 getLastName을 구현해야한다고 알려주고 있다.

 

그래서 추상메소드는 뭘까?!

수상메소드는 우리가 추상 클래스를 상속받는 모든 것들이 구현을 해야하는 메소드를 의미한다.

 

class Player extends User {
  getLastName() {
    console.log(this.lastName);
  }
}

 

이렇게 Player 안에서 getLastName을 어떻게든 구현해야하는데, 문제는 여기서 console.log(this.lastName)을 사용하지 못한다는 것이다.

왜냐면 우리가 lastName을 private property로 만들었기 때문이다.

 

property를 보호하는 방법은 private 말고도 더 있는데, 그게 protected이다.

만약 property가 private 필드로 되어있다면 해당 property들은 당연히 인스턴스 밖에서 접근할 수 없고, 다른 자식 클래스에서도 접근할 수 없다.

private은 말 그대로 개인적인 것을 말하고, User 클래스의 인스턴스나 메소드에서 접근할 수는 있으나, 이 클래스는 추상클래스여서 인스턴스화 할 수 없다.

우리가 만약 필드가 외부로부터는 보호되지만 다른 자식 class에서는 사용되기를 원한다면 private을 쓰면 안된다.

대신 protected를 쓰면 된다.

 

 


해시맵을 만들어보자. 이건 해싱 알고리즘을 쓰는 완벽한 해시맵이 될 것이다.

사전에 새 단어를 추가하고, 단어를 찾고, 그리고 단어를 삭제하는 메소드를 만들 것이다.

 

class Dict {
  private words
}

보다시피 constructor에서 직접 초기화 되지 않는 property이다.

우리가 지금까지 해온건, constructor라고 쓴 후 그 안에 private 뒤에 이름과 타입을 쓰는 거였다.

이제 우리는 private words를 선언해주고, words의 타입을 알려줄 것이다.

 

type Words = {
  [key: string]: string
}

class Dics {
  private words: Words
}

위 Words 타입의 의미는 Words 타입이 string 만을 property로 가지는 오브젝트라는 걸 말해준 것이다.

 

예를들어 Words type을 쓰는 object를 만들어보자

let dics: Words = {
  "potato": "food"
}

key와 value가 모두 string인 오브젝트를 만들 수 있다.

 

이제 Dics class의 words에 에러가 뜰건데, words는 initializer가 없고 Constructor에서 정의된 sign이 아니라는 에러이다.

우리는 words를 initailizer 없이 선언해주고, constructor에서 수동으로 초기화시켜줄 것이다.

type Words = {
  [key: string]: string
}

class Dics {
  private words: Words
  constructor() {
    this.words = {}
  }
}

이제 Word class를 만들어보자.

class Word {
  constructor(
    public term: string,
    public def: string
  ) {}
}


const kimchi = new Word("kimchi", "한국의 발효 음식")

이렇게 김치 객체를 만들 수 있는데, 우리는 이 Word class를 Dics 클래스에서 type으로 쓸 수 있다.

 

Dics 안에 add라는 메소드를 만들어주자. 타입은 Word이다.

type Words = {
  [key: string]: string
}

class Dics {
  private words: Words
  constructor() {
    this.words = {}
  }
  add(word: Word) {
    if(this.words[word.term] === undefined) {
      this.words[word.term] = word.def;
    }
  }
}

class Word {
  constructor(
    public term: string,
    public def: string
  ) {}
}

const kimchi = new Word("kimchi", "한국의 발효 음식")

const dict = new Dict()

dict.add(kimchi);

add method에서 word 파라미터가 Word class의 인스턴스이기를 원한다면 이렇게 Word class를 type으로 쓸 수 있다.

 

이렇게 하면 dict이라는 객체를 생성 후 add method를 이용하여 여러 단어를 추가할 수 있다.

 

위의 Dict 클래스에서  words가 private 이므로, dictionary 안에서만 words를 보기를 원한다. 그전에 term을 이용해서 단어를 불러오기를 원한다.

deff method를 만들자.

type Words = {
  [key: string]: string
}

class Dics {
  private words: Words
  constructor() {
    this.words = {}
  }
  add(word: Word) {
    if(this.words[word.term] === undefined) {
      this.words[word.term] = word.def;
    }
  }
  def(term: string) {
    return this.words[term]
  }
}

class Word {
  constructor(
    public term: string,
    public def: string
  ) {}
}

const kimchi = new Word("kimchi", "한국의 발효 음식")

const dict = new Dict()

dict.add(kimchi);
dict.def("kimchi");

이제 dict.def("kimchi") 를 부르면 kimchi의 정의를 볼 수 있다.

 

 

 

 

Interfaces 

 

type Words = {
  [key: string]: string
}

class Dics {
  private words: Words
  constructor() {
    this.words = {}
  }
  add(word: Word) {
    if(this.words[word.term] === undefined) {
      this.words[word.term] = word.def;
    }
  }
  def(term: string) {
    return this.words[term]
  }
}

class Word {
  constructor(
    public term: string,
    public def: string
  ) {}
}

const kimchi = new Word("kimchi", "한국의 발효 음식")

const dict = new Dict()

 

여기 Word class에서 property들을 모두 public으로 설정했다.

왜냐면 쟤네들을 위의 Dict class의 add(word: Word) method에서 접근해야되기 때문이다.

단어를 추가할 때, word를 보내면, 해당 부분에서 term과 def가 public이어야 한다.

 

하지만 이런식으로 누군가 단어의 내용을 수정하게 하고 싶지는 않다,

kimchi.def = "xxxx"

 

지금 현재로써는 term과 def 모두 public이라서 위의 코드가 아무 문제가 없다.

 

어떻게 하면 public이지만 더 이상 변경할 수 없도록 만들 수 있을까?

즉, 값을 보여주고싶지만 값을 수정하게 만들고싶진 않다.

이럴땐 property를 readonly로 만들어주면 된다.

 

class Word {
  constructor(
    public readonly term: string,
    public readonly def: string
  ) {}
}

 

주로 누군가가 데이터를 덮어 씌우는 걸 방지하기 위해 private이나 protected property를 사용하는데, property들을 public으로 만들고, 이걸 타입스크립트의 힘을 빌려 읽기전용으로 만들면, 타입스크립트는 데이터 수정을 못하도록 보호해 준다.

당연하겠지만 readonly는 자바스크립트에서는 보이지 않는다.

 

 

참고로 class의 static method가 있는데, 이건 타입스크립트 것이 아니고 자바스크립트 것이다.

 


interface는 type과 비슷하지만 두 가지 부분에서 다른 점이 있다.

우선 타입스크립트에서 type을 사용하는게 얼마나 유용한 것인지 기억해야 한다.

type Player = {
  nickname: string,
  healthBar: number
}

const hwayeon: Player = {
  nickname: "hwayeon",
  healthBar: 10
}

이런식으로 우리는 타입스크립트에게 object의 모양, property 이름, 타입을 알려줄 수 있다.

 

type Food = string;

const kimchi: Food = "delicious"

이렇게 variable의 타입을 정해줄 수도 있다.

 

타입 alias (대체명)을 쓸 수도 있다.

type Nickname = string
type Health = number
type Friends = Array<string>

type Player = {
  nickname: Nickname,
  healthBar: Health
}

이렇게 우리가 원하는 모든 것들의 타입을 만들 수 있다.

 

타입을 지정된 옵션으로만 제한할 수 있기도 하다.

type Team = "red" | "blue" | "yello"
type Health = 1 | 5 | 10

type Player = {
  nickname: string,
  team: Team,
  health: Health
}

team이 일반적인 string이 아니라 특정 string이 되도록 하고싶다면, 이렇게 Team type을 만들면 된다. 

이런식으로 string 같은 concrete 타입이 아니라 타입의 특정 값을 쓸 수도 있다.

 

Player type으로 만든 object는 주어진 옵션으로만 만들 수 있다.

const hwayeon: Player = {
  nickname: "hwayeon",
  team: "red",
  health: 5
}

Player를 interface로 작성하면 이렇게 된다.

 

type Team = "red" | "blue" | "yello"
type Health = 1 | 5 | 10

type Player = {
  nickname: string,
  team: Team,
  health: Health
}

interface Player {
  nickname: string;
  team: Team;
  health: Health;
}

 

먼저 이해해야할 것은, type은 우리가 원하는 모든 것이 될 수 있다.

Team type은 string의 배열이 될 수도 있고, 위처럼 지정된 값이 될 수도 있다.

그리고 Player type 처럼 object의 모양을 특정하는데 쓸 수도 있다.

 

하지만 interface는 한가지 특정만 가지고 있다.

그건 object의 모양을 특정해주기 위한 것이다.

interface는 React.js를 이용해 작업을 할 때 많이 사용할 것이다.

 

둘의 특징은 object의 모양을 결정한다는 점이고, 다른 점은 type은 interface에 비해 좀 더 활용할 수 있는 게 많다는 것이다.


 

interface User {
  name: string;
}

interface Player extends User {
  
}

const hwayeon: Player = {
  name: "hwayeon"
}

이렇게 Player interface는 User interface를 상속받아서 쓰고있다.

만약 객체지향 프로그래밍에 관심이 있다면, interface는 class처럼 보여질 것이다.

 

같은 작업을 type으로 해보자

type User = {
  name: string;
}

type Player = User & {
  
}

const hwayeon: Player = {
  name: "hwayeon"
}

interface의 또 다른 특징은 property 들을 축적시킬 수 있다는 것이다.

 

interface User {
  name: string
}

interface User {
  lastName: string
}

interface User {
  health: number
}

이렇게 세개의 interface를 만들었다.

그리고 User interface를 사용한 object는 이렇게 정의해 줄 수 있다.

const hwayeon: User = {
  name: "hwayeon",
  lastName: "song",
  health: 10
}

 

interface를 각각 만들기만 하면, 타입스크립트가 알아서 하나로 합쳐준다.

같은 interface에 다른 이름을 가진 property들을 쌓을 수 있다.

 

하지만 type으로 같은걸 3가지 만들면 이건 작동하지 않는다.

 


추상 클래스는 자신을 상속받는 다른 클래스가 가질 property와 method를 지정하도록 해준다.

(다른 클래스가 따라야 할 청사진)

 

두 개의 메소드를 가지는 추상 클래스를 하나 만들어 보자.

abstract class User {
  constructor(
    protected firstName: string,
    protected lastName: string
  ) {}
  
  abstract sayHi(name: string): string
  
  abstract fullName() : string
  
}

User 를 상속받는 class를 만들어 보자.

class Player extends User {
  fullName() {
    return `${this.fullName} ${this.lastName}`
  }
  sayHi(name: string) {
    return `Hello ${name}. My name is ${this.fullName}`
  }
}

추상 클래스는 그것으로부터 인스턴스를 만드는 걸 허용하지 않는다.

그래서 new User() 같은 걸 쓸 수 없다.

 

우리는 왜 추상 클래스를 만들까?

이유는 상속받는 클래스가 어떻게 동작해야할지를 알려주기 위해서이다.

object의 모양을 나타내는 interface와 type처럼, 다른 class의 모양(청사진)을 나타내기 위해서이다.

표준화된 property와 method를 갖도록 해주는 청사진을 만들기 위해서 추상 클래스를 사용하는 것이다.

 

추상클래스의 문제점은, 알다시키 JS에서는 abstract의 개념이 없다는 것이다.

그래서 우리가 추상 클래스를 만들면 이건 결국 JS에서 그냥 class로 변한다.

 

이럴때 우리는 interface를 사용한다.

interface는 가볍다. 컴파일하면 JS로 바뀌지 않고 사라지기때문이다.

 

어떻게 인터페이스로 class가 특정 형태를 따르도록 만들까?

 

abstract class를 interface로 바꿔보자.

interface User {
  firstName: string,
  lastName: string,
  sayHi(name: string): string,
  fullName(): string
}

Player class에는 extends라는 말을 지우장

그리고 implements라는 JS가 쓰지 않는 단어를 쓸 것이다.

implements 또한 JS에서 쓰지 않는 코드이기 때문에 컴파일 결과가 훨씬 가벼워진다.

class Player implements User {
  constructor(
    private firstName: string,
    private lastName: string
  ) {}
  fullName() {
    return `${this.firstName} ${this.lastName}`
  }
  sayHi(name: string) {
    return `Hello! ${name}. My name is ${this.fullName}`
  }
}

이 클래스는 firstName과 lastName이 필요하기 때문에 constructor로 정의해 줬다.

하지만 이렇게만 적으면 typescript는 에러를 띄울텐데, 인터페이스를 상속할 때는 property를 private으로 만들지 못하고 public만 가능하기 때문이다.

interface User {
  firstName: string,
  lastName: string,
  sayHi(name: string): string,
  fullName(): string
}

class Player implements User {
  constructor(
    public firstName: string,
    public lastName: string
  ) {}
  fullName() {
    return `${this.firstName} ${this.lastName}`
  }
  sayHi(name: string) {
    return `Hello! ${name}. My name is ${this.fullName}`
  }
}

 

 

이렇게 interface는 클래스의 모양을 알려준다는 점에서 매우 유용하다.

그러면서 JS코드로 컴파일 되지는 않는다.

 

하지만 interface를 상속하는 것의 문제점은 private property들을 사용하지 못한다는 점이다.

그리고 abstract class에서는 constructor를 써주었지만 interface를 쓰면 상속하는 class에서 constructor를 정의해 주어야한다.


 

우리는 각각의 다른 interface를 상속할 수도 있다.

 

interface User {
  firstName: string,
  lastName: string,
  sayHi(name: string): string,
  fullName(): string
}
interface Human {
  health: number
}
class Player implements User, Human {
  constructor(
    public firstName: string,
    public lastName: string,
    public health: number
  ) {}
  fullName() {
    return `${this.firstName} ${this.lastName}`
  }
  sayHi(name: string) {
    return `Hello! ${name}. My name is ${this.fullName}`
  }
}

type PlayerA = {
  name: string
}
const playerA: PlayerA = {
  name: "hwayeon",
}

////

interface PlayerB {
  name: string
}
const playerB: PlayerB = {
  name: "hwayeon"
}

타입과 인터페이스의 목적은 타입스크립트에세 오브젝트의 모양을 알려준다는 데에서 같다.

 

하지만 상속하는 부분에서 다르다.

type PlayerA = {
  name: string
}
type PlayerAA = PlayerA & {
  lastName: string
}
const playerA: PlayerA = {
  name: "hwayeon",
  lastName: "song"
}

type을 상속하려면, 또 다른 타입 하나를 만들어서 위외같이 PlayerAA type이 PlayerA type과 lastName을 가지는 object를 합친 거라고 알려줘야 한다.

 

 

interface PlayerB {
  name: string
}
interface PlayerBB extends PlayerB {
  lastName: string
}
const playerB: PlayerB = {
  name: "hwayeon",
  lastName: "song"
}

interface를 상속하는 것은 객체지향 프로그래밍의 컨셉과 매우 유사하다.

일단 PlayerBB interface를 하나 만들고 위와같이 이것이 PlayerB를 상속한다고 알려줘야한다.

 

 

 

 

타입스크립트 커뮤니티에서는 object와 class 모양을 결정지을 땐 interface를 사용하고 나머지 경우엔 모두 type을 쓰라고 권하고 있다.

 

 

 

Polymorphism 

 

 

polymorphism, generic, class, interface를 모두 합쳐보자.

 

polymorphism은 다른 모양의 코드를 가질 수 있게 해주는 것이다. 다양성을 이룰 수 있는 방법은, generic을 사용하는 것이다.

generic은 placeholder 타입을 쓸 수 있도록 해준다. (concrete 타입 x)

 


브라우저에서 쓰는 로컬 스토리지 API와 비슷한 걸 만들어 보자.

 

call signature랑 class를 만들 거지만, 실제로 구현하지는 않을 것이다.

 

먼저 LocalStorage class를 만들자

class LocalStorage {
  private storage
}

그리고 나서 Storage interface를 선언해보면 이미 Storage interface가 존재한다는 걸 알 수 있다.

interface Storage { }

이건 타입스크립트에 의해 이미 선언된 JS의 웹 스토리지 API를 위한 인터페이스이다.

interface SStorage {
  [key: string]: ~~
}

class LocalStorage {
  private storage: SStorage = {}
}

그래서 interface Storage 를 우리가 정의하면, 알다시피 이미 있는 Storage interface에 새 property를 추가하게 되므로, 다른 이름인 SStorage로 선언해 주었다.

 

우리가 로컬스토리지 클래스를 초기화할 때, 타입스크립트에게 T라고 불리는 generic을 받을 계획이라고 알려줄 것이다.

interface SStorage {
  [key: string]: ~~
}

class LocalStorage<T> {
  private storage: SStorage = {}
}

generic에 대한 놀라운 점 하나는, 제네릭을 다른 type에게 물려줄 수 있다는 사실이다.

 

보다시피 위의 T 제네릭은 클래스 이름에 들어있지만, 같은 제네릭을 인터페이스로 보내줄 수 있다.

그럼 인터페이스는 제네릭을 받고 [key: string]: ~~ 부분엔 T가 위치할 수 있게된다.

interface SStorage<T> {
  [key: string]: T
}

class LocalStorage<T> {
  private storage: SStorage<T> = {}
}

generic을 class로 보내고 class는 generic을 interface로 보낸 뒤에 interface는 generic을 사용한다.

 


이제 method를 만들어 보자.

interface SStorage<T> {
  [key: string]: T
}

class LocalStorage<T> {
  private storage: SStorage<T> = {}
  set(key: string, value: T) {
    this.storage[key] = value;
  }
  remove(key: string) {
    delete this.storage[key]
  }
  get(key: string): T {
    return this.storage[key]
  }
  clear() {
    this.storage = {}
  }
}

get은 string key 값을 받으면 T type의 리턴 값을 보낸다.

위와 같이 localstorage 를 사용할 수 있는 method들을 만들어 주었다.

 

이 class를 사용하려면 아래처럼 작성하면 된다.

const stringsStorage = new LocalStorage<string>()
stringsStorage.get("ddd")
stringsStorage.set("ddd", "how are you")

const booleansStorage = new LocalStorage<boolean>()
booleansStorage.get("ddd")
booleansStorage.set("ddd", true)

<> 안에는 타입을 명시해 줘야한다.

 

 

 

 

'개발 > TypeScript' 카테고리의 다른 글

window 객체에 property 추가하기  (0) 2023.08.23
Functions - call signature, overriding, polymorphism, generics  (0) 2023.04.04
Overview of Typescript  (0) 2023.04.03
Software Requirement  (0) 2023.04.03
Comments