ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [SE-0532] Optional noncopyable improvements and generalizations
    Swift 2026. 7. 18. 21:33

    안녕하세요. 그린입니다 🍏
    이번 포스팅에서는 SE-0532 — Optional noncopyable 개선 및 일반화에 대해 정리해보겠습니다 🙋🏻

    Intro

    Proposal: SE-0532

    Author: Alejandro Alonso

    Review Manager: John McCall

    Status: Accepted

    Implementation: swiftlang/swift#88505

    Motivation

    Swift 6.0에서 표준 라이브러리 일부가 noncopyable 값을 저장할 수 있도록 일반화된 이후(SE-0437), noncopyable optional을 다루는 일은 꽤 번거로웠습니다.
    optional 내부 값을 살짝 들여다보고 싶은 것뿐인데, borrow만 하고 싶을 때도 마땅한 방법이 없었거든요.

     

    아래 코드를 볼까요?

    if let payload = optional {
      
    }
    
    foo(optional) // error: use after consume!

     

    다행히 noncopyable switch를 쓰면 optional의 내용을 borrow하도록 제어 흐름을 만들 수 있습니다.

    switch optional {
    case .some(let wrapped):
      // wrapped는 borrow된 상태
    
    default:
      break
    }
    
    foo(optional) // ok

    하지만 if let이나 guard let에 익숙한 사람이라면 이 switch 문이 그리 직관적이진 않죠 🥲

    값을 consume하지 않고 mutate만 하고 싶은 경우도 마찬가지예요.

     

    단순한 프로퍼티 변경이나 mutating 메서드 호출 정도는 ?. 체이닝으로 충분하지만, payload를 조건부로 inout 함수에 넘기고 싶다면 이렇게 써야 합니다.

    if someStruct.x != nil {
      foo(&someStruct.x!)
    }

     

    혹은 switch로 consume한 뒤 다시 초기화하는, 더 장황하고 직관적이지 않은 방식을 써야 하죠.

    func foo(_: inout NoncopyableString) {}
    
    func bar(_ x: inout NoncopyableString?) {
      switch consume x {
      case .some(var string):
        foo(&string)
        x = consume string
    
      default:
        x = nil
      }
    }

    여기에 더해 map, flatMap, unsafelyUnwrapped 같은 Optional API들도 noncopyable 래핑 타입에는 지원되지 않아서, 이런 코드를 매번 switch 문으로 직접 풀어써야 했습니다.

     

    Proposed Solution

    Optional에 새로운 프로퍼티 두 개, refmutableRef를 추가합니다.

    이 프로퍼티들은 내부 payload가 있으면 그 참조를, 없으면 nil을 돌려줍니다.

    func bar(_ x: borrowing SomeNoncopyable) {
      ...
    }
    
    if let payload = optional.ref {
      bar(payload.value) // 'bar'는 'borrowing Wrapped'를 전달받음
    }
    
    foo(optional) // ok
    func baz(_ x: inout SomeNoncopyable) {
      ...
    }
    
    if var payload = optional.mutableRef {
      baz(&payload.value) // 'baz'가 payload를 그 자리에서 mutate 가능!
    }
    
    foo(optional) // ok

    이 두 프로퍼티 덕분에 optional의 소유권을 가져오지 않고도 내부 값에 조건부로 접근하는 관용적인 방법이 생겼습니다.

     

    더불어 아래 세 메서드도 noncopyable / nonescapable 래핑 타입을 지원하도록 일반화됩니다.
    • map
    • flatMap
    • unsafelyUnwrapped
    let optAtomicInt: Optional<Atomic> = ...
    let optInt: Optional = optAtomicInt.map {
      $0.load(ordering: .relaxed)
    }
    
    let optInlineArray: Optional<[0 of Atomic]> = []
    let optInt2: Optional = optInlineArray.flatMap {
      $0.isEmpty ? nil : $0[0].load(ordering: .relaxed)
    }
    
    let optMutex: Optional<Mutex> = ...
    let mutex: Mutex = optMutex.unsafelyUnwrapped
    optMutex?.withLock { ... } // error: use of 'optMutex' after consume

     

    마지막으로 Optional.put()이라는 편의 API도 추가돼요!

    아래처럼 !로 강제 언래핑하던 패턴을 생각해볼게요.

    struct Cache: ~Copyable {
      var opt: UniqueArray?
    }
    
    var cache = Cache()
    var items: UniqueArray = fooBar()
    cache.opt = items
    
    let newItem = await retrieveNewItem()
    cache.opt!.append(newItem)

    이미 값이 있다는 걸 알고 있는 상황이라 !가 사실 불필요하고, inline되지 않는 함수를 거치는 경우엔 컴파일러가 최적화해서 없애주지도 못해요.

    put()은 새 값을 넣어주면서 그 payload에 대한 direct mutable reference를 바로 돌려줍니다.

    struct Cache: ~Copyable {
      var opt: UniqueArray?
    }
    
    var cache = Cache()
    let items: UniqueArray = fooBar()
    var itemsRef = cache.opt.put(items)
    
    let newItem = await retrieveNewItem()
    itemsRef.value.append(newItem)

    Detailed Design

    ref와 mutableRef

    extension Optional where Wrapped: ~Copyable & ~Escapable {
      /// optional 안에 payload가 있으면 그에 대한 borrow된 참조를 반환합니다.
      public var ref: Ref? {
        @lifetime(borrow self)
        get
      }
    }
    
    extension Optional where Wrapped: ~Copyable {
      /// optional 안에 payload가 있으면 그에 대한 mutable 참조를 반환합니다.
      public var mutableRef: MutableRef? {
        @lifetime(&self)
        mutating get
      }
    }

    개념적으로 이 프로퍼티들은 optional과 그 payload의 소유권을 옮기는 역할을 해요.

    ref를 호출하려면 최소한 borrowing Optional<T>, 즉 Ref<Optional<T>>가 있어야 하는데, 이 ref는 그 Ref<Optional<T>>를 받아 Optional<Ref<T>>로 만들어줍니다.

    참조를 optional 내부로 옮겨서, 우리가 mutate하거나 consume하거나 독점적으로 접근할 수 있는 owned 값을 돌려받는 셈이죠.

    mutableRef도 마찬가지로 최소 inout Optional<T>를 받아 owned Optional<MutableRef<T>> 값으로 바꿔줍니다.

    put()

    extension Optional where Wrapped: ~Copyable {
      /// optional의 값을 새 값으로 설정하면서, optional 안에 새로 들어간
      /// payload에 대한 mutable 참조를 반환합니다.
      ///
      /// 이미 값이 있었다면 그 값은 destroy됩니다.
      @lifetime(&self)
      public mutating func put(_ new: consuming Wrapped) -> MutableRef
    }

     

    unsafelyUnwrapped 일반화

    extension Optional where Wrapped: ~Copyable & ~Escapable {
      public var unsafelyUnwrapped: Wrapped {
        consuming get
      }
    }

     

    map(), flatMap() 일반화

    extension Optional where Wrapped: ~Copyable & ~Escapable {
      @lifetime(copy self)
      public consuming func map(
        _ transform: (consuming Wrapped) throws(E) -> Result
      ) throws(E) -> Result?
    
      @lifetime(copy self)
      public consuming func flatMap(
        _ transform: (consuming Wrapped) throws(E) -> Result?
      ) throws(E) -> Result?
    }

    사실 SE-0437 당시에는 이 일반화를 급하게 적용 하고 싶지 않았다고해요.

    noncopyable 값을 다루는 map / flatMap에는 사실 세 가지 형태가 있을 수 있거든요.

    optional 전체를 consume해서 owned 값을 클로저에 넘기거나, borrow해서 참조를 넘기거나, mutate해서 inout 참조를 넘기거나. 셋 다 유용하지만, 전부 map이라는 이름으로 오버로드하기엔 규칙상 구분할 방법이 없었죠.

     

    그래서 이번 제안은 이 일반화를 기본적으로 항상 consuming으로 동작하게 만들고, 대신 ref() / mutableRef()로 owned optional 값을 먼저 만들어서 나머지 두 형태를 구현할 수 있게 했습니다.

    func foo(x: consuming Optional<UniqueArray>) -> Optional {
      x.map {
        $0[0]
      }
    }
    
    func bar(x: borrowing Optional<Atomic>) -> Optional {
      x.ref.map { // ok!
        $0.value.load(ordering: .relaxed) &+ 1
      }
    }
    
    func baz(x: inout Optional<UniqueArray>) -> Optional {
      x.mutableRef.map {
        // map 하면서 array를 그 자리에서 업데이트
        $0.value.append(123)
        return $0.value.count
      }
    }

     

    Source Compatibility

    Optional.ref, Optional.mutableRef, Optional.put()은 새로 추가되는 메서드라 소스 호환성 문제가 없습니다.

    map / flatMap의 새 오버로드는 기존 함수보다 덜 특수화된 오버로드예요.

     

    기존에 Optional.map을 쓰던 코드는 모두 Copyable wrapped 값을 다루고 있었을 테니, 더 특수화된 기존 Wrapped: Copyable 오버로드로 그대로 resolve됩니다.

    flatMap도 마찬가지고요.

    그래서 이 일반화된 함수들로 인한 소스 호환성 문제는 없을 것으로 예상합니다.

    unsafelyUnwrapped는 순수한 일반화라 소스 호환성 문제를 일으키지 않습니다.

     

    ABI Compatibility

    Optional에 추가되는 새 메서드들은 표준 라이브러리의 새 API지만 별도 ABI를 필요로 하지 않아요.

    map, flatMap, unsafelyUnwrapped의 일반화 역시 새로운 ABI를 추가하거나 기존 ABI를 깨뜨리지 않습니다.

     

    Implications on Adoption

    Optional.ref, Optional.mutableRef, Optional.put()RefMutableRef와 동일한 availability를 가집니다.

    나머지 일반화 부분들은 항상 사용 가능한 것으로 표시됩니다.

     

    Future Directions

    Borrow와 inout 바인딩

    앞으로 borrow/inout 바인딩을 지원하기로 한다면, 컴파일러가 optional에 대해 if borrow / if inout 패턴을 인식해서 조건부로 scope된 접근을 제공하게 할 수도 있어요.

    if borrow x = optional {
      
    }
    
    foo(optional) // ok

     

    만약 이 방향이 ref / mutableRef 방식보다 낫다고 판단되면, map, flatMap, unsafelyUnwrapped를 일반화하는 방식도 다시 고민해야 해요.

    더 이상 항상 consuming일 수 없으니까요.

    consumingMap, borrowingMap, mutatingMap 등을 따로 만드는 방법도 있지만, API가 계속 늘어나는 건 그리 썩 괜찮은 해법은 아니라고 하네요 😅

     

    Optional: Equatable / Hashable 일반화

    SE-0499가 Equatable, Hashable 같은 프로토콜을 noncopyable / nonescapable conformer까지 지원하도록 일반화한 만큼, Optional의 이 conformance들도 suppressed wrapped 타입을 지원하도록 일반화하는 게 당연해 보입니다.

    다만 특정 conformance가 어느 시점에 일반화됐는지 표현할 방법이 없어서, 오래된 ABI 안정 OS에서 실수로 제네릭 ==hash(into:)를 호출해 payload를 copy해버리는 상황은 막아야 해요.

     

    Ref / MutableRef 자동 역참조

    제안된 예시들에서 ref / mutableRefmap을 쓸 때 .value 프로퍼티를 명시적으로 접근해야 했죠.

    앞으로 RefMutableRef에 자동 역참조 기능을 추가해서, 아래처럼 더 편하게 쓸 수 있게 할 수도 있습니다.

    func bar(x: borrowing Optional<Atomic>) -> Optional {
      x.ref.map { // ok!
        // '.value.'가 더 이상 필요 없음
        $0.load(ordering: .relaxed) &+ 1
      }
    }

    Alternatives Considered

    Optional 바인딩의 기본 소유권 방식 변경

    이 제안의 동기에서는 ref, mutableRef 같은 프로퍼티가 borrowing 버전의 제어 흐름을 위해 필요하다고 설명했어요.

    대신 if let 같은 optional 바인딩의 기본 소유권 방식을 borrowing으로 바꾸는 방법도 있었습니다.

    하지만 이건 optional 바인딩이 consuming이라고 가정하는 기존 코드에 대해 source-breaking 변경이 됩니다.

     

    Optional.borrow()와 Optional.mutate() 메서드

    이전 리비전에서는 ref, mutableRef 대신 borrow(), mutate()라는 메서드 이름을 제안했었어요. 하지만 InlineArrayUniqueArray 같은 타입의 span, mutableSpan 프로퍼티와 결을 맞추고 싶어서 최종적으로는 프로퍼티 형태가 더 선호됐습니다.

     

    Conclusion

    noncopyable optional을 다루는 게 이번 제안으로 한결 편해질 것 같아요 🙌

    지금까지는 내부 값을 살짝 들여다보기만 해도 consume 에러를 피하려고 switch 문을 직접 작성해야 했는데, ref / mutableRef 덕분에 if let처럼 자연스럽게 접근할 수 있게 됐습니다.

    put()도 강제 언래핑을 줄여주는 실용적인 추가고요.

    map, flatMap, unsafelyUnwrapped까지 일반화되면서, noncopyable 타입을 다루는 코드가 Copyable 타입을 다룰 때와 비슷한 느낌으로 작성할 수 있을 것 같습니다 🙌

     

    References

     

    swift-evolution/proposals/0532-optional-noncopyable-improvements.md at main · swiftlang/swift-evolution

    This maintains proposals for changes and user-visible enhancements to the Swift Programming Language. - swiftlang/swift-evolution

    github.com

Designed by Tistory.