xho95 (소중한꿈)'s Swift Life

Apple 에서 공개한 The Swift Programming Language (Swift 5.7) 책의 Closures 부분1을 번역하고, 설명이 필요한 부분은 주석을 달아서 정리한 글입니다. 전체 번역은 Swift 5.7: Swift Programming Language (스위프트 프로그래밍 언어) 에서 확인할 수 있습니다.

Closures (클로저; 잠금 블럭)

클로저 (Closures) 는 그 자체로-동작하는 블럭으로 코드 안에서 전달하고 사용할 수 있습니다. 스위프트의 클로저는 C오브젝티브-C 에 있는 블럭 및 다른 프로그래밍 언어에 있는 람다식[^lambdas] 과 비슷합니다.

클로저는 자신이 정의된 곳의 어떤 상수와 변수로의 참조든 붙잡고[^capture] 저장할 수 있습니다. 이를 그 상수와 변수를 잠근다 (closing over) 라고 합니다. 스위프트는 붙잡는데 필요한 모든 메모리 관리를 직접 처리합니다.

붙잡는다라는 개념에 익숙치 않아도 걱정할 필요 없습니다. 아래의 Capturing Values (값 붙잡기) 에서 자세히 설명할겁니다.

전역 함수와 중첩 함수는, Functions (함수) 에서 소개했듯, 실제로는 클로저의 특수한 경우들입니다.2 클로저는 세 가지 형식 중 하나를 차지합니다:

스위프트의 클로저 표현식은 스타일이 깔끔하고, 명확하며, 일반적인 상황에서 간결하고, 번잡하지-않은 구문이 되도록 최적화합니다:

Closure Expressions (클로저 표현식)

중첩 함수는, Nested Functions (중첩 함수) 에서 소개하듯, 더 큰 함수 안에서 그 자체로-동작하는 코드 블럭에 이름을 붙여 정의하는 편리한 수단입니다. 하지만, 더 짧은 버전의 함수-같은 구조를 완전한 선언과 이름 없이 작성하는게 유용할 때도 있습니다. 이는 작업할 함수나 메소드가 자신의 인자로 함수를 입력 받을 때 특히 더 그렇습니다.

클로저 표현식 은 인라인 클로저8 를 간결하고, 집중된 구문으로 작성하는 방법입니다. 클로저 표현식이 제공하는 여러가지 구문 최적화로 클로저를 짧게 줄인 형식으로 작성하면서도 분명함과 의도를 잃지 않습니다. 아래의 클로저 표현식 예제는 이런 최적화를 묘사하기 위해 단일한 sort(by:) 메소드 예제를 여러번 반복하여 다듬는데, 이들 각각은 똑같은 기능을 더 간단명료하게 표현합니다.

The Sorted Method (정렬 메소드)

스위프트 표준 라이브러리가 제공하는 sorted(by:) 메소드는, 제공된 정렬 클로저의 출력에 기반하여, 알려진 타입의 값 배열을 정렬합니다. 일단 한 번 정렬 과정을 완료하면, sorted(by:) 메소드가 반환하는 새로운 배열은 예전과 똑같은 타입과 크기이나, 그 원소는 올바로 정렬된 순서입니다. 원본 배열을 sorted(by:) 메소드가 수정하진 않습니다.

아래의 클로저 표현식 예제는 sorted(by:) 메소드로 String 값의 배열을 알파벳 역순으로 정렬합니다. 정렬할 초기 배열은 이렇습니다:

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

sorted(by:) 메소드는 클로저 하나를 받아들이는데 이는 배열 내용물과 똑같은 타입의 두 인자를 입력받고, Bool 값을 반환하여 일단 한 번 값을 정렬하면 첫 번째 값이 두 번째 값 앞에 있는게 좋은지 뒤에 있는게 좋은지 말해줍니다. 첫 번째 값이 두 번째 값 앞에 있는게 좋으면 정렬 클로저가 true 를 반환할 필요가 있으며, 그 외 라면 false 입니다.

이 예제는 String 값 배열을 정렬하므로, 정렬 클로저에 필요한 건 (String, String) -> Bool 타입의 함수입니다.

정렬 클로저를 제공하는 한 방법은 올바른 타입의 보통 함수를 작성하고, 이를 sorted(by:) 메소드의 인자로 전달하는 겁니다:

func backward(_ s1: String, _ s2: String) -> Bool {
  return s1 > s1
}
var reversedNames = names.sorted(by: backward)
// reversedNames 는 ["Ewa", "Daniella", "Chris", "Barry", "Alex"] 와 같음

첫 번째 문자열 (s1) 이 두 번째 문자열 (s2) 보다 크면, backward(_:_:) 함수가 true 를 반환하여, 정렬된 배열에서 s1s2 앞에 있는게 좋다고 지시할 겁니다. 문자열 안의 문자가, “보다 크다 (greater)” 는 건 “알파벳에서 더 나중에 나타난다” 는 의미입니다. 이는 글자 "B" 가 글자 "A" “보다 크며”, 문자열 "Tom" 이 문자열 "Tim" “보다 크다” 는 의미입니다. 이는 알파벳 역순으로 정렬한 걸 주도록, "Barry""Alex" 앞에 두기, 등을 계속합니다.

