호댕의 iOS 개발

[Swift] 고차함수 map, filter, reduce 본문

Software Engineering/Swift

[Swift] 고차함수 map, filter, reduce

호르댕댕댕 2021. 11. 21. 01:21

왜 고차함수를 사용해야 할까?

디바이스의 성능이 향상되면서 기기의 성능이 좋아지면서 동시에 여러가지 일을 처리할 수 있게 됐다. 

하지만 여기서도 문제가 발생한다.

 

하나의 객체에 동시에 여기저기서 접근하는 동시성 문제가 발생하게 되는 것이다. 

 

이를 해결하기 위해선 2가지 방법이 존재한다. 

  • 동시에 접근하더라도 차례대로 접근할 수 있도록 제어를 한다. 
  • 객체를 불변의 상태로 만들어서 언제나 복사해도 무방한 형태로 복사해서 사용을 한다. 

첫 번째 방법의 경우 연산이 너무 길어지는 단점이 존재한다. 따라서 성능 상으로 좋은 해결책은 아니라고 판단했다. 

다음 해결책을 수행하기 위해선 객체 별로 프로퍼티(상태)를 갖고 있는 객체 지향 프로그래밍이 오히려 단점으로 작용한다. 

 

따라서 이 경우를 위해 순수 함수를 사용하는 함수형 프로그래밍이 필요하다. 

(여기서 순수함수는 같은 입력 값이 들어오면 항상 같은 결과만 나오는 함수를 말한다)

 

 

이 함수형 프로그래밍에서 중요한 역할을 하는 것이 바로 고차함수이다. 

 

따라서 오늘은 고차함수에 대해 정리를 해보고자 한다. 

 

 

map

map을 한마디로 정리해보면 기존의 Collection, Sequence 요소에 접근해서 바꿔주는 메서드이다. 

 

Returns an array containing the results of mapping the given closure over the sequence’s elements.

공식 문서에서도 Sequence의 요소에 대해 주어진 클로저로 mapping한 결과를 배열로 반환한다고 설명하고 있다. 

 

선언은 다음과 같이 한다. 

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

요소를 클로저를 통해 mapping한 후 최종적으로 배열 형태로 반환하고 있는 것을 볼 수 있다. 

 

그렇다면 어떻게 사용하는 것인지 예제코드를 보며 자세히 알아보도록 하자. 

 

let names = ["yang", "kim", "lee", "park", "hwang"]
let upperCaseNames = names.map { name in
    name.replacingOccurrences(of: name.first!.description, with: name.first!.uppercased())
}

print(upperCaseNames) // ["Yang", "Kim", "Lee", "Park", "Hwang"]

names라는 String 배열에서 각 요소(name)에 접근해서 name의 첫 글자를 대문자로 바꿔서 배열 형태로 다시 반환하고 있다. 

 

예제는 배열로 작성했으나 Sequence에서 사용할 수 있기 때문에 String 또한 사용할 수 있다. 

(String도 Character의 Collection으로 Sequence이다) 

또한 선언부와는 다르게 후행 클로저를 사용해 조건을 작성해주었다. 

 

위 클로저는 아래처럼 축약해서도 작성할 수 있다. 

let upperCaseNames = names.map { $0.replacingOccurrences(of: $0.first!.description,
                                                         with: $0.first!.uppercased())}

다만 아직 클로저 문법에 대해 익숙하지 않다면 축약형보다는 매개변수와 반환 타입을 전부 작성해보는 것을 추천한다. 

 

이런 식으로 말이다. 

let upperCaseNames = names.map { (name: String) -> String in
    name.replacingOccurrences(of: name.first!.description,
                              with: name.first!.uppercased())
}

 

그렇다면 map 메서드를 직접 구현해보자.

func myMap<S: Sequence, T>(_ target: S , _ transform: (S.Element) -> T) -> [T] {
    var result: [T] = []
    
    for element in target {
        result.append(transform(element))
    }
    
    return result
}

let temp = [1, 2, 3, 4]
let temp2 = myMap(temp) { test in
    String(test) // ["1", "2", "3", "4"]
}

