[Swift] Map 함수 구현해 보기

2018-09-14

개요

함수형 프로그래밍을 하면서 가장 자주 사용하는 함수라면 Map, Reduce, Filter를 들 수 있을 것 같습니다. 자주 사용은 하는데 실제 내부적으로 어떻게 구현돼 있는지, 직접 구현 할수는 없는지 궁금하지 않으신가요? 😄

이 포스트에서는 Map, Reduce, Filter 함수 중 Map함수를 어떻게 구현할 수 있는지 알아보도록 하겠습니다.

이 포스트를 제대로 이해하기 위해서는 Closure와 Generic에 대한 이해가 선행돼야 합니다. 만약 익숙하지 않으시다면 Swift 공식문서의 Closure(영어, 한국어)와 Generic(영어, 한국어) 섹션의 정독을 권해드립니다.

Map 함수의 동작

map함수를 실행하면 주어진 입력값을 변형해 반환합니다. 다시말해, map은 입력값을 변형(transforming) 해주는 함수입니다.

예를 들어, 입력값이 다음과 같다면

let array = [1,2,3,4,5]

이 입력값을 각각 1씩 증가시키거나, 2배로 만드는 기능을 수행하기 위해 다음과 같이 map함수를 사용할 수 있습니다.

let incresedResult = array.map{ $0 + 1 }
let doubledResult = array.map{ $0 * 2 }

map함수를 실행한 결과는 각각 다음과 같습니다.

incresedResult : [2, 3, 4, 5, 6]
doubledResult : [2, 4, 6, 8, 10]

그럼 지금부터 map함수를 어떻게 구현할 수 있는지 위 예제로부터 하나하나 추적해 나가 보겠습니다. map함수가 실행한 두 기능을 함수로 구현해 보겠습니다.

Map 함수의 구현

먼저 주어진 배열의 각 원소값을 1씩 증가시켜 반환하는 함수를 구현하면 다음과 같이 구현할 수 있을 것입니다.

func incrementArray(xs : [Int]) -> [Int] {
    var result = [Int]()
    
    for x in xs {
        result.append(x + 1)
    }
    
    return result
}

주어진 배열의 원소를 각각 순회하면서 원소에 1을 더한 값을 다른 배열에 넣어 그 배열을 반환합니다.

이제 배열의 각 원소값을 2배한 후 반환하는 함수를 구현해 보도록 하겠습니다.

func doubleArray(xs: [Int]) -> [Int] {
    var result = [Int]()
    
    for item in xs {
        result.append(item * 2)
    }
    
    return result
}

앞서 구현한 함수와 거의 비슷하고 내용중에 1줄만 다릅니다.

result.append(x + 1)
or
result.append(item * 2)

위 차이를 바탕으로 Int배열을 변환하는 공통된 함수를 정의해 보면 이렇게 만들 수 있을 것입니다.

func computeIntArray(xs: [Int]) -> [Int] {
    var result = [Int]()
    
    for item in xs {
        result.append(/* x에 관한 함수 */)
    }
    
    return result
}

x의 변화에 대한 내용을 함수로 입력받고, 실행하도록 구현하면 아래와 같이 구현할 수 있습니다.

func computeIntArray(xs: [Int], f: (Int) -> Int) -> [Int] {
    var result = [Int]()
    
    for x in xs {
        result.append(f(x))
    }
    
    return result
}

computeArrayf: (Int) -> Int 라는 Int를 각각 입력값과 출력값으로 갖는 함수 이고, 이 함수는 result.append()내에서 f(x)라는 함수로 실행됩니다.

이 함수를 이용해서 앞에 incrementArray , doubleArray 함수를 다음과 같이 구현할 수 있습니다.

func incrementArray(xs: [Int]) -> [Int] {
    return computeIntArray(xs: xs) { $0 + 1 }
}

func doubleArray2(xs: [Int]) -> [Int] {
    return computeIntArray(xs: xs) { $0 * 2 }
}

뭔가 그럴듯해 보입니다. 그러면 거의 끝난 것일까요? 아닙니다. map에서는 다음의 경우도 처리할 수 있습니다.

func isEvenArray(xs : [Int]) -> [Bool] {
    return computeIntArray(xs: xs) { $0 % 2 == 0 }
}

isEvenArray는 Int배열을 입력으로 받아 각 원소가 짝수인지 아닌지에 대해 판별한 Bool값을 결과 배열로 반환합니다. 이 계산에 computeIntArray 함수를 사용하면 타입에러가 발생합니다. 왜냐하면 이 함수는 Int -> Int를 인자로 사용하는데 isEvenArray 함수에서는 Int -> Bool을 필요로 하기 때문입니다.

그렇기 때문에 isEvenArray함수에서 computeIntArray를 사용하기 위해서는 computeIntArray를 다음과 같이 수정해야 합니다.

