Use of capture lists in structs

Hello, I'm new to Swift and I was looking at capture lists and their use cases for struct types. (Most information I've found online relate to how they're used to break reference cycles in classes and so on, but I wasn't able to put together if/when I might use them with structs)

So I was experimenting with capture lists, and ran into the following which surprised me:

struct SomeStruct {
    var a: Int
    var b: Int

    var sum: Int {
        return a + b
    }
    
    func getInnerClosure() -> () -> () {
        return { print(self.sum) }
    }

    func getInnerClosureWithExplicitCapture() -> () -> () {
        return { [self] in print(self.sum) }
    }
}

var myStruct = SomeStruct(a: 3, b: 5)

let innerClosure = myStruct.getInnerClosure()
let innerClosureWithExplicitCapture = myStruct.getInnerClosureWithExplicitCapture()
let outerClosure = { print(myStruct.sum) }
let outerClosureWithExplicitCapture = { [myStruct] in print(myStruct.sum) }

innerClosure()                      // prints 8
innerClosureWithExplicitCapture()   // prints 8
outerClosure()                      // prints 8
outerClosureWithExplicitCapture()   // prints 8

myStruct.a = 99

innerClosure()                      // prints 8
innerClosureWithExplicitCapture()   // prints 8
outerClosure()                      // prints 104
outerClosureWithExplicitCapture()   // prints 8

Here I'm defining a simple struct with two fields and a property. The struct also has two methods which act like "closure factories", where the closures capture self, and print out self.sum. I've labelled these "inner closures". One of these use an explicit capture list to capture self, and one does not.

At the same time, I'm creating the same closures in file-scope, by hand, which I'm referring to as "outer closures". Again, one with an explicit capture list and one without.

For the outer closures, the behavior seems to be that when the closed-over variable is included in the capture list, it is captured inside the closure "eagerly" (with a copy?) whereas not using a capture list results in a "lazy" capture. As in, if I modify myStruct and then evaluate the closure, the new struct attribute values appear to be visible to outerClosure.

I was expecting something similar for the "inner" closures that were generated by struct methods; namely that the capture lists control whether the capture is "lazy" or not. However it seems like regardless of the presence of [self] in a capture list, self is always captured eagerly.

Could you help me understand the difference in behavior I'm seeing. Is this expected? Thanks!

1 Like

You're running up against the behavior that is noted in this post as a potential gotcha. Namely, when you put a variable in a capture list, you are effectively capturing the referenced variable by value, whereas without the capture list you are capturing by reference.

The reason this isn't exhibited by capturing self is that when you modify myStruct by setting myStruct.a = 99, you have actually formed a new SomeStruct instance, which is now different from the instance that is captured in innerClosure or innerClosureWithExplicitCapture. Consider the simpler example:

struct S {
  var x: Int
  func printX() { print(x) }
}

var s = S(x: 0)
let printXOuter = s.printX

s.x = 3

printXOuter() // 0
s.printX() // 3

Here, even though there's no closures involved, we still see that the changes to s don't propagate to printXOuter.

3 Likes

Thank you! So for innerClosure, self was indeed captured by reference. However, the mutation applied to the myStruct variable in the file-scope was not an "in-place" mutation (couldn't be), which meant that the previously-captured self ended up being completely separate from the newly-mutated myStruct.

It's tricky indeed (certainly to me!). Thanks again! :)

I think a better way to think about it is that when you capture self implicitly you aren't necessarily capturing it at the location of the the instance that corresponds to the self param (since self is really just an invisible parameter to getInnerClosure). As you note, outerClosure is perfectly able to observe changes to myStruct, so the mutation must be performed in place.

Here's another example that I think is illustrative:

func formClosure(_ x: Int) -> () -> Void {
    { print(x) }
}

var x = 3
let innerClosure = formClosure(x)
let outerClosure = { print(x) }

x = 4

innerClosure() // 3
outerClosure() // 4

we've replaced self with an explicit parameter to the function, and we similarly are unable to observe changes to the argument outside formClosure from within formClosure.

2 Likes

Thanks again Jumhyn! Is there a missing word in the sentence " you aren't necessarily capturing it the location of the instance ..." -- I want to make sure I understand this right

1 Like

Yes, that snippet should read "you aren't necessarily capturing it at the location of the instance". Edited appropriately. Thank you!

1 Like

To round this off, could the takeaway be something like: "For a closure that implicitly captures a value-type, the closure can observe changes in the captured variable as long as those changes are in the same scope as where the closure was first defined."?

1 Like

I would say it's more important to think about storage locations than scopes. E.g., consider:

var x = 0
var maybeClosure: (() -> Void)?
do {
  maybeClosure = { print(x) }
}
x = 1
maybeClosure?() // 1

Even though the change happens outside the scope where the closure is defined, we're still able to observe the change.

If we instead think about storage locations, we can actually generalize everything we've seen under one principle: closures always capture variables by reference. The various behaviors we've seen so far that make some captures appear to happen by value are actually the result of us creating new storage locations at some step along the way.

First, let's consider capture lists. When I write:

var x = 0
let closure = { [x] in print(x) }
x = 1
closure() // 0

the compiler actually transforms this so that it is identical to writing:

var x = 0
let closure = { [x = x] in print(x) }
x = 1
closure() // 0

which is further equivalent to something like (departing from actually valid Swift syntax):

var x = 0
let closure = { [let x = x] in print(x) }
x = 1
closure() // 0

That is, when you insert a variable into the capture list, you're actually declaring a new variable with the same name as the original variable, and capturing the new variable. In fact, the way that capture lists are written inside the closure is somewhat misleading. The semantics are actually more like:

var x = 0
let closure: () -> Void
do {
  let x = x
  closure = { print(x) }
}
x = 1
closure() // 0

I.e., the new x gets initialized to the value of the old x at the point when the closure is declared, not when the closure is later run (as can be seen from the behavior in the examples).

Similarly, when you capture a function parameter (or self, since self is just an implicit parameter):

func formClosure(_ x: Int) -> () -> Void {
    { print(x) }
}

var x = 0
let closure = formClosure(x)
x = 1
closure() // 0

You're capturing the parameter x to formClosure, which does not necessarily have the same storage location as the argument x that we passed in. This should be clear from the fact that we can rename the parameter:

func formClosure(_ y: Int) -> () -> Void {
    { print(y) }
}

var x = 0
let closure = formClosure(x)
x = 1
closure() // 0

The fact that closure() here prints 0 should be no more surprising than the fact that the following prints 0 as well:

var x = 0
let y = x
let closure = { print(y) }
x = 1
closure() // 0
7 Likes

Awesome; all clear now :) Thank you sincerely for taking the time!

1 Like
Terms of Service

Privacy Policy

Cookie Policy