예외를 던지는 것은 부수 효과이며 바람직하지 않은 동작이다.

예외를 던진다는 것은 제어 상실을 의미한다.
예외가 던져지면 호출 스택을 거슬로 올라가며 catch되거나 처리되지 않아 crash가 나거나 한다. 

이런 상황은 복잡도가 높아지게 만드는 원인이다.

함수형 프로그래밍에서는 실패와 예외를 일반적인 값으로 표현할 수 있다. 그리고 오류 처리를 하는 고차함수를 작성해 처리한다. 
이렇게 하면 참조투명성을 유지할 수 있다. 

4.1 예외를 던지는 것의 문제점

fun failingFn(i: Int): Int {
    val y : Int = throw Exception("boom")
    return try {
        val x = 42 + 5
        x + y
    } catch (e: java.lang.Exception) {
        43
    }
}

이 함수에서는 예외를 던지고 있다. try 블럭에서 던진 예외가 아니기 때문에 프로그램이 종료될 것이다.

y를 치환할 수 있을까? y를 throw Exception()으로 치환한다면 

fun failingFn(i: Int): Int {
    return try {
        val x = 42 + 5
        x + throw Exception("boom")
    } catch (e: java.lang.Exception) {
        43
    }
}

try 블럭 안에서 예외가 발생하고 catch에서 예외가 처리되어 43이라는 결과가 반환될 것이다. 

이렇듯 예외는 참조투명하지 않을 수 있고, 문맥에 의존적이다. 따라서 단순한 추론이 어려워진다. 

또한 failingFn의 반환타입은 Int는 이 함수에서 예외가 발생한다는 것을 알려주지 못한다.
예외가 발생한다는 사실은 런타임에 알 수 있다. 

4.2 예외에 대한 문제가 있는 대안

다음의 함수는 리스트의 평균을 계산하는 함수이다. 리스트가 비어있으면 평균을 계산할 수 없어 예외를 던진다.

fun mean(xs: List<Double>): Double =
    if (xs.isEmpty()) {
        throw ArithmeticException("mean of empty list!")
    } else {
        xs.sum() / xs.size
    }

이 함수는 Partial function(부분함수)이다.

부분함수는 입력 중 일부에 대해 결과가 정의되지 않은 함수이다. 
위 함수 역시 예외가 던져지면 호출부에서 액션을 처리해야하기 때문에 빈 리스트가 들어왔을 때 결과가 정해지지 않았다 라고 볼 수 있다. 

해결법 1 - 센티넬 값

예외를 던지는 대신에 Double 타입의 가짜 값을 반환하는 방법이다.

Double.NaN 타입을 만들어 반환하거나, null 값을 반환하게 할 수 도 있다. 

이 방식은 이런 단점이 있다.

  • 오류가 조용히 전파됨. 호출한 쪽에서 조건 검사를 잊을 수 있음
  • 호출 지점에서 if를 사용해 항상 확인해야함 -> 보일러 플레이트 코드 증가
  • 다형적인 코드에 적용할 수 없다. 모든 타입을 아우르를 센티넬 값이 존재하지 않을 수 있다. 
  • 호출하는 쪽에서 정책이나 호출 규약을 지키도록 요구한다. mean 함수의 적절한 사용법은 호출자가 호출만 하는 것이다.

해결법 2 - 디폴트 값 제공

fun mean(xs: List<Double>, onEmpty: Double) =
	if (xs.isEmpty()) onEmpty else xs.sum() / xs.size

이렇게 호출 할 때 디폴트 값을 주게 할 수도 있다. 

이럴 경우

  • 호출하는 쪽에서, 함수 결과가 없는 경우(디폴트 값을 주는 경우)가 언제인지 이해해야한다.
  • 디폴트 값이 Double로 한정된다.
  • mean이 정의되지 않았을 때 특정 동작을 수행하게 하고 싶다면, 디폴트 값으로는 처리할 수 없다. 

 

4.3 Option으로 성공 상황 인코딩 하기