하지만, 이는 본질적으로 단일-표현식 함수 (a > b) 를 쓰기에는 다소 좀 길고-지루한 방식입니다. 이 예제에선, 클로저 표현식 구문으로, 정렬 클로저를 인라인으로 작성하는게 더 좋을 수 있습니다.

Closure Expression Syntax (클로저 표현식 구문)

클로저 표현식 구문엔 다음의 일반 형식이 있습니다:

    { (parameters-매개 변수) -> return type-반환 타입 in
        statements-구문
    }

클로저 표현식 구문의 매개 변수 (parameters) 는 입-출력 매개 변수9 일 수 있지만, 기본 값이 있을 순 없습니다. 가변 매개 변수에 이름을 붙이면 가변 매개 변수를 사용할 수 있습니다. 튜플을 매개 변수 타입과 반환 타입으로 사용할 수도 있습니다.

아래 예제는 위에 있던 backward(_:_:) 함수의 클로저 표현식 버전을 보여줍니다:

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

이 인라인 클로저에 있는 매개 변수와 반환 타입 선언은 backward(_:_:) 함수에 있는 선언과 정체가 같다는 걸 기록하기 바랍니다. 두 경우 모두, (s1: String, s2: String) -> Bool 이라고 씁니다. 하지만, 인라인 클로저 표현식에선, 매개 변수와 반환 타입을 중괄호 안에 (inside) 쓰지, 밖에다 쓰지 않습니다.

클로저 본문의 시작은 in 키워드로 도입합니다. 이 키워드는 클로저의 매개 변수와 반환 타입 정의는 종료했고, 클로저의 본문을 시작하려 한다고 지시합니다.

클로저 본문이 너무 짧기 때문에, 심지어 단 한 줄로 쓸 수도 있습니다:

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

이는 sorted(by:) 메소드의 전체 호출이 똑같게 남아 있는 걸 묘사합니다. 한 쌍의 괄호가 여전히 메소드의 전체 인자를 감싸고 있습니다.10 하지만, 그 인자는 이제 인라인 클로저입니다.

Inferring Type From Context (상황으로 타입 추론하기)

정렬 클로저는 메소드에 인자로 전달되기 때문에, 이것의 매개 변수와 반환 값 타입을 스위프트가 추론할 수 있습니다. sorted(by:) 메소드는 문자열 배열에서 호출11 하고 있으므로, 그 인자는 반드시 (String, String) -> Bool 타입의 함수여야 합니다. 이는 클로저 표현식의 정의 부분에 (String, String)Bool 타입을 작성할 필요가 없다는 걸 의미합니다. 모든 타입을 추론할 수 있기 때문에, 매개 변수 이름 주변의 반환 화살표 (->) 와 괄호도 생략할 수 있습니다:

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

클로저를 인라인 클로저 표현식으로 함수나 메소드에 전달할 땐 매개 변수 타입과 반환 타입을 추론하는게 항상 가능합니다. 그 결과, 클로저를 함수나 메소드 인자로 사용할 땐 인라인 클로저를 완전체 형식으로 작성할 필요가 절대 없습니다.

그럼에도 불구하고, 원한다면 여전히 타입을 명시할 수도 있고, 코드가 헷갈리는걸 피해준다면 그러는 걸 권장합니다. sorted(by:) 메소드의 경우, 정렬을 한다는 사실에 의해 클로저의 용도도 명확하며, 문자열 배열의 정렬을 거들고 있기 때문에, 클로저가 String 값과 작업할 것 같다고 가정해도 안전합니다.

Implicit Returns from Single-Expression Closures (단일-표현식 클로저의 암시적 반환)

단일-표현식 클로저에서 단일 표현식 결과를 암시적으로 반환하려면 선언에서 return 키워드를 생략하면 되는데, 이전 예제로 이렇게 한 버전은 이렇습니다:

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

여기서, sorted(by:) 메소드 인자의 함수 타입은 클로저가 반환하는게 반드시 Bool 값이라는 걸 명확하게 합니다. 클로저 본문이 담고 있는게 Bool 값을 반환하는 단일 표현식 (s1> s2) 이기 때문에, 헷갈릴 것 없이, return 키워드를 생략할 수 있습니다.

Shorthand Argument Names (짧게 줄인 인자 이름)

스위프트는 자동으로 인라인 클로저에 짧게 줄인 인자 이름을 제공하는데, 이를 쓰면 클로저 인자 값을 $0, $1, $2, 등의 이름으로 참조할 수 있습니다.

클로저 표현식 안에서 짧게 줄인 인자 이름을 쓰면, 정의에선 클로저의 인자 목록을 생략할 수 있습니다. 짧게 줄인 인자 이름의 타입은 예상한 함수 타입으로 추론하며, 가장 높은 수의 짧게 줄인 인자는 클로저가 입력 받을 인자 개수를 결정합니다. in 키워드도 생략할 수 있는데, 클로저 표현식이 전적으로 자신의 본문으로만 이루어지기 때문입니다:

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

여기서, $0$1 은 클로저의 첫 번째와 두 번째 String 인자를 참조합니다. $1 이 가장 높은 수의 짧게 줄인 인자기 때문에, 두 개의 인자를 입력 받는 클로저라고 이해합니다. 여기 있는 sorted(by:) 함수는 인자가 둘 다 문자열인 클로저라고 예상하기 때문에, 짧게 줄인 인자인 $0$1 은 둘 다 String 타입입니다.

