Rustling exercise notes

This Easter I had some time to dedicate to working through the rustling exercises. I still consider myself a novice rust developer. I frequently don’t know rust idioms, and spend a lot of time struggling to get an idea working at the fidelity of a prototype. Once I have something working, I’m able to revise and refine it incrementally, but getting the initial idea out is tough. My hope going into rustlings was that I would get some more experience debugging and fixing rust, and also hopefully reduce some of my knowledge gaps.

Rustlings?

Rustlings is a command line tool that provides a series of tutorials that give you exposure to the rust language and design features via small practical debugging exercises. Rustlings covers the basics like declaring variables, conditions, primitive types. As the exercise progress you get exposed to more advanced topics like generic data structures, traits, threading, lifetimes and iterators. I found these later exercises to be the most valuable, as they are areas I’m working on improving.

I ran my editor and rustlings in a split window, which gave a really quick feedback loop that was great to learn with.

Traits, and Lifetimes

Traits and lifetimes are one of the features of rust I have had a hard time learning. While rustlings did a good job of introducing traits, I would have loved to see more exercises that covered trait bounds. I really struggled with trait bounds in the past and would have liked to see traits with bounds, or associated types as part of rustlings.

I found the exercises on lifetimes to be great. I haven’t used lifetimes much in my rust projects yet, and they aren’t a concept that I’ve encountered in other languages previously. With the help of the rustlings exercises though, I feel like I have a much better understanding of lifetimes and when to use them. My understanding is that, lifetimes enable you to return references (not ownership) to values. When you return a reference, you have to tell the rust compiler what other data the reference you’re returning depends on. For example,

Show Plain Text
  1. fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
  2.     if x.len() > y.len() {
  3.         x
  4.     } else {
  5.         y
  6.     }
  7. }

Here, we’re comparing the length of two &str references, and returning the longest one by reference so that we can have fewer allocations. Because we’re accepting references, and returning one of those values by reference we need to use a lifetime – denoted by the 'a syntax. Rust requires lifetimes in situations like this so that it can ensure that the values used for x and y will be alive for as long as the return of longest is. If either parameter is dropped, then the return of longest would be invalid, as the underlying memory was freed.

Lifetimes names can use any sequence of characters. Lots of examples use single letter lifetimes, but you can use words as well. There is also a special 'static lifetime as it implies that the value is static – or will always exist.

Iterator magic

There is an exercise in the iterators chapter, and the solution I got to work surprised me:

Show Plain Text
  1. #[derive(Debug, PartialEq, Eq)]
  2. enum DivisionError {
  3.     // Example: 42 / 0
  4.     DivideByZero,
  5.     // Only case for `i64`: `i64::MIN / -1` because the result is `i64::MAX + 1`
  6.     IntegerOverflow,
  7.     // Example: 5 / 2 = 2.5
  8.     NotDivisible,
  9. }
  10.  
  11. // Otherwise, return a suitable error.
  12. fn divide(a: i64, b: i64) -> Result<i64, DivisionError> {
  13.     if b == 0 {
  14.         return Err(DivisionError::DivideByZero);
  15.     }
  16.     if a == i64::MIN && b == -1 {
  17.         return Err(DivisionError::IntegerOverflow);
  18.     }
  19.     if a % b > 0 {
  20.         return Err(DivisionError::NotDivisible);
  21.     }
  22.     Ok(a / b)
  23. }
  24.  
  25. // Desired output: `Ok([1, 11, 1426, 3])`
  26. fn result_with_list() -> Result<Vec<i64>, DivisionError> {
  27.     // This is the same logic from list_of_results.
  28.     let numbers = [27, 297, 38502, 81];
  29.     numbers.into_iter().map(|n| divide(n, 27)).collect()
  30. }
  31.  
  32. // Desired output: `[Ok(1), Ok(11), Ok(1426), Ok(3)]`
  33. fn list_of_results() -> Vec<Result<i64, DivisionError>> {
  34.     // This is the same logic from result_with_list
  35.     let numbers = [27, 297, 38502, 81];
  36.     numbers.into_iter().map(|n| divide(n, 27)).collect()
  37. }

What I found surprising about this exercise was that both result_with_list and list_of_results have the same implementation logic, but totally different return types. The first returns a result with a list of integers, and the second returns a list of results that each contain a single integer. I think of the rust compiler as fairly pedantic, and I was surprised that the same function body can have two very different return types! The answer turns out to be that Iterator.collect() is a trait method that has many implementations. The rust compiler will select the right implementation of collect() based on the return type used. Having ergonomic ‘just works’ behaviour was a total contrast to how picky the rust compiler typically is. It was a pleasant but initially head-scratching surprise.

Overall, I really enjoyed the rustlings exercises, and highly recommend them to anyone interested in learning rust.

Comments

There are no comments, be the first!

Have your say: