본문 바로가기
Swift 공식 가이드/Swift 3

Closures

by 토끼찌짐 2017. 2. 21.

Apple 제공 Swift 프로그래밍 가이드(3.0.1)의 Closures 부분을 공부하며 정리한 글입니다. 개인적인 생각도 조금 들어가있습니다.



들어가며

클로저는 코드 내에서 전달되거나 사용될 수 있는 기능을 독립적으로 포함하는(self-contained) 블록이다. C와 Objective-C에서의 블록과 유사하다.

클로저는 자신이 정의된 콘텍스트에 존재하는 상수/변수의 참조를 캡쳐해서 저장할 수 있다. (closing over) 즉 클로저는 자신이 정의되어 있는 곳의 scope에 속하는 상수/변수의 참조에 접근할 수 있다는 말이다. Swift는 이 과정에서 메모리 관리를 알아서 관리해준다.

<Note> 캡쳐라는 개념을 모르더라도 걱정하지 마시라. 나중에 자세히 다룬다. 참고로 이 포스트에서는 가이드에 나온 그대로 캡쳐라고 표기를 했다.


사실 global function, nested function은 클로저의 특수한 케이스에 속한다. 클로저는 다음 세 개의 유형 중 하나다.

  • Global function : 이름이 있고, 아무 값도 캡쳐하지 않는 클로저

  • Nested function : 이름이 있고, 자신을 감싸는 함수(enclosing function)에서 값을 캡쳐할 수 있는 클로저

  • Closure expression : 이름이 없고, 자신을 둘러싼 컨텍스트에서 값을 캡쳐할 수 있는, 가벼운 문법으로 작성된 클로저


Swift의 closure expression 은 깔끔하고, 명확하고, 간결하게 작성할 수 있도록 최적화 되어있으며, 공통 시나리오에서 혼란스럽지 않도록 해준다.

  • 파라미터와 리턴값 타입을 context에서 유추

  • single-expression closure 에서의 명확한 리턴

  • Shorthand argument names

  • 클로저 문법 추적



Closure Expressions

Nested function 은 함수 내부에 다른 함수(코드 블록)를 포함시키고 이름붙일 수 있는 유용한 수단이다. 그러나 경우에 따라 Nested Function 을 정의하기보다는 이와 비슷한 역할을 할 수 있는 함수 비슷한 것을 짧고 간단하게 정의하는 것이 더 유용할 수도 있다. (예를 들면 함수 타입의 파라미터를 여러 개 받는 함수의 경우) 이럴 경우 쓰이는 것이 Closure expression 이다. 이것이 인라인 클로저를 빠르게 작성할 수 있는 방법이다.

그럼 지금부터 sorted(by:) 메서드의 예제를 통해 Closure expression 의 몇 가지 최적화 문법을 살펴보자.


<The Sorted Method>

Swift의 standard library 가 제공하는 sorted(by:) 메서드는 특정 타입의 값들이 들어있는 배열을 정렬해준다. 이때의 정렬기준은 메서드가 argument로 받는 sorting closure에 따른다. 정렬이 끝나면 sorted(by:) 메서드는 값이 정렬된 새로운 배열을 리턴한다. (물론 기존의 배열의 값은 변경되지 않는다)

sorting closure의 역할 : 정렬할 배열에 들어있는 값과 같은 타입을 가지는 argument 두 개를 받아서, 첫 번째 값이 먼저 나와야 하면 true 아니면 false를 리턴.

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

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

위의 예제에서는 sorting closure 를 위해 함수를 하나 정의하고 있다. 그러나 단순히 a > b 를 알려주기 위해 이같이 함수를 만드는 것보다 더 좋은 방법이 있다. Closure expression syntax 를 사용하는 것이다.


<Closure Expression Syntax>

Closure Expression Syntax 의 일반적인 형태다.

상수/변수/tuple/inout 파라미터를 사용할 수 있으며 variadic 파라미터(가변적인 파라미터)도 이름을 붙인다면 사용할 수 있다. 디폴트 값은 허용되지 않는다.

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

위 예제코드의 인라인 클로저와 아까 보았던 backwards 함수는 파라미터 타입과 리턴 타입이 (String, String) -> Bool 로 동일하다. 그러나 인라인 클로저는 파라미터와 리턴 타입의 정의가 대괄호 안에 들어있으며, in 이라는 키워드로 클로저의 내용이 시작된다.

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

클로저 내용이 짧으니까 이렇게 한 줄에 쓸 수도 있다.


<Inferring Type From Context>

sorted 메서드의 경우, 정렬할 배열에 어떤 타입의 값이 저장되어 있는지 알 수 있으므로, 굳이 sorting closure에서 (String, String) -> Bool 이라는 타입을 일일히 명시하지 않아도 컨텍스트에서 유추할 수 있다. 따라서 이런 타입 명시는 다음과 같이 생략할 수 있다.

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

이렇듯 앞으로 인라인 클로저를 함수에 전달할 경우에는 파라미터 타입과 리턴 타입을 명시할 일이 거의 없을 것이다. (당연히 하고 싶으면 해도 된다)