Operator Methods (연산자 메소드)

실제로는 위의 클로저 표현식을 심지어 더 짧게 (shorter) 쓸 방법도 있습니다. 스위프트의 String 타입은 문자열에-특화된 보다 큰 연산자 (>) 를 자신의 메소드로 구현하여 이는 String 타입의 매개 변수 두 개로 Bool 타입의 값을 반환합니다. 이건 sorted(by:) 메소드에 필요한 메소드 타입과 정확하게 맞습니다. 그러므로, 보다 큰 연산자 (>) 만 단순히 전달할 수도 있으며, 스위프트가 (알아서) 문자열에-특화된 구현을 쓰고 싶어 한다고 추론할 겁니다.

reversedNames = names.sorted(by: >)

연산자 메소드에 대한 더 많은 건, Operator Methods (연산자 메소드) 부분을 보기 바랍니다.

Trailing Closures (뒤에 딸린 클로저)

클로저 표현식을 함수의 최종 인자로 전달할 필요가 있는데 클로저 표현식이 아주 길면, 그 대신 뒤에 딸린 클로저 (trailing closure) 로 작성하는 게 유용할 수 있습니다. 뒤에 딸린 클로저는 함수 호출 괄호 뒤에 쓰는데, 그래도 뒤에 딸린 클로저는 여전히 함수 인자입니다. 뒤에 딸린 클로저 구문을 사용할 땐, 첫 번째 클로저의 인자 이름표 (argument label) 는 함수 호출 부분에 작성하지 않습니다. 함수 호출은 여러 개의 뒤에 딸린 클로저를 포함할 수 있지만; 아래의 첫 몇몇 예제들은 단 하나의 뒤에 딸린 클로저만 사용합니다.

func someFunctionThatTakesAClosure(closure: () -> Void) {
  // 함수 본문은 여기에 둠
}

// 뒤에 딸린 클로저를 쓰지 않고 이 함수를 호출하는 방법은 이렇습니다:

someFunctionThatTakesAClosure(closure: {
  // 클로저 본문은 여기에 둠
})

// 뒤에 딸린 클로저를 써서 이 함수를 호출하는 방법은 이렇습니다:

someFunctionThatTakesAClosure() {
  // 뒤에 딸린 클로저 본문은 여기에 둠
}

위의 Closure Expression Syntax (클로저 표현식 구문) 절에 있는 문자열-정렬 클로저는 sorted(by:) 메소드 괄호 밖에다가 뒤에 딸린 클로저로 쓸 수도 있습니다:

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

클로저 표현식이 함수나 메소드에 제공된 유일한 인자인데 그 표현식을 뒤에 딸린 클로저로 제공한다면12, 함수를 호출할 때 함수나 메소드 이름 뒤에 한 쌍의 괄호 () 를 쓸 필요가 없습니다:

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

뒤에 딸린 클로저는 한 줄로 쓰는 게 불가능할 정도로 클로저가 충분히 길 때 가장 유용합니다. 한 예로, 스위프트의 Array 타입엔 map(_:) 메소드가 있는데, 이는 클로저 표현식을 자신의 단일 인자로 입력 받습니다. 클로저는 배열의 각 항목마다 한 번씩 호출하며, 그 항목의 (어떠한 다른 타입일 수 있는) 대체 맵핑 값13 을 반환합니다. 맵핑 고유의 성질과 반환 값 타입을 지정하려면 map(_:) 에 전달할 클로저 안에다 코드를 쓰면 됩니다.

제공된 클로저를 각각의 배열 원소에 적용한 후에, map(_:) 메소드는 모든 새 맵핑 값을, 원본 배열 안의 자신의 해당 값과 똑같은 순서로, 담은 새로운 배열을 반환합니다.

map(_:) 메소드와 뒤에 딸린 클로저를 사용하여 Int 값 배열을 String 값 배열로 변환하는 방법은 이렇습니다. 배열 [16, 58, 510] 을 써서 새로운 배열인 [ "OneSix", "FiveEight", "FiveOneZero"] 를 생성합니다:

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 배열로 String 값 배열을 생성하려면, 클로저 표현식을 뒤에 딸린 클로저로 배열의 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
}
// strings 는 [String] 타입이라고 추론함
// 그 값은 ["OneSix", "FiveEight", "FiveOneZero"] 임

map(_:) 메소드는 클로저 표현식을 배열의 각 항목마다 한 번씩 호출합니다. 클로저의 입력 매개 변수인, number 의, 타입은 지정할 필요가 없는데, 맵핑할 배열의 값으로 타입을 추론할 수 있기 때문입니다.

이 예제에선, number 변수를 클로저의 number 매개 변수 값으로 초기화해서, 클로저 본문 안에서 값을 수정할 수 있습니다. (함수와 클로저의 매개 변수는 항상 상수입니다.)14 클로저 표현식은 String 이라는 반환 타입을 지정하여, 맵핑된 출력 배열 안에 저장할 타입도 지시합니다.

클로저 표현식은 매 번 호출할 때마다 output 이라는 문자열을 제작합니다. 이는 나머지 연산자 (number % 10) 로 number 의 마지막 숫자를 계산하고, 이 숫자로 digitNames 딕셔너리에서 적절한 문자열을 찾아 봅니다. 클로저를 사용하면 0보다 큰 어떤 정수라도 문자열로 나타낸 걸 생성할 수 있습니다.

