[Swift] Closures (클로저)

LEEHAKJIN-VV·2022년 5월 9일
0

Study-Swift 5.6

목록 보기
8/22

참고사이트
The Swift Programming Language


Closures (클로저)

클로저는 코드 블록으로 C와 Objective-C의 블록과 다른 프로그래밍의 람다와 비슷하다. 클로저는 상수나 변수가 저장된 코드에서 그들에 대한 참조를 저장하거나 caputre(캡처) 할 수 있다.

NOTE
capturing에 대한 설명은 글 중반부에 설명한다.

Global function과 nested function은 실제로 closures의 특별한 케이스다. closures는 다음 세 가지 형태 중 하나를 가진다.

  • Global functions(전역 함수): 이름이 있고 어떤한 값도 캡처하지 않는 closures
  • Nested functions(중첩 함수): 이름이 있고 관련된 함수로부터 값을 캡처 할 수 있는 closures
  • Closure expressions(클로저 표현): 경량화된 문법으로 쓰이고 주위의 context로부터 값을 캡처할 수 있는 이름이 없는 closures

Swift에서 Clousre expressions은 최적화되어 간결하고 명확하다. 이 최적화에는 다음과 같은 내용이 포함된다.

  • context에서 인자 타입과 반환 타입의 추론
  • 단일 표현 clousre에서 암시적 반환
  • Shorthand argument names(축약된 인자 이름)
  • Trailing clousre syntax(후위 클로저 문법)

Closure Expressions (클로저 표현)

Closure Expression은 inline closure를 명확하게 표현하는 방법으로 문법에 초점이 맞춰져 있다. closure Expression은 코드의 명확성과 의도를 잃지 않으면서도 문법을 축약해 사용할 수 있는 다양한 최적화 방법을 제공한다.

The Sorted Method (정렬 메소드)

Swift의 표준 라이브러리에는 배열의 값들을 정렬해 주는 sorted(by:) 메서드를 제공한다. 이는 (by)에 어떤 방법으로 정렬을 수행할 것인지에 대해 기술한 closure를 넣으면 그 방법대로 정렬된 배열을 얻을 수 있다. sorted(by:) 메서드는 정렬된 새로운 배열을 반환하고 원본 배열은 수정되지 않는다.

아래 배열을 sorted(by:) 메서드를 이용해 알파벳 역순으로 정렬해 보자.

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

sorted(by:) 메서드는 배열의 타입과 같은 타입을 갖고, 같은 타입인 2개의 인자를 갖는 clousre를 받는다. 그리고 첫 번째 값이 두 번째 값 앞 또는 뒤에 나타나야 하는지를 결정하는 Bool형을 반환한다. sorting closures는 첫 번째 값이 두 번째 값 앞에 나타나야 한다면 true를 반환하고 그렇지 않으면 false를 반환한다.

이 예제에서 정렬하고자 하는 배열이 String values를 가지므로 sorting closure는 (String, String) -> Bool 타입을 가진다.

closure를 제공하는 일반적인 방법은 함수를 만드는 것이다. 위 타입을 만족시키는 함수를 만든다는 것은 sorted함수의 인자에 넣을 수 있는 클로저를 만든다는 뜻이다.

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

func backward(_ s1: String, _ s2: String) -> Bool {
    return s1 > s2 
}

var reversedNames = names.sorted(by: backward(_:_:))
// reversedNames ["Ewa", "Daniella", "Chris", "Barry", "Alex"]

만약 s1이 s2보다 크다면 backward(::) 함수는 true를 반환하고 이것은 정렬된 배열에서 s1이 s2보다 앞에 위치한다는 것을 의미한다. 문자열의 문자에서 크다는 의미는 사전 순으로 뒤에 있는 것을 의미한다.("A" < "B", "Tim" < "Tom")

예제를 다소 긴 코드로 작성했는데 앞으로 closure의 다양한 문법 및 사용하여 더 축약된 코드로 작성을 해본다.


Closure Expression Syntax (클로저 표현 문법)

closure expression 문법은 일반적으로 다음 형식을 가진다.

closure expression 문법에서 파라미터는 in-out parameter일수 있지만 default value를 가질 수 없다. 만약 variadic parameter의 이름을 지정하면 variadic parameter를 사용할 수 있다. 반환 타입과 파라미터 타입으로 튜플도 사용이 가능하다.

아래 예제는 위의 backward(::)함수의 closure expression 버전이다.

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

이렇게 함수로 따로 정의된 형태가 아닌 인자로 들어가 있는 형태의 closure를 inline closure라고 부른다. 이 inline closure와 backward(::)함수의 파라미터 타입과 반환 타입이 같다는 것을 확인할 수 있다.

