Rust and Swift (xviii)
Deinitialization: ownership semantics and automatic reference counting
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.)
Part I: Ownership Semantics vs. Reference Counting
Perhaps unsurprisingly, the Swift book follows on from its discussion of initialization with a discussion of deinitialization, and here the differences between Rust and Swift are substantial, but (as has so often been the case) so are the analogies.
In Rust, memory is, by default, stack-allocated and -deallocated, but with a very impressive system for tracking the lifetime of that data and allowing its to be moved from one function to another. The Rust compiler tracks the ownership of every given item in the program as it is passed from one function to another, allowing other parts of the program to âborrowâ the data safely, until a given piece of data goes out of scope entirely. At that point, Rust runs its destructors automatically. As part of its system for managing memory safely, Rust also tracks where and when a program attempts to access any given piece of data (whether directly or via reference), and will refuse to compile if you try to reference data in a place where it has already gone out of scope and been cleaned up (âdropped,â in Rust-speak).
If this was a bit fuzzy, donât worry: thereâs a lot to say here. Itâs arguably the most distinctive feature of the language, and itâs also the main thing that tends to trip up newcomers to the language. If youâre interested in further material on the topic, my own most succinct treatment of it is in an early episode of New Rustacean, my Rust developer podcast, and the official documentation is very good. For now, suffice it to say: Rust does extremely rigorous compile-time checks to let you do C or C++-style memory management, but with absolute guarantees that you wonât have e.g. use-after-free bugs, with a default to handling everything on the stack.
It is of course impossible to handle everything on the stack, so there are heap-allocated types (e.g. vectors, a dynamically sized array-like type), which are fundamentally reference (or pointer) types. But those follow the same basic rules: Rust tracks the pointers throughout their uses, and when they go out of scope, Rust automatically tears down not only the pointer but also the data behind it. There are times, though, when you canât comply with Rustâs normal rules for handling multiple-access to the same data. For those situations, it also supplies some âsmart pointerâ container types, Rc
and Arc
, the reference-counted (non-thread-safe) and atomically reference-counted (thread-safe) types. Both types just wrap up a type that you intend to put on the heap with reference-counters, which increment and decrement as various pieces of a program get access to them. Note that, unlike the compiler-level, compile-time checks mentioned earlier, these are run-time counts and they therefore incur a small but real runtime performance penalty. (The distinctions between the two types have to do with how they guarantee their memory safety and what kinds of a guarantees are required for cross-thread safety, and theyâre important for writing Rust but not so important for this comparison, so Iâll leave them aside.1)
In Swift, all class instances (which are pass-by-reference types) are tracked with automatic reference counting and cleaned up automatically when there are no more references to them. Donât confuse Rustâs âatomically reference-countedâ type with Swiftâs âautomatically reference-countedâ type. Unlike Rustâs behavior in having everything checked at compile-time, reference counting is a run-time check in Swift, just as it is with the Rc
and Arc
types in Rust.2 But it happens for all reference types all the time in Swift, not just when specified manually as in Rust. (Value types seem to be always passed by value, though the compiler has some smarts about that so it doesnât get insanely expensive.) Itâs automatic in that the compiler and runtime handle it âbehind the scenesâ from the developerâs perspective.
Swiftâs approach here isnât quite the same as having a full-on garbage-collected runtime like youâd see in Java, C#, Python, Ruby, JavaScript, etc. (and so doesnât have the performance issues those often can). But it also isnât like Rustâs default of having no runtime cost. Itâs somewhere in the middle, with a goal of very good performance but good developer ergonomics. I think it achieves that latter goal: for the most part, it means that you donât have to think about memory allocation and deallocation explicitly. Certainly there are times when you have to think about how your program handles those issues, but neither is it right up in your face like it is in Rust,3 nor does it come with the costs of a heavier runtime (from startup, to GC pauses, to non-deterministic performance characteristics).4
To make it concrete, the following snippets do basically the same thingâbut note that the reference counting is explicit in Rust. Weâll start with Rust, doing it the normal way:
struct WouldBeJedi {
name: String,
rank: u8,
description: String,
}
impl WouldBeJedi {
fn new(name: &str, rank: u8, description: &str) -> WouldBeJedi {
WouldBeJedi {
name: name.to_string(),
rank: rank,
description: description.to_string()
}
}
}
fn main() {
let trainee = WouldBeJedi::new(
"Zayne Carrick", 1, "not very competent, but still a great hero");
// When calling the function, we pass it a reference, and it
// "borrows" access to the data. But the validity of that access
// is checked at compile time. `main()` keeps the "ownership"
// of the data.
describe(&trainee);
// When `main` ends, nothing owns the data anymore, so
// Rust cleans it up. If something were still borrowing the
// data (say, if we'd passed a reference into another thread),
// this would actually be a compile error, because references
// have to be guaranteed to live as long as the thing they
// point back to. Rust has tools for managing that, as well,
// its "lifetimes", but we can leave them aside for this example.
}
fn describe(trainee: &WouldBeJedi) {
// Rust checks at compile time to make sure there are no
// mutable "borrows" of the data, and therefore knows
// that it is safe to reference the data here, because it can
// be *sure* nothing will change it at the same time.
// Under the covers, this macro will actually call a
// function with the data we pass it, so Rust actually checks
// the ownership and borrowing state here, too. Again, all
// at compile time, and therefore with no runtime penalty.
println!("{} (rank {}) is {}.",
trainee.name,
trainee.rank,
trainee.description);
// When we exit the function, Rust notes that it is no
// longer "borrowing" the data.
}
And hereâs the Swift codeânote as well that we use a class
not a struct
here:
class WouldBeJedi {
let name: String
let rank: UInt8
let description: String
init(name: String, rank: UInt8, description: String) {
self.name = name
self.rank = rank
self.description = description
}
}
func main() {
let aTrainee = WouldBeJedi(
name: "Zayne Carrick",
rank: 1,
description: "not very competent, but a great hero")
// When calling the function, the reference count goes up
// here, too, but it's implicit, rather than explicit.
describe(aTrainee)
// The implicit reference count Swift maintains for `aTrainee`
// will go from 1 to 0 here, and Swift will do its cleanup of the
// object data.
}
func describe(_ trainee: WouldBeJedi) {
// When we enter this function, Swift bumps the reference
// count, from 1 to 2. Both `main` and `describe` now have a
// reference to the data.
// No need for the unwrapping or any of that; Swift handles it
// all automatically... thus the name of the technology!
print("\(trainee.name) (rank \(trainee.rank)) is \(trainee.description).")
// When we exit the function, Swift bumps the reference count
// back down to 1 automatically.
}
Finally, here is the (much longer, because all the reference counting is done explicitly) reference-counted Rust version:
use std::rc::Rc;
pub struct WouldBeJedi {
name: String,
rank: u8,
description: String,
}
fn main() {
let trainee = WouldBeJedi {
name: "Zayne Carrick".to_string(),
rank: 1,
description: "not very competent, but a great hero".to_string()
};
let wrapped_trainee = Rc::new(trainee);
// Start by calling `clone()` to get a *reference* to the
// trainee. This increases the reference count by one.
let ref_trainee = wrapped_trainee.clone();
// Then pass the reference to the `describe()` function.
// Note that we *move* the reference to the function, so
// once the function returns, the reference will go out
// of scope, and the reference count will decrement.
describe(ref_trainee);
// When `main` ends, several things will happen in order:
// 1. The reference count on the `wrapped_trainee` will
// go to zero. As a result, the `wrapped_trainee`
// pointer---the `Rc` type we created---will get
// cleaned up.
// 2. Once `wrapped_trainee` has been cleaned up, Rust
// will notice that there are no more references
// anywhere to `trainee` and clean it up as well.
// (More on this below.)
}
fn describe(trainee: Rc<WouldBeJedi>) {
// We now have a *reference* to the underlying data, and
// therefore can freely access the underlying data.
println!("{} (rank {}) is {}.",
trainee.name,
trainee.rank,
trainee.description);
// When we exit the function, Rust destroys this *owned*
// clone of the reference, and that bumps the reference
// count back down to 1 automatically.
}
Note that if we strip out all the explanatory comments and details, the normal versions of the Rust and Swift code are pretty similar.
Rustâ
struct WouldBeJedi {
name: String,
rank: u8,
description: String,
}
impl WouldBeJedi {
fn new(name: &str, rank: u8, description: &str) -> WouldBeJedi {
WouldBeJedi {
name: name.to_string(),
rank: rank,
description: description.to_string()
}
}
}
fn main() {
let trainee = WouldBeJedi::new(
"Zayne Carrick",
1,
"not very competent, but still a great hero");
describe(&trainee);
}
fn describe(trainee: &WouldBeJedi) {
println!("{} (rank {}) is {}.",
trainee.name,
trainee.rank,
trainee.description);
}
Swift (as usual, is slightly briefer than Rust)â
class WouldBeJedi {
let name: String
let rank: UInt8
let description: String
init(name: String, rank: UInt8, description: String) {
self.name = name
self.rank = rank
self.description = description
}
}
func main() {
let aTrainee = WouldBeJedi(
name: "Zayne Carrick",
rank: 1,
description: "not very competent, but a great hero")
describe(aTrainee)
}
func describe(_ trainee: WouldBeJedi) {
print("\(trainee.name) (rank \(trainee.rank)) is \(trainee.description).")
}
Note that in both of these implementations, all the actual cleanup of the memory is handled behind the scenesâthis feels much more like writing Python than writing C, especially for complex data types. Not least because this same kind of nice cleanup can happen for complex, heap-allocated types like dynamically-sized vectors/arrays, etc. Both languages just manage it automatically. (The same is true of modern C++, for the most part, but it has a more complicated story there because of its relationship with C, where malloc
and free
and friends run rampant and are quite necessary for writing a lot of kinds of code.) Most of the time, when youâre done using data, you just stop using it, and both Rust and Swift will clean it up for you. The feel of using either language is fairly similar, though the underlying semantics are quite different.
Part 2: Deconstruction/Deinitialization
Both Rust and Swift recognize that, the ordinary case notwithstanding, there are many times when you do need to run some cleanup as part of tearing down an object. For example, if you had an open database connection attached to an object, you should return it to the collection pool before finishing tear-down of the object.
In Rust, this is accomplished by implementing the Drop
trait and supplying the requisite drop
method. Imagine we had defined a Jedi
type, with a bunch of details about the Jediâs lightsaber (including whether the Jedi even has a lightsaber. We know from the Star Wars movies that lightsabers turn off automatically when the Jedi dies, or even just drops it for that matter. We can implement all of this in Rust using just the Drop
trait. Hereâs a pretty full example.5 (Note that both of these implementations draw heavily on material I covered in previous posts.)
#[derive(Debug)]
enum Color {
Red,
Blue,
Green,
Purple,
Yellow
}
enum SaberState {
On,
Off,
}
struct Lightsaber {
color: Color,
blades: u8,
state: SaberState
}
impl Lightsaber {
pub fn new(color: Color, blades: u8) -> Lightsaber {
if blades > 2 {
panic!("That's just silly. Looking at you, Kylo.");
}
Lightsaber { color: color, blades: blades, state: SaberState::Off }
}
pub fn on(&mut self) {
self.state = SaberState::On;
}
pub fn off(&mut self) {
self.state = SaberState::Off;
}
}
struct WouldBeJedi {
name: String,
lightsaber: Option<Lightsaber>,
}
impl WouldBeJedi {
pub fn new(name: &str, lightsaber: Option<Lightsaber>) -> WouldBeJedi {
WouldBeJedi { name: name.to_string(), lightsaber: lightsaber }
}
pub fn describe(&self) {
let lightsaber = match self.lightsaber {
Some(ref saber) =>
format!("a {:?} lightsaber with {:} blades.", saber.color, saber.blades),
None => "no lightsaber.".to_string()
};
println!("{} has {}", self.name, lightsaber)
}
}
// Here's the actually important bit.
impl Drop for WouldBeJedi {
fn drop(&mut self) {
if let Some(ref mut lightsaber) = self.lightsaber {
lightsaber.off();
}
}
}
fn main() {
let saber = Lightsaber::new(Color::Yellow, 1);
let a_jedi = WouldBeJedi::new("Zayne Carrick", Some(saber));
a_jedi.describe();
}
We can do much the same in Swift, using its deinitializers, which are fairly analogous to (but much simpler than) its initializers, and fulfill the same role as Rustâs Drop
trait and drop()
method.
enum Color {
case red, blue, green, purple, yellow
}
enum SaberState {
case on, off
}
struct Lightsaber {
let color: Color
let blades: UInt8
var state: SaberState = .off
init?(color: Color, blades: UInt8) {
if blades > 2 {
print("That's just silly. Looking at you, Kylo.")
return nil
}
self.color = color
self.blades = blades
}
mutating func on() {
state = .on
}
mutating func off() {
state = .off
}
}
class WouldBeJedi {
let name: String
var lightsaber: Lightsaber?
init(name: String, lightsaber: Lightsaber?) {
self.name = name
self.lightsaber = lightsaber
}
deinit {
self.lightsaber?.off()
}
func describe() {
let saberDescription: String
if let saber = self.lightsaber {
saberDescription = "a \(saber.color) lightsaber with \(saber.blades) blades."
} else {
saberDescription = "no lightsaber."
}
print("\(name) has \(saberDescription)")
}
}
func main() {
let saber = Lightsaber(color: .yellow, blades: 1)
let aJedi = WouldBeJedi(name: "Zayne Carrick", lightsaber: saber)
aJedi.describe();
}
This is a bit briefer, but thatâs mostly down to Swiftâs shorthand for optionals (the ?
operator), which weâll get to in a future post.
Curiously, struct
and enum
types cannot have deinitializers in Swift. I expect this has something to do with their being value types rather than reference types, but the book offers no comment. (If a reader knows the answer, Iâd welcome clarification.)
Much as in the discussion of of initializers, the usual patterns with Rust and Swiftâs approach come into play. Rust opts to build the pattern on the same basic language machinery (traits). Swift uses a bit of syntactical sugar dedicated to the purpose. Itâs undeniable that the Swift is a bit briefer.
However, there are a couple upsides to Rustâs approach. First, it is applicable on all types, where Swiftâs applies only to classes. Second, there is no additional syntax to remember. Drop
is just a trait like any other, and drop
a method like any other. Third, then, this means that you can run it explicitly elsewhere if you need to, and as a result you can define whatever kind of custom deconstruction behavior you might need. If weâd created a_jedi
above in Rust, we could simply write a_jedi.drop()
anywhere:
fn prove_incompetent(a_jedi: WouldBeJedi) {
// make some series of grievous mistakes which mean
// you're no longer able to be a Jedi and as such,
// among other things, lose your lightsaber...
a_jedi.drop();
// other stuff
}
Or (going a bit more abstract) we could define a daring_derring_do()
method which called drop()
itself:
impl WouldBeJedi {
pub fn daring_derring_do(self) {
// do some other operation, like freeing slaves from
// a secret colony of slavers. But if it fails...
self.drop();
}
}
Or, really, define any behavior which culminated in a drop()
call. Thatâs extremely powerful, and itâs the upside that comes with its just being a trait whose behavior we have to define ourselves.
That takes us back to one of the fundamental differences in design between the two languages. Rust goes out of its way to leave power in the hands of the user, at the cost of requiring the user to be a bit more explicit. Swift prioritizes brevity and productivity, but it gets there by taking some of the power out of the hands of the developer. Neither of these is wrong, per se. Theyâre just aiming for (and in this case, I think, fairly successfully landing in) somewhat different spots on a spectrum of tradeoffs.
I did, however, cover them quite recently on my podcast. Yes, this is another shameless plug.â©
Mostly, anyway. I believe the Swift compiler also does some degree of static analysis similar to that done by Rustâthough to a much lesser extent and, speaking purely descriptively, much less rigorously (it just has different goals). Swift then uses that analysis to handle things at compile time rather than via reference counts if itâs able to determine that it can do so.â©
We could, if we so desired, get this same basic behavior in Rust. We can easily imagine a world in which every type was automatically wrapped in
Rc
orArc
, and in fact, Iâd be very interested to see just such a languageâsomething which was only a thin layer over Rust, keeping all its semantics but wrapping some or all non-stack-allocated types inRc
orArc
as appropriate. (Something like this, but done behind the scenes rather than manually opted into.) Youâd incur some performance coasts, but with the benefit that youâd have an extremely ergonomic, practical, ML-descended language quite appropriate for slightly higher-level tasks, and without the radical shift required by switching to a lazily-evaluated, purely functional language like Haskell.â©Notably, those tradeoffs are often entirely worth it, and high-performance VMs have astoundingly good characteristics in many ways. The JVM, the CLR, and all the JavaScript VMs have astonishingly excellent performance at this point.â©
I might have gotten slightly carried away in the details here. Iâm just a little bit of a nerd.â©