함수의 반환타입에 함수가 결과를 제공하는지, 실패나 예외상황인지를 명시적으로 표현하는 방법이다.
이 방법은 호출하는 쪽으로 오류 처리 전략을 미룬다고 할 수도 있다. 

이 때 사용하는 타입이 Option이다.

sealed class Option<out A>
data class Some<out A>(val get: A) : Option<A>()
object None : Option<Nothing>()

None은 정의 안됨, Some은 정의 됨을 표현한다. 

fun mean(xs: List<Double>): Option<Double> =
    if (xs.isEmpty()) {
        None
    } else {
        Some(xs.sum() / xs.size)
    }

Option을 사용하면 이렇게 mean을 정의할 수 있다. 반환 타입으로 결과 없음을 표시할 수 있게 되었다.

Option에  대한 기본함수

 Option은 3장에서 정의한 List에 원소가 하나만 들어가는 타입이라고 생각할 수 있다. 

List의 함수는 Option에도 상응하는 함수가 존재한다. 이런 함수들을 확장 함수로 구현해보자.

fun <A, B> Option<A>.map(f: (A) -> B): Option<B> =
    when (this) {
        is None -> None
        is Some -> Some(f(this.get))
    }

fun <A, B> Option<A>.flatMap(f: (A) -> Option<B>): Option<B> =
    when (this) {
        is None -> None
        is Some -> f(this.get)
    }

fun <A> Option<A>.getOrElse(default: () -> A): A =
    when (this) {
        is None -> default()
        is Some -> this.get
    }

fun <A> Option<A>.orElse(ob: () -> Option<A>): Option<A> =
    when (this) {
        is None -> ob()
        is Some -> this
    }

fun <A> Option<A>.filter(f: (A) -> Boolean): Option<A> =
    when (this) {
        is None -> None
        is Some -> {
            if (f(get)) this
            else None
        }
    }

기본적인 함수들을 구현해봤다. 이런 고차함수를 사용하는 시나리오를 살펴보자.

map

data class Employee(
    val name: String,
    val department: String,
    val manager: Option<String>
)

fun lookupByName(name: String): Option<Employee> = TODO()
fun timDepartment(): Option<String> =
    lookupByName("Tim").map { it.department }

timDepartment는 lookupByName으로 Option<Employee>를 받아 Employee의 department를 반환해주는 함수이다. 

timDepartment는 lookupByName의 결과를 검사할 필요가 없다. None이면 map의 결과도 None이기 때문이다. 

val unwieldy: Option<Option<String>> = 
	lookupByName("Tim").map {it.manager}

manager attribute로 map을 수행하면 Option<Option<String>>자료형이 나온다. 이런 상황에서 Option<String>을 반환받고 싶다면 flatMap을 사용하면 된다. 

val manager: Option<String> = lookupByName("Tim")
	.flatMap { it.manager }

filter

fiilter를 사용하면 성공적인 값이 술어를 만족하지 않을 때 성공을 실패로 변환할 수 있다. 

일반적으로 Option은 map, flatMap, filter 를 사용해서 변환한 후 getOrElse를 써서 오류 처리를 한다.

val dept: String = lookupByName("Tim")
    .map { it.department }
    .filter { it != "Accounts" }
    .getOrElse { "UnEmployed" }

이 식에서 Tim이 존재하지 않거나, department가 Accounts가 아니라면 default값인 "UnEmployed"를 반환하게 했다. 

각 연산에서 실패한다고 예외가 발생하지 않는다. 실패를 뜻하는 Option 타입인 None을 반환할 뿐이다.

그리고 각 단계마다 None을 검사하지 않는다. 변환과 연산을 다 적용한 후 마지막에 None 검사를 하면 되는 것이다. 

연습문제 4.2

flatMap을 이용해 variance 함수를 구현하라. 시퀀스의 평균이 m 이면 분산(variance)는 (x-m)^2의 평균이다. 
mean 함수를 사용해 구현할 수 있다.

fun mean(xs: List<Double>): Option<Double> =
    if (xs.isEmpty()) {
        None
    } else {
        Some(xs.sum() / xs.size)
    }
    
