<코틀린을 다루는 기술> 6장의 내용을 요약한 글입니다.
선택적 데이터
선택적 데이터란 데이터가 존재할 수도 있고 존재하지 않을 수도 있다는 뜻이다. 예를 들어, 리스트 원소 중에서 최댓값을 반환하는 함수가 있다고 하자. 리스트에 원소가 0 개일 때는 반환할 값이 존재하지 않는다. 이때, 오류는 발생하지 않았지만 "값이 존재하지 않음"을 표현할 방법이 필요하다.
가장 일반적인 방법으로 널 참조(null reference)를 사용하여 선택적 데이터를 표현한다. 데이터가 존재하는 경우에는 변수가 그 값을 가리키도록 하고, 데이터가 존재하지 않는 경우에는 널을 가리키도록 하는 것이다. 그러나, 널 참조를 사용하면 여러가지 문제점이 발생한다. 널 참조의 문제점과 선택적 데이터를 처리하기 위해 널 참조 대신에 사용할 수 있는 다른 대안들을 알아보자.
널 포인터의 문제점
널 참조를 사용하면 NPE(NullPointerException)가 발생할 가능성이 매우 높아진다.
val result: String? = someFunctionReturningStringThatCanBeNull()
val l = s.length
코틀린에서는 다행히 널이 될 수 있는 타입에 대한 처리를 강제하기 때문에 위 코드는 컴파일되지 않는다. 널이 될 수 있는 타입을 다룰 때는 안전한 호출(safe call) 연산자 ?. 를 사용해야 한다. 이 연산자는 특히 연쇄 호출을 할 때 더욱 유용하게 쓰인다.
다음 코드를 보자.
val city: City? = map[companyName]?.manager?.address?.city
자바에서 if ... else 구문를 사용하는 것보다 훨씬 간결하게 작성된다. 그러나, 널이라는 결과를 얻었을 때 companyName을 맵에서 찾은 결과가 없는 건지, manager가 없는 건지, address가 없는 건지, city가 없는 건지 전혀 파악할 수가 없다.
만약 companyName을 맵에서 찾은 결과가 null 이라면, 키 값이 맵에 존재하지 않는 것일 수도 있고, 키를 계산하는 과정에서 오류가 생겨 잘못된 키 값을 사용한 것일 수도 있다. 예를 들어 companyName이 어떤 원인에 의해 null 이라고 해도 HashMap은 null 을 키로 저장할 수 있기 때문에 예외는 발생하지 않는다. (심지어는 companyName이 null 이어도 null 이 아닌 결과가 나올 수 있다는 것이다.)
우리는 이런 복잡한 상황을 어떻게 정리해야할지 알아야만 한다.
선택적 데이터 처리하기
가장 기본적인 규칙지키기
선택적 데이터를 표현할 때 널을 사용하는 것만큼 편한 방법도 없지만, 널 참조를 허용하면서 안전한 프로그램을 작성하기 위해서는 다음 규칙을 꼭 지켜야 한다.
-
참조가 null 인지 아닌지 확인하지 않고는 절대 그 참조를 사용하지 않는다.
-
맵에 키가 존재하는지 아닌지 확인하지 않고는 절대 맵에서 값을 가져오지 않는다.
-
리스트가 비어 있지 않고 리스트 길이가 원하는 인덱스 값보다 크다는 사실을 확인하지 않고는 절대 리스트에서 원소를 가져오지 않는다.
이 세 가지를 모두 지킨다면 절대 NullPointerException 이나 IndexOutOfBoundsException 을 마주칠 일이 없지만, 이 규칙을 지키는 것을 한 번이라도 잊는다면 예외가 발생할 수 있는 위험한 상황이 된다.
리스트 사용하기
선택적 데이터를 안전하게 처리하기 위해 가장 자주 쓰이는 것은 리스트다. 예를 들어 함수의 반환 값이 있을 수도 있고 없을 수도 있을 때, 리스트를 반환 값으로 사용하는 것이다.
-
반환 값이 존재한다면 리스트에 그 값을 넣어서 원소가 1 개인 리스트를 반환한다.
-
반환할 값이 없다면 원소가 0 개인 리스트를 반환한다.
그러나 리스트의 원소를 하나로 제한할 방법이 없고, 값이 부재와 존재를 구분하는데 사용하기에는 너무 복잡한 자료구조이며 List 클래스에 있는 함수들이 이 용례에서는 전혀 쓸모가 없어진다.
널이 될 수 없는 타입만 사용하기
코틀린에서 변이 사용을 피하고 널이 될 수 없는 타입만 사용한다면 안전한 프로그래밍에 큰 도움이 된다. 그러나 코틀린 라이브러리 안에 있는 여러 함수들이 널이 될 수 있는 타입을 반환한다. 널을 반환하는 함수가 있다면 널 대신 다른 어떤 값을 반환하는 함수로 감싸는 래핑 함수(wrapping function) 기법을 사용할 수 있다.
fun mean(list: List<Int>): Double = when {
list.isEmpty() -> Double.NaN
else -> list.sum().toDouble() / list.size
}
값이 존재하지 않을 때 Double 클래스에 존재하는 Double.NaN 이라는 값을 반환해주었다. 이런 종류의 값을 센티넬 값이라고 부른다. 센티넬 값은 정상적인 동작에서는 절대로 나타날 수 없는 값, 즉 데이터의 부재를 나타낼 때만 사용될 수 있는 값을 선택해야 한다.
여기서도 몇 가지 문제가 있다. Double 과 달리 Int 클래스는 NaN에 해당하는 값을 제공하지 않으며, 따라서 위 함수를 파라미터화하여 제네릭 함수로 만들 수도 없다. 또한, 사용자에게 위 함수가 센티넬 값을 반환한다는 사실을 알려줄 방법이 없다.
그렇다면, 사용자에게 값이 존재하지 않을 때 사용할 디폴트 값을 지정하도록 하는 방법은 어떨까?
fun max(list: List<Int>, default: Int): Int = list.max() ?: default
위 함수는 리스트 원소 중에서 최댓값을 반환하거나, 리스트가 비어있을 때 디폴트 값을 반환한다. 이 경우에도 디폴트 값으로 사용할 마땅한 값이 없을 때 문제가 생긴다. 이 함수의 결과 값을 출력하는 코드를 보자.
val max = max(listOf(1, 2, 3), 0)
print(if (max != 0) "The maximum is $max\n" else "")
얼핏보면 제대로 동작하는 것처럼 보이지만, 리스트에 0만 들어있거나 0이 최댓값인 경우 결과가 제대로 출력되지 않는다.
지금까지 살펴본 방법보다 더 나은 대안이 필요해 보인다.
Option 타입 사용하기
선택적 데이터는 안전하지 않을 수 있기 때문에 안전하게 사용할 수 있는 환경에 넣어 사용해야 한다. Option 타입이 이러한 안전한 환경을 제공해준다.
Option 클래스의 코드를 살펴보자.
sealed class Option<out A> {
abstract fun isEmpty(): Boolean
internal object None: Option<Nothing>() {
override fun isEmpty() = true
override fun toString(): String = "None"
override fun equals(other: Any?): Boolean =
other === None
override fun hashCode(): Int = 0
}
internal data class Some<out A>(internal val value: A): Option<A>() {
override fun isEmpty() = false
}
companion object {
operator fun <A> invoke(a: A? = null): Option<A> =
when (a) {
null -> None
else -> Some(a)
}
}
}
Option 클래스는 봉인된 추상 클래스로, 두 개의 하위 클래스를 가진다.
-
None 은 모든 데이터 타입에 대해 사용할 수 있는 싱글턴 객체로 데이터의 부재를 나타내며, 데이터가 없는 경우는 모두 다 같다고 취급한다.
-
Some 은 데이터의 존재를 나타내는 데이터 클래스로, value 에 저장된 값에 따라 동등성이 평가된다.
Option 에서 값 가져오기
Option 에서 값을 가져올 때 주의할 점은 Option 안에 들어있는 위험한 값을 안전한 값으로 바꾸기 전까지 절대로 꺼내서는 안된다는 것이다.
getOrElse 함수
옵션에 값이 있으면 그 값을 반환하고, 없으면 기본 값을 반환하는 getOrElse 함수를 작성해보자.
fun getOrElse(default: () -> @UnsafeVariance A): A =
when (this) {
is None -> default()
is Some -> value
}
}
Some 인 경우(값이 있는 경우) 그 값을 반환하고, None 인 경우(값이 없는 경우) 인자로 받은 default 함수를 호출하여 기본 값으로 사용한다. 여기서 default 를 '값'이 아닌 '함수 값'으로 받은 이유는 파라미터를 지연 계산하기 위함이다.
default 를 '함수 값'이 아닌 '값'으로 받으면 어떤 문제가 발생할까? 다음 코드를 보자.
fun getOrElse(default: @UnsafeVariance A): A =
when (this) {
is None -> default
is Some -> value
}
이 함수를 사용하여 다음과 같이 값을 출력시켜보자. 파라미터가 리터럴인 경우에는 결과가 달라지지 않지만, 함수의 결과를 파라미터로 넘겨준다면 결과가 달라진다.
fun getDefault(): Int = throw RuntimeException()
fun main() {
val max1 = max(listOf(3, 5, 7)).getOrElse(getDefault())
println(max1)
val max2 = max(listOf()).getOfElse(getDefault())
println(max2)
}
코틀린은 즉시 계산 언어이기 때문에 max1 을 계산하는 과정에 default 값이 사용되지 않더라도 getOrElse 실행 전에 getDefault 함수가 먼저 실행되면서 아무것도 출력되지 않은 채로 예외가 발생한다.
반면, 파라미터를 함수 값으로 받는다면 getDefault 함수는 지연 계산되기 때문에 위 실행 코드는 7을 출력하는 것까지 정상적으로 실행 된 뒤에 max2 를 계산하는 과정에서 예외를 던질 것이다.
Option 에 함수 적용하기
map 함수
List 클래스의 map 함수를 생각해보자. A 타입 원소를 갖는 리스트에서 map 을 사용하면 B 타입 리스트를 만들 수 있다. Option 도 마찬가지로 A 타입의 옵션을 B 타입의 옵션으로 바꿔주는 map 함수를 작성할 수 있다. 함수 f: (A) -> B 를 적용해 Option<A> 를 Option<B> 로 바꿔주는 함수를 작성해보자.
첫 번째 방법으로는 Option 클래스에 추상 함수를 정의하고 None 과 Some 에서 각각 구현하는 방법이 있다.
다음 코드는 Option 클래스 내부의 추상 함수 시그니처이다.
abstract fun <B> map(f: (A) -> B): Option<B>
이제 이 함수에 대한 구현을 None 과 Some 클래스에 각각 추가해주면 된다. 주의할 점은 None 에서 함수 f 의 타입이 (Nothing) -> B 이어야 한다는 것이다.
override fun <B> map(f: (Nothing) -> B): Option<B> = None
override fun <B> map(f: (A) -> B): Option<B> = Some(f(value))
또 다른 방법으로 Option 클래스에 map 함수를 직접 정의할 수도 있다.
fun <B> map(f: (A) -> B): Option<B> =
when (this) {
is None -> None
is Some -> Some(f(value))
}
Option 합성하기
Option 에 유용하게 사용되는 다른 여러 함수들을 살펴보자. 위에서 정의한 getOrElse 와 map 의 경우에는 옵션에 들어있는 값을 직접 얻거나 결과 값을 옵션으로 감싸는 작업이 필요했다. 그러나 앞으로 정의할 함수들은 내부 값을 열어보지 않고 Option 을 서로 합성하는 방식으로 작성한다.
코드를 보며 Option 합성을 이해해보자.
flatMap 함수
List 클래스의 flatMap 과 마찬가지로 A 에서 Option<B> 로 가는 함수를 적용하여 Option<A> 에서 Option<B> 를 만든다. Option 에 함수 f: (A) -> Option<B> 를 적용하는 flatMap 함수를 작성해보자.
None은 None을, Some은 f(value)를 반환하는 방법 대신에, 부모 클래스인 Option 에 함수를 정의하여 두 하위 클래스 모두에 적용되도록 하는 유일한 구현을 작성할 수 있을까?
fun <B> flatMap(f: (A) -> Option<B>): Option<B> = map(f).getOrElse{None}
f 함수를 map 시켜 Option<Option<B>> 를 얻고, getOrElse 를 통해 값(Option<B>) 또는 None 을 반환하면 간단하게 해결된다. Option 내부 값을 열어볼 필요가 없기 때문에 하나의 함수를 None 과 Some 두 클래스에 각각 따로 구현하거나 함수 내에서 분기 처리를 하지 않고서도 두 하위 클래스 모두에 적용되는 유일한 구현을 작성할 수 있다.
orElse 함수
기본 값을 얻는 함수를 인자로 받아서 옵션에 값이 있을 때는 Some 을, 없을 때는 기본 값을 반환하는 orElse 함수를 작성해보자. 이 함수에서도 마찬가지로 옵션에 들어 있는 값을 직접 얻어올 필요가 없다.
fun orElse(default: () -> Option<@UnsafeVariance A>): Option<A> = map{ this }.getOrElse(default)
마찬가지로 map 을 사용하여 Option<Option<A>> 를 얻은 다음 getOrElse 를 통해 값이 존재하면 그 값을, 존재하지 않으면 디폴트로 설정된 값을 반환해준다.
filter 함수
이번에는 인자를 받아 Boolean 을 반환하는 술어 함수(predicate function)를 적용하여 조건을 만족할 때만 옵션을 반환하는 filter 함수를 작성해보자.
fun filter(p: (A) -> Boolean): Option<A> = flatMap{ x -> if(p(x)) this else None }
flatMap 을 통해 옵션의 값을 함수 p에 넣은 결과가 true 라면 현재 옵션을 그대로 반환하고, false 라면 None을 반환한다.
이와 같이 Option 을 합성하는 방법이 자주 쓰이기 때문에 이러한 방식에 익숙해지려는 노력이 필요하다.
Option 사용 예
맵에서 Option 을 사용하는 예를 살펴보자. 다음 함수는 키를 받아서 Option 을 반환하도록 하는 맵의 확장 함수다.
fun <K, V> Map<K, V>.getOption(key: K): Option<V> = Option(this[key])
여기서 this[key] 는 this.get(key) 를 호출하는데, 키가 맵에 있으면 값(value)를 반환하고 키가 맵에 없으면 null 을 반환한다. 이를 Option 에 작성한 동반 객체의 invoke 에 넘겨서 null 인 경우 None 을, null 이 아닌 경우 Some 을 만들어낸다. 맵의 containsKey 를 호출하여 키가 존재하는지 검사한 후에 값이 있을 때 get 을 호출하여 Option 을 만드는 것과 같은 결과가 나온다는 것을 알 수 있다. 즉, containsKey 로 키가 존재하는지 검사하는 과정을 생략하여 코드가 더 간결하게 작성된다.
맵에 넣을 데이터 클래스는 다음과 같다.
data class Toon (val name: String,
val email: Option<String> = Option()) {
companion object {
operator fun invoke(name: String,
email: String? = null) =
Toon(name, Option(email))
}
}
이제 getOption 과 Option 의 여러 함수들을 합성하여 맵을 다뤄보자.
fun main() {
val toons: Map<String, Toon> = mapOf(
"Mickey" to Toon("Mickey", "mickey@disney.com"),
"Donald" to Toon("Donald"))
val mickey = toons.getOption("Mickey").flatMap{ it.email }
val minnie = toons.getOption("Minnie").flatMap{ it.email }
val donald = toons.getOption("Donald").flatMap{ it.email }
println(mickey.getOrElse{ "No data" })
println(minnie.getOrElse{ "No data" })
println(donald.getOrElse{ "No data" })
}
맵에 들어있지 않은 Toon 을 찾거나, email이 업는 Toon 의 email 을 찾는 경우에도 그 어떤 검사를 수행할 필요가 없으며, NPE가 발생할 걱정도 없다. 그러나 여기서도 문제점이 있는데, 위 프로그램의 실행 결과를 출력해보자.
Some(value=mickey@disney.com)
No data
No data
첫 번째 줄은 Mickey 의 이메일 주소다. 두 번째 줄은 Minnie 가 맵에 존재하지 않기 때문에 No data 가 출력된다. 세 번째 줄은 Donald 의 이메일 주소가 없기 때문에 No data 가 출력된다. 맵에서 결과를 찾을 수 없는 경우와 맵에는 있지만 이메일 주소가 없는 경우를 구분할 수 없다. 이는 앞에서 null 이 될 수 있는 타입을 사용할 때도 동일하게 봤던 문제점이다. 이 문제를 해결할 방법은 다음 장에서 자세히 다룬다.
Option 을 조합하는 방법
Option 을 사용하는 프로그램에서 Option<A> 에서 Option<B> 로 가는 함수가 필요한데, A 에서 B 로 가는 라이브러리를 사용해야 한다면 어떻게 해야할까?
함수 끌어올리기(Lift)
이런 경우 함수를 끌어올리는 기법이 필요하다. A 에서 B 로 가는 함수를 인자로 받아 Option<A> 에서 Option<B> 로 가는 함수를 반환하는 lift 함수를 작성해보자.
fun <A, B> lift(f: (A) -> B): (Option<A>) -> Option<B> = { it.map(f) }
기존 라이브러리에 함수 값이 없고 fun 함수만 있는 경우에도 lift 를 사용하여 함수를 끌어올릴 수 있다. 예를 들어 String.toUpperCase 를 끌어올리는 코드는 다음과 같이 작성된다.
val upperOption: (Option<String>) -> Option<String> = lift(String::toUpperCase)
A 에서 B 로 가는 함수를 A 에서 Option<B> 로 가는 함수로 변환할 때도 같은 기법을 사용하여 구현할 수 있다.
fun <A, B> hLift(f: (A) -> B): (A) -> Option<B> = Option(it).map(f)
만약, 기존 함수가 인자를 두 개 받으면 어떻게 해야 할까?
Option<A>, Option<B> 와 (A) -> (B) -> C 타입의 커리한 함수 값을 인자로 받아 Option<C> 를 반환하는 map2 를 작성해보자.
fun <A, B, C> map2(oa: Option<A>,
ob: Option<B>,
f: (A) -> (B) -> C): Option<C> =
oa.flatMap { a ->
ob.map { b ->
f(a)(b)
}
}
이제 인자를 두 개 받는 함수를 커리한 함수 값으로 변환하여 map2 를 사용할 수 있다.
인자를 두 개 받는 함수를 커리한 함수로 변환하는 아주 간단한 예를 살펴보자.
fun f(a: Int, b: Int): Int = a + b
val add: (Int) -> (Int) -> Int = { a -> { b -> f(a, b) } }
Option 으로 List 합성하기
Option 끼리의 합성 외에 Option 을 다른 타입과 합성해야 될 때가 있을 것이다. 예를 들어 Option 을 List 와 합성하여 사용할 경우가 있을 수 있다.
우리는 List<B> 타입의 리스트가 있을 때, B 에서 Option<A> 로 가는 함수를 매핑하면 List<Option<A>> 를 얻을 수 있다는 사실을 안다. 이렇게 얻은 List<Option<A>> 를 다시 Option<List<A>> 로 변환하려면 어떻게 해야될까?
리스트의 모든 원소가 Some<A> 라면 Some<List<A>> 를 얻고, 적어도 하나가 None<A> 라면 None 를 얻어야 한다. 또는, 리스트에서 None<A> 인 원소는 모두 무시하고 Some<A> 만 모아서 Some<List<A>> 를 얻을 수도 있다. 상황에 따라 다른 결과가 필요할 것이다.
전자의 경우에 사용할 수 있는 sequence 함수를 작성해보자.
fun <A> sequence(list: List<Option<A>>): Option<List<A>> =
list.foldRight(Option(List())) { x: Option<A> ->
{ y: Option<List<A>> ->
map2(x, y) { a: A ->
{ b: List<A> ->
b.cons(a)
}
}
}
}
foldRight 를 사용하여 리스트를 오른쪽으로 접으면서 map2 를 적용하였다. 이렇게 작성한 함수를 사용하는 프로그램을 작성해보자.
fun main() {
val parseWithRadix: (Int) -> (String) -> Int =
{ radix -> { string -> Integer.parseInt(string, radix) } }
val parse16 = hLift(parseWithRadix(16)) // Option 에서 사용할 수 있는 함수로 끌어올리기
val list = List("4", "5", "6", "A", "B")
val result = sequence(list.map(parse16))
println(result)
}
result 를 얻기 위해 리스트의 map 과 sequence 를 호출하였는데, 두 함수 모두 내부에서 foldRight 를 통해 리스트의 모든 원소를 방문하므로 비효율적이라는 것을 알 수 있다. 이 둘을 합쳐서 매핑과 접기를 한번에 처리하면 효율성을 더욱 높힐 수 있다.
이를 구현하기 위해 리스트와 A 에서 Option<B> 로 가는 함수 값을 인자로 받아 옵션을 매핑할 때 Option<List<B>> 를 만드는 함수가 필요하다. 함수 내부에서 매핑을 할 때 인자로 받은 함수를 적용시켜준다. 다음 코드를 보자.
fun <A, B> traverse(list: List<A>, f: (A) -> Option<B>): Option<List<B>> =
list.foldRight(Option(List())) { x ->
{ y: Option<List<B>> ->
map2(f(x), y) { a -> // 여기서 함수 f 를 적용시킨다
{ b: List<B> ->
b.cons(a)
}
}
}
}
이제 traverse 를 사용하여 sequence 함수를 구현할 수 있다.
fun <A> sequence(list: List<Option<A>>): Option<List<A>> = traverse(list) { x -> x }
Option 을 언제 사용할까
선택적 데이터를 다룰 때 항상 Option 을 사용하는 것이 좋을까?
Option 을 사용하면 프로그램에서 NullPointerException 이 발생할 위험은 없어지지만, 예외가 발생한 상황에서 예외를 던지는 대신에 None 을 반환하기 때문에 그 예외에 대한 정보는 조용히 사라진다. 데이터가 존재하지 않는 경우는 일반적으로 예외가 발생하여 이를 처리해야되는 상황인 경우가 많을 것이다. 이런 상황을 판단할 수 없게 된다는 것은 여전히 큰 문제일 수 있다.
다음 장에서 선택적 데이터를 다룰 때 예외가 발생한 상황을 더 영리하게 처리하는 법을 배우고나면, Option 데이터 타입을 사용할 일이 거의 없어질 것이다. 하지만 이번 장에서 공부한 내용은 앞으로 계속 쓰게 될 중요한 개념이기 때문에 잘 기억해 둘 필요가 있다.
'Kotlin > 코틀린을 다루는 기술' 카테고리의 다른 글
8장. 고급 리스트 처리 (0) | 2020.08.06 |
---|---|
7장. 오류와 예외 처리하기 (0) | 2020.07.31 |
4장. 재귀, 공재귀, 메모화 (0) | 2020.07.18 |
3장. 함수로 프로그래밍하기 (0) | 2020.07.14 |
댓글