Swift 정리 - 13. 클로저


Swift에서 중요한 클로저를 알아보자.


애플은 자사 프레임워크를 모두 객체지향 프로그래밍 패러다임을 기반으로 구현했다. 새로운 언어 Swift에 굳이 함수형 프로그래밍 패러다임을 도입할 필요가 없었는데, 애플이 Swift를 발표할 때 직접 함수형 프로그래밍 언어라고 Swift를 소개했듯이 애플이 Swift를 만들었을 때 객체지향 프로그래밍 패러다임만큼이나 함수형 프로그래밍 패러다임에 중점을 뒀음을 알 수 있다.

함수형 프로그래밍 패러다임의 장점은 대규모 병렬처리에 유리하다, 스레드에 비교적 안전하다, 콜백 등 연관 처리에 있어서 코드가 분산되는 것을 최소화할 수 있다는 것이다. 기존 객체지향 프로그래밍 패러다임만을 사용했을 때 불편했던 부분을 Swift의 함수형 프로그래밍 패러다임으로 개선할 수 있다는 것을 알아보자.

클로저

클로저는 함수형 프로그래밍 패러다임을 이해하기 위한 중요한 개념중 하나다. 클로저는 특정 기능을 하는 블럭이고, 이 블럭은 코드 내에서 전달되고 사용될 수 있다. Swift의 클로저는 C와 Objective-C의 블럭, 그리고 다른 언어에서의 lambda와 비슷하다. 함수도 클로저의 한 형태다.

클로저는 정의된 위치에서 상수나 변수의 참조를 capture(획득)하고 저장할 수 있다. 이를 해당 상수와 변수들을 closing over한다고 한다. Swift는 이런 capturing을 할 때 사용되는 메모리들을 모두 관리해준다.

클로저에는 세 가지 형태가 있다.

  • 글로벌 함수 : 이름이 있고, 아무런 값도 capture하지 않음
  • 중첩 함수 : 이름이 있고, 자신을 감싸는 함수의 값을 capture함
  • 클로저 표현 : 이름이 없고, 주변 문맥에서의 값들을 capture할 수 있는 간단한 문법으로 작성된 클로저

Swift는 다양하게 표현할 수 있다.

  • 문맥에서 파라미터와 리턴 값을 유추할 수 있기 때문에 파라미터와 리턴 값을 생략할 수 있다.
  • 한 줄의 표현만 있을 경우 이를 암묵적으로 반환한다.
  • 축약된 전달인자 이름을 사용할 수 있다.
  • Trailing closure(후행 클로저)문법을 사용할 수 있다.

클로저 표현

중첩 함수는 더 넓은 범위의 함수의 일부 코드에 이름을 부여하고 이를 블럭으로 감싼 것이다. 하지만 이렇게 이름과 함께 정의하는 완전한 형태가 아닌 간단한 형태로 함수처럼 작동하는 블럭을 작성하는 게 유용한 경우가 있다.

Closure expressions는 인라인 클로저를 간단한 문법으로 작성하는 방법이다. 클로저 표현은 클로저를 분명하고, 의도를 명확하게 하면서 간단하게 클로저를 작성하게 해주는 여러 문법 최적화를 제공한다.

sorted(by:) 메서드를 같은 기능을 하면서도 여러 방법으로 작성하는 법을 알아보자.

Sorted Method

Swift 표준 라이브러리는 내가 제공하는 정렬하는 클로저의 결과에 따라 배열을 정렬하는 함수 sorted(by:)를 제공한다. 정렬하는 과정이 끝나면 sorted(by:) 메서드는 기존 배열에서 정렬된 새로운 버전의 배열을 리턴한다. 기존 배열은 수정되지 않고, 새로운 배열이 하나 더 생성되는 것이다.

sorted(by:) 메서드는 배열 요소의 타입과 같은 타입의 인자 두 개를 받고, 두 요소 중 어떤 게 더 먼저 위치해야 하는지를 나타내는 Bool 값을 리턴한다.

  • true를 리턴할 경우 : 첫 번째 요소가 두 번째 요소 앞에 와야 한다.
  • false를 리턴한 경우 : 두 번째 요소가 첫 번째 요소 앞에 와야 한다.

만약 아래와 같은 String 배열이 있다고 해보자.

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

이 배열에 sorted(by:) 메서드를 호출할 때 정렬하는 클로저는 (String, String) -> Bool 타입의 함수가 되어야 한다.