제네릭 타입인 S는 바꾸고 싶은 target의 타입으로 map은 Sequence의 요소에 접근하기 때문에 SSequence 프로토콜을 준수하도록 했다. 

또한 transformS의 요소를 다른 T라는 타입으로 바꿔주는 클로저 타입으로 설정해놓았다. T 또한 어떤 타입이든 들어갈 수 있는 제네릭 타입으로 구현해놓았다. 

 

map은 배열을 반환해주기에 [T]를 반환할 수 있도록 반환 타입을 줬다. 

 

그 후 for 문을 통해 target의 각 요소를 transform에 넣어주고 새로운 배열을 생성할 수 있도록 구현해주었다. 

 

작동은 map 메서드와 동일하게 됐으나 함수로 구현해준거라 메서드처럼 호출하진 않았다. 

 

 

filter

filter의 경우 기존의 Sequence 요소에서 조건을 충족하는 것들을 배열로 반환해주는 메서드이다. 

Returns an array containing, in order, the elements of the sequence that satisfy the given predicate.

공식문서에서도 주어진 predicate를 만족하는 sequence의 요소를 포함하는 배열을 반환한다고 설명하고 있다. 

 

선언은 다음과 같이 한다. 

func filter(_ isIncluded: (Self.Element) throws -> Bool) rethrows -> [Self.Element]

 

filter도 예제코드를 통해 살펴보자.

let deviceInformation: [String: Double] = ["A": 14.0, "B": 15.0, "C": 13.8, "D": 11.5]

let updateList = deviceInformation.filter { (name: String, iOS: Double) -> Bool in
    iOS <= 14.0
}.keys

print(updateList)

func sendUpdateMessage(to names: Dictionary<String, Double>.Keys) { // 배열처럼 보이지만 이렇게 타입을 작성해줘야 함.
    names.forEach { name in
        print("\(name)님, 업데이트 해주세요📱")
    }
}

sendUpdateMessage(to: updateList)

// D님, 업데이트 해주세요📱
// C님, 업데이트 해주세요📱
// A님, 업데이트 해주세요📱

여기서도 후행 클로저를 통해 iOS 버전이 14.0 이하인 사람들을 조건으로 걸고 있는 것을 볼 수 있다. 

(사람의 이름만 뽑아내기 위해 .keys를 사용했다)

 

그렇다면 filter 메서드도 직접 구현해보자.

func myFilter<T: Sequence>(_ target: T, _ transform: (T.Element) -> Bool) -> [T.Element] {
    var filteredResult: [T.Element] = []

    for element in target {
        if transform(element) {
            filteredResult.append(element) 
        }
    }

    return filteredResult
}

let temp3 = myFilter(temp) { element in
    element % 2 == 0
}

print(temp3) // [2, 4]

여기서 이런 식으로 구현하려고 하면 append를 사용할 수 없다는 오류를 마주치게 됐다. 

func myFilter<T: Sequence>(_ target: T, _ transform: (T.Element) -> Bool) -> T {
    var filteredResult: T

    for element in target {
        if transform(element) {
            filteredResult.append(element) 
        }
    }

    return filteredResult
}
Value of type 'T' has no member 'append' 

이런 오류였다. 그래서 Sequence의 정의부에 들어가서 'func append' 키워드를 검색해봤더니 append 함수가 정의되어 있긴 했다. 

하지만 Sequence의 정의부를 들어가도 Collection에 대한 문서가 나왔다. 

 

Sequence가 Collection보다 넓은 개념인 만큼 그렇다면 Collection을 채택하면 어떻게 될 지 궁금했다. 

func myFilter<T: Collection>(_ target: T, _ transform: (T.Element) -> Bool) -> T {
    var filteredResult: T

    for element in target {
        if transform(element) {
            filteredResult.append(element) 
        }
    }

    return filteredResult
}

하지만 동일한 오류가 발생했다. 

 

Array에서만 append가 가능하고 Set은 insert 메서드를 사용하고, Dictionary에서도 append 메서드가 없으니 그렇다면 Collection을 Array로 바꾸면 어떻게 될지 궁금했다. 

 

하지만 Array로 바꿔도 오류가 발생했다. 