digitNames 딕셔너리의 첨자 호출 뒤엔 느낌표 (!) 가 있는데, 딕셔너리 첨자는 옵셔널 값을 반환하는 걸로 키가 존재 안하면 딕셔너리 찾아보기가 실패할 수 있다는 걸 지시하기 때문입니다. 위의 예제에선, number % 10digitNames 딕셔너리의 유효한 첨자 키라는 걸 항상 보증하므로15, 느낌표로 첨자의 옵셔널 반환 값 안에 저장된 String 값을 강제로-풉니다.

digitNames 딕셔너리에서 가져온 문자열은 output 앞에 (front) 추가되며, 그 효과로 문자열 버전의 수를 역순으로 제작합니다. (표현식 number % 1016 이면 6, 58 이면 8, 510 이면 0 을 줍니다.)

그런 다음 number 변수를 10 으로 나눕니다. 정수이기 때문에, 나눗셈 중에 값이 잘려서, 161 , 585, 51051 이 됩니다.

이 과정을 반복하다 number0 과 같은, 그 순간 클로저가 output 문자열을 반환하고, map(_:) 메소드에 의해 출력 배열에 추가됩니다.

위 예제에서 뒤에 딸린 클로저 구문을 사용하면 클로저가 지원할 함수 바로 뒤에 클로저 기능을 깔끔하게 감춰서[^encapsulate], map(_:) 메소드의 바깥쪽 괄호로 전체 클로저를 감쌀 필요가 없습니다.

함수가 입력 받을 클로저가 여러 개면, 첫 번째로 뒤에 딸린 클로저의 인자 이름표는 생략하고 나머지로 뒤에 딸린 클로저엔 이름표를 붙입니다. 예를 들어, 아래의 함수는 사진 전시관[^gallary] 에 사진을 실어 올립니다:

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

이 함수를 호출하여 사진을 실어 올릴 땐, 두 개의 클로저를 제공합니다. 첫 번째 클로저는 완료 처리자16 로 다운로드가 성공한 후에 사진을 보여줍니다. 두 번째 클로저는 에러 처리자[^error-handler] 며 사용자에게 에러를 보여줍니다.

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

이 예제에서, loadPicture(from:completion:onFailure:) 함수는 자신의 네트워크 임무를 백그라운드로 급파하여17, 네트워크 임무를 종료할 때 두 완료 처리자 중 하나를 호출합니다. 함수를 이런 식으로 작성하면 네트워크 실패를 책임지는 코드와 다운로드 성공 후에 사용자 인터페이스를 업데이트하는 코드를 깨끗하게 분리하여, 단 하나의 클로저로 두 가지 상황을 모두 처리하는 걸 대신하게 해줍니다.

완료 처리자는 읽기 어려워질 수 있는데, 특히 여러 개의 처리자를 중첩해야 할 때 그렇습니다. 대안은, Concurrency (동시성) 에서 설명하듯, 비동기 코드를 사용하는 겁니다.

Capturing Values (값 붙잡기)

클로저는 자신이 정의된 곳 주위의 상수와 변수를 붙잡을 (capture) 수 있습니다. 그러면 클로저가 자신의 본문 안에서 그 상수와 변수를 참조하고 수정할 수 있는데, 심지어 상수와 변수를 정의한 원본 시야가 더 이상 존재하지 않더라도 그렇습니다.18

스위프트에서, 값을 붙잡을 수 있는가장 단순한 형식의 클로저는, 또 다른 함수 본문 안에 쓴, 중첩 함수입니다. 중첩 함수는 자기 바깥쪽 함수의 어떤 인자든 붙잡을 수 있으며 바깥 함수에서 정의한 어떤 상수 및 변수도 붙잡을 수 있습니다.

incrementer 라는 중첩 함수를 담은, makeIncrementer 라는 함수의 예는 이렇습니다. 중첩된 incrementer() 함수는, 자기 주위에 있는, runningTotalamount 라는,두 값을 붙잡습니다. 이 값들을 붙잡은 후, makeIncrementerincrementer 를 클로저로 반환하는데 이는 호출될 때마다 runningTotalamount 만큼 증가시킵니다.19

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

makeIncrementer 의 반환 타입은 () -> Int 입니다. 이는, 단순한 값 보단, 하나의 함수 (function) 를 반환한다는 걸 의미합니다. 반환 함수에는 매개 변수는 없고, 호출할 때마다 Int 값을 반환합니다. 함수에서 다른 함수를 반환할 수 있는 방법을 배우려면, Function Types as Return Types (반환 타입으로써의 함수 타입) 부분을 보기 바랍니다.

makeIncrementer(forIncrement:) 함수는 정수 변수인 runningTotal 을 정의하여, 반환할 증가기[^incrementer] 의 현재 총 누적 값을 저장합니다. 이 변수의 초기 값은 0 입니다.

makeIncrementer(forIncrement:) 함수에는 단일한 Int 매개 변수가 있는데 인자 이름표는 forIncrement 이고, 매개 변수 이름은 amount 입니다. 이 매개 변수로 전달된 인자 값은 반환된 증가기를 매 번 호출할 때마다 runningTotal 을 얼마나 증가하면 좋을지를 지정합니다. makeIncrementer 함수는 중첩 함수인 incrementer 를 정의하는데, 이게 실제로 증가 작업을 합니다. 이 함수는 단순히 amountrunningTotal 을 더하고, 그 결과를 반환합니다.

