[Swift] 커링(Currying)이란?
함수형 프로그래밍을 하다보면 커링(Currying)이라는 말을 종종 들어보셨을 겁니다. 이번 포스트에서는 이 커링에 대해 알아 보겠습니다. 포스트가 제법 깁니다. 😅
간단히 알고 넘어가실 분은 TL;DR만 읽으셔도 됩니다.
TL;DR
- 커링: 여러 인자를 사용하는 함수를 인자 하나만 사용하는 함수로 변환하는 것
- 커링의 장점
- 함수의 재사용성 향상
- 코드의 가독성 향상
이름의 유래
커링이라는 이름은 수학자이자 논리학자인 하스켈 커리(Haskell Brooks Curry)로 부터 유래 됐습니다.
네 맞습니다. 하스켈 언어도 이 사람의 이름에서 가져온 것입니다.
커링이란?
함수형 프로그래밍에서 커링이란
여러 인자를 입력받는 함수를 인자 하나만 입력받는 함수들의 시퀀스로 변환하는 것입니다.
변환된 함수는 인자 하나를 입력받고 값 대신 함수를 반환하는데, 이 반환되는 함수는 다음 인자를 입력으로 받습니다. 이 과정을 입력 값을 모두 처리하고 반환하는 값이 하나만 남을 때까지 반복합니다.
조금 복잡해 보이는데 다음 그림을 보시면 무슨 뜻인지 조금 감이 잡히실 것입니다.
일반 함수를 커링을 이용한 함수로 변환
위 그림이 어떤 의미인지 예제를 통해 살펴보겠습니다.
다음은 입력 받은 두 값을 더해 반환하는 함수 입니다.
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
가 입력되면 이미 입력받은 값 x
에 y
를 더한 값을 반환합니다. 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
를 반환하는 함수를 반환합니다.
그리고 그 반환된 함수는
입력받은 a
와 b
로 fn(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)
커링의 장점
지금까지 살펴본 예제 코드를 통해 커링 사용의 장점 두 가지를 알 수있습니다.
-
함수의 재사용을 쉽게 만들어 줌 (인자 중 변하지 않는 값은 고정시키고 변하는 값만 지정해서 함수를 실행할 수 있게 해줌)
-
가독성을 높여줌
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
시퀀스에 간단하게 마이그레이션 시킨 것을 확인할 수 있습니다.
이상으로 커링에 대해 알아봤습니다. 😄
[참 고]