This covers both section (1) and section (2) of the course.
Here is a consolidated list of unique keywords, combining terms that differ only in capitalization or singular/plural forms:
usize
isize
We're going to simulate a collection of playing cards.
We want a new
, shuffle
and deal
functionality.
To create a new project:
# Creating a new deck project $ cargo new deck $ cd deck $ cargo run
You can add
cargo run -q
to run a quiet project without the extra information.
Before going too far, some things to note about the code:
fn main() { println!("Hello, world!"); }
println!
uses a macro. We'll talk about this later.We want to store some data and attach some functionality to it. A good tool for this is a struct
. You can think of them as similar to "classes" in other languages.
struct Deck { cards: Vec<String>, }
Here we introduce vectors. They are like an array that can grow/shrink in size. Rust also has arrays, but they have fixed length.
We can create an instance as so:
let deck = Deck { cards: vec![] };
Deck { cards: vec![] }
is a struct literal.An equivalent for vec![]
is Vec::new()
. Both are the same.
While structs in Rust share some similarities with classes in other programming languages, there are important differences. Let me explain:
Similarities:
Key differences:
new()
by convention.A good analogy for a struct could be a blueprint for a house. Just as a blueprint defines the structure and layout of a house, a struct defines the structure and layout of data. The blueprint specifies where rooms go (like fields in a struct), but it doesn't include the actual furniture or decorations (which would be like the data stored in an instance of the struct).
This analogy works because:
The statement "An equivalent for vec![] is Vec::new(). Both are the same." is true because both expressions create an empty vector in Rust, but they do so in slightly different ways. Let me break this down:
Vec::new()
: This is a direct call to the new() associated function of the Vec struct.
It creates a new, empty vector with no allocated capacity.vec![]
: This is a macro invocation.The vec!
macro is a convenience macro provided by the Rust standard library.
When called with empty square brackets, it also creates a new, empty vector.
The reason they are equivalent is that the vec![]
macro, when expanded, essentially calls Vec::new()
under the hood. The macro exists to provide a more concise syntax and to allow for easy initialization with values.
Key points:
Both create an empty Vec<T>
where T
is inferred from context.
Both result in a vector with zero length and zero capacity.
The performance characteristics are identical.
The type of the resulting vector is the same.
It's worth noting that while they are functionally equivalent when creating empty vectors, the vec!
macro is more versatile. It can also be used to create pre-populated vectors, like vec![1, 2, 3]
, which Vec::new()
cannot do directly.
At this point, we have this code:
struct Deck { cards: Vec<String>, } fn main() { let deck = Deck { cards: vec![] }; println!("Here's your deck: {}", deck); }
However, deck
is getting an error about "not implementing some trait thing".
In general, following the tips, we end up with this code:
#[derive(Debug)] struct Deck { cards: Vec<String>, } fn main() { let deck = Deck { cards: vec![] }; println!("Here's your deck: {:?}", deck); }
If we run cargo run
, we get:
Here's your deck: Deck { cards: [] }
The statement #[derive(Debug)]
defines attributes for the Deck struct. These give the rust compiler some extra instructions on how to process the struct.
In our case, we are providing the derive attribute. It specifies which traits to automatically implement for this struct.
We want to derive the Debug trait. This trait is a set of functions.
So what's happening behind the scenes? We can imagine that we are saying "hey compiler, automatically add all of the 'Debug' functions to this struct". It will add those helpers behind the scenes using the compiler.
What it does:
The derive
attribute automatically implements the Debug
trait for your Deck
struct. This trait provides a way to format the struct for debugging purposes, allowing you to print it or use it in debug statements.
Behind the scenes:
When you use #[derive(Debug)]
, the Rust compiler generates an implementation of the Debug
trait for your struct. This saves you from having to manually implement the fmt::Debug
trait yourself.
Mental model:
Think of #[derive(Debug)]
as a code generator. It's like telling the compiler, "Please write the code to make this struct printable for debugging purposes."
Analogy: A good analogy might be a blueprint copier in an architect's office.
#[derive(Debug)]
attribute is like a special copier that not only copies your blueprint but also automatically adds standardized annotations and labels to make it easier for others to understand and inspect.derive(Debug)
saves the programmer time by automatically implementing debug formatting.What it enables:
After deriving Debug
, you can:
println!("{:?}", deck);
to print a debug representation of your Deck
.Debug
trait, like certain error handling scenarios.Generated code: While you don't see it, the compiler essentially generates an implementation similar to this:
impl fmt::Debug for Deck { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("Deck") .field("cards", &self.cards) .finish() } }
This automatic derivation is part of Rust's philosophy of making common operations easy and reducing boilerplate code. It's particularly useful for basic traits like Debug
, Clone
, Copy
, etc., where the implementation is often straightforward and mechanical.
We've quickly covered Vector vs Array before, but let's cover some more.
In our case of cards, it makes more sense to use arrays instead of vectors for this.
Instead of this:
// List of suits let suits = vec!["Hearts", "Diamonds", "Clubs", "Spades"]; // List of values let values = vec![ "2", "3", "4", "5", "6", "7", "8", "9", "10", "Jack", "Queen", "King", "Ace", ];
We will do this:
// List of suits let suits = ["Hearts", "Diamonds", "Clubs", "Spades"]; // List of values let values = [ "2", "3", "4", "5", "6", "7", "8", "9", "10", "Jack", "Queen", "King", "Ace", ];
In the vector example, we get type
Vec<&str>
, whereas for the array example we get type[&str; 4]
where 4 is the length of the array (different for ourvalues
array of course).
let mut cards = vec![] // Double for loop to create a deck of cards for suit in suits { for value in values { let card = format!("{} of {}", value, suit); cards.push(card); } }
Bindings are immutable by default.
Use the let mut
key word when you know that you need to mutate some state.
We're going to clean up the current code:
#[derive(Debug)] struct Deck { cards: Vec<String>, } fn main() { // List of suits let suits = ["Hearts", "Diamonds", "Clubs", "Spades"]; // List of values let values = [ "2", "3", "4", "5", "6", "7", "8", "9", "10", "Jack", "Queen", "King", "Ace", ]; let mut cards = vec![]; // Double for loop to create a deck of cards for suit in suits { for value in values { let card = format!("{} of {}", value, suit); println!("{}", card); cards.push(card); } } let deck = Deck { cards }; println!("Here's your deck: {:#?}", deck); }
The aim is to have something like Deck::new()
do that work for us.
We are going to do that using a inherent implementation:
impl Deck { fn new() -> Deck { // TODO } }
The return type annotation -> Deck
helps Rust know what type is being returned.
More often though, you will see Self
used as a reference to the parent implementation block.
impl Deck { fn new() -> Self { // TODO } }
We can shift around our existing to do this:
#[derive(Debug)] struct Deck { cards: Vec<String>, } impl Deck { fn new() -> Self { // List of suits let suits = ["Hearts", "Diamonds", "Clubs", "Spades"]; // List of values let values = [ "2", "3", "4", "5", "6", "7", "8", "9", "10", "Jack", "Queen", "King", "Ace", ]; let mut cards = vec![]; // Double for loop to create a deck of cards for suit in suits { for value in values { let card = format!("{} of {}", value, suit); println!("{}", card); cards.push(card); } } let deck = Deck { cards }; return deck; } } fn main() { let deck = Deck::new(); println!("Here's your deck: {:#?}", deck); }
Some more on inherent implementations:
Associated functions are identical in other languages to "class methods". Our new
declaration is an example of this. Examples could include full_deck()
, with_n_cards(10)
or empty_deck()
to bind a variable to a particular instance.
Methods operate on a specific instance of a struct. That will be like our fn shuffle(&self)
that we will be writing out soon. Examples include functionality to shuffle cards, add a card, remove a card, check if a card exists.
Certainly! Let's break down associated functions and methods in Rust, and I'll provide an analogy to help you remember the difference.
Associated Functions:
self
parameter.impl
blocks, just like methods.Methods:
self
, &self
, or &mut self
as their first parameter.Here's a simple code example to illustrate:
struct Rectangle { width: u32, height: u32, } impl Rectangle { // This is an associated function fn new(width: u32, height: u32) -> Rectangle { Rectangle { width, height } } // This is a method fn area(&self) -> u32 { self.width * self.height } } fn main() { // Using the associated function let rect = Rectangle::new(10, 20); // Using the method println!("Area: {}", rect.area()); }
Imagine a car factory:
Associated Functions (The Factory):
new
function above).CarFactory::create_new_car()
Methods (The Car):
my_car.start_engine()
Key points to remember:
This analogy helps illustrate why we use associated functions for things like constructors (new
) - because we're asking the "factory" to create a new instance for us, rather than operating on an existing instance.
Firstly, we can update our associated function new
to use an implicit returns:
#[derive(Debug)] struct Deck { cards: Vec<String>, } impl Deck { fn new() -> Self { // List of suits let suits = ["Hearts", "Diamonds", "Clubs", "Spades"]; // List of values let values = [ "2", "3", "4", "5", "6", "7", "8", "9", "10", "Jack", "Queen", "King", "Ace", ]; let mut cards = vec![]; // Double for loop to create a deck of cards for suit in suits { for value in values { let card = format!("{} of {}", value, suit); println!("{}", card); cards.push(card); } } Deck { cards } } } fn main() { let deck = Deck::new(); println!("Here's your deck: {:#?}", deck); }
Returning Deck {cards}
with no semicolon is an example of using an implicit return. It returns the last executed expression.
Crate = Package.
For example, "The Rust Standard Library" is a crate included with every project.
We will use a random number generator.
We can use cargo add <pkg>
to add an external crate.
We can use https://crates.io
# Add rand $ cargo add rand
This will also update your Cargo.toml
file.
Code in crates + programs is organized into modules.
Every crate has a root module and may have some additional submodules.
Every crate we install also obeys this.
For our case, we will use thread_rng()
from the root module, and the Trait SliceRandom
from a submodule.
Note: using modules from other crates is a little bit different to how we use submodules from our own project. We need to declare mod <submodule>
for our own submodules that we want to use.
mod oursubmodule; use rand::{thread_rng, seq::SliceRandom};
We implement our shuffle
method as the following:
fn shuffle(&mut self) { let mut rng = thread_rng(); self.cards.shuffle(&mut rng); }
fn shuffle(&mut self) { let mut rng = thread_rng(); self.cards.shuffle(&mut rng); }
fn shuffle(&mut self)
:
shuffle
.&mut self
means it takes a mutable reference to the instance it's called on. This allows the method to modify the Deck
.let mut rng = thread_rng();
:
thread_rng()
is a function from the rand
crate that returns a thread-local random number generator.rng
.self.cards.shuffle(&mut rng);
:
self.cards
refers to the Vec<String>
inside the Deck
struct..shuffle()
is a method provided by the rand
crate for Vec
types.&mut rng
).Now, let's dive deeper into how self.cards.shuffle(&mut rng)
works:
The shuffle
method is implemented by the rand
crate for slices (which includes vectors).
It uses the Fisher-Yates shuffle algorithm (also known as Knuth shuffle) internally. This algorithm works by iterating through the array from the last element to the first:
By passing &mut rng
, we're giving the shuffle algorithm a source of randomness to use when deciding which elements to swap.
This process effectively randomizes the order of the cards in the vector.
An analogy to understand this process:
Imagine you have a deck of cards laid out in order. To shuffle them:
This is essentially what self.cards.shuffle(&mut rng)
is doing, but very quickly and with a more sophisticated "die" (the random number generator).
The beauty of using the rand
crate's shuffle
method is that it implements this algorithm efficiently and correctly, saving you from having to write and debug this logic yourself.
What's happening is that the rand
crate provides an extension trait for slices (which includes vectors). This trait is called SliceRandom
.
When you use use rand::seq::SliceRandom;
at the top of your file, you're bringing this trait into scope.
This trait provides additional methods for types that can be treated as slices, including vectors. One of these methods is shuffle
.
In Rust, when a trait is in scope, you can use its methods on types that implement that trait, which is why you can call shuffle
on self.cards
.
So, it's not that Vec
inherits these functions, but rather that the SliceRandom
trait extends the functionality of slices (and thus vectors) when it's in scope.
This is a powerful Rust feature called "extension traits" or sometimes referred to as the "extension pattern". It allows libraries to add functionality to existing types without modifying their original implementation.
To make this clearer, you could write the use
statement more explicitly:
use rand::seq::SliceRandom;
Then, in your shuffle
method, you're implicitly using this trait:
fn shuffle(&mut self) { let mut rng = thread_rng(); SliceRandom::shuffle(&mut self.cards, &mut rng); }
This is equivalent to self.cards.shuffle(&mut rng);
, but it makes it more apparent that we're using a method provided by the SliceRandom
trait.
This pattern allows for great flexibility and modularity in Rust's design, enabling libraries to extend the functionality of types they don't own, without affecting the original type's implementation.
In Rust, all numbers have a type associated with them.
The three category prefixes:
There are some types isize
and usize
that are a bit special. Check extra credit.
We will make the use of a vector method split_off
. From the docs:
Splits the collection into two at the given index.
Returns a newly allocated vector containing the elements in the range
[at, len)
. After the call, the original vector will be left containing the elements[0, at)
with its previous capacity unchanged.
Our implementation will look like this:
fn deal(&mut self, num_cards: usize) -> Vec<String> { self.cards.split_off(self.cards.len() - num_cards) }
We can update our main
function to the following:
fn main() { let mut deck = Deck::new(); deck.shuffle(); let cards = deck.deal(3); println!("Here's your cards: {:#?}", cards); }
An when we run cargo run
, we will get some output at the end:
Here's your cards: [ "Jack of Diamonds", "Queen of Diamonds", "2 of Clubs", ]
At this point, we actually have a bug when we use deal
values greater than our cards length, we'll have an issue.
thread 'main' panicked at src/main.rs:39:30: attempt to subtract with overflow note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Error handling is going to be delegated until later in another project. Just note that you will need to handle that.
usize
and isize
are indeed special integer types in Rust. Let me explain their significance and use cases:
Definition:
usize
: An unsigned integer typeisize
: A signed integer typeSize: The key characteristic of these types is that their size matches the pointer size of the target platform:
Why they're special:
Primary use cases:
For usize
:
For isize
:
usize
Examples:
let arr = [1, 2, 3, 4, 5]; let index: usize = 2; println!("Element at index {}: {}", index, arr[index]); let vec = vec![10, 20, 30]; let length: usize = vec.len(); println!("Vector length: {}", length);
Advantages:
usize
/isize
can run efficiently on different architectures without modificationusize
for indexing prevents certain types of overflow errors that could occur with fixed-size typesConsiderations:
u32
, i64
, etc.i32
or i64
An analogy to understand usize
and isize
:
usize
and isize
are like rulers that automatically adjust their scale to fit the size of your workspace, always providing the maximum possible measurement range for your current system.
To summarize: usize
and isize
are architecture-adaptive integer types that automatically adjust their size to match the pointer width of the system, making them ideal for memory addressing, array indexing, and representing collection sizes in a portable manner.