Rustlings 😍 - Small exercises to get you used to reading + writing Rust πŸ¦€

I have been going through the Rustlings exercises and I have found them to be a great way to practice Rust. πŸ”₯

As I said in a previous post, it's all about pratice. πŸ’‘

Rust is also a language that requires some time to get used to, specially when coming from a dynamic language like Python. All of a sudden you have to get used to the strictness of the compiler and the ownership system.

Reading books like The Rust Programming Language and Programming Rust 2nd ed (both fantastic!), a lot might not stick until you put it into practice, there is a lot to take in!

That's where Rustlings comes in. It's a set of small exercises that you can work through to reinforce what you've learned.

Not only will you write and fix code, you'll also read a lot of code forcing yourself to understand what it does.

And you'll get to see a lot of compiler error messages in the process. Rust is strict and unforgiving so the sooner you get used to that the better.

How it works

Just follow the README, the only thing I had to do on my Mac was:

curl -L https://raw.githubusercontent.com/rust-lang/rustlings/main/install.sh | bash

And then cd into the rustlings directory.

Watch mode

There are various ways you can do the exercises, but I really liked this workflow:

  • In the first terminal window, run rustlings watch.

  • In the second terminal window, open the exercises directory in your favorite editor (in my case Vim + fzf for quick file search) and start coding, as soon as you save a file, watch will pick it up and the corresponding tests will run.

Toggling between the two terminal windows writing code + seeing the tests run + reading the error messages, felt like learning on steroids. πŸ’ͺ

Conclusion

I'm through ~80% of the 96 exercises and I can say that I've learned a lot: ownership, borrowing, lifetimes, pattern matching, enums, structs, traits, generics, iterators, error handling, writing tests, it's all there! πŸš€

I highly recommend giving it a go, it's a great tool to really solidify what you've learned from reading books and watching videos. You might be surprised how little actually has stuck from any passive learning you've done. 🀯

This effective learning also happens when you try to build smaller projects, but Rustlings is a great way to reinforce the basics and fundamental concepts. πŸ“ˆ

Ownership and borrowing in Rust

In Python, this code runs just fine:

def print_person(s):
    print(f"Inside function: {s}")

def main():
    person = "John"
    print_person(person)
    print(f"Hello, {person}!")  # person still accessible

main()

When I pass person to print_person, the ownership of person is not moved to the function. I can still use person after the function call.

In Rust, the same code will not compile:

fn print_person(s: String) {
    println!("Inside function: {}", s);
    // s goes out of scope here and is dropped
}

fn main() {
    let person = String::from("John");
    print_person(person);
    println!("Hello, {}!", person); // compile-time error
}

The Rust compiler gives this nicely descriptive error message:

...
7 |     let person = String::from("John");
  |         ------ move occurs because `person` has type `String`, which does not implement the `Copy` trait
8 |     print_person(person);
  |                  ------ value moved here
9 |     println!("Hello, {}!", person); // compile-time error
  |                            ^^^^^^ value borrowed here after move

What happens here is that person is moved to print_person, and I can't use it after that. This is because Rust is strict about ownership and borrowing.

This definitely takes some time to get used to, but it's a powerful and important feature of Rust.

It helps prevent memory-related bugs (e.g., use-after-free, double-free, dangling pointers, memory leaks) that are common in other languages that manage memory manually (e.g., C and C++).

The solution is to borrow person instead of moving it:

fn print_person(s: &String) {
    println!("Inside function: {}", s);
}

fn main() {
    let person = String::from("John");
    print_person(&person);
    println!("Hello, {}!", person); // now person is still usable
}

Here we pass a reference instead of the value itself. Note that you have to express this explicitly with & in the function signature and when calling the function.

In Rust speak person is borrowed by print_person. This way the function can use person without taking ownership of it.

Key Takeaways:

  • In Rust, passing ownership to a function means the original variable can no longer be used. This is to prevent multiple owners of the same data, which can lead to bugs and memory leaks. It also helps with performance and concurrency.

  • In Python, variables are references, so they remain valid after being passed to functions. Additionally, you don't have to worry about memory management because Python's garbage collector automatically handles the allocation and deallocation of memory (it tracks object references and uses reference counting and cyclic garbage collection to free memory that is no longer needed).

  • Rust's borrowing allows you to pass references to functions without transferring ownership, preserving the original variable’s validity.

Mutability and borrowing

In Rust, you can have multiple immutable references to the same data, but only one mutable reference. Additionally, you have to explicitly declare that you want to mutate the data.

In Python, the burden is on the programmer to ensure that data is not modified when it shouldn't be. Python doesn't distinguish between mutable and immutable references explicitly.

