Struct, enums, Traits and Object definitions
Pierre Cochard, Tanguy Risset
Struct in Rust
Course: Structs (from https://doc.rust-lang.org/book/)
We have already seen the Struct
concepts: Structs are similar to tuples
in that both hold multiple related values. Unlike with tuples, in a struct you’ll name each piece of data so it’s clear what the values mean. Adding these names means that structs are more flexible than tuples: you don’t have to rely on the order of the data to specify or access the values of an instance.
Here is an example of Struct definition:
struct User {
active: bool,
username: String,
email: String,
sign_in_count: u64,
}
We create an instance by stating the name of the struct and then add curly brackets containing key: value pairs as for example:
let user1 = User {
active: true,
username: String::from("someusername123"),
email: String::from("someone@example.com"),
sign_in_count: 1,
};
To get a specific value from a struct, we use dot notation. For example, to access this user’s email address, we use user1.email
. When creating instances from other instances The syntax .. specifies that the remaining fields not explicitly set should have the same value as the fields in the given instance.:
let user2 = User {
email: String::from("another@example.com"),
..user1
};
Unit struct (i.e. struct without fields) and Tuple struct (i.e. struct with no named fields) are special Rust features that might be useful in some cases (see https://practice.course.rs/compound-types/struct.html)
struct
definition (replacing string
by string slice str
):
struct User {
active: bool,
username: &str,
email: &str,
sign_in_count: u64,
}
Try to create an instance of this structure in a main program. You will not be able because the lifetime of the str
slice is not known (it depends on the life time of the string it points to). In a Struct
all fields must have the same lifetime.
Try to solve adding lifetime in your definition using the compiler message ?
Methods in Rust
As in any object oriented langage, methods are defined within the context of a struct
making a struct
a regular object as defined by object oriented programming paradigm.
(In Rust, methods can be also defined in the context of an enum
or a trait
object. The definition of methods use the keyword fn
as function but the are preceded by the keyword impl
(eg: impl MyStruct { le fn [...]
}) to specify that this function is only defined in the context of a particular type object.
For a method, the first parameter is always &self
(or self
if the method need to take ownership of self
). &self
is a short cut for &self: MyObjectType
whatever this type is.
For example, if we want to defined a methode name()
for the previous structure User
(the first on, with String
) in order to obtain the username
of a User
, we will use the following syntax:
impl User {
fn name(&self)->&String{
&self.username
}
}
When calling this method, the self
argument is never written, it is implicit (e.g. : user1.name()
). The above method is ofter called a getter
. Getters are not implemented by default for Rust Struct
, it is a good habit to name the getter
after the field name they are getting (hence we should have called this method username()
)
struct
Rectangle
with two integer fields width
and height
. Then implement two methods for Rectangle
: area(&self)
(which computes the surface of the rectangle) and fits_in(&self, other_rect: &Rectangle)
which indicate if self
fits completely inside the other_rect
rectangle.
Enum and pattern matching
Course: Enum and option
Enum (from https://doc.rust-lang.org/book/)
As in C, Enum
gives you a way of saying a value is one of a possible set of values. For instance, An IP address can be either an IPv4 address or an IPv6 address, but not both at the same time:
enum IpAddrKind {
V4,
V6,
}
This allows to store the two possible kind in "the same" memory location given that they will never be active together in one instance.
A new feature comparing to C is that we can put data directly into each enum variant.
enum IpAddr {
V4(u8, u8, u8, u8),
V6(String),
}
let home = IpAddr::V4(127, 0, 0, 1);
let loopback = IpAddr::V6(String::from("::1"));
Rust has an extremely powerful control flow construct called match
that allows you to compare a value against a series of patterns and then execute code based on which pattern matches. Pattern matching in an important area of computer science and compilation, we just show a very simple example here:
enum Coin {
Penny,
Nickel,
Dime,
Quarter,
}
fn value_in_cents(coin: Coin) -> u8 {
match coin {
Coin::Penny => 1,
Coin::Nickel => 5,
Coin::Dime => 10,
Coin::Quarter => 25,
}
}
The Option Enum
As explained in TD1, the Null
value was invented to represent the "no value" value and it was a bad invention. With the Option Enum
Rust proposes a mecanism that explicitely distinguish the cases where a variable has a value or no value. Rust defines (in the standard library, in the prelude) a particular Option<T>
enum:
enum Option<T> {
None,
Some(T),
}
The <T>
syntax is a feature of Rust called a generic type parameter (similar to template in C++) that will be explained hereafter.
f32
number and return None if the number is negative.
Of course a some(T)
object cannot be "casted" or "simplified" in a T
object, The only way to get the T
object is to unwrap the option. unwrap will be explained in next course (for Result
type), here it will return the T
object or panic in case of None
. The Option<T>
enum has a large number of methods that are useful in a variety of situations https://doc.rust-lang.org/std/option/enum.Option.html.
.unwrap()
) your square root for positive and negative number.
Generic Types
Every programming language has tools for effectively handling the duplication of concepts. In Rust, one such tool is generics. Generic Types
, Traits
and Lifetimes
are three kind of generics.
Generic Data Types can be used in the definition of functions, struct or enum. Generic Data Type are very close to the template concept in C++.
Vector
may this vector be a vector of i32
, f32
or characters or on any type that has the possibility of comparing elements. Use the following function signature: smallest<T: PartialOrd> (v: &[T])-> &T
Point
with two fields x
and y
that can be integer or floating point. Is Point{x:2,y:2.3}
a valid instance of the structure point
?
Traits: Defining Shared Behavior
A trait
defines the functionality a particular type has and can share with other types. We have already seen some very common traits
: Clone
or Debug
. The trait Clone
for instance represents the fact that a variable of a type can be dupplicated (i.e. cloned) to a second instance of the type, identical to the variable but refering to a different memory location. Traits are similar to a feature often called interfaces in other languages, although with some differences.
Defining a trait consists in defining the methods we can call on that type, using the keywork trait
. In general, trait names should be defined in UpperCamelCase (e.g. IsEven
) and traits methods should be defined with snake_case name (e.g. is_even(&self)
). Implementing a trait for a particular type is done using impl name_of_trait for name_of_type{[...]}
.
IsEven
that is composed of the method is_even(&self)
. Implement the trait for the Rectangle
type defined préviously (a Rectangle is even if both height and width are even)
You can specify a default implementation of each method in the definition of the trait (then an explicite implementation will hide the default implementation)
Trait as parameter: the Trait Bound Syntax
Trait can be used as function parameter, the function will be valid for any type implementing the trait. The usual syntax for that is called the Trait Bound Syntax:
fn myFunction<T: TheTrait>(a_variable: &T) { [..]}
notifyEven
that takes as paremeter a type that implements the trait IsEven
as parameter and notify (i.e. print) the fact that the object is even. Note that you can implement IsEven
and Debug
traits together by specifying IsEven+Debug
.
Sometimes, this syntax can be heavy and you can use the where
Clause: instead of writing this:
fn some_function<T: Display + Clone, U: Clone + Debug>(t: &T, u: &U) -> i32 {
we can use a where clause, like this:
fn some_function<T, U>(t: &T, u: &U) -> i32
where
T: Display + Clone,
U: Clone + Debug,
{
The impl trait
syntax can be used to specify that a result of a function must implement a trait
.
Using Trait Bounds to Conditionally Implement Methods
The following example (from https://doc.rust-lang.org/book/ch10-02-traits.html) illustrates the fact the trait can be used to conditional implementation of methods
use std::fmt::Display;
struct Pair<T> {
x: T,
y: T,
}
impl<T> Pair<T> {
fn new(x: T, y: T) -> Self {
Self { x, y }
}
}
impl<T: Display + PartialOrd> Pair<T> {
fn cmp_display(&self) {
if self.x >= self.y {
println!("The largest member is x = {}", self.x);
} else {
println!("The largest member is y = {}", self.y);
}
}
}
Programming paradigm in Rust
This section is largely inspired by https://corrode.dev/blog/paradigms/.
Rust is a multi-paradigm programming language, accommodating imperative, object-oriented, and functional programming styles. It is important to be aware that the programming paradigm is an important design choice when you start a new Rust programming. The choice of style often depends on a developer’s background and the specific problem they’re addressing. but there are also many "known habits" of Rust developpers.
An originality compared to other recent languages is the important influence of functionnal programming in Rust.
A simple example: integer Vector sum.
i32
values. Write a program to do it in an iterative/imperative
way (i.e. a loop accumulating in a temporary variable)
iter()
method of Vector
type and sum()
method of iterators
The second formulation is, of course, much more concise, as is often the case in functional programming, but it is less suited to certain types of processing (matrix calculations, for example).
A More Complete Example
Consider the following Rust code that defines a list of several languages along with the paradigms they are associated with. You will start from this code. The task will be to find the top five languages that support functional programming and have the most users.
#[derive(PartialEq,Clone,Debug)]
enum Paradigm {
Functional,
ObjectOriented,
}
#[derive(Clone,Debug)]
struct Language {
name: &'static str,
paradigms: Vec<Paradigm>,
nb_users: i32,
}
impl Language {
fn new(name: &'static str, paradigms: Vec<Paradigm>, nb_users: i32) -> Self {
Language { name, paradigms, nb_users }
}
}
let languages = vec![
Language::new("Rust", vec![Paradigm::Functional,Paradigm::ObjectOriented], 100_000),
Language::new("Go", vec![Paradigm::ObjectOriented], 200_000),
Language::new("Haskell", vec![Paradigm::Functional], 5_000),
Language::new("Java", vec![Paradigm::ObjectOriented], 1_000_000),
Language::new("C++", vec![Paradigm::ObjectOriented], 1_000_000),
Language::new("Python", vec![Paradigm::ObjectOriented, Paradigm::Functional], 1_000_000),
];
for
loops
-
into_iter()
method transforms a Vector in a iterator -
filter()
method can keep only element with a property (use a lambda as an argument to filter) -
sorted_by_key()
sorts all iterator elements into a new iterator in ascending order (useReverse()
) -
collect()
transform an iterator into a collection.