Swift Array의 메소드, flatMap에 대해 알아보자

flatMap에 대해서 뭔지 모르겠다는 이야기들이 들려와서 나름대로 한 번 정리해본다.

map, flatMap

Array 타입의 메소드 중에는 map()이 있다.  특히 Swift를 함수형 언어적인 접근에서 바라보는 시각이 많은 요즘에는 너무나도 유명한 메소드이다. 이 메소드의 타입 정의는 다음과 같으며, 우리는 타입을 살펴보는 것만으로도 이 함수가 무슨 일을 하는지 알아낼 수 있을 것이다.

func map<T>(_ transform: @noescape (Element) throws -> T) rethrows -> [T] 

메소드의 타입정의만으로 하는 일을 예상해본다면,  transform이라는 인자는 Element 타입의 단일 값을 받아서 T타입의 값을 만드는 함수를 넘겨 받아서, T타입의 값들을 원소로 하는 새로운 배열을 만들어 낸다.

이때 배열 자기 자신이 바로 [Element]타입이기 때문에, 자신의 모든 원소에 대해서 변형 함수를 적용한 변형된 사본을 만들어낸다는 의미이다.

사실 이 맵핑이라는 동작이 완전히 새로운 것도 아니고, Swift만의 특징도 아니어서 좀 새삼스러운 측면이 있는데, 여기서는  map이라는 함수/메소드 그 자체 보다는 배열의 특성 자체에 대해서 집중해보도록하자.

하나의 배열은 여러 개의 값을 묶은 집합으로 흔히 사용된다. 하지만 또 다른 맥락에서 배열은 값이 하나 이상이될 수 있다는 계산적인 맥락을 내포한다. 즉 배열은 하나 이상의 값이 될 수 있도록 어떤 값을 둘러싸는 wrapper로 기능하고 있다는 점이다. 따라서 map은 수학적으로 어떤 “집합”에 대한 연산이 아니라 그 아래에 어떤 값을 내포할 수 있는 “계산의 맥락”에 대한 연산인 셈이다.

만약 어떤 값이 어떤 컨테이너나 래퍼에 의해 감싸져 있을 때, 그것이 비단 배열이 아니더라도 map과 같은 동작을 할 수 있는 경우가 더 있을 것이다. 이러한 이렇게 특정한 함수를 내부 구조에 사상할 수 있는 컨테이너를 수학에서는 functor라 부르며, 이것은 단순히 말그대로 “mapped over”될 수 있는 컨테이너라는 뜻이다.

Swift의 기본 타입 중에서 이러한 functor의 한 종류로는 옵셔널이 있다. 옵셔널은 기본적으로 여러 문법적 장식을 통해서 Swift에 녹아들어있기는 하지만 다음과 같이 열거 타입으로 정의되어 있다.

enum Optional<Wrapped> {
    case none
    case some(Wrapped)
}

이 때 .none은 우리가 흔히 알고 있는 nil이며 .some()은 nil이 아닌 값이 옵셔널이라는 컨테이너에 들어있는 것으로 보며, 옵셔널 컨테이너는 값이 있을 수도 있지만 없을 수도 있는이라는 의미를 추가적으로 갖는 컨테이너이다.

이 때 값이 없다는 것은 수학적인 의미이다. 예를 들어 let emptyString = ""이라는 표현에서 emptyString은 값이 없는 것이 아니라 비어있는 문자열 값을 갖고 있다. 왜냐면 emptyString.characters.count < otherString.characters.count라는 표현식을 사용할 수 있기 때문이다. 진짜 값이 없다면 이러한 비교 자체를 할 수 없어야 정상이다. 비슷한 예로 let a = 0를 보자. 0은 ‘없다’는 뜻의 값이기도 하지만, -1, 0, 1에서 볼 수 있듯이 정수 내 포함되는 분명히 존재하는 (정의된) 값이다. nil은 그와는 달리 어떤 정의된 값도 가지고 있지 않다는 뜻이다.

흥미로운 것은 옵셔널에 의해 감싸져 있는 어떤 값은 실제 값이며, 이 값을 조작하는 함수는 얼마든지 존재가능하다는 점이다. 따라서 옵셔널에 대해서도 map 동작은 수행할 수 있다. 개념상으로도 충분히 가능한 일이지만, 실제로도 가능하다.

func fmap<T, U>(_ v: T?, _ fn: (T) -> U) -> U? {
    guard let a = v else { return nil }
    return fn(a)
}

위와 같이 특정한 값가진 옵셔널내에 변환함수를 사상해주는 함수를 작성할 수 있으며, 실제로 옵셔널 타입은 다음과 같이 map 메소드를 가지고 있다.


let a: Int? = 5
let b = a.map{ $0 * 2 }
print(b)
/// Optional(10)

그렇다면 트리 구조는 어떨까? 트리 구조는 배열은 아니지만, 값이 일정한 규칙에 의해 연결되어 있으며 복수의 값이 감싸져 있는 컨테이너이다.

물론 트리구조는 Swift의 기본형은 아니기에 커스텀 타입으로 구현해야 하지만, 매우 쉽게 functor로 구현할 수 있다.

indirect enum Tree<T> {
    case leaf(T)
    case node(T, Tree<T>, Tree<T>)
}

