Diana의 iOS 개발일기

[스위프트 프로그래밍 3판] - 15. 맵, 필터, 리듀스 본문

Swift/책 정리

[스위프트 프로그래밍 3판] - 15. 맵, 필터, 리듀스

Diana_iOS 2021. 3. 18. 11:28

맵과 필터 그리고 리듀스는 함수형 프로그래밍인 스위프트의 특징을 고스란히 가지고 있어 굉장히 재밌고 매력적인 고차함수들입니다.

이때 고차함수란 함수를 매개변수로 가지는 함수를 뜻하며 클로저(Closure)와 제네릭(Generic) 등의 중요 개념과 함께 사용되므로 해당 개념들에 대한 선행지식를 요구합니다.


[맵 - Map]

맵은 자신을 호출할 때 매개변수로 전달된 함수를 실행하여 그 결과를 다시 반환해주는 함수입니다. 약간 컨테이너 타입을 위한 간단한 산술 연산자와 비슷하다고 생각하시면 좋을 듯 합니다.

맵은 map 키워드를 사용하며 배열, 딕셔너리, 세트, 옵셔널 등에서 사용이 가능합니다.

 

맵은 사용하면 컨테이너가 담고 있던 각각의 값을 매개변수를 통해 받은 함수에 적용한 후 다시 컨테이너에 포장하여 반환합니다. 이때 기존 컨테이너 값은 변경되지 않고 새로운 컨테이너가 생성되어 반환되는데 따라서 맵은 기존 데이터를 변형(transform)하는데 많이 사용합니다.

let numbers: [Int] = [0, 1, 2, 3, 4]

var doubledNumbers: [Int] = [Int]()
var strings: [String] = [String]()

//for 구문 사용
for number in numbers {
    doubledNumbers.append(number * 2)
    strings.append("\(number)")
}

print(doubledNumbers)
print(strings)

//map 메서드 사용
doubledNumbers = numbers.map({ (number: Int) -> Int in //클로저
    return number * 2 //numbers의 값들을 받아 모두 2배를 취한 뒤 doubledNumbers에 할당
})
strings = numbers.map({ (number: Int) -> String in //클로저
    return "\(number)"
})

print(doubledNumbers) //[0, 2, 4, 6, 8]
print(strings) //["0", "1", "2", "3", "4"]

위의 예시의 경우, 16번째 줄의 doubledNumbers는 map을 사용하여 numbers의 값들을 받아 각각 2배를 취한 뒤 그 값들을  doubledNumbers에 할당해주었습니다.

따라서 doubledNumbers를 프린트하면 [0, 2, 4, 6, 8]의 결과가 나오게 됩니다.

 

7번째 줄의 for in 구문의 경우 map 메서드의 사용법과 별반 다르지 않지만 코드 재사용 측면이나 최적화 측면에서는 map을 사용하는 편이 더 유용합니다.

let evenNumbers: [Int] = [0, 2, 4, 6, 8]
let oddNumbers: [Int] = [0, 1, 3, 5, 7]
let multiplyTwo: (Int) -> Int = { $0 * 2 }

let doubledEvenNumbers = evenNumbers.map(multiplyTwo) //evenNumbers.map((Int) -> Int = { $0 * 2 })
print(doubledEvenNumbers) // [0, 4, 8, 12, 16]

let doubledOddNumbers = oddNumbers.map(multiplyTwo)
print(doubleOddNumbers) // [0, 2, 6, 10, 14]

위의 예제에서는 doubledEvenNumbers라는 상수에 evenNumbers의 값들을 2배하여 다시 넣어주었고 이를 프린트 하였습니다.

 

추가로 doubledEvenNumbers와 doubledOddNumbers 배열을 다시 보니 클로저 형식이 사용된 것을 알 수 있습니다. 클로저를 사용한 만큼 클로저 특유의 간략화가 가능하므로 한번 적용해보도록 하겠습니다.

doublesEvenNumbers = evenNumbers.map{ $0 * 2 }
print(doubledEvenNumbers)

 

여기까지, 맵(map)에 대한 설명이였고 아래는 맵을 배열 뿐만이 아닌 다른 다양한 컨테이너 타입에 적용한 예제입니다.

튜플이며 클로저며 앞에서 배웠던 내용들이 많이 섞여 녹아있네요.