fun variance(xs: List<Double>): Option<Double> =
    mean(xs).flatMap { m ->
        mean(xs.map { x -> (x-m).pow(2) })
    }

 4.3.2 Option 합성, 끌어올리기, 예외기반 API 감싸기

Option 자료형을 사용하기 시작했으면 전체 코드기반을 Option으로 바꿔야할까?
파라미터나 반환타입을 모두 Option<A>이런식으로 바꿔야하는 것일까?

아니다. 일반함수를 끌어올려서 Option에 대한 함수로 만들 수 있다. 

Option의 확장함수인 map을 보자.

fun <A, B> Option<A>.map(f: (A) -> B): Option<B> =
    when (this) {
        is None -> None
        is Some -> Some(f(this.get))
    }

map을 사용하면 Option<A> 타입에 (A) -> B 함수를 사용해서 Option<B>를 얻을 수 있다. 

(A) -> B를 Option<A> -> Option<B>로 변환한다고 볼 수 있다.

fun <A, B> lift(f: (A) -> B): (Option<A>) -> Option<B> =
    { oa -> oa.map(f) }

val toInt: (Option<Double>) -> Option<Int> =
    lift { it.toInt() }

fun main() {
    println(toInt(Some(12.3)))
}

Double.toInt() 대신 Option<Double>.toInt()를 만들 필요가 없다. 있던 함수를 끌어올리면 된다. 

예제

자동차 보험회사의 웹사이트를 구현한다고 하자.
사용자가 자신의 정보(나이, 과속 티켓 개수)를 넣으면 보험 할인률을 계산해주는 페이지를 만들고 싶다.  

fun insuranceRateQuote(
    age: Int,
    numberOfSpeedingTickets: Int
): Double = TODO()

이렇게 정보를 넣으면 할인률(Double)을 반환해주는함수가 있다. 이 함수만 호출하면 된다. 

하지만 사용자 입력 폼은 문자열을 받기 때문에 정수로 파싱해야한다. 
정수가 아닌 문자열이 들어오면 NumberFormatException 발생시킨다. 

먼저 예외가 발생하면 None을 반환해주는 catches 함수를 작성하자.

fun <A> catches(a: () -> A): Option<A> =
    try {
        Some(a())
    } catch (e: Throwable) {
        None
    }

catches 함수를 사용해 타입 캐스팅 에러처리를 할 수 있다.

fun parseInsuranceRateQuote(age: String, speedingTickets: String) : Option<Double> {
    val optAge: Option<Int> = catches { age.toInt() }
    val optTickets: Option<Int> = catches { speedingTickets.toInt() }
    
    return insuranceRateQuote(optAge, optTickets) // !! 에러
}

exception을 잡는 것은 성공했지만, insuranceRateQuote는 Option 타입을 인자로 받지 않기 때문에 그대로 전달 할 수 없다. 
Option을 사용할 수 있게 끌어올려야한다. 

연습문제 4.3

두 Option 값을 이항함수를 통해 조합하는 제네릭 함수 map2를 작성하라. 두 Option 중 어느 하나라도 None이면 반환값도 None이다.

fun <A, B, C> map2(a: Option<A>, b: Option<B>, f: (A, B) -> C): Option<C> = 
    if (a is Some && b is Some) {
        Some(f(a.get, b.get))
    } else {
        None
    }
    
fun <A, B, C> map2(a: Option<A>, b: Option<B>, f: (A, B) -> C): Option<C> = 
    a.flatMap {  aa ->
        b.map {  bb ->
            f(aa, bb)
        }
    }

map2 함수를 이용해 parseInsuranceRateQuote를 구현할 수 있다. 

fun parseInsuranceRateQuote(age: String, speedingTickets: String) : Option<Double> {
    val optAge: Option<Int> = catches { age.toInt() }
    val optTickets: Option<Int> = catches { speedingTickets.toInt() }
    
    return map2 (optAge, optTickets) { a, t ->
    	insuranceRateQuote(a, t) 
    }
}

