Swift에서 Monad와 Curried Function 사용하기

Functional Programing in Swift

Swift는 함수형 프로그래밍은 아니지만 함수형 언어들의 특성을 많이 따르고 있다. 그 중에 모나드와 커리드함수(부분적용함수)를 만드는 방법에 대해 살펴보자.

모나드(Monads)

모나드는 특정한 상태로 값을 포장한 것을 말한다. 예를 들어 하스켈의 Maybe 타입이 이에 해당하는데 이는 값을 “있을지도 없을지도 모르는 상태”속에 포장한다음, 이를 일반함수에 연결하는 것이다. 쉽게 말하자면

  1. 특정한 상태에 포장된 값이 있다.
  2. 일반값을 받고 포장된 값을 리턴하는 함수가 있다.
  3. 1을 2에 대입하면 포장된 상태 내부의 값을 조작하는 효과를 얻을 수 있으며, 계속해서 포장된 상태를 유지할 수 있다.

이를 위해 하스켈에서는 >>=이라는 연산자를 정의하고 있다. 이는 좌변의 값을 우변의 함수에 주입한다. 이를 통해서 함수를 연쇄적으로 적용하는 표현식을 만들 수 있다. Swift 버전의 >>=를 정의해보도록 하자.

연산자 정의

infix operator >>= { associativity left }
func >>= <T, U>(lhs:T, rhs:(T)->U) -> U {
    return rhs(lhs)
}

연산자를 위와 같이 정의했다. 이 연산자는 왼쪽우선이다. 예를 들어 다음과 같은 함수가 있다고 하면

func addOne(i:Int) -> Int {
    return i + 1
}

다음과 같이 쓸 수 있다는 이야기다.

let two  = 1 >> = addOne
let four = 1 >> = addOne >> = addOne >> = addOne

모나드 타입

하스켈의 Maybe 타입은 그 자체로 모나드이다. (하스켈에서는 타입 Maybe가 Monad 타입 클래스를 따른다고 말한다. 하스켈에서 타입클래스는 일종의 프로토콜이라 할 수 있다.) 이와 유사한 개념이 Swift에도 있으니 (아마도 하스켈에서 따왔을 것이라 보는데) 바로 옵셔널이다. Swift의 타입들은 기본적으로 nil 값을 가질 수 없는데, nil일 수 있는 (nil은 objective-C에서는 포인터의 개념이었으나, Swift에서는 “값이 없는 상태”를 의미한다) 케이스는 얼마든지 있으므로 모든 타입에 대해 옵셔널타입을 만들 수 있다. 따라서 옵셔널 타입은 “값이 있을 수도 없을 수도”있는 상태(혹은 컨텍스트)에 값을 포장해 넣은 상태이고, 이는 개념적으로 모나드와 동일하다.

막대를 들고 줄을 타는 사람을 모델링해보자. 막대의 양 끝에는 새가 앉을 수 있다고 가정한다. 만약 왼쪽과 오른쪽의 새의 수가 4마리 이상 차이나면 균형을 잡기 힘들어서 떨어진다고 하자. (떨어지고나면 막대가 존재하지 않으므로 nil이다) 이것을 표현하는 데이터 타입을 클래스나 구조체가 아닌 단순 튜플로도 설명할 수 있다.

(left, right)

이는 정수 2개로 이루어진 튜플이 되는데, 이에 별칭(typealias)를 붙여보도록 하겠다.

typealias Pole = (Int, Int)

그리고 막대에 새가 앉는 것을 두 개의 함수 landLeft, landRight로 표현해보자. 고전적인 C형태의 함수로는 다음과 같이 표현할 수 있을 것이다.

func landLeft(birds:Int, pole:Pole?) -> Pole? {
    if let (x, y) = pole {
        if abs((x + birds) - y) < 4 {
            return (x+birds, y)
        }
    }
    return nil
}

func landRight(birds:Int, pole:Pole?) -> Pole? {
    if let (x, y) = pole {
        if abs(x - (y + birds)) < 4 {
            return (x, y+birds)
        }
    }
    return nil
}

이제 빈 막대에서 다음과 같이 새가 앉은 후의 결과를 생각해보자.

  1. 왼쪽에 3마리
  2. 오른쪽에 2마리
  3. 오른쪽에 2마리
  4. 왼쪽에 1마리
  5. 왼쪽에 2마리

순서대로 앉음을 구현하면 다음과 같은 코드로 표현된다.