let alphabetDictionary: [String: String] = ["a":"A", "b":"B"]

var keys: [String] = alphabetDictionary.map{ (tuple:(String, String)) -> String in
    return tuple.0 // 튜플의 첫 번째 인자
} // ["a", "b"]

keys = alphabetDictionary.map { $0.0 } // "매개변수.0" 즉, 매개변수의 첫 번째 인자라는 뜻입니다

let values: [String] = alphabetDictionary.map{ $0.1 } // ["A", "B"]
print(keys) // ["a", "b"]
print(values) // ["A", "B"]

var numberSet: Set<Int> = [1, 2, 3, 4, 5]
let resultSet = numberSet.map{ $0 * 2 }
print(resultSet) // [2, 4, 6, 8, 10]

let optionalInt: Int? = 3
let resultInt: Int? = optionalInt.map{ $0 * 2 }
print(resultInt) // 6

let range: CountableCloseRange = (0...3)
let resultRange: [Int] = range.map{ $0 * 2 }
print(resultRange) // [0, 2, 4, 6]

 


[필터- Filter]

우리가 아는 필터는 뭔가를 걸러주는 거름망을 의미합니다. 스위프트에서의 필터(Filter) 또한 말 그대로 컨테이너 내부의 값을 걸러서 추출하는 역할을 하는 고차함수이며 기존의 값을 변환해서 반환해주는 맵과는 다르게 기존 내용을 특정 조건에 맞게 걸러 새로운 컨테이너에 담아 반환해줍니다.

이번엔 약간 컨테이너 타입을 위한 간단한 조건문 정도로 이해하시면 될듯 합니다.

let numbers: [Int] = [0, 1, 2, 3, 4, 5]

let evenNumbers: [Int] = numbers.filter { (number: Int) -> Bool in
    return number % 2 == 0
}

print(evenNumbers) // [0, 2, 4]

let oddNumbers: [Int] = numbers.filter{ $0 % 2 ==1 }
print(oddNumbers) // [1, 3, 5]

필터 함수의 반환형은 Bool로 true와 false를 반환해줍니다.

 

위에서 살펴보았던 map 함수와 filter함수를 동시에 사용하면 매개변수를 받아 변환시킨 후 필터링을 할 수 있습니다.

let numbers: [Int] = [0, 1, 2, 3, 4, 5]

let mappedNumbers: [Int] = numbers.map{ $0 + 3 }

let evenNumbers: [Int] = mappedNumbers.filter { (number: Int) -> Bool in
    return number % 2 == 0
}
print(evenNumbers) // [4, 6, 8]

이것저것 적용해보니까 너무 재밌습니다. 그럼 좀더 나아가 위의 예제를 체인형식으로도 작성해보도록 하겠습니다.

let oddNumbers: [Int] = numbers.map{ $0 + 3 }.filter{ $0 % 2 == 1 }
print(oddNumbers) // [3, 5, 7]

[리듀스 - Reduce]

리듀스(Reduce)를 컨테이너 내부의 값들을 하나로 합하는 기능을 실행하는 고차함수입니다.

내부의 값들을 하나로 합한다? 즉, 하나의 결과 값을 두고 해당 값에 반복된 작업을 해준 뒤 마지막엔 그 값의 결과 값만 반환하는 것을 의미합니다.

 

스위프트의 리듀스는 두 가지 형태로 구현이 가능하며 첫 번째는 아래와 같이 클로저가 각각의 값들을 전달받아 연산한 후 값을 다음 클로저 실행을 위해 값을 반환하여 컨테이너를 순환하는 형태입니다.

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

참고로, 위의 예제에서 사용된 throws와 rethrows의 키워드는 예외처리를 할 때 사용해주는 키워드입니다. 여기서는 자세히 다루지 않으므로 예외처리를 위한 구문이라는 것만 이해하고 넘어가도록 하겠습니다.

 

두 번째 리듀스 메서드의 형식은 컨테이너를 순환하며 클로저가 실행하지만 클로저가 따로 결괏값을 반환하지 않는 형태입니다. 대신 inout 매개변수를 사용하여 초깃값에 직접 연산을 실행하게 됩니다.

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

 

첫 번째와 두 번째 형태 모두 쉽게 이해하기는 쉽지 않습니다. 따라서 아래의 예제를 작성해보며 이해를 해보겠습니다.

let numbers: [Int] = [1, 2, 3]

