DEV Community

Cover image for The Not So Hard Bits In Practice
Steven Trotter
Steven Trotter

Posted on

The Not So Hard Bits In Practice

Writing Basic Programs

Introduction

Since the previous entry a lot has happened both for me and for Rust itself so I'll give a brief overview. It took me a while to write Article 2 in this series and I'm sorry about that, my initial thought was one article every month and maybe 3 months at worst case. I'm nowhere near that; I'll try and do better in future.

In Rust a lot has changed too. The big one for me is, we've got async in Rust now. If you don't know what this means that's fine, but if you're familiar with green threading and things like Go and Node you'll know this is potentially a big deal. We're early days, I'm currently playing around a lot with Tokio 0.2 and it's a big step forward in my view. It's early days, and a lot of other crates still need updating to Tokio 0.2 and we are still on the road to Tokio 1.0. But it really feels like the Rust guys took a while to make sure it's correct and it's an exciting time to be involved in Rust.

So basically my fourth reason for not using Rust in article 1 is now disappearing, not so sure the others are but I'll take that. I truly think Rust is going have a great year in 2020 and I can't wait to see where we are in a years time.

Anyway, to this article. In the previous entry in this series I talked a lot of theory. I mean if you're still here congratulations on getting through all that. I felt satisfied with my effort given that there is a lot of theory that does need to be covered and I think the hardest bits are done. However I didn't want it to become the case that you'd have all the theory and none of the practice. So in this second article in the series I'll basically be showing how to use Rust practically and how to use what we learned in the previous article. I'll introduce a lot of stuff along the way as well so that you can start using Rust in practice. After this hopefully you'll understand how to write a fairly basic Rust program using the cargo build tool and with help from the documentation you'll be able to do quite a lot. We'll cover strings, results, matching, enums, options, vectors and hashmaps, all of which you'll need to be comfortable with to be able to write good programs in Rust.

Together we're going to build a very basic calculator command line tool in this article. It'll be far from complete but it'll teach you a lot about Rust. I want to build a program that will take input on a command line through stdin and on pressing enter will perform the calculation and spit out the result. I'll go through a few iterations to get to where I want in order to help introduce as much as I can along the way. I'll make mistakes on purpose (honest) and show you what to do to correct them.

I'll assume you've got a command line system (I'm using a terminal on a Mac, Linux would work just as well, I'm sure Windows has a similar thing) with the rustc and cargo programs fully installed. See here for how to install Rust, rustup is the right way to do this.

I warn that this is a long article but overall it's almost certainly easier than the previous article. I'd recommend taking the time to try thing out yourself and I'd recommend reading the book alongside this, particularly if something is confusing.

Calculator - Iteration 1

So let's get started. Firstly I want a program that will do the following:

  • Take an input string that consists of two integers separated by an operator each of which is separated by spaces
  • When I press enter it will interpret this string and perform the sum specified and output that to the screen
  • Any division will be rounded down to the nearest integer to print out an integer at the end
  • All numbers will be i64's (signed 64 bit integers)

We may add more requirements as we go but let's keep things simple to begin with.

Creating a basic program with cargo

The first thing we're going to do is create a new program called calculator. To begin with run the following command in a terminal to create a calculator directory with the necessary bare bones:

> cargo new calculator
     Created binary (application) `calculator` package
Enter fullscreen mode Exit fullscreen mode

We can then change to this directory and build this as follows:

> cd calculator
> cargo build
   Compiling calculator v0.1.0 (<snip>/rust/calculator)
    Finished dev [unoptimized + debuginfo] target(s) in 1.93s
>
Enter fullscreen mode Exit fullscreen mode

We can then do cargo run to run the program and this will just print "Hello, World!" and exit as it's a skeleton program. We always use cargo run when we want to run the program going forward and it will recompile as necessary.

Let's take a look under the hood of what we have. In this directory we have the following:

Cargo.lock
Cargo.toml
src/
  main.rs
target/
  ...
Enter fullscreen mode Exit fullscreen mode

So we have two files, Cargo.lock and Cargo.toml. I won't discuss much the Cargo.lock file for now, the Cargo.toml file is the starting point. In a Rust program we have a variety of third party libraries we can use (as any decent language really) called Crates. If you look on crates.io you'll be able to search for these Crates there. For example if we search for the tokio crate we get directed here and we can see links for the documentation and code repository for Tokio. We'll be adding a crate to our program shortly.

Additionally we have a src/ directory with one file in it, main.rs and we have a target/ directory. The latter for the most part we can ignore as that's where programs live, we can run them with cargo run or we can do ./target/debug/calculator. In src/ is where all our code will live and we'll just work with main.rs in this article. I recommend reading the book for how to split your work to be more modular but there's nothing excessively hard there so I won't cover it here.

Adding Crates

It's generally not advised to reinvent the wheel in programming unless you're doing something purely as an exercise (which has merits) or unless you have a really compelling reason to; that is don't rewrite something that has already been written. In this case we want something to read lines that input to a program. Surely we can do this with either a) an existing crate or b) something in the standard library. Well we certainly could do the latter and just use std::io::stdin, see here for details on how to do that. With this though we just get something very basic, let's have something cooler.

A quick search for readline in crates.io shows me this page that looks interesting. According to that page Rustyline is a Readline implementation in Rust, sounds about what we want. When writing this that package was on version 5.0.4, it's possible (even likely) that by the time you're reading this that's progressed on. The following should still work anyway as we pin it to 5.0.4, but as an exercise feel free to take the latest version, it'll probably just work and you've got the latest and greatest but if it doesn't then that's a good exercise to get it to work in itself (though in that case don't spend ages on something you can't get working of course).

I also recommend reading up about Cargo in general and specifically about specifying packages. We can always take the latest by specifying * instead of 5.0.4 for example and a variety of other things. We might want to pin to a major version say. For now we'll just pin to 5.0.4 to make life easier.

So how do we use it. Well it's quite easy actually, let's add the following to the end of cargo.toml:

[dependencies]
rustyline = "5.0.4"

[dev-dependencies]
spectral = "0.6.0"
Enter fullscreen mode Exit fullscreen mode

The [dependencies] line is already present of course so just append to this. I've sneaked in a dev dependency called spectral that will allow me better assertion writing below when we start writing tests. Now let's run cargo build again. I get the following (yours may vary):

    Updating crates.io index
  Downloaded unicode-width v0.1.7
   Compiling libc v0.2.66
   Compiling cfg-if v0.1.10
<snip>
   Compiling rustyline v5.0.4
   Compiling calculator v0.1.0 (/Users/strotter/work/programming/rust/calculator)
Enter fullscreen mode Exit fullscreen mode

Now if I compile again it's much quicker as it has already compiled all these crates for us but it won't recompile them now. I'm mostly just going to copy and paste the Rustyline example into my src/main.rs and run it (ignoring the history stuff, I don't want that). So I end up with a src/main.rs as follows:

use rustyline::error::ReadlineError;
use rustyline::Editor;