For-in loop requires 'T' to conform to 'Sequence'

Sequence를 채택해도 안되는데 다시 Sequence를 채택해야 한다니... 

답답했다... 😱

 

답은 Array 공식 문서에 있었다. 

 

Array의 선언은 다음과 같다. 

Array<Element>

 

제네릭 타입으로 어떤 타입의 배열이든 만들 수 있었다. 

하지만 append 메서드의 설명을 보면 다음과 같이 적혀있다. 

Adds the elements of a sequence to the end of the array.

sequence의 요소 마지막에 추가를 해주는 메서드라고 설명이 나와있다. 

즉, 요소가 sequence의 요소여야 하는 것이다. 그래서 For-in loop requires 'T' to conform to 'Sequence' 이런 오류가 발생한 것이었다. 

그래서 제네릭 타입 T에는 Sequence 프로토콜을 준수하도록 했고, [T.Element]를 반환하도록 했다. 

 

(Filter를 설명하는 논점에서 약간 벗어나긴 했지만 구현하면서 배운 점이 많아 정리를 한다)

 

 

reduce

마지막으로 reduce 메서드이다. 

이 메서드는 sequence의 요소를 원하는 타입으로 반환하는 메서드 이다. 

 

공식문서에도 다음과 같이 설명하고 있다. 

Returns the result of combining the elements of the sequence using the given closure.

 

선언부는 다음과 같다. 

func reduce<Result>(_ initialResult: Result, _ nextPartialResult: (Result, Element) throws -> Result) rethrows -> Result

reduce에선 초기 결과와 다음 값을 작성하도록 하고 있다. 

 

그렇다면 실제 예시를 통해 살펴보자.

 

let numbers = [3, 5, 1, 9, 19, 43]
let multiplyEveryNumber = numbers.reduce(1) { firstNumber, secondNumber in
    firstNumber * secondNumber
}

print(multiplyEveryNumber) // 110295

초기 값부터 다음 요소의 값을 계속 곱해주고 Int 타입을 반환해주고 있다. 

 

그렇다면 reduce 메서드도 직접 구현해보자.

func myReduce<T:Sequence, S>(_ basic: S, _ target: T, operation: (S, T.Element) -> S) -> S {
    var reducedResult = basic
    
    for element in target {
        reducedResult = operation(reducedResult, element)
    }
    
    return reducedResult
}

let temp4 = myReduce(0, temp) { firstValue, secondValue in
    firstValue + secondValue
}

print(temp4) // 10

reduce 타입의 경우 무조건 기존의 타입의 요소를 반환 타입으로 갖는 것이 아니다. 

 

이는 이 예시에서 명확하게 보여진다. 

let temp5 = [1, 2, 3]
print(temp5.reduce("", { $0 + String($1) })) // 123

기존의 타입의 element는 Int였지만 반환 타입은 String으로 나올 수도 있는 것이다. 

 

그래서 반환 타입이 T.Element가 아닌 다른 타입 S로 구현해주었다. 

 

마치며

지금까지 클로저와 고차함수에 대해 알고는 있었지만 막연하게 축약형을 활용해 사용하고 있었는데 직접 매개변수(타입 포함)와 반환 타입을 직접 지정해주며 사용해보고, 고차함수를 만들어보기도 하니 확실히 이해가 잘 됐다. 

 

또한 기존에 고차함수에 대해 가지고 있던 오해도 해결할 수 있었다. 

(reduce는 무조건 sequence의 요소를 반환타입으로 갖는다고 생각했다...)

 

자주 사용하던 append 메서드에 대해서도 더욱 정확하게 알 수 있었던 좋은 계기였다. 

 

이 글을 보는 다른 분들도 클로저와 고차함수에 대해 아직 어색하다면 고차함수를 직접 만들어보시는 것을 추천한다. 

 

 

참고 문서

- https://developer.apple.com/documentation/swift/array/3017522-map

- https://developer.apple.com/documentation/swift/sequence/3018365-filter

- https://developer.apple.com/documentation/swift/array/2298686-reduce

- https://developer.apple.com/documentation/swift/array

 

Comments