Rust and Swift (xvi)
Initialization: another area where Swift has a lot more going on than Rust.
I am reading through the Swift book, and comparing it to Rust, which I have also been learning over the past few months. As with the other posts in this series, these are off-the-cuff impressions, which may be inaccurate in various ways. I’d be happy to hear feedback! Note, too, that my preferences are just that: preferences. Your tastes may differ from mine. (See all parts in the series.)
Thanks to ubsan, aatch, and niconii on the #rust-lang IRC for a fascinating discussion of the current status of Rust’s initialization analysis, as well as some very interesting comments on what might be possible to do in the future. Everything actually interesting about Rust in this post comes from the conversation I had with them on the evening of March 13.
The rules various languages have around construction and destruction of objects are extremely important for programmer safety and ergonomics. I think it’s fair to say that both Swift and rust are actively trying to avoid some of the mistakes made in e.g. C++ which poorly affect both its safety and its ease of use for developers, albeit it in some superficially different ways. Both languages also support defining how types are destroyed, which we’ll come back to in a future discussion.
The basic aim both Rust and Swift have in this area seems to be the same: avoid partially initialized objects. (You don’t want partially initialized objects. Ask Objective C developers.)
Swift does this via its rules around initializers. Rust does it by requiring that all the values of a type be initialized at its creation. So, for example, the following looks like it should work, but it doesn’t. You can initialize the variable piecemeal, but you cannot use it:
#[derive(Debug)] // to make it printable.
struct Foo {
pub a: i32,
pub b: f64,
}
fn main() {
// This will compile, but `foo` will be useless.
let mut foo: Foo;
foo.a = 14;
foo.b = 42.0;
// This would actually fail to compile. Surprising? A bit!
// println!("{:?}", foo);
// This will work, though, because it fully constructs the type.
let foo2 = Foo { a: 14, b: 42.0 };
println!("{:?}", foo);
}
(The reasons why this is so are fairly complicated. See the addendum at the end for a brief discussion.)
In any case, this means that especially with more complex data types, providing standard constructor-style methods like new
or default
is conventional and helpful. (If the type has non-public members, it’s also strictly necessary.)
Swift has a number of options for initializers, which correspond to things you in most cases can do in Rust, but in a very different way.
First, Swift allows you to overload the init
method on a type, so that you can have different constructors for different starting conditions. (This is, to my recollection, the first time any kind of overloading has come up so far in the Swift book—but that could just be my memory failing me. Certainly I haven’t referenced it in any previous discussion, though.)
The example offered by the Swift book is illuminating for the different approaches the languages take, so we’ll run with it. Here’s a class defining a Celsius type in Swift:
struct Celsius {
let temp: Double
init(fromFahrenheit f: Double) {
temp = 1.8 * (f - 32.0)
}
init(fromKelvin k: Double) {
temp = k - 273.15
}
}
// Create an instance each way
let freezing = Celsius(temp: 0)
let balmy = Celsius(fromFahrenheit: 75.0)
let absoluteZero = Celsius(fromKelvin: 0.0)
Note the internal and external parameter names. This is a common idiom Swift keeps (albeit with some non-trivial modification, and with more to come). More on this below; first, the same basic functionality in Rust:
struct Celsius {
temp: f64
}
impl Celsius {
fn from_fahrenheit(f: f64) -> Celsius {
Celsius { temp: 1.8 * (f - 32.0) }
}
fn from_kelvin(k: f64) -> Celsius {
Celsius { temp: k - 273.15 }
}
}
// Create an instance each way
let freezing = Celsius { temp: 0 };
let balmy = Celsius::from_fahrenheit(75.0);
let absoluteZero = Celsius::from_kelvin(0.0);
(Note that there might be other considerations in implementing such types, like using a Temperature
base trait
or protocol
, or employing type aliases, but those are for later entries!)
You can see a point I made about Swift’s initializer syntax back in part x: the way Rust reuses normal struct methods while Swift has the special initializers. Neither is clearly the “winner” here. Rust gets to use existing language machinery, simplifying our mental model a bit by not adding more syntax. On the other hand, the addition of initializer syntax lets Swift use a fairly familiar type construction syntax even for special initializer cases, and a leaves us with a bit less noise in the constructor method. Note, though, that initializers in Swift are special syntax; they’re not just a special kind of method (as the absence of the func
keyword emphasizes)—unlike Rust, where initializers really are just normal struct or instance methods.
The Swift book notes this distinction:
In its simplest form, an initializer is like an instance method with no parameters, written using the
init
keyword.
The new keyword is the thing I could do without. Perhaps it’s just years of writing Python, but I really prefer it when constructors for types are just sugar and you can therefore reimplement them yourself, provide custom variations, etc. as it suits you. Introducing syntax instead of just picking a standard function to call at object instantiation means you lose that. At the same time, and in Swift’s defense, I’ve only rarely wanted or needed to use those facilities in work in Python. It’s a pragmatic decision—and it makes sense as such; it’s just not where my preference lies. The cost is a bit higher than I’d prefer relative to the gain in convenience.
Back to the initializers and the issue of overloading: the external parameter names (the first parameter) is one of the main ways Swift tells apart the initializers. This is necessitated, of course, by the choice of a keyword for the initializer; Rust doesn’t have any need for this, and since Rust doesn’t have overloading, it also can’t do this. In Rust, different constructors/initializers will have different names, because they will simply be different methods.
[Edit: I’m leaving this here for posterity, but it’s incomplete. See below.] One other important thing falls out of this: the external parameter names are required when initializing a type in Swift. Because those parameter names are used to tell apart the constructor, this is not just necessary for the compiler. It’s also an essential element of making the item readable for humans. Imagine if this were not the case—look again at the Celsius
example:
struct Celsius {
let temp: Double
init(fromFahrenheit f: Double) {
temp = 1.8 * (f - 32.0)
}
init(fromKelvin k: Double) {
temp = k - 273.15
}
}
// Create an instance each way
let freezing = Celsius(0)
let balmy = Celsius(75.0) // our old fromFahrenheit example
let absoluteZero = Celsius(0.0) // our old "fromKelvin example
We as humans would have no idea what the constructors are supposed to do, and really at this point there would necessarily just be one constructor unless the later options took elements of another type. That would be fairly similar to how overloading works in C++, Java, or C♯, and while method overloading in those langauges is very powerful, it can also make it incredibly difficult to figure out exactly what method is being called. That includes when the constructor is being called. Take a look at the long list of C♯ DateTime
constructors, for example: you have to either have this memorized, have the documentation open, or be able simply to infer from context what is going on.
Given the choice of a keyword to mark initializers, then, Swift’s rule about external parameter name usage wherever there is more than one initializer is quite sensible.
[Edit: several readers, most notably including Joe Groff, who works on Swift for Apple, pointed out that Swift does support overloading, including in init()
calls, and uses types to distinguish them. Moreover, you can leave off the label for the parameter. My initial summary was simply incorrect. I think this is a function of my not having finished the chapter yet.]
Second, both languages support supplying default values for a constructed type. Swift does this via default values defined at the site of the property definition itself, or simply set directly from within an initializer:
struct Kelvin {
var temp: Double = 0.0 // zero kinetic energy!!!
init () {
temp = 305.0 // Change of plans: maybe just freezing is better
}
}
In Rust, you can not supply default values directly on a property, but you can define any number of custom constructors:
struct Kelvin {
temp: f64,
}
impl Kelvin {
fn abs_zero() -> Kelvin {
Kelvin { temp: 0.0 }
}
fn freezing() -> Kelvin {
Kelvin { temp: 305.0 }
}
}
We could of course shorten each of those two one line, so:
fn abs_zero() -> Kelvin { Kelvin { temp: 0.0 } }
The Rust is definitely a little noisier, and that is the downside of this tack. The upside is that these are just functions like any other. This is, in short, exactly the usual trade off we see in the languages.
Rust also has the Default
trait and the #[derive(default)]
attribute for getting some basic defaults for a given value. You can either define a Default
implementation yourself, or let Rust automatically do so if the underlying types have Default
implemented:
struct Kelvin {
temp: f64,
}
// Do it ourselves
impl Default for Kelvin {
fn default() -> Kelvin {
Kelvin { temp: 305.0 }
}
}
// Let Rust do it for us: calling `Celsius::default()` will get us a default
// temp of 0.0, since that's what `f64::default()` returns.
#[derive(default)]
struct Celsius {
temp: f64,
}
This doesn’t get you quite the same thing as Swift’s initializer values. It requires you to be slightly more explicit, but the tradeoff is that you also get a bit more control and flexibility.
There’s actually a lot more to say about initializers—there are many more pages in the Swift book about them—but this is already about 1,700 words long, and I’ve been slowly chipping away at it since March (!), so I’m going to split this chapter of the Swift book into multiple posts. More to come shortly!
Addendum: No Late Initialization in Rust
Returning to the first Rust example—
#[derive(Debug)] // to make it printable.
struct Foo {
pub a: i32,
pub b: f64,
}
fn main() {
// This will compmile, but `foo` will be useless.
let mut foo: Foo;
foo.a = 14;
foo.b = 42.0;
// This would actually fail to compile. Surprising? A bit!
// println!("{:?}", foo);
}
You can’t do anything with that data for a few reasons (most of this discussion coming from ubsan, aatch, and niconii on the #rust-lang IRC back in March):
- Rust lets you “move” data out of a struct on a per-field basis. (Rust’s concept of “ownership” and “borrowing” is something we haven’t discussed a lot so far in this series; my podcast episode about it is probably a good starting point.) The main takeaway here is that you could return
foo.a
distinctly from returningfoo
, and doing so would hand that data over while running thefoo
destructor mechanism. Likewise, you could passfoo.b
to the function created by theprintln!
macro - Rust allows you to re-initialize moved variables. I haven’t dug enough to have an idea of what that would look like in practice.
- Rust treats uninitialized variables the same as moved-from variables. This seems to be closely related to reason #2. The same “I’m not sure how to elaborate” qualification applies here.
I’ll see if I can add some further comments on (2) and (3) as I hit the later points in the Swift initialization chapter.