공식 문서로 공부하는 Swift (6) - 클로저

ci·2020년 5월 26일
1

Closures

클로저(Closure)는 코드 안에서 사용되고 전달될 수 있는 코드 블럭이다. C와 Objective-C의 블럭(blocks) 또는 다른 언어의 람다(lambdas)와 비슷하다. 클로저는 어떤 상수나 변수의 참조를 캡쳐(capture)해 저장할 수 있다. Swift는 이 캡쳐와 관련한 모든 메모리를 알아서 처리합니다.


전역 함수(global function)중첩 함수(nested function)는 사실 클로저의 특별한 형태다. 클로저는 다음 세 가지 형태 중 하나를 띤다.

  • 전역 함수(global function) : 이름이 있고, 어떤 값도 캡쳐하지 않는 클로저.
  • 중첩 함수(nested function) : 이름이 있고, 인접한 함수로부터 값을 캡쳐할 수 있는 클로저.
  • 클로저 표현(closure expression) : 경량화된 문법으로 쓰여지고, 관련 문맥(context)으로부터 값을 캡쳐할 수 있는 이름 없는 클로저.

Swift의 클로저 표현은 최적화되어 간결하고 명확한 스타일을 갖고 있다. 다음과 같은 최적화 내용이 포함된다.

  • 문맥으로부터 매개 변수와 반환 값의 타입을 추론.
  • 단일 표현 클로저의 암시적 반환.
  • 축약된 인자 이름.
  • 후위 클로저 문법.


클로저 표현

때때로, 완전한 선언과 이름 없이 상수와 같은 모습의 짧은 함수가 중첩 함수를 사용하는 것보다 유용할 수 있다. 함수가 인자로 하나 이상의 다른 함수를 받을 때 특히 더 그렇다.

클로저 표현은 인라인 클로저를 간단하고, 문법에 집중하여 작성할 수 있는 방법이다. 클로저 표현은 명확성이나 의도를 잃지 않고도 문법을 축약해 사용할 수 있는 몇 가지 최적화 방식을 제공한다.


정렬 메소드

Swift의 표준 라이브러리는 알려진 타입(클로저의 결과 값에 기반을 둔)의 배열 값을 정렬하는 sorted(by:) 메소드를 제공한다. 정렬이 성공하면 sorted(by:) 메소드는 기존과 같은 타입, 크기를 갖는 새로운 배열을 정렬하여 반환한다. 원래 배열은 sorted(by:)에 의해 수정되지 않는다.

아래의 클로저 표현 예시는 문자열 배열을 알파벳 역순으로 정렬하기 위해 sorted(by:)를 사용한다.

let names = ["Chris", "Alex", "Ewa", "Barry", "Daniella"]

sorted(by:) 메소드는 배열 요소와 같은 타입의 두 인자를 갖는 클로저를 수용한다. 그리고 정렬되었을 때 첫 번째 값이 두 번째 값보다 반드시 먼저 나온다는 것을 보여 주기 위해 Bool 값을 반환한다. 정렬 클로저는 (String, String) -> Bool 타입을 필요로 한다.

정렬 클로저를 제공하는 한 가지 방법은 일반적인 함수를 작성하는 것이다. 이는 sorted(by:) 메소드에 인자로 전달된다.

func backward(_ s1: String, _ s2: String) -> Bool {
    return s1 > s2
}
var reversedNames = names.sorted(by: backward)
// reversedNames is equal to ["Ewa", "Daniella", "Chris", "Barry", "Alex"]

이 방법은 a > b라는 단일 표현을 작성하기엔 지나치게 길다. 클로저 표현 문법을 이용하여 정렬 클로저를 한 줄에 작성하는 게 더 적합하다.


클로저 표현 문법

클로저 표현 문법의 기본적인 형태는 다음과 같다.

{ (parameters) -> return type in
    statements
}

클로저 표현 문법의 매개 변수는 인-아웃 매개변수가 될 수 있지만, 기본 값을 가질 수는 없다. 매개 변수 집합을 사용할 수도 있으며, 매개 변수 타입과 반환 타입에 튜플을 쓸 수도 있다.