extension Tree {
    func map<U> (_ transform: @noescape (T) throws -> U) rethrows -> Tree<U> {
        switch self {
        case let .leaf(v): return try .leaf(transform(v))
        case let .node(v, a, b): return try .node(tranform(v), a.map(transform), b.map(transform))
    }
}

let tree: Tree<Int> = .node(1, .node(2, .leaf(3), .leaf(4)), .node(5, .leaf(6), .leaf(7)))
let tree2 = tree.map{ $0 * 2 }

flatMap

그렇다면 그 외에 종종 볼 수 있는 flatMap은 무엇일까? 가장 쉽게 생각하면 flattenmap의 합성이다. Array 타입에는 실제로 flatten 메소드가 정의되어 있고, 이는 배열이나 Range를 원소로 갖는 배열을 (즉 2차 배열) 1차 배열로 풀어주는 역할을 한다.

let a = [[1,2,3],[4,5,6],[7,8,9]]
let b = a.flatten()
print(b)
/// "[1, 2, 3, 4, 5, 6, 7, 8, 9]

따라서 flatMap(T) -> [U] 타입의 transform 함수로 맵핑한 결과를 flatten하여 리턴하는 함수로 이해할 수 있다.

실제로 이러한 타입 시그니처를 가지고 있다.

func flatMap<SegmentOfResult: Sequence> 
    (_ transform: @noescape (Element) throws -> SegmentOfResult) 
    rethrows -> [SegmentOfResult.Iterator.Element]

다소 복잡해 보이기는 한데 풀어쓰면 다음과 같다.

  1. 이 메소드는 하나의 원소를 일련의 시퀀스로 변환하는 함수를 사상한다.
  2. 1차적인 결과로 원래의 배열로부터 배열의 배열, 즉 2차 배열이 만들어질 수 있다.
  3. 이 1차 결과물은 다시 하나의 배열로 연결되어 1차 배열로 변환된다.

이 과정을 단순하게만 바라보면 Array 혹은 Sequence 타입이 연결(concatenate)이 가능한 타입이기 때문에 이 동작이 가능하다는 것을 볼 수 있다. 즉 [1, 2, 3]과 [4, 5, 6]이 연결되어 [1, 2, 3, 4, 5, 6]이 될 수 있기 때문에 이러한 동작이 가능한 것 아니겠느냐는 것이다. 하지만 맵핑이 일어난 이후의 상황을 보면 정확하게는 [ [1, 2, 3], [4, 5, 6] ]이 [1, 2, 3, 4, 5, 6] 이 된 것이기 때문에 이는 단순히 연결가능한 타입에 대한 기능이 아니다.

엄밀하게 말하면 이는 배열 내 원소들이 어떤 컨테이너에 싸여 있을 때, 그 컨테이너를 벗겨낸 값들로 맵핑되는 동작이라고 봐야 한다. 이 관점에서 본다면 flatMap은 배열 뿐만 아니라 옵셔널타입에 대해서도 적용될 수 있다. 다음 코드는 셔널 값에 대해서 `(T) -> U?` 타입의 클로저를 맵핑했을 때, map, flatMap의 차이를 보여준다.

var a: Int? = 4 // Optional(4)
let q: (Int) -> Int? = { n in
    if n % 2 == 0 {
        return n + 3
    }
    return nil
}

let b = a.map(q)
print(b)
/// Optional(Optional(5))

let c = a.flatMap(q)
print(c)
/// Optional(5)

즉 옵셔널 내에 다시 옵셔널로 둘러싸진 결과를 내놓는 맵핑에 대해서 하위 옵셔널을 풀어낸 값이 나올 수 있게 하는 것이다. 결국 배열과 옵셔널은 모두 수학적으로 유사한 성질을 가지고 있으며 map, flatMap을 (약간 다른 관점으로 바라보게는 되지만) 똑같은 방식으로 적용할 수 있는 셈이다.

물론 컨테이너 속의 컨테이너라는 개념이 같은 컨테이너에 대해서만 적용되는 것은 아니다, 옵셔널의 배열에 대해서도 얼마든지 적용이 가능하다.

let a = [1,2,3,4,5]
let c : (Int) -> Int? = { n in 
    if n % 2 == 0 {
        return n * 2 
    }
    return nil
}

let b = a.flatMap(c)
print(b)
/// [4, 8]

결국 flatMap은 컨테이너의 깊이를 증가시키지 않고 map한 결과를 얻는 함수라고 이해하면 된다. 특히 위에서 살펴본바대로 T -> [U] 혹은 T -> U? 타입의 함수를 맵핑할 때 매우 유용하다.

예를 들어 일련의 숫자의 집합을 숫자값의 집합으로 바꾸는 과정을 생각해보자. 문자열을 정수값으로 변환하는 경우 Int.init?이 적용되기 때문에 변환한 값은 정수가 아닌 Int? 타입이 된다. 따라서 보통은 다음과 같은 코드를 작성하게 된다.

let words = ["123", "456.7", "eighty nine", "10", "100"]
let numbers = words.map{ Int($0) }.filter{ $0 != nil }.map{ $0! }

하지만 flatMap을 적용하면 보다 간단하게 코드를 작성할 수 있다.

let numbers = words.flatMap{ Int($0) }

정리

  • map 은 배열외에도 어떤 계산적 맥락에 대해 연산(함수)을 사상할 수 있는 기능으로 이해한다.
  • flatten 역시 배열 내의 계산적 맥락에 대해 각 원소를 벗겨내고(unwrap) 하나의 배열로 정리하는 기능이다. 이 때 배열로 표현된 상위 맥락 역시 다른 계산 맥락일 수 있다.
  • 따라서 T->[U] 외에 T->U? 같은 함수를 집합에 대해 사상해야 한다면, map 대신 flatMap을 사용하면 추가적인 필터, 맵 처리를 해주지 않아도 깔끔하게 정돈된 결과를 얻을 수 있다.