공변성, 반공변성, 무공변성

LSP (Liskov Substitution Principle)

A를 B로 교체해도 서비스에 아무 문제가 없도록 만드는 원칙을 리스코프 치환 원칙(Liskov Substitution Principle)이라고 한다.

예를 들면 기존에 OAuth 로그인을 카카오 로그인으로 구현했는데, 네이버 로그인으로 갑자기 기획안이 바뀌게 되어 로그인 방식을 교체하더라도 프로그램은 잘 동작해야 한다. 기존에 이메일을 처리하는 로직이 있었다면, 네이버 로그인 방식에도 이메일을 처리하는 로직이 들어가야 한다. 그래야 네이버 로그인으로 교체되더라도 기존과 동일하게 동작할 것이다.

B가 A의 서브타입일 때, A를 B로 대체해도 프로그램이 작동하는 데 문제가 없어야 한다.

변성 (Variance)

IntegerNumber을 상속받아 만들어진 객체이다. 따라서 IntegerNumber의 하위 타입이라고 할 수 있기 때문에, 다음과 같이 작성할 수 있다.

public void test() {
    List<Number> list;
    list.add(Integer.valueOf(1));
}

하지만 List<Integer>List<Number>의 하위타입이 될 수 없다. 이러한 상황에서 Java, Kotlin에서는 제네릭의 type parameter에 타입 경계 (bound)를 명시하여 subtype, supertype을 지정하도록 한다.

이러한 기능을 변성(Variance)이라고 한다.

BoundKotlinJava

상위 경계 (Upper bound)

Type<out T>

Type<? extends T>

하위 경계 (Lower bound)

Type<in T>

Type<? super T>

공변성 (Covariance)

공변성은 타입생성자에게 리스코프 치환 원칙(LSP)을 허용하여 유연한 설계를 가능하게 한다. 자기 자신과 자식 객체만을 허용한다.

variances.kts
interface Cage<T> {
    fun get(): T
}

open class Animal

open class Hamster(var name: String) : Animal()

class GoldenHamster(name: String) : Hamster(name)

fun tamingHamster(cage: Cage<out Hamster>) {
    println("길들이기: ${cage.get().name}")
}

val animal = object : Cage<Animal> {
    override fun get(): Animal {
        return Animal()
    }
}

val hamster = object : Cage<Hamster> {
    override fun get(): Hamster {
        return Hamster("Hamster")
    }
}

val goldenHamster = object : Cage<GoldenHamster> {
    override fun get(): GoldenHamster {
        return GoldenHamster("Golden")
    }
}

tamingHamster(animal) // Compile Error!
tamingHamster(hamster)
tamingHamster(goldenHamster)

여기서 tamingHamster() 함수는 Hamster 클래스의 서브타입만을 받기 때문에, animal은 들어갈 수 없다.

반공변성 (Contravariance)

공변성과 반대로 자기 자신과 부모 객체만을 허용한다.

variances.kts
fun ancestorOfHamster(cage: Cage<in Hamster>) {
    println("ancestor = ${cage.get()!!.javaClass.name}")
}

ancestorOfHamster(animal)
ancestorOfHamster(hamster)
ancestorOfHamster(goldenHamster) // Compile Error!

ancestorOfHamster() 함수에서 햄스터의 조상을 찾는 함수를 구현하고, Hamster를 포함한 그 조상들만 허용하도록 제한했다.

따라서 Hamster의 하위 타입인 GoldenHamster<in Hamster>의 제한에 걸려 Compile Error가 나는 것을 확인할 수 있다.

무공변성 (Invariance)

Java, Kotlin의 제네릭은 기본적으로 무공변성으로 아무런 설정이 없는 기본 제네릭을 뜻한다.

variances.kts
fun matingGoldenHamster(cage: Cage<GoldenHamster>) {
    val hamster = GoldenHamster("Golden 2")
    println("교배: ${hamster.name} & ${cage().get().name}")
}

matingGoldenHamster(animal) // Compile Error!
matingGoldenHamster(hamster) // Compile Error!
matingGoldenHamster(goldenHamster)

서로 부모, 자식 클래스를 구성하긴 하지만 제네릭에서 type parameter를 <GoldenHamster>로 명시했다.

Cage<Animal>, Cage<Hamster>, Cage<GoldenHamster>는 서로 연관이 없는 객체로서 무공변성의 적절한 예이다.

지점에 따른 변성

이러한 변성에는 지점에 따른 변성이 있는데, 이는 다시 선언 지점 변성사용 지점 변성으로 나뉜다.

선언 지점 변성 (Declaration-Site Variance)

클래스를 선언하면서 클래스 자체에 변성을 지정하는 방식(클래스에 in 또는 out을 지정)을 선언 지점 변성이라고 한다.

선언하면서 지정하면, 클래스의 공변성을 전체적으로 지정하는 것이 되므로, 클래스를 사용하는 지점에서는 따로 타입을 지정해줄 필요가 없어 편리하다.

사용 지점 변성 (Use-Site Variance)

선언 지점 변성이 클래스를 선언할 때 지정하는 것이라면, 사용 지점 변성은 메소드 파라미터에서, 또는 제네릭 클래스를 생성할 때 등 구체적인 사용 위치에서 변성을 지정하는 방식이다.

Java에서 사용하는 한정적 와일드카드 (bounded wildcard type)가 바로 이것이다.

이는 타입 파라미터가 있는 타입을 사용할 때마다, 해당 타입 파라미터를 하위 타입 또는 상위 타입 중 어떤 타입으로 대치할 수 있는지를 명시해야 한다.

  • 하위 타입: List<? extends Animal>

  • 상위 타입: List<? super Hamster>

정리

WordMeaning

공변성 (Covariance)

T’T의 서브타입이면, C<T’>C의 서브타입이다.

반공변성 (Contravariance)

T’T의 서브타입이면, CC<T’>의 서브타입이다.

무변성 (Invariance)

CC<T’>는 아무 관계가 없다.

WordMeaning (in Kotlin)

공변성 (Covariance)

T’T의 서브타입이면, C<T’>C<out T>의 서브타입이다.

반공변성 (Contravariance)

T’T의 서브타입이면, C<T>C<in T’>의 서브타입이다.

무변성 (Invariance)

CC<T’>는 아무 관계가 없다.

More

REF

Last updated