Question: Why does a nested self-capturing function interfere with isKnownUniquelyReferenced(_:)?

Question

Why does a nested self-capturing function interfere with isKnownUniquelyReferenced(_:)?

Answers 1
Added at 2017-01-03 18:01
Tags
Question

I was attempting to implement copy-on-write behaviour in a custom collection when I came across this strange behaviour with nested functions that capture self.

In the following code, the call to isKnownUniquelyReferenced(_:) will always return false, despite being called before the nested function is even defined:

class Foo {}

struct Bar {

    var foo = Foo()

    public mutating func bar() {

        print(isKnownUniquelyReferenced(&foo))

        func nestedFunc() {
            _ = self // capture self
        }
        nestedFunc()
    }
}

var b = Bar()
b.bar() // false ?!

Why on earth is this the case, and what can I do to fix it (assuming that nestedFunc actually does something useful with self)?

I’m aware that the capturing of self could interfere with the call to isKnownUniquelyReferenced(_:) – but surely that happens at the point of calling nestedFunc() in this case?

Answers to

Why does a nested self-capturing function interfere with isKnownUniquelyReferenced(_:)?

nr: #1 dodano: 2017-01-03 18:01

I consider this a bug, and have filed a bug report (SR-3530). I was however interested by the cause of this problem, so did some digging – and this is what I found.

Taking a look at the canonical SIL generated for the bar() method (for an -Onone build) reveals that Swift is heap allocating a box (alloc_box) for self at the very beginning of the method – so that it can be captured by nestedFunc().

// Bar.bar() -> ()
sil hidden @main.Bar.bar () -> () : $@convention(method) (@inout Bar) -> () {
// %0                                             // users: %10, %3
bb0(%0 : $*Bar):

  // create new heap-allocated box, and store self in it.
  // this is where the problem stems from – there are now two copies of the Bar instance, thus isKnownUniquelyReferenced will return false.
  %1 = alloc_box $Bar, var, name "self", argno 1, loc "main.swift":15:26, scope 9 // users: %11, %9, %7, %2
  %2 = project_box %1 : $@box Bar, loc "main.swift":15:26, scope 9 // users: %10, %5, %3
  copy_addr %0 to [initialization] %2 : $*Bar, scope 9 // id: %3

  // call isKnownUniquelyReferenced (I removed the print() function call as it generates a bunch of unrelated SIL).
  // function_ref isKnownUniquelyReferenced<A where ...> (inout A) -> Bool
  %4 = function_ref @Swift.isKnownUniquelyReferenced <A where A: Swift.AnyObject> (inout A) -> Swift.Bool : $@convention(thin) <τ_0_0 where τ_0_0 : AnyObject> (@inout τ_0_0) -> Bool, loc "main.swift":17:9, scope 10 // user: %6
  %5 = struct_element_addr %2 : $*Bar, #Bar.foo, loc "main.swift":17:35, scope 10 // user: %6
  %6 = apply %4<Foo>(%5) : $@convention(thin) <τ_0_0 where τ_0_0 : AnyObject> (@inout τ_0_0) -> Bool, loc "main.swift":17:39, scope 10

  // retain the heap-allocated box containing self, in preparation for applying nestedFunc() with it.
  // (as it's passed as an @owned parameter).
  strong_retain %1 : $@box Bar, loc "main.swift":27:9, scope 10 // id: %7

  // call the nested function with the box as the argument.
  // function_ref Bar.(bar() -> ()).(nestedFunc #1)() -> ()
  %8 = function_ref @main.Bar.(bar () -> ()).(nestedFunc #1) () -> () : $@convention(thin) (@owned @box Bar) -> (), loc "main.swift":27:9, scope 10 // user: %9
  %9 = apply %8(%1) : $@convention(thin) (@owned @box Bar) -> (), loc "main.swift":27:20, scope 10

  // once called, copy the contents of the box back to the address of the Bar instance that was passed into the method, and release the box.
  copy_addr %2 to %0 : $*Bar, scope 10            // id: %10
  strong_release %1 : $@box Bar, loc "main.swift":29:5, scope 10 // id: %11

  // so cute.
  %12 = tuple (), loc "main.swift":29:5, scope 10 // user: %13
  return %12 : $(), loc "main.swift":29:5, scope 10 // id: %13
}

(Full SIL here)

Because of this boxing, there are now two copies of the Bar instance in the bar() method, therefore meaning that isKnownUniquelyReferenced(_:) will return false, as there are two references to the Foo instance.

From what I can tell, the boxing of self at the beginning of the method seems to be a consequence of the optimisation of the mutating method from copy-in copy-out (self gets boxed at the start of the method call, mutations are then applied to that box, then written back to the callee at the end of the method) to pass-by-reference (this optimisation occurs between the raw SIL and canonical SIL).

The same box that was used in order to create a copy of self to mutate within the method is now used in order to capture self to call the nested function with. I see no reason why the box for the capture shouldn’t be created just before the call to nestedFunc(), as that’s the logical place to capture self (rather than at the beginning of the method).

Although, in any case, the creation of a box is completely redundant in the first place, as nestedFunc() doesn’t and cannot escape. Attempting to return nestedFunc() from the method yields the following compiler error:

Nested function cannot capture inout parameter and escape

So it really just looks like a corner case that hasn’t been optimised yet. Even in an -O build, although the heap allocation for the Bar instance is able to be optimised to a stack allocation for just the foo property, this still results in an unnecessary second reference to the Foo instance.


Solutions

One solution would be to just add an inout self parameter to nestedFunc(), allowing self to just be passed by reference, rather than being captured:

func nestedFunc(_ `self`: inout Bar) {
    _ = self // do something useful with self
}
// ...
nestedFunc(&self)

which now generates the SIL (-Onone):

// function_ref Bar.(bar() -> ()).(nestedFunc #1)(inout Bar) -> ()
%5 = function_ref @main.Bar.(bar () -> ()).(nestedFunc #1) (inout main.Bar) -> () : $@convention(thin) (@inout Bar) -> (), loc "main.swift":31:9, scope 10 // user: %6
%6 = apply %5(%0) : $@convention(thin) (@inout Bar) -> (), loc "main.swift":31:25, scope 10

The advantage of this solution is that it's just a straightforward pass-by-reference (as the Bar parameter is marked @inout). Because of this, there only ever exists one copy of the instance of Bar – thus isKnownUniquelyReferenced(_:) can return true.

Another possible solution, if self isn’t mutated within nestedFunc(), is to pass self by value, rather than reference. This can be done with a capture list in a local closure:

let nestedFunc = { [`self` = self] in // copy self into the closure.
    _ = self                          // the self inside the closure is immutable.
}
// ...
nestedFunc()

The advantage is that you don’t need to explicitly pass anything into the nestedFunc() call. Because the instance of Bar isn’t passed by value until the closure creation – it won’t interfere with the call to isKnownUniquelyReferenced(_:), assuming that the call precedes the closure creation.

Source Show
◀ Wstecz