클로저를 제공하는 첫 번째 방법은 올바른 타입의 일반적인 함수를 정의해서 전달인자로 보내는 것이다.

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와 같은 단일한 표현만이 필요한 것에 비해 너무 길게 작성되었다.

전달 인자로 함수를 보내기 함수형 프로그래밍 패러다임에서 전달인자에 함수를 전달하는 것은 자연스러운 일이다.

Closure Expression 문법

Closure Expression 문법은 아래의 일반적인 형태로 작성한다.

image

클로저의 파라미터는 in-out 파라미터가 될 수 있지만, 기본값을 가질 수는 없다. Variadic 파라미터를 사용할 수도 있고, 파라미터 타입과 리턴 타입으로 튜플을 사용할 수도 있다.

위 예시에서 사용한 backward(_:_:) 함수를 클로저 표현으로 작성하면 아래와 같다.

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

이 인라인 클로저의 파라미터, 리턴 타입의 정의가 backward(_:_:) 함수에서와 같다는 것을 확인할 수 있다. 인라인 클로저 표현에서 파라미터들과 리턴 타입은 괄호 안에서 작성되었다.

클로저 body는 in 다음에 나온다. in 키워드는 클로저의 파라미터들과 리턴 타입 정의가 끝났고, 클로저의 body가 시작됨을 알려준다.

문맥에서 타입 추론하기

클로저가 메서드의 전달인자로 전달되었기 때문에 Swift는 클로저의 파라미터들과 리턴 타입을 추론할 수 있다. 위 예시에서 sorted(by:) 메서드는 String 배열에서 호출되었기 때문에 전달인자의 타입이 (String, String) -> Bool임을 봤었다. 따라서 클로저 표현의 정의부분에서 이런 타입 정보를 쓰지 않아도 된다. 타입이 추론되기 때문에 정의하는 부분이나, 리턴 화살표도 쓰지 않아도 된다.

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

클로저가 함수나 메서드의 인자로 사용될 때 항상 타입 추론을 할 수 있기 때문에 인라인 클로저를 완전한 형태로 작성하지 않아도 된다. 다만 코드를 읽는 사람에게 타입 등을 분명하게 하기 위해서 명시적으로 작성해도 된다.

한 줄 클로저에서 암묵적으로 리턴하기

한 줄 클로저는 해당 줄의 결과를 암묵적으로 리턴하기 때문에 return 키워드를 쓰지 않아도 된다.

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

축약된 전달인자 이름

Swift는 자동으로 인라인 클로저에 축약된 전달인자 이름을 제공한다. $0, $1, $2, … 등으로 표현한다. 만약 축약된 전달인자 이름을 클로저에서 사용하면 클로저의 전달인자를 적은 부분을 뺄 수 있다. 가장 높게 작성한 번호의 전달인자 이름이 클로저가 받는 전달인자 개수를 정한다. in 키워드도 뺄 수 있다.

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

Operator 메서드들

위 표현을 더 간단하게 작성할 수 있다. Swift의 String 타입은 내부 구현에서 > 연산자를 두 개의 String을 전달인자로 받고 Bool 값을 리턴하는 메서드로 정의하고 있다. 그리고 이 타입은 sorted(by:) 메서드에서 요구하는 메서드 타입과 일치하기 때문에 단순히 연산자를 전달할 수 있다. 그러면 Swift는 String 타입에서 정의된 특별한 구현을 사용할 것임을 추론한다.

reversedNames = names.sorted(by: >)

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
}

위에서 작성한 정렬 클로저는 후행 클로저로 sorted(by:) 메서드 괄호 밖에서 작성될 수 있다.

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

만약 클로저 표현이 함수나 메서드의 유일한 인자고 표현을 후행 클로저로 제공하고 싶다면 소괄호를 붙이지 않고 호출할 수도 있다.

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

후행 클로저는 클로저가 인라인에서 한 줄에 다 작성하는게 불가능할 정도로 길 때 사용하면 좋다.

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"]

만약 함수가 여러 개의 클로저를 가진다면, 첫 번째 후행 클로저의 전달인자 레이블을 쓰지 않고, 나머지 후행 클로저의 레이블은 써야 한다.

func loadPicture(from server: Server, completion: (Picture) -> Void, onFailure: () -> Void) {
    if let picture = download("photo.jpg", from: server) {
        completion(picture)
    } else {
        onFailure()
    }
}

loadPicture(from: someServer) { picture in
    someView.currentPicture = picture
} onFailure: {
    print("Couldn't download the next picture.")
}

Capturing Values

