We can declare a tuple by placing our data within parenthesis, ()
.
// Here we have a tuple of type (char, i32, str).let awesome = ('q', 38, "this is great");
We can access the fields of a tuple by tacking on a .
and then providing the respective field’s index, which is referred to as dot notation. Rust uses 0-based indexing, which means that our first field is accessed with .0
.
let cat = ("Meowzer", 10, true);let name = cat.0; // "Meowzer"let age = cat.1; // 10let awake = cat.2; // true
We can destructure a tuple anywhere the Rust syntax allows it. Take a look at the accompanying code for examples.
// This function will return our tuple.fn get_cat() -> (&'static str, f64, bool) {("Meowzer", 2.8, true)}// Destructuring within the variable assignment.let (name, cuteness, is_sleeping) = get_cat();println!("This is our cat {name}.");// We can ignore unused fields with `_`let (name, _, _) = get_cat();println!("This is our cat {name}.");// Destructuring within a match expression:match get_cat() {(name, _, is_sleeping) => {if is_sleeping {println!("{name} is sleeping.");} else {println!("{name} is awake");}}// We can ignore a range of fields with `..`(name, ..) => {println!("{name} is cute when asleep or awake.");}}
A tuple that does not contain any fields, ()
, is its own primitive type in Rust called the unit type. We can think of the unit type as a piece of data without actual data. Its only value is its existence.
This type helps provides a safe way to handle certain situations while avoiding the pitfalls of a “null” type.
let this_variable_exists = ();
struct
sTo declare a struct, we utilize the struct
keyword. Fields are then provided within its declaration block, follow the ‘snake_case’ naming convention, and must be type annotated.
struct Guest {last_name: &'static str,rsvp: bool,costume: bool,}
struct
sWhen we instantiate a struct
, we must provide values for every field. If a field is missing, our code will not compile.
When instantiating a struct’s fields from variables, we can use a shorthand syntax if the variable name is the same as the field.
let guest = Guest {name: "Anand",rsvp: true,costume: false,};// We can declare the field and value simultaneously by first creating variables:let name = "Anand";let rsvp = true;let costume = false;let guest = Guest {name,rsvp,costume,};
impl
BlocksWhen we create a struct, we must define our own custom data type. Declaring a type allows us to make an impl
block to create specialized functions specific to that struct. Functions declared within an impl
block are called methods. Methods can utilize the special input parameter self
to operate directly on the type being implemented. mut self
and &mut self
are also available for accessing the type mutably.
impl Guest {pub fn print_name(&self) {println!("{}", self.name);}pub fn wearing_costume(&mut self) {self.costume = true;}}// We can call methods using dot notationguest1.print_name();// We can also supply the implemented type as a parameterGuest::print_name(&guest1);
Everything in Rust has a type. Some types are so commonly used in programming that they have been baked directly into the language itself. These basic types are called primitives and they do not rely upon the std library.
The bool
primitive is a boolean value, which can be either false
or true
.
let this_is_a_variable = true;// A bool is its own conditional.if this_is_a_variable {println!("yep, its a variable");} else {println!("wrong universe");}
Integer types that begin with an i
are signed and allow negative integers, while unsigned integers begin with a u
. For example, a u8
is an integer that can represent any number from 0 to 255.
Available integer primitives include:
i8
, i16
, i32
, i64
, i128
, isize
u8
, u16
, u32
, u64
, u128
, usize
usize
and isize
will determine the memory size based on the system architecture during compilation. When no type is specified, Rust infers the type to be i32
.
let integer = 102;let negative = -48;// We can type annotate our integers to make sure we can handle large numbers.let byte: u8 = 255;let large: i64 = -9223372036854775808;// We can also specify types as a postfix to the number.let byte = 255u8;let large = -9223372036854775808i64;
Rust has two floating point types of differing precision, f32
and f64
, which are also denoted by their bit size in memory. When no type is specified, Rust infers f64
. Floating point numbers are specified by their inclusion of a decimal point.
let float = 1.2;// Floats do not require a decimal portion.let still_a_float = 1.;// With type annotation.let a_less_precise_float: f32 = 2.83;
A char
is a character, or more specifically, a unicode scalar value. They are specified by enclosing the character within single parentheses.
Since Rust utilizes UTF-8 as an encoding scheme, some single-width characters may be composed of multiple char
s.
let letter = 'q';let accented = 'á';let percent = '%';let checkmark = '✓';// This emoji is more than 1 char in length and cannot be declared as a char literal.// We must instead use a &str.let yourself = "😀";
We can declare an array with square brackets, []
, containing comma-separated values. We can initialize the values of an array from an expression rather than manually defining each value. This takes the form of [expression; length]
and is useful when creating large arrays.
let integers = [1, 2, 3];// This will create an array with 20 chars of value 'e'let many_e = ['e'; 20];// We can use any expression to populate the default value.let initial_value = 'E';let more_e = [initial_value.to_ascii_lowercase(); 20];
We can access values of collections using a special syntax referred to as index expression syntax. This allows us to directly access a single value or a range of values by index. If we try to access an index that does not exist, our code will not compile.
We can access a single value of a collection by placing the index of the desired item within square brackets after the collection, collection[3]
. It is important to remember that array indexing in Rust, as with most languages, is 0-based.
let array_of_chars = ['a', 'b', 'c'];let letter_a = array_of_chars[0]; // 'a'let letter_c = array_of_chars[2]; // 'c'// Rather than directly supplying a number, we can utilize any expression which evaluates to the type `usize`.fn one() -> usize { 1 }let letter_b = array_of_chars[one()]; // 'b'
We can access a range of values from a collection by supplying a beginning and ending index separated by ..
within our index expression. This special syntax is referred to as range syntax. The beginning index is inclusive, and the ending index is exclusive. If we omit the beginning or ending index, the range will continue until the respective beginning or end.
let words = ["rise", "sun", "ship", "to", "sail"];let sunship = &words[1..3]; // ["sun", "ship"]println!("{sunship:?}");let head = &words[..2]; // ["rise", "sun"]println!("{head:?}");let tail = &words[2..]; // ["ship", "to", "sail"]println!("{tail:?}");let everything = &words[..]; // ["rise", "sun", "ship", "to", "sail"];println!("{everything:?}");
When we need a dynamically sized collection, the std library provides us the Vec<T>
type. Vec, commonly called a “Vector,” stores its data in the heap, which allows it to grow or shrink in size. We can construct Vecs using methods such as new()
and from()
, but Rust also provides us the vec![]
macro to allow us to declare a Vec the same way we would an array.
// Initialize a new, empty Veclet new_vec: Vec<char> = vec![];// Initialize with valueslet vec_of_chars = vec!['a', 'b', 'c'];// This is equivalent to the previous example, but utilizing methodslet mut vec_of_chars = Vec::new();vec_of_chars.push('a');vec_of_chars.push('b');vec_of_chars.push('c');
Like arrays, we can use index expressions to access the values of a Vec. Unlike an array, if we try to access an index that does not exist, our code will successfully compile but will panic at runtime. To avoid this, accessing the values on a Vec is generally accomplished through methods such as get()
and first()
. These methods return an Option<T>
which allows us to handle situations where the index does not exist.
let vec_of_chars = vec!['a', 'b', 'c'];let a = vec_of_chars.first(); // Some('a')// Note that the `get()` method does not use 0-based indexinglet c = vec_of_chars.get(2); // Some('c')let f = vec_of_chars.get(9); // None
enum
sTo create an enumeration, often called an enum, we utilize the enum
keyword. Every variant for our enum is placed within its declaration block. Variants follow the ‘PascalCase’ naming convention. We create a value of a specific variant using the ::
operator.
enum InnerPlanet {Mercury,Venus,Earth,Mars,}let home = InnerPlanet::Earth;
enum
sSince an enum
‘s value can only be a single variant, matching on an enum is a very valuable programming pattern in Rust.
When matching on an enum
, all variants must be handled. If we remove one of the match arms in the previous example, our code will not compile. We can use the _
catch-all operator to handle the remaining unspecified variants.
let vacation_location = InnerPlanet::Mercury;match vacation_location {InnerPlanet::Mercury => println!("Bring sun protection."),InnerPlanet::Venus => println!("Quite blue."),InnerPlanet::Earth => println!("Lots to see here."),InnerPlanet::Mars => {println!("Brrr...");println!("Bring a coat!");}}// Using _, catch-all operatormatch vacation_location {InnerPlanet::Earth => println!("Lots to see here."),_ => println!("Lets clean up Earth first..."),}
Enum variants can also contain values. This is possible because enum variants in Rust are structs. We can access a variant’s inner data by destructuring.
enum Meal {Pasta, // Unit structStirFry(Vec<String>), // Tuple structBurrito { // Struct with named fieldsbeans: bool,rice: bool,},}// Destructuringlet dinner = Meal::Burrito {beans: true,rice: false,};match dinner {Meal::Pasta => println!("Too heavy."),Meal::StirFry(veggies) => {println!("{veggies:?}")}Meal::Burrito { beans, rice } => {println!("with beans: {beans}");println!("with rice: {rice}")}}
A String is stored on the heap which allows us to mutate the value at will. While heap memory is never as fast as the stack, the heap allows Rust to automatically resize the allocated memory when needed during runtime.
let empty_string = String::new();let value_string = String::from("Cyan");let mut mutable_string = String::from("Indigo");mutable_string.push_str(" and Maroon.");println!("{mutable_string}");
&str
s?&str
s are immutable slices of bytes of a fixed size and are often referred to as “string slices.” Since &str
s are only a reference to data, our type declarations will always have the preceding &
. Their data is stored on the stack which makes them computationally efficient.
let immutable = "I am a location in memory";// A type annotated str.let value: &str = ", and cannot be mutated.";// A type annotated str with an explicit lifetime.let baked: &'static str = "I am baked into the binary!";println!("{immutable} {value}");println!("{baked}");
Strings are very ergonomic in terms of how we can compose them. Here are some examples of concatenation in Rust.
let a = "Welcome";let b = " to";let c = " the";let d = " show!";let welcome1 = format!("{a}{b}{c}{d}!");let welcome2 = [a,b,c,d,"!"].concat();let welcome3 = a.to_string() + b + c + d + "!";// We can even append to a mutable String with the `+=` operator.let mut welcome4 = welcome3;welcome4 += " Lets have some fun!";
We can turn a &str
into a String using the String::from()
or to_string()
methods. Both are commonly utilized in Rust and using one over the other is a matter of personal preference.
To utilize a String as a &str, we only have to reference the value.
// Converting from &str to Stringlet pineapple = "pineapple";let new_string = String::from(pineapple);let new_string = pineapple.to_string();// Converting from String to &strfn say_fruit(name: &str) {println!("{name}")}let papaya = String::from("Papaya");say_fruit(&papaya);