코틀린을 다루는 기술 3장을 읽고 정리한 내용입니다.
요약된 표현이 많기에 보다 자세한 설명은 책을 통해 확인하실 수 있습니다.

개요

함수의 출력이 결정적이고, 외부 상태에 의존하지 않는다면 더 쉽게 테스트하고 함수의 성질을 잘 추론할 수 있다.
외부 세계와 상호작용 하는 것을 '부수 효과'라고 하는데, 순수 함수는 이러한 부수 효과 없이 오로지 인자에 의해서만 반환 값이 결정되는 함수를 의미한다. 즉 순수 함수는 좀더 고급스런 용어로는 참조 투명성을 지키는 함수다.

부수효과, 참조 투명성 등 읽는 입장에서 왠지 거부감이 드는 한문 단어들은 사실 번역된 문장 대신 원문인 영어로 보면 더 이해하기 쉽다.

  • 부수효과 = side effect (부작용)
  • 참조 투명성 = Referential Transparency

이번 장에서는 순수 함수를 계산에 사용하는 방법과 커리한(curried) 함수를 사용하는 방법을 배운다. 커리 함수라는 용어를 나도 살면서 처음으로(?) 접했는데, 뭔지는 뒤에서 소개한다.

3.1 함수란 무엇인가

  • 함수의 정의
    • 수학적인 개념의 함수 : 소스 집합(정의역)과 타깃 집합(공역) 사이에서 어떤 조건을 만족시키는 대응 관계
    • 정의역의 모든 원소는 자신에 대응하는 원소는 공역에 있는 하나의 원소에 대응해야 함
  • 역함수
    • 역함수는 정의역과 치역이 역방향의 대응 관계를 가지는 함수. 함수에 역함수가 있을 수도 있고 없을 수도 있다.
    • f⁻¹(x) 기호로 나타냄
  • 부분함수
    • 정의역의 모든 요소가 아닌 일부분만 공역에 대응되는 함수, 전함수의 반대
    • 부분 함수를 전함수처럼 다뤄서 생기는 버그가 많다. ex) 1 / x, x = 0일 때
  • 함수의 합성
    • 두개 이상의 함수를 합성해서 다른 함수를 만들 수 있다.
    • f∘g(x) = f(g(x))
  • 인자가 여러개인 함수
    • 근본적으로는 존재하지 않는다. 함수는 정의역과 치역의 대응 관계로, 둘 이상의 정의역으로 대응 관계는 성립할 수 없기 때문이다.
    • 곱집합은 각 집합의 원소를 성분으로 가지는 튜플의 집합을 의미한다. 곱집합을 정의역으로 하는(튜플을 인자로 받는) 함수를 만들면 인자를 여러개 받는 함수처럼 보인다.

집합 {x, y, z}와 집합 {1, 2, 3}의 곱집합의 원소를 나열한 표

  • 커리한 함수
    • 인자가 여럿인 함수를 이런 식으로 표현할 수 있다.
    • f(x)f(y) = x + y``g(y) = x + y
    • f(x) = g
    • g(y)에서 x는 상수가 된다.
    • f(x)f(y)를 f(x, y)의 커리한 형태라고 한다.
    • 커리한 함수를 사용하면 두 인자를 따로 분리해 적용할 수 있다. 정의역을 함수에 적용한 순간 결과는 값이 아닌 새로운 함수가 된다. 이런 형태의 함수는 함수 인자를 평가할 때 유용하다.

3.2 코틀린 함수

