🟣 함수를 값처럼 넘긴다는 낯섦
Swift를 배우기 전에 다뤘던 언어에서 함수는 그냥 "호출하는 것"이었다. 그런데 Swift 코드를 읽다 보니 함수를 변수에 대입하고, 다른 함수의 인자로 넘기고, 함수가 함수를 돌려주는 장면이 계속 나왔다. sorted(by:)에 정렬 기준을 통째로 넘기는 코드를 처음 봤을 때, 저기 들어가는 게 값인지 코드인지 헷갈렸다.
정리하고 나니 답은 간단했다. Swift에서 함수는 정수나 문자열과 똑같은 급의 값이다. 이 사실 하나가 클로저와 고차함수를 관통한다. 이 장은 거기서 출발한다.
🟣 1급 객체라는 말의 뜻
어떤 값이 1급 객체(first-class citizen)라고 불리려면 대체로 세 가지가 된다. 변수에 담을 수 있고, 함수의 인자로 넘길 수 있고, 함수의 반환값으로 돌려받을 수 있다. 정수는 당연히 셋 다 된다. Swift에서는 함수도 이 셋을 전부 만족한다.
func square(_ x: Int) -> Int { x * x }
let f = square // 함수를 변수에 담는다
let result = f(5) // 담아 둔 함수를 호출한다. 25
func apply(_ fn: (Int) -> Int, to value: Int) -> Int {
fn(value) // 인자로 받은 함수를 호출한다
}
apply(square, to: 5) // 25여기서 f의 타입은 (Int) -> Int다. 함수의 타입은 파라미터 타입과 반환 타입의 조합으로 정해진다. (Int) -> Int는 정수를 받아 정수를 돌려주는 모든 함수를 담을 수 있는 그릇이다. 타입이 이렇게 값처럼 표기된다는 것 자체가, 함수가 값 취급을 받는다는 증거다.
이걸 받아들이고 나면 sorted(by:)가 자연스러워진다. 정렬 함수는 "두 원소 중 뭐가 앞이냐"를 판단하는 함수 하나만 있으면 어떤 기준으로든 정렬할 수 있다. 그 판단 함수를 값으로 받아서 내부에서 호출할 뿐이다.
🟣 클로저: 이름 없는 함수와 캡처
함수를 값으로 넘길 수 있게 되면, 넘기려고 매번 func로 이름 붙여 선언하는 게 번거로워진다. 그 자리에서 바로 만들어 넘기는 이름 없는 함수가 클로저다.
let numbers = [3, 1, 2]
// 이름 붙인 함수를 넘기는 방식
func ascending(_ a: Int, _ b: Int) -> Bool { a < b }
numbers.sorted(by: ascending)
// 클로저로 그 자리에서 넘기는 방식
numbers.sorted(by: { (a: Int, b: Int) -> Bool in a < b })두 코드는 같은 일을 한다. 아래쪽 { ... in ... }가 클로저다. in 앞이 시그니처, 뒤가 본문이다.
🚀 문법 축약
Swift는 클로저를 짧게 줄이는 규칙이 여러 겹이다. 처음엔 이 축약형들이 서로 다른 문법처럼 보였는데, 전부 위의 완전한 형태에서 생략만 한 것이다.
numbers.sorted(by: { (a: Int, b: Int) -> Bool in a < b })
numbers.sorted(by: { a, b in a < b }) // 타입 추론으로 타입 생략
numbers.sorted(by: { $0 < $1 }) // 인자 이름 대신 $0, $1
numbers.sorted { $0 < $1 } // 마지막 인자면 괄호 밖으로(trailing)맨 아래가 실무에서 가장 많이 보는 형태다. 컴파일러가 문맥에서 타입을 추론할 수 있으니 다 지운 것이지, 규칙이 여러 개인 게 아니다. 축약형이 헷갈릴 때는 항상 맨 위의 완전한 형태로 되돌려 읽으면 풀렸다.
🚀 값을 붙잡아 두는 캡처
클로저가 단순한 이름 없는 함수와 다른 결정적인 지점은 캡처다. 클로저는 자기 몸통 밖에 있는 변수를 안에서 참조하면, 그 변수를 자기 안에 붙잡아 둔다. 이게 캡처다.
func makeCounter() -> () -> Int {
var count = 0
return {
count += 1 // 바깥의 count를 캡처한다
return count
}
}
let counter = makeCounter()
counter() // 1
counter() // 2
counter() // 3makeCounter가 끝나면 지역 변수 count는 사라져야 정상이다. 그런데 반환된 클로저가 count를 캡처했기 때문에, 함수가 끝난 뒤에도 count는 살아남아 호출할 때마다 값을 기억한다. 여기서 중요한 건 클로저가 값을 복사한 게 아니라 그 변수의 저장 공간 자체를 붙잡는다는 점이다. 그래서 클로저 안에서 count += 1을 하면 진짜로 그 변수가 증가한다.
캡처가 값 복사가 아니라 참조라는 사실은 나중에 큰 문제로 돌아온다. 클로저가 객체를 캡처하면 그 객체를 붙잡고 놓지 않기 때문에, 3장에서 볼 순환참조의 씨앗이 된다. 지금은 "클로저는 바깥 변수를 붙잡는다"만 기억하면 된다.
🟣 고차함수: 함수를 받고 함수를 돌려주다
함수를 인자로 받거나 함수를 반환하는 함수를 고차함수(higher-order function)라고 부른다. 방금 본 makeCounter는 함수를 반환하는 고차함수였고, sorted(by:)는 함수를 받는 고차함수다.
일상에서 가장 많이 쓰는 고차함수는 컬렉션에 붙어 있다. 반복문으로 쓰던 코드를 이걸로 바꾸면 "무엇을 하는지"가 "어떻게 도는지"보다 앞에 드러난다.
🚀 map, filter, reduce
let nums = [1, 2, 3, 4, 5]
let doubled = nums.map { $0 * 2 } // [2, 4, 6, 8, 10]
let evens = nums.filter { $0 % 2 == 0 } // [2, 4]
let sum = nums.reduce(0) { $0 + $1 } // 15셋의 역할이 처음엔 뒤섞였는데, 넘긴 클로저가 무엇을 돌려주느냐로 구분하니 정리됐다. map은 각 원소를 변환한 값을 돌려줘서 같은 개수의 새 배열을 만든다. filter는 각 원소를 남길지 말지 판단하는 Bool을 돌려줘서 걸러진 배열을 만든다. reduce는 누적값과 원소를 받아 다음 누적값을 돌려주며 하나의 결과로 접는다.
// 반복문 버전
var doubled: [Int] = []
for n in nums { doubled.append(n * 2) }
// map 버전: 결과가 무엇인지 한 줄에 드러난다
let doubled = nums.map { $0 * 2 }반복문 버전은 빈 배열을 만들고, 돌면서, append 한다는 절차가 앞에 나온다. map 버전은 "각 원소를 두 배로"라는 의도가 먼저 읽힌다. 고차함수가 코드를 짧게 만드는 것보다, 의도를 절차보다 앞세운다는 점이 실무에서 더 값졌다.
🟣 정리
- Swift에서 함수는 정수나 문자열과 같은 급의 값이다. 변수에 담고, 인자로 넘기고, 반환할 수 있다. 함수의 타입은
(Int) -> Int처럼 파라미터와 반환 타입으로 정해진다. - 클로저는 그 자리에서 만드는 이름 없는 함수다. 실무에서 보는
{ $0 < $1 }같은 축약형은 모두 완전한 형태에서 타입과 이름을 생략한 것이므로, 헷갈리면 완전한 형태로 되돌려 읽으면 된다. - 클로저는 바깥 변수를 값이 아니라 참조로 캡처한다. 그래서 함수가 끝난 뒤에도 그 변수가 살아남는다. 이 캡처가 3장 순환참조의 씨앗이다.
- 고차함수는 함수를 받거나 돌려주는 함수다.
map은 변환,filter는 선별,reduce는 누적이며, 넘긴 클로저의 반환값으로 역할이 갈린다. - 면접에서 "클로저와 함수의 차이"를 물으면, 클로저가 주변 문맥의 변수를 캡처한다는 점을 짚는 것이 핵심이다. 문법 축약은 부차적이다.
함수를 값으로 넘기고 캡처까지 이해했으니, 이제 그 값들이 복사될 때 어떻게 행동하는지를 봐야 한다. 구조체를 복사하면 남남이 되는데 클래스를 복사하면 같은 것을 가리키는, 처음에 가장 헷갈렸던 그 차이가 다음 장이다.