[Swift] 고차함수
안녕하세요, 루피입니다.
오늘은 Swift 고차함수에 대해 정리해 보도록 하겠습니다. 바로 시작합니다.
1. 왜 고차함수를 정리할까?
블로그 글이 100개가 넘어서 고차함수를 정리하는 과정이 앞뒤가 맞지 않다고 생각합니다..ㅎㅎ. 하지만, 아 진짜 이번 기회에 고차함수를 꼭 정리하고 넘어가야겠어!!라고 생각을 갖게 된 계기가 생겨났습니다!! 바로, Swift로 코딩테스트를 보았기 때문입니다.
저는 원래 학교에서 1학년 때부터 사용했던 C++ 로 코딩테스트를 준비했었는데요, 이번 기회에 Swift로 준비하면서, 아 내가 함수형 언어, Swift의 특징과 강력함을 글로 알고 있었구나..라는 생각이 들었습니다. 더 나아가 내가 Swift를 사용하긴 했지만, 전혀 Swift스럽지 않은 코드를 사용하고 있어구나..라는 생각도 들더라고요.
그래서 이번 기회에 제가 Swift의 가장 강력함이라고 느꼈던 고차함수에 대해 자세하게 정리 해보려합니다. 단순히 읽고 해석할 줄 아는 정도가 아닌 언제든지 무슨 상황에서라도 바로 직접 구현할 수 있도록 정리해보겠습니다.
단순히 고차함수를 읽고 해석하는 게 아니라 직접 백지에 하나씩 구현하고 적용해 나가면 좋을 거 같습니다!
2. 고차함수란 무엇인가?
"고차함수에는 어떤 것들이 있어!!" "Swift에 있는 map이 고차함수야!!" 이러한 답변들 말고 고차함수란 무엇일까요??
고차함수란 "함수를 인자로 받거나, 함수를 결괏값으로 반환하는 함수"를 의미합니다. C++ 같은 언어에서는 함수를 변수에 담거나 다른 함수의 인자로 넘기는 것이 어색하게 느껴질 수 있습니다. 하지만 Swift에서는 함수가 Int나 String처럼 일급 객체로 취급됩니다.
즉, 함수를 자유롭게 다룰 수 있다는 뜻이고, 이것이 고차함수라는 강력한 기능을 가능하게 하는 핵심입니다.
3. 일급 객체 솔직히 글로만 이해했잖아요...
C++과 Swift를 코드를 기반으로 일급 객체에 대한 이해를 해보겠습니다.
함수를 변수에 저장해 보자
func add(_ a: Int, _ b: Int) -> Int {
return a + b
}
let op: (Int, Int) -> Int = add
print(op(3, 5)) // 8
다들 익숙하실 텐데요, add 함수를 만들고 이를 op에 담아서 Swift에서는 변수로 사용하고 있습니다. 이게 가능한 게 바로 함수가 일급객체 여서 가능한 겁니다.
그러면 C++은 어떨까요?
int add(int a, int b) {
return a + b;
}
// 아래 코드는 컴파일 에러 발생!
// int op = add; // 함수는 변수에 직접 저장할 수 없음
// 함수 포인터를 써야 함 (문법이 복잡함)
int (*op)(int, int) = add;
cout << op(3, 5) << endl; // 8
이렇게 포인터 문법을 사용해야 합니다. std::function 역시 추가해야 합니다. 하지만 Swift는 그렇지 않죠!!
4. 고차함수의 종류
이제 고차함수의 종류에 대해 정리해 봅시다. 단순히 정리만 하는 게 아니라. 이게 왜 편한지 다른 언어랑은 뭐가 다른지를 이해하면서 진행한다면 좋을 거 같습니다. 그러면 Swift 언어의 특징을 자연스럽게 이해하실 겁니다.
1) map
map은 배열, 딕셔너리와 같은 컨테이너 내부의 각 요소를 우리가 원하는 규칙에 따라 변환하여, 새로운 컨테이너를 만들어주는 함수입니다.
[1, 2, 3, 4]라는 숫자 배열을 ["1번", "2번", "3번", "4번"]이라는 문자열 배열로 바꾸고 싶을 때
C++ 스타일의 접근법
let numbers = [1, 2, 3, 4]
var stringArray: [String] = []
for number in numbers {
stringArray.append("\(number)번")
}
// 결과: ["1번", "2번", "3번", "4번"]
직관적이지만, 결과를 담을 stringArray라는 임시 변수가 필요하고, for문을 통해 직접 순회하며 값을 추가하는 과정이 필요합니다.
map을 이용한 Swift-다운 접근법
let numbers = [1, 2, 3, 4]
let stringArray = numbers.map { "\($0)번" }
// 결과: ["1번", "2번", "3번", "4번"]
코드가 단 한 줄로 줄었습니다. map은 "배열의 모든 요소에 "\($0) 번"이라는 변환 규칙을 적용해서 새 배열을 만들어 줘!"라는 선언과도 같습니다. '어떻게'가 아닌 '무엇을' 할지에 집중하게 되는 거죠!!
저는 이 부분을 보고 "내가 잘 못쓰고 있었구나!!"라는 생각을 했습니다.
Swift를 사용하면서 C++ 스타일로 계속 문제를 풀고 있더라고요. 물론 많은 분들이 기본적인 Swift 문법을 공부하면서 map이 뭔지 아는 분들은 많을 겁니다. 하지만, 한번 map을 이용해 Swift로 PS 문제를 한번 풀어보시길 바랍니다! 아마... 에러 엄청 뜰 겁니다. ( 저는 그랬습니다!! ㅠㅠ)
💡 언제 map을 쓸까?
배열의 모든 요소에 동일한 '변환'을 적용해 새로운 배열을 만들고 싶을 때 사용합니다.
2) filter
filter는 이름 그대로 컨테이너 내부의 각 요소를 검사하여, 특정 조건을 만족하는 요소만 골라내 새로운 컨테이너를 만들어주는 함수입니다.
[1, 2, 3, 4, 5]라는 숫자 배열에서 짝수만 골라내고 싶을 때
C++ 스타일의 접근법
let numbers = [1, 2, 3, 4, 5]
var evenNumbers: [Int] = []
for number in numbers {
if number % 2 == 0 { // if문으로 조건 검사
evenNumbers.append(number)
}
}
// 결과: [2, 4]
filter를 이용한 Swift-다운 접근법
let numbers = [1, 2, 3, 4, 5]
let evenNumbers = numbers.filter { $0 % 2 == 0 }
// 결과: [2, 4]
filter는 "배열의 모든 요소 중 $0 % 2 == 0 조건을 통과하는 녀석들만 뽑아 줘!"라는 의미를 명확하게 전달합니다. for문과 if문을 한 번에 대체하는 셈이죠.
💡 언제 filter를 쓸까?
배열에서 특정 '조건'을 만족하는 원소들만 뽑아서 새로운 배열을 만들고 싶을 때 사용합니다.
3) reduce
reduce는 컨테이너 내부의 요소들을 하나씩 순회하며 값을 누적하여, 단 하나의 최종 결괏값으로 응축하는 함수입니다.
[1, 2, 3, 4, 5]라는 숫자 배열의 모든 요소의 합을 구하고 싶을 때
C++ 스타일의 접근법
let numbers = [1, 2, 3, 4, 5]
var sum = 0 // 누적값을 저장할 변수 선언
for number in numbers {
sum += number // 각 요소를 순회하며 누적
}
// 결과: 15
reduce를 이용한 Swift-다운 접근법
let numbers = [1, 2, 3, 4, 5]
let sum = numbers.reduce(0, +)
// 또는 클로저 형태로: let sum = numbers.reduce(0) { (currentSum, nextValue) in currentSum + nextValue }
// 결과: 15
reduce(0, +)는 "초기값 0에서 시작해서, 모든 요소를 + 연산으로 합쳐 줘!"라는 뜻입니다. for문과 누적 변수 선언이 한 번에 해결됩니다.
💡 언제 reduce를 쓸까?
배열의 모든 원소를 사용하여 하나의 '결괏값'으로 합치거나 응축하고 싶을 때 사용합니다.
4) compactMap
compactMap은 map의 특별한 버전입니다. 변환 결과가 옵셔널일 때, nil이 아닌 값만 골라내 새로운 배열을 만듭니다.
["1", "2", "three", "4"]와 같이 문자열과 숫자가 섞인 배열에서, 숫자로 변환 가능한 요소만 뽑아 정수 배열로 만들고 싶을 때
C++ 스타일의 접근법
let stringArray = ["1", "2", "three", "4"]
var intArray: [Int] = []
for str in stringArray {
if let number = Int(str) { // if let으로 nil이 아닌지 검사
intArray.append(number)
}
}
// 결과: [1, 2, 4]
compactMap을 이용한 Swift-다운 접근법
let stringArray = ["1", "2", "three", "4"]
let intArray = stringArray.compactMap { Int($0) }
// 결과: [1, 2, 4]
Int(str)는 변환에 실패하면 nil을 반환하는 옵셔널 이니셜라이저입니다. compactMap은 이 변환 과정에서 나온 nil들을 자동으로 무시하고, 성공한 값들만 모아줍니다. map과 filter를 합쳐놓은 듯한 편리함을 제공합니다.
💡 언제 compactMap을 쓸까?
배열의 각 요소를 변환하되, 변환 결과가 옵셔널이고 nil 값은 최종 결과에서 제외하고 싶을 때 사용합니다.
5) flatMap
flatMap은 이름에서 유추할 수 있듯이, map의 기능에 '평탄화' 작업을 추가한 고차함수입니다. 각 요소를 변환한 결과가 또 다른 컬렉션일 때, 이 중첩된 구조를 하나의 1차원 배열로 펼쳐주는 강력한 역할을 합니다.
[[1, 2], [3, 4], [5]] 와 같이 2차원으로 이루어진 배열을 [1, 2, 3, 4, 5]라는 1차원 배열로 만들고 싶을 때
C++ 스타일의 접근법
let nestedArray = [[1, 2], [3, 4], [5]] var flattenedArray: [Int] = []
for array in nestedArray {
for number in array {
flattenedArray.append(number)
}
} // 결과: [1, 2, 3, 4, 5]
flatMap을 이용한 Swift-다운 접근법
let nestedArray = [[1, 2], [3, 4], [5]]
let flattenedArray = nestedArray.flatMap { $0 } // 결과: [1, 2, 3, 4, 5]
코드가 간결해졌습니다. flatMap은 "중첩된 배열의 각 배열($0)을 그대로 반환하되, 최종 결과는 하나의 배열로 펼쳐 줘!"라는 의미를 담고 있습니다. map과 compactMap이 그랬던 것처럼, '어떻게'가 아닌 '무엇을'에 집중하게 해 줍니다.
💡 언제 flatMap을 쓸까?
[[1], [2, 3]] 과 같은 2차원 배열을 1차원 배열로 만들고 싶을 때 또는 각 요소의 변환 결과가 배열이어서 최종 결과를 단일 배열로 합쳐야 할 때 사용합니다.
6) forEach
이름 그대로 컬렉션의 각 요소에 대해 특정 작업을 수행합니다. 하지만 중요한 차이점이 있습니다. map, filter와 달리 새로운 컬렉션을 반환하지 않는다는 점입니다. 반환 타입이 Void이죠.
배열의 모든 요소를 단순히 출력하고 싶을 때
C++ 스타일의 접근법 (for-in 구문)
let numbers = [1, 2, 3]
for number in numbers {
print(number)
} // 출력: // 1 // 2 // 3
forEach를 이용한 Swift-다운 접근법
let numbers = [1, 2, 3]
numbers.forEach { print($0) } // 출력: // 1 // 2 // 3
for-in 구문과 결과는 동일하지만, 클로저를 사용하여 함수형 프로그래밍 스타일을 유지할 수 있습니다.
주의할 점! forEach는 for-in을 완전히 대체하지 못합니다. forEach의 클로저 내부에서는 break나 continue를 사용하여 반복을 중단하거나 건너뛸 수 없습니다. return을 사용해도 클로저만 종료될 뿐, forEach 전체가 멈추지는 않습니다.
💡 언제 forEach를 쓸까?
배열의 각 요소를 사용해서 별도의 반환 값 없이 단순히 어떤 작업(ex: print, UI 업데이트 등)을 수행하고 싶을 때 사용합니다. 새로운 배열을 만드는 것이 목적이라면 map을 사용해야 합니다.
7) sorted / sort
데이터를 다룰 때 정렬은 빼놓을 수 없는 작업입니다. Swift는 sorted와 sort라는 두 가지 강력한 정렬 함수를 제공합니다. 둘은 비슷해 보이지만 결정적인 차이가 있습니다.
[5, 2, 8, 1, 9] 배열을 내림차순으로 정렬하고 싶을 때
sorted : 원본을 그대로 둡니다.
sorted(by:)는 정렬된 새로운 배열을 반환합니다. 원래의 배열은 전혀 변경되지 않죠.
let numbers = [5, 2, 8, 1, 9]
let sortedNumbers = numbers.sorted(by: >)
print(numbers) // [5, 2, 8, 1, 9] - 원본은 그대로!
print(sortedNumbers) // [9, 8, 5, 2, 1] - 새로운 배열이 반환됨
sort : 원본을 수정합니다.
sort(by:)는 원본 배열 자체를 정렬합니다. 따라서 let으로 선언된 상수 배열에는 사용할 수 없으며, var로 선언된 변수 배열에서만 사용 가능합니다.
var numbers = [5, 2, 8, 1, 9]
numbers.sort(by: >)
print(numbers) // [9, 8, 5, 2, 1] - 원본 자체가 변경됨
💡 언제 sorted / sort를 쓸까?
sorted: 원본 데이터를 유지하면서 정렬된 복사본이 필요할 때 사용합니다. (안전성)
sort: 원본 데이터를 직접 수정하여 메모리 사용을 최적화하거나, 해당 배열의 상태 자체를 바꾸는 것이 목적일 때 사용합니다.
8) 그 외 유용한 고차함수들
코딩 테스트나 실제 개발에서 유용하게 쓰이는 다른 고차함수들도 간단히 짚고 넘어가겠습니다. 이 함수들은 특정 조건을 확인하거나 컬렉션의 일부를 다룰 때 코드를 매우 명확하게 만들어 줍니다.
- contains(where:): 클로저로 주어진 조건을 만족하는 요소가 하나라도 있는지 확인하여 true / false를 반환합니다. for문을 돌며 if문으로 짝수를 찾고 flag 변수를 바꾸는 것보다 훨씬 간결합니다.
let numbers = [1, 3, 5, 7]
let hasEvenNumber = numbers.contains { $0 % 2 == 0 } // false
- removeAll(where:): 클로저로 주어진 조건을 만족하는 모든 요소를 원본 컬렉션에서 삭제합니다. sort()처럼 파괴적인(mutating) 함수이므로 var 변수에만 사용할 수 있습니다.
var numbers = [1, 2, 3, 4, 5]
numbers.removeAll { $0 % 2 == 0 } // 짝수를 모두 제거
print(numbers) // [1, 3, 5]
- first(where:): 조건을 만족하는 첫 번째 요소를 반환합니다. 조건에 맞는 요소가 없으면 nil을 반환하므로, 반환 타입은 옵셔널입니다.
let names = ["Apple", "Banana", "Cherry"]
let firstWithB = names.first { $0.hasPrefix("B") } // Optional("Banana")
let firstWithZ = names.first { $0.hasPrefix("Z") } // nil
- allSatisfy(_:): 모든 요소가 주어진 조건을 만족하는지 확인합니다.
let numbers = [2, 4, 6, 8]
let allAreEven = numbers.allSatisfy { $0 % 2 == 0 } // true
Swift를 Swift 답게 쓰도록 노력해봐야 할 거 같습니다. 오늘도 화이팅입니다!