Computer >> 컴퓨터 >  >> 스마트폰 >> iPhone

Swift의 map, filter, reduce 비하인드 스토리: 고차 함수의 동작 원리 파헤치기

함수는 입력값을 받아 어떤 처리를 수행한 뒤 출력을 만들어내는 존재입니다. 함수는 시그니처(signature)와 바디(body)로 구성되며, 같은 입력을 주면 항상 같은 출력을 얻게 됩니다. 이것이 함수(function)의 간단한 정의입니다.

고차 함수(Higher-Order Function)란?

이제 함수를 조금 더 깊이 들여다보겠습니다. Swift에서는 고차 함수(higher-order function)를 활용할 수 있습니다. 다른 함수를 입력으로 받거나 함수를 반환하는 함수를 고차 함수라고 부릅니다.

Swift 개발자라면 map, filter, reduce를 매일같이 사용하고 있을 것입니다. 이 함수들을 사용하다 보면 마치 마법처럼 느껴질 때가 있습니다. 하지만 내부에서 무슨 일이 일어나는지 제대로 이해하지 못하는 경우도 많습니다. 사실 이 세 가지 함수는 모두 함수형 프로그래밍(functional programming)의 아이디어와 접근 방식을 기반으로 동작합니다. Swift는 순수 함수형 언어는 아니지만, 함수형 프로그래밍 스타일로 코드를 작성할 수 있도록 충분히 지원합니다.

지금부터 이 세 함수가 내부적으로 어떻게 동작하는지 하나씩 살펴보겠습니다. 먼저 특정 데이터 타입에 맞는 기본 버전을 직접 구현해 본 뒤, 이를 어떤 타입에도 동작하는 제네릭(generic) 버전으로 발전시켜 보겠습니다.

Map 함수

정수 배열이 있고, 원본 배열의 각 요소에 델타(delta) 값을 더한 새로운 배열을 반환하는 함수가 필요하다고 가정해 봅시다. 간단한 for 반복문으로 다음과 같이 작성할 수 있습니다.

func incrementArray(_ array: [Int], by delta: Int) -> [Int] {
    var result: [Int] = []
    for element in array {
        result.append(element + delta)
    }
    return result
}

이번에는 원본 배열의 각 요소를 두 배로 만든 새로운 배열을 반환하는 함수가 필요합니다. 이 역시 다음과 같이 구현할 수 있습니다.

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

두 함수를 자세히 살펴보면 본질적으로 같은 일을 한다는 것을 알 수 있습니다. for 반복문 안의 로직만 다를 뿐입니다. 두 함수 모두 정수 배열을 입력받아, 반복문으로 각 요소를 변환하고, 새로운 배열을 반환합니다. 즉, 핵심은 각 요소를 새로운 값으로 변환(transform)하는 것입니다.

Swift는 고차 함수를 지원하기 때문에, 정수 배열과 변환 함수를 입력으로 받아 원본 배열의 각 요소에 변환 함수를 적용한 새로운 배열을 반환하는 함수를 작성할 수 있습니다.

func transform(_ array: [Int], using fn: (Int) -> Int) -> [Int] {
    var result: [Int] = []
    for element in array {
        result.append(fn(element))
    }
    return result
}

하지만 여전히 문제가 남아 있습니다. 위 함수는 정수 배열만 반환할 수 있습니다. 예를 들어 정수 배열을 문자열 배열로 변환해야 하는 요구사항이 생기면 이 함수로는 처리할 수 없습니다. 이를 해결하려면 어떤 타입에도 동작하는 제네릭 함수가 필요합니다.

Array 확장(extension)에 제네릭 함수를 구현하면 다음과 같습니다.

extension Array {
    func myMap<T>(_ transform: (Element) -> T) -> [T] {
        var result: [T] = []
        for element in self {
            result.append(transform(element))
        }
        return result
    }
}

구현 단계를 정리하면 다음과 같습니다.

  1. Array Extension에 제네릭 타입 T를 사용하는 map 함수를 선언합니다.
  2. 함수는 (Element) -> T 타입의 함수를 입력으로 받습니다.
  3. 함수 내부에 T 타입 데이터를 담을 빈 결과 배열을 선언합니다.
  4. for 반복문으로 자기 자신을 순회하면서 변환 함수를 호출해 각 요소를 T 타입으로 변환합니다.
  5. 변환된 값을 결과 배열에 추가(append)합니다.

