개발 공부 기록하기/01. JAVA & Kotlin

코틀린 제네릭 - 제네릭과 타입 파라미터

lannstark 2023. 8. 30. 15:50

제네릭은 추상화를 하는데 있어 굉장히 필수적인 기능이고, 정말 잘 활용되는 언어적 특징이다. 제네릭을 이해하기 위해 Cage 클래스를 만들어 보자. Cage 에는 여러 동물을 넣거나 꺼낼 수 있다. 가장 간단한 Cage 클래스는 다음과 같다.

class Cage {
  private val animals: MutableList<Animal> = mutableListOf()

  fun getFirst(): Animal {
    return animals.first()
  }

  fun put(animal: Animal) {
    this.animals.add(animal)
  }

  fun moveFrom(cage: Cage) {
    this.animals.addAll(cage.animals)
  }
}

당연히 Animal 클래스도 필요하니 간단하게 다음과 같이 금붕어, 잉어를 만들어 주자.

abstract class Animal(
  val name: String,
)

abstract class Fish(name: String) : Animal(name)

// 금붕어
class GoldFish(name: String) : Fish(name)

// 잉어
class Carp(name: String) : Fish(name)

여기까지 그림으로 나타내면 다음과 같다.

이제 Cage 에 잉어를 넣었다 잉어를 빼보자.

fun main() {
  val cage = Cage()
  cage.put(Carp("잉어"))
  val carp: Carp = cage.getFirst() // Error: Type Mismatch
}

앗! Error: Type Mismatch 가 발생한다. 생각해 보면 당연하다. 우리는 Cage를 만들어 잉어를 넣었다가 바로 가져오려 했는데, Cage 클래스의 getFirst() 함수 반환 타입은 Animal 이기 때문에 바로 Carp 타입을 가져올 수 없는 것이다.

이 에러를 해결하기 위해서는 어떻게 해야 할까?! 가장 간단한 방법은 타입 캐스팅(type casting)을 하는 것이다. Kotlin에서는 as 키워드를 이용해 타입 캐스팅을 할 수 있다.

fun main() {
  val cage = Cage()
  cage.put(Carp("잉어"))
  val carp: Carp = cage.getFirst() as Carp
}

이렇게 하면 바로 잉어 타입을 가져올 수 있게 된다.

자, 그런데 사실 위 코드는 매우 위험한 코드이다. 지금 당장은 잉어로 타입 캐스팅을 해도 문제가 없어 보이지만, 누군가 CageGoldFish 를 넣을 수도 있기 때문이다.

fun main() {
  val cage = Cage()
  cage.put(GoldFish("사실은 금붕어"))
  val carp: Carp = cage.getFirst() as Carp // 에러가 나지 않는다!!
}

실제로 두 번째 줄을 cage.put(GoldFish("금붕어")) 라고 바꾸었을 때 실제 오류는 런타임이 돼서야 발견하게 된다. 지금은 한 함수 내에서 금붕어를 넣고 잉어를 빼니 금방 발견할 수 있지만, 실제로는 복잡한 코드 내에서 이런 버그를 찾기란 쉽지 않다.

그렇다면 어떻게 타입 안전하게 잉어를 가져올 수 있을까??! 한 가지 방법은 Kotlin의 안전한 타입 캐스팅(safe type casting)과 엘비스 연산자(elivs operator)를 활용하는 것이다.

fun main() {
  val cage = Cage()
  cage.put(Carp("잉어"))
  val carp: Carp = cage.getFirst() as? Carp
    ?: throw IllegalArgumentException()
}

하지만 이 방법 역시 as 를 사용했을 때와 마찬가지로 여전히 실수할 여지가 있고, 실수로 금붕어가 Cage에 들어가면 에러가 발생한다. 우리는 아예 에러가 발생하지 않았으면 좋겠다.

그렇다면, 동일한 Cage 클래스이지만 잉어만 넣을 수 있는 Cage, 금붕어만 넣을 수 있는 Cage를 구분하는 방법은 어떨까? 이 방법을 사용하면 타입 안전하게 잉어를 Cage에 넣었다가 잉어 타입으로 가져올 수 있다.

이럴 때 바로 제네릭을 사용할 수 있다. Cage 클래스를 다시 한 번 만들어 보자. 이때 특정 타입만 Cage가 받을 수 있도록 처리하기 위해 타입 파라미터를 사용할 것이다. 타입 파라미터를 클래스에 적용하는 방법은 간단하다. 클래스 뒤에 < > 를 이용해 대문자를 적어주면 된다.

class Cage2<T> {

  private val animals: MutableList<T> = mutableListOf()

  fun getFirst(): T {
    return animals.first()
  }

  fun put(animal: T) {
    this.animals.add(animal)
  }

  fun moveFrom(cage: Cage2<T>) {
    this.animals.addAll(cage.animals)
  }

}

이렇게 Cage2 처럼 타입 파라미터가 적용된 클래스를 제네릭 클래스라 부르고, < > 에 들어간 T 를 타입 파라미터라고 부른다.

Cage2 를 제네릭 클래스로 만들며, getFirst(), put(), moveFrom() 함수에도 모두 T 가 들어가게 되었다. 이렇게 되면, Cage2 클래스를 인스턴스화 할 때 타입 정보를 넣어주어야 하고 그때 넣어준 타입 정보가 모두 T를 대체한다는 의미이다.

아까 살펴보았던 예제에 제네릭 클래스인 Cage2를 바로 적용해 보자.

fun main() {
  val cage = Cage2<Carp>()
  cage.put(Carp("잉어"))
  // 이제 as Carp 없이도 getFirst 메소드를 호출하면 바로 Carp가 나온다!!!
  val carp: Carp = cage.getFirst()
}

제네릭을 활용한 덕분에 타입 안전하게 잉어를 가져올 수 있게 되었다! 👍

실제로 이런 제네릭 클래스는 List나 Collection 같은 자바 표준 라이브러리, 코틀린 표준 라이브러리에서 활발하게 사용되고 있다.

자 이제 다음 요구사항을 생각해 보자.

  • 금붕어 Cage에 금붕어 한 마리를 넣고, 물고기 Cage에 moveFrom 메소드를 사용해 금붕어를 옮기자!

이 요구사항을 코드로 표현하면 다음과 같다.

fun main() {
  val goldFishCage = Cage2<GoldFish>()
  goldFishCage.put(GoldFish("금붕어"))

  val cage = Cage2<Fish>()
  cage.moveFrom(goldFishCage) // Error: Type Mismatch
}

자 그런데! 분명, 금붕어는 물고기 Cage에 들어갈 수 있을 것 같음에도 cage.moveFrom(goldFishCage) 는 Type Mismatch 에러가 발생한다! 😭

다음 시간에 왜 이런 에러가 발생하는지, 어떻게 해결할 수 있는지 알아보자.

 

내용 출처: https://inf.run/F9gX

 

코틀린 고급편 - 인프런 | 강의

코틀린의 모든 언어적 특성을 이해할 수 있습니다. 강의를 들으신 후 제네릭, 위임과 지연, DSL과 리플렉션 등 코틀린 고급 기술을 활용해 마음껏 프로그래밍하실 수 있습니다., 남들보다 깊은 코

www.inflearn.com