let poleA = landLeft(2, landLeft(1, landRight(2, landRight(2, landLeft(3, (0,0))))))

이건 너무 괄호 지옥이다. 이걸 앞서 만든 >>= 연산자를 사용해서 연쇄적으로 표기할 수 있을까? >>= 연산자를 사용하려면 (Pole?)->Pole? 타입의 함수가 필요하다. 그리고 이 함수는 결국 앞에서 정의한 두 함수를 호출해야 한다. 여기서 필요한 개념이 커링이 된다. 커링은 파라미터가 여럿있는 함수에 대해 파라미터를 일부만 적용한 중간단계의 함수를 만들 수 있는 것을 의미한다. 위 함수를 오버로딩해서 (Pole?)->Pole? 타입의 함수를 내놓는 부분적용 함수를 만들어 보자.

func landLeft(birds: Int) -> (Pole?) -> (Pole?) {
    let f:(Pole?)->(Pole?) = { pole in 
        return landLeft(birds, pole)
    }
    return f
}

func landRight(birds: Int) -> (Pole?) -> (Pole?) {
    let f:(Pole?)->(Pole?) = { pole in 
        return landRight(birds, pole)
    }
    return f
}

이 함수들은 첫번째 인자, 즉 새의 수를 받아서, 위에서 우리가 원하는 타입의 함수를 내놓는다. 그리고 같은 이름을 사용하기는 했지만, 시그니처 타입이 다르기 때문에 오버로딩되어 사용할 수 있다. 이제

let B = (0, 0) >>= landLeft(3) >>= landRight(2) >>= landRight(2) >>= landLeft(1) >>= landLeft(2)

이렇게 쓸 수 있다.

그런데 이런 식으로 함수를 체이닝하고 싶을 때마다 오버로딩해야 하는 불편할 수 있다. 공식문서에서는 아직 언급되지 않았지만 (이글의 작성일은 2014년 8월 22일) Swift는 각 파라미터들을 따로 괄호에 싸는 것으로 커링함수를 정의할 수 있다.

위 land… 함수들을 새로 정의해보자.

오버로딩이 아닌 완전히 새로 정의하는 것이다. 만약 playground에서 작성했다면 최초의 함수를 수정하고, 두 번째 함수를 삭제하라.

사실 함수의 본체는 똑같고 정의 부분만 “작은” 차이가 있다.

func landLeft(birds:Int)(pole:Pole?) -> Pole? {
//                     ~~~~~~~~~~~~
    if let (x, y) = pole {
        if abs((x + birds) - y) < 4 {
            return (x+birds, y)
        }
    }
    return nil
}

func landRight(birds:Int)(pole:Pole?) -> Pole? {
//                      ~~~~~~~~~~~~
    if let (x, y) = pole {
        if abs(x - (y + birds)) < 4 {
            return (x, y+birds)
        }
    }
    return nil
}

단지, 파라미터를 구분했을 뿐이다. 자, 이제 이 함수들은 이렇게 바뀐다.

  1. landLeft() 함수는 (Pole?)->(Pole?) 타입을 리턴하게된다.
  2. 이 때 생성되는 함수들은 반드시, 외부파라미터 이름을 사용해야 한다.
  3. 하지만 앞서 만든 >>= 연산자를 사용할 때는 외부 파라미터 이름을 쓰지 않아도 된다.

다음과 같이 테스트해보자.

let land2BirdsAtLeft = landLeft(2)
let poleC = land2BirdsAtLeft(pole:(0, 0)) // -> (2, 0)
let land5BirdsAtRight = landRight(5)
let poleD = land5BirdsAtRight(pole:poleC) // -> (2, 5)

따라서 위에서 사용한 모나드 체인이 그대로 적용될 수 있다.

let E = (0, 0) >>= landLeft(3) >>= landRight(2) >>= landRight(2) >>= landLeft(1) >>= landLeft(2)

이 예시는 파라미터 2개짜리였지만, 그 이상의 파라미터를 받는 경우에도 가능하다.

func sumOfThreeInts(first: Int, second: Int, last: Int) -> Int {

대신에

func sumOfThreeInts(#first: Int)(second: Int)(last: Int) -> Int {
    return first + second + last
}

이렇게 정의하면

let add2 = sumOfThreeInts(first:2) // (Int)(Int) -> Int
let add2And5 = add2(second:5) // (Int) -> Int
let sum = add2And5(last: 9) // 16

이렇게 사용할 수 있는 것이다.

관련 예제 코드