클로저는 자신이 정의된 위치 주변의 문맥에서 상수와 변수를 capture 할 수 있다. 값을 획득하고 클로저는 상수와 변수가 존재하던 범위 밖에서도 해당 상수와 변수의 값을 참조하고 수정할 수 있다. 클로저는 비동기 작업에 많이 사용되는데, 클로저를 통해 비동기 콜백을 작성하는 경우 현재 상태를 미리 capture하지 않으면 클로저를 실행하는 순간에 주변 상수나 변수가 메모리에 존재하지 않는 경우가 발생하게 될 것이다. 클로저는 값 획득을 통해 상수와 변수가 더 이상 존재하지 않더라도 값을 참조하고 사용할 수 있기 때문에 비동기 작업에 많이 사용된다.

값을 capture할 수 있는 클로저의 가장 간단한 형태는 중첩 함수다. 중첩 함수는 밖의 함수의 인자들이나 밖의 함수에서 정의된 상수나 변수의 값을 capture할 수 있다.

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

makeIncrementer 의 리턴타입은 () -> Int다. 이는 함수를 리턴한다는 뜻이다.

내부의 incrementer() 함수만 따로 떼어놓고 보면 뭔가 부자연스럽다.

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

runningTotalamount를 함수 내부 어디에서도 정의하고 있지 않은데 이를 참조하고 있다. 이는 이 함수 주변에서 runningTotalamount에 대한 참조를 capture 하고, 자신 함수 body에서 사용하는 것이다. 참조를 capture하면 makeIncrementer가 종료되도 runningTotalamount가 사라지지 않게 보장해주고, 다음에 incrementer 함수가 호출됐을 때도 runningTotal이 살아있게 보장해준다.

최적화로, Swift는 값이 클로저에 의해 변경되지 않거나, 클로저가 생성된 후 값이 변하지 않는 경우에 해당 값을 capture하고 복사본을 저장할 수 있다. 이런 과정에서 사용되는 메모리를 Swift는 모두 관리해준다.

let incrementByTen = makeIncrementer(forIncrement: 10)

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

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

incrementByTen()
// returns a value of 40

클래스 인스턴스 프로퍼티로서의 클로저 만약 클로저를 클래스 인스턴스의 프로퍼티에 할당했고, 클로저가 해당 인스턴스나 멤버에 대한 참조를 capture했다면, 클로저와 인스턴스간에 강한 참조를 생성하게 된다. Swift는 capture list를 사용해서 이런 강한 참조 사이클을 없앤다.

클로저는 참조 타입

위 예시에서는는 runningTotal 변수를 획득해서 계속 참조할 수 있었다. 이는 함수와 클로저가 참조타입이기 때문이다. 상수나 변수에 함수나 클로저를 할당하면 실제로는 그 상수나 변수를 해당 함수나 클로저에 대한 참조로 만들어버리는 것이다.

즉 아래와 같이 같은 클로저에 대한 참조를 다른 상수나 변수에 할당해서 사용할 수 있다.

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

incrementByTen()
// returns a value of 60

Escaping Closure

함수의 인자로 클로저가 전달됐는데, 함수가 끝난 후에 클로저가 호출될 때 함수를 escape한다고 한다. 함수의 전달인자를 정의할 때 클로저가 탈출할 수 있다는 걸 표시하기 위해 파라미터 타입 전에 @escaping을 써서 표시한다.

탈출 클로저는 사용하는 한 예는 비동기 작업이다. 많은 비동기 작업을 하는 함수들은 클로저를 completion handler로서 인자로 받는다. 함수는 작업이 끝난 후 클로저를 리턴하는데, 작업이 끝날때까지 클로저는 호출되지 않기 때문에 나중에 작업이 끝나고 나서 호출하기 위해서 escape해야 하는 것이다.

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

위 예제에서 @escaping 로 파라미터를 표시하지 않으면 컴파일 에러가 발생한다. @escaping 으로 표시되지 않은 클로저는 기본적으로 비탈출 클로저다. 비탈출 클로저는 탈출 클로저와 반대로 함수의 동작이 끝난 후 사용할 필요가 없을 때 사용한다.