아래의 예시는 위의 함수를 클로저 표현을 사용한 버전이다.

reversedNames = names.sorted(by: { (s1: String, s2: String) -> Bool in
    return s1 > s2
})

인라인 클로저에서 매개 변수 타입과 반환 값 타입을 선언하는 것과 backward(_:_:) 함수에서 선언하는 것은 동일하다. 양쪽 모두 (s1: String, s2: String) -> Bool 타입이 작성된다. 하지만 인라인 클로저는 매개 변수와 반환 값 타입을 바깥이 아닌 중괄호 안에 작성한다.

클로저의 바디는 in 키워드 이후에 시작한다. 이 키워드는 클로저의 매개 변수와 반환 값의 타입 선언이 끝났음을 알리는 역할을 한다.



문맥으로부터 타입 추론하기

정렬 클로저가 메소드의 인자로 들어가기 때문에 Swift는 그것의 매개 변수와 그것이 반환하는 값의 타입을 추론할 수 있다. sorted(by:) 메소드는 문자열 배열에서 호출되며, 이것의 인자는 (String, String) -> Bool 타입의 함수여야만 한다. (String, String)Bool 타입은 클로저를 선언할 때 작성할 필요가 없다는 뜻이다. 모든 타입이 추론 가능하기 때문에 반환 화살표(->)와 소괄호 역시 생략 가능하다.

reversedNames = names.sorted(by: { s1, s2 in return s1 > s2 } )

인라인 클로저 표현으로써 함수나 메소드에 클로저를 넣을 때, 항상 매개 변수와 반환 값의 타입을 추론할 수 있다. 결과적으로 클로저가 함수나 메소드의 인자로 사용될 때 인라인 클로저의 전체 형태를 적을 필요가 없다.

하지만 원한다면 코드의 모호성을 피하기 위해 명시적으로 타입을 표시할 수도 있다.



단일 표현 클로저에서의 암시적 반환

단일 표현 클로저는 return 키워드를 선언에서 생략함으로써 결과 값을 암시적으로 반환할 수 있다.

reversedNames = names.sorted(by: { s1, s2 in s1 > s2 } )

sorted(by:) 메소드에 인자로 들어간 함수의 타입은 클로저에 의해 Bool 값을 반환할 것이 명확하다. 클로저의 바디가 포함하고 있는 단일 표현(s1 > s2)가 Bool 값을 반환하기 때문이다. 여기에는 모호함이 존재하지 않고, return 키워드를 생략할 수 있다.


인자 이름의 축약

Swift는 자동적으로 인라인 클로저의 축약된 인자 이름을 제공한다. 이러한 이름은 $0, $1, $2와 같이 표현된다.

만약 클로저 표현에서 이런 축약된 인자 이름을 사용한다면, 클로저의 정의에서 인자 목록을 생략할 수 있다. in 키워드 역시 생략 가능하다.


reversedNames = names.sorted(by: { $0 > $1 } )

축약은 되었지만 논리를 표현하는 데는 지장이 없다. 인라인 클로저에 생략된 내용을 포함해 설명하면 $0$1 인자를 두 개 받아서 $0$1 보다 큰지를 비교하고 그 결과(Bool)를 반환하라는 뜻이다.


연산자 메소드

Swift의 String 타입 연산자에는 String끼리 비교할 수 있는 비교 연산자(>) 를 구현해 두었다. 이 때문에 그냥 이 연산자를 사용하면 된다.

reversedNames = names.sorted(by: >)


후위 클로저 (Trailing Closures)

함수의 마지막 인자로 클로저를 넣어야 하는데 그 클로저 표현이 길다면 후위 클로저(trailing closure)를 대신 사용하는 게 좋다. 후위 클로저는 호출하는 함수의 소괄호 이후에 작성한다. 후위 클로저 문법을 사용할 때는 함수 호출의 일부분으로써 인자 라벨을 클로저에 사용할 필요가 없다.

func someFunctionThatTakesAClosure(closure: () -> Void) {
    // function body goes here
}

// Here's how you call this function without using a trailing closure:

someFunctionThatTakesAClosure(closure: {
    // closure's body goes here
})

// Here's how you call this function with a trailing closure instead:

