I'd like to draw some attention towards and try to elaborate a bit on a part of this proposal that I don't think has been discussed yet: async initializers in classes.
Property Initialization
TL;DR = async property initializers should be disallowed.
While the proposal excludes the possibility that property getters and setters will be async
, it does not specifically discuss default property values (i.e., property initializers) and whether those values are allowed to be evaluated asynchronously. Let's consider this example:
func databaseLookup() async -> String { /*...*/ }
class Teacher {
var employeeID : String = await databaseLookup("DefaultID")
// ...
}
The initializer for employeeID
creates problems for all other explicit and implicit class initializers, because those initializers would have to be exclusively async
. This is because the class's designated initializer is the one who will implicity invoke the async
function databaseLookup
to initialize the employeeID
property. Any convenience initializers eventually must call a designated initializer, which is async
and thus the convenience intializer must be async
too.
So there's already an argument agianst async
property initializers: they can lead to confusion and have significant knock-on effects when added to a class, so they're likely to be unpopular. Additionally, the reason for these knock-on effects that I just explained are likely to be confusing for programmers who are not intimately familiar with Swift's initialization procedures.
Furthermore, there is already a precedent in the language that property initializers cannot throw an error outside of its context, i.e., property initializers do not throw and thus its class initializers are not required to be throws
. Instead, property initializers have to handle any errors that may arise either with a try!
, try?
, or do {} catch {}
plus the {}()
"closure application trick":
func throwingLookup() throws -> String { /*...*/ }
var employeeID : String = {
do {
try databaseLookup("DefaultID")
} catch {
return "<error>"
}
}()
The closest analogue to await
for error handling is try
, but we cannot use the same closure application trick for await
, because the closure itself will become async
and applying it immediately puts us back where we started!
There are also additional problems when we consider lazy properties with an async
initializer, since any use of that property might trigger an async
call, though only the first use would actually do so. But, because all uses might be the first use, we would need to have all uses annotated with await
, thus needlessy propagating async
everywhere.
Due to these three factors, I think we can reasonably rule out all async
property initializers.
Class Initialization
TL;DR = you must explicity write-out calls to an async super.init()
One of the key distinguishing features of initializers that are unlike ordinary functions is the requirement to call a super class's initializer, which in some instances is done for the programmer implicitly. Having async initializers in combination with implicit calls to them would create a conflict with the design goal of await
. Specifically, that goal is to make explicit and obvious to the programmer that a suspension can occur within that expression.
Now, let's consider this set of class definitions:
struct Data {
var x : Int = 0
func setCurrentID(_ x : Int) { /* ... */ }
func sendNetworkMessage() { /* ... */ }
}
class Animal {
init () async { /* ... */}
}
class Zebra : Animal {
var kind : Data = Data()
var id : Int
init(_ id : Int) async {
kind.setCurrentID(id)
self.id = id
// PROBLEM: implicit async call to super.init here.
kind.sendNetworkMessage()
}
}
Note that Animal
has a zero-argument designated initializer, so under the current rules, a call to super.init()
happens just after the initialization of self.id
during "Phase 1" of Zebra
's init
(according to my reading of the procedure). This implicit call could create problems for programmers who expect atomicity within the initializer's body to, say, send a network message immediately upon construction of a Zebra
.
Thus, the simple and practical solution here is to adjust the rule to say that an implicit call to super.init
will only happen if both of the following are true:
- The super class has a zero-argument, synchronous, designated initializer.
- The sub-class's initializer is declared synchronous.