Skip to content

Instantly share code, notes, and snippets.

@metasim
Last active January 22, 2025 21:04
Show Gist options
  • Save metasim/38ace0e3d1875d4e5e35413ca592d8d4 to your computer and use it in GitHub Desktop.
Save metasim/38ace0e3d1875d4e5e35413ca592d8d4 to your computer and use it in GitHub Desktop.
Rust for Experienced Developers

Rust for Experienced Developers

This document is intended for experienced developers coming from OO or FP backgrounds and are interested in learning Rust. It is my attempt at saving fellow travellers from slogging through the same bogs I did!

Learning Curve

If you're coming from C/C++ you will probably learn Rust faster than someone coming from a memory managed runtime because you are used to thinking about where in memory data lives... Rust does not abstract that from you. It's somewhere between C and C++ in terms of complexity.

If you're coming from an OO languge you'll have to unlearn certain design habits. Rust is NOT an OO language, but does allow you to emulate some OO type patterns.

If you're coming from an FP language you'll likely be frustrated that Rust isn't "pure". It values practicality and performance over FP purity, and attempts to address some of the promises of FP in its own unique way (e.g. allows mutibuility, but in a highly constrained way via its type-system). However, it's trait system is a near cousin to Haskell typeclasses, so you'll have a leg-up there.

Getting Started

Setup

  1. Install rustup, either through brew or via curl script. Then run rustup update. More details available in the rustup book here.

  2. Choose your editor. Each has their strengths and weaknesses. See Tooling section below.

    1. If you have a license or work on non-commercial projects, RustRover is what I use because I've been using JetBrains products for decades. and am used to it.

    2. VS Code with the rust-analyzer plugin. My collegues who don't use RustRover generally use VS Code.

    3. vim with rust-analyzer plugins. Not a vim user, but hear the integration is excellent.

  3. cargo new my-first-rusted

Learning

Where to go depends on your style of learning., Popular options are:

More options in the Learning Resources section below.

Principles

  • Memory and thread safety above all else

    • You have to be explicit about what memory is used, everywhere.

    • Default is on the stack.

    • Functions are pass by value. You explicitly have to "borrow" (create a reference) to not copy, or to handle types that aren't Copy (i.e. don't implement the Copy trait).

  • Zero cost abstractions

    • Makes no assumptions about compute resources. Can deploy to microcontrollers or compute clusters, with or without standard library or even a memory allocator.

    • Option and Result are often optimized to have no overhead (via "niche values"; depends on the contained type).

  • Only pay for what you need

    • Crates often have feature flags for selectively enabling/disabling capabilities.

    • derive macros to inject functionality.

Awesomeness

  • Single binary distribution

  • Astonishingly fast and lean on resource usage compared to managed runtime systems

  • Type system is very rich. Trait bounds are expressive.

  • Traits are closer to Haskell typeclasses than Scala traits.

  • Cargo is fantastic, especially compared to sbt or maven, but is not a generic build tool like cmake.

  • Native testing framework is no more complex than it needs to be.

  • Option/Result are fundamental, and everywhere. Often the compiler will optimize their overhead.

  • The serde library is amazing out of the box. Just about every format. Serde will increase compile times.

  • Macro system is hygienic and gives library providers a lot of power. Implementing your own macros is probably easier than in Scala, and has better error handling, but is still an advanced capability.

  • Cross compilation is amazingly seamless when no C libraries are involved.

  • Despite "pay for what you need", dependency hell isn't as present as expected. Exception is when C libraries like openssl are needed.

  • Once you get used to them, macros are powerful, principled, and not to be feared. Often used to eliminate lots of boilerplate and configure auto trait generation (see serde). However, "proc" macros can increase compile times quite a bit.

  • Compiles to WASM, and full-stack web app frameworks are coming onto the scene (but not yet mature). Note: Usually slower than JavaScript alone due to the communications bridge between browser interpreter and WASM sandbox.