떼어 놓고 볼 땐, 중첩된 incrementer() 함수가 특이해 보일지 모릅니다:

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

incrementer() 함수엔 어떤 매개 변수도 없는데, 그럼에도 자신의 본문 안에서 runningTotalamount 를 참조합니다. 이는 주위 함수로부터 runningTotalamount 로의 참조 (reference) 를 붙잡아 이를 함수 본문 안에서 사용함으로써 이렇게 합니다. 참조로 붙잡으면 makeIncrementer 호출이 끝났을 때도 runningTotalamount 가 사라지지 않도록 보장하며, 다음 번 incrementer 함수 호출 때도 runningTotal 이 사용 가능하도록 보장합니다.

최적화의 하나로, 클로저가 값을 변경하지지 않고, 클로저 생성 후에 값이 변경되지도 않는다면, 스위프트가 그 값의 복사본 (copy) 을 대신 붙잡아서 저장할 수도 있습니다.

더 이상 필요없을 때의 변수 처분과 엮인 모든 메모리 관리도 스위프트가 처리합니다.

makeIncrementer 의 실제 사례는 이렇습니다:

let incrementByTen = makeIncrementer(forIncrement: 10)

이 예제는 incrementalByTen 이라는 상수에 증가 함수로의 참조를 설정하여 매 번 호출할 때마다 runningTotal 변수에 10 을 더합니다. 함수를 여러 번 호출하면 이 동작을 실제로 보여줍니다:

incrementByTen()
// 값 10 을 반환함
incrementByTen()
// 값 20 을 반환함
incrementByTen()
// 값 30 을 반환함

두 번째 증가기를 생성하면, 자신만의 별도의, 새로운 runningTotal 변수로의 저장 참조를 가지게 됩니다:

let incrementBySeven = makeIncrementer(forIncrement: 7)
incrementalBySeven()
// 값 7 을 반환함

원본 증가기 (incrementByTen) 를 다시 호출하면 자신만의 runningTotal 변수가 계속 증가하며, incrementBySeven 으로 붙잡은 변수엔 영향을 주지 않습니다:

incrementByTen()
// 값 40 을 반환함

클래스 인스턴스의 속성에 클로저를 할당했는데, 클로저가 인스턴스나 그것의 멤버를 참조하여 그 인스턴스를 붙잡았다면, 클로저와 인스턴스 사이에 강한 참조 순환[^strong-reference-cycles] 을 생성하게 됩니다. 스위프트는 붙잡을 목록 (capture lists) 으로 이런 강한 참조 순환을 끊습니다. 더 많은 정보는, Strong Reference Cycles for Closures (클로저의 강한 참조 순환) 부분을 보기 바랍니다.

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

위 예제에서, incrementBySevenincrementByTen 은 상수지만, 이 상수들이 참조하는 클로저는 여전히 자신이 붙잡은 runningTotal 변수를 증가할 수 있습니다. 이건 함수와 클로저가 참조 타입 (reference types) 이기 때문입니다.

함수나 클로저를 상수나 변수에 할당할 때마다, 실제로는 그 상수나 변수가 함수나 클로저로의 참조 (reference) 가 되도록 설정하는 겁니다. 위 예제에서, 상수인 건 incrementByTen참조할 (refers to) 클로저의 선택인 것이지, 클로저 그 자체의 내용물이 아닙니다.20

이는 한 클로저를 두 개의 서로 다른 상수나 변수에 할당하면, 그 상수나 변수는 둘 다 똑같은 클로저를 참조한다는 의미이기도 합니다.

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

incrementByTen()
// 값 60 을 반환함

위 예제는 alsoIncrementByTen 을 호출하는게 incrementByTen 을 호출하는 것과 똑같다는 걸 보여줍니다. 둘이 참조하는 클로저가 똑같기 때문에, 둘 다 증가하여 똑같은 총 누적 값을 반환합니다.

Escaping Closures (벗어나는 클로저)

클로저가 함수를 벗어난다 (escape) 고 말하는 건 함수 인자로 전달한 클로저를, 함수 반환 후에 호출할 때입니다.21 자신의 매개 변수로 클로저를 입력 받는 함수를 선언할 때, 매개 변수 타입 앞에 @escaping 을 쓰면 클로저가 벗어나는 걸 허용한다고 지시할 수 있습니다.

클로저가 벗어나게 하는 한 방법은 함수 밖에서 정의한 변수에 저장하는 겁니다. 한 예로, 비동기 연산[^asynchronous-operation] 을 시작하는 수많은 함수들은 클로저 인자를 완료 처리자16 로 입력 받습니다. 연산을 시작한 후 함수는 반환하지만, 연산을 완료하기 전까지 클로저는 호출되지 않습니다-나중에 호출하려면, 클로저가 벗어날 필요가 있습니다. 예를 들면 다음과 같습니다:

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

someFunctionWithEscapingClosure(_:) 함수는 클로저를 인자로 입력 받아 함수 밖에서 선언한 배열에 이를 추가합니다. 이 함수의 매개 변수에 @escaping 을 표시하지 않으면, 컴파일-시간 에러를 가질 겁니다.

