Challenge: a good pattern to implement a must-consume type

Hi, I have a function returning a type, the type has several special methods. The core requirement is that I want the users of this function to always remember invoking one of the special methods before the returned value go out of scope.

My attempt is this: model this type as noncopyale, then mark those special methods as consuming, and at last rely on runtime checks in deinit:

struct MyResource: ~Copyable {

    var isConsumed = false

    // a *normal* function
    func read() -> String { "" }

    // a *special* function
    consuming func delete() {
        /* ... do something ... */
        isConsumed = true
    }

    // a *special* function
    consuming func saveToFile() {
        /* ... do something ... */
        isConsumed = true
    }
    
    deinit {
        assert(isConsumed)
    }
}

func sdkAPI() -> MyResource { MyResource() }

func user() {
    let resource = sdkAPI()
    if Bool.random() {
        resource.saveToFile()
    } else {
        return    // runtime crash
    }
}

What is your way to achieve this goal, is there any techniques that rely on -- not runtime, but -- compile-time features?

IMO your approach is a bit far-streched. Since there is no language feature to support that behavior, I think you might rely on API design. Suppose this is your original code:

struct Resource {
    var value: Int

    func delete() { }
    func save() { }
}

func _foo(value: Int) -> Resource {
    return Resource(value: value)
}

How about implementing a wrapper function and expose it as API?

extension Resource {
    enum Operation {
        case delete
        case save
    }
}

func foo(value: Int, op: Resource.Operation) {
    let res = _foo(value: value)
    switch op {
    case .delete:
        res.delete()
    case .save:
        res.save()
    }
}

It's simple and clear and works at compile time. It would be ideal to implement it using macro, though I doubt if it's feasible in practice.

EDIT: if delete() and save() take parameters, you can pass them using associated values.
EDIT2: Python has a nice feature called context manager, which is often used to manage resource. In Swift it's typically implemented as API. There are lots of examples in stdlib, though I can't think of one off the top of my head.

Thanks for your reply, but in my opinion that does not answer the general-purpose question being asked. What is needed here is kind of a reserved version of definite initialization analysis: definite consumption.

For your example, how can we prevent users from forgetting to call either foo(op: .delete) or foo(op: .save)?

You create the resource and clean it up in the same wrapper. The wrapper essentially does the following:

  • create a resource
  • call the closure passed by user on the resource (I forgot this part in my example)
  • clean up the resource

PS: I think those APIs in Swift stdlib starting with "with" prefix follow this design. Example: withCString(_:).

2 Likes

You can use discard self in a consuming method to indicate that self has already been destroyed and the deinit does not need to run. That will let you fatalError in deinit without tracking dynamic state:

struct MyResource: ~Copyable {
    // a *normal* function
    func read() -> String { "" }

    // a *special* function
    consuming func delete() {
        /* ... do something ... */
        discard self
    }

    // a *special* function
    consuming func saveToFile() {
        /* ... do something ... */
        discard self
    }
    
    deinit {
        fatalError("not consumed")
    }
}

Without language support for must-consume types, this is the best you can do.

11 Likes

And note that even if we got language support for must-consume types, you could always leak such a type:

struct TryingToBeLinear: ~Copyable {
    consuming func finalize() { ...; discard self }
    deinit { fatalError() }
}

func naughty(_ x: consuming TryingToBeLinear) {
    final class B {
        var other: AnyObject!
    }
    final class A {
        let x: TryingToBeLinear
        let b: AnyObject
        init(_ x: consuming TryingToBeLinear, _ b: AnyObject) { self.x = x; self.b = b }
    }
    let b = B()
    let a = A(x, b)
    b.other = a
    // no crash
}

You could make TryingToBeLinear also ~Escapable to avoid this possibility, I think.

2 Likes

It isn't foolproof, but one of the rules that a strictly-linear type would need to follow would be that any class that owns such a value would need to explicitly consume the value in its deinit. That wouldn't prevent you from leaking such a value by putting it in a global object or entangling it in a reference cycle, but the behavior would at least serve as a signal that it won't "just work". (Though more generally, your process can still crash or exit before the end of the value's lifetime even disregarding such leaks, in which case any cleanups also won't run.)

4 Likes