데이터와 함수는 근본적으로 동일하다. 어떤 데이터든 실제로는 함수라 할 수 있고, 어떤 함수든 실제로는 데이터라 할 수 있다.
3.2에서는 함수를 만들고, 적용하고, 합성하는 방법을 정리한다.

  • 함수를 데이터로 이해하기

    • 함수도 타입이 존재하고, 다른 함수의 인자로 넘기거나 반환 타입으로 사용하는 등 데이터처럼 다룰 수 있다.
    • 참고 : fun으로 정의한 함수는 이런 방식으로 조작할 수 없음
  • 데이터를 함수로 이해하기

    • 마찬가지로 데이터를 함수의 형태로 다룰 수 있다.
    • 상수 함수 : 인자에 따라 반환하는 값이 달라지지 않는 함수
    • ex. fun five() = 5
  • 순수 함수의 조건

    • 함수 외부의 어떤 것도 변이시켜서는 안되고, 내부에서의 상태 변화를 외부에서 관찰할 수 없어야 한다.
    • 인자가 같으면 항상 같은 결과가 나와야 한다.
    • 인자를 변이시키면 안된다.
    • 예외나 오류를 던지면 안된다.
    • 항상 값을 반환해야 한다.
    • 외부 요소의 영향을 받지 않고, 인자가 같으면 항상 동일한 결과가 나오므로 순수 함수에 해당된다.
      • fun add(a: Int, b: Int): Int = a + b
    • b가 0일 경우 에러가 발생하게 된다. 순수 함수는 어떤 예외도 던지면 안되므로 순수함수가 아니다. 이 함수를 순수함수로 만들기 위해서는 예외가 발생했을 때 적절한 값을 반환시켜야 한다.
      • fun div(a: Int, b: Int): Int = a / b
    • percent의 데이터가 변조될 경우 결과가 달라질 수 있기 때문에 순수 함수가 아니다. 이 함수를 순수함수로 만들기 위해서는 percent를 immutable하게 바꿔야 한다.
      var percent = 5
      fun applyTax(a: Int) = 1 / 100 * (100 + percent)
  • 객체 표기법 vs 함수 표기법

    • 자신이 들어 있는 클래스 인스턴스에 접근하지 않는 함수는 클래스 밖으로(ex. companion object, top-level function) 빼내도 안전하다. 이렇게 하면 함수 내부에서 외부에 접근할 가능성을 원천 차단할 수 있다.
  • 함수 값 사용하기

    • 코틀린에서는 함수를 선언하는 두가지 방법을 제공한다. fun을 사용하는 방법, 함수 타입의 식을 선언하는 방법

    • fun으로 정의된 함수

      • 인자에 따른 반환 값을 얻는 일만 하는 함수라면 fun으로 정의하자 (불필요한 연산을 줄이기 위해서)
      fun double(x: Int) = x * 2
    • 함수를 데이터처럼 다루는 방법

      • 함수를 데이터처럼 취급하거나, 함수를 리턴해야할 때는 객체 정의하자 형태로 정의하자
      val double: (Int) -> Int = { x -> x * 2 }
  • 함수 참조 사용하기

    • 자바에서처럼 코틀린에서도 메서드 참조를 이용할 수 있음. 적용 시 람다식이 한층 더 간결해진다.
      val multi: (Int) -> Int = { n -> double(n) }
      val multi2: (Int) -> Int = ::double
  • 함수를 합성하기

    • f∘g(x) = f(g(x))
    fun compose(f: (Int) -> Int, g: (Int) -> Int): (Int) -> Int = { f(g(it)) }
    
    // usage
    val customFunction = compose(::fun1, ::fun2)
    customFunction(1)
  • 함수 재사용하기

    • 제네릭 타입을 사용해 compose를 다양한 타입에 대해 재사용할 수 있다.