벗어나는 클로저가 self 를 참조하는데 self 가 클래스 인스턴스를 참조한다면 특별한 주의가 필요합니다. 벗어나는 클로저에서 self 를 붙잡으면 강한 참조 순환을 생성하는 사고가 일어나기 쉽습니다. 참조 순환에 대한 정보는, Automatic Reference Counting (자동 참조 카운팅) 을 보기 바랍니다.

보통은, 클로저 본문에서 변수를 사용하면 클로저가 암시적으로 변수를 붙잡지만, 이 경우엔 명시할 필요가 있습니다.22 self 를 붙잡고 싶으면, 쓸 때 self 를 명시하거나, 클로저가 붙잡을 목록[^capture-list] 에 self 를 포함시킵니다.23 self 를 명시하면 자신의 의도를 표현하게 해주며, 참조 순환이 없다고 확정한 걸 떠올리게 합니다. 예를 들어, 아래 코드에서, someFunctionWithEscapingClosure(_:) 로 전달된 클로저는 self 를 명시하여 참조합니다. 이와 대조하여, someFunctionWithNonescapingClosure(_:) 로 전달된 클로저는 벗어나지 않는 클로저[^nonescaping] 이며, 이는 self 를 암시적으로 참조할 수 있다는 걸 의미합니다.

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

class SomeClass {
  var x = 0
  func doSomething() {
    someFunctionWithEscapingClosure { self.x = 100 }
    someFunctionWithNonescapingClosure { x = 200 }
  }
}

let instance = SomeClass()
instance.doSomething()
print(instance.x)
// "200" 을 인쇄함

completionHandlers.first?()
print(instance.x)
// "100" 을 인쇄함

클로저가 붙잡을 목록에 포함시켜서 self 를 붙잡은 다음, 암시적으로 self 를 참조하는, doSomething() 버전은 이렇습니다:

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

self 가 구조체나 열거체의 인스턴스면, 항상 self 를 암시적으로 참조할 수 있습니다.24 하지만, self 가 구조체나 열거체 인스턴스일 땐 벗어나는 클로저가 변경 가능한 참조[^mutable-reference] 로 self 를 붙잡을 수 없습니다. 구조체와 열거체는, Structures and Enumerations Are Value Types (구조체와 열거체는 값 타입입니다) 에서 논의하듯, 변경 가능성의 공유를 허용하지 않습니다.

struct SomeStruct {
  var x = 10
  mutating func doSomething() {
    someFunctionWithNonescapingClosure { x = 200 }  // 괜찮음
    someFunctionWithEscapingClosure { x = 100 }     // 에러
  }
}

위 예제에서 someFunctionWithEscapingClosure 함수 호출은 에러인데 이게 변경 메소드 안에 있어서, self 가 변경 가능하기 때문입니다. 이는 벗어나는 클로저가 변경 가능한 참조로 구조체의 self 를 붙잡을 순 없다는 규칙을 위반합니다.

Autoclosures (자동 클로저)

자동 클로저 (autoclosure) 는 함수 인자로 전달된 표현식을 감싸서 자동으로 생성되는 클로저입니다. 이는 어떤 인자도 입력 받지 않고, 호출할 땐, 자기가 감싼 표현식의 값을 반환합니다. 이런 구문의 편의함은 함수 매개 변수 주변의 중괄호를 생략하게 해줘서 클로저를 명시하는 대신 보통의 표현식을 쓸 수 있게 합니다.

자동 클로저를 입력받는 함수를 호출(call) 하는 건 흔하지만, 그런 종류의 함수를 구현 (implement) 하는 건 흔하지 않습니다. 예를 들어, assert(condition:message:file:line:) 함수는 자신의 conditionmessage 매개 변수로 자동 클로저를 입력받지만; condition 매개 변수는 디버그 제작 모드[^debug-build] 일 때만 평가하고25 message 매개 변수는 conditionfalse 일 때만 평가합니다.

자동 클로저는 평가를 늦춰주는데, 클로저를 호출하기 전까진 그 안의 코드를 실행 (run)26 하지 않기 때문입니다. 평가를 늦추는 건 부작용27 이 있거나 계산 비용이 비싼 코드에 유용한데, 그 코드의 평가 시점을 제어하게 해주기 때문입니다. 아래 코드는 클로저가 평가를 늦추는 방법을 보여줍니다:

var customersInLine = ["Chris", "Alex", "Ewa", "Barry", "Daniella"]
print(customersInLine.count)
// "5" 를 인쇄함

let customerProvider = { customersInLine.remove(at: 0) }
print(customersInLine.count)
// "5" 를 인쇄함

print("Now serving \(customerProvider())!")
// "Now serving Chris!" 를 인쇄함
Print(customersInLine.count)
// "4" 를 인쇄함

클로저 안에 있는 코드에서 customersInLine 배열의 첫 번째 원소를 제거할지라도, 클로저를 실제 호출하기 전까진 배열 원소를 제거하지 않습니다. 클로저가 절대 호출되지 않으면, 클로저 안의 표현식도 절대로 평가하지 않는데, 이는 배열 원소도 절대로 제거하지 않는다는 의미입니다. customerProvider 의 타입은 String 이 아니라 () -> String 이라는-매개 변수가 없고 문자열을 반환하는 함수-라는 걸 기록하기 바랍니다.

