Search
Duplicate

Closure (클로저) - 클로저와 ARC

생성일
2023/09/12 05:38
태그
Grammar

Closure (클로저) - 클로저와 ARC

 클로저에서 값을 캡쳐한다는 것은

Closure란 내부 함수와 내부 함수에 영향을 미치는 주변 환경을 모두 포함한 객체이다.
예를들면
func doSomething() { var message = "Hi i am HJ" // 클로저 범위 시작 var num = 10 let closure = { print(num) } // 클로저 범위 끝 print(message) }
Swift
복사
closure란 익명함수는, 클로저 내부에서 외부 변수인 num이라는 변수를 사용(print) 하기 때문에 num의 값을 클로저 내부적으로 저장하고 있는데, 이것을 클로저에 의해 num의 값이 캡쳐 되었다 라고 표현한다.
즉, 외부 변수를 클로저 내부적으로 저장하고 있는 것을 캡쳐 라고 표현한다.
message란 변수는 클로저 내부에서 사용하지 않기 때문에 클로저에 의해 캅이 캡쳐되지 않는다.
이제 클로저의 값 캡쳐 방식에 대해 알아보자.

 1. 클로저의 값 캡쳐 방식

closure 는 값을 캡쳐할 때, Value/Reference 타입에 관계 없이 Reference Capture 한다.
아까 num이란 변수를 클로저 내부적으로 저장한다고 했었다. 근데 numInt 타입의 구조체 형식이고, 이는 곧 Value 타입이기 때문에, 값을 복사해서 들고 저장해야 되는 것이 일반적이다.
그러나 클로저는 Value/Reference 타입에 관계없이 캡쳐하는 값들을 참조한다. 이것을 Reference Capture 라고 한다.
예를들어
func doSomething() { var num: Int = 0 print("num check #1 = \(num)") let closure = { print("num check #3 = \(num)") } num = 20 print("num check #2 = \(num)") closure() }
Swift
복사
먼저, closure는 num 이라는 외부 변수를 클로저 내부에서 사용하기 때문에 num을 캡쳐할 것이다.
근데 어떻게 캡쳐하냐면 Reference Capture, 즉 num 이란 변수를 참조한다.
// num check #1 = 0 // num check #2 = 20 // num check #3 = 20
Swift
복사
클로저 내부에서 사용하는 num의 값 또한 변경 된다.
혹은, 클로저 내부에서 num의 값을 바꾸면
func doSomething() { var num: Int = 0 print("num check #1 = \(num)") let closure = { num = 20 print("num check #3 = \(num)") } closure() print("num check #2 = \(num)") }
Swift
복사
클로저 외부에 있는 num의 값도 변경이 된다.
// num check #1 = 0 // num check #3 = 20 // num check #2 = 20
Swift
복사
이렇듯, Closure값의 타입이 Value건 Reference건 모두 Reference Capture를 한다.
그럼 만약 Value Type으로 Capture를 하고 싶으면 어떻게 해야할까?

 클로저의 캡쳐 리스트 (Capture Lists)

let closure = { [num, num2] in
Swift
복사
클로저의 시작인 { 의 바로 옆에 [] 를 이용해 캡쳐할 멤버를 나열한다. 이때 in 키워드도 꼭 함께 작성한다.

 1. Value Type의 값을 복사해서 Capture 할 순 없나?

Capture Lists 라는 것을 이용하면 할 수 있다.
Value Type의 경우, Value Capture 하고 싶은 변수를 리스트로 명시해주는 것이다.
예를들면
func doSomething() { var num: Int = 0 print("num check #1 = \(num)") let closure = { [num] in print("num check #3 = \(num)") } num = 20 print("num check #2 = \(num)") Closure() }
Swift
복사
Closure를 실행하기 전에 외부 변수 num의 값을 20으로 변경했지만,
// num check #1 = 0 // num check #2 = 20 // num check #3 = 0
Swift
복사
이렇게 클로저의 num에는 영향을 주지 않는다.
근데, 한 가지 더 유의해야 할 점은 Value Type으로 캡쳐한 경우,
Closure를 선언할 당시의 num의 값을 Const Value Type으로 캡쳐한다.
여기서 중요한 것은 Const Value Type, 즉 상수 로 캡쳐된다는 것이다.
따라서 다음과 같이
closure 내부에서 Value Capture 된 값을 변경할 수 없다.
 요약
클로저는 기본적으로 Value Type의 값도 Reference Capture를 하지만, 클로저 캡쳐 리스트를 이용하면 Const Value Type으로 캡쳐가 가능하다.

 2. Reference Type의 값을 복사해서 Capture 할 순 없을까?

위에서 Value Type의 값을 클로저 “캡쳐 리스트”를 통해서 Value Capture 하하였다.
그렇다면, Reference Type의 값도 Capture Lists에 작성하면, Value Catpure가 될까?
class Human { var name: String = "HJ" } var human1: Human = .init() let closure = { [human1] in print(human1.name) } human1.name = "Unknown" closure()
Swift
복사
이런 코드가 있을 때, human1 이라는 인스턴스는 Reference Type 이다.
근데 내가 클로저 캡쳐 리스트를 통해 human1을 캡쳐 했으니까, human1 은 복사되어 캡쳐가 됐을까?
결과는 Unknown
캡쳐 리스트를 작성한다고 해도, Reference Type은 Reference Capture를 한다.
그러면 Reference Type은 클로저 캡쳐 리스트를 작성할 필요가 없을까? 싶겠지만, 이건 클로저와 ARC를 보면 언제 쓰는지 이해할 수 있다.

 클로저와 ARC

ARC 란?
인스턴스의 Reference Count를 자동으로 계산하여 메모리를 관리하는 방법이다.
그렇다면 클로저인스턴스 간의 관계에 대해 알아보자
다음과 같이, 내가 Human이란 클래스를 만들고, name을 얻을 수 있는 Lazy 프로퍼티를 클로저를 통해 초기화 하였다.
Class Human { var name = "" lazy var getName: () -> String = { return self.name } init(name: String) { self.name = name } deinit { print("Human Deinit!") } }
Swift
복사
그리고 다음과 같이
var HJ: Human? = .init(name: "Kim-HJ") print(HJ!.getName())
Swift
복사
HJ란 인스턴스를 만들고,
클로저로 작성되어 있는 getName 이란 지연 저장 프로퍼티를 호출하였다.
그리고 나서 더이상 HJ란 인스턴스가 필요 없어서
HJ = nil
Swift
복사
이렇게 인스턴스에 nil을 할당하였다.
그럼 인스턴스에 nil이 할당 되었고, 나는 이 인스턴스를 다른 변수에 대입한 적 없고, 따라서 인스턴스의 RC가 0이 되어 deinit이 호출되어야 한다.
그러나, deinit 함수는 불려지지 않는다.
왜 불려지지 않냐면, 클로저에 이유가 있다.

 클로저의 강한 순환 참조

먼저 클로저는 참조 타입으로, Heap에 살고 있다.
따라서 내가 생성한 human 이란 인스턴스는
print(HJ!.getName())
Swift
복사
getName을 호출하는 순간 getName이란 클로저가 Heap에 할당되며, 이 클로저를 참조할 것이다.
(지연 저장 프로퍼티니 인스턴스 생성 직후가 아닌, 호축되는 순간에 메모리에 올라간다.)
근데, getName 이란 클로저를 보면
class Human { lazy var getName: () -> String = { return self.name } }
Swift
복사
이렇게 self 를 통해 Human 이란 인스턴스의 프로퍼티에 접근하고 있다.
클로저는 Reference 값을 캡쳐할 때 기본적으로 strong 으로 캡쳐를 한다.
따라서, 이때 Human 이란 인스턴스의 Reference Count가 증가해버린다.
Human 인스턴스는 클로저를 참조하고, 클로저는 Human 인스턴스(의 변수)를 참조하기 때문에, 서로가 서로를 참조하고 있어서,
둘다 메모리에서 해제되지 않는 강한 순환 참조 가 발생해 버린 것이다.
그럼 어떻게 해야할까?
ARC에서 배웠지만, 강한 순환 참조는 weak, unowned 를 통해 해결할 수 있다고 했다.

 클로저의 강한 순환 참조 해결법

클로저에서 해결하려면 앞서 공부한 weak, unowned 에 우리가 Reference Type 일 땐 필요 없다 느꼈던 캡쳐 리스트 를 이용해야 한다.
 weak & unowned + Capture Lists
이 두 가지를 이용해서 강한 순환 참조를 해결할 수 있다.
클로저가 프로퍼티에 접근할 때, self를 참조하면서 문제가 발생하였으니,
self에 대한 참조를 Closure Cpature List를 이용해 weak, unowned캡쳐 해버리면된다.
class Human { lazy var getName: () -> String? = { [weak self] in return self?.name } }
Swift
복사
class Human { lazy var getName: () -> String = { [unowned self] in return self.name } }
Swift
복사
이런 식으로 weak, unowned 로 Reference Capture를 해버리면 된다.
이렇게 클로저 리스트를 통해 강한 순환 참조를 해결해 줄 수 있고,
Kim-HJ
Human Deinit!
deinit 이 정상 실행 된다.
근데 weak 의 경우 nil을 할당 받을 가능성이 있기에 Optional-Type으로 self에 대한 Optional Binding을 해주어야 하지만,
unowned 의 경우엔 Non-Optional Type으로 self에 대한 Optional Binding 없이 사용할 수 있다.
Lazy로 선언된 클로저가 self를 unowned로 캡쳐하고, 시점 차이로 인해 해당 인스턴스에 nil이 할당 된 후에도 lazy property 작업이 실행되어야 하는 상황이 생길 경우, 이때는 unowned self capture에 문제가 있어 보인다 → 따라서 weak 권장!

 Swift에서 클로저는 여러 개

클로저는 전역 함수, 중첩 함수, 익명 함수
이 세 가지를 모두 아우르는 말이다 (물론 일반적으로는 익명 함수를 칭한다)
이때까지는 Unnamed Closure, 즉 익명함수 일 때만 캡쳐 방식을 살펴봤다.
그러면 Named Closure 일 때 값 캡쳐하는 방식은?

 1. 전역 함수

우리가 일반적으로 func 쓰고 작성하는 함수를 말하는데, 이 전역 함수는 주변의 어떠한 값도 캡쳐하지 않는다.

 2. 중첩 함수

자신을 포함하고 있는 함수의 값을 캡쳐한다.
예를들면
func outer() { var num: Int = 0 func inner() { print(num) } }
Swift
복사
이런 함수가 있으면, inner 함수는 나를 포함하고 있는 함수 outer의 num이라는 값을 사용하니 캡쳐한다. (당연히 Reference Capture)

 중첩 함수 & @escaping 과 메모리의 관계

함수 파라미터를 받을 때, @escaping 이란 키워드 없이 받는 클로저는 모두 non-escaping 클로저이고, 따라서 다음과 같은 특징을 가진다.
함수 내부에서 직접 실행하기 위해서만 사용한다.
따라서 파라미터로 받은 클로저는 변수나 상수에 대입할 수 없고, 중첩 함수 내부에서 클로저를 사용할 경우, 중첩 함수를 리턴할 수 없다.
함수의 실행 흐름을 탈출하지 않아, 함수가 종료되기 전에 무조건 실행 되어야 한다.
여기서 정리하면 non-escaping 클로저는
변수나 상수에 대입 불가
중첩함수 내부에서 사용시 중첩함수 리턴 불가
함수 종료 후 실행 불가
이다. 클로저가 함수 외부로 탈출하지 못하게 하기 위해서이다.
non-escaping 클로저는 해당 클로저가 함수가 종료되기 직전에 무조건 실행이 된다는 조건이다.
근데 만약 변수나 상수에 대입할 경우, 해당 클로저가 변수나 상수로 함수에서 리턴될 수도 있고,
중첩함수 내부에서 클로저를 사용할 경우, 중첩함수가 클로저를 캡쳐하기 때문에 중첩함수를 리턴 시 클로저가 중첩함수에 의해 함수 외부에서 실행될 수 있기 때문이다.
func outer(closure: () -> ()) -> () -> () { func inner() { closure() } return inner }
Swift
복사
이런 식으로 closure를 사용하는 inner가 리턴되어 버리면,
외부에서 inner를 받고 실행하면, closure가 호출되어야 한다!

 1. 왜 non-escaping과 escaping을 나눴을까?

non-escaping 에 왜 함수 직전에 무조건 실행되어야 하는 조건이 붙을까?
클로저가 이 함수 내부에서만 쓰이기 때문에 컴파일러가 메모리 관리를 지저분하게 하지 않아도 되어서, 성능이 향상되기 때문이다.
non-escaping 의 경우, 함수가 종료됨과 동시에 클로저도 사용이 끝나지만,
escaping 의 경우, 함수가 종료 되더라도, 실제 클로저가 사용되지 않을 때까지 메모리를 추적 해야한다.

 Reference