클로저의 값 캡처 이해하기

권승용(Eric)·2024년 12월 4일

TIL

목록 보기
17/38

배경

  • 클로저의 갑 캡처 그리고 캡처 리스트의 동작 방식을 이해해보자

값 캡처하기

  • 클로저는 클로저가 정의된 주변 컨텍스트에서 상수와 변수를 캡처할 수 있다.
  • 클로저는 해당 상수와 변수가 정의된 원본 스코프가 더 이상 존재하지 않을지라도, 클로저 본문 내에서 캡처한 값을 참조하고 변경할 수 있다.
  • Swift에서 값을 캡처할 수 있는 클로저의 가장 간단한 형태는 중첩 함수이다.
func makeIncrementer(forIncrement amount: Int) -> () -> Int {
    var runningTotal = 0
    func incrementer() -> Int {
        runningTotal += amount
        return runningTotal
    }
    return incrementer
}
  • 위 예제에서 incrementer 함수는 runningTotal, amount라는 두 개의 외부 변수를 캡처한다.
  • 따라서 반환된 incrementer가 실행될 때 마다 amount만큼 runningTotal이 증가할 것이다.
let incrementer = makeIncrementer(forIncrement: 2)
print(incrementer())
print(incrementer())
print(incrementer())
print(incrementer())
print(incrementer())
  • 따라서 위 코드를 실행하면 아래와 같은 결과를 확인 가능하다.


좀더 이해해보기

  • 일반적으로 생각해보면 이는 이상한 일이다.
  • 함수 실행이 종료되면 함수 내부 스코프의 지역 변수들도 사라지는 것이 당연하기 때문에, runningTotal과 amount 변수도 makeIncrementer 호출 시 사라져야 한다.
  • 그러나 incrementer 클로저는 각각의 값을 가지고 있으며 수정까지 하고 있다. 어떻게?
  • 궁금했던 점은 클로저가 값을 캡처하는 원리이다.

클로저가 값을 캡처하는 방법

  • 공식문서에서는 아래와 같이 설명하고 있다.

    It does this by capturing a reference to runningTotal and amount from the surrounding function and using them within its own function body. Capturing by reference ensures that runningTotal and amount don’t disappear when the call to makeIncrementer ends, and also ensures that runningTotal is available the next time the incrementer function is called.

  • runningTotal과 amount로의 참조를 캡처한다고 설명한다.
  • 그러나 두 변수는 Int 타입, 즉 구조체이다.
  • 구조체는 스택 영역에 할당되기 때문에 참조를 캡처해도 값을 유지할 수 없다.
  • 그렇다면 우리는 runningTotal과 amount는 구조체로 선언되어 있지만 캡처가 일어날 경우 힙 영역에서 참조 의미론을 통해 관리되지 않을까? 라고 의심해볼 수 있다.
  • 이를 확인하기 위해 메모리 주소 및 영역 확인을 통해 클로저가 캡처하는 외부 변수 중 값 타입은 스택에 저장되어 있는지, 힙에 저장되어 있는지 테스트해 보았다.

값 타입 캡처 값은 어디에 저장될까? - 메모리 주소를 통해 알아보기

func demonstrateVariableCapture(incrementStep: Int) -> () -> Void {
    // 클로저에 의해 캡처될 변수
    var capturedVariable = 0
    
    // 클로저에 의해 캡처되지 않을 변수
    var nonCapturedVariable = 0
    
    // 초기 메모리 주소 출력
    print("🔍 캡처될 변수의 초기 메모리 주소: \(address(&capturedVariable))")
    print("🔍 캡처되지 않을 변수의 초기 메모리 주소: \(address(&nonCapturedVariable))")
    
    func closureWithPartialCapture() {
        // capturedVariable은 클로저 내부에서 수정되므로 캡처됨
        capturedVariable += incrementStep
        
        print("🔬 캡처된 변수의 현재 메모리 주소: \(address(&capturedVariable))")
        print("캡처된 변수 값: \(capturedVariable)")
    }
    
    return closureWithPartialCapture
}