클로저를 함수 인자로 전달할 땐 늦춰진 평가와 똑같이 작동합니다.

// customersInLine 은 ["Alex", "Ewa", "Barry", "Daniella"] 임
func serve(customer customerProvider: () -> String) {
  print("Now serving \(customerProvider())!")
}
serve(customer: { customersInLine.remove(at: 0) })
// "Now serving Alex!" 를 인쇄함

위에 나열한 serve(customer:) 함수는 고객 이름을 반환하는 명시적인 클로저를 입력 받습니다. 아래 버전의 serve(customer:) 는 연산은 똑같지만, 명시적 클로저를 입력 받는 대신, 매개 변수 타입에 @autoclosure 특성을 표시하여 자동 클로저로 입력 받습니다. 이제 마치 함수가 클로저 대신 String 인자를 입력받는 것처럼 호출할 수 있습니다. 인자가 클로저로 자동 변환되는 건, customerProvider 매개 변수의 타입에 @autoclosure 특성을 표시했기 때문입니다.

// customersInLine 은 ["Ewa", "Barry", "Daniella"] 임
func serve(customer customerProvider: @autoclosure () -> String) {
  print("Now serving \(customerProvider())!")
}

serve(customer: customersInLine.remove(at: 0))
// "Now serving Ewa!" 를 인쇄함

자동 클로저를 막 쓰다보면 코드 이해가 어려워질 수 있습니다. 상황과 함수 이름으로 평가가 미루질 수 있다는 걸 명확하게 하는게 좋습니다.

자동 클로저인데 벗어나는 걸 허용하고 싶으면, @autoclosure@escaping 특성을 둘 다 씁니다. @escaping 특성은 위에 있는 Escaping Closures (벗어나는 클로저) 에서 설명합니다.

// customersInLine 은 [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!" 를 인쇄함

위 코드에선, collectCustomerProviders(_:) 함수가 customerProvider 인자로 전달된 클로저를 호출하는 대신, 이를 customerProviders 배열에 덧붙입니다. 배열은 함수 시야 밖에 선언된 것인데, 이는 배열 안에 있는 클로저를 함수 반환 후에 실행할 수 있다는 걸 의미합니다. 그 결과, 반드시 customerProvider 인자 값이 함수 시야를 벗어나게 허용해야 합니다.

다음 장

Enumerations (열거체) >

