In this tutorial we’ll start dealing with more advanced topics in Rust, specifically moving towards an object orientated programming approach. By the end of this tutorial you’ll understand how to make “objects” in Rust using structs
. You’ll learn how to make generic and flexible structs
and functions using generic types. You’ll also learn how to use traits
to build common functionality for multiple data types at a time.
Object Orientated Programming in Rust
In contrast to languages like Python and C++, Rust diverges from class-based inheritance. It emphasizes struct composition and trait-based polymorphism.
Rather than relying on class inheritance, Rust promotes struct
composition, allowing structs
to contain instances of other structs
or types.
traits
, serving as a form of polymorphism, define sets of methods that types can implement, offering shared behaviors across different types without a single inheritance hierarchy.
This trait-based approach fosters modularity and flexibility while ensuring safety and performance.
Structs in Rust
Structs in Rust form the foundation of object-oriented programming (OOP). They can be seen as collections of variables that serve a related purpose or represent a specific context.
Consider the example below:
struct Point3D {
x: f32,
y: f32,
z: f32,
coord_system: String,
}
RustWe’ve created a struct
named Point3D
, representing a point in 3D space.
The struct
is defined by encapsulating member data within curly brackets {}
after naming it.
Within this struct
, we’ve defined fields such as x
, y
, and z
, each having the data type f32
, representing the coordinates in the x, y, and z axes, respectively.
Additionally, there’s a field named coord_system
of type String
, serving to describe the coordinate system.
In Rust, it’s common practice to separate each field with a ,
and a new line for readability.
The presence of a trailing ,
after the last field doesn’t cause a compile-time error and is often used to facilitate future struct modifications.
Methods can be implemented for a struct
in Rust, functioning as functions that the struct
itself can utilize. These methods can modify the struct
, perform actions based on the field data, and more. The impl
keyword is used to define these methods:
impl Point3D {
// Return a new Point3D
fn new() -> Point3D{
Point3D{
x: 0.0_f32,
y: 0.0_f32,
z: 0.0_f32,
coord_system: String::from("cartesian"),
}
}
// Get the magnitude of the Point3D
fn get_magnitude(self :&Self) -> f32{
(self.x.powi(2) + self.y.powi(2) + self.z.powi(2))
.sqrt()
}
// Add a constant value to the Point3D
fn add_constant(self: &mut Self, c : f32) -> (){
self.x += c / 3.0_f32.sqrt();
self.y += c / 3.0_f32.sqrt();
self.z += c / 3.0_f32.sqrt();
}
}
RustIn the code example above, we’ve implemented three methods for the `Point3D` struct.
- The
new
function on line 3 creates and returns a newPoint3D
with default values at the origin (0, 0, 0). To use it:let mut my_point = Point3D::new();
- Line 14 contains the
get_magnitude
method, which accesses the struct’s data without modifying it. It takes a non-mutable reference to itself (&Self
) and returns af32
. To call it:my_point.get_magnitude()
. - The
add_constant
method, defined on line 20, modifies thePoint3D
‘s data using a given value. It requires a mutable reference to itself (&mut Self
) and takes a constant (f32
). Usage example:my_point.add_constant(3.14);
. Note sincef32
implements theCopy
trait, we don’t need to pass by reference since thef32
will be copied when passed to the function.
Combining the above code would give the following usable example:
struct Point3D {
x: f32,
y: f32,
z: f32,
coord_system: String,
}
impl Point3D {
// Return a new Point3D
fn new() -> Point3D{
Point3D{
x: 0.0_f32,
y: 0.0_f32,
z: 0.0_f32,
coord_system: String::from("cartesian"),
}
}
// Get the magnitude of the Point3D
fn get_magnitude(self :&Self) -> f32{
(self.x.powi(2) + self.y.powi(2) + self.z.powi(2))
.sqrt()
}
// Add a constant value to the Point3D
fn add_constant(self: &mut Self, c : f32) -> (){
self.x += c / 3.0_f32.sqrt();
self.y += c / 3.0_f32.sqrt();
self.z += c / 3.0_f32.sqrt();
}
}
fn main(){
let mut my_point = Point3D::new();
let mut a = 25.0;
my_point.add_constant(a);
println!("Magnitude = {}", my_point.get_magnitude());
a += 12.3;
println!("Added = {}", a);
}
RustOn line 32 we use the new
method to get a new empty Point3D
. On line 39 we call the add_constant
method, passing the mutable f32 a
. On line 41 we call get_magnitude
and print the results. On line 42 we add a value to a
, showing that a
wasn’t consumed by add_constant
.
Generic Types
Generic types in Rust bear resemblance to C++ templates. They enable us to write versatile code that isn’t tied to specific data types. This allows for more reusable and adaptable code. Consider the following illustration:
Point2D<T>{
x:T,
y:T,
}
RustHere we have defined a generic Point2D
struct that represents a 2D point in space. This struct is designed to work with any data type, as it utilizes the placeholder type T
for both the x
and y
coordinates. Using this placeholder type allows the struct to remain agnostic to the specific data type used for its coordinates.
The versatility of this generic struct becomes apparent when implementing methods or functionalities that can work universally across various data types.
struct Point2D<T>{
x:T,
y:T,
}
impl <T> Point2D<T>{
fn get_x(self : &Self) -> &T{
&self.x
}
fn get_y(self : &Self) -> &T{
&self.y
}
}
RustIn the above we have implemented two functions assuming a type T
. Each return references of type T
. If we wanted to use these we could run:
struct Point2D<T>{
x:T,
y:T,
}
impl <T> Point2D<T>{
fn get_x(self : &Self) -> &T{
&self.x
}
fn get_y(self : &Self) -> &T{
&self.y
}
}
fn main(){
let my_point :Point2D<f32> = Point2D{x:0.1, y:4.3};
println!("x = : {} ", my_point.get_x());
println!("y = : {} ", my_point.get_y());
}
RustOn line two, we are explicitly specifying the data type as f32
.
We can combine traits and generics to enable custom data types that possess specific traits.
Traits in Rust
traits
in Rust provide a means to define common interfaces that can be implemented by different structs
. They enable struct
types to share behavior or functionality through shared methods.
Lets consider the following. Humans can speak, we can produce noise in a coherent fashion that is understood by others. Let say that Humans
have a function called speak
. Behind the scenes, the human body will need to “do things” to speak, maybe Human::think()
, Human::blow_air()
and Human::open_mouth()
. So when we call Human::speak()
, we may call these three functions behind the scenes. We can define a trait
called CanSpeak
. In order to have this trait
assignable, the type must be able to implement some function speak()
that will return a noise. For humans, this is easy, we already have the Human::speak()
functions.
Consider now we have dogs. While dogs can’t speak like humans can, they can be trained to produce a noise when told to “speak”. In this analogy this would be something like Dog::bark()
. If we wanted to say that dogs have the CanSpeak
trait
, we would need to map the Dog::bark()
method to a speak()
function.
If both Humans
and Dogs
have the CanSpeak
trait
we can write functions that can take a generic data type (T
) which implements the CanSpeak
trait
, knowing that T
will always have some callable function T::speak()
that we can utilize. Let’s see some examples of this.
It’s possible that the first time you’ll encounter traits
is when try to print custom structs
. Consider the following example:
struct Point2D <T> {
x: T,
y: T,
}
fn main(){
let mut my_point = Point2D::<f32>{x : 2.3, y: 5.7};
println!("{}", my_point);
}
RustHere we define a Point2D
struct
which takes a generic type T
. On line 9 we specify a f32
when creating a new type. On line 11 we try to print my_point
. If we ran this we would see an error like:
error[E0277]: `Point2D<f32>` doesn't implement `std::fmt::Display`
--> src/main.rs:11:20
|
11 | println!("{}", my_point);
| ^^^^^^^^ `Point2D<f32>` cannot be formatted with the default formatter
|
= help: the trait `std::fmt::Display` is not implemented for `Point2D<f32>`
= note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead
= note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)
For more information about this error, try `rustc --explain E0277`.
error: could not compile `testbed` (bin "testbed") due to previous error
RustThis is telling us that Point2D
doesn’t implement the trait
std::fmt::Display
. This makes sense, if we want to print our custom struct
we need to know what it is that we’re printing. We can implement this trait
as follows:
use std::fmt;
struct Point2D <T> {
x: T,
y: T,
}
impl <T : fmt::Display> fmt::Display for Point2D<T>{
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "({}, {})", self.x, self.y)
}
}
fn main(){
let mut my_point = Point2D::<f32>{x : 2.3, y: 5.7};
println!("{}", my_point);
}
RustOn line 1 we are using an external crate from the standard library called fmt
. As the name might suggest fmt
has lots of formatting functionality. It also has the fmt::Display
trait
that we want to implement for our struct
. We implement this for Point2D
on lines 7-12.
Notice the syntax on line 7. Since we’re using a generic type T
, we want to make sure that this generic type can be displayed so we require that type T also implements the fmt::Display
trait
. We can add multiple requirements like <T: fmt::Display + fmt::Debug + CanSpeak>
. This would require that type T implement the fmt::Display
, fmt::Debug
and the CanSpeak
traits
. The fmt::Display
trait
has a single function fmt
which takes a immutable reference to itself and a mutable reference to a fmt::Formatter
type (we don’t need to worry about this) and returns a fmt::Result
type (again don’t worry). We use the write!
macro to format the string to print “(x, y)”. When we print on line 18 we would see:
(2.3, 5.7)
RustSo we can now pass Point2D
to any function that takes any generic data type that implements the fmt::Display
trait
.
Let’s write our own trait
:
trait PointLike{
fn get_magnitude(self: &Self) -> f32;
fn add(self: &mut Self, c : f32) -> ();
}
RustHere we have a trait
called PointLike
. In order to be “PointLike
” a type must be able to call get_magnitude
and add
. When defining the trait
we sketch out the functions, adding what arguments to take and what is returned. get_magnitude
takes an immutable reference to Self
and returns a f32
, while add
takes a mutable reference to Self
and a f32
and returns nothing (()
). We don’t need to know what get_magnitude
and add
do under the hood, just the arguments they take and the return type.
We can implement the PointLike
trait
for the Point2D
struct
:
struct Point2D {
x: f32,
y: f32,
}
impl PointLike for Point2D{
fn get_magnitude(self: &Self) -> f32 {
(self.x.powi(2) + self.y.powi(2)).sqrt()
}
fn add(self: &mut Self, c : f32) -> () {
self.x += c / 2.0_f32.sqrt();
self.y += c / 2.0_f32.sqrt();
}
}
RustHere we haven’t implemented any other functions for Point2D
we’re using the impl
for PointLike
to define the functions. Let’s also define a Point3D
:
struct Point3D{
x: f32,
y: f32,
z: f32,
}
impl Point3D{
fn new() -> Point3D{
Point3D{x:0., y:0., z:0.}
}
fn add_constant(self : &mut Self, c :f32 ) -> (){
self.x += c / 3.0_f32.sqrt();
self.y += c / 3.0_f32.sqrt();
self.z += c / 3.0_f32.sqrt();
}
fn abs(self : &Self) -> f32{
(self.x.powi(2) + self.y.powi(2) + self.z.powi(2)).sqrt()
}
}
RustHere we’ve defined the Point3D
and implemented some functionality. We’ll notice that the add_constant
and abs
functions are similar to the add
and get_magnitude
functions required by the PointLike
trait
. We can extended Point3D
to be PointLike
by implementing this trait
and simply mapping the functions. Something like:
impl PointLike for Point3D{
fn add(self: &mut Self, c : f32) -> () {
self.add_constant(c);
}
fn get_magnitude(self: &Self) -> f32 {
self.abs()
}
}
RustHere we’re using existing functions and adding the trait
by simply adding calls to the preexisting functions.
Let’s extend this a little further and write some generic functions that can work on anything that implements the PointLike
trait
.
fn print_point_details <T:PointLike> (point: &T) -> (){
println!("The magnitude is: {}", point.get_magnitude());
}
RustHere we have a function that takes a reference to a generic type T
. We have specified that the generic type T
must implement the PointLike
trait
(<T:PointLike>
). Note that we are passing the generic type as a reference as they types don’t necessarily implement the Copy
trait
.
Wrapping all this together into a full working example we have something like:
trait PointLike{
fn get_magnitude(self: &Self) -> f32;
fn add(self: &mut Self, c : f32) -> ();
}
struct Point3D{
x: f32,
y: f32,
z: f32,
}
impl Point3D{
fn new() -> Point3D{
Point3D{x:0., y:0., z:0.}
}
fn add_constant(self : &mut Self, c :f32 ) -> (){
self.x += c / 3.0_f32.sqrt();
self.y += c / 3.0_f32.sqrt();
self.z += c / 3.0_f32.sqrt();
}
fn abs(self : &Self) -> f32{
(self.x.powi(2) + self.y.powi(2) + self.z.powi(2)).sqrt()
}
}
impl PointLike for Point3D{
fn add(self: &mut Self, c : f32) -> () {
self.add_constant(c);
}
fn get_magnitude(self: &Self) -> f32 {
self.abs()
}
}
struct Point2D {
x: f32,
y: f32,
}
impl PointLike for Point2D{
fn get_magnitude(self: &Self) -> f32 {
(self.x.powi(2) + self.y.powi(2)).sqrt()
}
fn add(self: &mut Self, c : f32) -> () {
self.x += c / 2.0_f32.sqrt();
self.y += c / 2.0_f32.sqrt();
}
}
fn print_point_details <T:PointLike> (point: &T) -> (){
println!("The magnitude is: {}", point.get_magnitude());
}
fn main(){
let mut my_point = Point2D{x : 2.3, y: 5.7};
let a = 1.5;
my_point.add(a);
println!("2D magnitude: {}", my_point.get_magnitude());
let mut my_point_3D = Point3D::new();
my_point_3D.add(a);
println!("3D magnitude: {}", my_point_3D.get_magnitude());
print_point_details(&my_point);
print_point_details(&my_point_3D);
}
RustSummary
In this tutorial we learnt how to write object orientated programs in Rust. We used structs
to create custom data types. We then implemented (impl
) functionality to these data types to create methods that can access data within the struct
. We then expanded the structs
by adding generic data types. We used traits
to define a common set of functions that we can implement for other data types. Using the traits
we were also able to write generic functions that will work for any data types which implements our custom traits
.
I hope this tutorial series helps you begin your Rust journey. There are still lots to learn in Rust such as macros, lifetimes and creating your own crates or packages. I may touch on some of these topics in the future (see other Rust posts!). In the meantime let me know what you think, I’d love to hear your thoughts!