본문 바로가기
Swift

고차함수에 관하여

by iOS 개린이 2023. 3. 6.

고차함수

-Swift에서 함수는 일급 객체로 취급되는데, 일급 객체의 특징 중 하나로 함수를 다른 함수의 인자로 받을 수 있다는 것이다.

고차함수는 함수를 매개변수로 갖는 함수를 말한다.

 

-Swift의 대표적인 고차 함수는 맵, 필터, 리듀스가 있다.

 

 

1. map(_:) 맵

-배열(Array)이나 리스트와 같은 컬렉션에 포함된 각 요소에 대해 특정 함수를 적용한 후,

그 결과를 모아 새로운 컬렉션을 생성하는 함수다.

클로저를 인자로 사용하고, 원래 배열의 각 요소에 해당 클로저 구현부를 적용한 결과를 반환해줌.

 

-Array, Dictionary, Set, Optional 에서 사용할 수 있다. (좀 더 구체적으로 말하면 Swift의 Sequence, Collection 프로토콜을 채택하는 타입과 옵셔널은 모두 맵을 사용할 수 있다.)

 

 

맵의 정의

 

@inlinable

-이 키워드는 컴파일러에게 이 함수를 inline 가능하게 만드는 지시를 제공한다.

즉, 이 함수를 호출하는 모든 위치에서 함수의 본문을 직접 삽입하도록 컴파일러에게 허용해주는 것.

특정 상황에서 성능 최적화에 도움이 된다.

 

public

-접근 제어 수준을 나타낸다. 어느 곳에서든지 이 함수에 접근할 수 있다는 의미.

 

func map<T>

-<T> 는 제네릭 타입을 의미하며, map 함수가 여러 종류의 데이터 타입으로 작동할 수 있음을 나타낸다.

 

(_ transform: (Element) throws -> T)

-transform은 함수의 매개변수임.

이 매개변수는 함수 타입이며, 'Element' 타입의 인자를 받아 T 타입의 결과를 반환하는 함수를 의미한다.

'throws' 키워드는 이 함수가 오류를 던질 수 있음을 나타낸다.

 

rethrows

-이 키워드는 인자로 받은 함수가 오류를 던질 수 있는 경우, 해당 함수도 역시 오류를 던질 수 있다는 것을 의미한다.

따라서, map의 매개변수인 'transform' 으로 전달받은 함수가 오류를 던지는 함수라면, map도 오류를 던질 수 있다는 것을 알리는 것이다. 

 

-> [T]

-이것은 map의 반환 타입을 나타낸다. 

따라서 map은 'T' 타입의 요소를 가지는 배열을 반환한다.

 

종합적으로, map은 'Element' 타입의 값을 'T' 타입의 값으로 변환하는 'transform' 함수를 인자로 받아,

이를 원본 배열의 각 요소에 적용한 후 그 결과를 모아 새로운 배열을 생성하여 반환한다.

제네릭 함수이기 때문에 다양한 데이터 타입에 대응할 수 있고, 오류를 던질 수 있는 변환 함수를 안전하게 처리할 수 있다.

 

근데 배열은 어떻게 map을 통해 요소 하나하나씩 계산을 하는걸까요?

이제 map의 실제 구현 부분은 어떻게 되어 있는지 알아보자.

 

맵의 실제 구현부

@inlinable public func map<T>(_ transform: (Element) throws -> T) rethrows -> [T] {
  
    // TODO: swift-3-indexing-model - review the following
    let n = self.count
    if n == 0 {
      return []
    }

    var result = ContiguousArray<T>()
    result.reserveCapacity(n)

    var i = self.startIndex

    for _ in 0..<n {
      result.append(try transform(self[i]))
      formIndex(after: &i)
    }
    
    _expectEnd(of: self, is: i)
    return Array(result)
  }

 

let n = self.count

-배열의 크기를 저장하는 부분이다.

 

if n == 0 { return [] }

-이 조건문은 원본 배열이 비어있는 경우를 처리한다.

만약 배열이 비어 있다면, 'transform' 함수를 적용할 요소가 없기 때문에 빈 배열을 반환한다.

 

