Kotlin에서 불변성(Invariance)와 가변성(Variance)

JhoonP·2022년 9월 1일
0

본문

Gradle script를 Groovy에서 KTS로 변경하던 중 다음과 같은 warning를 받았다.

Code 0

//	From Groovy
def getEnvFiles() {
    def envFilesDir = file("env_files")
    if(!envFilesDir.isDirectory()) {
        throw new GradleException("Input ${envFilesDirPath} is not directory!")
    }

    return envFilesDir.listFiles()
}

//	To Gradle
fun getEnvFiles(): Array<File> {
    val envFilesDir = file("env_files")
    if(!envFilesDir.isDirectory) {
        throw GradleException("Input $envFilesDir is not directory!")
    }

	//	WARNING Type mismatch. Required: Array<File>, Found: Array<(out)File!>?
    return envFilesDir.listFiles()	
}

File.listFiles()File[]를 반환하는 Java로 작성된 함수로, 내부를 확인해보니 Nullable annotation이 있는 것을 확인해 Kotlin side도 nullable로 수정했지만, out keyward는 생소하게 느껴졌다.

그렇게 공식문서에서 관련 내용을 확인해보다 불변성, 가변성 키워드를 보게되었다.

불변성(Invariance)은 비교적 간단한 성질이었지만, 가변성 내의 공변성/반공변성에 대해서는 공식 문서나 여러 문서를 읽은 뒤에도 직관적으로 와닿지는 못했는데, 쉽게 요약하면 아래의 이야기인듯 했다.

공변성: TV로 변할 수 있다면, C<T>C<V>로 변할 수 있다.
반공변성: TV로 변할 수 있다면, C<V>C<T>로 변할 수 있다.

객체들은 기본적으로 불변성을 띄고, out과 같은 키워드를 쓰면 공변성을 가지게 된다는데 왜 그러한지는 아래 예제 코드로 알 수 있었다.

Code 1

// Java
List<String> strs = new ArrayList<String>();
List<Object> objs = strs; // !!! A compile-time error here saves us from a runtime exception later.
objs.add(1); // Put an Integer into a list of Strings
String s = strs.get(0); // !!! ClassCastException: Cannot cast Integer to String

StringObject의 하위타입이라고 해서 List<String>List<Object>의 하위타입으로 가정하면 type safety가 깨지는 경우가 발생할 수 있고, 이는 run-time error을 발생시킬 수 있어, Java에서는 가변성을 compile-time에 막음으로써 에러를 방지하는 것이다.

하지만 특정 경우에는 공변성, 반공변성을 가져도 문제가 없을 수 있다.

Code 2

// Java
class ObjectContainer<E>  {
    E item;

    void setItem(ObjectContainer<E> objectContainer) {
        item = objectContainer.item;
    }
}

void test(ObjectContainer<Object> to, ObjectContainer<String> from) {
    to.setItem(from);
    // !!! Would not compile with the naive declaration of setItem:
    // ObjectContainer<String> is not a subtype of ObjectContainer<Object>
}

StringObject의 하위타입이기 때문에 test에서 setItem를 실행하는 것은 문제가 없을 것이다.
하지만 Java가 기본적으로 공변성을 compile-time에서 금지하고 있기 때문에 실행할 수 없다.

이러한 예외상황에서 공변성을 보장하기 위해서 Java에서는 Wildcard 를 사용한다.

Code 3

// Java
class ObjectContainer<E>  {
    E item;

    void setItem(ObjectContainer<? extends E> objectContainer) {
        item = objectContainer.item;
    }
}

위의 코드에서 보듯, setItem 함수의 제네릭 타입을 ? extends E로 변경하였다.
? extends EE 혹은 E를 상속하는 모든 것(?)를 허용한다는 의미로,
이렇게 되면 objectContaineritem 값을 읽어서 사용하는 것은 문제가 없고(E와 그의 하위타입만 존재하는 것을 알고 있음),
반대로 item 값을 수정하는 것은 불가능하다(item 값이 E의 어떠한 하위타입인지 알 수 없기 때문)

StringObject의 하위타입이고, ObjectContainer<String>ObjectContainer<? extends Object>의 하위타입이 될 수 있게 됐기 때문에 공변성을 가지게 되었다.

결국 공변성을 보장한다는 것은 객체를 (완전하지는 않지만) 읽기 전용으로 만들어 type safety를 보장하는 것이다.
그리고 Kotlin의 out은 Java의 ? extends E와 유사한 역할을 하여 이름에서부터 값을 "빼낸다"는 뉘앙스를 주듯이, 공변성을 부여해서 값을 빼내는 전용, (반쯤)읽기 전용으로 만들어준다.

"완전하지 않은" 읽기 전용의 의미는 아래 공식문서의 설명과 같이, 매개변수가 필요하지 않지만 내부 객체값에 쓰기를 수행하는 함수(clear() 등)는 호출이 가능하기 때문에 불변객체의 불변성(Immutability)와는 완전히 다르다고 설명하고 있다.

If you use a producer-object, say, List<? extends Foo>, you are not allowed to call add() or set() on this object, but this does not mean that it is immutable: for example, nothing prevents you from calling clear() to remove all the items from the list, since clear() does not take any parameters at all.