//초기값이 0이며 배열의 모든 값들을 더합니다
//result의 초기값은 reduce 키워드 바로 다음 값입니다
var sum: Int = numbers.reduce(0, { (result: Int, next: Int) -> Int in
	print("\(result) + \(next)")
    
    return result + next
})

print(sum) // 6

let substract: Int = numbers.reduce(0, { (result: Int, next: Int) -> Int in
	print("\(result) - \(next)")
    
    result result - next
})

print(substract) // -6

//초기값이 3이며 모든 배열의 값을 더합니다
//후행클로저를 사용하였습니다
let sumFromThress: Int = numbers.reduce(3) {
	print("\($0) + \($1)")
    
    return $0 + $1
}

print(sumFromThree) // 9

var substractFromThree: Int = numbers.reduce(3) {
    print("\($0) - \($1)")
    
    return $0 - $1
}

print(substractFromThree) // -3

let names: [String] = ["Chope", "Jay", "Joker", "Nova"]

let reducedNames: String = names.reduce("yagom's friend : "){
    return $0 + ", " + $1
}
print(reducedNames) // "yagom's friend : , Chope, Jay, Joker, Nova"

// 첫 번째 리듀스와 달리 클로저의 값을 반환하지 않고 내부에서 직접 이전 값을 변경합니다
sum = numbers.reduce(into: 0, { (result: inout Int, next : Int) in
    print("\(result) + \(next)")
    
    result += next
})

print(sum) // 6

// 초기값이 3이며 클로저의 값 반환 없이 내부에서 직접 이전 값이 변경됩니다
substractFromThree = numbers.reduce(into: 3, {
    print("\($0) - \($1)")
    
    $0 -= $1
}

print(substractFromThree) // -3

var doubledNumbers: [Int] = numbers.reduce(into: [1, 2]){ (result: inout [Int], next: Int) in
    print("result: \(result) next:\(next)")
    
    guard next.is else { // 빠른종료
        return
    }
    
    print("\(result) append \(next)")
    
    result.append(next * 2)
}

print(doubledNumbers) // [1, 2, 4]

//2의 배수인 값만 2배를 취해 넣어줍니다
doubledNumbers = [1, 2] + numbers.filter { $0.isMultiple(of: 2) }.map{ $0 * 2 }
print(doubledNumbers) // [1, 2, 4]

//이름을 모두 대문자로 변환하여 초깃값인 빈 배열에 직접 연산합니다
var upperCasedNames: [String]
upperCasedNames = names.reduce(into: [], {
    $0.append($1.uppercased())
})

print(upperCasedNames)

// 맵을 사용하여 위의 리듀스와 같은 기능을 구현하였습니다
upperCasedNames = names.map{ $0.uppercased() }
print(upperCasedNames)

46번째 줄의 "첫 번째 리듀스와 달리 클로저의 값을 반환하지 않고 내부에서 직접 이전 값을 변경한다"라는 의미는 무슨의미일까요?

 

이는 리듀스의 첫 번째 형태의 경우 리듀스 내부의 클로저는 리듀스 코드가 한번 진행됨에 따라 나온 결과 값을 반환해 주고 그 값을 다시금 해당 클로저에서 받아 클로저가 실행되지만, 두 번째 형태의 경우 리듀스 내부에서 값을 반환하지 않고 초기값의 변경만을 통해 코드가 진행되며 클로저의 결과 값은 리듀스가 최종 종료될 때 코드에 반영된다는 의미입니다.

 

해당 글은 야곰님의 스위프트 프로그래밍 3판 을 기반으로 한 정리 글이며 문제가 있을 시 삭제하도록 하겠습니다.

스위프트 프로그래밍 3판 eBook 구매 링크: www.yes24.com/Product/Goods/81530016

 

스위프트 프로그래밍 (3판)

문법을 넘어 프로그래밍 패러다임도 익히는 스위프트 5스위프트 5의 핵심 키워드는 ‘안정화’다. ABI 안정화 덕분에 버전과 환경에 크게 영향받지 않고 더 유연하게 스위프트를 사용할 수 있게

www.yes24.com

야곰 님의 블로그: blog.yagom.net

 

yagom's blog

야곰의 프로그래밍 블로그입니다. iOS, Swift, Objective-C, C에 대해 이야기합니다.

blog.yagom.net