loading...

Rust Review

yujiri8 profile image Ryan Westlund Updated on ・11 min read

How Rust and I met

I first heard of Rust long ago through stray searches. I was interested because it seemed like a competitor to Go, which was the most recent language I had learned for a job but that I had a low opinion of. My biggest gripe with Go was error handling. But when I read about Rust's error handling, I misunderstood what I was reading, causing me to think it was even more verbose than Go's, so I stopped investigating.

I think that was before most of my search for a better language which led me through brief dips into several of them but mostly Haskell. So a long time passed - long enough for me to become fairly competent with Haskell.

Then I heard about Rust again from a friend who held it in high esteem. I did a little more research and found out that it has sugar to cut down on error handling boilerplate. That combined with an ML-inspired type system made it sound to me like Go done right, so I eagerly jumped in.

Ownership

So the big unique thing about Rust is the concept of ownership. Every value is owned by the scope it's declared in, and only one scope is allowed to own it. You have to borrow a value to pass it to another scope (like a function call) without that scope taking ownership away from the caller.

I hear a lot of people saying the borrow checker is draconian and hard to figure out how to satisfy, but I've never had issues with it. I find the ownership rules pretty intuitive. The borrow checker error messages are good and almost all the time, they're preventing bugs.

I actually think the ownership rules breed good mental habits and especially make it easier to reason about performance, which is very appropriate for a systems language.

Ownership also enables Rust to not have garbage collection. It's the first language (besides C) I've learned that doesn't have it. Instead, Rust knows at compile time exactly when everything should be freed. You get the safety and ergonomics of automatic memory management, with the performance and simplicity of manual memory management.

Type system

The type system is ML-like. It has interface (trait)-based polymorphism, generics, sum types, and tuples, which is most of the requirements for a type system to never get in the way. It also has inference for local variables (but not for statics and function signatures).

The only big omission is inheritance. Structs can't embed each other flatly. Go shows what I'd hope to see here: a concise way to compose structs without the extra layer of member access. It's really just syntactic sugar on composition that avoids complicating serialization (with composition, fields of the child struct will serialize as a sub-object unless you do special stuff (like delegate the work to third-party library authors so it only works with that library)).