<Implicit Returns from Single-Expression Closures>

예제의 경우, sorted(by:) 메서드의 argument인 sorting closure가 Bool 타입을 리턴해야 하는 것은 명확하다. 또한 s1 > s2 가 Bool 을 리턴하는 것도 명확하다. 고로 return 키워드도 생략할 수 있다.

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


<Shorthand Argument Names>

클로저의 argument 들을 순서대로 $0, $1, $2, ... 등으로 표현할 수 있다. 이 경우 파라미터에 이름을 붙일 필요가 없으므로 in 키워드도 생략 가능하다.

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


<Operator Methods>

사실 여기서 Operator 함수를 쓰면 더 줄일 수가 있다. Swift에서 String 타입은 operator > 를 함수로 정의하고 있다.이것은 Operator 함수에 속하는데, String 타입을 두 개 받아서 Bool 타입의 값을 리턴하는 함수이다. 따라서 다음과 같이 축약할 수 있다.

reversedNames = names.sorted(by: >)

Operator 함수에 대해서는 나중에 자세히 다룹니다.



Trailing Closures

긴 closure expression 을 함수의 마지막 argument 로 넘길 경우 trailing closure 를 쓰는 것이 유용할 수 있다. trailing closure는 함수 호출 시 괄호의 바깥에 쓰는 closure expression이다. 이것은 argument label을 명시해주지 않아도 괜찮다.

func someFunctionThatTakesAClosure(closure: () -> Void) {
    // function body goes here
}
 
// trailing closure 를 안쓰고 최대한 줄이면:
someFunctionThatTakesAClosure(closure: {
    // closure's body goes here
})
 
// trailing closure 를 사용하면 다음처럼 호출할 수 있다:
someFunctionThatTakesAClosure() {
    // trailing closure's body goes here
}


아까 봤던 sorting closure의 예를 들어보면 이렇게 할 수 있다.

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

// 오직 trailing closure 만 argument로 전달되는 경우에는 괄호도 생략가능
reversed = names.sort { $0 > $1 }


