본문 바로가기
iOS/SWIFT

[Swift] Chapter 15. 맵, 필터, 리듀스

by 원만사 2021. 10. 10.
반응형

스위프트는 함수를 일급 객체로 취급한다. 따라서 함수를 다른 함수의 전달인자로 사용할 수 있다. 매개변수로 함수를 갖는 함수를 고차함수라고 부르는데, 스위프트에 유용한 대표적인 고차함수로는 맵, 필터, 리듀스 등이 있다.

 

15.1 맵(Map)


맵은 자신을 호출할 때 매개변수로 전달된 함수를 실행하여 그 결과를 다시 반환해주는 함수이다. 스위프트에서 맵은 배열, 딕셔너리, 세트, 옵셔널 등에서 사용할 수 있다. 더 정확히 말하자면 스위프트의 Sequence, Collection 프로토콜을 따르는 타입과 옵셔널은 모두 맵을 사용할 수 있다.

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

map 메서드의 사용법은 for-in 구문과 별반 차이가 없지만 코드의 재사용 측면이나 컴파일러 최적화 측면에서 본다면 성능 차이가 있다. 또, 다중 스레드 환경일 때 대상 컨테이너의 값이 스레드에서 변경되는 시점에 다른 스레드에서도 동시에 값이 변경되려고 할 때 예측하지 못한 결과가 발생하는 부작용을 방지할 수도 있다.

 

/*
	코드 15-1. for-in 구문과 맵 메서드의 사용 비교
*/

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) // [0, 2, 4, 6, 8]
print(strings)        // ["0", "1", "2", "3", "4"]



// map 메서드 사용
doubledNumbers = numbers.map({ (number: Int) -> Int in
    return number * 2
})
strings = numbers.map({ (number: Int) -> String in
    return "\(number)"
})

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

map 메서드를 사용하면 for-in 구문을 사용하기 위하여 빈 배열을 처음 생성해주는 작업이 필요 없다. 배열의 append 연산을 실행하기 위한 시간도 필요 없다.

클로저 표현식을 사용하여 map에서의 표현을 더 간략화해볼 수 있다.

 

/*
	코드 15-2. 클로저 표현의 간략화
*/

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

// 기본 클로저 표현식 사용
var doubledNumbers = numbers.map({ (number: Int) -> Int in
    return number * 2
})

// 매개변수 및 반환 타입 생략
doubledNumbers = numbers.map( {return $0 * 2} )
print(doubledNumbers) // [0, 2, 4, 6, 8]

// 반환 키워드 생략
doubledNumbers = numbers.map( { $0 * 2 })
print(doubledNumbers)

// 후행 클로저 사용
doubledNumbers = numbers.map { $0 * 2 }
print(doubledNumbers)

같은 기능을 여러 번 사용할 것이라면 하나의 클로저를 여러 map 메서드에서 사용하는 편이 좋을 것 같다. [코드 15-3]을 통해 재사용 가능한 코드로 재구성했다.

 

/*
	코드 15-3. 클로저의 반복 사용
*/

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)
print(doubledEvenNumbers) // [0, 4, 8, 12, 16]

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

 

map 메서드는 배열뿐만 아니라 여러 컨테이너 타입에 모두 적용이 가능하다. [코드 15-4]는 다양한 종류의 컨테이너에서 map 메서드를 실행해본 결과이다.

 

/*
	코드 15-4. 다양한 컨테이너 타입에서의 맵의 활용
*/

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

var keys: [String] = alphabetDictionary.map { (tuple: (String, String)) -> String in
    return tuple.0
}

keys = alphabetDictionary.map{ $0.0 }

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



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 - 경고가 발생하는 이유는 타입캐스팁의 tip에서 설명한다.

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

 

15.2 필터


필터는 말 그대로 컨테이너 내부의 값을 걸러서 추출하는 역할을 하는 고차함수이다. 맵과 마찬가지로 새로운 컨테이너에 값을 담아 반환해준다. 다만 맵처럼 기존 콘텐츠를 변형하는 것이 아니라, 특정 조건에 맞게 걸러내는 역할을 할 수 있다는 점이 다르다.

filter 함수의 매개변수로 전달되는 함수의 반환 타입은 Bool이다. 해당 콘텐츠의 값을 갖고 새로운 컨테이너에 포함될 항목이라도 판단하면 true를, 포함하지 않으려면 false를 반환해주면 된다.

 

/*
	코드 15-5. 필터 메서드의 사용
*/

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]

 

만약 콘텐츠의 변형 후에 필터링하고 싶다면 [코드 15-6]처럼 맵을 사용한 후에 필터 메서드를 호출하면 된다.

/*
	코드 15-6. 맵과 필터 메서드의 연계 사용
*/

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]

// mappedNumbers를 굳이 여러 번 사용할 필요가 없다면 메서드를 체인처럼 연결하여 사용할 수 있다.
let oddNumbers: [Int] = numbers.map{ $0 + 3 }.filter{ $0 % 2 == 1 }
print(oddNumbers) // [3, 5, 7]

 

