Swift closure는 전달하고 저장할 수 있는 function value이고, callback은 다른 code에 넘겨 두었다가 특정 시점에 호출하도록 맡긴 function의 역할을 가리킨다. 둘은 동의어가 아니다. closure를 callback으로 쓸 수 있지만, 모든 closure가 비동기 callback인 것도 아니고 callback이 반드시 한 번만 호출되는 것도 아니다.
closure expression의 기본 형태
Swift function type은 (입력 타입) -> 반환 타입으로 표현한다.
let greet: (String) -> String = { name in
"Hello, \(name)!"
}
print(greet("Swift"))
문맥에서 type을 추론할 수 있으면 parameter와 return type을 생략할 수 있다. 한 expression만 반환하면 return도 생략할 수 있다.
let numbers = [5, 2, 7, 3, 1]
let sortedNumbers = numbers.sorted { $0 < $1 }
trailing closure와 $0, $1은 짧은 동작을 읽기 쉽게 만들 때 유용하다. 조건이 길거나 같은 type의 parameter가 많다면 축약 이름보다 의미 있는 이름을 쓰는 편이 낫다.
callback은 호출 계약까지 봐야 한다
다음 operation은 closure parameter지만 함수 안에서 즉시 한 번 호출되는 synchronous callback이다.
func transform(
_ value: Int,
using operation: (Int) -> Int
) -> Int {
operation(value)
}
let doubled = transform(3) { $0 * 2 }
print(doubled) // 6
API가 closure를 받는다는 사실만으로 asynchronous라고 판단할 수 없다. 문서에서 다음 contract를 확인해야 한다.
- caller가 return하기 전인가, 나중인가?
- 정확히 한 번, 여러 번, 아니면 호출하지 않을 수도 있는가?
- 어느 executor나 thread에서 호출하는가?
- cancellation과 error는 어떻게 전달하는가?
호출 시점을 잘못 가정하면 callback이 채우기 전에 결과를 읽거나, 여러 번 호출되는 handler에서 상태를 중복 변경할 수 있다.
@escaping은 function 밖으로 살아남을 수 있다는 뜻이다
closure parameter는 기본적으로 nonescaping이다. 함수가 반환된 뒤에도 저장하거나 나중에 호출하려면 @escaping을 붙인다.
var pendingHandlers: [() -> Void] = []
func register(_ handler: @escaping () -> Void) {
pendingHandlers.append(handler)
}
register {
print("나중에 실행")
}
pendingHandlers.first?()
@escaping은 곧 ‘background thread에서 실행’이라는 뜻이 아니다. 위 예제처럼 바깥 저장소에 보관하는 것만으로도 escaping이다. 반대로 synchronous higher-order function의 closure는 보통 nonescaping이다.
closure는 주변 값을 capture한다
closure는 정의된 scope의 constant와 variable을 capture할 수 있다.
func makeCounter() -> () -> Int {
var count = 0
return {
count += 1
return count
}
}
let next = makeCounter()
print(next()) // 1
print(next()) // 2
makeCounter가 반환된 뒤에도 closure가 count 상태를 유지한다. 같은 closure를 다른 변수에 대입하면 독립 counter가 복제된다고 단정할 수 없다. closure와 captured state의 identity를 함께 이해해야 한다.
class instance의 self를 escaping closure가 강하게 capture하고, 그 instance가 다시 closure를 강하게 보관하면 reference cycle이 생길 수 있다.
final class Loader {
var onFinish: (() -> Void)?
func configureCallback() {
onFinish = { [weak self] in
self?.finish()
}
}
private func finish() {
print("finished")
}
}
[weak self]를 모든 closure에 기계적으로 붙이는 것도 답은 아니다. cycle 가능성과 원하는 lifetime을 먼저 본다. unowned는 호출 시점에 대상이 반드시 살아 있다는 보장이 있을 때만 사용한다. 보장이 깨지면 optional nil이 아니라 runtime error로 이어질 수 있다.
callback과 async/await 중 무엇을 쓸까
completion handler API는 event가 여러 번 도착하거나 기존 framework contract와 맞출 때 여전히 필요하다. 한 번의 비동기 결과와 error를 순서대로 처리하는 code라면 Swift concurrency의 async/await가 control flow와 error propagation을 더 분명하게 만들 수 있다.
기존 callback API를 continuation으로 감쌀 때는 continuation을 정확히 한 번 resume해야 한다. 여러 번 또는 한 번도 resume하지 않는 API라면 그대로 감싸면 안 된다. callback을 없애는 것이 목적이 아니라 원래 API의 호출 계약을 보존하는 것이 먼저다.
강한 참조와 capture list는 Swift strong·weak·unowned reference, value type의 method 변경 규칙은 Swift mutating에서 이어서 볼 수 있다.
참고 자료
'배움과 성장 > 소프트웨어 개발' 카테고리의 다른 글
| SwiftUI에서 struct와 class 고르기: @State·@Observable 기준 (1) | 2024.11.11 |
|---|---|
| Swift ARC의 strong·weak·unowned: 수명 관계로 참조 고르기 (2) | 2024.11.10 |
| Swift 초기화와 @main: 저장 프로퍼티·App 진입점·onAppear 구분 (2) | 2024.11.09 |
| SwiftUI some View 이해하기: Opaque Return Type과 #Preview (8) | 2024.10.28 |
| Swift 언더스코어(_): wildcard pattern·인자 레이블·property wrapper (4) | 2024.10.25 |
댓글