[Kotlin] 제네릭이 들어왔다 나왔다, in! out!

김병수·2024년 5월 10일
0
post-thumbnail

이번 포스팅은 코틀린 공식 문서의 예시와 설명을 참고했습니다.

Generic?

Generic은 여러 프로그래밍 언어들이 지원하는 기능으로, 클래스나 인터페이스 그리고 함수 등에서 하나의 코드로 여러 타입을 지원하기 위한 기능입니다.
가령 클래스를 예를 들면, 아래의 Box 클래스는 Int, Double, String 등의 여러 타입을 지원합니다.
여기서 <T>형식 인자(Type parameter)를 의미합니다.

class Box<T>(t: T) {
    var value = t
}

fun main() {
	val integerBox = Box<Int>(2024)
    val doubleBox = Box<Double>(5.9)
    val stringBox = Box<String>("2024.05.09")
}

다른 언어의 제네릭과 비교했을 때 한가지 특징이 있다면, 코틀린은 파라미터의 타입 추론이 가능하기 때문에 아래와 같이 타입을 따로 명시하지 않아도 컴파일러는 각각의 타입을 알아낼 수 있다고 합니다.

val integerBox = Box(2024)
val doubleBox = Box(5.9)
val stringBox = Box("2024.05.09")

이번에는 함수를 예시로 들어보겠습니다.

fun <T : Comparable<T>> compare(left: T, right: T): Boolean {
	return left > right
}

Comparable을 구현한 타입에 대하여 > 연산을 활용하여 값을 비교하는 함수입니다.
Comparable을 구현한 타입만 > 연산이 가능하기 때문에, T : Comparable<T>로 명시해 주었습니다.

이러한 Generic은 실제로 Kotlin Collections List, ArrayList, Set 등에서도 사용되며, 매우 중요한 개념인데요.
Generic을 잘 사용하기 위해서는 Variance라는 개념을 먼저 알아야 합니다.

Variance

Variance는 타입의 계층 관계(Type Hierarchy)에서 서로 다른 타입 간에 어떤 관계가 있는지를 나타내는 개념이라고 합니다.
즉, Generic을 사용할 때 기저 타입(Base Type)이 같고 형식 인자(Type Argument)가 다른 경우에 서로 어떤 관계가 있는지를 나타내는 것을 의미합니다.
(여기서 List<String>을 예시로 들면 기저 타입(Base Type)은 List, 형식 인자(Type Augument)는 String이 됩니다.)

Variance를 제대로 이해하려면 아래의 질문에서 시작하는 것이 좋다고 합니다.

타입 ST의 하위 타입일 때, List[S]List[T]의 하위 타입인가?
e.g. List<String>List<Object>의 하위 타입인가?

그렇다면 Variance를 이루는 속성에는 어떤 것이 있을까요?

Invariance

Invariance는 위에서 언급한 질문에 대하여

타입 ST의 하위 타입일 때, List[S]List[T]의 하위 타입이 아니다.
e.g. List<String>List<Object>의 하위 타입이 아니다.

라고 말하는 것을 의미합니다.
이해를 돕기 위해서 한 가지 예시를 들어보겠습니다.

val strs: MutableList<String> = mutableListOf()

// 여기서 컴파일 에러 발생!
val objs: MutableList<Object> = strs
// 그렇지만 컴파일 에러가 발생하지 않는다고 가정하고 코드를 계속 짜보면

objs.add(Object())

// strs의 첫 번째 원소는 String이 아닌 Object이기 때문에 타입 캐스팅 에러가 발생하게 됩니다.
val str: String = strs.get(0)

위의 코드는 실제로 두 번째 줄에서 컴파일 에러가 발생하며, 이는 Kotlin의 Generic이 기본적으로 Invariance이기 때문입니다.
만약 MutableList<String>MutableList<Object>의 하위 타입이라면, 두 번째 줄에서 컴파일 에러가 발생하지 않고 네 번째 줄에서 런타임 에러, 타입 캐스팅 에러가 발생하게 됩니다.

Covariance

Covariance는 위에서 언급한 질문에 대하여 아래와 같이 답변을 합니다.

타입 ST의 하위 타입일 때, List[S]List[T]의 하위 타입이다.
e.g. List<String>List<Object>의 하위 타입이다.

StringObject의 하위 타입이기 때문에, List<String>List<Object>의 하위 타입이 됩니다.