someFunctionThatTakesAClosure() {
    // trailing closure's body goes here
}

위의 정렬 클로저는 후위 클로저를 사용해 다음과 같이 표현할 수 있다.

reversedNames = names.sorted() { $0 > $1 }

만약 함수의 인자가 후위 클로저뿐이라면 함수를 호출할 때 소괄호를 쓸 필요가 없다.

reversedNames = names.sorted { $0 > $1 }

후위 클로저는 한 줄에 작성할 수 없을 정도로 클로저가 충분히 클 경우 유용하다. 예를 들어 Swift 배열 타입에는 클로저 표현 하나만을 인자로 갖는 map(_:) 메소드가 존재한다.

let digitNames = [
    0: "Zero", 1: "One", 2: "Two",   3: "Three", 4: "Four",
    5: "Five", 6: "Six", 7: "Seven", 8: "Eight", 9: "Nine"
]
let numbers = [16, 58, 510]

let strings = numbers.map { (number) -> String in
    var number = number
    var output = ""
    repeat {
        output = digitNames[number % 10]! + output
        number /= 10
    } while number > 0
    return output
}
// strings is inferred to be of type [String]
// its value is ["OneSix", "FiveEight", "FiveOneZero"]

위 코드는 각 자리수를 구해서 그 자리수를 문자로 변환하고, 10으로 나눠서 자리수를 바꾸며 문자로 변환하는 것을 반복한다. 이 과정을 통해 숫자 배열을, 문자 배열로 바꿀 수 있다. number값은 상수인데, 이 상수 값을 클로저 안에서 변수 var로 재정의 했기 때문에 number값의 변환이 가능하다. 기본적으로 함수와 클로저에 넘겨지는 인자 값은 상수이다.



값 캡쳐 (Capturing Values)

클로저는 그것이 정의된 주변의 문맥으로부터 상수나 변수를 캡쳐할 수 있다. 클로저는 바디에서 이 상수/변수의 값을 보내고 수정할 수 있다. 원본 스코프가 더 이상 존재하지 않더라도 가능하다.

Swift에서 값을 캡쳐할 수 있는 가장 간단한 방법은 중첩 함수(nested function)이다. 중첩 함수는 바깥 함수의 인자나 그 안에서 선언된 상수/변수를 캡쳐해 사용할 수 있다.

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

makeIncrementer의 반환 값 타입은 () -> Int이다. 이 함수는 매개 변수가 없으며 Int 값을 반환한다.


incrementer() 함수만 따로 놓으면 일반적인 함수처럼 보이지 않는다.

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

incrementer() 함수는 매개 변수가 없고, 블록 안에 runningTotalamount도 없다. 이는 상위 함수로부터 runningTotalamount의 참조를 캡쳐해 와서 incrementer()의 바디 안에서 사용하는 방식으로 작동한다. makeIncrementer()가 끝나도 참조의 캡쳐는 사라지지 않고 유지된다.

최적화를 위해 Swift는 더 이상 클로저에 의해 값이 사용되지 않으면 그 값을 복사해 저장하거나 캡쳐링 하지 않는다. Swift는 또 특정 변수가 더 이상 필요하지 않을 때 제거하는 것과 관련한 모든 메모리 관리를 알아서 처리한다.


makeIncrementer(forIncrement:) 함수를 실행해 보자.

let incrementByTen = makeIncrementer(forIncrement: 10)

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

각 호출마다 runningTotal에 10을 더하게 된다.


만약 두 번째로 호출한다면 새로운 runningTotal의 참조를 갖게 된다.

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

incrementByTen()
// returns a value of 40

만약 클로저를 어떤 클래스 인스턴스의 프로퍼티로 할당하고 그 클로저가 그 인스턴스를 캡쳐링하면 강한 순환참조에 빠지게 됩니다. 즉, 인스턴스의 사용이 끝나도 메모리를 해제하지 못하는 것이다. 그래서 Swift는 이 문제를 다루기 위해 캡쳐 리스트(capture list)를 사용한다.



클로저는 참조 타입 (Reference Type)이다.

