소피it블로그

[Swift 공식문서] Closures 정리 (2) 본문

개발_iOS/스위프트

[Swift 공식문서] Closures 정리 (2)

sophie_l 2022. 5. 23. 23:54

https://docs.swift.org/swift-book/LanguageGuide/Closures.html

 

Closures — The Swift Programming Language (Swift 5.6)

Closures Closures are self-contained blocks of functionality that can be passed around and used in your code. Closures in Swift are similar to blocks in C and Objective-C and to lambdas in other programming languages. Closures can capture and store referen

docs.swift.org

3. Capturing Values

 

클로저는 정의된 주변 맥락으로부터의 상수나 변수를 캡쳐할 수 있다. 그리고 클로저는 해당 상수나 변수를 정의한 원래의 범위가 더 이상 존재하지 않는 경우에도 클로저 내부에서 그 상수나 변수의 값을 가리키거나 수정할 수 있다.

스위프트에서 값을 캡쳐할 수 있는 가장 간단한 형태의 클로저는 다른 함수의 바디 내에 적히는 중첩 함수이다. 중첩함수는 바깥 함수의 인수들 중 어느 것이든 캡쳐할 수 있고, 바깥 함수 내부에서 정의된 상수나 변수를 캡쳐할 수 있다.

아래의 예시에서 incrementer라는 중첩함수를 포함한 makeIncrementer라는 함수를 볼 것이다. 이 incrementer()라는 중첩함수는 주변 맥락으로부터 runningTotal과 amount라는 두 가지 값을 캡쳐한다. 이 값들을 캡쳐한 후에 incrementer는 makeIncrementer에 의해 runningTotal을 호출된 횟수만큼 증가시키는 클로저로서 리턴된다.

func makeIncrementer(forIncrement amount: Int) -> () -> Int {
    var runningTotal = 0
    func incrementer() -> Int {
        runningTotal += amount
        return runningTotal
    }
    return incrementer
}

makeIncrementer의 리턴 타입은 () -> Int이다. 즉, 단순한 값이 아닌 함수를 리턴한다는 것이다. 리턴되는 함수는 매개변수가 없고 호출될 때마다 Int 값을 리턴한다.

makeIncrementer(forIncrement:) 함수는 runningTotal이라고 하는 정수 변수를 정의하여 리턴될 incrementer의 현재까지의 총합을 저장한다. 이 변수는 0으로 초기화된다.

makeIncrementer(forIncrement:) 함수는 또한 forIncrement라는 인수 라벨과 amount라는 매개변수 이름을 가진 정수 매개변수를 갖는다. 이 매개변수로 전달되는 인수의 값은 리턴되는 incrementer 함수가 불릴 때마다 runningTotal이 얼마나 증가해야할지를 구체화한다. makeIncrementer 함수는 incrementer라는 중첩 함수를 정의하는데, 이는 실제적인 증가를 구현한다. 이 함수는 단순히 runningTotal에 amount를 더한 후 그 결과를 리턴한다.

따로 떼어놓고 보면 중첩함수인 incrementer() 함수는 이상해보일 수 있다.

func incrementer() -> Int {
    runningTotal += amount
    return runningTotal
}

 

incrementer() 함수는 매개변수도 없으면서 runningTotal과 amount를 함수 바디 내에서 언급한다. 이는 이 함수가 runningTotal과 amount의 레퍼런스를 외부 함수에서 캡쳐해서 가져와 자신의 내부에 씀으로써 가능하게 된다. 레퍼런스로 캡쳐함으로써 runningTotal과 amount가 makeIncrementer의 호출이 끝났을 때도 사라지지 않을 수 있게 되고, incrementer 함수가 더 호출되었을 때에도 runningTotal을 가져다 쓸 수 있게 된다.

let incrementByTen = makeIncrementer(forIncrement: 10)

incrementByTen()
// returns a value of 10
incrementByTen()
// returns a value of 20
incrementByTen()
// returns a value of 30

만일 새로운 incrementer를 생성한다면, 그것은 별개의 새로운 runningTotal 변수에 자신의 레퍼런스를 저장할 것이다.

let incrementBySeven = makeIncrementer(forIncrement: 7)
incrementBySeven()
// returns a value of 7

이 상황에서 원래의 incrementer(incrementByTen)을 다시 부른다면 이는 자기 자신의 runningTotal 변수를 증가시킬 것이고 incrementBySeven이 캡쳐한 변수에는 영향을 주지 않는다.

incrementByTen()
// returns a value of 40

4. 클로저는 레퍼런스 타입이다

 