var result = ContiguousArray<T>()

-'result' 는 결과를 저장 할 배열이다.

'ContiguousArray' 는 연속된 메모리 공간에 요소를 저장하는 배열이다.(일반 배열에 비해 성능적 이점이 있을 수 있음)

 

result.reserveCapacity(n)

-'result' 배열에 필요한 메모리 공간을 미리 확보하는 것이다.

이렇게 함으로써 배열에 요소를 추가할 때마다 메모리를 재할당하는 비용을 줄일 수 있다.

 

var i = self.startIndex

-'i' 는 원본 배열의 현재 인덱스를 저장하는 변수이다. 배열의 시작 인덱스로 초기화된다.

 

for _ in 0..<n { }

-이 반복문은 원본 배열의 모든 요소에 대해 'transform' 함수를 적용하기 위한 것이다.

 

result.append( try transform( self[i] ) )

-'transform' 함수를 현재 인덱스 요소에 적용하고, 결과 배열에 추가한다.

 

formIndex( after: &i )

-'i' 인덱스를 다음으로 이동시킨다.

 

_expectEnd( of: self, is: i )

-배열의 끝에 도달했는지 확인하는 코드.

디버깅을 위한 것이며, 배열의 끝을 넘어서 접근하는 것을 방지한다.

 

return Array(result)

-최종 결과물을 Array 타입으로 변환하여 반환한다.

 

이렇게 map의 실제 구현부를 뜯어봄으로써, 맵이 어떻게 사용되는 지 알아보았다.

우리는 이 개념을 이용하여 맵을 Custom하는 것도 가능할 것이다.

이제 맵을 사용해보자.

 

map의 사용

-map은 for문과 별 차이가 없기 때문에 for문 예시와 비교해서 보면 이해하기가 수월하다.(다른 고차함수들도 동일)

 

for문 예시

 

doubledNumbers 배열에는 numbers 배열에 있는 요소들에 * 2 해준 결괏값을 append 해주었고,

strings 배열에는 numbers 배열에 있는 요소들을 string으로 변환해서 append 해주었다.

이 코드를 map으로 사용해보자.

 

map을 통한 예시

 

map은 for문과 다르게 코드의 양이 확연하게 줄어든 것을 볼 수 있다.

closer의 경량표현을 통해 더 간결하게 표현해줄 수 있다.

 

 

더 간결해진 map 표현

 

매개변수, 반환타입과 반환 키워드를 생략하고, 후행 클로저까지 사용해주니 매우 간단해졌다.

 

또한 map이 가져올 수 있는 효과중에 코드의 재사용이 용이하다는 점이 있다.

하나의 클로저를 여러 map 메서드에서 사용하는 예시를 보자.

 

정리해보면, map은 기존 컨테이너 요소를 매개변수로 받아들이고, 정의한 클로저에 맞게 변환된 값을 반환해준다. 

map을 이용하면, for문보다 훨씬 간결한 표현이 가능하고, 코드의 재사용 측면에서 효율적이다.

 

 

 

2. filter(필터)

-이름의 뜻 그대로 어떤 값을 걸러내고, 추출하는 역할을 한다.

-주어진 컬렉션에서 특정 조건에 맞는 요소들만 선택하여 새로운 컬렉션을 반환한다.

 

필터의 정의

 

(_ isIncluded: (Element) -> Bool)

-함수 타입의 매개변수로, 'Element' 타입의 인자를 받아 Bool 타입의 결과를 반환하는 함수를 의미한다.

배열의 각 요소를 받아, 조건에 맞는지 결정하고, 'true' 를 반환했을 경우에는 결과 배열에 포함시켜준다.

 

필터의 실제구현부

public func filter(_ isIncluded: (Element) throws -> Bool) rethrows -> [Element] {
    var result = ContiguousArray<Element>()
    var i = self.startIndex
    for _ in self.indices {
        if try isIncluded(self[i]) {
            result.append(self[i])
        }
        self.formIndex(after: &i)
    }
    return Array(result)
}

 

