We recently discovered an issue where non-escaping closure parameters (the default since Swift 3) can escape through Objective-C without the user realizing that they've done something wrong. The easiest way to see the problem is to define an @objc
protocol with a closure-taking method:
@objc protocol Fooable {
func foo(completion: () -> Void) // note:closure parameter is implicitly '@noescape'
}
func useFooable(fooable: Fooable, completion: () -> Void) {
fooable.foo(completion: completion)
}
And then implement that protocol in Objective-C
@interface MyClass : NSObject <Fooable>
@end
@implementation MyClass
- (void)fooWithCompletion:(void (^)(void))completion {
dispatch_async(dispatch_get_main_queue(), completion); // oops, we escaped a no-escape block!
}
@end
One could blame the user: the generated header will have SWIFT_NOESCAPE
on the parameter in the @protocol Fooable
definition, but nobody looks at that and there's no help whatsoever from the tools. Moreover, up until fairly recently, the Swift compiler didn't treat @noescape
closures any differently than @escaping
closures, so the code would not break. The Swift compiler now (on master) optimizes away ref-count traffic for @noescape
closures, and code that goes through this code path---i.e., Swift code that forms a closure and ends up calling -[MyClass fooWithCompletion:]
via Fooable
---will end up accessing memory that's already been freed if the queued work happens later.
This problem doesn't happen (silently) in pure Swift, because the compiler will complain if you try to save a @noescape
closure somewhere for use later. The way to trigger this kind of problem in pure Swift is to abuse withoutActuallyEscaping
:
func sneakyEscaping(completion: () -> Void) {
withoutActuallyEscaping(completion) { completion in
DispatchQueue.main.async(completion)
}
}
However, this error will be (deterministically) caught at runtime, because withoutActuallyEscaping
performs checking to ensure that the closure itself did not escape.
I see a couple of potential solutions here, which aren't mutually exclusive:
-
Implicitly use withoutActuallyEscaping when calling an
@objc
API from Swift: if a@noescape
closure is being passed to an@objc
method, perform the same checking logic performed bywithoutActuallyEscaping
. This won't prevent the runtime failure, but it will make the runtime failure deterministic (rather than causing random memory corruption!) so the problems will be easier to spot. -
Warn about non-escaping closure parameters in
@objc
entry points that can be implemented in Objective-C: this would warn about any method in an@objc
protocol that takes a closure that isn't marked@escaping
, as well as any non-final@objc
method that takes a closure that isn't marked@escaping
. Examples:
@objc protocol P {
func foo(completion: () -> Void) // warn: non-escaping function parameter in Objective-C entrypoint
func bar(completion: @escaping () -> Void) // no warning
}
@objc class Foo : NSObject {
@objc func method1(completion: () -> Void) { } // warn: non-escaping function parameter in Objective-C entrypoint
@objc func method2(completion: @escaping () -> Void) { } // no warning
@objc final func method3(completion: () -> Void) { } // no warning; can't override from Objective-C
func method4(completion: () -> Void) { } // no warning; can't override from Objective-C
}
We would probably want to bring back @noescape
(it's spelling was removed from the language by SE-0103) as a way to suppress the warning for those cases where you do want a non-escaping closure exposed to Objective-C. The warning would have two Fix-Its: one to add @escaping
and one to silence the warning with @noescape
.
Thoughts?
Doug