Should I learn rust?

It’s been awhile since I’ve gone back to basics and become a beginner again on a programming language. Learning a new one is tough after you’ve been ingrained in one community for so long.

 

Inspiration

I’ve been on the fence about learning a new language for awhile now. I’ve used Python for most of my professional career. I’ve used languages like PowerShell, Lua, and Javascript when necessary, but have always returned to what I’m comfortable with.

I’ve never used a compiled or systems language outside of college, and felt it time to question my assumptions. Like learning a spoken language, learning a programming language that has drastically different paradigms makes you think differently (hopefully better) when you’re presented with a challenge.

 

gopher python crab

 

I’ve been going back and forth between learning Go and Rust, while at the same time thinking I’ve never really hit a wall with Python that would force a switch. Rust has been at the top of the leaderboards for speed and safety for awhile now, and is at the top of the list for the most desired language according to the Stack Overflow survey.

What really triggered me to dive into Rust is listening to the Ship It podcast where Tim McNamara talked about how he’s been evangelizing Rust at AWS. Rust isn’t just going to be saving cycles, it’ll be improving sustainability. It’ll be saving money. Looking at the Lambda cold start times and extrapolating over all the resources that could benefit from it, there’s going to be a huge reduction in resources needed.

 

Immersion

Reading Rust code feels like I’m deciphering hieroglyphics. I knew from the start I would need to immerse myself in the code in order to learn it.

These are the resources I used within this first week of learning Rust.

Thanks to these resources, I’m getting a lot better at reading code written in Rust. I’m still not 100% confident in writing it.

 

Be Deliberate

Learning can’t be passive. Use some or all of these approaches to start getting some muscle-memory in the language.

  • Add to spaced repetition system (Anki)
  • Use coding “katas”, like rustlings
  • Model your existing workflows in the new language (see below)
  • Blog about it so you have a reason to learn about key aspects

For me, I really want to see how my workflows might change by switching to Rust. Let’s take a look at some of them.

 

Comparing workflows

For those coming from Python, here are some of the differences. I prefer to have a bunch of different standalone examples in order to gauge if it’s something I’m comfortable with day to day.

 

Starting a new project

Python

With Python, you get in the habit of first making/sourcing a virtual environment.

# python
mkdir py-api && cd py-api

# virtualenv
python3.11 -m venv venv
source venv/bin/activate
pip install --upgrade pip

# dependencies
echo "Flask" >> requirements.txt # api framework
pip install -r requirements.txt

Rust

For rust, we use cargo to set up the directory and dependencies for us. I admittedly chose a more complex start than is usually necessary, so you’ll see I’m overriding the standard toolchain for Rust with the nightly build.

# rust
cargo new rust-api && cd rust-api
rustup update && cargo update

# dependencies
rustup override set nightly && cargo add rocket # api framework, requires nightly build
cargo build # or: cargo run, to both build and run

One thing I noticed is that if you don’t have a nightly build of rust installed (rustup toolchain list), adding the rocket dependency will download it for you so long as you’ve overridden the build for your current project. When you’re in a project that needs a different build, you’ll see that build as overriding the default.

Not overridden

rustup toolchain list

# stable-aarch64-apple-darwin (default)
# nightly-aarch64-apple-darwin

Overridden

rustup toolchain list

# stable-aarch64-apple-darwin (default)
# nightly-aarch64-apple-darwin (override)

If you want to explicitly download that build, you can with: rustup toolchain install nightly.

 

Finding packages

Python

Use pypi, the Python Package Index, which pip uses by default.

Rust

Go to crates.io, which Cargo uses by default.

 

APIs

Let’s model a simple API in Python and Rust. We’ll have two endpoints that can be hit:

  • /hello/<person>/<age>
  • /world/<planet>

For Python, we’ll use Flask, a common web framework for building APIs. For Rust, I chose Rocket without doing too much research. It seems easy to use, but immediately I ran into needing a non-standard rust toolchain.

Python

Here’s a simple Flask app listening on port 5000 for a couple different routes.

main.py

# Run: source venv/bin/activate && python main.py

from flask import Flask

app = Flask(__name__)

@app.route("/hello/<name>/<age>")
def hello(name: str, age: int):
    return f"<p>Hello, {age} year old named {name}!</p>"

@app.route("/world/<planet>")
def world(planet: str):
    return f"<p>Hello, from planet {planet}!</p>"

if __name__ == "__main__":
    app.run(debug=False, host="0.0.0.0")

requirements.txt

Flask

URL examples:

  • http://127.0.0.1:5000/hello/erik/35
  • http://127.0.0.1:5000/world/earth

Rust