코드를 보면, 맵과 굉장히 유사하게 구현되어 있다는 것을 알 수 있다.

 

 

var result = ContiguousArray<Element>()

-연속된 메모리 공간에 요소를 저장하는 배열이다.

 

var i = self.startIndex

-'i' 는 원본 배열의 현재 인덱스를 저장하는 변수이고, 배열의 시작 인덱스로 초기화된다.

 

for _ in self.indices { }

-'indices' 란 배열의 모든 인덱스를 나타내는 범위를 반환하는 것으로, 

예를 들어 [10. 20, 30, 40, 50].indices => 0..<5 와 같음.

이 반복문은 원본 배열의 모든 요소에 대해 'isIncluded' 함수를 적용하기 위한 것이다.

 

if try isIncluded( self[ i ] )

-isIncluded' 함수를 현재 인덱스의 요소에 적용하고, 'true' 를 반환하면 다음 단계('result' 배열에 추가)로 넘어간다.

 

result.append( self[ i ] )

-현재 요소를 결과물에 추가함.

 

self.formIndex( after: &i )

-'i' 인덱스를 다음으로 이동시킨다.

 

return Array(result)

-최종 결과물을 Array 타입으로 변환하여 반환한다.

 

맵은 원본 배열의 모든 요소를 변환하여 같은 크기의 새 배열을 만들어서 반환하지만,

필터는 특정 조건을 만족하는 요소들만을 선택하여 새 배열을 반환한다.

 

필터의 사용

 

numbers 안에 있는 요소 중 2로 나누었을 때, 나머지가 0인 요소들만 반환시켜준다.

필터도 맵과 동일하게 클로저의 경량 문법으로 표현을 간단하게 해줄 수 있다.(맵과 동일하기 때문에 생략)

 

필터는 이렇게 원하는 데이터들만 따로 추출하고 싶을 때 편리하게 사용이 가능하다.

 

맵과 필터의 연계 사용

-맵과 필터의 연계 사용으로 원하는 컨텐츠의 변형 후 필터링이 가능하다.

 

map 메소드를 이용한 결과물에 또 필터를 사용해주었다. 

위의 코드를 맵과 필터의 연계로 더 간략하게 표현이 가능하다.

 

맵과 필터의 연계

 

 

3. reduce(리듀스)

-컬렉션의 모든 요소를 하나의 값으로 줄이는데 사용된다. (결합이라고 봐도 좋음.)

-맵, 필터와 다르게 두 가지 형태로 구현되어 있다. 

 

첫 번째 형태의 리듀스 정의

 

첫 번째 형태의 리듀스는 초기값과 이진 함수를 인자로 받아 단일 출력값을 만들어 내는 형태이다.

 

func reduce<Result>

-reduce 함수의 정의와 'Result' 라는 제네릭 타입을 선언.

 

(_ initialResult: Result, _ nextPartialResult: (Result, Element) throws -> Result)

 

1. initialResult 

이 매개변수는 reduce 연산을 시작할 때 사용되는 초기값이다.

 

2. nextPartialResult

이 매개변수는 함수 타입으로, 이전 결과와 배열의 현재 요소를 인자로 받아 새로운 결과를 생성한다.

(오류를 던질 수 있는 함수)

 

-> Result

-reduce는 'Result' 타입의 값을 반환한다.

컨테이너의 요소가 없을 때는 initialResult의 값이 반환된다.

 

두 번째 형태의 리듀스 정의

 

두 번째 형태의 리듀스는 결과를 생성할 수 있는 형태의 값과 이진 함수를 인자로 받아서 결과의 형태에 맞는 값을 만들어 내는 형태이다.

 

(into initialResult: Result, _ updateAccumulatingResult: (inout Result, Element) throws ->())

첫 번째 형태의 리듀스와 비교했을 때, 딱 이부분만 다르다.

 

1. initialResult: Result

연산이 시작될 때, 사용할 초기값이다.

 

2. updateAccumulatingResult: (inout Result, Element) throws ->()

두 번째 매개변수로 반환 값이 없는 함수 타입이다.

 

이 함수의 첫 번째 매개변수는 'inout Result' 이다.