15.3 리듀스(Reduce)


리듀스 기능은 사실 결합이라고 불려야 마땅한 기능이다. 리듀스는 컨테이너 내부의 콘텐츠를 하나로 합하는 기능을 실행하는 고차함수다. 배열이라면 배열의 모든 값을 전달인자로 전달받은 클로저의 연산 결과로 합해준다.

스위프트의 리듀스는 두 가지 형태로 구현되어 있다. 첫 번째 리듀스는 클로저가 각 요소를 전달받아 연산한 후 값을 다음 클로저 실행을 위해 반환하며 컨테이너를 순환하는 형태이다.

 

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

 

initialResult라는 이름의 매개변수로 전달되는 값을 통해 초깃값을 지정해 줄 수 있다. nextPartialResult라는 이름의 매개변수로 클로저를 전달받는다. nextPartialResult 클로저의 첫 번째 매개변수는 리듀스 메서드의 initialResult 매개변수를 통해 전달받은 초깃값 또는 이전 클로저의 결괏값이다. 모든 순회가 끝나면 리듀스의 최종 결괏값이 된다. 두 번째 매개변수는 리듀스 메서드가 순환하는 컨테이너의 요소이다.

 

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

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

 

updateAccumulatingResult 매개변수로 전달받는 클로저의 매개변수 중 첫 번째 매개변수를 inout 매개변수로 사용한다. updateAccumulatingResult 클로저의 첫 번째 매개변수는 리듀스 메서드의 initialResult 매개변수를 이용해 전달받은 초깃값 또는 이전에 실행된 클로저 때문에 변경되어 있는 결괏값이다. 모든 순회가 끝나면 리듀스의 최종 결괏값이 된다. 두 번째 매개변수는 리듀스 메서드가 순환하는 컨테이너의 요소이다. 상황에 따라서는 리듀스를 맵과 유사하게 사용할 수도 있다.

 

/*
	코드 15-7. 리듀스 메서드의 사용
*/

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

// 첫 번째 형태인 reduce(_:_:) 메서드의 사용

// 초깃값이 0이고 정수 배열의 모든 값을 더한다.
var sum: Int = numbers.reduce(0, { (result: Int, next: Int) -> Int in
    print("\(result) + \(next)")
    // 0 + 1
    // 1 + 2
    // 3 + 3
    return result + next
})

print(sum) // 6


// 초깃값이 0이고 정수 배열의 모든 값을 뺀다.
let subtract: Int = numbers.reduce(0, { (result: Int, next: Int) -> Int in
    print("\(result) - \(next)")
    // 0 - 1
    // -1 - 2
    // -3 - 3
    return result - next
})

print(subtract) // -6


// 초깃값이 3이고 정수 배열의 모든 값을 더한다.
let sumFromThree: Int = numbers.reduce(3) {
    print("\($0) + \($1)")
    // 3 + 1
    // 4 + 2
    // 6 + 3
    return $0 + $1
}

print(sumFromThree) // 9


// 초깃값이 3이고 정수 배열의 모든 값을 뺀다.
var subtractFromThree: Int = numbers.reduce(3) {
    print("\($0) - \($1)")
    // 3 - 1
    // 2 - 2
    // 0 - 3
    return $0 - $1
}

print(subtractFromThree) // -3

// 문자열 배열을 reduce(_:_:) 메서드를 이용해 연결시킨다
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




// 두 번째 형태인 reduce(into:_:) 메서드의 사용

// 초깃값이 0이고 정수 배열의 모든 값을 더한다.
// 첫 번째 리듀스 형태와 달리 클로저의 값을 반환하지 않고 내부에서
// 직접 이전 값을 변경한다는 점이 다르다.
sum = numbers.reduce(into: 0, { (result: inout Int, next: Int) in
    print("\(result) + \(next)")
    // 0 + 1
    // 1 + 2
    // 3 + 3
    result += next
})

print(sum) // 6

// 초깃값이 3이고 정수 배열의 모든 값을 뺀다.
// 첫 번째 리듀스 형태와 달리 클로저의 값을 반환하지 않고 내부에서
// 직접 이전 값을 변경한다는 점이 다르다.
subtractFromThree = numbers.reduce(into: 3, {
    print("\($0) - \($1)")
    // 3 - 1
    // 2 - 2
    // 0 - 3
    $0 -= $1
})

print(subtractFromThree) // -3

// 첫 번째 리듀스 형태와 다르기 때문에 다른 컨테이너에 값을 변경하여 넣어줄 수도 있다.
// 이렇게 하면 맵이나 필터와 유사한 형태로 사용할 수도 있다.
// 홀수는 걸러내고 짝수만 두 배로 변경하여 초깃값인 [1, 2, 3] 배열에 직접 연산한다.
var doubledNumbers: [Int] = numbers.reduce(into: [1, 2]) { (result: inout [Int], next: Int) in
    print("result: \(result) next : \(next)")
    // result: [1, 2] next : 1
    // result: [1, 2] next : 2
    // result: [1, 2, 4] next : 3
    
    guard next.isMultiple(of: 2) else {
        return
    }
    
    print("\(result) append \(next)")
    // [1, 2] append 2
    
    result.append(next * 2)
}

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