clousre의 몸통(body)의 시작은 in 키워드로 시작된다. 이 키워드는 closure의 파라미터 타입과 반환 타입의 선언이 끝났음과 동시에 closure body가 시작된다는 것을 알린다.

closure가 매우 짧기 때문에 한 줄로 표현이 가능하다.

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

Inferring Type From Context (문맥에서 타입 추론)

sorting closure는 메서드에 인자로 전달되기 때문에 Swift는 파라미터 타입과 반환 타입을 유추할 수 있다. sorted(by:)메서드는 string 배열에서 호출되기 때문에 이것의 해당 인자는 (String, String) -> Bool이어야 한다. 이는 closure expression에서 선언의 일부가 작성될 필요가 없다는 것을 의미한다. 모든 타입은 추론될 수 있기 때문에 반환을 나타내는 화살표(->)와 파라미터의 이름 주위의 괄호도 생략될 수 있다.

아래는 파라미터 타입, 반환 타입, 화살표, 괄호을 생략하여 위의 sorting closure을 다시 선언하였다.

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

위와 같이 clousre의 파라미터 타입과 반환 타입을 생략할 수 있지만, 가독성과 코드의 모호성을 피하기 위해 의도적으로 타입을 명시할 수 있다.


Implicit Returns from Single-Express Clousres (단일 표현 클로저에서 암시적 반환)

Single-Expression closure에서 반환 키워드를 생략할 수 있다.

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

sorted(by:)함수의 타입은 closure에 의해서 Bool타입을 반환된다는 것이 명확하다. 그렇기 때문에 반환 키워드를 생략하여도 모호성이 없다.


Shorthand Arguments Names (축약 인자 이름)

Swift는 inline closure에 자동으로 shorthand argument name(축약 인자 이름)을 제공하고, 이 인자를 사용하면 $0, $1, $2등의 이름으로 closure 인자의 값을 참조하는 데 사용할 수 있다.

closure expression(클로저 표현식)에서 shorthand argument name을 사용하면 closure의 인자들을 생략할 수 있다. shorthand argument name의 타입은 함수의 타입으로부터 예측되며, 함수 파라미터의 수는 shorthand argument의 가장 높은 숫자로부터 예측된다. 또한 closure expression이 body의 전체를 구성하기 때문에 in 키워드를 생략할 수 있다.

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

Operation Methods (연산자 메소드)

Swift의 String 타입 연산자에는 String끼리 비교할 수 있는 비교 연산자(>)가 구현되어 있다. 그래서 연산자를 사용하여 더 짧게 표현할 수 있다.

reversedNames = names.sorted(by: >)

Trailing Closures (후위 클로저)

만약 함수의 마지막 인자로 closure를 넣고 그 길이가 길다면 trailing closure를 사용할 수 있다. 이런 형태의 함수와 closure가 있다면

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

위 closure의 파라미터 입력 부분과 반환 형 부분을 생략해 다음과 같이 표현할 수 있다.

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

이를 trailing closure로 표현하면 아래와 같이 표현할 수 있다. 함수를 대괄호 {}로 묶어 그 안에 처리할 내용을 적으면 된다. 사실 일반적인 전역 함수 형태가 클로저를 사용하고 있던 것이다.

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

앞의 정렬 예제를 trailing closure를 이용해 표현하면 다음과 같이 표현할 수 있다.

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

함수의 인자에 closure뿐이고 이것을 trailing closure로 표현한다면 괄호()를 생략할 수 있다.

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

Trailing closure는 closure의 길이가 너무 길어 한 줄의 인라인으로 작성할 수 없을 때 유용하다. 예를 들어 Swift의 배열은 한 개의 인자를 가지는 closure expression을 가지고 있다. 이 closure은 배열의 각 항목에 대해 한번 호출되고 아이템과 매핑된 값을 반환한다.(배열과 다른 유형일 수도 있음) map에 전달되는 closure의 코드를 작성하여 매핑의 특성과 반환되는 값의 타입을 지정할 수 있다.

배열의 각각의 요소에 대해 전달된 closure을 적용한 후 map 메서드는 원본 배열의 해당하는 값과 동일한 순서로 새롭게 매핑된 값들을 포함한 새로운 배열을 반환한다.

다음은 map 메서드와 trailing closure을 이용하여 Int형 배열을 String형 배열로 변환하는 방법이다

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]

이제 numbers 배열을 사용하여 배열의 map 메서드의 closure expression에 전달하여 Int형 배열을 String형 배열로 바꾼다.

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

map 메서드는 배열의 각 아이템에 대해 closure expression을 한번 호출한다. 위 예제는 배열의 각 아이템에 대해 10으로 나눠 자릿수를 구하고 이를 바꿔가며 문자로 변환하는 것을 반복한다. number 값은 상수인데 closure 안에서 var로 재정의 했기 때문에 number 값의 변환이 가능하다.(기본적으로 함수와 closure에 넘겨지는 파라미터 값은 상수)