'inout' 키워드는 이 매개변수가 참조에 의해 전달되어 함수 내에서 이 매개변수가 가진 값이 변경될 수 있음을 나타낸다.

즉, 함수가 호출되면서 매개변수 값이 변경되면, 함수가 종료된 이후에도 변경된 값이 유지된다.

따라서 'inout Result' 는 'initialResult' 혹은 이전 단계의 연산 결과를 참조한다.

 

이 함수의 두 번째 매개변수는 'Element' 타입이다.

이는 원본 배열의 각 요소를 나타낸다.

 

updateAccumulatingResult 함수를 각 배열 요소에 적용하면서, 'initialResult' 값을 계속 업데이트 한다.

배열의 모든 요소를 순환하면, 최종 업데이트 된 'initialResult' 값을 반환한다.

 

첫 번째 형태 리듀스의 실제구현부

@inlinable public func reduce<Result>(_ initialResult: Result,
  _ nextPartialResult: (_ partialResult: Result, Element) throws -> Result
  ) rethrows -> Result {
  
  var result = initialResult
  for element in self {
    result = try nextPartialResult(result, element)
  }
  return result
}

 

간단하죠? 분석들어가보겠습니다.

 

var result = initialResult

-먼저 initialResult를 result 에 할당한다.

result 변수는 배열의 각 요소를 순회하며 업데이트 된다.

 

for element in self { }

-원본 배열의 각 요소를 순회한다.

 

result = try nextPartialResult(result, element)

-nextPartialResult 함수를 호출하고, 그 결과를 다시 result 변수에 할당한다.

 

return result

-원본 배열의 모든 요소들을 순회하며, nextPartialResult 함수를 실행한 후, 최종 result 값을 반환한다.

 

 

두 번째 형태 리듀스의 실제구현부

@inlinable public func reduce<Result>(
  into initialResult: Result,
  _ updateAccumulatingResult: (_ partialResult: inout Result, Element) throws -> ()
) rethrows -> Result {

  var result = initialResult
  for element in self {
    try updateAccumulatingResult(&result, element)
  }
  return result
}

 

 

try updateAccumulatingResult(&result, element)

-updateAccumulatingResult() 함수를 호출하고, 그 결과값을 result 변수에 할당한다.

아까 'inout' 키워드로 설정해둔 매개변수는 참조에 의해 전달되기 때문에 함수가 종료되도 변경된 값이 유지된다고 했죠?

따라서 계속 함수의 결과값을 result 값으로 변경하면서 인자로 들어가는 것입니다.

 

첫 번째나 두 번째나 리듀스 별거 없네요. 걍 하나하나씩 돌아가면서 연산 갈기는 거죠.

 

 

첫 번째 형태의 리듀스 사용

 

initialResult로 들어온 초기값은 0이다. 

그 다음 클로저는 nextPartialResult 매개변수죠?

여기서 첫 번째 매개변수인 result는 클로저가 처음 실행될 때 initialResult에 있는 초기값을 받는다고 했기에 처음에는 0이 전달된다. (이 다음부터는 이전 클로저의 결괏값이 전달됨.)

그리고 두 번째 매개변수인 next는 numbers에 있는 요소들이 차례대로 들어가는 것이다.

 

따라서 결과는? 

 

컨테이너(numbers) 안에 있는 요소들을 모두 결합한 값이 결괏값으로 나왔다. 

 

 

리듀스를 간략하게 표현도 가능.

 

문자열도 결합 가능.

 

 

두 번째 형태의 리듀스 사용

 

두 번째 형태의 리듀스는 반환값이 따로 없고, 대신 첫 번째 매개변수를 inout으로 설정하여 클로저 내부의 연산을 실행한다고 했다.

따라서 초기값으로 설정한 0이 처음 실행될 때 result 매개변수로 들어가고, 나머지 순환은 모두 이전 클로저의 결괏값이 들어간다.

 

두 번째 매개변수인 next는 동일하게 컨테이너 numbers에 있는 요소들이 차례대로 들어감.

결괏값은 첫 번째 형태의 리듀스 결괏값과 동일하게 나온다.

 