Rough Edges

  • Memory location is not abstracted away from you. You have to have a mental model of where data is going to be stored (stack, heap, thread local, shared, etc.), and the associated restrictions. The good part is that it's all through strong types.

  • You have to select your own preferred set of micro libraries, including async and concurrency libraries. Work in the community is ongoing to provide better abstraction points.

  • Orphan rule: you can implement traits for types you own, or for traits you define over types you don't own, but you can't implement foreign traits for foreign types.

  • Often multiple crates (libraries) for the same thing, of varying degrees of maturity. Like the early days of Scala where there's a lot of library competition and abandonware

  • It's not a pretty language. It can be verbose.

  • Lots of examples use unwrap on Option/Result . Bad practice! Propagate Err or None with ? operator. anyhow can help when E in Result<R, E> doesn't type match, or you can use thiserror's #[from] attribute to unify with your other error types.

  • String formatting is pretty verbose, but has improved.

  • No currying. :(

  • No method function overloading. (However, it can somewhat be simulated with generic traits.)

  • Larger frameworks are still evolving. For example, I think Axum is the most promising of the web service frameworks, but it's one of the newer ones, and behind Actix and Rocket in maturity. Kinda like when Sinatra and Play 2 were battling for supremacy, and Akka HTTP showed up.

  • The crates.io ecosystem has a flat namespace, so the create name alone (e.g. s3 isn't enough to let you know if it's the best/most prominent/sanctioned implementation.

Potential "Gotchas"

  • Memory types are a first-class concern in Rust. See Tips section below.

  • Embrace using macros, but judiciously. They are considered first class citizens in Rust. However, they can effectively inject a DSL into you code, affecting readability.

  • All traits on a type have to be requested/injected, often via derive macros. If you want Clone or Debug printing on a type, you have to ask for it.

  • No runtime exceptions (but there is panic, which crashes the containing thread). Assumes Result is used to propagate errors.

  • Error handling is principled, but overly flexible. Use anyhow for error handling. Use thiserror if creating your own error types.

  • Sum/union types are via enum. Enums have three variants: unit-like, tuple, and struct.

  • An enum variant is not a distinct type. There is no type projection for an enum (e.g. MyEnum#Variant) You cannot implement a trait for a specific enum variant. You cannot get inheritance-like behavior from Enums. This is frustrating. You have to implement your own match-based dispatch if you want that kind of behavior on an impl.

  • No inheritance. Polymorphism is handled by generics (which are monomophized at compile time) or trait objects (fat pointers to the data struct and a vtable pointing to a trait implementation).

  • No reflection on types. Everything is erased at compile time.

  • Self referential data structures require the use of reference counted pointers (Rc or Arc) or self-managed indexing.

  • Orphan rule: you can only implement a trait when your crate either owns the trait, or owns the type for which the trait is implemented. If you own neither, you have to use the (verbose) "Newtype" pattern.

  • Extension methods are powerful but verbose. Use the extend crate.

  • cargo build --release can be significantly faster (and smaller) than the default debug build.

  • There are three types of macros: declarative, procedural and derive. They have slightly different syntax and call site requirements.

  • Rust has "editions", rustc has versions; rustup has toolchains.

  • Just because a library hasn't been updated in a while doesn't mean it's abandoned... just that it's "done".

Tips

  • Lifetimes are only relevant when references are involved.

  • Until you become adept at lifetime parameters and borrow checking, don't hesitate to just .clone() when semantics allow.

  • Sometimes you have to hold onto a reference on the stack to satisfy the borrow checker. This means that sometimes you have to introduce intermediate variables when you'd be inclined to chain method calls.

  • The 'static lifetime doesn't mean that the value lived since the start of the program, but only that it lives to the end of the sub-program (will never be droped).

  • Implementing From and TryFrom for your types can significantly aid composability.

  • Implement Default when possible, and new constructor method. For structs of Default types, you can use #[derive(Default)]

  • Understand your memory (ordered by frequency of use, generally):

    • By default, Rust allocates everything on the stack. Rust won't let you access anything incorrectly, and you can actually do a lot with stack-alone allocations.

    • To allocate something on the heap, you use the Box<T> type. When the owning binding of a Box<T> goes out of scope, the memory is deallocated. There is only a single owner of a Box<T> (although you can borrow it, just like anything else).

    • For shared ownership there is Rc<T>, which is a reference counted pointer, but for single-thread use. Rust is able to enforce this because it does not implement Send, a requirement for moving data between threads. When a binding to an Rc<T> goes out of scope, the reference count is decremented. When it reaches zero, the object is Dropped.

    • For shared ownership across threads there is Arc<T>, an atomic reference counted heap allocated object. It's able to be shared across threads because it does implement Send.

    • For "dynamic", safe mutability of shared state, there's Cell<T> and RefCell<T>. For read-only data sharing but with the ability to change the value one sees locally, there is Cow<T>, a "copy on write" reference type.

  • Destructors in Rust are implemented with the Drop trait.

Learning Resources

Dead Tree Books

Core Online

Geospatial

Python Interop

Other

Tooling

  • Cargo is amazing (despite using TOML)

  • RustRover Plugin is pretty good if you already use JetBrains products. It implements its own language server, which has some downsides.

  • VS Code gets the most attention. It uses the excellent rust-analyzer language server. Debugging is not as nice as Jetbrains, but errors and refactorings are more mature.

  • Finding libraries: crates.io is the primary index (but it's a bit of a mess to navigate). libs.rs is a slightly more organized nice. IMO, a serious weakness is the lack of namespaces in the packaging system.

  • For minimalists: https://dystroy.org/bacon/

  • Crates hall-of-fame (in no particular order):

    • anyhow
    • clap
    • tokio
    • futures-util
    • rayon
    • chrono/chrono-humanize
    • hyper
    • axum
    • mime
    • extend
    • log
    • envlogger
    • tracing
    • base64
    • serde
    • serde_json
    • serde_yaml
    • strum
    • num-traits
    • num-derive
    • syntect
    • crossterm
    • inquire
    • indicatif
    • load-dotenv
    • semver
    • ndarray
    • pyO3
    • kube-rs

    See https://blessed.rs/crates for additional opinions.

  • This guy has some opinions on what a starter toolchain should look like. Of course, preferences vary.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment