[Swift] 커링(Currying)이란?

2020-01-07

함수형 프로그래밍을 하다보면 커링(Currying)이라는 말을 종종 들어보셨을 겁니다. 이번 포스트에서는 이 커링에 대해 알아 보겠습니다. 포스트가 제법 깁니다. 😅

간단히 알고 넘어가실 분은 TL;DR만 읽으셔도 됩니다.

TL;DR

  • 커링: 여러 인자를 사용하는 함수를 인자 하나만 사용하는 함수로 변환하는 것
  • 커링의 장점
    1. 함수의 재사용성 향상
    2. 코드의 가독성 향상

이름의 유래

커링이라는 이름은 수학자이자 논리학자인 하스켈 커리(Haskell Brooks Curry)로 부터 유래 됐습니다.

네 맞습니다. 하스켈 언어도 이 사람의 이름에서 가져온 것입니다.

HaskellBCurry


커링이란?

함수형 프로그래밍에서 커링이란

여러 인자를 입력받는 함수를 인자 하나만 입력받는 함수들의 시퀀스로 변환하는 것입니다.

변환된 함수는 인자 하나를 입력받고 값 대신 함수를 반환하는데, 이 반환되는 함수는 다음 인자를 입력으로 받습니다. 이 과정을 입력 값을 모두 처리하고 반환하는 값이 하나만 남을 때까지 반복합니다.

조금 복잡해 보이는데 다음 그림을 보시면 무슨 뜻인지 조금 감이 잡히실 것입니다.

Currying 일반 함수를 커링을 이용한 함수로 변환

위 그림이 어떤 의미인지 예제를 통해 살펴보겠습니다.

다음은 입력 받은 두 값을 더해 반환하는 함수 입니다.

func add2(_ x: Int, _ y: Int) -> Int {
    return x + y
}
add2(1, 2) // 결과: 3

위 함수를 커링하면 다음과 같이 변환할 수 있습니다.

func add2Currying(_ x: Int) -> ((Int) -> Int) {
    return { y in
        return x + y
    }
}
add2Currying(1)(2) // 결과: 3

두 함수 모두 같은 입력에 대해 같은 결과를 반환하는 것을 확인할 수 있습니다.

이제 커링을 사용한 함수와 사용하지 않은 함수를 비교해 세부적으로 살펴보겠습니다.

반환값

  • 커링 미사용
func add2(_ x: Int, _ y: Int) -> Int

x, y를 입력 받아 Int 값을 반환합니다.

  • 커링 사용
func add2Currying(_ x: Int) -> ((Int) -> Int)

x를 입력 받고 ((Int) -> Int) 과 같이 Int를 입력받아 Int를 반환하는 함수를 반환합니다.

구현

  • 커링 미사용
return x + y

입력받은 값을 바로 더해 반환 합니다.

  • 커링 사용
return { y in return x + y }

return x + y를 클로저로 감싸서 y가 입력되면 이미 입력받은 값 xy를 더한 값을 반환합니다. y가 입력되기 전에는 x값만 들고 있다가 y가 입력되는 순간 값을 계산해 반환합니다.

사용

  • 커링 미사용
add2(1, 2)

인자를 콤마로 구분해 함수를 실행합니다. 함수를 한개 실행하기 때문에 여닫는 괄호가 한번 뿐입니다.

  • 커링 사용
add2Currying(1)(2)

괄호를 분리해 두번 나눠서 실행합니다. 괄호가 두개인 이유는 함수를 두번 호출하기 때문입니다.

그럼이제 앞서 설명드린 세가지를 종합해서 커링함수가 동작하는 방식을 설명드리겠습니다.

func add2Currying(_ x: Int) -> ((Int) -> Int) {
    return { y in return x + y } 
}

위 함수는 앞에서 add2Currying(1)(2)의 형태로 호출했는데 이 함수를 다음과 같이 사용할 수 있습니다.

let threePlus = add2Currying(3)
threePlus(5) // 결과 8

여기서 threePlus값이 아니라 함수 입니다. 어떤 함수냐구요? Int값 하나를 입력 받아서, 3에다가 입력받은 Int값을 더해서 반환하는 함수입니다.

바로 이런 함수입니다.

{ y in return 3 + y }

이해가 되시나요? 그렇다면 조금 더 복잡한 케이스를 다뤄보면서 제대로 이해했는지 한번 더 확인해 보도록 하겠습니다.

이번에는 인자 3개를 입력받고 이 값을 모두 더해 반환하는 함수를 만들어 보겠습니다. 우선, 커링을 사용하지 않은 일반적인 함수입니다.

func add3(_ x: Int, _ y: Int, _ z: Int) -> Int {
    return x + y + z
}
add3(1, 2, 3) // 결과 : 6

위 함수를 커링을 사용해 구현하면 다음과 같습니다.

func add3Currying(_ x: Int) -> (Int) -> ((Int) -> Int) {
    return { z in
        return { y in
            return x + y + z
        }
    }
}
add3Currying(1)(2)(3) // 결과 : 6

add3Currying(_ x: Int) -> (Int) -> ((Int) -> Int)에서

첫번째 함수

(_ x: Int) 이 입력값이고 반환값은 Int -> ((Int) -> Int) 입니다.

두번째 함수

(Int)이 입력값이고 반환값은 (Int) -> Int 입니다.

세번째 함수

Int가 입력값이고 Int가 반환값 입니다.

각 함수를 함수별로 쪼개 보겠습니다.

func add3Currying(_ x: Int) -> (Int) -> ((Int) -> Int) {
    return { z in
        return { y in
            return x + y + z
        }
    }
}
let onePlus = add3Currying(1)
let threePlus = onePlus(2)
let result = threePlus(3)

onePlus는 다음과 같은 함수입니다.

    return { z in
        return { y in
            return 1 + y + z
        }
    }

threePlus는 다음과 같은 함수입니다.

    return { z in
        return 1 + 2 + z
    }

result는 다음의 결과 값을 반환합니다.

return 1 + 2 + 3

이제 좀 감이 오시나요?

그럼 이제 이 커링을 Generic을 사용해 일반화 된 함수로 정의해 보겠습니다.

func curry<A, B, C>(_ fn: @escaping (A, B) -> C) -> (A) -> (B) -> C {
        return { (a: A) in
            return { (b: B) in
                return fn(a, b)
            }
        }
    }

커링 함수는 함수(fn)를 인자로 받는데 이 함수는

(a: A) -> (B) -> C와 같이

(a: A)를 인자로 입력받고 (B)를 입력으로 받고 C를 반환하는 함수를 반환합니다.

그리고 그 반환된 함수는

(b: B) -> C와 같이

(b: B)를 인자로 입력받고 C를 반환하는 함수를 반환합니다.

그리고 그 반환된 함수는

입력받은 abfn(a, b)를 수행한 값을 반환하게 됩니다.

활 용

커링을 사용하면 함수를 재사용하는데 유용합니다.

함수가 여러 단계를 거쳐 실행되고 재사용될때 마지막 단계에서 변화가 있는 경우, 커링을 사용하면 앞의 단계는 고정시켜 놓고 가장 마지막 단계만 변경할 수 있어 효과적입니다.

예제를 보며 좀 더 자세히 알아보겠습니다.

enum LogLevel: String {
   case debug
   case error
}
func logMessage(level: LogLevel, message: String) {
   print("[\(level)] \(message)")
}

위 코드는 로그를 출력하는 함수 입니다. logMessage 함수에 로그 level과 로그message를 넣으면 로그가 출력됩니다.

커링을 사용하지 않은 일반적인 방법은 다음과 같이 사용하는 것입니다.

logMessage(level: .debug, message: "Log debug - Normal")

로그를 출력하는데 로그레벨은 변함이 없고 메시지만 변하게 되는 경우 어떻게 처리할 수 있을까요?

함수에 Default 파라미터를 지정할 수도 있지만, 그 경우는 Default 파라미터로 지정된 값만 생략 가능하기 때문에 적절한 해법이 될 수 없습니다.

이럴때 커링을 활용할 수 있습니다.

logMessage(level: .debug, message: "Log debug 1st")
logMessage(level: .debug, message: "Log debug 2nd")
logMessage(level: .debug, message: "Log debug 3rd")

위 코드대신 커링을 사용해

let logCurried = curry(logMessage)
let debug = logCurried(.debug)

debug("Log debug 1st")
debug("Log debug 2nd")
debug("Log debug 3rd")

debug("Message") 처럼 변하는 값만 인자로 넣어 사용할 수 있습니다.

또 다른 예를 살펴보겠습니다.

// Without Currying
let fruits: [String] = ["Apple", "Banana", "Peach", "Grape"]
        
fruits.forEach { fruit in
   logMessage(level: .debug, message: fruit)
}
        
fruits.forEach { fruit in
   logMessage(level: .error, message: fruit)
}

위 코드를 커링을 사용하면 다음과 같이 작성할 수 있습니다.

// with Currying
let debug = curry(logMessage)(.debug)
let error = curry(logMessage)(.error)        
fruits.forEach(debug1)
fruits.forEach(error1)

커링의 장점

지금까지 살펴본 예제 코드를 통해 커링 사용의 장점 두 가지를 알 수있습니다.

  1. 함수의 재사용을 쉽게 만들어 줌 (인자 중 변하지 않는 값은 고정시키고 변하는 값만 지정해서 함수를 실행할 수 있게 해줌)

  2. 가독성을 높여줌


RxSwift에서 응용

Observable에 다음과 같은 extension코드를 작성 후, completion 블럭을 사용하는 비동기 코드를 Observable 시퀀스에 마이그레이션을 커링을 사용해 쉽게 할 수 있습니다.

extension Observable {
    public static func fromAsync(_ asyncRequest: @escaping (@escaping (Element?, Error?) -> Void) -> Void) -> Observable<Element> {
        return Observable.create { (o) -> Disposable in
            asyncRequest { res, error in
                if let err = error {
                    o.onError(err)
                    return
                }
                guard let element = res else {
                    o.onCompleted()
                    return
                }
                o.onNext(element)
                o.onCompleted()
            }
            return Disposables.create {}
        }
    }
    
    public static func fromAsync<T>(_ asyncRequest: @escaping (T, @escaping (Element?, Error?) -> Void) -> Void) -> (T) -> Observable<Element> {
        return { param1 in Observable.fromAsync(curry(asyncRequest)(param1)) }
    }
}
let fetchPostObservable = Observable.fromAsync(PostService.fetchPosts(by:completion:))
        fetchPostObservable(10)
            .subscribe(onNext: { post in
                print("Fetching post with id:\(post.id) succeeded")
            }, onError: { error in
                print("Fetching post failed: \(error)")
            })
            .disposed(by: disposeBag)

struct Post {
    let id: Int
}

class PostService {
    static func fetchPosts(by postId: Int, completion: @escaping (Post?, Error?) -> Void ) {
        print("fetchPosts Closure")
        // post는 API를 통해 수신한 객체
        let post = Post(id: postId)
        completion(post, nil)
    }
}
let fetchPostObservable = Observable.fromAsync(PostService.fetchPosts(by:completion:))

이와 같이 기존에 completion block을 사용하던 코드를 Observable 시퀀스에 간단하게 마이그레이션 시킨 것을 확인할 수 있습니다.

이상으로 커링에 대해 알아봤습니다. 😄

[참 고]



[책] 토미의 Git with 소스트리

Git을 제대로 알고 싶으신 분들께 추천드립니다.