Structs and Enums
Structure (struct) is a custom data type that lets you package together multiple related, named values (fields) in a meaniful group similar to object's data attributes. We create an instance of struct by specifying concrete values for each fields in key:value pairs, and access the values using the dot notation. The individual fields cannot be marked as mutable hence the entire instance needs to be declared as mutable.
struct User { active: bool, username: String, email: String, sign_in_count: u64, } fn main(){ let mut user1 = User { active: true, sign_in_count: 1, username: String::from("someone123"), email: String::from("someone@example.com"), }; user1.sign_in_count += 1; println!("user1.username: {}", user1.username); let mut user2 = User { email: String::from("someone456@example.com"), ..user1 // remaining fields of user1 is copied into user2 // string is also copied, not a stack only copy hence, user1 won't be available }; println!("user2.sign_in_count: {}", user2.sign_in_count); // println!("user1.username: {}", user1.username); } // functions can use the field init shorthand syntax rather than repeat each field names fn build_user(email: String, username: String) -> User { User { active: true, username, email, sign_in_count: 1, } }
Structure can be used to define custom types with requirement validation. Group related methods within impl blocks to organize code logically and improve readability.
pub struct Guess { value: i32, } impl Guess { pub fn new(value: i32) -> Guess { if value < 1 || value > 100 { panic!("Guess value must be between 1 and 100, got {value}."); } Guess { value } } pub fn value(&self) -> i32 { //getter self.value } }
Special Structs
Tuple Structs are similar to Tuples, without field names
struct Color(i32, i32, i32); struct Point(i32, i32, i32); fn main() { let black = Color(0, 0, 0); let origin = Point(0, 0, 0); }
unit-like structurs don't have any fields at all, similar to ()
the unit type. These are useful when you need to implement a trait on some type but don’t have any data that you want to store in the type itself.
struct AlwaysEqual; fn main() { let subject = AlwaysEqual; }
Enum
Enumeration (enum) allows to define a type by enumerating its possible variants. Enum give you a way of saying a value is one of a possible set of values, not both at the same time. We can even optionally put data directly into each enum variant as well by defining its associated data type.
#![allow(unused)] fn main() { enum IpAddr { V4(String), V6(String), } let home = IpAddr::V4(String::from("127.0.0.1")); let loopback = IpAddr::V6(String::from("::1")); fn route(ip_kind: IpAddrKind) {} // to call function with either variant }
The standard library define IpAddr
enum with struct as data type instead of string.
A popular enum defined by standard library is Option
included in the prelude, which encodes the common scenario in which a value could be something or it could be nothing (instead of null feature). With this functionality compiler can check whether you have handled all the cases and prevent bugs related to null references. You have to convert an Option<T>
to a T
before you can perform T operations with it. This helps catch one of the most common issues with null: assuming that something isn’t null when it actually is.
#![allow(unused)] fn main() { enum Option<T> { None, Some(T), } let absent_number: Option<i32> = None; let some_number = Some(5); }
Another most common enum is Result that represent either success Ok
or failure Err
.
Methods
All functions defined within an impl block are called associated functions.
Methods are associated function that specifies the behaivors associated with a struct type. Unlike functions they are defined wihtin the context of a struct (or enum or a trait object). Their first parameter is always a reference to the struct instance (self), which represent instance of the struct the method is being called on. We don't have to repeat the type of self in every method. Also all the things we can do with an instance of a type is placed in one impl
block rather than in various places in the library. However, each struct is allowed to have multiple impl blocks.
struct Rectangle { width: u32, height: u32, } impl Rectangle { fn area(&self) -> u32 { // &self is short for self: &Self ; alias for the type self.width * self.height } } fn main() { let rect1 = Rectangle { width: 30, height: 50, }; println!("The area of the rectangle is {}", rect1.area()); }
Unlike C/C++ which uses .
for calling method on object and ->
for calling method on pointer, Rust has automatic referencing and dereferencing. When you call a method, Rust automatically adds in &
, &mut
, or *
so object matches the signature of the method.
p1.distance(&p2);
is equivalent to (&p1).distance(&p2);
Given the receiver and name of a method, Rust can figure out definitively whether the method is reading (&self
), mutating (&mut self
), or consuming (self
).
We can define associated functions that don’t have self as their first parameter (and thus are not methods) eg: is String::from
function that’s defined on the String type. These associated functions are often used for constructors that will return a new instance of the struct. These are often called new, but new isn’t a special name and isn’t built into the language.
impl Rectangle { fn square(size: u32) -> Self { Self { width: size, height: size, } } }
To call this associated function, we use the ::
syntax with the struct name; let sq = Rectangle::square(3);
is an example. This function is namespaced by the struct.
eg: Fibonacci number
#[derive(Debug)] struct Fibonacci { current: u8, previous: u8, } impl Fibonacci { fn new() -> Self { Self { current: 0, previous: 0, } } } impl Iterator for Fibonacci { type Item = u8; fn next(&mut self) -> Option<Self::Item> { if self.current == 0 { self.current = 1; return Some(self.current); } let next_value = self.previous.checked_add(self.current)?; self.previous = self.current; self.current = next_value; Some(self.current) } } fn main() { for fb in Fibonacci::new() { println!("{fb}"); if fb > 30 { break; } } println!("------"); for fb in Fibonacci::new() { println!("{fb}"); } }
Generics
Generics is an abstract stand-ins for concrete types (i32, String) or other properties that allows to reduce code duplication when defining functions, structures, enums and methods.
Generics in functions
#![allow(unused)] fn main() { use std::cmp::PartialOrd; fn largest<T>(list: &[T]) -> &T { let mut largest = &list[0]; for item in list { if item > largest { // won't work for all possible types that T could be largest = item; } } largest } }
is generic over some type T. We can call the function with wither i32 or char values. It uses std::cmp::PartialOrd
trait to enable comparisons
In struct and method definitions, Methods written within an impl that declares the generic type will be defined on any instance of the type, no matter what concrete type ends up substituting for the generic type.
struct Point<T> { // both should be of same type x: T, y: T, } struct Point2<T, U> { // allow two different types x: T, y: U, } impl<T> Point<T> { // can have different name but its conventional fn x(&self) -> &T { &self.x } } impl Point<f32> { fn distance_from_origin(&self) -> f32 { (self.x.powi(2) + self.y.powi(2)).sqrt() } } // when generic parameters aren't always same impl<X1, Y1> Point<X1, Y1> { fn mixup<X2, Y2>(self, other: Point<X2, Y2>) -> Point<X1, Y2> { Point { x: self.x, y: other.y, } } } fn main() { let integer = Point { x: 5, y: 10 }; let float = Point { x: 1.0, y: 4.0 }; let mixed = Point2 { x: 5, y: 4.0 }; println!("p.x = {}", integer.x()); let p1 = Point { x: 5, y: 10.4 }; let p2 = Point { x: "Hello", y: 'c' }; let p3 = p1.mixup(p2); }
In Enum
#![allow(unused)] fn main() { enum Option<T> { Some(T), None, } enum Result<T, E> { Ok(T), Err(E), } }
Rust accomplishes this by performing monomorphization of the code using generics at compile time. Monomorphization is the process of turning generic code into specific code by filling in the concrete types that are used when compiled.
Trait
Traits (similar to interfaces) define functionality/behaviors for a particular type in a generic way. We can use trait bounds to specify that a generic type can be any type that has certain behavior. We can combine traits with generic types to constrain a generic type to accept only those types that have a particular behavior, as opposed to just any type.
Lifetimes
Lifetimes is a variety of generics that give the compiler information about borrowed values and how references relate to each other