self를 참조하는 탈출 클로저는 self가 클로저의 인스턴스를 참조할 때 특별히 주의해야 한다. 탈출 클로저에서 self를 획득하면 강한 참조 사이클이 생기기 쉽다. 일반적으로 클로저는 클로저 바디 내에서 특정 변수를 사용해서 암묵적으로 변수를 capture하는데, 이 경우에는 명시적으로 표시해야 한다. self를 capture하고 싶으면 self를 사용할 때 명시적으로 쓰거나, 클로저의 capture list에 self를 포함시켜야 한다. self를 명시적으로 쓰면 의도를 분명히 할 수 있고, 참조 사이클이 없는지 다시 한 번 확인할 수 있게 해준다.

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

class SomeClass {
    var x = 10
    func doSomething() {
        // self를 명시적으로 써야 한다.
        someFunctionWithEscapingClosure { self.x = 100 }
        someFunctionWithNonescapingClosure { x = 200 }
    }
}

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

completionHandlers.first?()
print(instance.x)

클로저의 capture list에 self를 포함시켜야 capture하려면 아래와 같이 쓴다. 이러면 self를 암묵적으로 참조한다.

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

만약 self가 구조체나 열거형이라면 self를 항상 암묵적으로 참조할 수 있다. 하지만 탈출 클로저는 self가 구조체나 열거형의 인스턴스일 때 self에 대한 변경가능한 참조를 획득할 수 없다. 구조체나 열거형은 shared mutability를 허용하지 않는다.

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

somFunctionWithEscaptingClousre 함수에 전달한 클로저는 mutating 메서드이기 때문에, self가 변경가능하게 된다. 근데 이는 구조체의 self에 대한 변경 가능한 참조를 획득할 수 없다는 규칙을 어기기 때문에 에러가 난다.

withoutActuallyEscaping

비탈출 클로저로 전달한 클로저가 탈출 클로저인 척 해야 할 때가 있다. 실제로는 escape 하지 않는데 다른 함수에서 탈출 클로저를 요구할 때다.

func hasElements(in array: [Int], match predicate: (Int) -> Bool) -> Bool {
    return (array.lazy.filter({ predicate($0) }).isEmpty == false)
}

위 메서드에서는 lazy 컬렉션에 있는 filter 메서드의 매개변수로 비탈출 클로저 predicate를 전달한다. 그런데 lazy 컬렉션은 비동기 작업을 할 때 사용되기 때문에 filter 메서드는 탈출 클로저를 요구한다. 그래서 아래와 같이 탈출 클로저 자리에 비탈출 클로저를 전달할 수 없다는 오류가 뜨게 된다.

image

하지만 hasElements 함수를 보면 match 클로저가 탈출할 필요가 없다. 이때 비탈출 클로저를 탈출 클로저인 것처럼 사용할 수 있게 하는 withoutActuallyEscaping(_:do:) 함수가 있다.

let numbers = [2, 4, 6, 8]

let evenNumberPredicate = { (number: Int) -> Bool in
    return number % 2 == 0
}

let oddNumberPredicate = { (number: Int) -> Bool in
    return number % 2 == 1
}

func hasElements(in array: [Int], match predicate: (Int) -> Bool) -> Bool {
    
    return withoutActuallyEscaping(predicate) { escapingClosure in
        return array.lazy.filter { escapingClosure($0) }.isEmpty == false
    }
}

Autoclosure

Autoclosure는 함수의 인자로 전달된 표현을 감싸기 위해 자동으로 생성된 클로저를 말한다. 인자를 받지 않고, 호출됐을 때 안에 감싸진 표현의 값을 리턴한다. 이렇게 자동 클로저는 함수로 전달하는 클로저를 어려운 클로저 문법을 사용하지 않고도 클로저로 사용할 수 있게 문법적 편의를 제공한다.

자동 클로저는 클로저가 호출되기 전까지 클로저 내부의 코드가 동작하지 않는다. 이렇게 연산을 지연해주는 건 부작용이 있거나 무겁고 복잡한 연산을 가진 코드에 유용한데, 코드의 실행을 제어할 수 있기 때문이다.

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

위 코드는 명시적으로 클로저를 인자로 받고 고객의 이름을 리턴한다. 아래의 코드를 보자.

// 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 attribute로 표시해서 자동 클로저를 받는다. 이제 함수를 호출할 때 클로저 대신에 String 인자를 받는 것처럼 할 수 있다. 인자는 자동으로 클로저로 변환된다.

자동 클로저를 필요 이상으로 사용하면 코드를 이해하기 어렵게 한다.

기본적으로 @autoclousre를 사용하게 되면 @noescape 속성도 부여된다. 포함 만약 자동 클로저를 escape할 수 있게 하고 싶다면, @autoclosure@escaping attribute를 모두 사용하면 된다.

// 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!"