위의 예시에서 incrementBySeven과 incrementByTen은 상수이지만, 이 상수들이 레퍼런스로 갖는 클로저들은 캡쳐한 runningTotal 변수를 여전히 증가시킬 수 있다. 이는 함수와 클로저가 레퍼런스 타입이기 때문이다.

함수나 클로저를 상수나 변수에 할당할 때 실제로는 해당 상수 또는 변수를 해당 함수나 클로저의 레퍼런스가 되도록 만드는 것이다. 위의 예시에서 incrementByTen이 레퍼런스로 갖는 상수는 클로저 자체의 내용이 아니라 클로저의 초이스이다.

즉 클로저를 두 개의 다른 상수나 변수에 할당한다면 둘은 같은 클로저를 가리킨다는 것이다.

let alsoIncrementByTen = incrementByTen
alsoIncrementByTen()
// returns a value of 50

incrementByTen()
// returns a value of 60

위의 예시는 alsoIncrementByTen을 호출하는 것이 incrementByTen을 호출하는 것과 같다는 것을 보여준다. 둘 다 같은 클로저를 참조하고 있기 때문에, 둘 다 증가하고 같은 running total을 리턴한다.

 

5. 이스케이핑 클로저

 

클로저가 함수의 인수로 전달되고 함수가 끝난 후에 호출되는 경우, 이를 가리켜 클로저가 함수를 탈출(이스케이프)한다고 한다. 매개변수로 클로저를 받는 함수를 선언할 때, 매개변수의 타입 앞에 @escaping을 붙여줌으로써 해당 클로저가 이스케이프하는 것이 가능하다는 것을 명시해줄 수 있다.

클로저가 이스케이프할 수 있는 방법 중 하나는 함수의 바깥에서 정의된 변수에 저장됨으로써이다. 예를 들어 많은 비동기 함수들이 컴플리션 핸들러로써 클로저 인수를 받는다. 해당 함수는 오퍼레이션이 전부 끝난 후에 리턴을 하지만, 클로저는 해당 오퍼레이션이 완성될 때까지는 호출되지 않는다. 클로저는 이스케이프하여 나중에 호출되어야 한다.

var completionHandlers: [() -> Void] = []
func someFunctionWithEscapingClosure(completionHandler: @escaping () -> Void) {
    completionHandlers.append(completionHandler)
}

someFunctionWithEscapingClosure(_:) 함수는 인수로써 클로저를 받고 함수의 바깥에서 선언된 배열에 이를 저장한다. 이 함수의 매개변수를 @escaping 없이 작성했다면 컴파일 타임 에러가 나온다.

self를 참조하는 이스케이핑 클로저는 self가 클래스의 인스턴스일 때 특히 더 주의를 요한다. 이스케이핑 클로저에서 self를 캡쳐하는 것은 실수로 강한 레퍼런스 사이클을 만들어내게 할 수 있다.

보통 클로저는 클로저의 바디 안에서 사용함으로써 변수들을 캡쳐하지만, 이 경우에는 명확히 드러낼 필요가 있다. self를 캡쳐하고 싶다면 사용할 때 명확히 self라고 써주거나 self를 클로저의 캡쳐 리스트에 포함시켜줘야 한다. self를 확실히 써줌으로써 의도를 드러낼 수 있고, 레퍼런스 사이클이 없음을 확인할 수 있다. 예를 들어 아래의 코드에서 someFunctionWithEscapingClosure(_:)에 전달되는 클로저는 명확히 self를 언급하고 있다. 반대로 comeFunctionWithNonescapingClosure(_:)에 전달되는 클로저는 이스케이핑하지 않는 클로저이다. 즉, 이 경우 self를 암시적으로 언급할 수 있다.

func someFunctionWithNonescapingClosure(closure: () -> Void) {
    closure()
}

class SomeClass {
    var x = 10
    func doSomething() {
        someFunctionWithEscapingClosure { self.x = 100 }
        someFunctionWithNonescapingClosure { x = 200 }
    }
}

let instance = SomeClass()
instance.doSomething()
print(instance.x)
// Prints "200"

completionHandlers.first?()
print(instance.x)
// Prints "100"

클로저의 캡쳐 리스트에 포함시킴으로써 self를 캡쳐하고 암시적으로 언급하는 doSomething()의 버전

class SomeOtherClass {
    var x = 10
    func doSomething() {
        someFunctionWithEscapingClosure { [self] in x = 100 }
        someFunctionWithNonescapingClosure { x = 200 }
    }
}

만일 self가 구조체의 인스턴스이거나 enumeration이면 self를 항상 암시적으로 가리킬 수 있다. 그렇지만 이스케이핑 클로저는 셀프가 구조체의 인스턴스이거나 enumeration일때 가변적인 레퍼런스를 셀프로 캡쳐할 수 없다.