Kotlin에서는 Covariance를 지원하기 위해 out 이라는 기능을 제공하고 있다는 사실과 더불어, 보다 쉬운 이해를 위해 한 가지 예시를 들어보겠습니다.

open class League

// 농구 리그는 League의 하위 타입
open class BasketballLeague: League()

// NBA는 BasketballLeague의 하위 타입
class NBA: BasketballLeague() {}

fun covariance(league: List<out BasketballLeague>) {}

val leagueList = List<League>()
val basketballList = List<BasketballLeague>()
val nbaList = List<NBA>()

// 컴파일 에러 발생!
covariance(leagueList)
// 이하의 코드는 모두 정상
covariance(basketballList)
covariance(nbaList)

위의 코드에 작성되어 있는 3개의 클래스는 아래와 같은 계층 구조를 가집니다.

League > BasketballLeague > NBA

covariance 함수의 파라미터는 List<out BasketballLeague> 타입으로 정의되었기 때문에, BasketballLeague 또는 그 하위 타입만을 받을 수 있습니다.
따라서 List<League> 타입을 파라미터로 넘기려고 할 때에는 컴파일 에러가 발생함을 알 수 있습니다.

Contravariance

Contravariance는 Covariance와 정반대되는 개념이라고 생각하면 됩니다.
실제로 Kotlin에서는 Contravariance를 지원하기 위해 in 이라는 기능을 제공하고 있습니다.

open class League

// 농구 리그는 League의 하위 타입
open class BasketballLeague: League()

// NBA는 BasketballLeague의 하위 타입
class NBA: BasketballLeague() {}

fun contravariance(league: List<in BasketballLeague>) {}

val leagueList = List<League>()
val basketballList = List<BasketballLeague>()
val nbaList = List<NBA>()

covariance(leagueList)
covariance(basketballList) 
// 컴파일 에러 발생!
covariance(nbaList)

contravariance 함수의 파라미터는 List<in BasketballLeague> 타입으로 정의되었기 때문에, BasketballLeague 또는 그 상위 타입만을 받을 수 있습니다.
따라서 List<NBA> 타입을 파라미터로 넘기려고 할 때에는 컴파일 에러가 발생함을 알 수 있습니다.

Covariance 그리고 Contravariance

이렇게 Covariance 및 Contravariance에 대해 정리해보니 아래와 같은 특징이 있다는 것을 알 수 있습니다.

Convariance는 읽기는 가능하지만 쓰기는 불가능하고,
Contravariance는 쓰기는 가능하지만 읽기는 불가능하다.

실제로 아래의 코드에서

fun covariance(league: List<out BasketballLeague>) {}
fun contravariance(league: List<in BasketballLeague>) {}

covariance 함수의 파라미터 league 객체의 원소는 BasketballLeague 타입으로 꺼내어 담을 수 있지만, league 객체에 BasketballLeague 객체를 추가할 수는 없습니다.
그 이유는 league 객체의 타입이 List<BasketballLeague>일 수도 있고, List<NBA>일 수도 있기 때문입니다.
따라서 Covariance는 읽기는 가능하지만 쓰기는 불가능하다고 표현할 수 있습니다.

contravariance 함수의 파라미터 league 객체는 BasketballLeague의 상위 타입 객체를 모두 추가할 수 있습니다.
하지만 반대로 league에서 원소를 꺼내는 경우에는 그 원소를 어떤 타입으로 담아야 할지 모르는 상황이 발생합니다. (꺼낸 원소가 BasketballLeague인지 League인지 모르기 때문)
따라서 Contravariance는 쓰기는 가능하지만 읽기는 불가능하다고 표현할 수 있습니다.

이러한 특징을 바탕으로 Covariance는 읽기만 제공하기 때문에 값을 방출한다는 의미에서 Producer라 칭하고, Contravariance는 쓰기만 제공하기 때문에 값이 소비된다는 의미에서 Consumer라고 불립니다.

이를 정리하면 아래와 같이 나타납니다.

  • Covariance = Producer = out
  • Contravariance = Consumer = in

마무리

지금까지 Kotlin의 Generic 및 Variance 그리고 in, out 키워드에 대해 정리해 보았습니다.
막연하게 생각하고 있던 개념을 정리해보니 조금 더 명확해진 것 같습니다.
잘못된 부분이 있다면 지적 부탁드린다는 말을 끝으로 마무리하겠습니다👋🏻

profile
주니어 개발자

0개의 댓글