// This will always refer to the latest onTimeout function that // LandingScreen was recomposed with val currentOnTimeout by rememberUpdatedState(onTimeout)
// Create an effect that matches the lifecycle of LandingScreen. // If LandingScreen recomposes, the delay shouldn't start again. LaunchedEffect(true) { delay(SplashWaitTimeMillis) currentOnTimeout() }
/* Landing screen content */ }
위 코드에서 rememberUpdatedState의 역할은 무엇일까요? rememberUpdatedState를 사용하지 않고 onTimeout을 LaunchedEffect에서 사용하면 어떻게 될까요?
rememberUpdatedState 없이 사용하는 경우
LaunchedEffect는 키가 true 로 설정되어 한번만 실행됩니다. 하지만 LandingScreen은 onTimeout 이 변경될 때마다 재구성됩니다.
이때 LaunchedEffect는 처음 실행될 때 캡쳐한 onTimeout을 사용하기 때문에, LandingScreen의 onTimeout이 변경되어 재구성된 후에도 처음 받은 onTimeout을 호출하게 됩니다.
1 2 3 4 5 6 7 8 9
@Composable funLandingScreen(onTimeout: () -> Unit) { LaunchedEffect(true) { delay(SplashWaitTimeMillis) onTimeout() // 변경된 onTimeout이 아니라 처음 캡처된 onTimeout이 호출됨 }
/* Landing screen content */ }
LaunchedEffect의 키로 onTimeout을 사용하는 경우
LaunchedEffect의 키로 onTimeout을 사용하면, onTimeout이 변경될 때마다 LaunchedEffect가 재실행됩니다.
이렇게 하면 항상 최신의 onTimeout이 호출되지만, LaunchedEffect가 재실행될 때마다 delay(SplashWaitTimeMillis)도 다시 시작되므로 딜레이 시간이 길어질 수 있습니다.
1 2 3 4 5 6 7 8 9
@Composable funLandingScreen(onTimeout: () -> Unit) { LaunchedEffect(onTimeout) { delay(SplashWaitTimeMillis) onTimeout() // 항상 최신 onTimeout이 호출됨 }
/* Landing screen content */ }
rememberUpdatedState의 역할
rememberUpdatedState를 사용하면 LaunchedEffect가 재실행되지 않으면서도 항상 최신의 onTimeout을 참조할 수 있습니다. 이는 LaunchedEffect가 재실행되지 않아 딜레이 시간이 유지되면서도, 최신 상태를 유지할 수 있게 합니다.
이제 아래의 rememberUpdatedState에 대한 공식 문서의 한줄 설명이 이해가 되시나요?
reference a value in an effect that shouldn’t restart if the value changes
혹시 이 글이 다소 이해가 되지 않는다면 람다와 클로저, 그리고 이펙트에 대해 다시 학습해보면 도움이 되리라 생각합니다.
구현 보기
마지막으로 rememberUpdatedState의 구현을 코드로 살펴봅시다.
rememberUpdatedState는 아래와 같이 구현되어 있습니다. remember에서 mutableStateOf(newValue)를 통해 newValue를 값으로 갖는 State를 반환 합니다. 따라서 이렇게 반환된 State는 리컴포지션이 발생하더라도 변경되지 않고 캐싱됩니다.
코틀린 코드에서 자주 보게되는 in과 out, 그리고 자바에서 자주 보게 되는 <? extends T>, <? super T> 는 제네릭의 변성과 깊은 관련이 있습니다. 이 글에서는 자바와 코틀린과 같은 객체지향 프로그래밍 언어에서 사용되는 변성의 개념을 살펴 보도록 하겠습니다.
서브타입과 다형성(Subtypes and Polymorphism)
객체지향 프로그래밍 언어에서는 클래스의 상속이나 인터페이스, 추상클래스를 이용하여 객체를 모델링 합니다. 추상화된 개념을 이용하여 슈퍼타입을 만들면 이로부터 슈퍼타입을 구체화 하는 서브타입을 만들어 낼 수 있습니다.
서브타입은 슈퍼타입에 정의된 기능과 속성을 상속받아 그대로 사용하거나 필요에 따라 재정의할 수 있습니다.
서브타입을 인스턴스화 할 때 해당 인스턴스는 그 슈퍼타입으로 받아 사용이 가능합니다. 이를 다형성이라고 합니다. 다형성은 객체지향 프로그래밍의 핵심 기능으로 아주 강력한 편의성과 확장성을 가져다 주는 도구입니다.
이를테면 동물이라는 추상화를 하고 이를 구체화하는 개, 고양이와 같은 서브타입을 만들 수 있는데 이를 서브타이핑이라 하고, 동물이란 타입을 이용하여 개나 고양이 객체를 받아서 동물에 정의된 공통 동작과 속성을 사용하는 것을 다형성 이라고 합니다.
객체 지향 프로그래밍 언어에서 갖는 설계상 이점의 아주 큰 부분이 다형성을 바탕으로 존재합니다.
상한 와일드 카드는 제네릭 타입의 E에 E 또는 E 의 서브타입이 들어올 수 있도록 컴파일러에 알려줍니다. 상한 와일드 카드는 읽기만 허용하고 쓰기는 허용하지 않습니다. 쓰기를 허용하지 않는 것은 제약이지만 이 제약 덕분에 앞서 설명한 런타임 타입 안정성이 깨지는 상황을 막을 수 있습니다.
잘 생각해보면 제네릭에 공변성을 부여하는 것은 불변성의 제약을 제네릭 타입에 쓰기를 허용하지 않는 제약으로 바꾸는 걸로도 볼 수 있습니다.
앞서 공변성을 줄 수 있는 조건에 대한 설명과도 딱 맞아 떨어집니다.
Collection의 addAll()이 공변성이 없이 아래와 같이 선언이 되었다면 우리는 animals.addAll(dogs) 같은 코드를 사용할 수 없었을 것 입니다.
이번에는 코틀린에서의 공변성을 부여하는 방법에 대해 알아보도록 하겠습니다. 아래 Source 인터페이스를 보면 T를 반환하는 메소드만 존재합니다.
1 2 3
interfaceSource<T> { T nextT(); }
이 인터페이스는 T 타입을 반환하는 코드만 있기 때문에 공변성을 부여할 수 있는 조건에 해당합니다. 즉 이 제네릭 인터페이스는 값을 생산(읽기) 하기만 할 뿐 쓰는 동작을 하지 않는 생산자입니다. 하지만 그럼에도 불구하고 아래와 같은 코드는 컴파일 에러를 발생 시키며 허용하지 않습니다.
1 2 3 4
voiddemo(Source<String> strs) { Source<Object> objects = strs; // !!! Not allowed in Java // ... }
이는 앞서 설명한 대로 제네릭이 불변이기 때문입니다. 자바에 Source<T> 가 안전하다는걸 알려주기 위해 앞서 배웠던 상한 와일드 카드를 이용해서 아래와 같이 수정이 가능합니다.
자바에서처럼 제네릭 타입을 사용할 때마다 사용하는 곳에서 상항 와일드 카드를 사용해서 공변성을 부여하는 것을 사용 지점 변성(use-site variance) 이라고 합니다. 하지만 이렇게 매번 반복하는 것은 번거롭습니다.
코틀린에서는 선언 지점 변성(Declation-site variance) 을 이용하여 이를 좀 더 편리하게 사용할 수 있습니다. 선언 지점 변성은 제네릭 타입이 이미 공변성이나 반공변성을 만족한다면 제네릭 타입의 선언 시점에 이를 함께 선언해줄 수 있습니다. (참고로 코틀린에서는 사용 지점 변성과 선언 지점 변성을 둘 다 지원합니다.)
즉 코틀린에서는 매번 공변성을 사용할때마다 상한 와일드 카드를 사용하는 것이 아니라 Source 인터페이스를 선언하면서 T 앞에 out 이라는 키워드를 추가하는 방식으로 한번에 공변성 부여가 가능합니다.
1 2 3 4 5 6 7 8
interfaceSource<out T> { funnextT(): T }
fundemo(strs: Source<String>) { val objects: Source<Any> = strs // This is OK, since T is an out-parameter // ... }
다만 out 키워드를 이용해 선언 지점 반성을 사용하기 위해서는 제네릭 타입이 공변성의 조건에 부합해야합니다. 그러기 위해서는 해당 제네릭 타입은 쓰기 동작이 없고, 읽기 동작만 있어야합니다. 읽기 동작은 데이터를 생산 하는 것을 의미 합니다. 따라서 T는 이제 out-positon 에서만 사용이 가능합니다. 이는 T가 제네릭 클래스 내 함수의 리턴 타입 위치, 또는 퍼블릭 속성으로만 사용이 가능하다는 의미입니다.
이렇게 T가 제네릭 클래스 안에서 out-position 에서만 사용이 되는 경우 이 제네릭 클래스는 T의 생산자라고 합니다. 이런 경우 해당 클래스 또는 인터페이스는 T를 소비하지 않고 생산만 하기 때문에 out 키워드를 통해 제네릭 타입의 선언 시점에 공변성 부여가 가능합니다.
공변성을 말할 때는 아래의 네 가지를 떠올리면 이해가 빠릅니다.
Upper Bounded Wildcards (<? extends Type>)
out
producer
read
반공변(Contravariance)
반공변성에서는 한 타입이 다른 타입의 서브타입이면, 그 제네릭 타입은 슈퍼타입 관계를 유지합니다. 예를 들어, Dog이 Animal의 서브타입인 경우, List<Animal>은 List<Dog>의 슈퍼타입으로 간주할 수 있습니다.
반공변성의 조건
반공변성은 데이터를 소비(쓰기)만 하는 경우 안전하게 사용이 가능합니다. 읽기도 가능은 하지만 이 경우 안전하게 사용하기 위해서는 자바에서는 Object, 코틀린에서는 Any와 같이 가장 범용적인 타입으로 가져와야 안전하게 사용이 가능합니다.
데이터를 쓰는데 사용되기 때문에 이를 소비자 라고 부릅니다.
자바에서 제네릭 타입에 반공변성 부여하기
자바에서는 하한 와일드 카드(Lower bound wild card)를 이용하여 반공변성을 부여할 수 있습니다. 아래 코드에서 보면 <? super E>가 하한 와일드 카드입니다.
1
publicstatic <T> voidsort(List<T> list, Comparator<? super T> c)
sort 메서드는 List<T>의 원소들을 정렬하는 데 사용되며, Comparator<? super T> 타입의 Comparator를 인자로 받습니다. Comparator는 리스트의 원소 타입 T 또는 T의 슈퍼타입 객체들을 비교할 수 있습니다.
Comparator<? super T>는 T 타입 또는 T의 어떤 슈퍼타입을 사용하여 두 객체를 비교할 수 있습니다. 이는 Comparator가 반공변적임을 의미하는데, T의 슈퍼타입에 대해 비교 기능을 제공할 수 있기 때문입니다.
// AnimalAgeComparator는 Comparator<Animal>이지만, 여기서는 Dog 리스트를 정렬하는 데 사용됩니다. Collections.sort(dogs, newAnimalAgeComparator()); }
위 예제에서 AnimalAgeComparator는 Animal 객체들을 나이에 따라 비교합니다. Collections.sort() 메서드는 이 비교자를 사용하여 Dog 객체의 리스트를 정렬할 수 있습니다. 이는 Comparator<Animal>이 Dog의 슈퍼타입인 Animal을 처리할 수 있기 때문입니다. 이 예제에서 Comparator<? super T>의 반공변성을 통해 Dog 리스트를 정렬하는 데 Animal을 대상으로 하는 Comparator를 사용할 수 있습니다.
코틀린에서 제네릭 타입에 반공변성 부여하기
아래 예제는 코틀린의 Comparable 제네릭 인터페이스 입니다. Comparable 은 operator fun compareTo(other: T): Int 메소드만 가지고 있습니다. 이 메소드는 T 타입을 파라미터로 받아서 소비하기만 합니다. 따라서 in 키워드를 통해 Comparable에 반공변성을 부여할 수 있습니다.
1 2 3 4 5 6 7 8
interfaceComparable<in T> { operatorfuncompareTo(other: T): Int }
fundemo(x: Comparable<Animal>) { x.compareTo(Dog()) val y: Comparable<Dog> = x }
코드를 보면 x: Comparable<Animal> 를 가지고 Animal의 서브타입인 Dog와 값 비교를 하고, val y: Comparable<Dog> = x 역시 가능함을 볼 수 있습니다.
반공변성을 말할 때는 아래의 네 가지를 떠올리면 이해가 빠릅니다.
Lower Bounded Wildcards (<? super Type>)
in
consumer
write
마치며
변성에 대한 이해가 부족하여 공부를 할겸 작성한 글입니다. 변성이란 개념은 처음에는 다소 이해하기가 어렵지만 잘 이해해둔다면 다른 코Ï드를 읽거나, 제네릭을 이용한 객체지향 설계 능력을 향상 시키는데 큰 도움이 될거라 생각합니다.
개인적으로는 이 개념을 이해하는게 참 힘들었는데 이 글이 다른 분들에게도 아주 조금이나마 도움이 되었으면 좋겠습니다.