struct SomeStruct {
    var x = 10
    mutating func doSomething() {
        someFunctionWithNonescapingClosure { x = 200 }  // Ok
        someFunctionWithEscapingClosure { x = 100 }     // Error
    }
}

위 예시에서 someFunctionWithEscapingClosure 함수의 호출은 에러가 난다. mutating 메서드 안에 있어서 self가 가변적이기 때문이다. 이는 이스케이핑 클로저가 가변적인 레퍼런스를 self로 캡쳐하지 못한다는 법칙에 어긋난다.

 

6. 오토클로저

 

오토클로저란 함수에 인수로써 전달되는 표현을 감싸기 위해 자동적으로 생성되는 클로저이다. 오토클로저는 인수를 받지 않고, 호출되었을 때 안에 자신이 감싸고 있는 표현의 값을 리턴한다. 이 문법적인 편리함 덕분에 함수의 매개변수 주변에 중괄호를 생략해줄 수 있다. 명시적인 클로저 대신 일반적인 표현을 써줌으로써 가능하다.

오토클로저를 취하는 함수를 부르는 일은 잦지만, 고안하는 일은 흔치 않다. 예를 들어 assert(condition:message:file:line:) 함수는 조건과 메시지 매개변수에 대한 오토클로저를 취한다. 조건 매개변수는 디버깅 빌드에서만 평가되며 메시지 매개변수는 조건이 거짓인 경우에만 평가된다.

오토클로저는 호출할 때까지 안의 코드가 작동하지 않기 때문에 평가를 지연시킬 수 있게 해준다. 평가를 지연시키는 것은 코드가 언제 평가될지를 컨트롤할 수 있게 해주기 때문에 부작용이 있거나 비용이 큰 코드에 있어서 특히 효과적이다. 아래의 코드는 클로저가 평가를 어떻게 지연시키는지 보여준다.

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"

customersInLine 배열의 첫 번째 요소가 클로저 내부의 코드에 의해 제거되었다고 할지라도, 배열 요소는 클로저가 실제로 호출될 때까지는 제거되지 않는다. 클로저가 호출되지 않는다면 클로저 안의 표현 역시 평가되지 않는데, 이는 해당 배열 요소 또한 제거되지 않는다는 것을 의미한다. 주의할 점은 customerProvider의 타입이 String이 아닌 () -> String 즉 매개변수가 없고 문자열을 리턴하는 함수라는 것이다.

함수의 인수로 클로저를 전달할 때면 이와 같은 지연된 평가를 접할 수 있다.

// customersInLine is ["Alex", "Ewa", "Barry", "Daniella"]
func serve(customer customerProvider: () -> String) {
    print("Now serving \(customerProvider())!")
}
serve(customer: { customersInLine.remove(at: 0) } )
// Prints "Now serving Alex!"

위의 리스팅에서의 serve(customer:) 함수는 고객의 이름을 리턴하는 명확한 클로저를 취한다. 아래의 serve(customer:)는 같은 오퍼레이션을 수행하지만 명백한 클로저를 취하는 대신 매개변수의 타입에 @autoclosure 속성을 붙임으로써 오토클로저를 취한다. 이제 해당 함수가 클로저 대신 문자열 인수를 취하는 것처럼 호출할 수 있다. customerProvider 매개변수의 타입이 @autoclosure 속성으로 표시되어 있기 때문에 해당 인수는 자동적으로 클로저로 변형된다.

// customersInLine is ["Ewa", "Barry", "Daniella"]
func serve(customer customerProvider: @autoclosure () -> String) {
    print("Now serving \(customerProvider())!")
}
serve(customer: customersInLine.remove(at: 0))
// Prints "Now serving Ewa!"

다만, 오토클로저를 너무 남용하는 것은 코드를 이해하기 어렵게 만들기 때문에 주의할 필요가 있다. 맥락과 함수명을 통해 평가가 지연되고 있음을 알릴 필요가 있다.

오토클로저가 이스케이프할 수 있게 만들고 싶다면 @autoclosure와 @escaping 속성 둘 다를 써주면 된다. 

// customersInLine is ["Barry", "Daniella"]
var customerProviders: [() -> String] = []
func collectCustomerProviders(_ customerProvider: @autoclosure @escaping () -> String) {
    customerProviders.append(customerProvider)
}
collectCustomerProviders(customersInLine.remove(at: 0))
collectCustomerProviders(customersInLine.remove(at: 0))

print("Collected \(customerProviders.count) closures.")
// Prints "Collected 2 closures."
for customerProvider in customerProviders {
    print("Now serving \(customerProvider())!")
}
// Prints "Now serving Barry!"
// Prints "Now serving Daniella!"