def modify_data(data):
    data.append(4)

def main():
    my_list = [1, 2, 3]
    modify_data(my_list)
    print(f"Modified list: {my_list}")  # my_list is modified

main()

For example here my_list is modified inside modify_data function. In Rust, this would not compile because my_list is borrowed immutably by modify_data:

fn modify_data(data: &Vec<i32>) {
    data.push(4);
}

fn main() {
    let my_list = vec![1, 2, 3];
    modify_data(&my_list);
    println!("Modified list: {:?}", my_list); // compile-time error
}

The Rust compiler will give this error:

2 |     data.push(4);
  |     ^^^^ `data` is a `&` reference, so the data it refers to cannot be borrowed as mutable
  |
help: consider changing this to be a mutable reference
  |
1 | fn modify_data(data: &mut Vec<i32>) {
  |                       +++

In Rust, to modify data within a function, you must pass a mutable reference using &mut. This ensures that only one mutable reference exists at a time, preventing data races and ensuring memory safety.

fn modify_data(data: &mut Vec<i32>) {
    data.push(4);
}

fn main() {
    let mut my_list = vec![1, 2, 3];
    modify_data(&mut my_list);
    println!("Modified list: {:?}", my_list); // now it works
}

Note: This is for example's sake. I don't like mutating outer scope data inside functions. A more functional approach would be to return the modified data.

Conclusion

Understanding ownership and borrowing is a key concept in Rust. It can be frustrating at first, but it's a powerful feature that helps prevent bugs and makes your code more reliable. Rust achieves a balance between performance and safety, leveraging its strict ownership model to prevent common programming errors while still being highly performant.

Python uses a different approach to memory management, relying on garbage collection to handle memory allocation and deallocation so you generally don't have to worry about these details.

I still have a lot to learn about the nuances of ownership and borrowing in Rust, but I hope this post gives you a good starting point.

Does Rust have a similar NamedTuple type?

When exploring a new language after Python, one of the first things you might look for is a similar Python types, and one of my favorite ones is the namedtuple.

It's a simple way to create a class with named fields, and it's very useful when you want to create a simple data structure.

Python named tuples

# old way
from collections import namedtuple
Person = namedtuple('Person', ['name', 'age', 'email'])
p = Person('John', 30, 'john@example.com')
print(p.name, p.age, p.email)

# more modern way with type hints I prefer:
from typing import NamedTuple

class Person(NamedTuple):
    name: str
    age: int
    email: str

p = Person('John', 30, 'john@example.com')
print(p.name, p.age, p.email)
# p.name = 'John Doe'  # AttributeError... -> named tuples are immutable

Named tuples are more readable

I am such a big fan of named tuples because unlike regular tuples they let you access the fields by name, which makes your code more readable and less error-prone.

# regular tuple
person = ('John', 30, 'john@example.com')
# what field is at what index?
print(person[0], person[1], person[2])

# named tuple
Person = namedtuple('Person', ['name', 'age', 'email'])
person = Person('John', 30, 'john@example.com')
# instantly readable
print(person.name, person.age, person.email)

Named tuples are immutable

Named tuples are also immutable, which is a good thing because it makes the code safer (you can't accidentally change the values).

This is a core concept in Rust I learned, where data structures are immutable by default. If you want mutability, you have to explicitly make them mutable. We'll see how to do that in the Rust in a bit ...

Rust offers structs

In Rust, you can use a struct with named fields to create the same Person type as before. Here's how you can do it:

struct Person {
    name: String,
    age: u32,
    email: String,
}

fn main() {
    let person = Person {
        name: String::from("John"),
        age: 30,
        email: String::from("john@example.com"),
    };

    // struct is immutable by default
    // person.age = 31;

    println!("Name: {}, Age: {}, Email: {}", person.name, person.age, person.email);
}

This prints:

$ cargo run
...
Name: John, Age: 30, Email: john@example.com

(Ommitting the cargo run command from here on.)

Notice that if I uncomment the line person.age = 31; the Rust compiler will complain. I am really impressed by with how helpful the Rust compiler is. It gives you very helpful and specific error messages. Here's what it says in this case:

...
  --> src/main.rs:15:5
   |
15 |     person.age = 31;
   |     ^^^^^^^^^^^^^^^ cannot assign
   |
help: consider changing this to be mutable
   |
8  |     let mut person = Person {
   |         +++

...

So as per the error message, you can make the struct mutable by adding the mut keyword before the variable name:

fn main() {
    let mut person = Person {
        name: String::from("John"),
        age: 30,
        email: String::from("john@example.com"),
    };

    // This is allowed because `person` is mutable.
    person.age = 31;

    println!("Name: {}, Age: {}, Email: {}", person.name, person.age, person.email);
}

This works and prints:

Name: John, Age: 31, Email: john@example.com

Implement methods

Optionally you can implement methods on the struct to make it more powerful. Here's an example:

struct Person {
    name: String,
    age: u32,
    email: String,
}

impl Person {
    // Method to display a greeting
    fn greet(&self) {
        println!("Hello, my name is {} and I am {} years old. You can contact me at {}", self.name, self.age, self.email);
    }
}

fn main() {
    let person = Person {
        name: String::from("John"),
        age: 30,
        email: String::from("john@example.com"),
    };

    person.greet();
}

Which prints:

Hello, my name is John and I am 30 years old. You can contact me at john@example.com

In Rust, you can enhance your structs by implementing methods using the impl block. This allows you to define functions that operate on instances of your struct, providing a cleaner and more encapsulated way to manage data + associated behaviors (think properties and methods in Python classes).

The greet(&self) method is an instance method that takes an immutable reference to the struct (= &self), similar to how Python methods take self as their first parameter. This method simply prints a greeting using the struct's fields.

The reference part here touches upon the concept of ownership and borrowing in Rust. Ownership is a big and important concept in Rust, and I'll cover it here more in detail when I have a better understanding of it ...

Conclusion

In Rust, you can use a struct with named fields to achieve the same thing as a Python namedtuple.

Structs are immutable by default, but you can make them mutable by adding the mut keyword before the variable name.

Optionally, you can implement methods on the struct using the impl block. πŸ¦€πŸ˜Ž

I find the Rust compiler very helpful with its detailed and specific error messages. It helps a lot, specially when you're learning the language. πŸ˜πŸ’‘πŸ“ˆ

Enhancing Pybites Search caching using serde_json

I made a Pybites search command-line tool the other day, only to find out that the caching was in-memory which was not very useful for a CLI tool.

So I decided to add caching manually to it using serde_json. Here's how I did it (with the help of ChatGPT).

fn save_to_cache(items: &Vec<Item>) -> Result<(), Box<dyn std::error::Error>> {
    let cache_path = get_cache_file_path();
    let cache_data = CacheData {
        timestamp: SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(),
        items: items.clone(),
    };
    let serialized = serde_json::to_string(&cache_data)?;
    fs::write(cache_path, serialized)?;
    Ok(())
}

fn load_from_cache(cache_duration: u64) -> Result<Vec<Item>, Box<dyn std::error::Error>> {
    let cache_path = get_cache_file_path();
    let data = fs::read_to_string(cache_path)?;
    let cache_data: CacheData = serde_json::from_str(&data)?;

    let current_time = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
    if current_time - cache_data.timestamp <= cache_duration {
        Ok(cache_data.items)
    } else {
        Err("Cache expired".into())
    }
}

#[derive(Deserialize, Serialize)]
struct CacheData {
    timestamp: u64,
    items: Vec<Item>,
}

fn get_cache_file_path() -> PathBuf {
    let mut path = home_dir().expect("Could not find home directory");
    path.push(CACHE_FILE_NAME);
    path
}

Full code

  • I added a CacheData struct to hold the timestamp and the items.
  • I added a save_to_cache function to save the items to a file (using the CACHE_FILE_NAME constant to define the file name).
  • I added a load_from_cache function to load the items from a file with a cache duration check.
  • I added a get_cache_file_path function to get the path to the cache file (using the home_dir function from the dirs crate).
  • The cache duration is set to 24 hours by default (DEFAULT_CACHE_DURATION constant) but can be overridden by setting the CACHE_DURATION environment variable. I like how you can chain methods in Rust, it's very concise and readable 😍 πŸ“ˆ
    let cache_duration = env::var("CACHE_DURATION")
        .ok()
        .and_then(|v| v.parse().ok())
        .unwrap_or(DEFAULT_CACHE_DURATION);

Now it works well: the items are saved to the cache file and loaded from it till the cache expires. Apart from the first run, when the cache is created, the search results are now almost instant!

First install the crate (see my previous article how to publish Rust code to crates.io):

$ cargo install pybites-search

Then run it:

$ time psearch pixi
...

[podcast] #141 - Wolf Vollprecht: Making Conda More Poetic With Pixi
https://www.pybitespodcast.com/14040203/14040203-141-wolf-vollprecht-making-conda-more-poetic-with-pixi

psearch pixi  0.06s user 0.06s system 5% cpu 2.323 total

$ time psearch pixi
...

[podcast] #141 - Wolf Vollprecht: Making Conda More Poetic With Pixi
https://www.pybitespodcast.com/14040203/14040203-141-wolf-vollprecht-making-conda-more-poetic-with-pixi

psearch pixi  0.01s user 0.01s system 82% cpu 0.018 total

After the caching it's almost instant!

I compared it to our Python search tool and it's now faster than that one:

$ time search all pixi
...
search all pixi  0.33s user 0.06s system 96% cpu 0.410 total

That's like 20x faster. 🀯

But that might not be fair, because it uses requests-cache with sqlite and I also started a new API endpoint on our platform specially for the Rust search tool.

So I had ChatGPT translate the Rust code to Python and after a few Pythonic tweaks and fixes it was faster and more comparable to the Rust tool:

$ time python script.py pixi
...
python script.py pixi  0.18s user 0.03s system 98% cpu 0.214 total

That's faster but not as fast as the Rust tool. πŸš€πŸ˜Ž


I still have a lot of Rust fundamentals to learn, but by building practical tools I am learning much more, faster and seeing results like these is way more fun and gratifying than only studying or even doing exercises.

So if you're learning Rust or any other language, I recommend building real-world tools with it. It really works! πŸ› οΈ

How to ship code to crates.io and automate it with GitHub Actions

In the previous post, I made a little Rust script to search Pybites content. In this post I share how I deployed this crate (the Rust term for a package) to crates.io (the Rust package index). πŸŽ‰

Next I will show you how I streamlined it using GitHub Actions. πŸš€

Setup

First you need to make an account on crates.io, confirm your email, and create an API token. You can find this under your account settings.

Next you login from the command line:

$ cargo login your_token

Publishing

Then you can publish your crate, from your project directory:

$ cargo publish
   Uploading pybites-search v0.1.0 (/Users/bbelderbos/code/rust/pybites-search)
    Uploaded pybites-search v0.1.0 to registry `crates-io`
note: waiting for `pybites-search v0.1.0` to be available at registry `crates-io`.
You may press ctrl-c to skip waiting; the crate should be available shortly.
   Published pybites-search v0.1.0 at registry `crates-io`

It takes the version from your Cargo.toml file. If you want to publish a new version, you need to update this file.

Installing the crate

As a user you can now install your crate:

$ cargo install pybites-search
    Updating crates.io index
  Downloaded pybites-search v0.1.0
...
...
  Installing /Users/bbelderbos/.cargo/bin/psearch
   Installed package `pybites-search v0.1.0` (executable `psearch`)

I added a [[bin]] section to my Cargo.toml file, so the binary is installed as psearch:

[[bin]]
name = "psearch"
path = "src/main.rs"

Now when people install the crate, they can run psearch from the command line. πŸƒ

$ psearch
Usage: search <search_term> [<content_type>] [--title-only]

Automating with GitHub Actions

Of course manually pushing to crates.io is not ideal. You can automate this with GitHub Actions. Here is the workflow I use:

name: Release to crates.io

on:
  push:
    tags:
      - 'v*.*.*'  # Matches tags like v1.0.0, v2.1.3, etc.

jobs:
  release:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v2

      - name: Set up Rust
        uses: actions-rs/toolchain@v1
        with:
          toolchain: stable
          override: true

      - name: Cache Cargo registry
        uses: actions/cache@v2
        with:
          path: ~/.cargo/registry
          key: ${{ runner.os }}-cargo-registry
          restore-keys: |
            ${{ runner.os }}-cargo-registry

      - name: Cache Cargo index
        uses: actions/cache@v2
        with:
          path: ~/.cargo/git
          key: ${{ runner.os }}-cargo-index
          restore-keys: |
            ${{ runner.os }}-cargo-index

      - name: Build the project
        run: cargo build --release

      - name: Publish to crates.io
        env:
          CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
        run: cargo publish

This workflow triggers on a tag push, so you can tag your releases with vX.Y.Z and it will automatically publish your crate to crates.io (when you do a git push --tags) 🏷️

2 simple steps to publish your crate:

  • Update your Cargo.toml file with the new version.
  • Tag your release with vX.Y.Z and push it to GitHub: git tag v0.2.0 && git push --tags

Also note that you'll need the CARGO_REGISTRY_TOKEN secret for this workflow to work. You can add this in your GitHub repository settings under Settings -> Secrets -> New repository secret. 🀫

Job run example.


That's it! Now you can easily share your Rust projects with the world. πŸš€

And if you happen to follow my Python work, next time install the pybites-search crate and run psearch from the command line. 🐍 πŸ“ˆ