Ownership Annotations

I don't this restriction was actually mentioned above? But I'll assume it's something like

Ownership annotations on witnesses satisfying protocol requirements must match the annotations on the protocol declaration, e.g.

protocol Foo {
    associatedtype T
    consuming func f() 
    func g(x: consume T, y: borrow T)
}
struct Bar: Foo {
    func f() {} // error: missing 'consuming'
    func g(x: borrow T, y: consume T) {} // error: 'x' should be 'consume', 'y' should be 'borrow'
}

Sound reasonable?

The proposal doesn't actually say if a witness can be annotated. In fact, I don't actually think there's a way to get to make this API-compatible with the annotations-must-match restriction:

  • if they can be annotated, then the protocol cannot have its annotations changed in a API-compatible way, as type that conforms may have annotations matching the old declaration, but these won't match (and will violate the restriction)
  • if they can't be annotated, adding even a defaulted requirement may break code along the lines of @orobio's example, where there's a (previously) unrelated method that has annotations: adding the requirement makes this method a witness, but it has annotations!

This, along with not wanting to silently change behaviour as @orobio's example demonstrates, pushes me more towards no restriction. It seems feasible to have tooling (or a compiler flag/Xcode setting) that will flag mismatches between witnesses and protocol requirements on an opt-in basis.

The abstract idea of a protocol may not match all concrete implementations of it, because they may be more specialised or have "surprising" trade-offs (e.g. optimise one function to be O(1) at the cost of another becoming O(n2)). For instance, imagine a Bag protocol (ala Collection):

protocol Bag {
    associatedtype Element
    // presumably 'add' will usually store the value somewhere
    func add(_ value: consume Element)
    ...
}

extension Set: Bag {
    // 'add' does store the value into memory somewhere, and so 
    // benefits from 'consume'
    func add(_ value: consume Element) { ... }
    ...
}
extension Array: Bag {
    // Same as 'Set'
    func add(_ value: consume Element) { ... }
    ...
}

struct StringTrie: Bag {
    typealias Element = String
    // this is a trie, and so never actually stores 'value': it is
    // broken into parts  so there's no point in consuming the value
    func add(_ value: borrow String) { ... }
    ...
}

Concrete users of StringTrie avoid retains and releases, and generic users pay "only" the cost of the extra function call of the thunk, since consume->borrow is cheap. I think the protocol has the correct annotation here, as it is optimising for the most common case (and, in a moveonly world, maintaining maximum generality of what types can conform to it), but the concrete type has extra constraints that allow it to relax the requirements.

In a world where distinctions between inout, consume and borrow are enforced strictly there's a hierarchy: an owned value (consume) can always have a mutable pointer (inout) created to it for free and a mutable pointer can always be downgraded to an immutable one (borrow) for free . This implies that concerns about the performance of copying don't apply to satisfying a consume requirement with an inout witness (definitely a bit weird, but it's theoretically okay), an inout requirement with a borrow witness (i.e. protocol is giving the option for mutation, but the concrete implementation doesn't need it) and, transitively, a consume requirement with a borrow one.

Of course, the cost of the thunk call itself could be problematic enough (although these very small thunks seem like they'd be pretty much just the cost of a call to a function in another dylib?).

I don't think this is a particular concern: it will always be statically known if a type could be a moveonly type (due to source compatibility, there can't be a way to retroactively make a type moveonly, or instantiate a generic parameter with a moveonly type unless the parameter is marked as "possibly moveonly" in some form), and so statically known when ownership annotations must match. I don't think there's much value in maintaining consistency with behaviour with copyable types, because this doesn't seem particularly different to, say, func f(_: consume T) {} func g(x: borrow T) { f(x) } (that's also a mismatch in borrow/consume: that works with copyable T and can't if it's moveonly), and there's numerous other ways in which moveonly types different in behaviour (e.g. let t = (x, x)).

(This changed to borrow very recently: Plan to change the convention of passed "Normal Parameters" from +1 to +0 - #9 by Michael_Gottesman)

2 Likes