Lecture: Rust Mains
Dear students:
Today we switch to our final language of the semester: Rust. We will see some new ideas that Rust contributes to programming language landscape, but its primary strength is that it blends together a lot of great ideas that we've already seen without the ascetism of Haskell.
We'll have a look at this upstart of a language. When I was first learning to code, I loved writing lots of little main programs that did silly things. I'm still learning to code, and my love of silly mains persists. So that's how we'll begin. We won't enumerate all the features of the language.
Countdown
Imagine you want a little timer to help you take a break from staring at a screen. Certainly we could call up one of the many apps on your phone or the web, but they pose more opportunities for distraction. We want one that runs in the terminal. Let's write it in Rust. Our main function starts off with a variable marking the current time:
fn main() {
let time = 60;
}
fn main() {
let time = 60;
}Notice there's no explicit type. What kind of typing system do you suppose Rust has? Dynamic or static?
What do I need to do next? Display the time. Tick time downward. Sleep. Repeat. Let's tick first with an arithmetic assignment operator:
fn main() {
let time = 60;
time -= 1;
}
fn main() {
let time = 60;
time -= 1;
}Compilation fails. All variables are immutable unless we opt in to mutability. Immutability means we programmers can be certain that a variable is what it was initialized to. We don't need to look anywhere but the initialization for its value. It also means we can share data between tasks with no concern that they'll interfere like two roommates over the microwave. Compilation succeeds once we modify the variable with mut:
fn main() {
let mut time = 60;
time -= 1;
}
fn main() {
let mut time = 60;
time -= 1;
}Now we add a loop:
fn main() {
let mut time = 60;
while time >= 0 {
time -= 1;
}
}
fn main() {
let mut time = 60;
while time >= 0 {
time -= 1;
}
}There are no parentheses around conditions. If you include them, the compiler will warn you to stop being so noisy.
Let's print:
fn main() {
let mut time = 60;
while time >= 0 {
println!("{}", time);
time -= 1;
}
}
fn main() {
let mut time = 60;
while time >= 0 {
println!("{}", time);
time -= 1;
}
}Rust's format strings are more like Python's than C's. The output looks nice, but we need to slow it down. We call the sleep function, which needs to be imported with a use statement:
use std::thread;
use std::time::Duration;
fn main() {
let mut time = 60;
while time >= 0 {
println!("{}", time);
thread::sleep(Duration::from_millis(1000));
time -= 1;
}
}
use std::thread;
use std::time::Duration;
fn main() {
let mut time = 60;
while time >= 0 {
println!("{}", time);
thread::sleep(Duration::from_millis(1000));
time -= 1;
}
}This timer shows both the current time and all the old times. There's no reason it needs to take up so much output. We don't care about the old times. Let's erase them. We could use Curses, or we could just print a carriage return to bring the cursor back to the beginning of the line. We'll need print! instead of println!. Since one-digit numbers won't overwrite the second digit, we need to pad them out with a formatting flag:
use std::thread;
use std::time::Duration;
fn main() {
let mut time = 60;
while time >= 0 {
print!("\r{:<2}", time);
thread::sleep(Duration::from_millis(1000));
time -= 1;
}
}
use std::thread;
use std::time::Duration;
fn main() {
let mut time = 60;
while time >= 0 {
print!("\r{:<2}", time);
thread::sleep(Duration::from_millis(1000));
time -= 1;
}
}But this switch causes the output to disappear altogether. The problem, like always, is unflushed buffers. When we write data, it doesn't go immediately to its destination. Instead it goes to a buffer in RAM. Only when that buffer gets full does it get flushed onward. Or sometimes newlines cause it to flush. Or we can explicitly flush it, which requires some more imports and also error handling:
use std::thread;
use std::time::Duration;
use std::io::{self,Write};
fn main() {
let mut time = 60;
let mut stdout = io::stdout();
while time >= 0 {
print!("\r{:<2}", time);
stdout.flush().unwrap();
thread::sleep(Duration::from_millis(1000));
time -= 1;
}
}
use std::thread;
use std::time::Duration;
use std::io::{self,Write};
fn main() {
let mut time = 60;
let mut stdout = io::stdout();
while time >= 0 {
print!("\r{:<2}", time);
stdout.flush().unwrap();
thread::sleep(Duration::from_millis(1000));
time -= 1;
}
}There's not a global variable for standard output as we see in other languages, so we have to request a handle to it. Most I/O methods, like flush, have the potential to fail. Rust doesn't have exceptions. Instead it uses enums like Option and Result, which add a secondary state to normal return values. We have to acknowledge the multiple variants of a returned value. The unwrap call causes the process to panic if a failure variant is returned. Panic means exit with a bad status code. We'll see other ways of dealing with optional types in our next examples.
Flashmod
Rust doesn't have exceptions. Execution doesn't jump around. Rather, we use the existing return mechanism for propagating errors. For that to work cleanly, we have a couple of wrapper types for making a type error-able. They are Option and Result. The compiler, through its normal typechecking, verifies that we aren't operating on failed data. We won't get null pointer exceptions. Let's see how to work with these wrapper types by making a little flashcard app for solving modular arithmetic problems.
Rust doesn't ship with a library for generating random numbers, so we'll have to install a package. As soon as a project has dependencies, we are better off using its package manager Cargo than using rustc directly. We build a new Cargo project and add the third-party rand crate (library) with these shell commands:
cargo new flashmod
cargo add rand
cargo new flashmod cargo add rand
For our first task, let's just get a number from the user. This first draft of a utility function prompts the user and reads in a line from the user:
fn promptInt(prompt: String) -> u32 {
print!("{}", prompt);
io::stdout().flush().unwrap();
let mut response = String::new();
let result = io::stdin().read_line(&mut response);
}
fn promptInt(prompt: String) -> u32 {
print!("{}", prompt);
io::stdout().flush().unwrap();
let mut response = String::new();
let result = io::stdin().read_line(&mut response);
}The read_line accepts a mutable string. Why? If it returned a new string every time, that would be a lot of memory allocations. By passing in a parameter, it will reuse memory we have already allocated. The call can fail, so we must examine the returned value to see what happened. Rust gives us several ways to check check and respond to the value:
-
The
matchcommand lets us pattern match on theOkandErrvariants and deal with them separately. -
The
unwrapmethod has a builtin match that returns theOkvalue or panics with a stock error message. -
The
expectmethod is like unwrap, but it lets us define a custom error message. -
The lightweight
?operator propagates the error to the caller.
Standard input failing is pretty serious, and there's not much chance of recovery. Let's unwrap or expect the result.
Parsing is a different story. We expect typos and can recover. Let's use match to handle the two variants that we might get back from parse:
io::stdin().read_line(&mut response).unwrap();
let maybe_int = response.trim().parse::<u32>();
match maybe_int {
Ok(number) => return number,
Err(_) => println!("That wasn't a number."),
}
io::stdin().read_line(&mut response).unwrap();
let maybe_int = response.trim().parse::<u32>();
match maybe_int {
Ok(number) => return number,
Err(_) => println!("That wasn't a number."),
}If they didn't enter a number, we want to loop around and try again. In other languages, we have two options for repeating code: check the condition strictly before or strictly after the iteration code. Rust has a loop expression that lets us put the check in the middle. Here's our final draft with loop:
fn promptInt(prompt: String) -> u32 {
loop {
print!("{}", prompt);
io::stdout().flush().unwrap();
let mut response = String::new();
io::stdin().read_line(&mut response).unwrap();
let maybeInt = response.trim().parse::<u32>();
match maybeInt {
Ok(number) => return number,
Err(_) => println!("That wasn't a number."),
}
}
}
fn promptInt(prompt: String) -> u32 {
loop {
print!("{}", prompt);
io::stdout().flush().unwrap();
let mut response = String::new();
io::stdin().read_line(&mut response).unwrap();
let maybeInt = response.trim().parse::<u32>();
match maybeInt {
Ok(number) => return number,
Err(_) => println!("That wasn't a number."),
}
}
}In the main function we add code to generate and print two random numbers:
fn main() {
let mut generator = rand::rng();
let a = generator.random_range(0..50);
let b = generator.random_range(1..10);
let prompt = format!("{} % {} = ", a, b);
let response = promptInt(prompt);
}
fn main() {
let mut generator = rand::rng();
let a = generator.random_range(0..50);
let b = generator.random_range(1..10);
let prompt = format!("{} % {} = ", a, b);
let response = promptInt(prompt);
}Since there's no newline, we flush the output. Let's give the user ten problems with a for loop:
fn main() {
let mut generator = rand::rng();
for _ in 0..10 {
let a = generator.random_range(0..50);
let b = generator.random_range(1..10);
let prompt = format!("{} % {} = ", a, b);
let response = promptInt(prompt);
}
}
fn main() {
let mut generator = rand::rng();
for _ in 0..10 {
let a = generator.random_range(0..50);
let b = generator.random_range(1..10);
let prompt = format!("{} % {} = ", a, b);
let response = promptInt(prompt);
}
}We don't name the loop element because we never reference it. All that remains is a final conditional to give feedback to the user:
fn main() {
let mut generator = rand::rng();
let mut stdout = io::stdout();
for _ in 0..10 {
let a = generator.random_range(0..50);
let b = generator.random_range(1..10);
let prompt = format!("{} % {} = ", a, b);
let response = promptInt(prompt);
if a % b != response {
println!("Well, no.");
}
}
}
fn main() {
let mut generator = rand::rng();
let mut stdout = io::stdout();
for _ in 0..10 {
let a = generator.random_range(0..50);
let b = generator.random_range(1..10);
let prompt = format!("{} % {} = ", a, b);
let response = promptInt(prompt);
if a % b != response {
println!("Well, no.");
}
}
}Wc
The Unix utility wc counts characters, words, and lines in a file. We run it from the command-line as wc path/to/file.txt. Let's write our own version in Rust. Here are the questions we'll have to answer to implement a solution:
Okay, we're ready to write some code. Let's start by pulling out the path from the command-line arguments.
use std::env;
use std::fs;
fn main() {
let args = env::args();
println!("{:?}", args);
}
use std::env;
use std::fs;
fn main() {
let args = env::args();
println!("{:?}", args);
}The output shows that the executable name is the first parameter. We want the second.
When we call env::args we do not get back a collection. Instead we get an iterator. In Java, iterators have two methods, hasNext and next, that we use to drive forward and eventually terminate. In Rust, we have next but no hasNext. Any guesses why? The next method returns Option. It'll give back the None variant when hasNext would give back false.
To get at the second parameter, we could call next twice. Iterators also have an nth method for fetching the \(n^\textrm{th}\) parameter. Let's call that instead:
fn main() {
let path = env::args().nth(1);
println!("{:?}", path);
}
fn main() {
let path = env::args().nth(1);
println!("{:?}", path);
}The path is wrapped up in an Option variant. Earlier we forced the program to panic if we got a None back. What method did that? unwrap. Instead, we could explicitly handle each variant with a match statement:
fn main() {
match env::args().nth(1) {
Some(path) => println!("{}", path),
None => panic!("Usage: wc <file>"),
}
}
fn main() {
match env::args().nth(1) {
Some(path) => println!("{}", path),
None => panic!("Usage: wc <file>"),
}
}Neat, but the path is only valid inside the first arm. Do we need to nest all our code inside that arm? Imagine if we had five more calls that returned Option. The nesting would get out of hand. Sequences are easier to read than nesting, so let's switch to a match expression that yields the path to the outer scope:
fn main() {
let path = match env::args().nth(1) {
Some(path) => path,
None => panic!("Usage: wc <file>"),
};
}
fn main() {
let path = match env::args().nth(1) {
Some(path) => path,
None => panic!("Usage: wc <file>"),
};
}The second arm exits the process, so it doesn't return anything.
This pattern of trying to get a value and panicking if it's bad is common. It's exactly what expect abstracts away. It's expect:
fn main() {
let path = env::args().nth(1).expect("Usage: wc <file>");
}
fn main() {
let path = env::args().nth(1).expect("Usage: wc <file>");
}That's much easier to read. Let's hand the path off to a function that reads in the file and computes its statistics. We'll have that function return an instance of this struct:
#[derive(Debug)]
struct Statistics {
character_count: usize,
word_count: usize,
line_count: usize,
}
#[derive(Debug)]
struct Statistics {
character_count: usize,
word_count: usize,
line_count: usize,
}Then we frame our helper function. It takes in a borrowed string and gives back an instance of our struct:
fn count(path: &str) -> Statistics {
// ...
}
fn count(path: &str) -> Statistics {
// ...
}The main function will call count like this:
fn main() {
let path = env::args().nth(1).expect("Usage: wc <file>");
let statistics = count(&path);
println!("{:?}", statistics);
}
fn main() {
let path = env::args().nth(1).expect("Usage: wc <file>");
let statistics = count(&path);
println!("{:?}", statistics);
}Inside count we need to read the file. That might fail. For the moment, let's panic if that reading fails:
fn count(path: &str) -> Statistics {
let text = fs::read_to_string(path).expect("Couldn't read file.");
// ...
}
fn count(path: &str) -> Statistics {
let text = fs::read_to_string(path).expect("Couldn't read file.");
// ...
}Most Rust collections have a len method reporting their number of elements. We can use it to get the number of bytes in the string, which is not quite the same as the number of characters. Function split_whitespace gives us an iterator over words, and function lines gives us an iterator over the lines. Iterators don't have a len method, but they do have count. With these methods, we compute our statistics:
fn count(path: &str) -> Statistics {
let text = fs::read_to_string(path).expect("Couldn't read file.");
Statistics {
character_count: text.len(),
word_count: text.split_whitespace().count(),
line_count: text.lines().count(),
}
}
fn count(path: &str) -> Statistics {
let text = fs::read_to_string(path).expect("Couldn't read file.");
Statistics {
character_count: text.len(),
word_count: text.split_whitespace().count(),
line_count: text.lines().count(),
}
}That concludes our first tour of Rust. It sits somewhere between Ruby and Haskell. The compiler knows the types, so it can perform typechecking. Values can be mutable, so we don't have to contort ourselves into recursion like we did in Haskell. It's got a rich standard library and a very friendly package manager and build tool in Cargo. It's hip. It's supposed to make software safer. It forces you to contend with errors either by explicitly panicking or by handling the error variants of returned values.
TODO
Here's your list of things to do before we meet next:
See you next time.
Sincerely,
P.S. It's time for a haiku!