Here’s a Rocket API to do the same thing. By default, it’ll be listening on port 8000. As a beginner, my eyes just try to ignore all the attributes for now (e.g. #![feature](...)). I know they’re necessary, but I’ll come back to understanding them later. See attributes or procedural-macros for more info.

src/main.rs

// Run: cargo run

// inner attribute - apply to the item that the attribute is declared within
#![feature(proc_macro_hygiene, decl_macro)]
// outer attribute - apply to the thing that follows the attribute
#[macro_use] extern crate rocket;

// HTTP methods use outer attributes
// because /hello maps to this function, full path=/hello/name/age
#[get("/<name>/<age>")]
fn hello(name: String, age: u8) -> String {
    format!("Hello, {} year old named {}!", age, name)
}

// because /world maps to this function, full path=/world/planet
#[get("/<planet>")]
fn world(planet: String) -> String {
    format!("Hello from planet {}!", planet)
}

fn main() {
    rocket::ignite()
        .mount("/hello", routes![hello])
        .mount("/world", routes![world])
        .launch();
}

Cargo.toml

[package]
name = "rust-api"
version = "0.1.0"
edition = "2021"

[dependencies]
rocket = "0.4.11"

URL examples:

  • http://127.0.0.1:8000/hello/erik/35
  • http://127.0.0.1:8000/world/earth

 

Building CLI tooling

Let’s make a simple CLI app to update our local tooling managed by brew.

We want to run the following shell script, but have it accept new arguments to add runtime features.

#!/bin/zsh
# Place in /scripts/brew-upgrade-packages-and-cleanup.sh
# crontab: 0 8,20 * * * /scripts/brew-upgrade-packages-and-cleanup.sh > /scripts/upgrade-brew.log 2>&1
/opt/homebrew/bin/brew update
/opt/homebrew/bin/brew upgrade
/opt/homebrew/bin/brew upgrade pyenv
/opt/homebrew/bin/brew cleanup -s
/opt/homebrew/bin/terminal-notifier -message "Brew upgrade/cleanup complete"

An upgraded version that accepts arguments like update would look like the following.

zsh

Example code
#!/bin/zsh
run_brew_commands() {
    local commands
    commands=(
        "/opt/homebrew/bin/brew update"
        "/opt/homebrew/bin/brew upgrade"
        "/opt/homebrew/bin/brew upgrade pyenv"
        "/opt/homebrew/bin/brew cleanup -s"
        "terminal-notifier -message 'Brew upgrade/cleanup complete'"
    )

    for command in "${commands[@]}"; do
        eval $command
    done
}

main() {
    if [[ "$1" == "update" ]]; then
        run_brew_commands
        echo "Brew update and upgrade completed."
    else
        echo "Usage: $0 update"
    fi
}

main "$@"

Python

Example code
# main.py
# run: python main.py
# run update: python main.py update

import argparse
import subprocess

def run_brew_commands():
    commands = [
        "/opt/homebrew/bin/brew update",
        "/opt/homebrew/bin/brew upgrade",
        "/opt/homebrew/bin/brew upgrade pyenv",
        "/opt/homebrew/bin/brew cleanup -s",
        "terminal-notifier -message 'Brew upgrade/cleanup complete'",
    ]

    for command in commands:
        subprocess.run(command, shell=True)

def main():
    parser = argparse.ArgumentParser(description="Brew Update and Upgrade CLI")
    parser.add_argument("action", nargs="?", help="Action to perform (update)")

    args = parser.parse_args()

    if args.action == "update":
        print("Starting application update")
        run_brew_commands()
        print("Update complete")
    else:
        parser.print_help()

if __name__ == "__main__":
    main()

Rust

Example code
// src/main.rs
// dependencies: cargo add clap@2
// run: cargo run
// run update: cargo run -- update
extern crate clap;

use clap::{App, Arg};
use std::process::Command;

fn run_brew_commands() {
    let commands = vec![
        "/opt/homebrew/bin/brew update",
        "/opt/homebrew/bin/brew upgrade",
        "/opt/homebrew/bin/brew upgrade pyenv",
        "/opt/homebrew/bin/brew cleanup -s",
        "/opt/homebrew/bin/terminal-notifier -message 'Brew upgrade/cleanup complete'",
    ];

    for command in commands {
        let mut cmd = Command::new("sh");
        cmd.arg("-c").arg(command);

        let output = cmd.output().expect("Failed to execute command");
        if !output.status.success() {
            println!("Error running command: {}", command);
            println!("{}", String::from_utf8_lossy(&output.stderr));
            std::process::exit(1);
        }
    }
}

fn main() {
    let matches = App::new("Brew Updater")
        .version("1.0")
        .about("Update and upgrade Homebrew packages")
        .arg(
            Arg::with_name("action")
                .help("The action to perform")
                .required(true)
                .possible_values(&["update"]),
        )
        .get_matches();

    let action = matches.value_of("action").unwrap();
    if action != "update" {
        println!("Usage: brew_updater update");
        std::process::exit(1);
    }

    run_brew_commands();
    println!("Brew update and upgrade completed.");
}

 

Testing

Python

After setting up the dependencies and files, run by calling: pytest.

# python
mkdir py-tester && cd py-tester

# virtualenv
python3.11 -m venv venv
source venv/bin/activate
pip install pytest

pytest

main.py

def sum(a: int, b: int):
    """Calculate the sum of two integers."""
    return a + b

def is_positive(num: int):
    """Check if a number is positive."""
    return num > 0

def is_odd(num: int):
    """Check if a number is odd."""
    return num % 2 != 0

tests/test_main.py

from main import sum, is_positive, is_odd

def test_sum():
    assert sum(2, 3) == 5
    assert sum(-1, 1) == 0

def test_is_positive():
    assert is_positive(5) is True
    assert is_positive(-1) is False
    assert is_positive(0) is False

def test_is_odd():
    assert is_odd(1) is True
    assert is_odd(-3) is True
    assert is_odd(2) is False
    assert is_odd(0) is False

Rust

No dependencies are necessary outside of the standard library. Here’s some information on test organization in Rust.

Run with: cargo test.

src/main.rs

// main.rs

// Function to calculate the sum of two integers
fn sum(a: i32, b: i32) -> i32 {
    a + b
}

// Function to check if a number is positive
fn is_positive(num: i32) -> bool {
    num > 0
}

// Function to check if a number is odd
fn is_odd(num: i32) -> bool {
    num % 2 != 0
}

fn main() {
    let num1 = 5;
    let num2 = 7;

    println!("Sum of {} and {} is: {}", num1, num2, sum(num1, num2));
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_sum() {
        assert_eq!(sum(2, 3), 5);
        assert_eq!(sum(-1, 1), 0);
    }

    #[test]
    fn test_is_positive() {
        assert!(is_positive(5));
        assert!(!is_positive(-1));
        assert!(!is_positive(0));
    }

    #[test]
    fn test_is_odd() {
        assert!(is_odd(1));
        assert!(is_odd(-3));
        assert!(!is_odd(2));
        assert!(!is_odd(0));
    }
}

 

Build a binary

Here we use the CLI example from above.

Python

The packaging story with Python is difficult and filled with pitfalls. This post doesn’t attempt to go into any detail as it’s been covered at length by others.

Rust

If we used the CLI tooling example, we could create an executable binary with the following:

# rust-cli-app
cargo build --release
./target/release/rust-cli-app # run it
./target/release/rust-cli-app update # run it with arguments

# rust-api
cargo build --release
./target/release/rust-api # run it
# curl localhost:8000/hello/erik/35
# curl localhost:8000/world/earth

 

Package your app with Docker

Here we use the API example from above and package it with Docker. We use a build script called build.sh to make it simple to build and reuse.

Change the image name, ports exposed, and test steps based on which app you’re testing.

Python

Dockerfile

FROM python:3.11-slim
WORKDIR /app
COPY . .
RUN pip install --no-cache-dir -r requirements.txt
CMD ["python", "main.py"]

build.sh

#!/bin/zsh
IMAGE="python_api_app"

echo "Cleaning up any existing containers..."
docker rm -f $IMAGE

echo "Building the image..."
docker build --platform linux/amd64 -f Dockerfile -t $IMAGE .

echo "Running the container..."
docker run -dit --platform linux/amd64 -p 8100:5000 --name $IMAGE $IMAGE

echo "Testing..."
sleep 2

curl localhost:8100/hello/erik/35
echo ""
curl localhost:8100/world/earth
echo "\n"

Rust

Dockerfile

# FROM rust:latest as builder, if you don't need nightly
FROM rustlang/rust:nightly as builder 
WORKDIR /app

COPY . .
RUN cargo build --release

# or FROM rockylinux:9.2-minimal
# or FROM amazonlinux:2
FROM debian:bullseye-slim
WORKDIR /app
COPY --from=builder /app/target/release/rust-api /app/rust-api
RUN chmod +x /app/rust-api
CMD ["/app/rust-api"]

build.sh

#!/bin/zsh
IMAGE="rust_api_app"

echo "Cleaning up any existing containers..."
docker rm -f $IMAGE

echo "Building the image..."
docker build --platform linux/amd64 -f Dockerfile -t $IMAGE .

echo "Running the container..."
docker run -dit --platform linux/amd64 -p 8000:8000 --name $IMAGE $IMAGE

echo "Testing..."
sleep 2
curl localhost:8000/hello/erik/35
echo ""
curl localhost:8000/world/earth
echo "\n"

 
 

Troubleshooting Rust

You’re trying to use a nightly build but it keeps using the stable build

If you installed Rust with homebrew, rustc might be at the wrong path (which rustc should show it in ~/.cargo/bin/rustc). It’s recommended to brew uninstall rust && curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh to install with the recommended approach from rustup.rs.