<코틀린을 다루는 기술> 3장의 내용을 요약한 글입니다.
순수 함수와 순수 효과 분리하기
프로그램은 "함수"라고 불리는 하위 프로그램으로 구성된다. 하위 프로그램은 외부 상태에 의존하는 부분과 의존하지 않는 부분으로 분리할 수 있다. 안전한 프로그램을 작성하기 위해서는 외부와 상호작용하는 순수 효과와 외부와 아무런 상호작용이 없이 계산만을 담당하는 순수 함수를 따로 분리해 작성해야한다.
-
순수 함수 : 외부 상태에 따른 부수 효과(side effect) 없이 오로지 인자에 의해서만 반환 값이 결정된다.
-
순수 효과 : 계산이 포함되지 않으며 값을 반환하지 않는 오로지 외부 세계와의 상호작용만 담당한다.
순수 함수와 순수 효과를 분리하는 것에서 더 나아가서 더욱 안전한 프로그램을 작성하고 싶다면 순수 효과를 테스트하기 쉬운 형태로 바꿔야한다. 순수 함수는 테스트하기 쉽지만 순수 효과는 그렇지 않다. 순수 효과를 쉽게 테스트하기 위해서는 효과가 아직 실행되지 않은 형태로 의도한 효과를 표현하는 데이터를 반환하는 함수를 사용해야 하는데, 이 방법은 12장에서 자세히 다룬다.
순수 함수
순수 함수에 대한 특징을 몇 가지 정리해보면 다음과 같다.
-
같은 입력에 대해서는 항상 같은 결과를 반환한다.
-
외부 요소에 의해 결과가 달라지지 않는다.
-
외부 요소를 변화시키지 않는다.
-
예외를 던지지 않는다.
-
함수를 보고 결과를 미리 예측할 수 있다.
-
함수의 결과 값으로 함수 호출을 대체할 수 있다.
순수 함수와 순수하지 않은 함수의 예 (1)
간단한 함수들의 예를 보면서 순수 함수와 순수하지 않은 함수를 구분해보자.
fun add(a: Int, b: Int): Int = a + b
add 함수는 외부와의 상호 작용 없이 오로지 인자에 의해서만 반환 값이 결정되는 순수 함수다.
fun mult(a: Int, b: Int?): Int = 5
mult 함수는 반환 값이 항상 일정한 상수 함수다. 상수 함수는 순수 함수다.
fun div(a: Int, b: Int): Int = a / b
Int 를 인자로 받는 div 함수는 b = 0 일 때, divide by zero 예외를 던진다. 따라서 이 함수는 순수하지 않은 함수다.
fun div(a: Double, b: Double): Double = a / b
반면, Double 을 인자로 받는 div 함수는 b = 0 일 때 Double 의 인스턴스인 Infinity 또는 -Infinity 를 반환한다. 이 함수는 예외를 던지지 않으면서 인자에 의해서만 반환 값이 결정되는 순수 함수다.
순수 함수와 순수하지 않은 함수의 예 (2)
또 다른 함수들의 예를 살펴보자.
var percent = 5
fun applyTax1(a: Int): Int = a / 100 * (100 + percent)
applyTax1 은 percent 값에 따라 결과가 달라진다. percent 는 var 로 정의된 가변 변수이므로 언제든지 값이 바뀔수 있다. 따라서 이 함수는 순수하지 않은 함수다.
fun applyTax2(a: Int, percent: Int): Int = a / 100 * (100 + percent)
반면, applyTax2 는 항상 인자 a 와 percent 에 의해서만 반환 값이 결정되는 순수 함수다.
class FunFunctions {
private var percent = 5
fun applyTax3(a: Int): Int = a / 100 * (100 + percent)
}
applyTax3 는 percent 값에 따라 결과가 달라진다. percent 는 클래스 내부에서만 접근 가능한 가변 변수이다. 이 클래스 내부에 percent2 값을 바꾸는 코드가 없기 때문에 순수 함수라고 할 수 있다. 그러나 이 함수는 안전하지 않다.
class FunFunctions {
val percent = 5
fun applyTax4(a: Int): Int = a / 100 * (100 + percent)
}
이 코드의 경우 percent 가 val 로 정의 된 불변 변수이며 applyTax4 는 항상 인자 a 에 의해서만 반환값이 결정된다. 이 함수는 안전한 함수이면서 순수 함수다.
순수 함수와 순수하지 않은 함수의 예 (3)
이번에는 List 를 사용하는 함수들을 살펴보자.
fun append1(i: Int, list: MutableList<Int>): List<Int> {
list.add(i)
return list
}
append1 은 외부에서 관찰 가능한 인자 list 를 변이시키는 부수 효과가 존재한다. 따라서 이 함수는 순수하지 않은 함수다.
fun append2(i: Int, list: List<Int>) = list + i
append2 는 인자 list 를 변이시키지 않고, 새로운 리스트를 만들어서 반환하는 순수 함수다.
Kotlin 함수의 두 가지 유형
코틀린 함수에는 fun 함수와 함수 값(함수 타입의 식으로 정의한 함수) 두 가지 유형이 있다. 앞서 본 함수들은 모두 fun 으로 정의한 함수였다.
fun 함수를 함수 값으로 표현하기
fun 함수를 함수 값으로 바꾸어보기 위해 아주 간단한 fun 함수를 정의해보자.
fun double(n: Int): Int = n * 2
이 함수를 함수 타입의 식으로 정의하려면 (즉, 함수 값으로 바꾸려면) 먼저 타입을 결정해야 한다. 함수의 타입은 (A) -> B 로 표기한다. A 는 입력받을 값의 타입이고 B 는 반환되는 값의 타입이다. double 함수의 입력 값과 반환 값의 타입은 모두 Int 이므로 함수 값의 타입은 (Int) -> Int 가 될 것이다.
val double: (Int) -> Int = { x -> x * 2 }
함수 값의 정의는 람다(lambda)식을 사용한다. 만약 식이 복잡해서 여러 줄에 걸쳐 작성한다면, 맨 마지막 줄의 값이 함수의 결과가 된다. 함수 값은 return 키워드를 사용하지 않음에 유의하자.
위와 같이 파라미터가 1 개만 존재한다면, it 이라는 이름을 사용해 그 파라미터를 가리킬 수 있다. double 을 다음과 같이 정의할 수도 있다.
val double: (Int) -> Int = { it * 2 }
파라미터가 2개 이상인 함수 값 표현하기
그렇다면, 파라미터가 2개 이상일 때는 어떻게 작성할까? 간단한 예를 보자.
val add: (Int, Int) -> Int = { x, y -> x + y }
함수 타입을 표기할 때는 인자를 괄호 안에 넣지만 람다식에서는 괄호를 쓰지 않는다는 점에 유의하자.
함수를 데이터로 이해하기
함수를 데이터와 비슷하다는 것을 이해해야 한다. 함수는 데이터와 같이 다음의 특징을 가진다.
-
함수도 타입이 있다.
-
함수를 참조에 대입할 수 있다.
-
함수를 다른 함수에 인자로 넘길 수 있다.
-
함수가 함수를 반환할 수 있다.
-
함수를 List, Map 등 자료구조에 저장할 수 있다.
함수 참조 사용하기
fun 함수를 람다 안에서 사용하는 방법을 알아보자.
위에서 정의한 double 함수를 함수 식에서 사용할 때, 함수 참조를 사용할 수 있다. 다음 코드는 람다 안에서 fun 함수를 사용할 때 작성할 수 있는 여러가지 방법을 나타낸다.
val multiplyBy2 = (Int) -> Int = { x -> double(x) }
= { double(it) }
= ::double // 함수 참조
함수 참조를 사용하면 코드를 매우 간결하게 작성할 수 있다.
다른 클래스의 인스턴스 함수 참조하기
만약, double 이 다른 클래스에 정의된 인스턴스 함수인 경우에는 어떻게 써야할까? 다음 코드를 보자.
class MyClass {
fun double(n: Int): Int = n * 2
}
fun main() {
val foo = MyClass()
val multiplyBy2 = foo::double // 외부 클래스의 인스턴스 함수를 참조
}
이 경우에도 간결하게 코드를 작성할 수 있다. 타입 추론에 의지하여 함수 값의 타입 또한 생략이 가능하다.
커리한 함수 작성하기
함수는 기본적으로 인자가 1 개만 존재하는 것을 다룬다. 인자가 여러 개일 때는 함수를 커링(currying) 하여 사용할 수 있다.
다음과 같은 함수가 있을 때,
f(x, y) = x + y
함수 f 를 커리한 함수로 변환하면, f(x)(y) = x + y 이러한 형태가 된다.
첫 번째 인자 x 에 값을 대입해보자. (x = 3 이라고 하자)
f(3)(y) = 3 + y
여기서 f(3) = g 라고 하면, g(y) = 3 + y 라는 식을 얻는다.
즉, 첫 번째 인자를 함수에 적용한 결과가 다시 새로운 함수가 된다는 것이다.
이제 두 번째 인자 y 에 값을 대입하여 최종 결과를 얻을 수 있다. (y = 2 라고 하자)
g(2) = 3 + 2 = 5
위 식을 코드로 표현해보자.
인자가 여러 개인 함수 처리하기
다음과 같이 인자를 두 개 받는 함수 add 가 있을 때, 이를 커리한 함수로 바꿔보자.
val add: (Int, Int) -> Int = { x, y -> x + y }
먼저 커리한 함수의 타입을 생각해보면, Int 를 인자로 받아서 (Int) -> Int 인 새로운 함수를 반환해야 한다. 이를 식으로 표현하면, (Int) -> (Int) -> Int 가 될 것이다.
val add: (Int) -> (Int) -> Int = { x -> { y -> x + y } }
이렇게 작성한 커리한 함수 add 는 다음과 같이 사용할 수 있다.
println(add(3)(5))
함수 부분 적용하기
커링한 함수를 사용할 때의 장점은 무엇일까? 물품 가격에 세율을 적용하는 함수를 예로 들어보자.
val taxRate = 0.09
val addTax: (Double, Double) -> Double = { taxRate, price -> price + price * taxRate }
println(addTax(taxRate, 12.0))
이 함수를 커리한 형태로 작성하면 다음과 같다.
val taxRate = 0.09
val addTax: (Double) -> (Double) -> Double = { taxRate -> { price -> price + price * taxRate } }
println(addTax(taxRate)(12.0))
튜플을 인자로 받는 함수와 이를 커링한 함수 모두 같은 결과를 내놓기 때문에 동일한 것으로 보인다. 그러나 두 버전에는 큰 차이가 있다.
튜플을 인자로 받는 형태는 함수를 실행할 때마다 모든 인자를 입력해야 하는 반면, 커링한 함수는 첫 번째 인자만 부분 적용하여 새로운 함수를 얻을 수 있다. 예를 들어, 여러 세율에 따른 세율 계산기(TaxComputer)를 만들어서 이를 재사용할 수 있다.
val tc09 = addTax(0.9)
println(tc09(12.0))
println(tc09(15.0))
val tc10 = addTax(1.0)
println(tc10(12.0))
println(tc10(15.0))
커리한 함수의 인자 순서 바꾸기
반대로, 동일한 가격에 대해 여러가지 세율을 적용해보고 싶을 수도 있을 것이다. 두 번째 인자만 부분 적용하려면 어떻게 해야할까?
커리한 함수의 두 인자를 서로 바꾼 새로운 함수를 반환하는 fun 함수를 작성해보자.
fun <T, U, V> swapArgs(f: (T) -> (U) -> V): (U) -> (T) -> V = { u -> { t -> f(t)(u) } }
swapArgs 함수를 이용하여 addTax 인자의 순서를 서로 뒤 바꾼 새로운 함수를 얻을 수 있다. 그렇게 얻은 함수의 첫 번째 인자(가격)만 적용하여 동일한 가격에 대해 여러가지 세율 적용해 볼 수 있다.
val addTax2 = swapArgs(addTax)
val price12 = addTax2(12.0)
println(price12(0.9))
println(price12(0.7))
swapArgs 함수만 있으면 커리한 함수의 두 인자 중 아무거나 부분 적용할 수 있기 때문에 매우 유용하다.
예를 들어, 이자율과 원금을 받아서 매달 납입금을 계산하는 함수가 있다고 하자.
val payment = { amount -> { rate -> . . . } }
첫 번째 인자인 원금을 고정시키고 이자율에 따라 월 납입액이 어떻게 달라지는지 계산할 수 있다. 반대로, 정해진 이율(두 번째 인자)에 대해 원금이 달라질 때마다 월 납입액을 계산하는 함수도 쉽게 만들 수 있다.
함수 합성하기
두 개 이상의 함수를 서로 합성해서 또 다른 함수를 만들어낼 수 있다. 함수 f, g 의 합성은 f º g 라고 쓴다.
다음과 같은 두 함수가 있다고 하자.
f(x) = x * x
g(x) = x + 3
두 함수를 합성하면
f º g(x) = f(g(x)) = (x + 3) * (x + 3) 가 된다.
함수의 합성을 코드로 표현해보자.
고차 함수(HOF: Higher Order Function) 작성하기
다음 두 함수 square 와 triple 함수를 합성하는 함수를 작성해보자.
fun square(x: Int): Int = x * x
fun triple(x: Int): Int = x * 3
두 함수를 합성한 compose 함수를 작성하기 위해 타입을 생각해보자. (Int) -> Int 형 함수 두 개를 파라미터로 받아서 (Int) -> Int 함수를 반환한다.
fun compose(f: (Int) -> Int, g: (Int) -> Int): (Int) -> Int = { x -> f(g(x)) }
함수 compose 와 같이 함수를 인자로 받거나, 함수를 결과로 반환하는 함수를 고차 함수라고 한다.
고차 함수 값 작성하기
이번에는 커리한 형태의 함수 값으로 작성해보자.
함수의 타입은 ((Int) -> Int) -> ((Int) -> Int) -> (Int) -> Int 가 될 것이다.
타입이 너무 길고 복잡하다면, 타입 별명(typealias)을 사용할 수 있다.
typealias IntUnaryOp = (Int) -> Int
val compose: (IntUnaryOp) -> (IntUnaryOp) -> IntUnaryOp = { x -> { y -> { z -> x(y(z)) } } }
이렇게 작성한 compose 를 square 와 triple 함수를 사용하여 테스트해보자.
val square: IntUnaryOp = { it * it }
val triple: IntUnaryOp = { it * 3 }
fun main() {
val squareRoundTriple = compose(square)(triple)
println(sqaureRoundTriple(2))
}
함수의 실행 순서는 triple 이 먼저 적용되고, 그 결과가 square 에 적용된다는 점에 유의하자. 결과는 36 이다.
다형적 고차 함수
위에서 정의한 고차 함수 compose 는 Int 에서 Int 로 가는 함수만 서로 합성할 수 있다. 타입 파라미터를 사용하여 다른 타입의 함수들끼리도 합성할 수 있도록 만들면 더욱 유용할 것이다. 다만, 첫 번째 적용하는 함수의 반환 타입이 두 번째 적용하는 함수의 파라미터 타입과 같아야 한다는 점에 유의해야 한다.
compose 함수를 파라미터화 하여 higherCompose 함수를 정의해보자.
코틀린은 다형적 프로퍼티(클래스나 패키지 내부에서 val 을 선언하는 것)를 제공하지 않기 때문에 함수 값으로는 다형적 고차 함수를 작성할 수 없다. 따라서, fun 함수로 작성하여 우리가 원하는 함수 값을 반환하도록 만드는 일종의 트릭을 쓴다.
fun <T, U, V> higherCompose(): ((U) -> V) -> ((T) -> U) -> (T) -> V =
{ x ->
{ y ->
{ z -> x(y(z)) }
}
}
이 함수는 항상 같은 값을 반환하는 상수 함수다. 이제 higherCompose 를 사용해 triple 과 square 함수를 합성할 수 있다. 단, 컴파일러는 T, U, V 의 타입을 추론 할 수 없기 때문에 직접 알려주어야 한다.
val squareRoundTriple = higherCompose<Int, Int, Int>()(square)(triple)
올바른 타입 사용하기
프로그래밍에서 Int, Double, String 과 같은 표준 타입을 사용하는 것은 흔한 일이지만, 너무 흔히 쓰는 타입을 사용하면 문제가 생길 수 있다. 가령, Double 값인 'price' 와 또 다른 Double 값인 'taxRate' 두 개를 실수로 더한다고 해도 컴파일러는 아무런 문제가 없다고 판단한다.
표준 타입의 문제점
구체적인 예제를 보면서 표준 타입을 사용할 경우 어떤 문제가 생기는지 살펴보자.
상품에 대한 주문을 처리하는 프로그램을 예로 들어보자.
먼저 상품을 표현하는 데이터 클래스를 정의하자. 이 클래스에는 상품 이름, 가격, 무게 정보를 담는다.
data class Product(val name: String, val price: Double, val weight: Double)
다음으로 상품 판매를 표현하는 데이터 클래스를 정의하자. 상품 이름, 수량, 전체 가격, 전체 무게 정보를 담는다.
data class OrderLine(val product: Product, val count: Int) {
fun weight() = product.weight * count
fun amount() = product.price * count
}
이제 위에서 정의한 두 개의 클래스를 이용하여 주문을 처리하는 프로그램을 작성할 수 있다.
object Store { // 싱글턴 객체
@JvmStatic
fun main(){
val toothPaste = Product("Tooth paste", 1.5, 0.5)
val toothBrush = Product("Tooth brush", 3.5, 0.3)
val orderLines = listOf(
OrderLine(toothPaste, 2),
OrderLine(toothBrush, 3))
val weight = orderLines.sumByDouble{ it.amount() }
val price = orderLines.sumByDouble{ it.weight() }
println("Total price: $price")
println("Total weight: $weight")
}
}
이 프로그램은 실행은 잘 되지만 결과가 잘못된 프로그램이다. 어디가 잘못되었는지 눈치를 챘는가?
9, 10 번째 줄의 weight 와 price 를 서로 뒤집어서 계산했다.
val weight = orderLines.sumByDouble{ it.amount() }
val price = orderLines.sumByDouble{ it.weight() }
두 값 모두 Double 이기 때문에 컴파일러는 이 오류를 알 수가 없다.
값 타입(value type) 사용하기
모델링의 오래된 원칙 중에는 클래스 하나에는 타입이 같은 프로퍼티가 여러 개 들어가서는 안 된다라는 규칙이 있다.
클래스에 같은 타입의 프로퍼티가 들어있는 것을 피하기 위해서는 값 타입을 사용하는 방법이 있다. 가격을 표현하는 값 타입을 다음과 같이 정의할 수 있다.
data class Price private constructor (val value: Double) {
override fun toString() = value.toString()
operator fun plus(price: Price) = Price(this.value + price.value)
operator fun times(num: Int) = Price(this.value * num)
companion object {
val identity = Price(0.0)
operator fun invoke(value: Double) =
if (value > 0)
Price(value)
else
throw IllegalArgumentException("Price must be positive")
}
}
operator 키워드로 plus(+) 와 times(*) 연산을 중위 연산자 위치에 사용할 수 있도록 정의하였다. 또한, 항등원으로 0 값을 사용할 때를 제외하면 Price 를 0 또는 그 이하의 값으로 생성할 수 없도록 하는 것이 안전하다. 이를 위해 비공개 생성자와 팩터리 함수를 사용하여 0 이하의 값을 생성하려고 시도할 때 에러를 던지도록 하였다.
무게를 표현하는 값 타입 또한 다음과 같이 정의할 수 있다.
data class Weight private constructor (val value: Double) {
override fun toString() = value.toString()
operator fun plus(weight: Weight) = Weight(this.value + weight.value)
operator fun times(num: Int) = Weight(this.value * num)
companion object {
val identity = Weight(0.0)
operator fun invoke(value: Double) =
if (value > 0)
Weight(value)
else
throw IllegalArgumentException("Weight must be positive")
}
}
이제 Product 클래스를 위에서 정의한 값 타입을 사용하여 다시 정의해보자.
data class Product(val name: String, val price: Price, val weight: Weight)
주문을 처리하는 프로그램도 다시 작성해보자.
object Store {
@JvmStatic
fun main() {
val toothPaste = Product("Tooth Paste", Price(1.5), Weight(0.5))
val toothBrush = Product("Tooth Brush", Price(3.5), Weight(0.3))
val orderLines = listOf(
OrderLine(toothPaste, 2),
OrderLine(toothBrush, 3))
val weight: Weight = orderLines.fold(Weight.identity) { a, b -> a + b.weight() }
val price: Price = orderLines.fold(Price.identity) { a, b -> a + b.amount() }
println("Total price: $price")
println("Totla weight: $weight")
}
}
이제 Price 와 Weight 타입을 섞어 사용하면 컴파일러는 오류를 알아챌 것이다. 가격과 무게 각각의 합계를 계산할 때 sumByDouble 을 더 이상 사용할 수 없기 때문에 fold 함수를 사용했다. fold 에 사용한 항등원 0 값은 동반 객체에 정의된 identity 를 가져와 사용해야 한다. 만약 invoke 를 통해 생성하려고 하면 IllegalArgumentException 을 던지므로 직접 생성은 불가능하다.
훨씬 더 안전한 프로그램이 되었다.
'Kotlin > 코틀린을 다루는 기술' 카테고리의 다른 글
7장. 오류와 예외 처리하기 (0) | 2020.07.31 |
---|---|
6장. 선택적 데이터 처리하기 (0) | 2020.07.23 |
4장. 재귀, 공재귀, 메모화 (0) | 2020.07.18 |
1장. 프로그램을 더 안전하게 만들기 (0) | 2020.07.04 |
댓글