Why isn’t isUniquelyReferenced(_:) working here?

i have the following test program:

final
class C 
{
    init() 
    {
    }
}
enum E
{
    case empty 
    case c(C)
}

func foo()  
{
    var e:E = .c(.init())
    switch e 
    {
    case .c(var c):
        e = .empty
        print(isKnownUniquelyReferenced(&c))
    default:
        break
    }
}

foo()

by all means it should be printing true since i am releasing the enum reference by setting it to .empty. But it’s printing false. Why?

this similar function does not have this issue:

func foo()  
{
    var e:E = .empty
    switch e 
    {
    case .c:
        break
    case .empty:
        var c:C = .init()
        print(isKnownUniquelyReferenced(&c))
        e = .c(c)
        print(isKnownUniquelyReferenced(&c))
        e = .empty 
        print(isKnownUniquelyReferenced(&c))
    }
}
true
false
true

[It is 12:45 AM in my timezone so this might be way off the mark but...]

I suspect that the reason is that matching is non-consuming. If c was uniquely referenced, then based on the scope, one would expect that it would be released after the print. However, that means that adding code which again matched on e afterwards will run into trouble.

func foo2()
    var e:E = .c(.init())
    switch e 
    {
    case .c(var c):
        // +2
        e = .empty
        print(isKnownUniquelyReferenced(&c))
        // +1
    default:
        break
    }
    // We need to have at least +1 for later access.
    switch e {
    case .c(var c):
        // +2
        e = .empty
        print(isKnownUniquelyReferenced(&c))
        // +1
    default:
        break
    }
}

This code should certainly work. However, if this also works, and foo() works as per your expectations, now the behavior of the first print is affected by what is present later in the function, possibly hundreds of lines below. Which would be strange from a user perspective.

One can imagine that you could have a move switch which avoids the extra retain/release but it prevents you from accessing e later so it is less flexible. IIUC, this is exactly what Rust's match does for non-trivially-copyable types.

@Michael_Gottesman has been looking at this kind of thing lately and might have some insight.

On the 5.3 compiler, it looks like it does return true when compiled with -O, even without the = .empty assignment, because the unused enum wrapper is completely eliminated. This kind of observable behavior at different optimization levels makes me a little uneasy, tbh. It would be great to make more formal guarantees about this even in unoptimized code (and also to provide in-place accessors for enum associated values).

Okay, i was able to get the correct refcounting behavior with the class directly in the enum payload, but this slightly more realistic example, where the class is wrapped in a struct doesnt:

final
class C 
{
    init() 
    {
    }
}
struct S
{
    var c:C

    init()
    {
        self.c = .init()
    }
    mutating 
    func foo()
    {
        print(isKnownUniquelyReferenced(&self.c))
    }
}

enum E
{
    case empty 
    case s(S) 
}

func foo()  
{
    var e:E = .s(.init())
    switch e 
    {
    case .s(var s):
        e = .empty
        s.foo()
    default:
        break
    }
}

foo()

the assignments that happen inside the switch still work as expected:

func foo()  
{
    var e:E = .empty 
    switch e 
    {
    case .s:
        break
    default:
        var s:S = .init()
        s.foo()
        e = .s(s)
        s.foo()
        e = .empty
        s.foo()
    }
}
// true
// false
// true

what i really don’t get is why this, structurally identical code using Optional instead of E works but my first example doesn’t:

func foo()  
{
    var e:S? = .some(.init())
    switch e 
    {
    case .some(var s):
        e = nil 
        s.foo()
    case .none:
        break
    }
}

The observable semantics of isUniquelyReferenced have to be sensitive to optimization or else we'd almost never be able to shorten the lifetimes of objects. That's all that's happening here: the optimizer is destroying the variable e early because it's not being used. We do not want to codify a C++-like strict-lifetimes model.