NOTE
digitNames[number % 10]! 에서 느낌표가 붙어있는 것을 확인할 수 있다. 이는 dictionary의 subscript는 optional이기 때문이다. 위의 딕셔너리는 모든 자릿수에 대한 키를 가지고 있기 때문에 코드에서 force-unwrap을 해줬다.


Capturing Values (값 캡처)

closure는 선언된 코드를 둘러싸는 context에서 상수나 변수들을 capture 할 수 있다. closure는 상수와 변수를 선언한 원래 영역이 존재하지 않는 경우에도 해당 상수 및 변수의 값을 참조하고 수정할 수 있다.

Swift에서는 값을 capture 할 수 있는 가장 간단한 형태의 closure는 nested function(중첩 함수)이다. nested function은 외부 함수의 파라미터와 외부 함수 내의 상수나 변수들을 캡쳐할 수 있다.

여기에 makeIncremeter 함수는 incrementer nested function을 포함한다. nested function인 incrementer()runningTotal, amount 2개의 값을 capture한다. 이 값들을 capture한 후에 incrementer는 호출될 때마다 runningTotalamount만큼 증가시키는 closure로 makeIncrementer에 의해 반환된다.

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

makeIncrementer의 반환 타입은 [ () -> Int ]이다. 즉 파라미터가 없고 반환하는 값이 Int다.

makeIncrementer(forIncrement:) 함수는 변수인 runningTotal을 선언하고 이는 초깃값이 0이며 incrementer가 반환할 값들을 저장한다.

nested function 부분만 따로 보면 이상하게 보일 수도 있다.

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

NOTE
최적화를 위해서 Swift는 해당 값이 closure에 의해 변경되지 않고, 그리고 closure가 생성된 후 그 값이 변경되지 않은 경우 Swift는 대신 값의 복사본을 캡하고 저장할 수 있다. 또한 Swift는 특정 변수가 더 이상 필요하지 않을 때 제거하는 것과 관련한 모든 메모리 관리를 알아서 처리한다.

이제 위의 makeIncrementer를 실행시켜 보자.

let incrementByTen = makeIncrementer(forIncrement: 10)

이 예제에서 호출될 때마다 runningTotal에 10을 추가하는 incrementer 함수를 참조하는 상수를 선언했다. 이 함수를 여러 번 호출하면 다음과 같은 행동을 볼 수 있다.

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

함수가 각기 실행되지만 실제로는 변수 runningTotal과 amount가 capturing 돼서 그 변수를 공유하기 때문에 계산이 누적된다. 그러면 아래와 같이 새로운 closure를 생성해 보자.

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

다른 closure이기 때문에 다른 고유의 저장소의 runningTotal과 amout를 capturing 해서 사용하는 것을 확인할 수 있다. 다시 처음 closure를 실행해 보자.

incrementByTen()
// returns a value of 40

역시 자기 자신만의 저장소의 변수를 사용하여 계산하는 것을 확인할 수 있다.

NOTE
class 인스턴스의 속성에 closure를 할당하고 closure가 인스턴스 또는 멤버를 참조하여 인스턴스를 참조하는 경우, closure와 인스턴스 사이에 strong reference cycle(강한 순환 참조)에 빠지게 된다. Swift는 이 문제를 다루기 위해 capture list(캡처 리스트)를 사용한다.


Closures Are Reference Types (클로저는 참조 타입)

앞의 예제에서 incrementBySevenincremnetByTen은 상수이다. 그러나 runningTotal을 증가시키는 것을 확인할 수 있다. 이는 함수와 클로저가 reference type(참조 타입)이기 때문이다. 함수나 클로저를 상수나 변수에 할당할 때 실제로 클로저의 자체 내용이 아닌 함수나 클로저의 참조(reference)가 할당된다. 그래서 한 개의 클로저를 두 상수나 변수에 할당하면 같은 클로저를 참조하는 것이다. c나 c++에 익숙한 사람들은 함수 포인터를 저장한다고 생각하자.

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

incrementByTen()
// returns a value of 60

asloIncrementByTeincrementByTen은 같은 클로저를 참조하기 때문에 같은 runningTotal을 증가시키는 것을 확인할 수 있다.


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

closure는 함수에 인자로 전달될 때 함수를 escape 한다고 말하지만 사실은 함수가 반환된 후에 closure가 실행된다. 함수가 파라미터로 closure를 가지면 파라미터의 타입 앞에 @escaping 키워드를 작성하여 closure가 escape 할 수 있다는 것을 명시적으로 나타낼 수 있다.

NOTE
escape 뜻은 단어 뜻처럼 탈출한다는 의미가 아니라 closure가 인자로 사용된 함수가 종료된 후 함수 외부에서 실행된다는 뜻이다.