trailing closure 은 closure expression이 한 줄에 끝나지 않고 길어지는 경우에 특히 효과적이다. 예를 들어 Array 의 map(_:) 메서드는 argument로 클로저를 하나 받는데 이 클로저는 내용이 길어지는 경우가 많다. (* Array의 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"]

<note> 예제에서 digitNames[number % 10] 뒤에 ! (느낌표) 를 붙인 것에 주목하자. Dictionary의 subscript는 optional 값을 리턴하는데, 위 예제에서는 항상 값이 있음을 우리가 알고 있기 때문에 force-unwrap을 한 것이다.


Capturing Values

클로저는 자신이 정의된 곳을 둘러싼 컨텍스트에서 상수/변수를 캡쳐capture할 수 있다. 한 번 캡쳐를 해놓으면 클로저는 그 상수/변수들의 값을 참조하거나 변경할 수 있는데, 그 상수/변수들이 정의되어 있던 original scope가 더이상 존재하지 않을 때조차 가능하다.

Swift에서 값을 캡쳐하는 클로저의 가장 간단한 예제는 nested function 이다. nested function은 function 안에 정의된 function인데, 이것은 자신이 정의되어 있는 function(즉, outer function)의 상수/변수/함수인자(argument)를 캡쳐할 수 있다.

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

var incrementer = makeIncrementer(forIncrementer: 5)
print(incrementer()) // 5
print(incrementer()) // 10
print(incrementer()) // 15

incrementer 는 runningTotal과 amount의 참조를 캡쳐해놓는다. incrmenter의 실행이 끝나도 계속 값들이 유지가 되어야 하며, 또 다시 불렸을 때에도 runningTotal의 값을 변경할 수 있어야 하기 때문이다.

<note> Swift는 최적화를 위해 클로저의 안밖에서 수정되지 않을 값은 "참조가 아니라 값복사를 해서" 캡쳐하고 저장한다. 따라서 위의 예제에서 amount는 참조캡쳐가 아니라 값캡쳐가 되었을 것이다. 또한 Swift는 클로저에서 캡쳐한 값이 더이상 필요하지 않을 때에는 알아서 메모리 관리를 해준다.

let incrementByTen = makeIncrementer(forIncrement: 10)
incrementByTen()
// returns a value of 10
incrementByTen()
// returns a value of 20
incrementByTen()
// returns a value of 30


// 새로운 incrementer 를 만들경우
// 이전의 incrementer가 캡쳐했던 runningTotal과는 독립적인
// runningTotal 의 참조를 캡쳐하게 된다.
let incrementBySeven = makeIncrementer(forIncrement: 7)
incrementBySeven()
// returns a value of 7
incrementByTen()
// returns a value of 40

<Note> 클로저를 클래스 인스턴스의 프로퍼티로 만들고, 그 클로저가 인스턴스 혹은 인스턴스의 멤버변수를 캡쳐한다면, 강한 참조 싸이클(strong reference cycle)이 만들어질 위험이 있다. 이 싸이클을 깨기 위해 Swift는 capture list 를 사용한다. 자세한 것은 나중에.


Closures Are Reference Types

위 예제코드에서 incrementBySeven 와 incrementByTen 은 상수이다. 그러나 각 클로저가 캡쳐해둔 runningTotal 변수는 호출 시마다 값이 변하고 있다. 이것은 함수와 클로저가 reference type 이기 때문이다.

(incrementBySeven 과 incrementByTen는 상수이므로 가리키는 대상을 바꿀 수는 없다. 그러나 reference type이기 때문에 대상자체는 바꾸지 못해도 대상의 상태, 내부값 등은 바뀔 수 있다는 뜻이다)

함수/클로저를 상수/변수에 할당할 때, 그 상수/변수에는 함수/클로저를 가리키는 참조가 할당된다. 예제로 이해를 해보자. 참조이기 때문에, incrementByTen 을 다른 상수/변수에 할당하게 되면 두 개는 같은 클로저를 가리키게 된다.

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



Escaping Closures

기본적으로 클로저를 파라미터로 받는 함수에서 클로저는 그 함수를 빠져나갈 수 없다. 즉 해당 함수 안에서만 실행되고 함수가 종료되는 시점에 같이 소멸되는 것이다. 이때 파라미터로 받는 클로저 앞에 @escaping 키워드를 붙여주면 클로저가 함수를 빠져나가는 것이 허락된다.

예를 들면 asynchronous operation을 한 뒤 파라미터로 받은 클로저 (ex: completion handler) 를 실행해주는 함수를 생각해볼 수 있다. 이런 경우 operation이 시작되자마자 함수는 return이 되어버리기 때문에, 함수가 종료된 시점이 아니라 operation이 끝난 시점에서 클로저가 호출되기 위해서는 클로저가 escape가능해야 한다.

한 가지 예제 코드를 보자.

var completionHandlers: [() -> Void] = []
func someFunctionWithEscapingClosure(completionHandler: 
           @escaping () -> Void) {
    completionHandlers.append(completionHandler)
    // 함수 인자로 넘어온 completionHandler라는 클로저가
    // 함수 바깥의 변수에 저장되는 것이 허락된다
    // (함수 외부로 탈출가능)
}


<추가설명> @escaping 붙이지 않으면 허락되지 않는 것들



func someFunctionWithNonescapingClosure(closure: () -> Void) {
    closure()
   // 파라미터로 받은 클로저가 이 함수에서만 쓰이는게 확실할 때는
   // escaping 을 허락하지 말자
}

class SomeClass {
    var x = 10
    func doSomething() {
        someFunctionWithEscapingClosure { self.x = 100 }
        // self 명시 필요       

        someFunctionWithNonescapingClosure { x = 200 }
        // self 명시할 필요 없음
    }
}
 
let instance = SomeClass()
instance.doSomething()
print(instance.x)
// prints "200"
 
completionHandlers.first?()
print(instance.x)
// prints "100"

Escaping 클로저가 아닌 경우 어차피 해당 함수 안에서만 실행되기 때문에 self 를 명시할 필요가 없다.



Autoclosures

오토클로저는 함수 argument로 넘어온 expression 을 자동으로 감싸서 만들어주는 클로저다. @autoclosure 키워드를 파라미터에 붙이게되면 함수 argument로 클로저 형태가 아닌 일반 expression을 넘겨도 자동으로 클로저로 만들어준다.


오토클로저가 쓰이는 예를 하나 들어보면 Assert가 있다.

// 정의를 보면 condition 은 사실 클로저이다
public func assert(_ condition: @autoclosure () -> Bool, _ message: @autoclosure () -> String = default, file: StaticString = #file, line: UInt = #line)

// 그러나 밖에서 쓸때는 treu/false만 셋팅하고 있다
assert(false, "")


오토클로저는 delay evaluation 이 가능하게 해준다. 클로저를 호출할 때까지 expression 수행이 시작되지 않기 때문이다. 예를 들어 첫번째 파라미터로 오토클로저를 받는 someFunction(expensiveComputationForRemoveArrayItem, "") 라는 함수를 생각해보자. expensiveComputationForRemoveArrayItem의 역할은 배열의 아이템을 삭제하는 것이고, 이것은 @autoclosure에 의해 자동으로 클로저로 바뀌게 되고, 클로저가 실행되기 전까지는 배열의 아이템 삭제도 수행이 되지 않는다. someFunction 안에서 클로저가 호출되기 전까지는 배열의 아이템이 삭제되지 않는 것이다.

참고로 오토클로저를 escape 클로저로 지정하고 싶다면 @autoclosure @escaping 를 같이 명시해주면 된다.


'Swift 공식 가이드 > Swift 3' 카테고리의 다른 글

Classes and Structures  (0) 2017.02.27
Enumerations  (0) 2017.02.27
Functions  (0) 2017.02.19
Control Flow  (0) 2017.02.15
Collection Types - Dictionary  (0) 2017.02.11