첫 번째 형태와 두 번째 형태의 리듀스 차이

1. 첫 번째 형태

이 리듀스는 결과를 계산하는데 사용되는 'nextPartialResult' 함수가 새로운 값을 반환한다.

새로운 값이 생기고, 이전 값이 변경되지 않는다는 점에서 순수한 함수형 프로그래밍 스타일에 가깝다.

 

2. 두 번째 형태

이 리듀스는 결과를 계산하는데 사용되는 'updateAccumulatingResult' 함수가 inout 매개변수를 통해 결과를 수정한다.

결과 타입이 참조 타입이거나 큰 값 타입(사이즈가 큰 배열이나 사전 등)인 경우에는 이 형태의 리듀스를 사용하는 것이 더 효율적일 수 있다.

 

왜 효율적일까?

 

-값 타입은 주로 스택에 할당되며, 변수가 복사될 때마다 새로운 메모리 공간에 복사본이 생성된다.

반면 참조 타입은 힙에 할당되며, 변수를 복사해도 원래 객체에 대한 참조만 복사되므로 새로운 메모리 할당이 발생하지 않는다.

 

따라서 두 번째 형태의 리듀스는 inout 매개변수를 통해 원래 객체를 수정하기 때문에,

매번 새로운 객체를 생성하거나 복사하는 비용을 줄일 수 있다.

 

 

결론 = 사용할 리듀스의 형태는 함수형 프로그래밍 스타일이나 성능 요구 사항에 따라 달라진다.

 

 

 

 

4. flatMap

-배열의 각 요소에 변환함수를 적용한 후, 반환된 배열들을 하나의 배열로 합치는 고차함수.

-'flatten' 이 '평평하게하다' 라는 의미를 가지고 있는데, map 함수의 기능에서 flatten 속성이 더해진 것이라 볼 수 있다.

-포장된 값을 받아서 값이 있으면 포장을 풀어 연산을 실행한 후 포장된 값을 반환하고,

값이 없으면 다시 포장해서 반환한다.

 

 

flatMap의 정의

@inlinable public func flatMap<SegmentOfResult>(_ transform: (Element) throws -> SegmentOfResult) rethrows -> [SegmentOfResult.Element] where SegmentOfResult : Sequence

 

func flatMap<SegmentOfResult>

-flatMap의 선언과, 'SegmentOfResult' 라는 제네릭 타입 매개변수를 가진다.

 

(_ transform: (Element) throws -> SegmentOfResult)

-transform은 Element' 타입의 인자를 받아 'SegmentOfResult' 타입의 결과를 반환하는 함수를 의미한다.

 

-> [SegmentOfResult.Element]

-flatMap 은 'SegmentOfResult.Element' 타입의 배열을 반환한다.

 

where SegmentOfResult : Sequence

-제네릭 타입의 매개변수 'SegmentOfResult' 가 'Sequence' 프로토콜을 준수해야 한다는 것을 의미한다.

'Sequence' 프로토콜은 특정 순서에 따라 요소를 순회할 수 있는 유형을 정의한다.

예를 들어 배열, 문자열 등은 모두 'Sequence' 프로토콜을 준수하는 타입이다.

 

-transform 함수의 반환 타입은  'SegmentOfResult' 다.

'SegmentOfResult' 는 'Sequence' 프로토콜을 준수해야 한다.

이 말은 즉, transform 함수가 시퀸스를 반환하고, 플랫맵은 이 시퀸스를 단일 배열로 합친다는 것이다.

 

 

실제 구현 부분은 못찾았어요ㅕ...알려주세요

 

flatMap의 사용

let nestedArray = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
let flatArray = nestedArray.flatMap { element in 
    return element
}
//결과는 [1, 2, 3, 4, 5, 6, 7, 8, 9]

 

-2차원 배열을 단일 배열로 평평하게 만드는 예시코드다.

매개변수인 'transform' 함수는 원본 배열의 요소 'element' 를 받고, 그대로 리턴해준다.

그럼 flayMap은 리턴 받은 'element' 를 단일 배열로 합쳐서 만들어 주는 원리.

 

