import UIKit
@MainActor func issue(stackView: UIStackView) {
//error: Converting function value of type '@MainActor (UIView) -> Void' to '(UIView) throws -> Void' loses global actor 'MainActor'
stackView.arrangedSubviews.forEach(stackView.removeArrangedSubview)
}
forEach is of course
func forEach(_ body: (Path.Element) -> Void)
I am convinced that the typesystem should understand this is fine. Non-escaping parameter body can only be called on the same actor as forEach, which is known at the diagnostic to be the main actor. So this "conversion" of closure type isn't really.
I could imagine some kind of "re-actor" annotation (by analogy to rethrows) that makes the connection to forEach and its body explicit, but I think inferring it for nonescaping closures would handle most of the common cases.
That doesn't seem strictly true; one could use withoutActuallyEscaping, send the closure to another actor, and then block until the results come back. I haven't thought through whether that can violate any of the guarantees Sendable is protecting, though.
one could use withoutActuallyEscaping , send the closure to another actor, and then block until the results come back.
This is a good catch. I was under the impression that this was UB, but a close reading of withoutActuallyEscaping agrees with you. Assuming we don't expand its list of UB I think there are no easy annotation-free solutions.
Perhaps I'm exposing a gap in my understanding of how all the concurrency proposals fit together, but I still don't see how that lets you achieve the "send the closure to another actor" part of the story. In the example at top-of-thread, body: (Path.Element) -> Void isn't @Sendable, and withoutActuallyEscaping(_:do:) doesn't make it @Sendable, so wouldn't you keep the guarantee that the @escaping version of the closure can't cross concurrency domains within the call to forEach?
I was surprised by this as well, but it appears in practice that the closure does not have to be sendable to cross a concurrency domain. Demo
actor Illicit {
var mutation = false
var view: UIView
func takeIllicitly(_ body: /* not sendable */ (UIView) -> Void) {
//prove we are 'on' the actor
mutation = true
//prove we can use the closure
body(view)
}
init() { fatalError() }
}
When we combine it with jrose's gadget
extension Array {
func notForEach(_ body: (UIView) -> Void) {
let _ = withoutActuallyEscaping(body,do: { (escapingClosure) -> () in
let lock = DispatchSemaphore(value: 0)
Task {
let i = Illicit()
await i.takeIllicitly(escapingClosure)
lock.signal()
}
lock.wait()
return
})
}
}
I do think this is "not how I expect Swift to work", but I am a little uncertain whether the culprit is Illicit, notActuallyEscaping, or some hypothetical annotation scheme that would make their combination illegal.
withoutActuallyEscaping is not supposed to produce a Sendable function, so (1) capturing escapingClosure in a Sendable closure (which the Task initializer requires of its parameter) and then (2) using it as a parameter to an actor-isolated function that doesn't match the isolation of the caller are both supposed to be forbidden.
func assertSendable( body: @Sendable () -> ()) {}
func ex(body: () -> ()) {
withoutActuallyEscaping(body) { escapingClosure in
//Passing non-sendable parameter 'body' to function expecting a @Sendable closure
//Parameter 'body' is implicitly non-sendable
assertSendable(body: body)
}
}
..but somehow it can still be moved into a Task anyway.
This is a bit puzzling to me but withoutActuallyEscaping is a compiler builtin, not a normal function, so I wonder if there is some bug in the typechecker that allows is.
The big trouble is that opaque function Task.init may escape operation (arbitrarily, beyond the lifetime of Task and our control, etc.) Consequently it is UB for an escaping operation to capture a value from withoutActuallyEscaping.
The check is based on the lifetime of the captured closure; if you contrive it a bit further…
actor Illicit {
func takeIllicitly(body: () -> ()) {
body()
}
}
class Box<T> {
var value: T
init(_ value) {
self.value = value
}
}
func device(body: () -> ()) {
withoutActuallyEscaping(body) { escapingClosure in
let closureBox = Box(escapingClosure as Optional)
let semaphore = DispatchSemaphore(value: 0)
do { //constrain the lifetime of Task to the minimum lifetime permitted by law
Task {
do { // ditto for the closure
let localClosure = closureBox.value!
closureBox.value = nil
await Illicit().takeIllicitly(body: localClosure)
}
semaphore.signal()
}
}
semaphore.wait()
}
}
func body() {
print("called")
}
func withoutActuallyUB() {
for _ in 0..<1000 {
print("iteration")
device(body: body)
}
print("finished")
}
then I think it'll pass the run-time check, and correctly too (ignoring Sendable for the time being). But I say "I think" because I didn't actually check before replying.
[EDIT: contrived a bit further to try to make lifetimes more explicit]
[EDIT: rewrote to use a class for indirection instead of a local var, and checked in Swift 5.6, which is what I have on hand today]