Burt.K

Awesome Discovery

[SE-0035] inout 캡처를 @noescape 컨텍스트로 제한하기

작성일 — 2025년 3월 9일

Table of Contents

inout 캡처를 @noescape 컨텍스트로 제한하기

소개

Swift에서 클로저가 inout 매개변수를 캡처하고 해당 컨텍스트를 벗어나는 경우의 동작은 종종 혼란을 일으키는 원인이다. @noescape 클로저를 제외하고는 inout 매개변수의 암시적 캡처를 허용하지 않아야 한다.

Swift-evolution 스레드: @noescape 클로저에서만 inout 매개변수 캡처 허용

리뷰

동기

@noescape가 도입되기 전에도, inout 매개변수와 mutating 메서드를 클로저에서 사용할 수 있도록 하면서도, inout 매개변수가 호출자에게 예상치 못한 별칭(aliasing)이나 수명 연장 문제를 일으키지 않고 로컬에서만 변이(mutate)된다는 강력한 보장을 유지하고 싶었다. Swift는 표준 라이브러리의 컬렉션 연산에서부터 @autoclosure 기능을 통해 &&|| 같은 연산자 및 어설션(assertion)에 이르기까지 클로저를 광범위하게 사용한다. 따라서 inout 매개변수를 전혀 캡처할 수 없다면 매우 제한적일 것이다. Dave Abrahams는 현재의 캡처 의미론을 타협점으로 설계했다: inout 매개변수는 섀도우 복사본(shadow copy)으로 캡처되며, 이 복사본은 호출자가 반환될 때 원래 인자에 다시 기록된다. 이 방식은 클로저가 호출될 때 inout 매개변수가 활성 상태일 때 예상된 의미론(semantics)으로 캡처되고 변이될 수 있게 한다:

func captureAndCall(inout x: Int) {
  let closure = { x += 1 }
  closure()
}
var x = 22
captureAndCall(&x)
print(x) // => 23

그러나 클로저가 탈출(escape)할 때는 *섀도우 복사본*이 원래 인자와 독립적으로 유지되기 때문에 직관적이지 않은 결과가 발생한다:

func captureAndEscape(inout x: Int) -> () -> Void {
  let closure = { x += 1 }
  return closure
}

var x = 22
let closure = captureAndEscape(&x)
print(x) // => 22
closure()
print("still \(x)") // => still 22

이 변경 사항은 지속적으로 혼란과 버그 리포트의 원인이 되었으며, 최근 David Ungar의 IBM Swift 블로그 포스트 “Seven Swift Snares & How to Avoid Them”에서도 언급되었다. 이는 이 주제에 대한 오랜 불만 중 하나이다.

제안하는 해결책

inout 매개변수를 암묵적으로 탈출 가능한 클로저에 캡처하는 것을 오류로 처리하도록 제안한다. Swift 1.2에서 명시적인 @noescape 어노테이션을 추가했고, 이후 표준 라이브러리 전체에 적절히 적용해 왔다. 이로 인해 기존의 타협은 더 이상 유용성을 잃었고 혼란의 원인이 되었다.

상세 설계

mutating 메서드 내에서 inout 매개변수(예: self)를 에스케이프 가능한 클로저 리터럴에서 암시적으로 캡처하면 오류가 발생한다. 단, 명시적으로 캡처하여 불변으로 만든 경우는 예외다.

func escape(f: () -> ()) {}
func noEscape(@noescape f: () -> ()) {}

func example(inout x: Int) {
  escape { _ = x } // 오류: 클로저가 @noescape가 아닌 경우 inout 매개변수를 암시적으로 캡처할 수 없음
  noEscape { _ = x } // 정상, 클로저가 @noescape임
  escape {[x] in _ = x } // 정상, 불변 캡처
}

struct Foo {
  mutating func example() {
    escape { _ = self } // 오류: 클로저가 mutating self 매개변수를 암시적으로 캡처할 수 없음
    noEscape { _ = self } // 정상
  }
}

중첩 함수 선언의 경우, 클로저는 중첩 함수에 대한 참조가 값으로 사용될 때까지 생성하지 않는다. 중첩 함수가 자신을 둘러싼 스코프의 inout 매개변수를 참조하는 경우, 에스케이프 클로저를 형성하는 중첩 함수에 대한 참조를 허용하지 않는다.

func exampleWithNested(inout x: Int) {
  func nested() {
    _ = x
  }
  escape(nested) // 오류: inout을 참조하는 중첩 함수는 에스케이프할 수 없음
  noEscape(nested) // 정상
}

구현 상세 사항으로, 이 변경은 클로저에서 참조될 수 있는 inout 매개변수에 대한 섀도우 복사본을 생성할 필요를 없앤다. 이 변경 후에도 여전히 허용되는 코드의 경우, 섀도우 복사본이 에스케이프하지 않는다는 것이 확실할 때 항상 이를 제거하는 최적화 단계가 존재하므로 관찰 가능한 영향은 없다.

기존 코드에 미치는 영향

이 변경은 현재 inout 캡처 의미에 의존하는 코드를 깨뜨릴 수 있다. 특히 영향을 받을 수 있는 몇 가지 합법적인 사례는 다음과 같다:

마이그레이션을 위해 컴파일러는 위의 수정 사항 중 하나를 제공할 수 있다. 캡처된 inout의 사용을 검사하여 캡처 이후 뮤테이션이 있는지 확인하고, 불변 캡처가 더 적절한지 아니면 명시적인 섀도우 복사가 더 적절한지 결정할 수 있다. (또는 단순히 섀도우 복사 수정 사항을 제공할 수도 있다.)

이 변경은 또한 라이브러리가 가능한 한 @noescape를 더 많이 사용하도록 압력을 증가시킨다. 이는 SE-0012에서 제안하는 바와 같다.

고려한 대안들

이 제안을 확장하는 한 가지 방법은 섀도우 복사 캡처를 위한 새로운 캡처 종류를 도입하는 것이다:

func foo(inout x: Int) {
  {[shadowcopy x] in use(&x) } // 스트로맨 문법
}

하지만 논의 과정에서, 이런 경우가 드물기 때문에 추가적인 복잡성을 감수할 가치가 없다고 판단했다. 새로운 var 선언을 통해 명시적으로 복사하는 방식이 훨씬 명확하며, 새로운 언어 지원이 필요하지 않다.