Understanding object-oriented programming is a must for any developer. Object-oriented programming involves creating classes, which act as descriptions or blueprints of an object. The object is typically made up of several variables or functions.
In languages like C, Go, and Rust, classes are not a feature. Instead, these languages use structs, which define only a group of properties. While structs don’t allow you to define methods, both Rust and Go define functions in a way that provides access to structs.
In this tutorial, we’ll learn the basics of how structs operate in Rust. Let’s get started!
Rust, a programming language created by Mozilla, fills a similar role to C by being a fast, low-level language that uses modern syntax patterns and a central package manager.
In the code below, we’ll write a simple struct for a Cat
type that includes the properties name
and age
. Once we define our struct, we’ll define our main function.
We’ll create a new string
and a new instance of the struct, passing it the name
and age
properties. We’ll print the entire struct and interpolate its properties in a string. For name
, we’ll use Scratchy
. For age
, we’ll use 4
:
// This debug attribute implements fmt::Debug which will allow us // to print the struct using {:?} #[derive(Debug)] // declaring a struct struct Cat { // name property typed as a String type name: String, // age typed as unsigned 8 bit integer age: u8 } fn main() { // create string object with cat's name let catname = String::from("Scratchy"); // Create a struct instance and save in a variable let scratchy = Cat{ name: catname, age: 4 };
Note that we’re using the derive
attribute, which we’ll cover in detail later, to automate the implementation of certain traits on our struct. Since we derive the debug
trait, we can print the entire struct using {:?}
:
// using {:?} to print the entire struct println!("{:?}", scratchy); // using individual properties in a String println!("{} is {} years old!", scratchy.name, scratchy.age); }
There are several important things to note in this section. First, as with any value in Rust, each property in the struct must be types
. Additionally, be sure to consider the difference between a string (a string object or struct) and &str
(a pointer to a string). Since we’re using the string type, we have to create a string from a proper string literal.
derive
attributeBy default, structs aren’t printable. A struct must implement the stc::fmt::debug
function to use the {:?}
formatter with println!
. However, in our code example above, we used the derive(Debug)
attribute instead of implementing a trait manually. This attribute allows us to print out structs for easier debugging.
Attributes act as directives to the compiler to write out the boilerplate. There are several other built in derive
attributes in Rust that we can use to allow the compiler to implement certain traits for us:
[#derive(hash)]
: converts the struct into a hash[#derive(clone)]
: adds a clone method to duplicate the struct[#derive(eq)]
: implements the eq
trait, setting equality as all properties having the same valueWe can create a struct with properties, but how can we tie them to functions as we do classes in other languages?
Rust uses a feature called traits, which define a bundle of functions for structs to implement. One benefit of traits is you can use them for typing. You can create functions that can be used by any structs that implement the same trait. Essentially, you can build methods into structs as long as you implement the right trait.
Using traits to provide methods allows for a practice called composition, which is also used in Go. Instead of having classes that typically inherit methods from one parent class, any struct can mix and match the traits that it needs without using a hierarchy.
Let’s continue our example from above by defining a Cat
and Dog
struct. We’d like for both to have a Birthday
and Sound
function. We’ll define the signature of these functions in a trait called Pet
.
In the example below, we’ll use Spot
as the name
for Dog
. We use goes Ruff
as Sound
and 0
for age
. For Cat
, we’ll use goes Meow
as the sound and 1
for age
. The function for birthday is self.age += 1;
:
// Create structs #[derive(Debug)] struct Cat { name: String, age: u8 } #[derive(Debug)] struct Dog { name: String, age: u8 } // Declare the struct trait Pet { // This new function acts as a constructor // allowing us to add additional logic to instantiating a struct // This particular method belongs to the trait fn new (name: String) -> Self; // Signature of other functions that belong to this trait // we include a mutable version of the struct in birthday fn birthday(&mut self); fn sound (&self); } // We implement the trait for cat // we define the methods whose signatures were in the trait impl Pet for Cat { fn new (name: String) -> Cat { return Cat {name, age: 0}; } fn birthday (&mut self) { self.age += 1; println!("Happy Birthday {}, you are now {}", self.name, self.age); } fn sound(&self){ println!("{} goes meow!", self.name); } } // We implement the trait for dog // we only define sound. Birthday and name are already defined impl Pet for Dog { fn new (name: String) -> Dog { return Dog {name, age: 0}; } fn birthday (&mut self) { self.age += 1; println!("Happy Birthday {}, you are now {}", self.name, self.age); } fn sound(&self){ println!("{} goes ruff!", self.name); } }
Notice we define a new method that acts like a constructor. Instead of creating a new Cat
like we did in our previous snippet, we can just type our new variable!
When we invoke the constructor, it will use the new implementation of that particular type of struct. Therefore, both Dog
and Cat
will be able to use the Birthday
and Sound
functions:
fn main() { // Create structs using the Pet new function // using the variable typing to determine which // implementation to use let mut scratchy: Cat = Pet::new(String::from("Scratchy")); let mut spot: Dog = Pet::new(String::from("Spot")); // using the birthday method scratchy.birthday(); spot.birthday(); // using the sound method scratchy.sound(); spot.sound(); }
There are several important things to note about traits. For one, you must define the function for each struct that implements the trait. You can do so by creating default definitions in the trait definition.
We declared the structs using the mut
keyword because structs can be mutated by functions. For example, birthday
increments age and mutates the properties of the struct, therefore, we passed the parameter as a mutable reference to the struct (&mut self)
.
In this example, we used a static method to initialize a new struct, meaning the type of the new variables is determined by the type of struct.
Sometimes, a function may return several possible structs, which occurs when several structs implement the same trait. To write this type of function, just type the return value of a struct that implements the desired trait.
Let’s continue with our earlier example and return Pet
inside a Box
object:
// We dynamically return Pet inside a Box object fn new_pet(species: &str, name: String) -> Box<dyn Pet> {
In the example above, we use the Box
type for the return value, which allows us to allocate enough memory for any struct implementing the Pet
trait. We can define a function that returns any type of Pet
struct in our function as long as we wrap it in a new Box
.
We create a function that instantiates our Pet
without specifying age
by passing a string of the type Pet
and name
. Using if
statements, we can determine what type of Pet
to instantiate:
if species == "Cat" { return Box::new(Cat{name, age: 0}); } else { return Box::new(Dog{name, age: 0}); } }
The function returns a Box
type, which represents memory being allocated for an object that implements Pet
. When we created Scratchy
and Spot
, we no longer had to type the variables. We laid out the logic explicitly in the function where a Dog
or Cat
would be returned:
fn main() { // Create structs using the new_pet method let mut scratchy = new_pet("Cat", String::from("Scratchy")); let mut spot = new_pet("Dog", String::from("Spot")); // using the birthday method scratchy.birthday(); spot.birthday(); // using the sound method scratchy.sound(); spot.sound(); }
We have learned the following about structs in Rust:
derive
attribute allows us to implement certain traits in our structs with easeNow, we can implement typical object-oriented design patterns in Rust using composition over inheritance.
Debugging Rust applications can be difficult, especially when users experience issues that are hard to reproduce. If you’re interested in monitoring and tracking the performance of your Rust apps, automatically surfacing errors, and tracking slow network requests and load time, try LogRocket.
LogRocket is like a DVR for web and mobile apps, recording literally everything that happens on your Rust application. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app’s performance, reporting metrics like client CPU load, client memory usage, and more.
Modernize how you debug your Rust apps — start monitoring for free.
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowLearn how to manage memory leaks in Rust, avoid unsafe behavior, and use tools like weak references to ensure efficient programs.
Bypass anti-bot measures in Node.js with curl-impersonate. Learn how it mimics browsers to overcome bot detection for web scraping.
Handle frontend data discrepancies with eventual consistency using WebSockets, Docker Compose, and practical code examples.
Efficient initializing is crucial to smooth-running websites. One way to optimize that process is through lazy initialization in Rust 1.80.