// 필터와 맵을 사용한 모습
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) // ["CHOPE", "JAY", "JOKER", "NOVA"]

// 맵을 사용한 모습
upperCasedNames = names.map { $0.uppercased() }
print(upperCasedNames) // ["CHOPE", "JAY", "JOKER", "NOVA"]

 

리듀스도 [코드 15-8]처럼 맵과 필터로 결합을 이룰 수 있다.

/*
	코드 15-8. 맵, 필터, 리듀스 메서드의 연계 사용
*/

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

// 짝수를 걸러내어 각 값에 3을 곱해준 후 모든 값을 더한다.
var result: Int = numbers.filter{ $0.isMultiple(of: 2) }.map{ $0 * 3 }.reduce(0) {
    $0 + $1
}
print(result) // 36

// for-in 구문 사용 시
result = 0

for number in numbers {
    guard number.isMultiple(of: 2) else {
        continue
    }
    
    result += number * 3
}

print(result) // 36

 

15.4 맵, 필터, 리듀스의 활용


[코드 15-9]와 [코드 15-10]은 목록의 친구들을 특정 조건으로 분류하여 콘솔에 출력하는 예제이다. 맵, 필터, 리듀스를 활용해보자.

 

/*
	코드 15-9. 친구들의 정보 생성
*/

enum Gender {
    case male, female, unknown
}

struct Friend {
    let name: String
    let gender: Gender
    let location: String
    var age: UInt
}

var friends: [Friend] = [Friend]()
friends.append(Friend(name: "Yoobato", gender: .male, location: "발리", age: 26))
friends.append(Friend(name: "JiSoo", gender: .male, location: "시드니", age: 24))
friends.append(Friend(name: "JuHyun", gender: .male, location: "경기", age: 30))
friends.append(Friend(name: "JiYoung", gender: .female, location: "서울", age: 22))
friends.append(Friend(name: "SungHo", gender: .male, location: "충북", age: 20))
friends.append(Friend(name: "JungKi", gender: .unknown, location: "대전", age: 29))
friends.append(Friend(name: "YoungMin", gender: .male, location: "경기", age: 24))

 

[코드 15-9]에 입력된 자료는 작년 자료다. 친구들의 나이는 실제 나이보다 한 살 더 적게 적혀 있고 이를 전제로 조건에 맞는 친구를 찾아보자. 조건은 "서울 외의 지역에 거주하며 25세 이상인 친구"이다.

 

/*
	코드 15-10. 조건에 맞는 친구 결과 출력
*/

enum Gender {
    case male, female, unknown
}

struct Friend {
    let name: String
    let gender: Gender
    let location: String
    var age: UInt
}

var friends: [Friend] = [Friend]()
friends.append(Friend(name: "Yoobato", gender: .male, location: "발리", age: 26))
friends.append(Friend(name: "JiSoo", gender: .male, location: "시드니", age: 24))
friends.append(Friend(name: "JuHyun", gender: .male, location: "경기", age: 30))
friends.append(Friend(name: "JiYoung", gender: .female, location: "서울", age: 22))
friends.append(Friend(name: "SungHo", gender: .male, location: "충북", age: 20))
friends.append(Friend(name: "JungKi", gender: .unknown, location: "대전", age: 29))
friends.append(Friend(name: "YoungMin", gender: .male, location: "경기", age: 24))

var result: [Friend] = friends.map{
    Friend(name: $0.name, gender: $0.gender, location: $0.location, age: $0.age + 1)
}

result = result.filter { $0.age >= 25 && $0.location != "서울" }

let string: String = result.reduce("서울 외의 지역에 거주하며 25세 이상인 친구") {
    $0 + "\n" + "\($1.name) \($1.gender) \($1.location) \($1.age)세"
}

print(string)

 

 

스위프트 프로그래밍: Swift 5 - 교보문고

객체지향, 함수형, 프로토콜 지향 패러다임까지 한 번에! | 스위프트를 제대로 이해하고 싶은 개발자를 위한 책스위프트는 iOS와 macOS용 애플리케이션 개발에 주로 사용하는 프로그래밍 언어입니

www.kyobobook.co.kr

 

반응형

'iOS > SWIFT' 카테고리의 다른 글

[Swift] Chapter 17. 서브스크립트  (0) 2021.10.10
[Swift] Chapter 16. 모나드  (0) 2021.10.10
[Swift] Chapter 14. 옵셔널 체이닝과 빠른 종료  (0) 2021.10.09
[Swift] Chapter 13. 클로저  (0) 2021.10.09
[Swift] Chapter 12. 접근제어  (0) 2021.10.08

댓글