Units in Go and Rust show philosophical differences
Monday, June 5, 2023
Units are a key part of doing any calculation. A number on its own is just a scalar and doesn't represent anything in particular. If I tell you to go drive 5, you'd naturally ask "5 what?"
Software often has to deal with quantities that represent real-world things. How we represent these quantities in different languages is an interesting window into how those languages represent and interact with these quantities. A common one we run into is the representation of time. Nearly every program will eventually need to deal with time, even just to do a little sleeping (as a treat).
Let's compare how Go and Rust represent units of time! Specifically, we'll look at how they represent durations of time for things like thread sleeps. For this, we'll look primarily at the standard library; other libraries may do it differently, but this is a somewhat "blessed" path, and the world of libraries is so vast. The standard libraries also are more likely to represent idiomatic usage1.
Go
Let's start with Go.
Times use the package time.
Specifically, this package defines the type Duration
, which represents elapsed time between two instants.
It's defined as an integer, representing elapsed nanoseconds.
Here's the full definition of the type:
type Duration int64
There are also some constants provided: Nanosecond
, Microsecond
, Millisecond
, Second
, Minute
, and Hour
.
These give easy constants to allow easily constructing durations.
Here is the example of printing out a 10-second duration from the docs:
seconds := 10
fmt.Print(time.Duration(seconds)*time.Second) // prints 10s
We create a time.Duration
(casting the input int, 10, into a Duration
), which represents 10 nanoseconds.
When we multiply it by time.Second
, we are multiplying by the number of nanoseconds in a second, which scales the duration to represent 10 seconds.
At all times, a Duration
is just an int, which largely means you can use it like an int (but may have to cast it sometimes).
You can do all the usual integer things, like adding other integers and multiplying by other integers.
The same example as above can be represented using integer math:
duration := time.Second * 10
fmt.Print(duration) // prints 10s
And you could add, here representing 1.00000001s:
duration := time.Second + 10
fmt.Print(duration) // prints 1.00000001s
Rust
Rust takes a different approach. Times are in the package std::time. Within this package, we have Duration.
This type is more complicated in its definition, as it is a struct. In fact, the docs do not tell us what the internal representation is, just giving us:
pub struct Duration { /* private fields */ }
If we look at the source code, we can see that it doesn't contain very much:
// some attributes are skipped for clarity
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
pub struct Duration {
secs: u64,
nanos: Nanoseconds, // Always 0 <= nanos < NANOS_PER_SEC
}
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
struct Nanoseconds(u32);
This differs significantly from the Go definition in two ways:
- It's storing seconds (and nanoseconds for sub-second precision), not nanoseconds
- It's stored in a structured way, rather than as an integer that you can use as an integer
You construct Duration
s using struct methods.
For example, you can make 10 seconds using Duration::from_secs(10)
.
Here's the same example as above, adapted for Rust:
let seconds = Duration::from_secs(10);
println!("{:?}");
However, the arithmetic operators are not all defined here with integers! You can multiply a duration by an integer, which makes sense: we know that 1 second times a unitless 10 is 10 seconds. But what does it mean to add a unitless 10 to 1 second? It doesn't mean anything, and if you try you get an error message saying that the operation isn't defined.
Philosophical differences
Between Go and Rust, we see a philosophical difference. Rust prefers to put the unit into the type system, preventing errors by enforcing that usage goes through the implemented interface. In contrast, Go prefers to document the unit and use a relatively bare type definition but placing fewer restrictions on the programmer. Rust makes things explicit; Go allows things to be implicit.
These are philosophical differences, not limitations or enhancements afforded by either language, because both approaches can be implemented in either language.
You could define a similar Duration
struct in Go, like so:
type Duration struct {
secs int64
nanos int32
}
And in Rust, we could define Duration
as a type alias, similar to what was done in Go:
type Duration = u64;
This example reflects a lot of my feelings and experiences using both of these languages in general. They're great tools that excel in overlapping domains, and they come at it from different angles. Go tends to feel like it expects the programmer to be diligent and careful, and it gives you footguns (though notably fewer than C or C++, which I'm thankful about). Rust tends to feel like it's working hard to prevent the programmer from making mistakes, which can be very comforting and can also feel awfully restrictive sometimes.
I'm extremely thankful that Rust is restrictive about memory accesses to prevent pernicious memory bugs. This sort of handling of unit bugs could also help prevent bugs that crash space probes. But we're not all writing systems software or Mars orbiters, and this can feel like overkill sometimes.
To me, the Rust approach feels better, because it lives up to the promise of code being self-documenting and it helps prevent mistakes in codebases we don't understand. And let's be honest, we don't understand most of the codebases we work in, because they're too large for any one human to fit in their head, let alone their working memory. My opinion is that the more things we can push onto the compiler, the more we free up cognitive resources to actually think about the problems we're solving.
The Rust approach isn't quite there to me, because a lot of extra complexity comes along for the ride. I overheard someone describe it recently as a language that has both a systems programming community and a fancy programming language community. It feels like there's a lot of baggage from the latter that doesn't necessarily improve the overall use of the language. It's still a really fun language, but I am also optimistic that we may get something even better in the future: Something cleaner and easier, which still affords the most important protections that Rust provides.
Post notes: I think there are also some important things to say about the cultural differences between the Go and Rust communities. But, I don't think I'm the person to say them. I'm largely on the outside of both communities, because I don't spend a lot of time talking about the languages with other people; just using them, and collaborating in work and hobby contexts. Both communities have great strengths and tragic flaws. Just like the languages.
That said, standard libraries are also slower to change than practices may be, so idiomatic use can shift out from under them. But I think it's a reasonable basis, because it's what a lot of users will look to and will seek to remain compatible with.
If this post was enjoyable or useful for you, please share it! If you have comments, questions, or feedback, you can email my personal email. To get new posts and support my work, subscribe to the newsletter. There is also an RSS feed.
Want to become a better programmer?
Join the Recurse Center!
Want to hire great programmers?
Hire via Recurse Center!