참고 자료

  1. 원문은 Closures에서 확인할 수 있습니다. 

  2. 전역 함수와 중첩 함수는 클로저의 부분 집합이라고 볼 수 있습니다. 

  3. ‘클로저 표현식 (closer expressions)’ 에 대한 설명은 바로 다음에 이어집니다. 

  4. ‘가벼운 구문 (lightweight syntax)’ 는 표현이 간결한 구문을 말합니다. 

  5. ‘단일-표현식 클로저 (single-expression closures)’ 는 클로저 본문이 단 하나의 표현식으로 된 클로저를 말합니다. 

  6. ‘짧게 줄인 인자 이름 (shorthand argument names)’ 은 클로저 안에서 사용할 수 있는 $0 등을 말합니다. 이에 대해서는 뒤에서 더 자세히 설명합니다. 

  7. ‘뒤에 딸린 클로저 구문 (trailing closure syntax)’ 은, 클로저가 함수의 마지막 인자일 경우, 클로저를 함수 뒤로 꺼내서 본문 뒤에 딸리도록 하는 구문입니다. 이것도 뒤에서 더 자세히 설명합니다. 

  8. ‘인라인 클로저 (inline closure)’ 는 변수에 할당하지 않고 직접 사용하는 클로저입니다. 보통 해당 코드 줄에 직접 집어 넣어서 (in-line) 사용합니다. 

  9. 스위프트의 ‘입-출력 매개 변수 (in-out parameters)’ 는 함수 안에서 값을 바꿀 수 있습니다. 이에 대한 더 자세한 정보는, Functions (함수) 장의 In-Out Parameters (입-출력 매개 변수) 부분을 참고하기 바랍니다. 

  10. ‘한 쌍의 괄호가 메소드의 전체 인자를 감싼다’ 는 건 sorted 메소드의 괄호 ( ... ) 가 클로저인 중괄호 { ... } 를 감싸고 있다는 의미입니다. 

  11. sorted(by:) 메소드를 names 의 멤버 함수로써 호출하고 있습니다. 

  12. 클로저 표현식이 함수나 메소드의 유일한 인자라면, 그 클로저 표현식은 자동으로 최종 인자이기 때문에, 뒤에 딸린 클로저가 될 수 있습니다. 

  13. ‘대체 맵핑 값 (alternative mapped value)’ 에서의 맵핑은 수학에서 말하는 맵핑과 같습니다. 맵핑을 직역하면 대응 관계 정도로 옮길 수 있습니다. ‘맵핑’ 에 대한 더 자세한 정보는, 위키피디아의 [Map (mathematics)](https://en.wikipedia.org/wiki/Map_(mathematics) 항목과 [맵핑 (수학)](https://ko.wikipedia.org/wiki/맵핑_(수학) 항목을 참고하기 바랍니다. 

  14. 함수와 클로저의 매개 변수는 항상 상수이기 때문에, var number = number 처럼, 매개 변수 number 를 다시 변수 number 에 할당해야 수정할 수 있습니다. 입-출력 매개 변수는 원본 값 자체를 변경해도 되는 (또는 원본 값 자체를 변경하는 게 더 효율적인) 특수한 경우에만 사용합니다. 

  15. number % 10 의 결과는 항상 0...9 사이의 정수이므로 유효한 키입니다. 

  16. ‘완료 처리자 (completion handler)’ 는 해당 비동기 연산을 완료한 후에 호출되는 클로저를 말합니다.  2

  17. 애플 OS 에서 ‘급파 (dispatch)’ 는 동시성 (concurrent) 과 관련이 있습니다. 여기서 loadPicture(from:completion:onFailure:) 함수가 작업을 백그라운드로 급파한다는 건, 함수를 호출한 다음 결과를 기다리지 않고 곧바로 다른 작업을 하며, 실제 다운로드 작업은 별도의 백그라운드 쓰레드에서 진행한다는 걸 의미합니다. 애플 *OS 의 동시성에 대해서는, Concurrency (동시성) 장을 보기 바랍니다. 그 외에, 동시성 자체에 대한 더 자세한 정보는, 위키피디아의 Concurrent computing 항목과 병행 컴퓨팅 항목을 참고하기 바랍니다. 

  18. 기본적으로는, 스위프트 클로저가 상수와 변수를 참조로 붙잡지만, 최적화에 따라, 복사도 합니다. 따라서, 원본 영역과는 상관없이 참조하고 수정할 수 있습니다. 클래스 처럼, 클로저도 참조 타입이라 메모리 안의 자유 저장소 영역 (free storage; 또는 heap) 에 생성됩니다. 

  19. 스위프트에서 make 로 시작하는 함수나 메소드는 보통 ‘공장 메소드 패턴 (factory method pattern)’ 을 사용하는 ‘공장 메소드 (factory method)’ 입니다. 공장 메소드에 make 를 사용하는 것은 API Design Guidelines (API 설계 지침)Strive for Fluent Usage (자연스러운 사용법이 되도록 노력하기) 부분을 보기 바랍니다. 공장 메소드와 공장 메소드 패턴에 대한 더 자세한 정보는, 위키피디아의 Factory method pattern 항목과 팩토리 메서드 패턴 항목을 참고하기 바랍니다. 

  20. 이는 A 를 참조하기로 선택한 클로저를 B 로 참조하게 바꿀 순 없지만, A 의 내용은 바꿀 수 있다는 의미입니다. 

  21. ‘벗어나는 (escape)’ 이라는 의미는 왕발님의 블로그Escape string (이스케이프) 이란? 이라는 글에 설명이 잘 되어있습니다. ‘escape character’ 를 ‘(본래 의미를) 벗어나서 (특수한 의미를 가진) 문자’ 라고 이해한다면, ‘escaping closure’ 는 (본래 범위를) 벗어나서 (호출할 수 있는) 클로저라고 이해할 수 있습니다. 

  22. 앞에서 말한대로, 암시적으로 붙잡으면 강한 참조 순환을 만드므로, 명시적으로 붙잡아서 참조 순환을 만들지 않도록 해야 합니다. 

  23. self 를 명시적으로 사용한다는 것은 클로저 내부에서 { self.x = 1 } 처럼 self 를 명시적으로 붙여주는 것을 말하며, 붙잡을 목록에 self 를 포함한다는 것은 { [self] in x = 1 } 처럼 self 를 클로저 앞부분에 명시하는 것을 말합니다. 이 두 가지 방법 중에서 한 가지를 선택하면 됩니다. 

  24. 구조체나 열거체는 값 타입이라 참조 순환이 없기 때문에, 클로저 안에서 self 를 명시하지 않아도 됩니다. 이 내용은 스위프트 5.3 이후에 추가된 것입니다. 이에 대한 더 자세한 정보는, 애플에서 공개한 SE-0269: Increase availability of implicit self in @escaping closures when reference cycles are unlikely to occur 문서를 참고하기 바랍니다. 

  25. ‘평가한다 (evaluate)’ 는 건 표현식을 실행하여 특정 값을 구한다는 의미입니다. 함수를 실행하는 건 ‘호출 (call)’ 이라고 하고, 표현식을 실행하는 건 ‘평가 (evaluation)’ 라고 합니다. 값 ‘평가 (evaluation)’ 에 대한 더 자세한 정보는, 위키피디아의 Evaluation strategy 항목과 평가 전략 (컴퓨터 프로그래밍) 항목을 보도록 합니다. 

  26. ‘실행’ 이라는 용어에는 ‘run’ 과 ‘execution’ 두 가지가 있는데, 스택오버플로우의 Difference between running and executing states of a process in the Operating System 항목에 따르면, ‘run’ 은 프로그램을 메모리로 불러와서 process 상태로 만드는 것이고, ‘execute’ 는 프로그램이 CPU 를 사용하게 하는 것을 말합니다. 즉, 여기서 코드를 실행 (run) 하지 않는다는 건 메모리조차 사용하지 않는다는 의미입니다. 

  27. 컴퓨터 용어에 있는 ‘부작용 (side effect)’ 은, Expressions (표현식) 맨 앞에 있는 ‘부작용 (side effect)’ 의 주석에서 설명한 것처럼, ‘부수적인 효과’ 정도로 이해하는 것이 좋습니다.