let captureDemonstrator = demonstrateVariableCapture(incrementStep: 2)
captureDemonstrator()
captureDemonstrator()

func address(_ o: UnsafeRawPointer) -> String {
    let bit = Int(bitPattern: o)
    return String(format: "%p", bit)
}
  • 출력 결과는 아래와 같다.
  • 여기서 알 수 있는 특이사항은 아래와 같다:
    • 캡처될 변수는 캡처 이전에도 캡처 이후같은 메모리 공간에 할당된다.
  • 그리고 캡처되지 않을 변수와 캡처될 변수의 메모리 주소가 꽤 차이난다, 즉 서로 다른 영역에 할당됨을 짐작할 수 있다.
  • 실제 저장되는 영역을 알아보기 위해 vmmap을 활용해보자

    vmmap <pid> | grep Stack

  • 위와 같은 결과를 얻을 수 있었다.
  • 스택 영역은 0x16f604000 ~ 0x16fe00000임을 확인할 수 있다.
  • 이제 다시 출력 결과를 살펴보면, 캡처될 변수의 초기 메모리 주소는 스택 범위를 벗어나있음을 알 수 있다.
    • 클로저가 캡처하는 값은 Int 구조체임에도 힙 영역에 저장되어 있다.
    • 그리고 클로저에 캡처된 이후 힙 영역에 저장되는 것이 아닌, 그 이전에도 힙 영역에 미리 저장되어 있는 것을 확인할 수 있다.
  • 또한 캡처되지 않을 변수의 초기 메모리 주소는 스택 범위 내에 있음을 알 수 있다.
    • 캡처되지 않는 구조체는 얌전히 스택 영역에 할당됨을 알 수 있다.
  • 요약
    • 캡처되는 변수는 값 타입이어도 애초에 힙 영역에 할당된다.
      • 클로저 외부 변수임에도 불구하고!
    • 따라서 해당 영역에 대한 레퍼런스 카운트를 클로저가 증가시키기 때문에, 모든 클로저의 실행이 끝나기 전에는 변수가 사라지지 않고 값을 변경 및 참조할 수 있는 것.
  • 위와 같은 사실로부터, 같은 변수를 캡처하는 여러 클로저가 있을 경우 각각 독립적인 값을 캡처하는 것이 아닌, 동일한 힙 인스턴스를 캡처한다는 사실을 짐작할 수 있다.

다만 캡처된 값이 클로저 내부에서 변경되지 않고 클로저가 생성된 이후에도 변경되지 않는다면, Swift는 최적화를 위해 값의 복사본만을 저장할 수도 있다고 한다. (캡처 리스트 동작 방식)
Swift는 변수들이 더 이상 필요하지 않을 때 폐기하는 것과 관련한 모든 메모리 관리도 처리한다.

결론

  • 클로저의 캡처 개념을 레퍼런스 카운트의 개념으로 이해하면 편할 것 같다.
  • 어떤 클로저에게 캡처될 값은 그것이 값 타입이건 참조 타입이건 상관없이 힙 영역에 할당되고, 그 할당을 유지하기 위해서는 레퍼런스 카운트가 필요하다.
  • 클로저는 캡처한 값의 레퍼런스 카운트를 올리고, 값 인스턴스에 참조로 접근해 값을 가져오거나 수정할 수 있다.
  • 만약 클래스 self 인스턴스를 캡처한다면 해당 레퍼런스 카운트도 오르기 때문에 순환참조 고려.
  • 그러나 구조체 self라면? 얘도 캡처하면 힙 영역에 저장되어서 사라지지 않는 거 아니야?
    • 구조체 self는 힙 영역에 저장되는 것이 아닌, 스택 영역의 값 자체를 그대로 쓰는 듯. 따라서 immutable하다는 속성을 갖고 있고, context 외부에서는 쓰지 못하게 에러 발생.

출처

https://docs.swift.org/swift-book/documentation/the-swift-programming-language/closures

profile
ios 개발자에용

0개의 댓글