The only thing guaranteed by wildcards (or other types of variance) is type safety. Immutability is a completely different story.

Code 4

class TestDeclarationOut<out T>(val testVal: T) {
    fun get(): T {
        return testVal
    }
}

fun covarianceTestCode2() {
    val doubleObj: TestDeclarationOut<Double> = TestDeclarationOut(1.0)
    val anyObj: TestDeclarationOut<Number> = doubleObj
    
    val numberVal: Number = anyObj.testVal
}

위 예제 코드의 TestDeclarationOut<out T>T를 매개변수로 받는 함수가 존재하지 않고 out 키워드를 붙였기 때문에 공변성을 가질 수 있다.
따라서 covarianceTestCode2에서 보듯이, DoubleNumber의 하위타입이고, TestClass<Double>TestClass<Number>의 하위타입으로 사용해도 compile-time error가 발생하지 않는것을 볼 수 있다.

out이 있으니 역시 반공변성을 가능하게 해주는 in 키워드 또한 존재한다.
in은 Java의 ? super E와 유사하게 동작하고, 풀이하면 EE의 상위타입만 받을 수 있도록 설정한다는 뜻이다.

Code 5

class TestDeclarationIn<in T> {
    fun tagToString(tag: String, value: T): String {
        return "$tag: ${value.toString()}"
    }
}

fun contravarianceTestCode2() {
    val anyObj: TestDeclarationIn<Number> = TestDeclarationIn()
    val doubleObj: TestDeclarationIn<Double> = anyObj

    doubleObj.tagToString("tag", 1.0)
}

TestDeclarationIn<in T>T 타입의 값을 반환하는 함수는 존재하지 않고 in 키워드를 붙였기 때문에 반공변성을 가질 수 있다.
따라서 contravarianceTestCode2에서 보듯이, DoubleNumber의 하위타입이고, TestClass<Number>TestClass<Double>의 하위타입으로 사용해도 compile-time error가 발생하지 않는것을 볼 수 있다.

위의 Code 5, 6 을 보면, in/out 키워드를 class 선언 시 붙여준 것을 볼 수 있고, 이러한 방식을 Declaration-site variance 라고 한다.
하지만 대부분의 Interface, class는 producer(공변성)나 consumer(반공변성) 둘 중 하나로만 동작하는 단순한 형태가 아니다.

Code 6

class TestUseSite<T>(var testVal: T) {
    fun get(): T {
        return testVal
    }

    fun set(source: T) {
        testVal = source
    }
}

위의 TestUserSite<T> class는 T를 반환하는 함수, T를 매개변수로 가지는 함수 모두를 가지고 있다.
이러한 객체를 상황에 맞게 producer로, consumer로만 사용하고 싶은 경우가 있을 수 있다.
이러한 경우에는 Use-site variance를 사용하면 된다.

Code 7

fun covarianceTestCode2() {
    val doubleObj: TestUseSite<Double> = TestUseSite(1.0)
    val numberObj: TestUseSite<out Number> = doubleObj

    val numberVal: Number = numberObj.testVal

    numberObj.testVal = 1.0    //  ERROR: Setter for 'testVal' is removed by type projection
    numberObj.set(1.0)         //  ERROR: The floating-point literal does not conform to the expected type Nothing
}

anyObjTestUserSite<out Number> 타입으로 객체 선언 시 제너릭 타입에 out 키워드를 명시해주었다.
공변성을 가지게 된 anyObjTestUserSite<Double>의 하위타입이 될 수 있어 doubleObj를 할당받을 수 있게 되었지만, T 값을 매개변수로 가지는 함수는 접근할 수 없게 되었다.

Code 8

fun contravarianceTestCode2() {
    val numberObj: TestUseSite<Number> = TestUseSite(1.0)
    val doubleObj: TestUseSite<in Double> = numberObj

    doubleObj.testVal = 1.0
    doubleObj.set(1.0)

    val doubleVal: Double = doubleObj.get()      //  ERROR: Type mismatch. Required: Double, Found: Any?
    val numberVal: Number = doubleObj.testVal    //  ERROR: Type mismatch. Required: Number, Found: Any?
}

위의 코드는 반대로 doubleObjTestUserSite<in Double>으로 선언, 제너릭 타입에 in 키워드를 명시하여 반공변성을 부여하였다.
반공변성을 가지게 된 doubleObjTestUseSite<Number>의 하위타입이 될 수 있어 numberObj를 할당받을 수 있게 되었지만, T 값을 반환하는 함수는 접근할 수 없게 되었다.

고찰

Kotlin 관련 reference로 공변성, 반공변성을 찾다보니 producer, consumer 구현을 위한 성질, 도구로써 정도만 이해하고 작성한 것 같은데, 이후 C# base로 설명한 MS docs와 리스코프 치환과 관련하여 설명한 문서를 보니 다형성 추구와 관련해서 더 깊게 공부할 부분이 많은 것 같다(심지어 C#도 같은 in, out 키워드를 쓰고 있었네...)

참고자료

profile
배울게 끝이 없네 끝이 없어

0개의 댓글