ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [SE-0544] Mutation and consumption in non-Copyable type deinits
    Swift 2026. 8. 31. 13:37

    안녕하세요. 그린입니다 🍏
    이번 포스팅에서는 SE-0544 — noncopyable 타입 deinit에서의 mutation과 consumption에 대해 정리해보겠습니다 🙋🏻

    Intro

    Proposal: SE-0544

    Author: Joe Groff

    Review Manager: John McCall

    Status: Accepted

    Implementation: swiftlang/swift#90836

    noncopyable 타입은 deinit을 정의해서 생명주기가 끝날 때 소유하고 있던 리소스를 정리할 수 있어요.

    하지만 이 제안 이전에는 deinit 본문 안에서 self가 immutable하고 오직 borrow만 가능했어요.

    이 제안은 deinitself의 필드를 mutate하거나 consume할 수 있게 허용하되, value resurrection 문제를 피하기 위해 self 전체를 하나의 값으로서 mutate하거나 consume하는 건 여전히 막아두자고 제안합니다.

     

    Motivation

    많은 noncopyable 타입 구현체는 리소스를 소유한 다른 noncopyable 값들로 구성돼요.

    그 구성 요소들이 전체 값의 정리 과정에서 어떻게 consume되는지 제어하고 싶은 건 자연스러운 요청일겁니다.

     

    struct File: ~Copyable {
      consuming func close() {...}
    }
    
    struct Buffer: ~Copyable {
      borrowing func flush(to file: borrowing File) {...}
      consuming func release() {...}
    }
    
    struct BufferedFile: ~Copyable {
      let file: File
      let buffer: Buffer
      
      deinit {
        // Flush then close the buffer
        buffer.flush(to: file)
        buffer.release()
        // Then close the file
        file.close()
      }
    }

     

    비슷한 맥락에서, deinit이 정리 과정의 일부로 mutating 메서드에 담아둔 코드를 쓰고 싶을 수도 있어요.

     

    Proposed Solution

    deinitself의 필드를 mutate하고 consume할 수 있도록 허용하자고 제안합니다.

     

    Detailed Design

    "resurrection"과 의도치 않은 재귀 피하기

    noncopyable 타입의 deinit은 값의 소유권을 가진 컨텍스트 중에서도 독특한 존재예요.

    다른 소유 컨텍스트라면 값을 암묵적으로 deinit을 호출해서 파괴하겠지만, deinit 자신은 당연히 그럴 수 없죠.

     

    deinit은 오직 값을 구성하는 저장 프로퍼티나 채워진 enum case만 파괴합니다.

    만약 deinitself를 consuming이나 mutating 연산에 통째로 넘길 수 있다면, 그 callee의 생명주기가 끝나는 시점에 다시 deinit이 호출될 테니 값이 callee 안에서 "부활"하는 셈이 돼요.

     

    이러면 실수로 무한 루프를 만들기 쉬워집니다.

     

    struct Foo: ~Copyable {
      deinit {
        self.foo()
      }
    
      consuming func foo() {
        // oops, implicitly calls back into \`deinit\`
      }
    }
    
    struct Bar: ~Copyable {
      deinit {
        self.bar()
      }
    
      mutating func bar() {
        // oops, implicitly calls \`deinit\` on the old value of \`self\`
        // before reassigning it
        self = Bar()
      }
    }

     

    이 제안은 이런 문제를 피하기 위해 self를 하나의 값으로서 mutate하거나 consume하는 걸 금지해요.

    대부분의 경우 이 정도 제약은 받아들일 만하다고 봅니다.

    다만 이 제약의 한 가지 결과로, deinit은 같은 타입의 mutating이나 consuming 메서드를 호출할 수 없어요.

    그래도 deinit은 필드를 다루는 static 메서드를 통해 다른 메서드와 로직을 공유할 수는 있습니다.

     

    struct Resource: ~Copyable {
      var resourceID: Int
      
      // Shared logic for releasing the underlying resource by ID.
      // The release operation may surface error conditions, but these can be
      // ignored in normal use.
      private static func release(resourceID: Int) throws {...}
      
      // Consuming method that releases the resource, surfacing errors to be
      // handled
      consuming func release() throws {
        try Self.release(resourceID: self.resourceID)
        discard self
      }
      
      // Deinit that implicitly closes the resource, swallowing errors
      deinit {
        do {
          try Self.release(resourceID: self.resourceID)
        } catch {
          // Ignore the error
        }
      }
    }

     

    또한 deinit이 값 전체의 소유권을 넘기고 싶은 상황도 있을 수 있어요.

    예를 들어 값을 정리하는 데 시간이 오래 걸린다면, deinit 도중 바로 정리하기보다는 죽어가는 값을 나중에 정리하도록 큐에 넣어두는 게 나을 수 있죠.

    deinit은 항상 타입의 원본 선언 안에 정의되기 때문에, 언제나 struct의 레이아웃과 memberwise 이니셜라이저에 접근할 수 있어요.

     

    그래서 self의 필드들을 memberwise 이니셜라이저에 넘겨서 값을 명시적으로 "부활"시킬 수 있습니다.

    let deferredCleanupValues: ConcurrentQueue
    
    struct DeferredCleanup: ~Copyable {
      var resource1: Resource1
      var resource2: Resource2
    
      deinit {
        // Instead of cleaning up this value's resources immediately, push an
        // equivalent value into the queue to be cleaned up later
        let newSelf = Self(resource1: self.resource1, resource2: self.resource2)
        deferredCleanupValues.push(newSelf)
      }
    
      consuming func runTimeConsumingCleanup() async { ... }
    }
    
    func runDeferredCleanups() async {
      while let value = deferredCleanupValues.pop() {
        await value.runTimeConsumingCleanup()
      }
    }

     

    그 밖의 제약

    deinit 도중 클로저 안에서 self를 캡처하는 건 여전히 허용되지 않아요.

     

    부분적으로 consume된 self의 정리

    deinit이 리턴하는 시점에 self의 어떤 구성 요소가 아직 consume되지 않았다면, 남은 구성 요소들은 암묵적으로 파괴돼요.

    여기엔 noncopyable 구성 요소의 deinit을 실행하는 것도 포함됩니다.

     

    Alternatives Considered

    self를 전체 값으로서 mutate하거나 consume하는 제약이 실제로 너무 부담스럽다고 판명되면, 이 제약을 완화하는 방향도 열려 있어요.

     

    resurrection 위험을 완화할 다른 방법들도 있습니다.

     

    resurrect self 연산 도입하기

    이 제안의 deinit들은 필드로부터 memberwise-initialize해서 self를 수동으로 "부활"시킬 수 있지만, 이건 장황해요.

    이게 흔한 패턴이 되면 이를 위한 축약 문법을 도입할 수도 있어요.

    이건 discard self의 반대로 볼 수 있는데, discard selfself의 암묵적 deinit을 비활성화하고 self를 전체 값으로 더 이상 쓸 수 없게 만드는 거라면, 이건 반대로 self의 암묵적 deinit을 다시 활성화해서 전체 값으로 다시 다룰 수 있게 하는 셈이니까요.

     

    "deinit-safe" 메서드에 어노테이션 달기

    deinit이 전체 값에 적용할 수 있는 연산을, 어떤 방식으로든 "deinit-safe"임을 명시적으로 표시한 메서드로만 제한할 수도 있어요.

    그렇게 표시된 consuming 메서드는 반드시 discard self를 하도록 요구하고, 표시된 mutating 메서드는 self를 완전히 재할당하는 걸 막는 식입니다.

     

    deinit이 지역적으로 정의된 메서드만 호출하도록 제한하기

    명시적인 어노테이션 대신, deinitself를 mutate하거나 consume할 수 있는 방법을, deinit과 함께 원본 타입 정의 안에 정의된 메서드나 같은 모듈 안의 메서드로만 제한할 수도 있어요.

    그러면 파일/모듈 단위 분석으로 deinit에서 호출되는 메서드가 잠재적으로 다시 deinit을 호출하는 지점을 감지할 수 있을 거예요.

     

    mutation 이후 self의 명시적 consumption이나 discard 요구하기

    첫 번째 pitch 스레드에서 ellie20은 "값이 mutate된 이후엔 어떤 의미에서 다른 값이다"라고 지적하면서, self를 전체 값으로 mutating한 이후에는 그 뒤의 값을 명시적으로 discard하거나 consume하도록 요구해야 한다고 주장했어요.

    그 시점의 값은 더 이상 원래 deinit 대상이던 값이 아니기 때문이에요.

     

    struct A: ~Copyable {
      deinit {
        self.replace()
        // explicitly indicate that the replaced \`self\` is discarded, rather than
        // deinit-ed
        discard self
      }
    
      mutating func replace() { ... }
    }
    
    struct B: ~Copyable {
      deinit {
        self.replace()
        // explicitly indicate that the replaced \`self\` is consumed, causing deinit
        // to run again on the new value
        _ = consume self
      }
    
      mutating func replace() { ... }
    }

     

    Conclusion

    그동안 noncopyable 타입의 deinitself를 오직 borrow만 할 수 있어서, 내부 필드를 정리하는 로직을 mutating/consuming 메서드로 재사용하기 어려웠어요.

    이 제안 덕분에 필드 단위의 mutate/consume은 자유로워지면서도, self 전체를 다루는 건 여전히 막아서 "부활"과 무한 재귀라는 위험한 함정은 피했다는 점이 인상적이에요.

    static 메서드로 로직을 공유하거나, memberwise 이니셜라이저로 명시적으로 부활시키는 우회로도 함께 마련해둔 균형 잡힌 제안 같습니다 🙌

     

    References

     

    swift-evolution/proposals/0544-mutate-or-consume-in-deinit.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.