This is especially problematic for frameworks like the Diesel ORM, where you may need separate copies of each model for querying and inserting (and maybe another for updating since there's no default arguments either!).

No magic

Rust has basically no "magic". Syntactic constructs like indexing, iteration, and comparison use traits that you can implement on custom types. Even the implementation of Vec, the main container type, is library code - no compiler magic involved.

There's also the derive feature to automatically generate instances of certain traits for structs, if all their members implement them (like Eq and Ord).

Error handling

So error-handling was the #1 thing I hoped Rust would improve over Go (type system was #2). It does, but not as much as I hoped. The improvements are:

  • Sugar to cut down on boilerplate. Instead of (Rust without the sugar):

    let val = match could_fail() {
        Ok(val) => val,
        Err(err) => return Err(err),
    }
    

    We can do:

    let val = could_fail()?;
    

    Excellent. If could_fail() returns an Err, we'll just propagate it upward, and otherwise, val becomes the unwrapped success value. Barely any more verbose than an exception language, and more explicit.

  • Error-handling is checked at compile time. Since errors are a proper part of the type system via the Result sum type, trying to use a Result value as its Ok type is a type error. You have to do that pattern match thing or otherwise unwrap it first. (.unwrap() is a way of saying "although the type checker can't tell, I'm sure it's Ok so just give me the unwrapped value and explode at runtime if it turns out to be an Err".)

  • Composability of functions is maintained because Result only takes up one return slot (and Rust has tuple indexing anyway).

The big downside we still have is that there are no stack traces by default. Propagating an error with ? only propagates the original error, with no context added, so when you see the error, it won't have a line number or any other accompanying information, let alone a stack trace. You get that out of the box in most dynamic languages, but in Rust you have to work hard to get them.

r/rust users informed me that the situation is similar to Go in that you're just expected to use third-party crates to get sane error handling. There isn't even just one that's dominant; apparently the verdict is that anyhow is appropriate for applications and thiserror is appropriate for libraries.

anyhow works like this:

use anyhow::{Context, Result};

fn main() -> Result<()> {
    let args: Vec<String> = std::env::args().collect();
    do_stuff(&args[1], &args[2], &args[3])
}

fn do_stuff(file1: &str, file2: &str, file3: &str) -> Result<()> {
    let text = std::fs::read_to_string(file1).context("when reading")?;
    std::fs::write(file2, text).context("when writing")?;
    std::fs::remove_file(file3).context("when removing")?;
    Ok(())
}

And if I pass a filename that doesn't exist, I'll get output like:

Error: when reading

Caused by:
    No such file or directory (os error 2)

This is actually almost exactly how Go's github.com/pkg/errors works: you have to write the context messages yourself, which is tedious and prone to wrong error messages if you copy-paste, and you're still not getting a line number, so in a large codebase it can still be a hassle to track down where in the source that message is from.

Also like Go, you get backtraces on panics (if you set environment var RUST_BACKTRACE), but they include libraries.

Syntax

Rust's syntax is pretty verbose. Not just that it's a brace and semicolon language, but types take extra characters: function parameters need param: Type, whereas in most other static languages it's just param Type or Type param, and parameterized types need a pair of angle brackets: Vec<u8> is a vector of bytes, instead of Vec u8 like it would be with Haskell syntax. This can get very ugly with nested types like Box<Vec<Option<Thing>>>.

The syntax for namespacing is :: instead of . (except struct fields which still use .). A downside of this besides being less ergonomic is that it's precedence is misleading when combined with .: comments::table.load::<Comment> looks like comments :: table.load :: <Comment>, because the :: is more visual separation so intuitively it should bind less tightly, but it's actually comments::table . load::<Comment>.

Rust is often littered with "glue" calls like .to_string() after string literals to turn them from type &str into String (the difference between those types is for good reason, but you'd think literals would be able to be interpreted as String when necessary, just like numeric literals are agnostic). Not having concise string concatenation also contributes to the verbosity, and you also need explicit impl StructName {...} around methods defined on a struct, and impl TraitName for StructName {...} for around trait implementations.

No default values in function args

An annoyance. It's not as much of a hindrance as with Go though, since you can use Option types to avoid the callers having to specify the default value, so it's only an ergonomic issue.

Default values in struct fields

Rust actually does supports default values in struct fields via the Default trait, which is a nice surprise, but it has an extremely verbose syntax:

#[derive(Default)]
struct Options {
    opt_a: bool,
    opt_b: bool,
    opt_c: bool,
}

fn main() {
    let options = Options { opt_a: true, ..Default::default() };
}

Go has a much more streamlined syntax for it (you can just omit default fields) but in return it doesn't support custom defaults, only the "zero values" of the field types. In Rust you can provide your own implementation of the default method.

Variable declarations

Rust requires let on the first use of a variable. A second let shadows, which solves the lexical coupling issues other explicit declaration languages have. A let mut is required to make a mutable binding; immutability by default is important for the borrowing rules to be practical, and it also breeds a good awareness of where mutations can happen.

Array operations

The Vec type (which is the main sequence type) accomodates most common sequence operations out of the box: push, pop, membership test, insert at position, remove at position, sort, reverse, filter, map, comprehension, and a ton of relatively obscure ones. Pretty much the only thing missing is negative index.

Concurrency

Rust has OS-level threads as well as async/await. The designers said somewhere that OS-level threads made more sense than green threads because Rust is supposed to be a systems language, and I'm happy with that.

It's communication system is similar to Go's, but ownership solves data races and mutex hell. Mutexed data can't be accessed without locking because of the type system, and when the unwrapped data goes out of scope, it's automatically relocked. You can still deadlock of course, but this makes mutexes much easier to work with.

Resource management

Rust actually leverages ownership to solve this too. Files are automatically closed when they go out of scope; it's handled by the Drop trait which you can implement. I think it's the most elegant solution I've ever seen.

On the other hand, it is less flexible than the Go and Python solutions in that it doesn't pull double-duty for other use cases. As far as I know, nothing in Rust provides the full power of defer or finally.

Module system

The thing I've found most confusing about Rust is the package import/namespacing system. There's the use keyword, which is actually not really the same thing as import from other languages. There's pub use for re-exporting. There's mod, which is a bit confusing because it's used both to explicitly declare a module and to indicate that the definition is in another file, and pub mod. I'm apparently not alone in being confused by all this. There's weird stuff like import paths starting with :: and the crate keyword for the current crate, and the extern crate keyword which they say should only be necessary for "sysroot" crates, but I've found it seemingly necessary to work with Diesel.

I actually love the way use works. You don't strictly have to have a use declaration at the top to be able to use an external crate because dependencies are all declared in Cargo.toml; all use does is unwrap namespaces. For example, use std::env; lets you use env directly in that scope, but without it you can still reference env as std::env, which can be more convenient for single uses, and I'm very attracted to the idea of not having to edit something at the top of the file when I realize I need to use a stdlib module on line 500.

In general, I despise Rust's zealous data hiding. Some unsafe code legitimately requires the outside to not mess with certain stuff, but most often defaulting everything to private just means cutting off possible uses for no reason. Library authors can't anticipate everything a caller might have reason to do, so pub should be the default.

Another onerous restriction is that a trait can only be implemented in either the crate that defines the type or the crate that defines the trait. You can't implement external traits on external types. The Rust book explains:

This rule ensures that other people’s code can’t break your code and vice versa. Without the rule, two crates could implement the same trait for the same type, and Rust wouldn’t know which implementation to use.

But couldn't the import system fix this? Couldn't you just only import one implementation? At the very least, couldn't it be allowed to make private implementations?

Another part of the Rust book offers wrapper types as a solution, but also explains how that's inadequate.

Macros

Rust uses macros to have type-safe string formatting, JSON literals, and other niceties. I believe they're also how serde is implemented. They're a lot better than C macros though; instead of naive string substitution, they create their own syntax contexts.

I've never extensively used macros in any language, but my impression of them so far is that they're awesome. A much more enlightened solution to all of these problems than runtime reflection in an otherwise static language.

Tooling

Rust doesn't have build system hell. cargo Just Works (and comes with subcommands to generate the project boilerplate for you). Cargo.toml is powerful too; you can specify dependencies by filesystem path, git URL, or crates.io name. Most other languages I know make filesystem path imports hard.

The compiler is the most helpful I've ever seen. It shows source context with colored output, the error descriptions are pretty good, and there's a rustc --explain feature that gives a more in-depth explanation of an error. It also automatically points out unused stuff and unhandled Results, which is great!

There's even an official formatter and linter: rustfmt and clippy, which can be invoked as cargo subcommands for easy awareness of the project, and you can even see a list of all the clippy lints.

Documentation

The content of documentation is pretty average, but I think the tool for it is above so. cargo doc generates it in HTML, and has the --open flag to automatically open it in your browser. The output includes links everywhere, a great layout, and a search bar.

Stdlib and ecosystem

Terrible in every way.

The standard library is tiny. It features absolutely nothing besides language basics and type methods, no randomness, not even a time struct. std::time features the Duration, Instant, and SystemTime types; the latter two are completely opaque meaning you can't do things like get the year number out of them (of course there's no strftime or strptime). Even Haskell has more of a stdlib than this.

The ecosystem is npm all over again. Every crate seems to have a hundred recursive dependencies. For every task, there are a dozen libaries, half of them are deprecated and the other half have weird APIs and similar names. Usually none are stable.

Hopefully this will be improved over the next couple of years. But Go is only one year older than Rust and already has a Python-level stdlib and ecosystem.

Performance

My impression of Rust's performance is that it's extremely good, but especially on memory use. Not having garbage collection is probably its main edge over other compiled languages. See this page about Rust vs C speed (yes, it's a two-sided comparison!). Basically, Rust is as performant as you can hope to be while offering great protection against memory safety and other bugs that plague every program written in C or C++.


I think Rust is a great language. I want to see it replace Go. I hope it becomes my go-to. I still have a lot of evaluating to do, but my next big project will definitely be in Rust.

Most languages are either high or low level, but Rust is both. It's almost as expressive as dynamic languages, but safer than other static languages and has facilities for when you need really fine control over things like memory layout. There's an even a tutorial on writing an operating system in Rust and honestly, that doesn't sound like a terrible idea. Bryan Cantrill also gives the idea a lot of consideration.

Posted on by:

yujiri8 profile

Ryan Westlund

@yujiri8

I'm a programmer, writer, and philosopher. My Github account is yujiri8; all my content besides code is at yujiri.xyz.

Discussion

pic
Editor guide
 

find item by predicate and count occurrences.

in more general Iterator

nothing in Rust provides the full power of defer or finally

macros but you will have problem regarding borrowing

defer!{
   println!("Hello World!");
}

No default values in function args

you can create a macro in nightly

default_args!{
  fn foo(a: i32, b: i32 = 1) { ... }
}

impl StructName {...}

The impl is much more powerful with generics

impl<T: Default> Option<T> { fn unwrap_or_default(self) -> T }
impl<T> Option<Option<T>> { fn flatten(self) -> Option<T> }
 

Thanks for pointing me to the iterator docs. I could've sworn I'd already checked, but somehow I missed those two.

Could you expand on the last two? I can't find anything on the default_args macro or how to implement it, and on the last one, I'm not sure what that means because languages that don't require the impl wrapper can still do things like that

 

default args playground link

regarding impl

I would love to see an example of similar behaviour in other languages because I haven't used any other language that much expect Javascript :D

That default args macro doesn't seem at all satisfactory. The comment it can only be used in one function per module, and it also seems like it only allows one default argument. I don't know proc_macro so I don't know how different that would be.

Regarding impl, I believe this Haskell snippet accomplishes that (implementing methods for a trait bounded generic):

-- Example data type. Haskell's builtin version of this is called Maybe, so I'm calling the example Option.
data Option a = Some a | None

-- Example typeclass (trait) for boolean coercion.
class YesNo a where
 yesno :: a -> Bool

-- Implement on lists as an example (strings are linked lists).
instance YesNo [a] where
 yesno s = not $ null s

-- Implement the trait for any form of Option where the type parameter implements it.
instance YesNo a => YesNo (Option a) where
 yesno (Some a) = yesno a
 yesno None = False

main = do
 print $ yesno "" -- prints False
 print $ yesno "hi" -- prints True
 print $ yesno $ Some "" -- prints False
 print $ yesno $ Some "hi" -- prints True
 -- The type annotation is necessary because Haskell's type inference is imperfect
 print $ yesno $ (None :: Option String) -- prints False
 -- print $ yesno $ Some 5 -- Type error because ints don't implement YesNo

fixed problems with default args. I am not saying this is a very good macro. I won't even use it myself. Just saying that macros and traits are powerful enough to mimic many things. I would rather use

fn add(a: i32, b: impl Into<Option<i32>>) -> i32 {
    let b = b.into().unwrap_or(0);
    a + b
}

add(10, 2);
add(10, None);

Looks like I should learn haskell (couldn't get it to work on windows last time). But atleast to me these look very similar

instance YesNo a => YesNo (Option a) where ...
impl<A: YesNo> YesNo for Option<A> { ... }

Looks like I should learn haskell (couldn't get it to work on windows last time). But at least to me these look very similar

Hm, you're right. I could make it in Go but then it wouldn't have generics. I guess I don't really have an example of a statically typed language that does the same thing smaller. Maybe Go 2 will allow it (it's bringing generics, and interfaces in Go are implemented implicitly by implementing all the methods, unlike Haskell and Rust).

 

Thank you for the writing Ryan. As someone that is reading the Rust Book I agree with most of your points.

Concerning the Stdlib and ecosystem:
I couldn't agree more with you. Anyway, I've found a helpful resource that points to the libraries/crates that are considered "standard" for usual tasks: rust-lang-nursery.github.io/rust-c.... In there, the "Date and Time" section points directly to the use of chrono, for example.

From what I've seen there were some crates that were incorporated into std, but it seems that the community don't always approve this approach (internals.rust-lang.org/t/expansio...).

I think a list of "featured" libraries or "community picks" in crates.io could be helpful tought 🤔; Because nowadays, for a newcomer, it's really hard to follow the thread to understand that tokio is the "pseudo(?)" "official(?)" async runtime (I guess 😅).

It's true that many languages std libraries have become bloated and obsolete. But perhaps the current "rust way" is too extreme.

 

Thanks, that Cookbook link is excellent.

 

There is a lot I like about rust, but the issues with operator overloading for reference types has nearly put me off entirely. You either have to implement the trait 4 times, making it hard to reason about which is called, use a macro library, or you end up with tons of dereferences at the call site.

 

That sounds like something I haven't run into yet

 

Found the tracking bug for fixing a number of dereferencing issues github.com/rust-lang/rust/issues/4...

 

I cannot say I agree fully with you, but still I appreciate a good article.