Swift - 클로저

임성빈·2022년 3월 9일
1

Swift

목록 보기
7/26
post-thumbnail
post-custom-banner

클로저


클로저는 코드블럭으로 C와 Objective-C의 블럭과 다른 언어의 람다와 비슷하다. 전역 함수와 중첩 함수은 실제 클로저의 특별한 경우입니다. 클로저는 다음 세 가지 형태 중 하나를 갖습니다.

  • 전역 함수 : 이름이 있고 어떤 값도 캡쳐하지 않는 클로저
  • 중첩 함수 : 이름이 있고 관련한 함수로 부터 값을 캡쳐 할 수 있는 클로저
  • 클로저 표현 : 경량화 된 문법으로 쓰여지고 관련된 문맥으로부터 값을 캡쳐할 수 있는 이름이 없는 클로저

Swift에서 클로저 표현은 최적화 되어서 간결하고 명확하다. 최적화에는 다음과 같은 내용을 포함한다.

  • 문맥에서 인자 타입과 반환 타입의 추론
  • 단일 표현 클로저에서의 암시적 반환
  • 축약된 인자 이름
  • 후위 클로저 문법

클로저 표현

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

정렬 메소드

Swift의 표준 라이브러리에 sorted(by:)라는 알려진 타입의 Array 값을 정열하는 메소드를 제공한다. 여기 by에 어떤 방법으로 정렬을 수행할 것인지에 대해 기술한 클로저를 넣으면 그 방법대로 정렬된 Array를 얻을 수 있다. sorted(by:)메소드는 원본 Array는 변경하지 않는다.

let names = ["짱구","철수","맹구","이슬"]

sorted(by:) 메소드는 Array의 콘텐츠와 같은 타입을 갖고 두 개의 인자를 갖는 클로저를 인자로 사용한다. names의 콘텐츠는 String 타입이므로(String, String) -> (Bool)의 타입의 클로저를 사용해야한다.

func backward(_ s1: String, _ s2: String) -> Bool {
	return s1 > s2
}
var reversedNames = names.sorted(by: backward)
// reversedNames is equal to ["철수", "짱구", "이슬", "맹구"]

backward 클로저를 만들고 names.sorted(by: backward) 에 넣으면 원본 Array에서 순서가 바뀐 Array를 얻을 수 있다.

클로저 표현 문법

클로저 표현 문법은 일반적으로 아래의 형태를 띈다.

{ (parameters) -> return type in
	statements
}

인자로 넣은 parameters, 인자값으로 처리할 내용을 기술하는 statements 그리고 return type 이다.
앞의 backward 클로저를 이용해 Array를 정렬하는 코드는 클로저 표현을 이용해 다음과 같이 바꿀 수 있다.

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

위와 같이 클로저의 인자값과 반환 타입을 생략할 수 있지만, 가독성과 코드의 모호성을 피하기 위해 타입을 명시할 수도 있다.

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

단일 표현 클로저에서는 반환 키워드를 생략할 수 있다.

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

이렇게 타입을 생략해도 Swift의 타입추론 덕분에 어떠한 모호성도 없이 s1과 s2를 인자로 받아 그 두 값을 비교한 결과를 반환한다.

인자 이름 축약

Swift는 인라인 클로저에 자동으로 축약 인자 이름을 제공한다.
이 인자를 사용하면 인자값을 순서대로 $0, $1, $2 등으로 사용할 수 있다. 축약 인자 이름을 사용하면 인자값과 그 인자로 처리할 때 사용하는 인자가 같다는 것을 알기 때문에 인자를 입력 받는 부분과 in 키워드 부분을 생략할 수 있다.

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

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

연산자 메소드

심지어 여기서도 축약을 더 할 수 있다. Swift의 String 타입 연산자에는 String 끼리 비교할 수 있는 비교 연산자(>)를 구현해 두었다.

reversedNames = names.sorted(by: >)

후위 클로저

만약 함수의 마지막 인자로 클로저를 넣고 그 클로저가 길다면 후위 클로저를 사용할 수 있다.

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

위 클로저의 인자값 입력 부분과 반환형 부분을 생략할 수 있다.

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

이것을 후위 클로저로 표현하면 이렇게 표현할 수 있다.

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

앞의 정렬 예제를 후위 클로저를 이용해 표현할 수 있다.

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

만약 함수의 마지막 인자가 클로저이고 후위 클로저를 사용하면 괄호() 를 생략할 수 있다.

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

이번에는 후위 클로저를 이용해 Int를 String으로 Mapping하는 예제를 살펴보자.

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]

이 값을 Array의 map(_:) 메소드를 이용해 특정값을 다른 특정값으로 매핑할 수 있는 클로저를 구현한다.

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
}
print(strings)
// strings는 타입 추론에 의해 String타입의 Array를 갖는다.
// Prints : ["OneSix", "FiveEight", "FiveOneZero"]

