준호씨의 블로그

swift - closures (클로저) 본문

개발이야기

swift - closures (클로저)

준호씨 2016. 11. 21. 23:52
반응형


클로저? (참고로 closures 이다. 프로그래밍 언어 중 하나인 clojure 가 아니다.)

C, Objective-C 의 blocks 와 비슷하고 다른 언어의 람다와 비슷하다고 한다.

그런데 blocks?
Apple 이 추가한 비표준 확장이라고 함. 내용을 대충 봐서는 함수포인터를 응용해서 C 에서 클로저 같은걸 사용 할 수 있도록 해 주는 기능인 것으로 보인다.


그나저나 평소에 궁금 했던건 클로저와 람다의 차이점이었다. 비슷해 보이는데 뭐가 다른건지?
stackoverflow 에 유명한 글이 있었다.

What is the difference between a 'closure' and a 'lambda'? 2008.10.21

A lambda is just an anonymous function - a function defined with no name. In some languages, such as Scheme, they are equivalent to named functions. In fact, the function definition is re-written as binding a lambda to a variable internally. In other languages, like Python, there are some (rather needless) distinctions between them, but they behave the same way otherwise.

A closure is any function which closes over the environment in which it was defined. This means that it can access variables not in its parameter list.
 
대충 요약 해 보면 람다는 그냥 익명 함수라고 보면 된다. 클로저는 close over 의 의미를 가지고 있으며 자신이 정의된 영역의 변수를 사용할 수 있다. 일반적인 함수는 매개변수로 받은 값들만 사용 가능 하지만 클로저는 자신이 선언된 곳 영역의 변수에도 접근이 가능 하다는 것이다. 그리고 클로저는 이름이 없다.

다른 함수와 비교를 해 보자면
전역함수: 이름이 있지만 주변의 값을 획득하지 않는 클로저
중첩함수: 이름이 있고 내부의 함수의 값을 획득할 수 있는 클로저. 중첩함수는 함수 안에 함수를 선언한 것이다. 클로저와 성격이 유사한데 이름이 있고 함수 안에서 선언된다는 차이가 있다.


클로저로 코드 단순화 하기
클로저를 이용하면 유추가 가능한 내용들을 생략해서 문법을  최적화(줄이기) 할 수 있다.

swift 문서에 나온 예를 보면 sorted(:by) 함수의 인자로 backward 함수를 작성해서 넘기는 예제가 있다.
func backward(_ s1: String, _ s2: String) -> Bool {
    return s1 > s2
}
var reversedNames = names.sorted(by: backward)

이걸 클로저를 이용해서 점점 줄여 나간다.

클로저 표현식은 다음과 같다. 함수와 유사한데 함수와 달리 이름이 없고 in 다음에 내용을 적는다.
{ ( parameters ) -> return type in
   statements
}

클로저를 이용해서 구현을 해 보면 다음과 같다.
reversedNames = names.sorted(by: { (s1: String, s2: String) -> Bool in
  return s1 > s2
})

한줄로도 표현이 가능 하다.
reversedNames = names.sorted(by: { (s1: String, s2: String) -> Bool in return s1 > s2 })
다만 여전히 좀 장황한 느낌이 있다.

sorted(by:) 함수를 호출한 것이 문자열 배열이고 비교의 결과는 Bool 이기 때문에 sorted 에는 (String, String) -> Bool 로 비교 함을 유추 할 수 있다. 그래서 String 과 -> Bool 은 생략 가능 하다. 이 경우 파라미터를 둘러싼 괄호도 생략 가능 하다.
reversedNames = names.sorted(by: { s1, s2 in return s1 > s2 } )

표현식이 하나뿐인 경우 s1 > s2 내용을 반환하는게 뻔하기 때문에 return 도 생략 가능 하다
reversedNames = names.sorted(by: { s1, s2 in s1 > s2 })

perl 같은 언어에서 함수 마지막에 return 없이 변수만 적어 두면 그걸 리턴 한다는 의미가 되는데 뭔가 비슷한 느낌이다.

swift 에서는 자동으로 단축 인자를 제공 해 주는데 인자들을 순서대로 $0, $1, $2... 순서로 맵핑 해 준다. 단축인자를 사용하면 인자 리스트를 생략 가능 하며 그에 따라 in 키워드도 생략 가능하다.
reversedNames = names.sorted(by: { $0 > $1 })

String 타입은 비교 연잔자를 두 String 인자를 갖고 Bool 타입을 반환하는 함수로 정의하고 있다. 이 연산자는 sorted(by:) 함수와 일치하며 생략이 가능하다. 크기 비교 연산자를 전달하면 String 전용의 구현체를 사용하려고 한다고 유추하게 된다. 그래서 다음과 같이 줄일 수 있다.
reversedNames = names.sorted(by: >)