-3차원이든, 4차원이든 모든 중첩 배열들을 단일 배열로 만들어 줄 수 있음!

 

 

 

 

4. compactMap

-컬렉션의 모든 요소에 변환을 적용한 후 nil값이 아닌 결과를 모아 새 컬렉션을 만드는 함수다.

(map과 filter를 합친 기능과 비슷함)

 

compactMap의 정의

@inlinable public func compactMap<ElementOfResult>(_ transform: (Element) throws -> ElementOfResult?) rethrows -> [ElementOfResult]

 

func compactMap<ElementOfResult>

-'ElementOfResult' 제네릭 타입의 매개변수를 받는 compactMap의 정의

 

(_ transform: (Element) throws -> ElementOfResult?)

-'Element' 타입의 인자를 받아 'ElementOfResult' 타입의 옵셔널 값을 반환한다.

 

 

-compactMap의 원리는 대략 이렇다.

1. 배열의 각 요소에 대해 transfrom 함수를 적용한다. 

2. transform 함수의 적용은 실패할 수 있으므로, 옵셔널 값을 반환한다.

3. 변환 결과가 nil인 요소는 필터링되고, 결과 배열에 포함되지 않는다. 

 

 

compactMap의 사용

 

1. 배열에서 nil값을 제거하기.

let optionalArray : [Int?] = [1, nil, 2, nil, 3, nil]
let filteredArray = optionalArray.compactMap{ $0 } 
//결과는 [1, 2, 3]

 

 

배열의 요소들을 하나씩 인자로 받고, 그대로 리턴해준다. 

compactMap의 내부 구현에서 nil값은 모두 필터링하고, 결과 배열을 반환한다.

 

 

2. 문자열 배열을 정수 배열로 변환.

let stringArray = ["1", "2", "three", "4", "5"]
let integerArray = stringArray.compactMap{ Int( $0 ) }
//결과는 [1, 2, 4, 5]

 

배열의 모든 요소에 대해 Int() 변환을 시도한다.

그 과정에서 실패한 "three" 문자열은 Int 타입으로 변환할 수 없기 때문에 nil 값이 리턴된다.

따라서 "three" 가 제외된 결과물이 반환된다.

 

 

flatMap과 compactMap의 차이

 

flatMap

플랫맵은 배열의 각 요소에 변환함수를 적용한 후, 결과를 단일 배열로 한다.

따라서 2차원 배열(중첩 배열)을 1차원 배열로 만드는 데 유용하다.

 

compactMap

컴팩트맵은 배열의 각 요소에 변환함수를 적용한 후, nil 값을 제거한다.

따라서 nil을 제거하거나, 변환을 적용하면서 실패할 수 있는 작업을 처리하는데 유용하다.

 

원래 flatMap은 다음과 같은 유형의 정의도 있었다.

public func flatMap<ElementOfResult>(_ transform: (Element) throws -> ElementOfResult?) rethrows -> [ElementOfResult]

 

compactMap의 정의랑 동일하다. 왜냐면 이 기능이 deprecated 되고, compactMap이 등장한 것이기 때문이다.

이유는 flatMap의 기능이 평평하게도 만들고, nil값을 제외시키기도 하다보니 네이밍과 맞지 않기도 하고, 복잡했다.

따라서 flatMap의 nil 제거 기능만 compactMap으로 분리한 것이다.

 

 

 

 

 

Reference :

-야곰님의 Swift 문법 개정 3판

-[Swift] 고차함수 (map, filter, reduce) (velog.io)

-[Swift] 스위프트의 고차함수들(High Order Functions) (tistory.com)

-[Swift] map 파헤치기. Collection에서 map 이 실제로 어떻게 구현되어 있을까요? | by naljin | Medium

 

'Swift' 카테고리의 다른 글

타입 캐스팅에 관하여  (0) 2023.03.13
모나드에 관하여  (0) 2023.03.10
Closer에 관하여2  (0) 2023.03.02
Closer에 관하여  (0) 2023.03.01
연산자 정리  (0) 2023.02.28