Rust and Swift (xvii)
More on initializers!
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.)
In the last part, I talked through the first chunk of the Swift book’s material on initializers. But it’s a long section, and I definitely didn’t cover everything. (I also got a few bits incorrect, and thankfully got great feedback to tighten it up from Twitter, so if you read it right after I posted it, you might skim back through and find the places where I added “Edit: …”)
Picking up from where we left on, then. Swift has a number of further initializer types, some of which map rather directly to the way initializers work in Rust, and some of which have no direct analog at all.
In the first category are the memberwise initializers Swift supplies by default for all types. The most basic init
method just uses the names of the members of any given struct
or class
type in Swift (as in the previous section, I’m going to use the types the Swift book uses for simplicity):
struct Size {
var height = 0.0, width = 0.0
}
someSize = Size(height: 1.0, width: 2.0)
This actually looks almost exactly like the normal way we construct types in Rust, where the same basic pattern would look like this:
struct Size {
height: f64,
width: f64,
}
some_size = Size { height: 1.0, width: 2.0 }
There are two big differences between the languages here. The first, and most immediately apparent, is syntactical: in this case, Rust doesn’t have a function-call syntax for creating instances, and Swift does. Swift’s syntax is similar to one of the several C++ constructor patterns, or especially to Python’s initializer calls (if we made a point to be explicit about the keyword arguments):
class Size:
height = 0.0
width = 0.0
def __init__(height, width):
self.height = height
self.width = width
someSize = Size(height=1.0, width=2.0) # unnecessarily explicit
The second, and more significant, is that the default, memberwise initializer in in Swift is only available if you have not defined any other initializers. This is very, very different from Rust, where there’s not really any such thing as a dedicated initializer—just methods. If we defined Size::new
or Size::default
or Size::any_other_funky_initializer
, it wouldn’t make a whit of difference in our ability to define the type this way.1 However, and this is important: because Rust has field-level public vs. private considerations, we cannot always do memberwise initialization of any given struct
type there, either; it is just that the reasons are different. So:2
mod Shapes {
struct Rectangle {
pub height: f64,
pub width: f64,
area: f64,
}
}
fn main() {
// This won't work: we haven't constructed `Size::area`, and as we noted
// last time, you cannot partially initialize a struct.
let some_size = Shapes::Size { height: 1.0, width: 2.0 };
// But neither will this, because `area` isn't public:
let some_other_size = Shapes::Size { height: 1.0, width: 2.0, area: 2.0 };
}
Swift lets you refer to other initializers on the same type (reinforcing that init()
is basically a kind of method, albeit one with some special rules and some special sugar). You do that by calling self.init()
, and—very importantly—you can only call it from within another initializer. No funky reinitializations or anything like that. The net result is that if you have a couple different variations on ways you might initialize a type, you still get the benefit of reusability; you don’t have to reimplement the same initialization function over and over again. Do whatever additional setup is required in any given instance, and then call a common base initializer.
With Rust, again, we just have methods, so you could of course call them wherever you like. However, those methods are distinguished as being type-level or instance-level methods by their signatures, rather than by keyword. If the first argument is (some variant on) self
, it’s an instance method, otherwise, a type-level method. This eliminates any potential confusion around the initializers:
struct Foo {
pub a: i32
}
impl Foo {
pub fn new(a: i32) -> Foo {
Foo { a: a }
}
pub fn bar(&self) {
// yes:
let another_foo = Foo::new();
// no (won't even compile):
// let self_foo = self.new();
}
}
You can (of course!) build up a type through multiple layers of methods which are useful to compose an instance together. This is what the builder pattern is all about. There are definitely times when you want to be able to tweak how your initialization plays out, and being able to do that without just passing in some hairy set of options in a special data type is nice.
One other important qualification on the Swift initializers: those default, memberwise constructors you get for free? You only get them for free if you don’t define your own initializers. (The closest analogy to this in Rust is that you’ll have issues if you try to both #[derive(Default)]
and impl Default for Foo
, since both will give you an implementation of Foo::default()
.) You can get around this in Swift by using an extension. We’ll come back to that in a future post.3 You can also get around it by supplying a parameter-less, body-less initializer in addition to any other initializers you supply, so: init() {}
. (This, frankly, seems like a hack to me. It’s a useful hack, given the other constraints, but these kinds of things pile up.) Similarly, you can just reimplement member-wise initializers yourself if you have a reason to (say, if you’ve implemented any others and therefore the defaults no longer exist).
Now things take a turn into Swift-only territory again as we look at initialization in the context of inheritance. (As mentioned last time: Rust will eventually get inheritance-like behavior, but it’s coming much later, and is not going to be exactly like classical inheritance. Rust strongly favors composition over inheritance, where Swift lightly does but still supports the latter.)
Swift has two kinds of initializers for class initializers. One, a designated initializer, is required; a designated initializer must fully initialize every property on a class, and call the superclass initializer (assuming there is one). These can be inherited, but again: they are required.
There are also convenience initializers, which provide variant APIs for setting up any given class. These (by definition, given what we said a moment ago) must call a designated initializer along the way. These could be useful in a lot of different scenarios: setting up variants on the class (as in our temperature examples from before), doing alternate setup depending on initial conditions, etc.
The only difference between the two syntactically is that convenience initializers get the convenience
keyword in front of the init
declaration, so:
class Foo {
var bar : Int
let quux: String
// designated
init(_ bar: Int, _ quux: String) {
self.bar = bar
self.quux = quux
}
// A convenience method which only takes the string.
convenience init(_ quux: String) {
self.init(0, quux)
}
}
The Swift book gives a set of rules about how these delegated and convenience initializers must behave. The short version is that convenience initializers (eventually) have to call a delegated initializer from their own class, and designated initializers have to call a designated initializer from the superclass. This is an implementation detail, though: from the perspective of a user of the class, it doesn’t matter which initializer is called.
The other important bit about Swift class initialization is that it is a two-phase process, which you might think of as “primary initialization” and “customization.” The primary initialization sets up the properties on a class as defined by the class which introduced them. The following sample should illustrate how it plays out:
class Foo {
let plainTruth = "Doug Adams was good at what he did."
let answer = 0
init() {
baz = answer / 2
}
}
// Bar inherits from Foo
class Bar: Foo {
let question = "What is the meaning of life, the universe, and everything?"
let answer = 42
init() {
super.init() // calls Foo.init()
}
convenience init(newQuestion question: String, newAnswer answer: Int) {
self.question = question
self.answer = answer
self.init() // calls own `init()`
}
}
When building a Bar
via either the designated or convenience initializer, plainTruth
and answer
will be set up from Foo
, then question
will be set and answer
will be reassigned in Bar
. If the convenience initializer is used, then it will also override those new defaults with the arguments passed by the caller, before running the designated initializer, which will in turn call the superclass designated initializer. The machinery all makes good sense; I appreciate that there are no weird edge cases in the initialization rules here. (There are a bunch of special rules about which initializers get inherited; I’m just going to leave those aside at this point as they’re entirely irrelevant for a comparison between the languages. We’re already pretty far off into the weeds here.)
Obviously, none of this remotely applies to Rust at all. Not having inheritance does keep these things simpler (though of course it also means there’s a tool missing from your toolbox which you might miss). And of course, the rules around method resolution are not totally trivial there, especially now that impl
specialization is making its way into the language. But those don’t strictly speaking, affect initialization.
To account for the case that initialization can fail, Swift lets you definite failable initializers, written like init?()
. Calling such an initializer produces an optional. You trigger the nil
valued optional state by writing return nil
at some point in the body of the initializer. Quoting from the Swift book, though, “Strictly speaking, initializers do not return a value…. Although you write return nil
tro trigger an initialization failure, you do not use the return
keyword to indicate initialization success.” These failable initializers get the same overall behavior and treatment as normal initializers in terms of delegating to other initializers within the same class, and inheriting them from superclasses.
class Foo {
let bar: Int
init?(succeed: Bool) {
if !succeed {
return nil
}
bar = 42
}
}
let foo = Foo(true)
print("\(foo?.bar)") // 42
let quux = Foo(false)
Print("\(foo?.bar)") // nil
This is another of the places where Swift’s choice to treat initialization as a special case, not just another kind of method, ends up having some weird side effects. If init
calls were methods, they would always just be returning the type. This is exactly what we see in Rust, of course. To be clear, there are reasons why the Swift team made that choice, and many of them we’ve already touched on incidentally; the long and short of it is that inheritance adds some wrinkles. These aren’t constructors, they’re initializers. The point, per the Swift book, is “to ensure that self
is fully and correctly initializer by the time that initialization ends.” If you’re familiar with Python, you can think of Swift initializers as being quite analogous to __init__(self)
methods, which similarly are responsible for initialization but not construction. When we build a type in Rust, by contrast, we’re doing something much more like calling Python __new__(cls)
methods, which do construct the type.
Edit: interestingly, I’m informed via Twitter that Swift initializers can also throw errors. (Thanks, Austin!) The Swift book doesn’t mention this because it hasn’t gotten to error-handling yet (and so, neither have we).4
You can of course write failable constructors in Rust, too:
struct Foo {
bar: i64,
};
impl Foo {
pub fn optional_new(succeed: bool) -> Option<Foo> {
if succeed { Some(Foo { bar: 0 }) }
else { None }
}
}
let foo = Foo::optional_new(true);
match foo {
Some(f) => println!("{}", f.bar),
None => println!("None"),
};
There are conditions in both languages where you’d want to do this: places where an initialization can fail, e.g. trying to open a file, or open a websocket, or anything where the type represents something that is not guaranteed to return a valid result. It makes sense then that in both cases, returning an optional value is the outcome. Of course, Rust can equally well have an initializer return a Result<T, E>
:
struct Waffles {
syrup: bool,
butter: bool,
}
impl Waffles {
fn properly(all_supplies: bool) -> Result<Waffles, String> {
if all_supplies {
Ok(Waffles { syrup: true, butter: true } )
}
else {
let msg = "Who makes waffles this way???";
Err(msg.to_string())
}
}
}
let waffles = Waffles::properly(true);
match waffles {
Ok(_) => println!("Got some waffles, yeah!"),
Err(s) => println!("{:}", s),
};
This is simply not the kind of thing you can do in Swift, as far as I can tell. The upside to Swift’s approach is that there is one, standard path. The downside is that if you have a scenario where it makes sense to return an error—i.e., to indicate why a class failed to initialize and not merely that it failed—you’re going to have to jump through many more hoops.5 Edit: See above; Swift can do this. Moreover, the underlying semantics aren’t especially different from Rust’s. However, it does introduce yet more syntax, rather than just being a normal return. But we’ll talk about that in more detail when we get to error-handling.4 The downside for Rust is that there’s no shorthand; everything is explicit. The upside is the flexibility to do as makes the most sense in a given context, including defining whatever types you need and returning them as you see fit. If you need a type like PartialSuccessPossible<C, P, E>
where C
is a complete type, P
a partial type, and E
an error, you can do that. (I’m not saying that’s a good idea, for the record.) That in turn flows out of building even higher level language features on lower-level features and not introducing new syntax for the most part. Trade-offs!
And with that, we’re done talking about initializers. This was a huge topic—but it makes sense. If you don’t nail this down carefully, you’ll be in for a world of hurt later, and that goes whether you’re designing a language or just using it to build things.
- Previous: Initialization: another area where Swift has a lot more going on than Rust.
- [**Next: Deinitialization: ownership semantics and automatic reference counting][18]
Also recall that in Rust, we would set the default values either by using the
#[derive(Default)]
annotation or by implementing theDefault
trait ourselves.↩I’m including a module because of a quirk around the public/private rules: within the same module,
area
isn’t hidden and you can actually go ahead and initialize the object.↩Depending on how you think about extensions, either Rust doesn’t have anything quite like them… or every type implementation is just an extension, because
impl
allows you to extend any data type in basically arbitrary ways (a few caveats of course). More on all of this when we get there.↩Here’s a preview of what that would look like, though (fair warning, there’s a lot going on here we haven’t talked about!):
enum Setup { case succeed case error case fail } enum BarSetupError: ErrorProtocol { case argh } class Bar { let blah: Int init?(setup: Setup) throws { switch setup { case .succeed: blah = 42 case .error: throw BarSetupError.argh case .fail: return nil } } } do { let bar = try Bar(setup: .succeed) print("\(bar!.blah)") let baz = try Bar(setup: .fail) print("\(baz?.blah)") let quux = try Bar(setup: .error) print("\(quux?.blah)") } catch BarSetupError.argh { print("Oh teh noes!") }
The output from this would be
42
,nil
, andOh teh noes!
.↩It’s conceivable this is actually possible, but nothing in The Swift Programming Language even hints at it, if so.See above!↩