Struct, enums, Traits and Object definitions
Pierre Cochard, Tanguy Risset
- Struct in Rust
- Enum and pattern matching
- Generic Types
- Traits: Defining Shared Behavior
- Programming paradigms in Rust
Struct in Rust
Course: Structs (from https://doc.rust-lang.org/book/)
We have already seen the Struct concept: Structs are similar to tuples in that they both hold multiple related values.
Unlike tuples, you can name each field member so it’s clear what the types 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:
#![allow(unused)] fn main() { 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:
#![allow(unused)] fn main() { 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:
#![allow(unused)] fn main() { 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)
Consider the following structdefinition (replacingstringby string slicestr):#![allow(unused)] fn main() { 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
strslice is not known (it depends on the lifetime of the string it points to). In aStructall fields must have the same lifetime.Try to solve the problem by adding lifetime in your definition, using the compiler error message.
Correction
$ cargo run
Compiling structs v0.1.0 (file:///projects/structs)
error[E0106]: missing lifetime specifier
--> src/main.rs:3:15
|
3 | username: &str,
| ^ expected named lifetime parameter
|
help: consider introducing a named lifetime parameter
|
1 ~ struct User<'a> {
2 | active: bool,
3 ~ username: &'a str,
#![allow(unused)] fn main() { struct User<'a> { active: bool, username: &'a str, email: &'a str, sign_in_count: u64, } }
Methods in Rust
As in any object-oriented langage, methods can be defined within the context of a struct making a struct a regular object as defined in the 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 { 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())
Define a structRectanglewith two integer fieldswidthandheight. Then implement two methods forRectangle:area(&self)(which computes the surface of the rectangle) andfits_in(&self, other_rect: &Rectangle)which indicate ifselffits completely inside theother_rectrectangle.
Correction
#![allow(unused)] fn main() { struct Rectangle { height: i32, width: i32, } impl Rectangle { fn area(&self)->i32{ self.height * self.width } } impl Rectangle { fn fits_in(&self,other_rect: &Rectangle)->bool{ if other_rect.width>self.width && other_rect.height>self.height { return true; } else { return false; } } } }
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:
#![allow(unused)] fn main() { 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.
#![allow(unused)] fn main() { 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:
#![allow(unused)] fn main() { 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:
#![allow(unused)] fn main() { 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.
Write a function that computes the square root of a f32number and return None if the number is negative.
Correction
fn square_root(a: f32) -> Option<f32>{ match a { n if n >= 0.0 => Some(a.sqrt()), _ => None, } } fn main() { println!("square root of N={:?}: {:?}",5,square_root(5.0)); println!("square root of N={:?}: {:?}",-5,square_root(-5.0)); }
Of course, a Some(T) object cannot be "casted" or "simplified" in a T object. The only way to get the Tobject is to unwrap the option.
unwrap will be explained in next course (for Result type), here it will return the Tobject 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.
Try to unwrap (i.e. apply .unwrap()) your square root for positive and negative number.
Correction
fn square_root(a: f32) -> Option<f32>{ match a { n if n >= 0.0 => Some(a.sqrt()), _ => None, } } fn main() { println!("square root of N={:?}: {:?}",5,square_root(5.0).unwrap()); println!("square root of N={:?}: {:?}",-5,square_root(-5.0).unwrap()); //fails }
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++.
Write a function that will be able to find the minimum of a Vectormay this vector be a vector ofi32,f32or characters or on any type that has the possibility of comparing elements. Use the following function signature:smallest<T: PartialOrd> (v: &[T])-> &T
Correction
fn smallest<T: PartialOrd> (v: &[T])-> &T { let mut min=&v[0]; for e in v{ if e < min { min = e; } } min } fn main() { let v1 = vec![2,7,9,1]; println!("min v1= {}",smallest(&v1)); let v2 = vec![2.0,7.7,9.9,1.1]; println!("min v2= {}",smallest(&v2)); let v3 = vec!['a','t','4','j']; println!("min v3= {}",smallest(&v3)); }
Define a structure Pointwith two fieldsxandythat can be integer or floating point. IsPoint{x:2,y:2.3}a valid instance of the structurePoint?
Correction
No, because the types of the two fields are not some same:
either Point{x:2, y:3} or Point{x:2.0, y:2.3}.
#![allow(unused)] fn main() { struct Point<T> { x: T, y: T, } }
Traits: Defining Shared Behavior
A trait defines the functionality or behaviour that 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 duplicated (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 the following syntax:
impl TheTrait for TheType {...}.
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{[...]}.
Define a trait IsEventhat is composed of the methodis_even(&self). Implement the trait for theRectangletype defined préviously (a Rectangle is even if both height and width are even)
Correction
trait IsEven { fn is_even(&self)->bool; } #[derive(Debug)] struct Rectangle { height: i32, width: i32, } impl IsEven for Rectangle { fn is_even(&self)->bool { return (self.height%2 == 0) && (self.width%2 == 0) } } fn main() { println!("Hello, world!"); let r1=Rectangle{height:20,width:30}; println!("Rectangle {:?} is even: {}",r1,r1.is_even()); }
You can specify a default implementation of each method in the definition of the trait (then an explicit 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. There are two possible syntaxes, the impl Trait syntax for simple cases and the Trait Bound Syntax for the general case:
fn myFunction(a_variable: &impl TheTrait) { [..]}
fn myFunction<T: TheTrait>(a_variable: &T) { [..]}
Define a function notifyEventhat takes as parameter a type that implements the traitIsEvenand notify (i.e. print) the fact that the object is even. Note that you can implementIsEvenandDebugtraits together by specifyingIsEven+Debug.
Correction
fn notify_even<T: IsEven+Debug> (o: &T){
println!("This object: {:?} is even? {}",o,o.is_even());
}
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
#![allow(unused)] fn main() { 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 paradigms 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 program. 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.
A specificity compared to other recent languages is the important and growing influence of functional programming in Rust.
A simple example: integer Vector sum.
Consider the problem of suming all the component of vector with i32values. Write a program to do it in aniterative/imperativeway (i.e. a loop accumulating in a temporary variable)
Correction
#![allow(unused)] fn main() { let vector = vec![9,6,-2,7]; let mut sum = 0; for i in 0..vector.len() { sum += vector[i]; } println!("Hello, world sum = {}!",sum); }
Do the same thing in a more functionnal way: Use the iter()method ofVectortype andsum()method of iterators
Correction
#![allow(unused)] fn main() { let vector = vec![9,6,-2,7]; let sum2 :i32 = vector.iter().sum(); println!("Hello, world sum2 = {}!",sum2); }
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, which 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.
#![allow(unused)] fn main() { #[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), ]; }
Give an imperative solution to the task using nested forloops
Correction
#![allow(unused)] fn main() { fn five_best_func_lang1(lang_list: &Vec<Language>)->Vec<Language>{ let mut fun_lang: Vec<Language> = vec![]; for l in lang_list { if l.paradigms.contains(&Paradigm::Functional){ fun_lang.push(l.clone()); } } //buble sort to sort the vector let mut changed = true; while changed { changed = false; for i in 1..fun_lang.len(){ if fun_lang[i].nb_users > fun_lang[i-1].nb_users { fun_lang.swap(i,i-1); changed = true; } } } //Select the first five while fun_lang.len()>5 { fun_lang.remove(fun_lang.len()-1); } fun_lang } }
Give a more functional implementation by transforming the vector in an iterator and using the following method (see https://docs.rs/itertools/latest/itertools/trait.Itertools.html):
use itertools::Itertools;
into_iter()method transforms a Vector into an 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.
Correction
#![allow(unused)] fn main() { fn five_best_func_lang2(lang_list: &Vec<Language>)->Vec<Language>{ lang_list.into_iter() .sorted_by_key(|lang| lang.nb_users) .filter(|lang| lang.paradigms.contains(&Paradigm::Functional)) .rev() .take(5) .cloned() .collect() } }
Which implementation is more efficient in terms of computation time?
-use std::time::Instant;
- Or (more complex) use
criterioncrate:
#![allow(unused)] fn main() { [dev-dependencies] criterion = "0.5" }
Correction
let start = Instant::now();
let l2 = five_functionnal_lang2(&languages);
let t2 = start.elapsed();
println!("func2: {:?}", t2);