이것이 바로 Swift에서 map 함수가 동작하는 방식입니다. 만약 우리가 map 함수를 직접 구현해야 한다면 위와 같은 형태가 될 것입니다. 결국 map은 배열에 마법을 부는 것이 아니라, 누구나 스스로 정의할 수 있는 함수일 뿐입니다.

Filter 함수

정수 배열에서 짝수만 남기고 싶다고 가정해 봅시다. 간단한 for 반복문으로 다음과 같이 구현할 수 있습니다.

func evenNumbers(from array: [Int]) -> [Int] {
    var result: [Int] = []
    for element in array where element % 2 == 0 {
        result.append(element)
    }
    return result
}

이번에는 프로젝트의 클래스 파일 이름들이 담긴 문자열 배열이 있고, 그중 .swift 파일만 남기고 싶다고 해봅시다. 이 역시 하나의 반복문으로 처리할 수 있습니다.

func swiftFiles(in names: [String]) -> [String] {
    var result: [String] = []
    for name in names where name.hasSuffix(".swift") {
        result.append(name)
    }
    return result
}

두 구현을 자세히 살펴보면 역시 같은 일을 하고 있다는 것을 알 수 있습니다. 배열의 데이터 타입만 다를 뿐입니다. 이를 일반화하면, 배열과 함수를 입력으로 받아 includeElement 함수의 결과에 따라 해당 요소를 결과 배열에 포함할지 결정하는 제네릭 filter 함수를 만들 수 있습니다.

extension Array {
    func myFilter(_ isIncluded: (Element) -> Bool) -> [Element] {
        var result: [Element] = []
        for element in self where isIncluded(element) {
            result.append(element)
        }
        return result
    }
}

Reduce 함수

정수 배열이 있고, 요소들의 합(sum)과 곱(product)을 반환하는 두 함수를 구현한다고 가정해 봅시다. 간단한 for 반복문으로 다음과 같이 작성할 수 있습니다.

func sum(of array: [Int]) -> Int {
    var result = 0
    for element in array {
        result += element
    }
    return result
}

func product(of array: [Int]) -> Int {
    var result = 1
    for element in array {
        result *= element
    }
    return result
}

이번에는 정수 배열 대신 문자열 배열이 있고, 모든 요소를 하나로 이어 붙이고 싶다고 해봅시다.

func concatenate(_ strings: [String]) -> String {
    var result = ""
    for string in strings {
        result += string
    }
    return result
}

세 함수 모두 본질적으로 같은 일을 합니다. 배열을 입력받고, 결과 변수를 초기화한 뒤, 배열을 순회하면서 결과 변수를 계속 업데이트합니다.

여기서 세 경우 모두에 적용할 수 있는 제네릭 함수를 만들 수 있습니다. 이를 위해서는 두 가지가 필요합니다. 결과 변수의 초기값과, 매 반복마다 결과 변수를 업데이트할 함수입니다.

따라서 제네릭 함수는 다음과 같은 정의로 구현할 수 있습니다.

extension Array {
    func myReduce<T>(_ initialResult: T,
                     _ nextPartialResult: (T, Element) -> T) -> T {
        var result = initialResult
        for element in self {
            result = nextPartialResult(result, element)
        }
        return result
    }
}

위 구현은 [Element] 타입의 어떤 입력 배열에도 동작하며, T 타입의 결과를 계산합니다. 동작하려면 결과 변수에 할당할 T 타입의 초기값이 필요하고, for 반복문 내에서 매 반복마다 결과 변수를 업데이트하는 데 사용될 (T, Element) -> T 타입의 함수가 필요합니다.

마무리

map, filter, reduce는 겉보기에 마법처럼 보이지만, 실제로는 반복문과 변환 로직을 추상화한 고차 함수일 뿐입니다. 이들의 내부 동작 원리를 이해하면 Swift의 함수형 프로그래밍 패러다임을 훨씬 깊이 있게 활용할 수 있습니다. 읽어주셔서 감사합니다!