fn main() {
    let mut rl = Editor::<()>::new();
    loop {
        let readline = rl.readline(">> ");
        match readline {
            Ok(line) => {
                println!("Line: {}", line);
            },
            Err(ReadlineError::Interrupted) => {
                println!("CTRL-C");
                break
            },
            Err(ReadlineError::Eof) => {
                println!("CTRL-D");
                break
            },
            Err(err) => {
                println!("Error: {:?}", err);
                break
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

We'll cover the match command later more thoroughly but basically on successfully taking input we run the first println! macro, if we send Ctrl-C or Ctrl-D we end up in the next two blocks and a generic error we end up in the last one.

Now if I run it I get the following:

> cargo run
   Compiling calculator v0.1.0 (/Users/strotter/work/programming/rust/calculator)
    Finished dev [unoptimized + debuginfo] target(s) in 1.52s
     Running `target/debug/calculator`
>> testing
Line: testing
>> 1 + 2 = 3
Line: 1 + 2 = 3
>>
CTRL-D
> 
Enter fullscreen mode Exit fullscreen mode

How great is that, I can use left and right arrow keys, I can press ctrl-c or ctrl-d to quit and it prints to say that's what I've done. I've basically done hardly anything and got a program that accepts input and does it well.

But of course this does nothing yet really. I need to make this do something. So we've learned a little about Cargo and getting going, Cargo is awesome and you definitely should use it in Rust development. I'm not going to search for any more crates here however as the point going forward is to teach you Rust properly, but I'd recommend doing so in your day to day usage, make your life as easy as possible.

Interpreting the line

Now we want to write another function basically that will perform a calculation given an input string. I personally love writing unit tests for this kind of thing, I hope you do too. I'll demonstrate how to do this kind of thing with unit tests in Rust now.

So I'm believe I'm going to have to write a function that will take as input a string, and will return an integer (you could try floating point numbers as an exercise, we're just using integers for simplicity as the point is teaching, not to have a fully fledged calculator). Let's add this function to the top of main.rs:

fn calculate(calculation_string: &str) -> i64 {
    0
}
Enter fullscreen mode Exit fullscreen mode

We pause now for a quick discussion about strings as we'll need to know some basics before proceeding.

Aside: Strings in Rust

So let's briefly talk about strings in Rust first. The sad thing is it's a bit complicated (though I think less than C++ say but more so than Python or JavaScript certainly). There are essentially two string types we care about in Rust, String and &str. You'll notice the latter is actually a reference and you'd be right, but you basically never use the str type directly, you can't even do that for reasons I'll not go into here. Roughly I'll use String if I want to be able to build and change the string around and &str if I just want to pass a static string around, though you'll learn and understand more about this as you go through this article (I hope).

So let's demo creating an &str now. I can create one as follows:

fn main() {
    let example_string = "STRING";
    println!("{}", example_string);
}
Enter fullscreen mode Exit fullscreen mode

Easy enough it seems, but how do I know this is an &str, doesn't look much like a reference? Well, I'll show you a neat trick now that I learned from The Rust Crash Course and I use all the time now for how to debug this kind of thing. Basically I change the above program as follows:

fn main() {
    let example_string = "STRING";
    let a: bool = example_string;
    println!("{}", example_string);
}
Enter fullscreen mode Exit fullscreen mode

So the third line here is now saying I'm creating a variable a of type bool from whatever example_string is. Well Rust won't be able to just assign pretty much anything to a bool without being explicitly told so, so this will throw an error when compiled (unless it is a bool, but then you'd know that I guess). So let's try and compile this now, I get:

error[E0308]: mismatched types
 --> src/main.rs:3:19
  |
3 |     let a: bool = example_string;
  |            ----   ^^^^^^^^^^^^^^ expected `bool`, found `&str`
  |            |
  |            expected due to this

error: aborting due to previous error

For more information about this error, try `rustc --explain E0308`.
error: could not compile `calculator`.
Enter fullscreen mode Exit fullscreen mode

Oh it told me at the end of the 5th line, I have an &str as promised.

Let's remove this and try and do something with this (spoiler alert, I can't do much in terms of writing but reading is mostly OK). I can try the following:

fn main() {
    let example_string = "STRING";
    if example_string == "STRING" {
        println!("{}", example_string);
    }
}
Enter fullscreen mode Exit fullscreen mode

This works fine, I've checked if our example_string has value "STRING" and as it does the if condition is evaluated to true. Now let's try and append to it though:

fn main() {
    let example_string = "STRING";
    let example_string = example_string + "TEST";
    if example_string == "STRINGTEST" {
        println!("{}", example_string);
    }
}
Enter fullscreen mode Exit fullscreen mode

Well that didn't work, when I ran it I got:

   Compiling calculator v0.1.0 (/Users/strotter/work/programming/rust/calculator)
error[E0369]: binary operation `+` cannot be applied to type `&str`
 --> src/main.rs:3:41
  |
3 |     let example_string = example_string + "TEST";
  |                          -------------- ^ ------ &str
  |                          |              |
  |                          |              `+` cannot be used to concatenate two `&str` strings
  |                          &str
  |
help: `to_owned()` can be used to create an owned `String` from a string reference. String concatenation appends the string on the right to the string on the left and may require reallocation. This requires ownership of the string on the left
  |
3 |     let example_string = example_string.to_owned() + "TEST";
  |                          ^^^^^^^^^^^^^^^^^^^^^^^^^

error: aborting due to previous error
Enter fullscreen mode Exit fullscreen mode

Yikes, what is that? Actually it's a pretty good error, it tells me exactly how to fix it which is basically turn my &str here into a String and then I can do this. I can even then add an &str to a String and it's all fine, if I do what it says it works.

On the other hand maybe I have a String and I'm trying to pass to a function that takes an &str, what then? Example:

fn print_data(to_print: &str) {
    println!("Output: {}", to_print);
}

fn main() {
    let example_string = "STRING".to_owned();
    print_data(example_string);
}
Enter fullscreen mode Exit fullscreen mode

Doesn't like that either:

error[E0308]: mismatched types
 --> src/main.rs:7:16
  |
7 |     print_data(example_string);
  |                ^^^^^^^^^^^^^^
  |                |
  |                expected `&str`, found struct `std::string::String`
  |                help: consider borrowing here: `&example_string`

error: aborting due to previous error
Enter fullscreen mode Exit fullscreen mode

Clearly I need to convert from a String here to an &str. How do I do that? Easy here actually, I can just prepend an & and I'm good:

fn print_data(to_print: &str) {
    println!("Output: {}", to_print);
}

fn main() {
    let example_string = "STRING".to_owned();
    print_data(&example_string[..]);
}
Enter fullscreen mode Exit fullscreen mode

See, it's all easy enough right. Well actually this isn't the full story and it does get a bit more complicated. You've two choices now, you can either get the full picture from the article about slices in the Rust book, or you can basically just stick to the following algorithm till you need to know more and it will pretty much serve you well:

  • If something wants a String and it's complaining it is a &str add a .to_owned() to it, e.g. "123".to_owned() converts a &str 123 to a String 123. Alternatively you can use the format! macro (don't worry about what a macro is for now, just think of it as a command) if you want to do something more complicated, e.g. I could do format!("Converting to String: {}", s) which converts an &str variable s to a String with "Converting to String: " prepended to it.
  • If something wants a &str and it's complaining it is a String then prepend an & to it and append [..] to it, e.g. the following converts a String s with value "123" to a &str with value "123":
let s = "123".to_owned();
print_data(&s[..]);
Enter fullscreen mode Exit fullscreen mode

So why would I ever use &str is probably the next question. Well one answer is sometimes the standard library and Crates you use will insist you do. Other than that you can really just pass around String everywhere in your app if you want but it won't be the most efficient. String's can allocate more memory than needed and when passing by value you might need to think about cloning and thus using even more memory. If you want the gory details I'd say read the top response to this stackoverflow question but essentially the answer is, use &str wherever possible and where it isn't use String. We will get a better feeling for the answer to this question after reading this article.

Back to the Calculator

Anyway, back to our calculator. We had the following function:

fn calculate(calculation_string: &str) -> i64 {
    0
}
Enter fullscreen mode Exit fullscreen mode

Obviously this doesn't do much for now and I'm not even going to plug it into my main function until my tests are all working. So TDD says I should write a test that fails and make it pass, let's do that now. Advantages of this are essentially I can run this really quickly and tests give me really fast feedback, they also serve to document to future developers exactly what my thought process was when writing this kind of thing. I don't want to get too side tracked on TDD so let's just jump in now and write the following test, add this to the bottom of main.rs:

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

    #[test]
    fn test_addition() {
        let res = super::calculate("1 + 2");
        assert_that(&res).is_equal_to(&3);
    }
}
Enter fullscreen mode Exit fullscreen mode

There's a lot going on here but basically it's all to allow cargo and the compiler to know to run this as a test. So anything marked with #[test] is ran as a test when we do cargo test. The use statement brings the spectral crate in so we have better assertions and error reporting. OK, let's run cargo test now:

> cargo test
  Downloaded spectral v0.6.0
<snip>
   Compiling spectral v0.6.0
   Compiling calculator v0.1.0 (/Users/strotter/work/programming/rust/calculator)
warning: unused variable: `calculation_string`
 --> src/main.rs:4:14
  |
4 | fn calculate(calculation_string: &str) -> i64 {
  |              ^^^^^^^^^^^^^^^^^^ help: consider prefixing with an underscore: `_calculation_string`
  |
  = note: `#[warn(unused_variables)]` on by default

    Finished test [unoptimized + debuginfo] target(s) in 10.72s
     Running target/debug/deps/calculator-3aae0d9719182d41

running 1 test
test tests::test_addition ... FAILED

failures:

---- tests::test_addition stdout ----
thread 'tests::test_addition' panicked at '
        expected: <3>
         but was: <0>
', /Users/strotter/.cargo/registry/src/github.com-1ecc6299db9ec823/spectral-0.6.0/src/lib.rs:343:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace.


failures:
    tests::test_addition

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out
Enter fullscreen mode Exit fullscreen mode

Oh no, big lots of badness and errors. The first warning I can ignore as that will be fixed soon. As for the test error, well wouldn't I expect this though? It returns 0 which is not equal to 3. Well OK, let's fix it so it does. Obviously I could just change it to return 3, but who am I kidding? Myself? Let's try and parse the string properly now and make sense of it, another test coming on.

My first thought is I want another function tokenize that takes this &str and returns a Vector of strings. Wait, a what? What's a Vector? Time for another aside.

Aside: Vectors

Well you may have seen lists or arrays in other languages, it's kinda similar. A vector in Rust is a collection of elements that all have the same type and it can be added to along with various other operations. It's part of the standard library. We can create a vector in a few ways, but the easiest I find is the following:

let vector: Vec<i64> = vec!(1, 2, 3);
Enter fullscreen mode Exit fullscreen mode

This creates a vector with 3 elements that are all i64's. I could of course do the same thing with 10 or 100 or no elements at all. I can also add to this by making it mutable and then pushing elements onto it as follows:

let mut vector: Vec<i64> = vec!(1, 2, 3);
vector.push(4);
Enter fullscreen mode Exit fullscreen mode

It should be noted that a vector owns all its elements and once a vector gets dropped then all of its elements also get dropped. However if it owns references the references are dropped but not the things they're referencing.

I have the following example of a program (the {:?} in the println! means print with debugging info):

fn main() {
    let mut vector: Vec<i64> = vec!(1, 2, 3);
    vector.push(4);
    println!("{:?}", vector[0])
}
Enter fullscreen mode Exit fullscreen mode

which I can run to get:

1
Enter fullscreen mode Exit fullscreen mode

I can also loop over vectors with for loops as follows:

fn main() {
    let mut vector: Vec<i64> = vec!(1, 2, 3);
    vector.push(4);
    for i in &vector {
        println!("{:?}", &vector.get(*i as usize))
    }
}
Enter fullscreen mode Exit fullscreen mode

There's not loads that truly tricky about vectors themselves, and you're welcome to read the Vector page of the book. It is, however, the case that you have to be careful with ownership (an anything in Rust, remember lesson 1?). If you have a vector that owns its entries directly (rather than references to) then when accessing them you need to either transfer ownership or take references. In transferring ownership have a few options, popping entries off or take entries out of vectors and leave something else in its place. For example we can do the following:

let mut vec: Vec<String> = vec!["TEST1".to_owned(), "TEST2".to_owned(), "TEST3".to_owned()];
let item = std::mem::replace(&mut vec[0], "TEST1_REPLACEMENT".to_owned());
println!("Vector: {:?}", vec);
println!("Item: {}", item);
Enter fullscreen mode Exit fullscreen mode

We've used a standard library function std::mem::replace that will literally swap and replace safely an existing entry out. This can be extremely useful when trying to take ownership of something. It's also worth considering using an Option which we discuss below.

Back to the Calculator

Returning to the problem at hand, should my vector have String's or &str's as its type? Well we're supposed to have &str to begin with right so let's try that first, we're not going to have to do loads of changing the string it seems, just interpreting, so this seems like a good first shout.

So we have another function:

fn tokenize(string_to_parse: &str) -> Vec<&str> {
    let vec = vec!();
    vec
}
Enter fullscreen mode Exit fullscreen mode

Note here that because the last line in this function doesn't have a semi-colon that means it's a return. I could just write return vec; instead but this is a more Rusty way of doing things so I do as my Rust overlords tell me.

This compiles and we're all good. Let's write another test now, put this inside the tests module again.

    #[test]
    fn test_parsing_string() {
        let res = super::tokenize("1 + 2");
        let expected = vec!("1", "+", "2");
        assert_that(&res).is_equal_to(expected);
    }
Enter fullscreen mode Exit fullscreen mode

Now two tests failing, way to go me. Let's fix at least this one.

In the tokenize I want to split the string handed to me by whitespaces. If I look at the Rust standard library documentation forStrings I see there is a method split_ascii_whitespace that gives me an iterator. Iterators I haven't covered and won't be doing an aside for here but essentially I can iterate over iterators (duh). In particular I can do the following:

for token in string_to_parse.split_ascii_whitespace() {
    println!("Token: {}", token);
}
Enter fullscreen mode Exit fullscreen mode

and then run with cargo test -- --nocapture to see the logged lines and I get:

Token: 1
Token: +
Token: 2
Enter fullscreen mode Exit fullscreen mode

Cool, that's pretty much what I need. So I can now finish this function off:

fn tokenize(string_to_parse: &str) -> Vec<&str> {
    let mut vec = vec![];

    for token in string_to_parse.split_ascii_whitespace() {
        vec.push(token);
    }

    vec
}
Enter fullscreen mode Exit fullscreen mode

I had to change the vector to be mutable to get it compiling but it compiles now. Now we run the tests and one test passes now, woohoo:

test tests::test_parsing_string ... ok
test tests::test_addition ... FAILED

failures:

failures:
    tests::test_addition

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filter
Enter fullscreen mode Exit fullscreen mode

In fact I could have also written the function in a more compact and functional style as follows, but this involves knowledge of closures and iterators so I've not done this for now, but if you know these things I could also have done:

fn tokenize(string_to_parse: &str) -> Vec<&str> {
    string_to_parse.split_ascii_whitespace().collect()
}
Enter fullscreen mode Exit fullscreen mode

Performing Calculations

Back to the tests anyway. Obviously the other one doesn't pass, how would it, it's not implemented yet. I can start implementing it now though with the tokenize function.

So we can start implementing by using the calculate function, we can do the following to begin with:

fn calculate(calculation_string: &str) -> i64 {
    let vec = tokenize(calculation_string);
    0
}
Enter fullscreen mode Exit fullscreen mode

Now we want to turn the first element into an integer. I recommend at this point trying to parse the standard library documentation first and attacking these problems, start by looking at the String and str types to see if we have anything.

So did you find the parse function/turbofish operator? Well (this)[https://doc.rust-lang.org/std/string/struct.String.html#method.parse] is what I'd use for this (there may be other ways). So I can solve the problem using this, it works on both a &str and a String so let's try it on a reference:

fn calculate(calculation: &str) -> i64 {
    let vec = tokenize(calculation);
    let num1 = vec[0].parse::<i64>();
    println!("{}", num1);
    0
}
Enter fullscreen mode Exit fullscreen mode

Note here that I can just take vec[0] and parse that because we have a vector of references and we don't need to worry about ownership. Had we have chosen to have a vector of String's we'd have had to do (&vec[0]).parse::<i64>() to prevent trying to take ownership. Alternatives of swapping out with std::mem::replace, see here for more details.

Here's an interesting error we get now though:

error[E0277]: `std::result::Result<i64, std::num::ParseIntError>` doesn't implement `std::fmt::Display`
 --> src/main.rs:7:20
  |
7 |     println!("{}", num1);
  |                    ^^^^ `std::result::Result<i64, std::num::ParseIntError>` cannot be formatted with the default formatter
  |
  = help: the trait `std::fmt::Display` is not implemented for `std::result::Result<i64, std::num::ParseIntError>`
  = note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead
  = note: required because of the requirements on the impl of `std::fmt::Display` for `&std::result::Result<i64, std::num::ParseIntError>`
  = note: required by `std::fmt::Display::fmt`

error: aborting due to previous error

For more information about this error, try `rustc --explain E0277`.
Enter fullscreen mode Exit fullscreen mode

It's telling us num1 can't be displayed because it doesn't implement the Display trait. I was expecting an i64 but it doesn't look like it is an i64. What is it? It tells me, it's a std::result::Result<i64, std::num::ParseIntError>, whatever that is. It recommends I fix it by putting a {:?} inside the String, which would make the program compile but wouldn't give me what I want, I want num1 to be an i64 but the parse function/turbofish operator can't guarantee I didn't write "bananas" here, so it gives me a Result back instead. Let's do a quick aside on this one.

Aside: Results, Enums and Matching

So what is a Result? A Result is basically an enum that can be set to either something correct or an error. Great, what's an enum? Enums in Rust are probably a bit different to enums you're used to in other languages and it's well worth reading up about them here, but basically an enum is a type that has several options and an enum value will assume one (and only one) of those values. What really makes enums pop for me at least is a combination of the match keyword and that I can attach extra information to one entry. We'll discuss in the context of a Result as an example now or you can also read more about this in the book here.

In a Result we have two types, an error type in case something went wrong and an OK type for when we succeeded and I'll only get one or the other. A Result is defined in the standard library as follows:

pub enum Result<T, E> {
    Ok(T),
    Err(E),
}
Enter fullscreen mode Exit fullscreen mode

I took out comments and things but this is it with T and E here being placeholders for a particular type. E will generally be for handling errors but T will be whatever you want essentially. In our case T is clearly i32 and E is whatever is returned by parse (doesn't matter for now). So if everything was alright with number 32 say I'd return Ok(32) which would be a Result<i32>. Or if something went wrong I might return Err(err) where I've built an appropriate variable err of type E.

But how can I get useful information out in our case. Well as I say match, let's try and pull the error out (as we'll clearly have an error here, I mean bananas integer, what was I thinking). We try the following:

let not_number = "bananas";
let number = not_number.parse::<i32>();
match number {
    Err(error) => {
        println!("Error: {}", error);
    }
};
Enter fullscreen mode Exit fullscreen mode

This is saying that if number which is either Ok with type i32 or an Err with some error associated. This is saying if it's an Err then match that, set the variable error to whatever the error was and we can reference that within that block of code to handle this.

But if we try this we get:

error[E0004]: non-exhaustive patterns: `Ok(_)` not covered
 --> src/main.rs:4:11
  |
4 |     match number {
  |           ^^^^^^ pattern `Ok(_)` not covered
  |
  = help: ensure that all possible cases are being handled, possibly by adding wildcards or more match arms
Enter fullscreen mode Exit fullscreen mode

Well that's pretty helpful so let's add a leg for Ok too:

let not_number = "bananas";
let number = not_number.parse::<i32>();
match number {
    Ok(num) => {
        println!("Num: {}", num);
    },
    Err(error) => {
        println!("Error: {}", error);
    },
};
Enter fullscreen mode Exit fullscreen mode

Now we can run it and we get as expected:

     Running `target/debug/calculator`
Error: invalid digit found in string
Enter fullscreen mode Exit fullscreen mode

Let's pause now and look at this again just to make sure it's clear. We've ran a command match on the number variable which we know has two different value possibilities, its either worked and I get Ok(num) where num is an i32 or its not worked and I get Err(err) where err is some kind of error. The "arms" of the match command in lines 4 and 9 above match on those patterns and then run the code in between the braces dependent on which one matched. I can also use the variables num or err (called whatever I want) and they'll have the values they're supposed to have.

If I wanted to find out the error type I could play my trick above of doing this:

let not_number = "bananas";
let number = not_number.parse::<i32>();
match number {
    Ok(num) => {
        println!("Num: {}", num);
    },
    Err(error) => {
        let a: bool = error;
        println!("Error: {}", error);
    },
};
Enter fullscreen mode Exit fullscreen mode

to find that our E has type std::num::ParseIntError:

error[E0308]: mismatched types
 --> src/main.rs:9:27
  |
9 |             let a: bool = error;
  |                    ----   ^^^^^ expected `bool`, found struct `std::num::ParseIntError`
  |                    |
  |                    expected due to this

error: aborting due to previous error
Enter fullscreen mode Exit fullscreen mode

This really is just the tip of the iceberg where match is concerned, it's extremely powerful. It may just look a bit like a switch but I don't know of many languages that have this ability to specify values within the enums like this (I gather Haskell has this kind of thing, I don't know it personally but I understand a lot of the inspiration for Rust comes from functional languages like this, I'm not trying to report the history but I'll happily report that I really love this part of Rust).

Also here we've handled the error but we carry on afterwards. Sometimes this isn't what we want, sometimes we want to make sure that if we do get somewhere we just bail out straight away. Maybe it's dangerous to carry on in which case we can use things like the panic! macro:

let not_number = "bananas";
let number = not_number.parse::<i32>();
match number {
    Ok(num) => {
        println!("Num: {}", num);
    },
    Err(error) => {
        panic!("Bailing: {}", error);
    },
};
Enter fullscreen mode Exit fullscreen mode

and we get:

thread 'main' panicked at 'Bailing: invalid digit found in string', src/main.rs:9:13
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace.
Enter fullscreen mode Exit fullscreen mode

If I find I'm in this situation and I run it with the RUST_BACKTRACE variable exported it will, as it promises, give me a stack trace which can be really useful.

> RUST_BACKTRACE=1 cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.45s
     Running `target/debug/calculator`
thread 'main' panicked at 'Bailing: invalid digit found in string', src/main.rs:9:13
stack backtrace:
   0: backtrace::backtrace::libunwind::trace
             at /Users/strotter/.cargo/registry/src/github.com-1ecc6299db9ec823/backtrace-0.3.40/src/backtrace/libunwind.rs:88
   1: backtrace::backtrace::trace_unsynchronized
             at /Users/strotter/.cargo/registry/src/github.com-1ecc6299db9ec823/backtrace-0.3.40/src/backtrace/mod.rs:66
   2: std::sys_common::backtrace::_print_fmt
             at src/libstd/sys_common/backtrace.rs:84
<snip>
  21: std::rt::lang_start
             at /Users/strotter/code/third_party/rust/rust/src/libstd/rt.rs:67
  22: calculator::main
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.
Enter fullscreen mode Exit fullscreen mode

On mine number 13 shows me where I did this, yours may vary with Rust versions.

So let's fix it so I don't get an error:

let number_to_parse = "12";
let number = number_to_parse.parse::<i32>();
match number {
    Ok(num) => {
        println!("Num: {}", num);
    },
    Err(error) => {
        println!("Error: {}", error);
    },
};
Enter fullscreen mode Exit fullscreen mode

and we get predictably:

Num: 12
Enter fullscreen mode Exit fullscreen mode

We don't always need to go to the full effort of using match when we just expect it to work though. The match command is great when you want a lot of power but there are a few other ways of just going "this should work but if not panic" as above. The big one I use all the time is expect, it's a bit of a strange name for it at first but it's basically like saying "I expect this to work, if not throw the error I give you". You can also use unwrap and the ? operator but I tend to prefer expect because if it happens I then know exactly where and why. By using expect I always expect it will work and if it doesn't that implies there's a bug. If it should be handled then I should think about using match.

Example:

let number_to_parse = "bananas";
let number = number_to_parse.parse::<i32>().expect("Couldn't find integer");
Enter fullscreen mode Exit fullscreen mode

and now I get:

thread 'main' panicked at 'Couldn't find integer: ParseIntError { kind: InvalidDigit }', src/libcore/result.rs:1189:5
Enter fullscreen mode Exit fullscreen mode

Also I can use match on things other than enums, I can use it on strings, ints, whatever really. But it's going to be difficult to exhaustively list out all strings or ints or whatever. For this reason we have the _ matcher, this will match on anything else and should always come last otherwise anything after will be ignored. Example:

let string = "TESTING";
match string {
    "TESTING" => {
        println!("As expected");
    },
    _ => unreachable!()
}
Enter fullscreen mode Exit fullscreen mode

Note unreachable! is a useful macro too for when you're really confident you can't get somewhere and if you do it's a bug. It'll just hard panic if you get to one of these. Here we never will clearly.

So we need the _ otherwise we'll get an error as match insists that you are exhaustive (cover everything):

error[E0004]: non-exhaustive patterns: `&_` not covered
 --> src/main.rs:3:11
  |
3 |     match string {
  |           ^^^^^^ pattern `&_` not covered
  |
  = help: ensure that all possible cases are being handled, possibly by adding wildcards or more match arms

error: aborting due to previous error
Enter fullscreen mode Exit fullscreen mode

The match command being exhaustive is a great reminder to handle your errors and the like.

One other thing with the match command though that we need to make sure that each arm evaluates to the same type, or will panic! or whatever. For example the following will fail to compile:

let num = 32;
match num {
    1 => "TEST",
    32 => 33,
    _ => unreachable!(),
};
Enter fullscreen mode Exit fullscreen mode

to give us:

error[E0308]: match arms have incompatible types
 --> src/main.rs:5:15
  |
3 | /     match num {
4 | |         1 => "TEST",
  | |              ------ this is found to be of type `&str`
5 | |         32 => 33,
  | |               ^^ expected `&str`, found integer
6 | |         _ => unreachable!(),
7 | |     }
  | |_____- `match` arms have incompatible types
Enter fullscreen mode Exit fullscreen mode

But if we take out the 1 arm it's fine, note that we don't return an integer from the other pattern but that's fine because it's unreachable! and this means we just hard panic anyway. We need to return the same type from each arm because it's possible to set a variable to this value, for example, this will print ret: 33 out:

let num = 32;
let ret = match num {
    32 => 33,
    _ => unreachable!(),
};
println!("ret: {}", ret);
Enter fullscreen mode Exit fullscreen mode

Back to the Calculator (again).

Ok, so we'll use expect against recommendation in the aside now as it's easier, to do this properly we should use match as we don't know for sure the string is as i64. Let's now try this and run the tests again:

fn calculate(calculation: &str) -> i64 {
    let vec = tokenize(calculation);
    let num1 = vec[0].parse::<i64>().expect("num1 should be a number");
    num1
}
Enter fullscreen mode Exit fullscreen mode

Getting closer, it's complaining it has a 1 now instead of a 0. We'll do the same for num2 and in fact I could get this test passing by doing the following:

fn calculate(calculation: &str) -> i64 {
    let vec = tokenize(calculation);
    let num1 = vec[0].parse::<i64>().expect("num1 should be a number");
    let num2 = vec[2].parse::<i64>().expect("num2 should be a number");
    num1 + num2
}
Enter fullscreen mode Exit fullscreen mode

But this is wrong as what if I'd put a - instead of a +, it'll add it anyway. I could add a subtraction test next and fix this, this kind of thing will be left as an exercise for the reader however.

To do this properly we use the match operator from the aside. So for our first example here we can give the following a go to match on the operator (remembering that _ matches anything):

fn calculate(calculation: &str) -> i64 {
    let vec = tokenize(calculation);
    let num1 = vec[0].parse::<i64>().expect("num1 should be a number");
    let num2 = vec[2].parse::<i64>().expect("num2 should be a number");
    let op = vec[1];
    match op {
        _ => {
            panic!("Bad operator");
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This is not finished of course because it matches everything and panics. Let's try and finish this off properly now:

fn calculate(calculation: &str) -> i64 {
    let vec = tokenize(calculation);
    let num1 = vec[0].parse::<i64>().expect("num1 should be a number");
    let num2 = vec[2].parse::<i64>().expect("num2 should be a number");
    let op = vec[1];
    match op {
        "+" => num1 + num2,
        _ => {
            panic!("Bad operator");
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The cargo test passes, woohoo. Now I glossed over a minor detail here, did you notice? You may have done (and if so well done) that there's no return here. Well we didn't put a semi-colon on the match statement so whatever that returned is what we returned, in this case it's what we want for addition.

Now we need to finish this and implement the same for subtraction, multiplication and division. I'll leave this as an exercise for the reader.

Finishing off

OK so we now (if you've done the exercises) covered off writing the tests, the function can interpret adding, subtracing, etc within a string (if you write it correctly). But I don't use the function yet. In fact I get warnings saying "Are you crazy? You meant to not use this stuff you've worked so hard on?":

warning: function is never used: `calculate`
warning: function is never used: `tokenize`
 --> src/main.rs:4:4
  |
4 | fn tokenize(string_to_parse: &str) -> Vec<&str> {
  |    ^^^^^^^^
  |
  = note: `#[warn(dead_code)]` on by default

warning: function is never used: `calculate`
  --> src/main.rs:14:4
   |
14 | fn calculate(calculation: &str) -> i64 {
   |    ^^^^^^^^^
Enter fullscreen mode Exit fullscreen mode

Well gosh, Rust is right it seems. Let's use it, it's no good yet.

Joking aside though when I wrote this, I knew before plugging it into the main function it was very likely to work because I'd tested it. Let's see this now, change main to:

fn main() {
    let mut rl = Editor::<()>::new();
    loop {
        let readline = rl.readline(">> ");
        match readline {
            Ok(line) => {
                println!("Result: {}", calculate(&line));
            }
            Err(ReadlineError::Interrupted) => {
                println!("CTRL-C");
                break;
            }
            Err(ReadlineError::Eof) => {
                println!("CTRL-D");
                break;
            }
            Err(err) => {
                println!("Error: {:?}", err);
                break;
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Now if I run it I get what I want:

>> 1 + 32
Result: 33
>> 2 + 33
Result: 35
>> 5 * 21
Result: 105
>> 2 - 12
Result: -10
>> 10 * 214
Result: 2140
>> 214 / 24
Result: 8
>>
CTRL-C
Enter fullscreen mode Exit fullscreen mode

For completeness, here's the code I ended up with, yours may vary.

use rustyline::error::ReadlineError;
use rustyline::Editor;

fn tokenize(string_to_parse: &str) -> Vec<&str> {
    let mut vec = vec![];

    for token in string_to_parse.split_ascii_whitespace() {
        vec.push(token);
    }

    vec
}

fn calculate(calculation: &str) -> i64 {
    let vec = tokenize(calculation);
    let num1 = vec[0].parse::<i64>().expect("num1 should be a number");
    let num2 = vec[2].parse::<i64>().expect("num2 should be a number");
    let op = vec[1];
    match op {
        "+" => num1 + num2,
        "-" => num1 - num2,
        "*" => num1 * num2,
        "/" => num1 / num2,
        _ => {
            panic!("Bad operator");
        }
    }
}

fn main() {
    let mut rl = Editor::<()>::new();
    loop {
        let readline = rl.readline(">> ");
        match readline {
            Ok(line) => {
                println!("Result: {}", calculate(&line));
            }
            Err(ReadlineError::Interrupted) => {
                println!("CTRL-C");
                break;
            }
            Err(ReadlineError::Eof) => {
                println!("CTRL-D");
                break;
            }
            Err(err) => {
                println!("Error: {:?}", err);
                break;
            }
        }
    }
}

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

    #[test]
    fn test_addition() {
        let res = super::calculate("1 + 2");
        assert_that(&res).is_equal_to(&3);
    }

    #[test]
    fn test_subtraction() {
        let res = super::calculate("2 - 1");
        assert_that(&res).is_equal_to(&1);
    }

    #[test]
    fn test_multiplication() {
        let res = super::calculate("2 * 3");
        assert_that(&res).is_equal_to(&6);
    }

    #[test]
    fn test_division() {
        let res = super::calculate("47 / 3");
        assert_that(&res).is_equal_to(&15);
    }

    #[test]
    fn test_parsing_string() {
        let res = super::tokenize("1 + 2");
        let expected = vec!["1", "+", "2"];
        assert_that(&res).is_equal_to(expected);
    }
}
Enter fullscreen mode Exit fullscreen mode

Cool huh? Well, not really I guess, we're just on iteration 1 though, we can do better. For example what happens if I write a letter instead of a number, or what if I write a bad operator, or what if I write nothing or multiple sums:

>> 1 + 2 + 3
Result: 3
Enter fullscreen mode Exit fullscreen mode
>> abc * 1
thread 'main' panicked at 'num1 should be a number: ParseIntError { kind: InvalidDigit }', src/libcore/result.rs:1084:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace.
Enter fullscreen mode Exit fullscreen mode
>>
thread 'main' panicked at 'index out of bounds: the len is 0 but the index is 0', /rustc/8a58268b5ad9c4a240be349a633069d48991eb0c/src/libcore/slice/mod.rs:2690:10
note: run with `RUST_BACKTRACE=1` environment variable to display a back
Enter fullscreen mode Exit fullscreen mode

We won't handle the errors here because you have the skillset mostly to do this yourself now, just have it print more useful errors is about all you can do here anyway. But let's have a look at changing it so that we can have a variable abc say that we can set like abc = 42 and then use in calculations.

Calculator - Iteration 2

OK, so this time we'll change it to accept variables. Let's change it so that we have the following requirements:

  • We can set variables with the = operator and it will set a variable in the left as long as it starts with an alphabet character.
  • We can use variables within sums whenever they are set. We leave error handling as an exercise again, the point is to teach you things after all and there's no better way of learning than doing.

Variable Substitution

So we take care of number 2 first. You might think number 1 is better to tackle first but we do number 2 first here because otherwise we can't easily test number 1 without number 2. However for number 2 I need to know how I'll be specifying variables. It seems that we need an extra parameter to send to that contains variable names and their values. Some kind of HashMap (hint hint), try and see if you can find one in the standard library.

Well we have one and we can add the following line to the beginning of the program:

use std::collections::HashMap;
Enter fullscreen mode Exit fullscreen mode

and we add a variable to the calculation:

fn calculate(calculation: &str, variables: HashMap<&str, i64>) -> i64 {
    <snip>
}
Enter fullscreen mode Exit fullscreen mode

Now we need to alter main:

fn main() {
    let mut rl = Editor::<()>::new();
    let variables: HashMap<&str, i64> = HashMap::new();
    loop {
        let readline = rl.readline(">> ");
        match readline {
            Ok(line) => {
                println!("Result: {}", calculate(&line, variables));
            }
            <snip>
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

but we get an error now:

error[E0382]: use of moved value: `variables`
  --> src/main.rs:39:57
   |
34 |     let variables: HashMap<&str, i64> = HashMap::new();
   |         --------- move occurs because `variables` has type `std::collections::HashMap<&str, i64>`, which does not implement the `Copy` trait
...
39 |                 println!("Result: {}", calculate(&line, variables));
   |                                                         ^^^^^^^^^ value moved here, in previous iteration of loop
Enter fullscreen mode Exit fullscreen mode

Well we don't want to copy it as it's hinting because we want one copy of global variables essentially. So let's change it to a reference that the calculate function takes:

fn calculate(calculation: &str, variables: &HashMap<&str, i64>) -> i64 {
    <snip>
}
Enter fullscreen mode Exit fullscreen mode

and there's a few additional changes like taking a reference in main and adding this parameter to the tests, we'll leave this for the reader (sorry but it is for your own good).

Now let's add a new test for being able to use variables:

#[test]
fn test_variables_in_sums() {
    let mut variables: HashMap<&str, i64> = HashMap::new();
    variables.insert("abc", 123);
    let res = super::calculate("abc + 5", &variables);
    assert_that(&res).is_equal_to(&128);
}
Enter fullscreen mode Exit fullscreen mode

Now when I try and implement this I find that I want to make this possible for both num1 and num2. I don't particularly want to repeat the logic for both so I'll write a new function that checks if the first character is a letter and if so tries to substitute the value in. I'll write the following signature:

fn get_value(token: &str, variables: &HashMap<&str, i64>) -> i64 {
    0
}
Enter fullscreen mode Exit fullscreen mode

and write the following tests:

#[test]
fn test_substitute_variable() {
    let mut variables: HashMap<&str, i64> = HashMap::new();
    variables.insert("abc", 123);
    let res = super::get_value("abc", &variables);
    assert_that(&res).is_equal_to(&123);
}

#[test]
fn test_return_parsed_number() {
    let variables: HashMap<&str, i64> = HashMap::new();
    let res = super::get_value("123", &variables);
    assert_that(&res).is_equal_to(&123);
}
Enter fullscreen mode Exit fullscreen mode

and now I can implement get_value by:

fn get_value(token: &str, variables: &HashMap<&str, i64>) -> i64 {
    match token.chars().next().unwrap().is_ascii_alphabetic() {
        true => *variables.get(token).unwrap(),
        false => token.parse::<i64>().expect("token should be a number"),
    }
}
Enter fullscreen mode Exit fullscreen mode

and the tests just written now pass. I can easily fix the remaining test as follows:

fn calculate(calculation: &str, variables: &HashMap<&str, i64>) -> i64 {
    let vec = tokenize(calculation);
    let num1 = get_value(vec[0], variables);
    let num2 = get_value(vec[2], variables);
    <snip>
}
Enter fullscreen mode Exit fullscreen mode

and everything is all good again.

Variable Setting

Now we want to be able to set variables by sending abc = 123 say. Let's add a test for this now:

#[test]
fn test_setting_variables() {
    let variables: HashMap<&str, i64> = HashMap::new();
    let res = super::calculate("abc = 5", &variables);
    assert_that(&res).is_equal_to(&5);
    assert_that(variables.get("abc").unwrap()).is_equal_to(5);
}
Enter fullscreen mode Exit fullscreen mode

Because we currently have to return an i64 from calculate we have to either return an i64 for this case or change the return type and deal with it elsewhere. Ordinarily I'd probably choose to return what the variable gets set to here but as we're learning I'll give an example of an Option.

Aside: Options

As one final aside we have another similar entry to Result called Option that is equally as useful. This is an enum that takes two options, either something or nothing. So an Option<T> is either Some(T) or None. This is how we handle nulls and it forces us to consider both when something is null and exists when using matching.

So for example we can have:

let num: Option<i32> = Some(32);
println!("{:?}", num);
Enter fullscreen mode Exit fullscreen mode

We can also use match and expect and that kind of thing on Options too in a similar way as we can with Result. E.g. here I can do num.unwrap() if I know that num is Some(32) and I'll get 32 out.

We don't use it here but it's so useful I point it out now anyway. We can take ownership of an entry in an Option with the take method which can be very useful. For example:

let mut option: Option<String> = Some("TEST".to_owned());
let item = option.take();
println!("Option: {:?}", option);
println!("Item: {:?}", item);
Enter fullscreen mode Exit fullscreen mode

and we get

Option: None
Item: Some("TEST")
Enter fullscreen mode Exit fullscreen mode

This take method here and the std::mem::replace function are examples of things I wish I'd known about sooner in my journey learning Rust. Both are extremely useful and can get you out of some tight spots in places you sometimes otherwise wouldn't know how to, keep them in mind. So often I had an Option that was Some(s), I could get references to s easy enough but taking ownership evaded me when I wanted to.

Let's finish off our calculator now anyway.

Back to the Calculator

Let's begin by changing the return type of calculate to an Option where we agree that if we return Some(num) then num is the evaluated result and if we return None then no result was evaluated. We have the following now:

fn calculate(calculation: &str, variables: &HashMap<&str, i64>) -> Option<i64> {
    <snip>
}
Enter fullscreen mode Exit fullscreen mode

To fix the main function that is causing this we change the part calling calculate as follows:

Ok(line) => match calculate(&line, &variables) {
    Some(num) => println!("Result: {}", num),
    None => {}
},
Enter fullscreen mode Exit fullscreen mode

and we can fix the first test as follows (with similar fixes for the others):

#[test]
fn test_addition() {
    let variables: HashMap<&str, i64> = HashMap::new();
    let res = super::calculate("1 + 2", &variables);
    assert_that(&res).is_equal_to(Some(3));
}
Enter fullscreen mode Exit fullscreen mode

and we fix test_setting_variables test such that it checks we get None back as follows:

#[test]
fn test_setting_variables() {
    let variables: HashMap<&str, i64> = HashMap::new();
    let res = super::calculate("abc = 5", &variables);
    assert_that(&res).is_equal_to(None);
    assert_that(variables.get("abc").unwrap()).is_equal_to(5);
}
Enter fullscreen mode Exit fullscreen mode

and finally we fix the calculate function as follows:

fn calculate(calculation: &str, variables: &HashMap<&str, i64>) -> Option<i64> {
    let vec = tokenize(calculation);
    let num1 = get_value(vec[0], variables);
    let num2 = get_value(vec[2], variables);
    let op = vec[1];
    match op {
        "+" => Some(num1 + num2),
        "-" => Some(num1 - num2),
        "*" => Some(num1 * num2),
        "/" => Some(num1 / num2),
        _ => {
            panic!("Bad operator");
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Now we need to refactor calculate a little bit further. We need to perform a calculation in the case we have one of the 4 operators +, -, * or / but in the case of an equals sign we want to do something else. We'll try and refactor this a bit as follows:

fn calculate(calculation: &str, variables: &HashMap<&str, i64>) -> Option<i64> {
    let vec = tokenize(calculation);
    let num2 = get_value(vec[2], variables);
    let op = vec[1];
    match op {
        "+" => Some(get_value(vec[0], variables) + num2),
        "-" => Some(get_value(vec[0], variables) - num2),
        "*" => Some(get_value(vec[0], variables) * num2),
        "/" => Some(get_value(vec[0], variables) / num2),
        _ => {
            panic!("Bad operator");
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

I've also refactored out the check on whether something is a variable as this is useful here too:

fn is_variable(token: &str) -> bool {
    token.chars().next().unwrap().is_ascii_alphabetic()
}

fn get_value(token: &str, variables: &HashMap<&str, i64>) -> i64 {
    match is_variable(token) {
        true => *variables.get(token).unwrap(),
        false => token.parse::<i64>().expect("token should be a number"),
    }
}
Enter fullscreen mode Exit fullscreen mode

We can have faith that this is a good refactor as the same 8 tests are still passing. We now just need to implement the one remaining test.

fn calculate(calculation: &str, variables: &HashMap<&str, i64>) -> Option<i64> {
    let vec = tokenize(calculation);
    let num2 = get_value(vec[2], variables);
    let op = vec[1];
    match op {
        "+" => Some(get_value(vec[0], variables) + num2),
        "-" => Some(get_value(vec[0], variables) - num2),
        "*" => Some(get_value(vec[0], variables) * num2),
        "/" => Some(get_value(vec[0], variables) / num2),
        "=" => match is_variable(vec[0]) {
            true => {
                variables.insert(vec[0], num2);
                None
            }
            false => {
                panic!("Bad variable name");
            }
        },
        _ => {
            panic!("Bad operator");
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Now let's try and run this final test and we should be good.

error[E0623]: lifetime mismatch
  --> src/main.rs:38:34
   |
27 | fn calculate(calculation: &str, variables: &HashMap<&str, i64>) -> Option<i64> {
   |                           ----                      ----
   |                           |
   |                           these two types are declared with different lifetimes...
...
38 |                 variables.insert(vec[0], num2);
   |                                  ^^^^^^^ ...but data from `calculation` flows into `variables` here
Enter fullscreen mode Exit fullscreen mode

Oh dear. This error looks bad. We've not covered lifetimes in this article and I don't propose to yet, at least not fully, that's a later article. But basically what's happening here is that we've two references, one under calculation and one under the key for the variables HashMap. Now that we're using the first in the same place as the second, Rust needs to know that what is under the calculation reference will live at least as long as the variables HashMap will, we wouldn't want to free the memory for calculation while it's still being used in variables, memory corruption issues be here.

So suffice is it to say that these "lifetime problems" can occur when working with references like here. In fact even if we understood lifetimes it's not straight forward to fix this problem and we may want to consider just changing tact completely, which is what we're going to do now. We're going to use String instead as the key type in variables and that gets rid of the lifetimes problem. At this point we no longer worry about the underlying key living long enough as the HashMap owns it. In fact it's generally not a bad idea to have the HashMap own the keys like this for this very reason.

So now let's change variables to be of type HashMap<String, i64>. There's a few changes to make, including in the tests and we leave this to the reader, basically everywhere you see HashMap<&str, i64> change it to HashMap<String, i64>. Once you've done this you'll need to change the places that you're adding stuff to variables to add a String instead, use the .to_owned() method for this, e.g.:

        "=" => match is_variable(vec[0]) {
            true => {
                variables.insert(vec[0].to_owned(), num2);
                None
            }
            false => {
                panic!("Bad variable name");
            }
        },
Enter fullscreen mode Exit fullscreen mode

and similarly in the tests.

Now we get an error:

error[E0596]: cannot borrow `*variables` as mutable, as it is behind a `&` reference
  --> src/main.rs:38:17
   |
27 | fn calculate(calculation: &str, variables: &HashMap<String, i64>) -> Option<i64> {
   |                                            --------------------- help: consider changing this to be a mutable reference: `&mut std::collections::HashMap<std::string::String, i64>`
...
38 |                 variables.insert(vec[0].to_owned(), num2);
   |                 ^^^^^^^^^ `variables` is a `&` reference, so the data it refers to cannot be borrowed as mutable
Enter fullscreen mode Exit fullscreen mode

Does make sense actually as we're changing variables, however we've just got a reference, not a mutable reference. Thankfully in our case we can just change this to a mutable reference and after a bit of thought this does make sense to do here. Let's do that, there's a fair amount of error fixing because we need to change all our tests to have variables be mutable and pass a mutable reference to calculate to allow it to change things. We end up with the following code finally.

use std::collections::HashMap;

use rustyline::error::ReadlineError;
use rustyline::Editor;

fn tokenize(string_to_parse: &str) -> Vec<&str> {
    let mut vec = vec![];

    for token in string_to_parse.split_ascii_whitespace() {
        vec.push(token);
    }

    vec
}

fn is_variable(token: &str) -> bool {
    token.chars().next().unwrap().is_ascii_alphabetic()
}

fn get_value(token: &str, variables: &HashMap<String, i64>) -> i64 {
    match is_variable(token) {
        true => *variables.get(token).unwrap(),
        false => token.parse::<i64>().expect("token should be a number"),
    }
}

fn calculate(calculation: &str, variables: &mut HashMap<String, i64>) -> Option<i64> {
    let vec = tokenize(calculation);
    let num2 = get_value(vec[2], variables);
    let op = vec[1];
    match op {
        "+" => Some(get_value(vec[0], variables) + num2),
        "-" => Some(get_value(vec[0], variables) - num2),
        "*" => Some(get_value(vec[0], variables) * num2),
        "/" => Some(get_value(vec[0], variables) / num2),
        "=" => match is_variable(vec[0]) {
            true => {
                variables.insert(vec[0].to_owned(), num2);
                None
            }
            false => {
                panic!("Bad variable name");
            }
        },
        _ => {
            panic!("Bad operator");
        }
    }
}

fn main() {
    let mut rl = Editor::<()>::new();
    let mut variables: HashMap<String, i64> = HashMap::new();
    loop {
        let readline = rl.readline(">> ");
        match readline {
            Ok(line) => match calculate(&line, &mut variables) {
                Some(num) => println!("Result: {}", num),
                None => {}
            },
            Err(ReadlineError::Interrupted) => {
                println!("CTRL-C");
                break;
            }
            Err(ReadlineError::Eof) => {
                println!("CTRL-D");
                break;
            }
            Err(err) => {
                println!("Error: {:?}", err);
                break;
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use std::collections::HashMap;

    use spectral::prelude::*;

    #[test]
    fn test_addition() {
        let mut variables: HashMap<String, i64> = HashMap::new();
        let res = super::calculate("1 + 2", &mut variables);
        assert_that(&res).is_equal_to(Some(3));
    }

    #[test]
    fn test_subtraction() {
        let mut variables: HashMap<String, i64> = HashMap::new();
        let res = super::calculate("2 - 1", &mut variables);
        assert_that(&res).is_equal_to(Some(1));
    }

    #[test]
    fn test_multiplication() {
        let mut variables: HashMap<String, i64> = HashMap::new();
        let res = super::calculate("2 * 3", &mut variables);
        assert_that(&res).is_equal_to(Some(6));
    }

    #[test]
    fn test_division() {
        let mut variables: HashMap<String, i64> = HashMap::new();
        let res = super::calculate("47 / 3", &mut variables);
        assert_that(&res).is_equal_to(Some(15));
    }

    #[test]
    fn test_variables_in_sums() {
        let mut variables: HashMap<String, i64> = HashMap::new();
        variables.insert("abc".to_owned(), 123);
        let res = super::calculate("abc + 5", &mut variables);
        assert_that(&res).is_equal_to(Some(128));
    }

    #[test]
    fn test_parsing_string() {
        let res = super::tokenize("1 + 2");
        let expected = vec!["1", "+", "2"];
        assert_that(&res).is_equal_to(expected);
    }

    #[test]
    fn test_substitute_variable() {
        let mut variables: HashMap<String, i64> = HashMap::new();
        variables.insert("abc".to_owned(), 123);
        let res = super::get_value("abc", &mut variables);
        assert_that(&res).is_equal_to(&123);
    }

    #[test]
    fn test_return_parsed_number() {
        let mut variables: HashMap<String, i64> = HashMap::new();
        let res = super::get_value("123", &mut variables);
        assert_that(&res).is_equal_to(&123);
    }

    #[test]
    fn test_setting_variables() {
        let mut variables: HashMap<String, i64> = HashMap::new();
        let res = super::calculate("abc = 5", &mut variables);
        assert_that(&res).is_equal_to(None);
        assert_that(variables.get("abc").unwrap()).is_equal_to(5);
    }
}
Enter fullscreen mode Exit fullscreen mode

This then passes all tests and I can use it:

> cargo run
   Compiling calculator v0.1.0 (/Users/strotter/work/programming/rust/calculator)
    Finished dev [unoptimized + debuginfo] target(s) in 1.54s
     Running `target/debug/calculator`
>> abc = 1
>> abc + 123
Result: 124
Enter fullscreen mode Exit fullscreen mode

Summary

This is a pretty good outcome and I'm happy with it, it's not perfect but it's a fairly efficient and decent design. I haven't error handled but I believe you now have the skills to do so. What would be good next is to be able to support multiple sums and brackets but that may need to wait for a later date.

We've still to cover a few bits and bobs, the difficult ones in the next article are likely to be lifetimes and closures, both of which you need to understand if you're truly going to be a good Rust programmer. Thanks for reading this far and I'll see you in the next part.

Oldest comments (0)