위 코드는 각 자리수를 구해서 그 자리수를 문자로 변환하고, 10으로 나눠서 자리수를 바꾸며 문자로 변환하는 것을 반복한다. 이 과정을 통해 Int 타입의 Array를 String 타입의 Array로 바꿀 수 있다.

digitNames[number % 10]! 에서 뒤에 !가 붙어있는 것은 Dictionary의 subscipt는 옵셔널이기 때문이다.
즉, Dictionary에서 특정 key에 대한 값은 있을 수도 있고 없을 수도 있기 때문이다.


값 캡쳐

클로저는 특정 문맥의 상수나 변수의 값을 캡쳐할 수 있다. 다시말해 원본 값이 사라져도 클로저의 body 안에서 그 값을 활용할 수 있다. Swift에서 값을 캡쳐하는 가장 단순한 형태는 중첩 함수이다. 중첩 함수는 함수의 body 에서 다른 함수를 다시 호출하는 형태로 된 함수이다.

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

이 함수는 makeIncermenter 함수 안에서 incermenter 함수를 호출하는 형태로 중첩 함수이다.

인자와 반환값 (forIncrement amount: Int) -> () -> Int 중에 처음 -> 을 기준으로 앞의 (forIncrement amount: Int) 부분이 인자값이고 () -> Int 부분이 반환값이다.
반환값을 인자가 없고 Int 형의 클로저를 반환한다는 의미이다.

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

incrementer 함수는 파라미터가 없고runningTotalamount 도 없지만 runningTotalamount 가 캡쳐링 되어서 함수는 돌아간다.

let incrementByTen = makeInrcementer(forIncerment: 10)

makeIncrementer 함수는 클로저를 반환한다.
여기서 makeIncrementer 내부의 incrementer 함수를 실행하는 메소드를 반환한다.

incrementByTen()
// 값으로 10을 반환
incrementByTen()
// 값으로 20을 반환
incrementByTen()
// 값으로 30을 반환

함수가 각기 실행되지만 실제로는 변수 runningTotalamount 가 캡쳐링 되서 그 변수를 공유하기 때문에 계산이 누적된다.

이때 새로운 클로저 생성하게 되면 고유의 저장소에 runningTotalamount 를 캡쳐링해서 사용하기 때문에 다른 값이 나온다.

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

또 다시 이전의 클로저를 실행한다면 이전 저장소의 변수를 사용해 계산한다.

incrementByTen()
// 값으로 40을 반환

클로저는 참조 타입

앞의 예제에서 incrementBySevenincrementByTen 은 상수이다. 그런데 함수와 클로저는 참조 타입이기 때문에 runningTotal 변수를 계속 증가시킬 수 있다. 함수와 클로저를 상수나 변수에 할당할 때 실제로는 상수와 변수에 해당 함수나 클로저의 참조가 할당된다.

let alsoIncrementByTen = incrementByTen
alsoIncrementByTen()
// 값으로 50을 반환

앞에서 사용했던 클로저를 상수에 할당하고 실행시키면 사용한 클로저의 마지막 상태에서 한번 더 계산한 결과값을 반환한다.


이스케이핑 클로저

클로저를 함수의 파라미터로 넣을 수 있는데, 함수가 끝나고 실행되는 클로저 예를 들어, 비동기로 실행되거나 completionHandler 로 사용되는 클로저는 파라미터 타입 앞에 @escaping 이라는 키워드를 명시해야 한다.

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 }
        // 명시적으로 self를 언급
        
        someFunctionWithNonescapingClosure { x = 200 }
    }
}

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

completionHandlers.first?()
print(instance.x)
// Prints "100"

자동클로저

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

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 : ["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 키워드를 이용해서 더 간결하게 사용할 수 있다.

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

serve 함수의 인자를 받는 부분 customerProvider: @autoclosure () 에서 클로저의 인자 () 앞에 @autoclosure 라는 키워드를 붙였다. 이 키워드를 붙임으로써 인자값은 자동으로 클로저로 변환된다. 그래서 함수의 인자값을 넣을 때 클로저가 아니라 클로저가 반환하는 반환값과 일치하는 형의 함수를 인자로 넣을 수 있다.
serce(customer: { customersInLine.remove(at: 0) }) 이 코드를 @autoclosure 키워드를 사용했기 때문에
serve(customer: customersInLine.remove(at: 0)) 이렇게 {} 없이 사용할 수 있다.
정리하면 클로저 인자에 @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!"

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

profile
iOS 앱개발
post-custom-banner

1개의 댓글

comment-user-thumbnail
2022년 5월 11일

Javascript 에서 클로저를 이해하기가 그렇게 어려웠는데 Swift에도 람다 대신해서 클로저가 있군요. 람다를 대신한다는 말에서 Swift는 잘 모르지만 힌트 얻고 갑니다 :D

답글 달기