func computeIntArray(xs: [Int], f: (Int) -> Bool) -> [Bool] {
    var result = [Bool]()
    
    for x in xs {
        result.append(f(x))
    }
    
    return result
}

처음에 작성한 computeIntArray과 비교했을 때 일부값의 타입이 Int에서 Bool로 변경되었습니다. 반환값의 형이 다를 때마다 computeIntArray를 재정의 해서 사용해야 한다면 확장성이 없어 비효율적일 것입니다. 이럴 때 사용하라고 Swift에서는 Generic를 제공해줍니다. 😄 Generic을 사용해 computeIntArray를 다음과 같이 구현할 수 있습니다.

func genericComputeArray<U>(xs: [Int], f: (Int) -> U) -> [U] {
    var result = [U]()
    
    for x in xs {
        result.append(f(x))
    }
    
    return result
}

여기서 한발 더 나아가 [Int](Int)도 타입의 제한없이 사용할 수 있도록 generic 파라미터 T를 추가해보도록 하겠습니다.

func customMap<T, U>(xs: [Int], f: (T) -> U) -> [U] {
    var result = [U]()
    
    for x in xs {
        result.append(f(x))
    }
    
    return result
}

이제 저희가 구현한 customMap을 이용해 isEvenArraydoubleArray3를 구현하면 다음과 같습니다.

func isEvenArray(xs : [Int]) -> [Bool] {
    return customMap(xs: xs) { $0 % 2 == 0 }
}
    
func doubleArray3(xs: [Int]) -> [Int] {
    return customMap(xs: xs) { $0 * 2 }
}

두 함수의 반환 값이 각각 [Bool][Int]이지만 customMap함수를 generic으로 구현했기 때문에 두 함수 다 잘 동작하는 것을 확인할 수 있습니다.

[소스코드]

let array = [1,2,3,4,5]

let isEvenResult = isEvenArray(xs: array)
let doubleResult3 = doubleArray3(xs: array)

print("isEvenResult : \(isEvenResult)")
print("doubleResult3 : \(doubleResult3)")

[실행결과]

isEvenResult : [false, true, false, true, false]
doubleResult3 : [2, 4, 6, 8, 10]

여기서 끝이 아닙니다. map에 최대한 가까기 위해 마지막으로 한걸음 더 들어가 보겠습니다. map은 배열을 입력받아서 실행되지 않고 배열에서 직접 이루어 집니다. 이것을 지원하기 위해 Array 클래스에 customMap을 확장해 추가해 보도록 하겠습니다.

extension Array {
    func customMap<U>(f: (Element) -> U) -> [U] {
        var result = [U]()
        
        for x in self {
            result.append(f(x))
        }
        
        return result
    }
}

Array의 인자는 T대신 Element라는 지정된 파라미터를 사용합니다. 드디어 다 끝났습니다. 😎

Map 함수와 CustomMap 함수의 동작

이제 Swift에서 기본적으로 제공해주는 map의 동작과 저희가 구현한 customMap의 동작을 비교해 보도록 하겠습니다.

[소스코드]

let array = [1,2,3,4,5]

let mapResult1 = array.map{ $0 + 1 }
let customMapResult1 = array.customMap{ $0 + 1 }
    
let mapResult2 = array.map{ $0 * 2}
let customMapResult2 = array.customMap{ $0 * 2 }

let mapResult3 = array.map{ $0 % 2 == 0 }
let customMapResult3 = array.customMap{ $0 % 2 == 0 }
    
print("Map       #1 : \(mapResult1)")
print("CustomMap #1 : \(customMapResult1)")
print("==============================")
print("Map       #2 : \(mapResult2)")
print("CustomMap #2 : \(customMapResult2)")
print("==============================")
print("Map       #3 : \(mapResult3)")
print("CustomMap #3 : \(customMapResult3)")

[실행결과]

Map       #1 : [2, 3, 4, 5, 6]
CustomMap #1 : [2, 3, 4, 5, 6]
==============================
Map       #2 : [2, 4, 6, 8, 10]
CustomMap #2 : [2, 4, 6, 8, 10]
==============================
Map       #3 : [false, true, false, true, false]
CustomMap #3 : [false, true, false, true, false]

Swift에서 제공하는 map함수와 저희가 만든 customMap함수가 동일하게 동작하는 것을 확인할 수 있습니다.

결론

map함수는 어떤 마법의 함수가 아니라 우리 모두 직접 만들 수 있는 함수입니다. 모든게 그런 것 같습니다. 알면 쉽고 모르면 어려워 보이는. 😄

이상 map함수에 대해 알아보고 직접 구현해 보았습니다. 이후 포스트에서는 나머지 함수 reduce,filter함수도 직접 구현해 보겠습니다.



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

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