앞의 예제에서 incrementBySevenincrementByTen은 상수입니다. 그럼에도 runningTotal변수를 계속 증가 시킬 수 있었다. 함수와 클로저는 참조 타입이기 때문이다.

함수와 클로저를 상수나 변수에 할당할 때, 실제로는 상수와 변수에 해당 함수나 클로저의 참조(reference)가 할당된다. 그래서 만약 한 클로저를 두 상수나 변수에 할당하면 그 두 상수나 변수는 같은 클로저를 참조하게 된다.

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

incrementByTen()
// returns a value of 60


이스케이핑 클로저 (Escaping Closures)

클로저가 함수의 인자로 들어갔지만 함수가 반환된 이후에 호출될 때, 클로저는 함수를 탈출했다고 말한다. 클로저를 매개 변수 중 하나로 선언할 때, 이 클로저는 탈출(escape)을 허용한다는 뜻으로 @escaping 어노테이션을 매개 변수 타입 앞에 작성할 수 있다.

클로저가 탈출할 수 있는 하나의 방법은 함수 바깥에서 정의된 변수에 저장되는 것이다. 예를 들어, 많은 함수가 클로저를 completion handler로 취하기 위해 비동기로 작동된다. 이 함수는 클로저는 조작이 끝날 때까지 호출되지 않음에도 조작을 시작한 이후에 반환된다. 클로저는 이후에 호출되기 위해 탈출할 필요가 있다.

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

위 함수에서 인자로 전달된 completionHandlersomeFunctionWithEscapingClosure 함수가 끝나고 나중에 처리 된다. 만약 함수가 끝나고 실행되는 클로저에 @escaping 키워드를 붙이지 않으면 컴파일시 오류가 발생한다.


@escaping 를 사용하는 클로저에서는 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"

someFunctionWithEscapingClosure(_:)는 이스케이핑 클로저이기 때문에 self를 명시적으로 보여 준다.



자동 클로저 (Autoclosures)

자동 클로저는 인자 값이 없으며 특정 표현을 감싸서 다른 함수에 전달 인자로 사용할 수 있는 클로저이다. 자동 클로저는 클로저를 실행하기 전까지 실제 실행이 되지 않는다. 그래서 계산이 복잡한 연산을 하는데 유용하다. 왜냐면 실제 계산이 필요할 때 호출되기 때문이다.

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 배열의 첫 번째 원소가 클로저 안의 코드에 의해 지워졌음에도 불구하고, 클로저가 호출될 때까지 배열의 원소는 실제로 삭제되지 않는다. 만약 클로저가 호출되지 않는다면 클로저 안의 표현 역시 평가되지 않을 것이다.


함수의 인자로 클로저를 넣음으로써 같은 효과를 얻을 수 있다.

// 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함수는 인자로 () -> String) 형, 즉 인자가 없고, String을 반환하는 클로저를 받는 함수이다. 그리고 이 함수를 실행할 때는 serve(customer: { customersInLine.remove(at: 0) } )와 같이 클로저{ customersInLine.remove(at: 0) }를 명시적으로 직접 넣을 수 있다.

함수의 인자로 클로저를 넣을 때 명시적으로 넣는 대신, @autoclosure키워드를 이용할 수 있다. 이제 클로저 대신 String 인자를 받는 함수를 호출할 수 있다. customerProvider매개 변수 타입이 @autoclosure 속성으로 마크됐기 때문에, 인자는 자동적으로 클로저로 변환된다. 그래서 함수의 인자 값을 넣을 때 클로저가 아니라 클로저가 반환하는 반환 값과 일치하는 형의 함수를 인자로 넣을 수 있다. 클로저 인자에 @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를 사용하기에 분명해야 한다.


만약 자동 클로저가 탈출하는 것을 허용하고 싶을 경우, @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!"

collectCustomerProviders함수의 인자 customerProvider@autoclosure이면서 @escaping로 선언되었다. @autoclosure로 선언됐기 때문에 함수의 인자로 리턴값 String만 만족하는 customersInLine.remove(at: 0) 형태로 넣을 수 있다. 이 클로저는 collectCustomerProviders함수가 종료된 후에 실행되는 클로저이기 때문에 인자 앞에 @escaping 키워드를 붙여 준다.

0개의 댓글