Programming a Guessing Game
Best way to learn programming languages, and coding in general, is by building projects. This will be our first.
Learning Outcomes
- Learn about
let
andmatch
. - Methods in Rust.
- Associated functions.
- External crates.
Idea
The program generates a random integer between 1-100, prompts the player to enter a guess, and then indicates if too low or too high or correct.
Setting up a New Project
Like we did in Learning Rust set up a new project using Cargo.
To confirm everything’s working nicely, try cargo run
and you should get the usual hello world message.
Processing a guess
First thing I need to do is to prompt the user for input so we can parse it and check if they guessed correctly. Steps:
- Prompt for guess using the
println!
macro. - Take in the answer using standard input (stdin).
- Print it back at them.
use std::io;
fn main() {
println!("Enter a number guess between 1 and 100.");
let mut guess_value = String::new();
io::stdin().read_line(&mut guess_value).expect("Failed to read stdin line input");
println!("You guessed {guess_value}");
}
Standard I/O
We import libraries using use
to bring it into scope. All I/O operations are done using the std::io
this is read as the io
library from the std
library.
Rust refers to this as “bringing into scope” not hard to understand why. Some data types exist in the Standard library but not always included automatically. the set that is included in every program is called the Prelude.
Storing variables
We use the let
keyword to define a variable. For example: let count = 2;
.
The previous example had the mut
keyword as well, this indicates that this variable is Mutable and its value can be changed. ==In Rust, variables are immutable by default, more on this in Learning Rust - Ch.3 (Variables, Data types, Control Flow and Loops)==.
The
::
Path OperatorIs sometimes referred to as the “path separator” and is used denote association for crates and modules.
We then instantiate a new empty string using String::new()
this syntax signals that the new()
function is associated with the String
type. Many data types have new functions.
Back to IO
Now that our string has been made, let’s read the standard input.
Using the io
library’s stdin()
function which returns a std::io::Stdin
type which handles standard input for us, then to read the line using using the read_line
method which we’re also passing &mut guess
as the argument to store (it appends to the string NOT overwrites) the user input in the empty string we created.
// This is all basic stuff so far but Rust's syntax is interesting...
The Reference Operator
&
This operator allows multiple parts of the code base to “borrow” the data without the need to copy it multiple times. It’s one of Rust’s major advantages that contribute to memory safety. References are immutable by default just like variables, so to mutate what they reference we add the
mut
keyword. See Learning Rust - Ch.4 (Ownership, Strings, and Slices) for more.
Printing values
To print variable values with println!
we can use one of two methods:
- Placeholders: This is the idiomatic way to do this in Rust. E.g:
println!("Printing values using placeholders, value: {}", value_here)`
The curly braces serve as placeholders, and followed by a comma-separated list of expressions or variables to print in each placeholder, with their respective orders.
- String Interpolation:
println!("This is another way to do it, {value_here});
Failure and error handling and Enums
You’ve likely noticed the .expect("Failure message")
at the end of the line that reads the standard input.
The read_line
function returns a Result
type, which is an Enumeration (Enum). RustEnum are types that can have one of multiple possible states or values, each state is a called a RustVariant.
The reason we expect a failure is in case that Result is an error.
Results can either be Ok
or Err
, and are handy for indicating if something succeeds or fails.
- The
Ok
variant indicates the operation was successful, and it contains the successfully generated value. - The
Err
variant means the operation failed, and it contains information about how or why the operation failed. // This is common among FunctionalProgramming languages.
Result types have methods, if the read_line
returns an Err
then the expect
method will crash the program and display the message passed to it, but if it returns an Ok
then the expect will simply return the value to use, and that’s how we get the number the user inputs.
Generating random numbers
Rust doesn’t have a random number generator but the rand
crate does, so we’ll use that.
First we need to include this crate in our dependencies, add the following to Cargo.toml
.
[dependencies]
rand = "0.8.5" # the latest longterm version here
If you’re using an IDE like RustRover or even official plugins in VS Code, then this will likely be done automatically.
Semantic Versioning
“Cargo understands Semantic Versioning (sometimes called SemVer), which is a standard for writing version numbers. The specifier
0.8.5
is actually shorthand for^0.8.5
, which means any version that is at least 0.8.5 but below 0.9.0.”
Now back to the numbers
let’s generate one using the rand
crate’s methods.
use rand::Rng;
use std::cmp::Ordering;
use std::io;
fn main() {
println!("Enter a number guess between 1 and 100.");
let mut guess_value = String::new();
let rand_number = rand::rng().random_range(1..=100);
io::stdin()
.read_line(&mut guess_value)
.expect("Failed to read stdin line input");
println!("You guessed {guess_value}");
}
The rng()
function returns a random number generator and then invoke its random_range(start..=end)
, the form start..=end
and is inclusive on the lower and upper bounds, so we need to specify 1..=100 to request a number between 1 and 100.
Comparing values
We can compare if two values are equal or not easily, but for this game we’d be better off using a MatchExpression and the Ordering
crate.
use rand::Rng;
use std::cmp::Ordering;
use std::io;
fn main() {
println!("Enter a number guess between 1 and 100.");
let mut guess_value = String::new();
let rand_number = rand::rng().random_range(1..=100);
io::stdin()
.read_line(&mut guess_value)
.expect("Failed to read stdin line input");
match guess_value.cmp(&rand_number) {
Ordering::Less => println!("Too low."),
Ordering::Greater => println!("Too high."),
Ordering::Equal => println!("You Win!\nThe number was {}", rand_number),
} println!("You guessed {guess_value}");
}
We’ve encountered match expressions before in languages such as Ocaml and Elixir, so I won’t go over them more.
Here's what the docs say about
match
:“A
match
expression is made up of arms. An arm consists of a pattern to match against, and the code that should be run if the value given tomatch
fits that arm’s pattern. Rust takes the value given tomatch
and looks through each arm’s pattern in turn. Patterns and thematch
construct are powerful Rust features: they let you express a variety of situations your code might encounter and they make sure you handle them all.”
Type mismatch
Rust has a strong static type system but also supports type inference. But that doesn’t solve the issue when comparing an i32
(32 bit integer) with a string.
Example:
fn main() {
println!("Enter a number guess between 1 and 100.");
let mut guess_value = String::new();
let rand_number = rand::rng().random_range(1..=100);
io::stdin()
.read_line(&mut guess_value)
.expect("Failed to read stdin line input");
let guess_value: u32 = guess_value.trim().parse().expect("Please type a number!");
match guess_value.cmp(&rand_number) {
Ordering::Less => println!("Too Low!\nThe number was {}", rand_number),
Ordering::Greater => println!("Too High!\nThe number was {}", rand_number),
Ordering::Equal => println!("You Win!\nThe number was {}", rand_number),
}
}
The guess_value
is shadowed with the new value parsed from the string. More on this in Learning Rust - Ch.3 (Variables, Data types, Control Flow and Loops).
The parse
method on strings converts a string to another type, in this case string to i32
integer, and we ensure the user entered a number by chaining an expect
in case the the parse
method returns an Err
result due to the user entering anything but a number.
Looping
Rust’s loop
keyword creates an infinite loop.
To break out of it, preferably on some condition, use the break
keyword.
Error handling
Instead of simply crashing when receiving an Err
, we can handle both scenarios of a possible result.
let guess: u32 = match guess.trim().parse() {
Ok(num) => num,
Err(_) => continue,
};
The match expression allows us to handle both scenarios and act accordingly.
Summary
This chapter and mini project went over the following Rust concepts:
- Variable definition using
let
. - Standard I/O using
std::io
library. - MatchExpression using the
match
keyword. - Functions
- External crates, how to import them with
use
, and update thecargo.toml
file.