3.3 고급 함수 기능

  • 인자가 여러개인 함수 처리하기

    • 함수 내부에서 또 다른 함수를 선언할 수 있는데, 이를 로컬 함수라 한다.
    fun main(args: Array<String>) {
        val function: (Int) -> ((Int) -> Int) = { a -> { b -> a + b } }
        println(function(3)) // Function1<java.lang.Integer, java.lang.Integer>
        println(function(3)(4)) // 7
    }
  • 고차 함수 (Higher-Order Function, HOF)

    • 고차함수는 함수를 인자로 받거나 반환타입으로 돌려주는 함수를 의미한다.

    • 함수를 합성하는 고차 함수 만들기

      fun main(args: Array<String>) { val compose = { x: (Int) -> Int -> { y: (Int) -> Int -> { z: Int -> x(y(z)) } } } val fun1: (Int) -> Int = { it * 2 } val fun2: (Int) -> Int = { it * 3 } val customFunction = compose(fun1)(fun2) println(customFunction(3)) // 18 }
  • 익명 함수

    • 한번만 사용할 함수라면 이름을 붙이지 않고 익명 함수로 선언할 수 있다.
    • 인자가 여러개 있을 때, 맨 마지막 인자로 람다를 쓸 수 있을 때는 람다식을 () 우측으로 빼놓을 수 있다.
    fun main(args: Array<String>) {
        val compose = { x: (Int) -> Int -> { y: (Int) -> Int -> { z: Int -> x(y(z)) } } }
        val result: (Int) -> Int = compose { it * 2 }() { it * 3 }
        println(result(3)) // 18
    }
  • 클로저 구현하기

  • 클로저는 내부 함수가 외부 함수의 맥락에 접근할 수 있는 것을 의미한다. 자바에서 람다식은 바깥쪽 scope에서 final로 선언된 변수에만 접근이 가능했으나, kotlin의 경우 가변 변수에도 접근이 가능하다.

  • 코틀린은 자신을 둘러싼 스코프의 변수에 접근이 가능

  • val taxRate = 1.0 fun addTax(price: Int) = price + taxRate * price

  • 클로저 대신 튜플을 인자로 받는 것이 좋다.

    • 커링을 적용했을 경우

      val taxRate = 0.09 
      val addTax = { taxRate: Double -> { price: Double -> price + price * taxRate } } 
      println(addTax(taxRate)(12.0))

      둘의 차이점은 부분 적용 여부다. 클로저를 사용하는 함수 버전은 (val로 정의됐으므로) 바뀌지 않는 파라미터에 대해 닫혀 있다. 커리한 버전이 클로저를 사용하는 버전보다 세율을 더 자주 바꾸지 않겠지만, 커리한 버전에서는 함수를 호출할 때마다 두 인자를 모두 바꿀 수 있다.

      val taxRate = 1.0 
      fun addTax(taxRate: Double, price: Int) = price + taxRate * price 
      // val addTax = { taxRate: Double, price: Double -> price + price * taxRate } //usage addTax(taxRate, 100)
  • 부분 적용 함수의 인자 뒤바꾸기

    • f(x)f(y)를 뒤집어서 f(y)f(x)로 사용하고 싶다면?

      fun <T, U, V> swapArgs(f: (T) -> (U) -> V): (U) -> (T) -> (V) = { u -> { t -> f(t)(u) } }
  • 항등 함수 정의하기

    • 중립 원소 : 덧셈의 0, 곱셈의 1, 문자열 연결의 "" 같은 연산에 사용할 중립적인 원소
    • 항등 함수 : 인자를 그대로 되돌려주는 함수
    • val identity: (Int) = { it }
  • 올바른 타입 사용하기

    • 표준타입을 사용하면 이름을 잘 짓더라도 컴파일러가 잘못된 동작을 정상으로 판단할 수 있다

    • 프로그램을 더 안전하게 만들기 위해 강력한 타입을 사용해야 함만약 개발자가 실수로 amount와 weight를 바꿔 쓰더라도 타입이 똑같이 때문에 컴파일러는 눈치채지 못함

      • 이런 문제를 예방하기 위해 표준 데이터를 사용하는 클래스를 데이터 클래스로 감싸 더 구체적으로 정의하자.

        data class Price(val value: Double) { 
        operator fun plus(price: Price) = Price(this.value + price.value) 
        }
        data class Weight(val value: Double) { 
        operator fun plus(weight: Weight) = Weight(this.value + weight.value) 
        } 
        data class Product(val name: String, val price: Price, val weight: Weight)
        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 
        }