후행 클로저 (Trailing Closures)
함수 마지막 인자로 클로저를 받는 경우 괄호 밖으로 뺄 수 있다.
javascript 에서 콜백 함수 만들 때 마지막 괄호에 신경 써야 되는 귀찮음이 있었는데 그런 걱정을 덜 수 있게 해 준다.

클로저를 인자로 받는 함수를 하나 선언한다
func someFunctionThatTakesAClosure(closure: () -> Void) {
    // function body goes here
    print("haha")
    closure()
}

후행 클로저를 안쓰면 다음과 같이 사용 한다.
someFunctionThatTakesAClosure(closure: {
    // closure's body goes here
    print("hoho")
})

후행 클로저를 쓰면 다음과 같이 표현 가능 하다.
someFunctionThatTakesAClosure() {
    // trailing closure's body goes here
    print("hihi")
}

앞서 나온 sorted(by:) 도 마찬가지로 사용 가능 하다
reversedNames = names.sorted() { $0 > $1 }

클로저 표현식이 유일한 인자이면 괄호도 생략 가능 하다.
reversedNames = names.sorted { $0 > $1 }


값 캡쳐하기 (Capturing Values)
클로저가 선언된 범위에 있는 변수를 사용할 수 있다는 말이다.

클로저를 선언하고 incrementCounter0 에 할당 해 주었는데 이 클로저는 자신이 선언된 영역에 counter0 이라는 변수가 있고 그 변수를 사용한다.
var counter0 = 0
let incrementCounter0 = {
    counter0 += 1
}

incrementCounter0()
counter0 // 1

incrementCounter0()
counter0 // 2

중첩함수도 자신이 선언된 영역의 변수를 사용 할 수 있다. 아래는 중첩함수의 예이다.
func makeIncrementer(forIncrement amount: Int) -> () -> Int {
    var runningTotal = 0
    func incrementer() -> Int {
        runningTotal += amount
        return runningTotal
    }
    return incrementer
}
incrementer 함수에서 runningTotal 도 사용하고 amount 도 사용한다. 중첩 함수 상위의 함수에서 인자로 받은 amount 는 값을 복사해서 가져온다. 하지만 중첩함수 선언 영역에 있는 runningTotal 는 참조하기 때문에 해당 값을 변경 할 수 있다.

makeIncrementer 함수를 호출 하면 중첩함수인 incrementer 를 리턴하며 이 함수가 호출 되면 makeIncrementer 안에 있던 runningTotal 값이 변경 됨을 알 수 있다.
let incrementByTen = makeIncrementer(forIncrement: 10)

incrementByTen() // 10
incrementByTen() // 20
incrementByTen() // 30

참고로 새로운 incrementer 를 생성하면 서로 독립적인 runningTotal 변수를 참조 하게 된다.

let incrementBySeven = makeIncrementer(forIncrement: 7)
incrementBySeven() // 7

incrementByTen() // 40

그리고 클로저는 참조 타입이라 클로저를 다른 변수나 상수에 할당 하면 같은 클로저라고 보면 된다.
let alsoIncrementByTen = incrementByTen
alsoIncrementByTen() // 50


클로저 벗어나기 (Escaping Closures)
벗어난다는 말이 어색한데 인자로 받은 클로저를 함수 밖의 변수에 저장 한다는 말이다. @escaping 을 써 주면 된다. 안써주면 함수 밖에 변수에 저장 할 수 없다.

아래 예시와 같이 클로저로 받은 내용을 함수 밖의 completionHandlers 에 추가 해 주려면 @escpaing 을 적어 줘야 된다는 거다.
var completionHandlers: [() -> Void] = []
func someFunctionWithEscapingClosure(completionHandler: @escaping () -> Void) {
    completionHandlers.append(completionHandler) // @escaping 이 없었으면 completionHandler 은 벗어날 수 없음
}

이런거를 언제 써야 될지는 잘 모르겠지만 자바나 다른 언어에서 리스너 등록 해 두고 사용 하는 것과 비슷한 느낌이다.

@escaping 사용 하는 경우 명시적으로 self 를 써줘야 되며, @escaping 이 없는 nonescaping 클로저는 self 를 안적어 줘도 된다.

무슨말인지 좀 어려운데 일단 nonescaping 클로저를 하나 선언한다.
func someFunctionWithNonescapingClosure(closure: () -> Void) {
    closure()
}

클래스 내부의 메소드에서 클로저를 사용하는 함수를 호출 할 때 클래스 내부의 변수에 접근 할 때 escaping 클로저는 self.x 라고 적어 줘야 되지만 nonescaping 클로저는 self.x 라고 적어줘도 되고 self 를 생략 해도 된다.
class SomeClass {
    var x = 10
    func doSomething() {
        // escaping closure 는 명시적으로 self 를 기입해야 한다.
        someFunctionWithEscapingClosure { self.x = 100 }

        // nonescaping closure 는 암시적으로 self 를 참조 할 수 있다
        someFunctionWithNonescapingClosure { x = 200 }
    }
}


