클로저의 값 획득(Capture)

클로저는 자신이 정의된 위치의 주변 문맥을 통해 상수나 변수를 획득하는 특성을 가지고 있다. 애플에서는 이것을 값을 Capture한다고 표현한다. 값 획득을 통해 클로저는 주변의 상수나 변수가 메모리에서 해제되어 존재하지 않더라도 자신의 내부에서 해당 상수와 변수의 값을 참조하고 수정할 수 있다.

이 같은 특성을 이용해 클로저는 비동기 작업에 많이 사용된다. 만약 값 획득이 없다면 클로저가 실행되는 순간 참조할 상수나 변수가 존재하지 않아 제대로된 작업을 하지 못할 것이다.

중첩함수 예제

다음은 중첩 함수를 사용한 애플의 예제이다.

1
2
3
4
5
6
7
8
func makeIncrementer(forIncrement amount: Int) -> () -> Int {
var runningTotal = 0
func incrementer() -> Int {
runningTotal += amount
return runningTotal
}
return incrementer
}

makeIncrementer 함수의 반환 타입은 () -> Int이다. 그리고 그 안에 그러한 타입인 incrementer() 중첩 함수를 정의해 놓고 최종적으로 이 함수를 반환한다.

incrementer() 함수는 makeIncrementer 내부의 변수인 runningTotal과 인자인 amount를 이용하는데 만약 따로 떨어뜨려 놓으면 이런 형태가 된다.

1
2
3
4
func incrementer() -> Int {
runningTotal += amount
return runningTotal
}

단독으로 보면 runningTotal, amount라는 변수가 없고 어떠한 값도 인자로 갖지 않는 함수가 된다.

하지만, 기존의 형태에서는 incrementer() 주변의 변수인 runningTotal과 amount의 참조를 획득한다. 그 결과 makeIncrementer 함수의 실행이 끝나도 두 변수를 중첩 함수가 획득하고 있기 때문에 계속해서 사용할 수 있게 된다.

1
2
3
4
5
6
7
let incrementByFive = makeIncrementer(forIncrement: 5)
incrementByFive() // 5
incrementByFive() // 10

let incrementByTen = makeIncrementer(forIncrement: 10)
incrementByTen() // 10
incrementByTen() // 20

위와 같이 각각의 incrementer 함수는 자신만의 runningTotal 변수를 획득했기 때문에 다른 함수의 영향도 받지 않고 독자적인 반환값을 보여준다.

획득 목록

클로저를 사용할 때 주의할 점은 강한참조 순환 문제를 피하는 것이다. 강한참조 순환은 클로저가 클래스와 같은 참조 타입이기 서로 강함 참조로 순환의 형태를 이룰 경우 때문에 발생할 수 있다. 이를 해결하기 위해 클로저의 획득 목록(Capture List)를 사용한다.

강한 참조 상황

다음은 강한 참조가 일어나는 상황이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Animal {
let name: String

lazy var cry: () -> String = {
return "\(self.name) \(self.name)"
}

init(name: String) {
self.name = name
}

deinit {
print("\(name) is being deinitialized")
}
}

var alpaca: Animal? = Animal(name: "Alpaca")
print(alpaca?.cry()) // Optional("Alpaca Alpaca")
alpaca = nil // 출력되지 않음.

Animal 클래스의 cry()라는 프로퍼티는 클로저를 할당 받는다. 참고로 lazy를 사용한 지연 저장 프로퍼티이기 때문에 클로저 내부에서 self로 Person 클래스 인스턴스에 접근이 가능하다.

문제는 cry() 클로저가 호출될 때 일어난다. cry() 클로저는 실행될 때마다 alpaca 인스턴스의 참조 횟수를 올리게 된다. 그러면 alpaca에 할당될 당시에 +1, 또 한번 +1이 되어 참조 카운트가 +2가 된다. 그래서 alpaca에 nil을 넣어 -1이 한 번 되어도 +1이 남아 있기 때문에 메모리에서 해제가 되지 않는다.

이유는 클로저가 호출이 되면 자신의 내부에 있는 변수들을 획득하는데 클로저는 자신이 획득한 참조 변수들을 계속해서 사용할 수 있도록 참조 횟수를 증가시켜 메모리에서 해제하는 것을 방지한다. 이 때 자신을 프로퍼티로 갖는 인스턴스의 참조 횟수도 증가시켜버리기 때문에 강한참조 순환이 발생한다.

획득 목록이란?

획득 목록은 클로저 내부에서 참조 타입을 획득하는 규칙을 제시할 수 있는 기능이다. 좀 더 정확히 말하면, 어떤 방식으로 참조할 것인지를 명시할 수 있는데 여기에는 강한 획득(Strong Capture), 약한 획득(Weak Capture), 미소유 획득(Unowned Capture)이 있다. 획득 목록은 대괄호로 대상을 둘러싼 형태로 작성되며 그 앞에 어떻게 획득할 것인지 참조 방식을 적는다.

만약 값 획득을 하는 대상이 참조 타입이 아닐 경우에는 클로저 내부에서 초기화가 되어 외부의 값과 다르게 사용이 된다.

1
2
3
4
5
6
7
8
9
10
11
var a = 0
var b = 0
let closure = { [a] in
print(a, b)
b = 20
}

a = 10
b = 10
closure() // 0 10
print(b) // 20

위의 예에서 클로저 내부에서 a를 획득했기 때문에 외부에서 a를 10으로 바꿔도 이미 안에서 획득된 a는 0이기 때문에 a와 b를 출력했을 때 0과 10이 나오는 것을 볼 수 있다. 반대로 b는 클로저 실행 이후 값이 바뀌었다.

코드를 Playground에서 확인해보면 색이 다르게 나오는 것을 볼 수 있다.

하지만 참조 타입의 경우에는 결과가 다르게 나온다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class PacaClass {
var value: Int = 0
}

var firstPaca = PacaClass()
var secondPaca = PacaClass()

let closure = { [firstPaca] in
print(firstPaca.value, secondPaca.value)
}

firstPaca.value = 10
secondPaca.value = 10

closure() // 10 10

위의 예에서는 참조 타입인 클래스의 인스턴스인 firstPaca 변수만을 획득 목록에 명시했는데 나온 결과를 보면 같다. 이는 두 변수 모두 참조 타입이기 때문이다.

이러한 변수들은 어떠한 방식으로 획득할지 정해줌으로써 강한 참조 순환을 피할 수 있다.

해결

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Animal {
let name: String

lazy var cry: () -> String = { [weak self] in
return "\(self?.name) \(self?.name)"
}

init(name: String) {
self.name = name
}

deinit {
print("\(name) is being deinitialized")
}
}

var alpaca: Animal? = Animal(name: "Alpaca")
print(alpaca?.cry()) // Optional("Optional(\"Alpaca\") Optional(\"Alpaca\")")
alpaca = nil // Alpaca is being deinitialized

위의 예에서 본 클로저에 self를 weak으로 획득하는 것으로 변경하면 deinit에서 명시한 print문이 제대로 나오는 것을 볼 수 있다. 이는 클로저가 alpaca 변수의 참조 카운트를 올리지 않아 alpaca에 nil을 넣었을 경우 참조 카운트가 0이 되어 deinit이 실행되었기 때문이다.

Reference