Let's step back to look at the original question. There is no way to mutate case payloads in a switch in Swift today. That might seem like a non sequitur, but it's actually critical. The question answered by isUniquelyReferenced is really "does this variable hold the only reference to its current value?", and that only really works from the user's perspective if the variable passed in is really the variable that the user cares about, not a copy that happens to hold the same value. That's why it takes the variable by reference in the first place: it's not actually a useful question to ask about a value. So this function can only do the right thing for case payloads if we're able to bind the pattern variable directly to the storage of the payload, which means that the entire switch essentially has to be an exclusive access to wherever the enum is stored. If we could do that, then we could just as well allow payloads to be mutated. But those aren't the current semantics of switch.

So the right solution here is to add some way to do a mutating switch, and then you'll be able to bind pattern variables like c directly to the storage of the payload, and then it'll be reasonable to ask whether that storage holds a unique reference.

4 Likes

could we have something like a

enum E
{
    case foo(inout [Int], Bool)
}

where pattern matching it with a var binding would mutate the original enum payload?

that still wouldn’t solve the case where completely replacing the enum case should release all references though. but can’t that already be done without changing the semantics of the language?

I don’t think it makes sense to put inout in the case declaration. It should be in the pattern match, like this:

var e = E.foo([42], true)
switch e {
case .e(inout ints, inout bool):
    ints.append(43)
    bool = false
}
print(e) // .foo([42, 43], false)
1 Like

It should also hold write access to e, so?

switch &e {
...
}

I wonder what should be the right behaviour if we access e in case block. It should be access violation, but then default case makes me uncertain.

3 Likes

well, a switch is really just an elaborate if statement, so there’s no reason why the following should have different behavior, yet for some reason it does:

func foo()  
{
    var e:E = .s(.init())
    if case .s(var s) = e 
    {
        s.foo() // true 
        e = .empty 
        s.foo() // true
    }
    
    e = .s(.init())
    switch e 
    {
    case .s(var s):
        s.foo() // false
        e = .empty 
        s.foo() // false
    default:
        break
    }
}

this would make for some strange spellings when combined with let/var bindings. where else in the language do you see inout var?

It wouldn’t make any sense to combine this with var or let.

Lantua is right, though: we’d want something up front in the switch to flag that the whole thing is a mutation of the variable. Once you have that, we could potentially just use var in the pattern itself.

5 Likes

Syntax wise, I think mutating switch works nicely in tandem with inout variable declaration:

do {
  ref x = &y
}

switch &y {
case ref x: ...
}

i would support the switch &y syntax. but i think this thread (as usual) has drifted far from the original issue, which is that this assignment:

case .c(var c):
    e = .empty

ought to decrement the reference count of c by 1, just as setting an equivalent Optional<C> to nil would. i don’t see why we need to invent new syntax and completely new semantics to fix what i would just have considered a bug.

It does, but the old enum value is copied to get the value to switch over, and then the enum payload is copied to initialize the var, so it’s still not uniquely referenced. This is why I emphasized the importance of doing an “in-place” switch that guarantees direct binding all the way down.

why does this retain persist through the entire execution of the case blocks? the reference is only needed for the pattern matching:

    e = .s(.init()) // +1 (total: 1)
    switch e // +1 (total: 2)
    {
    // +1 for var binding (total: +3)
    case .s(var s): 
    // -1 for return from pattern match (total: +2)
        s.foo() 
        e = .empty // -1 (total: +1)
        s.foo() // should be uniquely referenced
   
    default:
    // -1 for return from pattern match (total: +1)
        break
    }

It’s not guaranteed either way, but yes, the implementation could absolutely do a better job “forwarding” the switched-over value into the pattern variable.

2 Likes

should I file a bug?

Never hurts.

1 Like

should I file a bug?

John already sorta said this, but the answer to "should I file a bug?" is always yes. If it makes sense to ask the question, the answer is yes. (Not just for Swift, for any project.)

2 Likes

Filed the following issues:

SR-13112: assignment to enum instance should deinitialize associated values on all optimization levels

SR-13113: assignment to enum instance should recursively deinitialize associated values

SR-13114: switch statement over enum instance should release references in associated values on successful pattern match

1 Like