자동클로저 (Autoclosures)
자동클로저는 인자로 넘겨진 표현식을 랩핑하는 자동으로 생성된 클로저 (직역 하니 좀 이상하다) 라고 하며 이 클로저는 인자를 가지지 않는다. 호출 되면 랩핑된 표현식의 값을 리턴한다. 이러한 문법적인 편의는 중괄호 { } 를 생략 할 수 있게 해 준다.

설명이 좀 장황한데 원래 중괄호 { } 안에 넣던 내용을 자동으로 싸주기 때문에 { } 를 생략 할 수 있다는 말이다.

자동클로저를 사용하는 함수를 호출하는건 흔한데 오토클로저를 사용하는 함수를 작성하는건 흔치 않다. assert(condition:message:file:line:) 을 예를 들자면 condition 과 message 에 자동클로저를 사용하고 condition 은 debug 빌드에서만 사용되며 message 는 condition 이 false 일 때만 사용된다.

자동클로저는 클로저를 호출하기 전까지는 실행되지 않기 때문에 지연실행이 가능하다. 부작용이 있거나 연산이 오래 걸리는 코드에서 유용하다. 왜냐하면 언제 실행할지 제어가 가능 하기 때문. (사실 이게 왜 여기서 설명 하는지 이해가 안감. 그냥 지연 실행에 대한 설명은 따로 두는게 좋지 않을까?)

아래 예를 보면 customerProvider 에서 customersInLine 의 첫번째 내용을 제거 하라고 작성 했지만 실제로 customerProvider 가 호출 되기 전까지는 동작하지 않는다. (앞에서도 계속 나왔듯이 당연한거 아닌가;; 왜 하필 자동클로저에 이 설명이 있을까? 아래 예에도 @autoclosure 는 등장하지 않는다.)
var customersInLine = ["Chris", "Alex", "Ewa", "Barry", "Daniella"]
print(customersInLine.count)
// Prints "5"

let customerProvider = { customersInLine.remove(at: 0) }
print(customersInLine.count)
// 5

print("Now serving \(customerProvider())!")
// Now serving Chris!"

print(customersInLine.count)
// 4

customerProvider 가 문자열이 아니고 () -> String 타입임에 유의한다. 앞서 오토클로저 조건에 나왔듯이 인자가 없다.

아래의 예를 보자
func serve(customer customerProvider: () -> String) {
    print("Now serving \(customerProvider())!")
}
serve(customer: { customersInLine.remove(at: 0) } )
serve(customer:) 함수는 customer 의 이름을 반환하는 명시적인 클로저를 사용한다. 

@autoclosure 사용할 조건에 맞으니 @autoclosure 를 붙인다. 그러면 serve(customer:) 함수 호출시 클로저 표현식에서 중괄호 { } 를 생략 할 수 있게 된다. { } 생략하고 적었지만 자동으로 클로저로 변환된다.
func serve(customer customerProvider: @autoclosure () -> String) {
    print("Now serving \(customerProvider())!")
}
serve(customer: customersInLine.remove(at: 0)) // { } 생략

자동으로 변환되기에 자동클로저를 남용하면 코드를 이해하기 어려워진다. 컨텍스트나 함수명이 실행지연이 된다고 명시적해야 한다. (여기도 지연실행 이야기가 나오는거 보면 자동클로저랑 지연실행이 관련 있는걸까? ㅠㅠ)

앞서 나왔던 벗어나기 (@escaping) 을 하고 싶으면 @escaping 도 써 주면 된다.

// cutomersInLine 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.")
// Collected 2 closures.

for customerProvider in customerProviders {
    print("Now serving \(customerProvider())!")
}
// Now serving Barry!
// Now serving Daniella!

내가 잘 이해가 되지 않는 걸 다시 정리 해 보자면 자동클로저와 지연실행간의 관계가 이해가 안된다. 그냥 클로저도 지연실행 가능한거 아닌가?

처음에 나왔던
let customerProvider = { customersInLine.remove(at: 0) }
요거 선언 해 주고 뒤에 클로저 호출 할 때 실행 되는게 일종의 지연실행 아닌가?

지연실행에 대해 잘못 이해 하고 있는 걸까?

Building assert() in Swift, Part 1: Lazy Evaluation 2014.07.18

여기 내용을 봐도 굳이 @autoclosure 속성을 넣지 않아도 지연실행 되는거 같고 @autoclosure 속성을 넣었을 때 중괄호 { } 를 생략 가능한 거 정도의 차이만 보이는 것 같다.

나중에 혹시 이해가 좀 되는거 같으면 다시 정리 해 봐야 겠다.


참고


반응형
Comments