1. 클로저
클로저란?
클로저는 현대적인 프로그래밍 언어라면 대부분 지원하는 기능이다.
클로저란 자신이 정의된 스코프 외부의 변수를 캡처하여, 해당 값을 자신의 실행 컨텍스트에 포함하는 코드 블록을 의미한다.
Swift에서 클로저는 매우 핵심적인 개념이며,
함수 또한 이름을 가진 클로저라고 볼 수 있을 정도로 클로저가 상위 개념에 가깝다.
즉, 함수는 클로저의 한 형태라고 설명해도 큰 무리는 없다.
func makeAdder() -> (Int) -> Int {
var x = 10
return { value in
x += value
return x
}
}
let adder = makeAdder()
adder(5) // 15
adder(3) // 18
위 코드는 클로저의 개념을 설명하기에 좋은 예시다.
makeAdder 함수는 내부 상태를 유지한 채, 전달받은 값을 누적해 더하는 동작을 수행하는 클로저를 반환한다.
이 문법을 처음 접하면 다음과 같은 의문이 생길 수 있다.
x는 함수 내부에 선언된 변수이므로, makeAdder의 실행이 끝나면 함께 사라져야 할 것처럼 보이는데,
왜 adder를 호출할 때마다 값이 계속 유지되는가?
그 이유는 반환된 클로저가 x를 캡처하여 자신의 실행 컨텍스트에 포함하고 있기 때문이다.
이때 캡처된 x는 함수의 스코프와는 독립적으로 존재하며,
해당 클로저가 살아 있는 동안 함께 유지되고, 클로저가 해제되는 시점에 같이 정리된다.
캡처는 밑에서 다시 설명하도록 하겠다.
클로저 표현식
초심자들은 흔히 클로저와 클로저 표현식을 같은 개념으로 혼동한다.
흔히 보이는 다음과 같은 문법만이 클로저라고 생각하기 쉬운데,
// 클로저 표현식 문법
{ (<#parameters#>) -> <#return type#> in
<#statements#>
}
// 실제 사용 코드
{ (value) in
value + 1
}
이 문법 자체가 클로저인 것은 아니다.
이 문법은 ‘클로저 표현식’이고, 이를 통해 클로저를 만들어내는 것이다.
즉, { } 형태로 작성된 코드만이 클로저인 것은 아니다.
스위프트에서 말하는 클로저는 다음과 같이 정리할 수 있다.
- 전역 함수
이름을 가지고 있으며, 어떠한 값도 캡처하지 않는 클로저 - 중첩 함수
이름을 가지고 있으며, 외부 함수의 값을 캡처할 수 있는 클로저 - 클로저 표현식
주변 컨텍스트에서 값을 캡처할 수 있는, 이름이 없는 클로저를 간결한 문법으로 표현한 것
클로저 표현식의 문법 자체에 대한 설명은 이미 잘 정리된 자료들이 많으므로, 여기서는 자세히 다루지 않겠다.
(정렬함수를 가지고 만들어내는 예제가 정말 잘 설명된 예제이다)
그래도 후행 클로저는 중요하니 이 부분만 다루도록 하겠다.
후행 클로저(trailing closure)는
함수의 마지막 인자가 클로저일 때, 해당 클로저를 함수 호출 괄호 밖으로 분리해 작성할 수 있게 해주는 문법이다.
예를 들어 다음과 같이 클로저를 인자로 받는 함수가 있다고 하자.
func perform(_ action: () -> Void) {
action()
}
// 1
perform({
print("hello")
})
// 2
perform {
print("hello")
}
perform 함수를 일반적으로 호출하면 1번과 같은 형태로 작성할 것이다. 하지만 스위프트에서는 마지막 인자가 클로저인 경우 함수 호출 괄호 밖으로 빼서 2번 형태로 작성할 수 있다.
이것이 바로 후행 클로저 문법이다.
그리고 추가로 덧붙이자면,
클로저 표현식은 Swift 컴파일러의 타입 추론 능력이 가장 극적으로 드러나는 지점이라고 볼 수 있다.
이 점을 이해하고 나면, 문법 차원에서의 클로저 표현식에 대해서는 더 이상 설명할게 없을 정도다.
이 문단에서는
클로저란 무엇인지, 그리고 그 개념을 Swift에서 어떻게 표현하고 있는지를 중심으로 살펴보았다.
또한 Swift에서 클로저를 표현하는 여러 방식 중 하나인 클로저 표현식이 무엇을 의미하는지,
그 개념을 명확히 구분하는 데에 의의를 두었다.
2. 클로저 캡쳐와 탈출클로저
캡쳐
위에 설명한 makeAdder() 예제를 좀더 살펴보자.
adder로 리턴되는 값의 타입은 () -> Int 형의 함수이다. 3장에서 함수는 타입이 될 수 있다고 했으니 이 부분은 이해가 될것이다.
그리고 클로저인 adder를 실행할때마다 x값이 변경되어서 리턴할 때마다 그 값이 증가되는 것을 볼 수 있다.
그럼 의문은 x로 옮겨간다. 함수의 실행이 이미 종료되었음에도 불구하고, x의 값이 유지되는데 이게 어떻게 가능한걸까?
반환되는 함수 부분만 따로 떼어놓고 보면 다음과 같은 형태가 된다.
func adder(value: int) -> int {
x+= value
return x
}
이 함수는 명백히 이상하다.
x는 함수의 매개변수도 아니고, 함수 내부에서 선언된 변수도 아니기 때문이다.
여기서 클로저의 성질이 드러난다.
위 함수에서 사용되는 x는 외부 스코프의 값을 클로저가 자신의 실행 컨텍스트로 가져온 것이며,
이러한 동작을 캡처(capture)라고 한다.
이렇게 캡처된 값은 함수 스코프와는 독립적으로 존재하며,
해당 값을 캡처한 클로저 인스턴스가 살아 있는 동안 함께 유지된다.
캡처는 언제 발생하는가?
그럼 자연스럽게 다음과 같은 의문점이 들것이다.
캡처는 언제 발생하는가?
그것은 클로저가 실행될때가 아닌 생성될때 발생한다는 것이다.
makeAdder 기준으로 보면,
클로저가 adder에 할당되는 시점에 클로저 인스턴스가 생성되고,
이때 외부 변수인 x가 캡처된다.
앞에서 언급했듯이,
캡처된 값은 클로저 인스턴스와 동일한 생명주기를 가진다.
캡쳐된 값은 공유되는가?
그럼 다음으로 드는 의문은 캡쳐된 값은 고유한가 아니면 별개인가이다.
결론을 말하자면, 클로저 인스턴스를 생성할때마다 캡쳐하는 값은 각각 별도로 생성된다.
그래서 클로저로 캡쳐되는 값은 공유되는 값이 아닌 클로저 인스턴스가 각각 소유한다고 보면 된다.
func makeCounter() -> () -> Int {
var count = 0
return {
count += 1
return count
}
}
let closureA = makeCounter()
let closureB = makeCounter()
closureA() // 1
closureA() // 2
closureB() // 1
let closureC: () -> Int = closureA
closureC() // 3
추가로, c의 사례를 보면 알 수 있듯이, 클로저는 레퍼런스 타입이어서 위와 같은 사용이 가능하다.
또한 클로저에서 캡쳐하는 대상이 값 타입인지, 참조 타입인지에 따라 성격이 달라진다
- 값 타입을 캡처하면 값 자체가 클로저의 환경에 포함되고
- 참조 타입을 캡처하면 해당 객체에 대한 참조가 포함된다
이 부분은 추후 다룰 ARC파트에서 좀 더 자세히 다루도록 하겠다.
탈출 클로저
여기까지 오면 다음과 같은 의문이 들 수 있다.
func networkCode(@escaping completion: () -> Void) {
API.request { response in
...
completion()
}
}
networkCode {
...
}
위 코드에서 completion으로 전달된 클로저는
networkCode() 함수의 실행이 이미 끝난 이후, 즉 함수 밖에서 호출된다.
그렇다면 함수의 스코프가 종료된 이후에도, 어떻게 이 클로저는 살아남아 실행될 수 있을까?
스위프트에서는 이러한 사용을 지원하며, 코드에 보이듯이 @escaping 키워드를 사용하여 이를 허용한다.
@escaping이 붙은 클로저는 함수의 스코프를 벗어나서도 살아남을 수 있는 클로저,
즉 함수가 반환된 이후에 실행될 수 있는 클로저를 의미한다.
이런 클로저를 탈출 클로저(Escaping Closure) 라고 부른다.
반대로, 지금까지 앞에서 다뤘던 대부분의 클로저는
함수 내부에서 바로 실행되고 함수의 생명주기 안에서만 존재하는 클로저들이다.
이러한 클로저를 비탈출 클로저(Non-escaping Closure) 라고 한다.
참고로, 초기의 Swift에서는 클로저의 기본 속성은 탈출 클로저였고, 탈출하지 않는 경우에 @noescape를 붙여야 했다.
하지만 Swift3부터 그 기본값이 반대로 바뀌어 비탈출 클로저가 기본이며, 탈출이 필요한 경우에만 @escaping을 명시하도록 되어 있다.
탈출클로저 vs 비탈출 클로저
그렇다면 Swift는 왜 탈출 클로저와 비탈출 클로저를 굳이 구분할까?
그 이유는 컴파일러의 분석 가능성과 최적화에 직접적인 영향을 주기 때문이다.
비탈출 클로저(non-escaping)의 경우, 클로저의 실행 시점이 함수의 스코프 안으로 한정된다.
컴파일러는 클로저의 생성부터 소멸까지의 범위를 명확하게 알 수 있기 때문에, 컴파일 과정에서 불필요한 retain / release 호출을 제거하는 등 객체의 수명을 보다 효율적으로 최적화할 수 있다.
그 결과 관련 객체들의 라이프사이클을 보다 효율적으로 관리할 수 있게 된다.
반면 탈출 클로저(escaping)는 함수가 반환된 이후에도 실행될 수 있기 때문에,
컴파일러는 클로저가 언제, 어떤 시점에 실행될지 확정할 수 없다.
이로 인해 캡처된 객체에 대한 수명 연장, 추가적인 참조 관리, 안전성 검증 등이 필요해지고 자연스럽게 컴파일러의 최적화 여지는 줄어들게 된다.
이러한 차이 때문에 Swift는 클로저의 탈출 여부를 명확히 구분하고, 개발자가 의도를 코드로 직접 드러내도록 요구한다.
이는 단순한 문법적 제약이 아니라, 안전성과 성능을 동시에 확보하려는 Swift의 언어 설계 철학을 보여주는 부분이라고 볼 수 있다.
withoutActuallyEscaping
가끔 비탈출 클로저를 탈출 클로저를 요구하는 함수에 전달해야하는 상황이 발생할 수 도 있다.
이러한 경우에 대비하여 Swift는 비탈출 클로저를 일시적으로 탈출 클로저처럼 다룰 수 있는 방법을 제공하는데,
그 역할을 하는 함수가 바로 withoutActuallyEscaping이다.
이 함수에 대한 자세한 소개는 애플의 공식문서를 참고하면 된다.
https://developer.apple.com/documentation/swift/withoutactuallyescaping(_:do:)
// 공식문서 예제코드
func allValues(in array: [Int], match predicate: (Int) -> Bool) -> Bool {
return array.lazy.filter { !predicate($0) }.isEmpty
}
func allValues(in array: [Int], match predicate: (Int) -> Bool) -> Bool {
return withoutActuallyEscaping(predicate) { escapablePredicate in
array.lazy.filter { !escapablePredicate($0) }.isEmpty
}
}
///
// 사실 이렇게 탈출클로저라고 명시해주면 문제될 것은 없다
func allValues(in array: [Int], match predicate: @escaping (Int) -> Bool) -> Bool {
return array.lazy.filter { !predicate($0) }.isEmpty
}
예제 코드를 보면 array.lazy.filter에 전달되는 predicate클로저는 비탈출 클로저인데, array.lazy.filter 함수는 탈출클로저를 요구하는 함수다 보니 여기서 문제가 발생한다.
위와 같은 상황에서 withoutActuallyEscaping 함수를 사용해서 해결할 수 도 있지만,
아마 대부분의 경우에는 밑의 코드처럼 predicate를 탈출클로저로 바꿔서 해결하는 경우가 더 많을 것이다.
그럼에도 불구하고 withoutActuallyEscaping를 사용하는 것은 다음과 같은 의미가 있다.
- 개발자가 수정할 수 없는 함수(라이브리러나 프레임워크 코드)를 다뤄야 할 때
- 개발자가 컴파일러에게 해당 클로저는 탈출하지 않는다는 보증하여 좀 더 최적화에 유리
정리하면, 컴파일러가 예측할 수 없는 부분을 개발자가 의도를 명확하게 전달하여 프로그램 최적화에 도움을 주는 장치라고 볼 수 있다.
자동 클로저
자동 클로저(autoclosure)는 함수에 인자로 전달되는 표현식을 감싸기 위해 자동으로 생성되는 클로저다.
이 클로저는 인자를 가지지 않으며, 호출될 때, 내부에 감싸진 표현식의 값을 반환한다.
자동 클로저의 설명을 스위프트 문서에서 찾아보면 위와 같이 소개한다.
이것만 보면 정확하게 자동 클로저가 무엇인지 와닿지 않는데 그래서 아래의 코드를 준비했다.
var customersInLine = ["Chris", "Alex", "Ewa", "Barry", "Daniella"]
func serve(customer customerProvider: () -> String) {
print("Now serving \(customerProvider())!")
}
serve(customer: { customersInLine.remove(at: 0) } )
func serve(customer customerProvider: @autoclosure () -> String) {
print("Now serving \(customerProvider())!")
}
serve(customer: customersInLine.remove(at: 0))
코드를 보면 알다시피 serve함수는 customer이름으로 클로저를 전달받아서 { } 문법(클로저 표현식)을 통해 클로저를 전달하는 것을 볼 수 있는데,
@autoclosure가 들어간 serve함수는 { } 없이 바로 표현식만 전달한 것을 볼 수 있다.
정리하면 @autoclosure를 붙이면 위에서 얘기한대로 표현식을 자동으로 클로저로 바꿔주는 문법이라고 이해하면 된다.
그럼 자동 클로저의 존재이유는 무엇일까?
결론부터 말하면, 자동 클로저는 새로운 기능을 제공하기 위한 장치는 아니다.
일반 클로저로도 동일하게 구현할 수 있는 동작을 호출부 문법만 단순하게 만들어 주는 문법적 장치가 자동 클로저 인것이다.
또한, 자동 클로저는 지연 평가(lazy evaluation)를
개발자가 클로저를 직접 작성하지 않아도 자연스럽게 사용할 수 있도록 만들어준다.
(지연 평가에 대해선 아래에서 더 자세히 설명하도록 하겠다.)
아래 코드를 보자.
assert(x > 0)
위 코드는 얼핏 보면 Bool 값을 전달하는 것처럼 보이지만,
실제로는 x > 0이라는 표현식이 자동으로 클로저로 감싸져
assert 내부에서 필요할 때만 평가된다.
만약 자동 클로저가 없었다면 호출부는 다음과 같았을 것이다.
assert({ x > 0 })
기능적으로는 동일하지만, 호출부의 가독성은 확연히 떨어진다.
이 차이점이 바로 자동 클로저의 존재 이유다.
다만 자동 클로저는 호출부에서 클로저라는 사실이 드러나지 않기 때문에, 남용할 경우 오히려 코드 이해를 어렵게 만들 수 있어,
이 때문에 Swift 공식 문서에서도 자동 클로저임을 명확히 드러내지 않으면 오히려 혼동을 줄 수 있다고 경고하고 있다.
실제로 x > 0이라는 표현식이
단순한 Bool 값인지,
아니면 나중에 평가될 코드 블록인지
한눈에 구분되지 않아 혼란을 줄 수 있다.
지연 평가
위에서 자동 클로저의 특징으로 지연 평가를 언급했는데,
지연 평가란 값의 계산을 즉시 수행하지 않고, 실제로 필요해질 때까지 미루는 방식을 의미한다.
var customersInLine = ["Chris", "Alex", "Ewa", "Barry", "Daniella"]
print(customersInLine.count)
// Prints "5".
let customerProvider = { customersInLine.remove(at: 0) }
print(customersInLine.count)
// Prints "5".
print("Now serving \(customerProvider())!")
// Prints "Now serving Chris!".
print(customersInLine.count)
// Prints "4".
위 코드를 보면 customerProvider 클로저를 선언하는 시점에
customersInLine의 값을 변경하는 코드가 포함되어 있음에도 불구하고,
클로저가 실제로 호출되기 전까지 배열의 상태는 변하지 않는다.
이는 클로저 내부의 코드가 정의되는 순간이 아니라, 호출되는 순간에 평가되기 때문이다.
즉, 코드의 위치와 무관하게 값의 평가 시점이 늦춰진 것이다.
이렇게 표현식이 즉시 평가되지 않고, 클로저가 호출되는 시점까지 평가가 지연되는 것을 지연 평가라고 한다.
그렇다면 이 지연 평가가 자동 클로저와 어떠한 관계가 있는가?
위에서 언급했듯이 자동 클로저는 바로 이 지연 평가를 개발자가 클로저를 직접 작성하지 않아도 자연스럽게 사용할 수 있도록 만들어준다는 점이다.
다만 지연 평가가 자동 클로저의 특징인 것같은 오해를 할 수 있는데,
이 특성은 클로저 자체의 특성이니 자동 클로저여야만 지연 평가를 쓸 수 있다는 건 아니니 유의하자.
추가로, 탈출이 허용되는 자동 클로저를 사용하고 싶다면, @autoclosure와 @escaping 속성을 함께 사용하면 된다.