이렇게 하면 기존 insuranceRateQuote를 변경하지 않고도 Option 타입을 사용할 수 있다.

4.3.3. for comprehension 사용하기

map2 함수에서 flatMap과 map을 연쇄적으로 호출해 Option 값을 뽑아냈다.
이런 연산을 더 보기 쉽게 명령형 코드처럼 만들어주는 것이 for comprehension이다.

코틀린의 기본 요소로는 제공되지 않고 Arrow라는 라이브러리에서 지원된다. 코드를 보면 다음과 같다. 

fun <A,B,C> map2(oa: Option<A>, ob: Option<B>, f: (A, B) -> C) = 
	Option.fx {
    	val a = oa.bind()
        val b = ob.bind()
        f(a,b)
    }

이렇게 bind()를 통해 사용할 수 있다. 컴파일러는 각 식을 flatMap으로 바꾸고, 마지막 식은 결과를 돌려주는 map 함수로 변환한다.

fun <A, B, C> map2(a: Option<A>, b: Option<B>, f: (A, B) -> C): Option<C> = 
    a.flatMap {  aa ->
        b.map {  bb ->
            f(aa, bb)
        }
    }

위에서 정의한 map2 함수보다 읽기 편한 것을 볼 수 있다. 

 

4.4 성공과 실패를 Either로 인코딩하기

Option사용한 예외처리 방식은 실패와 예외를 일반적인 값으로 표현하는 것이었다.

하지만 Option은 예외적인 상황에서 무엇이 잘못된 것인지 알려주지 못한다. None을 반환할 뿐이다.

어떤 예외가 발생했는지 알고싶다면, Either 타입을 사용할 수 있다. 

sealed class Either<out E, out A>
data class Left<out E>(val value: E) : Either<E, Nothing>()
data class Right<out A>(val value: A) : Either<Nothing, A>()

Option 과 마찬가지로 sealed 클래스를 상속받는 2 가지 타입이 있지만, Either는 두 타입 모두 값을 유지한다는 데 차이점이 있다.

Right는 성공을 나타내고 Left는 실패를 나타낸다.

fun mean(xs: List<Double>): Either<String, Double> =
    if (xs.isEmpty())
        Left("mean of empty list!")
    else
        Right(xs.sum() / xs.size)

fun safeDiv(x: Int, y: Int): Either<Exception, Int> =
    try {
        Right(x / y)
    } catch (e: Exception) {
        Left(e)
    }
    
fun <A> catches(a: () -> A) : Either<Exception, A> =
    try {
        Right(a())
    } catch (e: Exception) {
        Left(e)
    }

이렇게 Either를 사용해 함수를 정의할 수 있다.

fun main() {
    val toInt: Either<Exception, Int> = catches1 { 
        "123".toInt()
    }
    when (toInt) {
        is Right -> {
            println(toInt.value)
        }
        is Left -> {
            toInt.value.printStackTrace()
        }
    }
}

사용할 때는 타입매칭을 통해 성공 실패 케이스를 구분해준다.

 

데이터를 검증하기 위해 Either 사용하기

data class Name(val value: String)
data class Age(val value: Int)
data class Person(val name: Name, val age: Age)

fun mkName(name: String): Either<String, Name> =
    if (name.isBlank()) Left("Name is empty.")
    else Right(Name(name))

fun mkAge(age: Int): Either<String, Age> =
    if (age < 0) Left("Age is out of range")
    else Right(Age(age))

fun mkPerson(name: String, age: Int): Either<String, Person> =
    map2(mkName(name), mkAge(age)) { n, a -> Person(n, a) }

이렇게 객체를 생성하는 메소드를 만들고 Either를 반환하게 만들었습니다. 

mkName과 mkAge를 사용해서 Person 객체를 만들때 따로 에러처리를 해주지 않아도 됩니다. 

fun main() {
    println(mkPerson("", 12))
    println(mkPerson("", -1))
    println(mkPerson("d", -1))
    println(mkPerson("John", 12))
}

각 케이스에 맞춰 에러처리가 되는 것을 볼 수 있습니다. 

+ Recent posts