closure가 escape 할 수 있는 방법 중 1가지는 함수의 외부에 정의된 변수에 저장되는 것이다. 예를 들어 asynchronous(비동기) 작업을 하는 많은 함수들은 completion handler로 closure를 인자로 사용한다. 함수는 작업을 시작한 후 나중에 반환되지만, 그러나 closure는 작업이 완료될 때까지 실행되지 않는다.

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

someFunctionWirhEscapingClosure(_:) 함수는 인자로 closure를 가지고 함수 외부의 선언된 배열에 closure를 추가한다. 만약 파라미터에 @escaping 키워드를 생략하면 compile-time error를 확인할 수 있다.

self를 참조하는 escaping closure는 self가 class의 인스턴스를 참조한다면 특별한 고려가 필요하다. 왜냐하면 escaping closure에서 self를 caputring하는 것은 실수로 strong reference cycle(강한 순환 참조)를 쉽게 만들 수 있기 떄문이다.

일반적으로 closure는 closure 본문에서 변수를 사용하여 암시적으로 변수를 캡처하지만 이 예제에서는 명시적이어야 한다. self를 캡처하려면 명시적으로 self를 작성하거나 closure의 캡처 목록에 self를 포함해야 한다. self를 명시적으로 작성하면 코드의 의도를 표현할 수 있고 reference cycle(순환 참조)가 없음을 확인할 수 있다.

아래 예제 코드를 보면 someFunctionWithEscapingClosure(_:)에 전달된 closure는 명시적으로 self를 참조한다. 그러나 someFunctionWithNonescapingClosure(_:)에 전달된 closure는 nonescaping closure로 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"

다음은 closure의 capture list에 포함하여 self를 capture하는 doSoemthing() 버전이다.

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

Autoclosures (자동 클로저)

Autoclosure는 함수에 파라미터로 전달되는 표현식을 wrap하기 위해 자동으로 생성되는 closure 이다. 파라미터를 사용하지 않으면 내부에 wrapped된 값을 반환한다.

Autoclosure는 closure가 호출될때 까지 내부코드가 실행되지 않기 때문에 delay evaluation(평가 지연)을 할수 있다. Delaying evaluation은 그 코드가 evaluated되는 시기를 제어할 수 있기 때문에 부작용이 있거나 계산 비용이 많이 드는 코드에 유용하다. 아래 예제는 dealy evaluation하는 방법을 보여준다.

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"

customerInLine 배열은 closure 내부의 코드에서 첫 번째 요소가 제거되지만, 그 closure가 실제로 호출될때까지 배열의 요소는 제거되지 않는다. 만약 closure가 계속 호출되지 않는다면 closure안의 expression은 절대 실행이 안된다.

NOTE
customerProvider의 타입은 String이 아니라 () -> String타입 이다.

함수의 인자로 closure를 전달하여 똑같은 결과를 얻을 수 있다.

// 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:) 함수는 손님의 이름을 반환하는 명시적 closure를 가진다.

아래 예제의 server(customer:) 함수는 동일한 작업을 수행하나 명시적 closure대신 @autoclosure 속성으로 파라미터 타입을 표시하여 autoclosure를 사용한다. 이제 함수를 호출할 떄 closure를 인자로 가지지 않고 String을 전달한다. 파라미터 타입을 자동으로 closure라고 변환하기 때문에 {} 생략하여 String 타입을 인자로 전달할 수 있다.(closure 반환 타입이 String이기 떄문)

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

NOTE
Autoclosure를 남용하면 코드를 이해하는데 어려울 수 있다. 그래서 context와 함수 이름이 autoclosure 사용하기에 분명해야 한다.

만약 escape이 가능한 autoclosure를 사용하고 싶다면, @autoclosure@escaping속성을 사용한다.

// customersInLine is ["Barry", "Daniella"]
var customerProviders: [() -> String] = []        //  클로저를 저장하는 배열을 선언
func collectCustomerProviders(_ customerProvider: @autoclosure @escaping () -> String) {
    customerProviders.append(customerProvider)
} // 클로저를 인자로 받아 그 클로저를 customerProviders 배열에 추가하는 함수를 선언
collectCustomerProviders(customersInLine.remove(at: 0))    // 클로저를 customerProviders 배열에 추가
collectCustomerProviders(customersInLine.remove(at: 0))

print("Collected \(customerProviders.count) closures.")
// Prints "Collected 2 closures."        // 2개의 클로저가 추가 됨
for customerProvider in customerProviders {
    print("Now serving \(customerProvider())!")    // 클로저를 실행하면 배열의 0번째 원소를 제거하며 그 값을 출력
}
// Prints "Now serving Barry!"
// Prints "Now serving Daniella!"

0개의 댓글