The Rust way of achieving object-oriented features

The Rust way of achieving object-oriented features

·

25 min read

Object-Oriented Programming is a programming paradigm that enables the organization of code in a way that offers several benefits:

  1. Ease of Understanding: It allows for the arrangement of code into manageable components, making it easier to comprehend and work with.

  2. Extensibility: Code can be extended without disrupting other parts of the program, avoiding monolithic structures that are challenging to evolve due to tightly bound dependencies.

  3. Reusability: Components developed using this paradigm can be reused in different parts of the codebase.

  4. Protection of data via Abstractions and Encapsulation: Abstractions and encapsulation techniques provide security by concealing implementation details and controlling access to data.

Functional Programming is another paradigm in which functions play a central role. Here's a brief overview:

  1. Avoiding Side Effects: Functional programming emphasizes immutability and provides immutable data structures, reducing side effects that can result from data mutation. Pure functional programming languages like Haskell embody this approach.

  2. Minimizing State: In functional programming, loops and traditional control flow structures are avoided or minimized. Languages like Elm and Haskell use restricted control flow techniques and instead embrace iterator-based processing to reduce the low-level details normally needed in loops.

  3. Higher-Order Functions: Functional programming promotes the use of higher-order functions, allowing functions to be stored, passed, and returned as values, akin to lambda functions in non-functional programming languages.

The aforementioned concepts are effectively implemented using functional programming as it's designed from the ground up to address these concerns.

Pillars of Object-Oriented Programming:

Abstraction:

Abstraction involves hiding implementation details and providing high-level APIs for performing tasks, shielding users from implementation complexities. Abstraction's meaning varies depending on the context in which it's used.

Abstraction enhances portability across platforms, ensuring source-level compatibility. For instance, primitive and object types, along with their methods, remain consistent across operating systems. However, their underlying implementations differ across platforms, abstracted by the programming language's standard libraries. This leads to ease of learning, as developers don't need to learn distinct APIs for each operating system, promoting code maintenance within a single codebase.

Encapsulation:

Encapsulation involves safeguarding data within a class. Access modifiers control data access, and public methods expose controlled access. This approach facilitates altering internal implementations without affecting the API's users. Public methods' functionality remains the same, but the underlying implementation can differ. This helps protect internal mechanisms and maintain compatibility with existing code, unless significant updates disrupt compatibility.

Inheritance:

Inheritance entails inheriting data (state) and methods (behavior) from a superclass into a subclass, promoting code reuse.

However, managing multiple subclasses and superclasses without restrictions on inheritance can lead to complications. Not all methods of the parent class make sense in the child subclass, and it is our responsibility to not call those methods or to throw an exception if those methods are called inside the child class. This makes reasoning more difficult and cumbersome. Ambiguities may arise due to state sharing. Some languages limit inheritance to single superclass and multiple subclasses to mitigate these challenges.

Polymorphism:

Polymorphism allows for the acceptance of arbitrary types that share common behaviors. It manifests in various forms, with the same method exhibiting different behavior depending on the type. Polymorphism enables the definition of a single interface (method) with varying implementations. For example, calling a binary search method on a string and an array, despite their different types, showcases polymorphism. This approach enhances ease of learning by enabling the use of the same API across diverse types.

Two forms of polymorphism exist based on the availability of type information:

  • Compile-Time Polymorphism: Type information is known at compile time, which aids in generating optimized code without runtime overhead.

  • Runtime Polymorphism: Type information may not be known until runtime, necessitating runtime polymorphism.

Design Flaw:

Object-Oriented Programming provides benefits in designing large-scale software, but there are also other considerations to keep in mind.

Implicit Side Effects: C++, Java, and Python support implicit casting, which can lead to confusion.

Memory Usage and Type Safety: Let's consider a scenario where a superclass has many instance methods, and a child class inherits them. The question arises: which methods are meaningful for the child class, and what about the other methods that remain unused in the child class?

Let's dig into the following simple C++ code, which highlights some problems associated with C++ design. The actual code can be found here.

string name = "Hello";

class ADT {
    void Delete() {
        name.clear();
    }

    int num;
    string String;
    bool Bool;

    int getInt() {
        return num;
    }

    string getString() {
        return String;
    }

    bool getBool() {
        return Bool;
    }
}

The Delete method in the ADT class accesses the variable outside of the class, which is implicit. This is problematic because it shouldn't be allowed within the class if other parts of the code rely on external variables without our knowledge. I'm wondering why C++ inherent methods have access to the data field outside of their scope (the class). In Rust, we can't access the struct data without passing the self type in the function parameters. Even though those methods are in scope, and without explicit passing of data, Rust won't have access to the external data, except through closures, which also have their restrictions.

The second problem is that without initializing the variables, the getter methods return random values, which can lead to memory errors in C++.

Now let's consider the Java code. The same memory error in the above C++ code won't occur, but different problems can arise. The full code can be found here.

class Point {

    // Primitive types
    private int a;
    private double b;
    private boolean c;
    private char d;

    // Object types
    private String e;
    private Integer f;

    // Getters
    int getInt() {
        return a;
    }

    double getDouble() {
        return b;
    }

    boolean getBool() {
        return c;
    }

    char getChar(){
        return d;
    }

    String getStringObject(){
        return e;
    }

    Integer getIntObject(){
        return f;
    }

    char charat(){
        return e.charAt(1);
    }
}

Issues in the above code:

  1. Without initializing variables, the getter methods return the default value for a type. For primitive types, default values are 0 for int, 0.0 for double, false for boolean, and the null character for char. For object types, the default value is null, regardless of the object type. This should be avoided by initializing the variables first, but Java doesn't enforce this.

  2. Another problem arises when calling the charat() method or any other object method on them. The charAt method is defined for a string, but Java doesn't know about it until initialization due to the lack of type information. It throws an exception because performing an operation on null is undefined behavior. In Java, this results in a runtime error, which is safer than C++, but it's still a problem. In Rust, this never happens because when defining None in Rust, it needs to know the type of some value. Additionally, without handling the None case, it won't compile. Java introduces Optional, but it's still a best practice to follow, not a compile-time enforcement.

Object-oriented patterns in Rust:

Rust doesn't strictly adhere to object-oriented programming (OOP) in the same way that languages like Java, Python, and C++ do; however, it offers ways to achieve similar patterns.

Rust doesn't have keywords like class, this, public, private, protected, or new. Instead, it provides its own mechanisms to achieve the principles of OOP.

Return types and method chaining:

In Rust, method chaining(In functional language, this is called a monad or combinators) is determined by the return type of the methods. Depending on the return type, you can call more methods based on that type. Incorrect use of method chaining can result in a compile error.

Combinators can be found in iterators, error-handling types such as Result and Option, the builder pattern, and even in parser libraries like chumsky.

    let iterator_combinators = (0..=100)
        .into_iter()
        .map(|integer| integer * 2)
        .filter(|integer| *integer % 2 == 0)
        .skip(10)
        .take(5)
        .collect::<Vec<_>>();

    use std::env::args;
    let error_combinators = args()
        .skip(1)
        .take(1)
        .nth(1)
        .map(|mut some| {
            some.push_str("Only add elements if env value is passed");
            some
        })
        .and_then(|mut some| {
            some.clear();
            Some(some)
        })
        .unwrap_or_default();

Custom types or user defined types (UDT) or abstract data types (ADT):

In Rust, custom types are defined using:

  • Struct: A product type that represents both this and that state. A Rust struct is similar to a C struct, but with inherent methods, akin to objects in object-oriented programming. A struct encompasses both data and methods.

  • Enum: A sum type that can be either this state or that state. An enum is like a C enum but safer to use, and it also has methods. Enums have both state and behavior, like structs.

  • Trait: A shareable type that can be implemented by both structs and enums. Traits define only behavior, and the state cannot be accessed directly.

Rust Struct

The structure in Rust is a type that is analogous to objects in object-oriented programming, with distinct differences.

1) The methods (behavior) and the data (attributes) are separated. The methods are defined using the impl block in the same scope where the struct is defined.

2) There are four different types of self or equivalent this types, which are:

    • self: A method with this signature moves the self (a struct or enum) into the method body. After that, no method can be called on that struct.
    • mut self: Also takes ownership but allows methods to mutate the data before deallocation of data.
    • &self: Borrowed, akin to getter methods, allowing read access to the states.
    • &mut self: Mutable borrow method of the struct, akin to setter methods, used to modify or mutate the states. Note that even though we can have mutable methods, we can't call these methods without making the variable mutable when initializing it with a static function.

In Rust all inherent or instance methods are combination or one of the above receiver type. If no receiver type is defined then it should be a static function or associated function. Below is the example code where you can see all the different receiver types of the methods on the Vector.

     //Static function
    let mut vector = Vec::new();

    let _immuatble_method = vector.len();
    //vector still exist

    let _mutable_method = vector.push(10);
    //vector still exist

    let _takes_ownership = vector.into_boxed_slice();
    //Vector no longer exist

There are three ways to define a struct type in Rust, each with different use cases.

  1. Named Struct: Fields are named.
struct NamedStruct {
    x: i32,
    y: f64,
    z: String,
}

// Syntax for constructing and accessing
let mut named_struct = NamedStruct {
    x: 45,
    y: 45e-10,
    z: "Named Struct".to_string(),
};
// Accessing
println!("{} \n {:} \n {}", named_struct.x, named_struct.y, named_struct.z);
// Modifying
named_struct.x = 67;
named_struct.z.push('I');
  1. Tuple Struct: Fields are ordered but not named.
struct TupleStruct(String, f64, Vec<i32>);

let mut tuple_struct = TupleStruct("Tuple".to_string(), 56.7, vec![1, 3, 7, 3]);
// Accessing
println!(
    "{}\n {}\n {:?}",
    tuple_struct.0, tuple_struct.1, tuple_struct.2
);
// Modifying
tuple_struct.0.push_str("Extra tuple");
tuple_struct.2.extend(&[12, 67, 45]);
  1. Unit Struct: It has no size, fields, and represents an empty type, i.e., it represents nothing.
struct Unit;
let unit_struct = Unit;
println!("{}", std::mem::size_of::<Unit>());

Now implement the same getter and setter using Rust struct.


//Importing point from the same module.
use self::point::Point;
fn main() {

 //Without making Point public this would be compiled error.
    let mut p = Point::new();

    //Explicitly passing
    p.setX(1.2);
    p.setY("Hello".to_string());
    p.setZ('a');

    println!("{} {} {} ", p.getX(), p.getY(), p.getZ());

    let F64 = 56.7;
    let string = String::from("Helloooo");
    let Char = 'c';

    let p = Point::new1(F64, string, Char);
    println!("{} {} {} ", p.getX(), p.getY(), p.getZ());
}
mod point{
pub struct Point {
    x: f64,
    y: String,
    z: char,
}
impl Point {
    pub fn new() -> Self {
        //explicitly setting default values.
        Self {
            x: Default::default(),
            y: Default::default(),
            z: Default::default(),
        }
    }
    //explicitly getting inputs from outside.
    pub fn new1(x: f64, y: String, z: char) -> Self {
        Self { x, y, z }
    }

    pub fn setX(&mut self, val: f64) {
        self.x = val;
    }
    pub fn setY(&mut self, val: String) {
        self.y = val;
    }
    pub fn setZ(&mut self, val: char) {
        self.z = val;
    }
    pub fn getX(&self) -> f64 {
        self.x
    }
    pub fn getY(&self) -> &str {
        &self.y
    }
    pub fn getZ(&self) -> char {
        self.z
    }
}
}

In the code snippet, getters and setters for Rust's struct are implemented. Notable points:

  1. new isn't a keyword; rather, it is a convention used in Rust communities to serve as a constructor for a type.

  2. Without passing self as the first parameter in instance methods, you can't access the data attributes at all.

  3. Setters require &mut self to modify the struct's variables. In other words, if you want to mutate the data, you have to use &mut self.

  4. Getters require &self to access the struct's variables. It's also possible to use &mut self to access the states, though it is unnecessary and ambiguous.

  5. When initializing the struct in the same module, you can use the fields directly. In other modules, you need a constructor to return Self to call methods on the struct.

  6. Without the mut keyword in front of the variable, you can't call methods that take a mutable reference (setter methods).

  7. Unlike in C++ or Python, Rust functions, whether they are free functions, instance methods, or struct definitions, can't access the environment implicitly. You need to pass it explicitly to them.

  8. By default, a struct and its fields are private. Use the pub keyword to make the struct publicly available. Just because the struct itself is public doesn't make the fields public. You need to annotate the variable names with the pub keyword in front of them to access them. Without declaring a method or static function with the pub keyword, they are private to the struct.

Rust's approach differs from that of most programming languages. Data attributes are defined in the struct itself, and methods for manipulating data are separated into impl blocks (short for implementations). This is not how Java, C++, or Python work – in those languages, the scope, states, and behavior are in the same place, which is why we can access the fields of the object without explicit passing of self or this type to the instance methods, unlike Rust, where a function that doesn't take a self type doesn't have access to states. Just because we have the self-type doesn't mean we can do whatever with that state.The Rust impl block can also have const values, in addition to methods and static functions.

User-defined types should have a static method to initialize the struct. This allows calling methods on the struct or enum, as Rust lacks null object-like concepts. Be sure to use a static method that returns the self type (the type you implement) or derive the default trait to provide a constructor for your custom types without implementing a static method. Use the default trait derive implementation only when default values make sense, or create a static function for your specific needs.

struct NoStatic {
    x: i32,
    y: f64,
    z: Option<String>,
}
impl NoStatic {
    fn set_x(&mut self, x: i32) {
        self.x = x;
    }
}

Can you call the set_x method in the above Rust code? It's not possible to invoke the set_x method without the implementation of a static function or deriving the default trait.

Getters (Observers) and Setters (Mutators) have distinct signatures. In other Object-Oriented Programming languages, both types of methods receive the same "self", making it difficult to determine whether they mutate the data or not just by looking at the method signature. However, in Rust, just by looking at the signature, we can deduce the purpose of the code. Though it's still possible to use &mut self even though the method doesn't mutate anything, it's up to us to make sure not to do such a thing.

The Default::default is a trait method used to provide a default value for a type if the type implements it. This is explicit rather than implicit. This is helpful in situations like:

  1. When dealing with many fields, providing default values without requiring the user of the function to pass every value is necessary, as Rust doesn't support default values in argument positions as in Python except in generic definitions.

  2. Updating the remaining struct fields with their default values is necessary when we only need to modify a few fields.

The default values are different from the Java way of providing default values.

  1. We can override the default values for a given type by implementing the Default trait.

  2. The default value for the option is None, but the type information is preserved. In other words, we won't produce any exceptions or call inappropriate methods on it because the type information is provided when defining the types, i.e., either as Some (specified type) or None (representing nothing). This is unlike the null object type, where we can't determine the type without initializing the value.

Rust Enum:

An enum is similar to a struct and is used to build custom types. However, the semantics of an enum differ from that of a struct. In a struct, we can access all the fields at once or any other possible combination, but in an enum, we are only able to access one variant at a time, as it describes the or relation instead of the and relation like a struct. Enums can be used wherever there is a question of "this or that," such as in error handling where we shouldn't get both success and failure for a fallible operation.

fn main() {
    // Enum variants are accessed using the :: operator.
    let enum_var = Enumeration::UnitLike;
    let enum_var2 = Enumeration::TupleLike(String::from("Enumeration"));
    let enum_var3 = Enumeration::NamedLike {
        x: 89,
        y: vec![1, 2, 3, 4],
    };
    // Compile error
    // println!("{}", enum_var2.0);
    // println!("{} {:?}", enum_var3.x, enum_var3.y);
}

enum Enumeration {
    // Variant with no data
    UnitLike,
    // Variant with tuple-like data
    TupleLike(String),
    // Variant with named struct
    NamedLike { x: i32, y: Vec<i32> },
}

The only way to access enum fields is through pattern matching, as accessing them directly is not safe. Using pattern matching or if let expressions is safe because an enum is in one of its possible states at any given time, unlike a struct. This feature is often used in Rust to encode error handling logic using enums. A struct cannot be used for this purpose, as having both success and failure states simultaneously would result in logical inconsistencies.

let var;
let x = true;
if x {
    var = 10;
} else {
    var = 11;
}

Enum logic is equivalent to the above control flow code. If x is true, var is set to 10, otherwise, it's set to 11. The path is either to set var to 10 and exit or to set var to 11 and exit.

let vector: Option<Vec<i32>> = Some(vec![1, 2, 3, 4, 5, 6, 7]);

// The return type of vector is an Option type,
// so we must cover both variants of Option to
// safely access the variable.
match vec {
    // The vector is a local variable inside the match block,
    // so we can access it here.
    Some(vector) => {
        for i in vector {
            println!("{}", i);
        }
    },
    // If we're here, then nothing happens.
    None => {},
}
// The return type of match must be the same for every branch,
// which is the unit type ()

In the code, if we have the Some variant, we access the vector and print its contents. If we have the None variant, nothing happens.

We will explore the enum in more detail in the upcoming post.

Rust Traits

In C++, concepts like generics and polymorphism, bounds are established using concepts, dynamic dispatch is achieved through virtual functions, operator overloading utilizes function operators, and object-oriented concepts are embodied in classes. However, in Rust, these concepts are unified under traits, eliminating the need to learn fragmented concepts, and Rust's error messages are also superior to those of C++.

Traits encompass methods within their definition. However, the methods within the trait cannot directly access the state.

fn main() {
    let struct_group = StructGroup {
        string: "String from Struct".into(),
        int: 25,
    };

    let enumgroup_string = EnumGroup::Str("String from Enum".to_string());
    let enumgroup_int = EnumGroup::Float(45.86);

    //calling trait methods on struct and enum.
    struct_group.print();
    enumgroup_string.print();
    enumgroup_int.print();
}
trait Print {
    //only definition, the actual implementation is implemented using the syntax
    // impl trait for struct | enum
    fn print(&self);
}
struct StructGroup {
    string: String,
    int: i32,
}
enum EnumGroup {
    Str(String),
    Float(f64),
}
impl Print for StructGroup {
    //here the Print trait accesses the StructGroup data i.e state
    fn print(&self) {
        println!(
            "The string is :{} and the integer is : {}",
            self.string, self.int
        );
    }
}

impl Print for EnumGroup {
    //here the Print trait accesses the EnumGroup data.
    fn print(&self) {
        match self {
            Self::Str(s) => println!("The string is :{}", s),
            Self::Float(f) => println!("The float is: {}", f),
        }
    }
}

In structs and enums, the states and behaviors are separated. Traits, on the other hand, have no state at all. They only define behaviors and can also include default implementations if it makes sense, otherwise, we can override them when implementing.

There is no overhead when calling print on different types. This is known as compile-time or static polymorphism. To achieve runtime polymorphism in Rust, we use trait objects.

Traits aren't simple in Rust; they can be generic, include lifetimes, and have associated types and bounds.

Explicitness

  1. By default, UDTs are moved when assigned to a new variable or passed to a function or thread, even if they only contain copy types. You have to implement or derive the Copy trait.

  2. Types created by us don't have any capabilities, such as performing arithmetic operations like addition, comparison, or ordering, unless explicitly defined. Also, this is applicable only if the types themselves support those operations. The advantages and disadvantages here are:

  3. Clients won't accidentally perform operations that may not make sense at a high level, even if the inner types they contain support those operations.

  4. But implementing those manually would be cumbersome, though Rust macros help you automate this process.

How would you make your type perform actions similar to the built-in types?

1) If you want your type to support arithmetic operations, i.e., operator overloading, just import the trait methods from std::ops to implement Add (+), Sub (-), Mul (*), Division (/), and others for your types, allowing the use of arithmetic operators on them. Within each trait method, you can have different implementations due to Rust's distinctions in ownership, immutable, and mutable borrow variants.

2) If you want your type to be both debuggable via {:?} and printable via {}, then derive the Debug trait and implement the Display trait manually for your type.

3) By implementing the From and Display traits for your type, you will get the into and to_string trait methods automatically, respectively.

What's the deal with having Eq and PartialEq, and Ord and PartialOrd trait variants?

PartialEq and PartialOrd traits are the traits that makes the types to use the compartion operators or equivalent methods.

Eq trait is a marker trait - traits that don't have any size or runtime representation but provide confirmation to the compiler that our intention is not to make any mistakes.

If the type implements Ord trait then it is following the total order that is all mathematical operation of comparing such as less than( < ),greater than( >) , less than or eqal ( <= ) , greter than or eqal ( >= ) , not equal to( !=) and equal ( == ). But float type can't be guarantee this relations . If you look at the implementation of Eq and Ord , the float type is not implements these traits hence we can't use float values if those bounds were specified.

Here, Rust uses its type system to prevent this logic error(but some extents it's also a memory error because interpreting the bytes in different pattern) at compile time without any testing and runtime overhead. Rust provides different abstractions to ensure that our intentions are correct at a high level without allowing too many incorrect things(logic errors) while the program is running.

Constructors and Destructors in Rust:

In an Object-Oriented Programming language, the constructor has the same name as the class, along with optional parameters.

In Rust, there are two possibilities:

  1. Literal initialization (i.e., using fields directly) is only possible if the struct or enum is in the same module or if its fields have been made public in another module to be used in the current module, which is rare.

  2. Static Functions, which don't take the self parameter and return the self instance, are common. These static functions are often called initializers. The syntax for static functions or associated functions uses the scope resolution operator, ::. Enum variants act as initializers.

Accessing methods and fields in Rust is similar to other languages. Method calls are denoted with parentheses (). Fields are accessed using the dot . operator. However, tuple indexing is different tuple elements are accessed using the dot . followed by a positive constant value.

Destructors in Rust are called implicitly when the variable's ownership goes out of scope. This follows the Resource Acquisition Is Initialization (RAII) principle.

In Python, everything is an object. What does this mean? In languages like C++ or older languages, primitive types or built-in types are scalar types—meaning they contain a fixed set of values. In Python, built-in types have methods associated with them. Depending on the return type of these methods, we can use them in appropriate situations. For example, if a method returns a bool, we can directly use it in an if statement. If it returns an iterable type, it can be used in loops, and if it returns another type, we can perform operations defined on that type. Object-Oriented Programming is organized-oriented programming.

In Rust, primitive types and custom types have methods associated with them, similar to Python.

Abstraction in Rust

Abstraction involves hiding implementation details. In Rust, structs and enums have methods that help hide details. In the Rust code mentioned above, methods are the only way to interact with the data. To call a method outside the module, it must be made public using the pub keyword, which is explicit, as all the fields of the struct, the struct itself, and the methods are private by default.

Iterator abstraction in Rust

Iterators provide a useful abstraction by allowing us to use them without needing to know the underlying data structure and implementations. Iterator methods, such as sum and fold, reduce boilerplate code. Additionally, iterators reduce the need for explicitly managing state information, as seen in loops.

fn main(){
    let vec = vec![1,2,3,4,5,6,7];
    let string = String::from("Object Oriented Programming In Rust");
    let mut hashmap = std::collections::HashMap::new();
    hashmap.insert("Harry","Boy");
    hashmap.insert("Hermoine","Girl");
    hashmap.insert("Voldemort","Old weird bad Guy");

    for i in &vec{
        println!("{}",i);
    }
    for (key,value) in &hashmap{
        println!("{} {}\n",key ,value);
    }
    for line in string.split_whitespace(){
        println!("{}",line);
    }
}

In the code above, we utilized three different data structures. However, all three can be equally handled by the iterator trait. The need to specify the initial and ending values is eliminated, as iterators abstract away the low-level details, yet it's a zero-cost abstraction.

Encapsulation in Rust

Modules aid in encapsulating the data of structs and enums. By default, all fields of a struct and the struct itself are private, as are enums and traits. Literal initialization is not directly possible. Instead, initialization is carried out through public methods on them. In the Rust code for setters and getters, accessing the fields of the Point struct is not allowed due to their private nature within the struct. To access them, we need to explicitly mark the fields as public.

let string = String::new();
let hashmap = HashMap::new();
let hashset = HashSet::new();
let vector = Vec::new();

The inner fields of those types cannot be accessed. Even if we know the fields, we cannot construct them directly. The only method of usage is through the static function on the type. Here, the term new is not a keyword, any appropriate name can be used, chosen according to the domain to ensure readability and comprehension by readers.

Code Reuse via Traits

Code like below does look like inheritance,

pub trait Error: Debug + Display

It appears to inherit the traits of Debug and Display. Remember that traits don't have access to states; instead, they serve as specifications or requirements for a type. If you wish to implement the Error trait for your type (struct or enum), you should also implement the Debug and Display traits to satisfy the compiler. This is helpful for specifying our requirements in types to maintain conformation between the author and the client who uses them.

Rust doesn't support inheritance, meaning states and methods can't be inherited from other structs or enums. In Rust, code reuse is achieved through traits, and it's shared among structs and enums.

The iterator is one way to achieve code reuse and, at the same time, ensure safety. You can reuse code for your type by simply implementing the next method and setting the associated type Item. APIs are consistent for all types that implement the iterator.

Now, let's implement the floating-point iterators:

fn main() {
    let m = FloatIter::new(1.0, 0.1, 2.0)
        .into_iter().skip(2)
        //here x is converted to String from float
        .map(|x| format!("{x:.1}"))
        //here x is converted to formatted float from string
        .filter_map(|x| x.parse::<f64>().ok())
        .collect::<Vec<_>>();

    for i in &m {
        println!("{:?}", i);
    }

    for i in FloatIter::new(2.0, 0.3, 3.0).into_iter() {
        println!("Square root {i:.1}");
    }
}

The benefits of the above approach as follows:

  1. Once we implement the Iterator trait for the FloatIter struct, we can automatically get all the other methods from the iterator traits without needing to implement them separately. This is because these methods have default implementations that rely on the next method. The standard library defines a blanket implementation like this: impl<I: Iterator> IntoIterator for I, which means that we can invoke the methods of the IntoIterator trait if the type I implements the Iterator trait. This is why we can call the into_iter method on our type to use Mapper, Filter, and Reducer on them.

  2. In contrast to Java and C++, where not all inherited methods make sense for your type, Rust takes a different approach. In Rust, all the inherited methods make sense for your type, not just some of them. The semantics in Rust differ from those in other languages. There's no need to use override to disable certain methods, as is the case with throwing UnsupportedOperation exceptions in Java. In Rust, you call methods that conform to the types without encountering such issues.

Here's a snippet from the Google Guava Java library to illustrate this point: Calling certain methods on a subclass doesn't make sense. In Java, we explicitly throw an exception when a superclass method is called on the subclass. Otherwise, it would break the invariant we established.


 @Override  
  @CheckForNull //we don't need this in Rust and statically we can prevent this 
  public Entry<E> pollFirstEntry() {
    throw new UnsupportedOperationException();
  }
  @Override
  @CheckForNull
  public Entry<E> pollLastEntry() {
      throw new UnsupportedOperationException();
  }
  1. When we implement the Iterator trait for our type, we get a total of 74 methods (excluding the next method). It's often the case that we only use a few methods out of all the available methods. Rust adheres to the zero-cost principle, meaning the compiler only generates the code that you actually use.

Another way to reuse code is through the Deref and DerefMut traits. These traits coerce one type into another type, essentially enabling code reuse. Slices, for instance, provide a view into the underlying data structure and have many useful methods defined on them. Due to the presence of these traits, these methods can be directly used on a type without needing an explicit conversion to a slice.

Polymorphism in Rust:

In C++, errors often arise only when running the program, not during compilation. This stands in contrast to Rust, where using a generic type without any bounds results in an error. Rust's approach ensures that not every type implements everything. This can be referred to as Bounded Polymorphism because the types accepted are constrained by the traits, adding a level of safety to our code. We don't require all types, only specific valid types.

Polymorphism facilitates code reuse and enhances the flexibility of the types they accept. For instance, a function with an i32 parameter can only accept i32 values. In contrast, a generic function with trait bounds can accept different types as long as they share common operations, such as Copy types, where all primitive types are accepted by the function, not just a single primitive type.

As mentioned in the Rust book:

The other reason to use inheritance relates to the type system: to enable a child type to be used in the same places as the parent type.

use std::fmt::Display;
use std::ops::Add;
fn main() {
    accept_more_types(89);
    accept_more_types(7.6);
    accept_more_types("Hello");
    accept_more_types('A');
    accept_more_types(String::from("String implements Display"));

    accept_more_but_minimal(12);
    accept_more_but_minimal(2.4);

    //Compile error
    accept_more_but_minimal("Hello")
}
fn accept_more_types<T: Display>(x: T) {
    println!("{}", x);
}
fn accept_more_but_minimal<T: Display + Add>(x: T) {}

Reference:

Code Examples.

Recreating the same pattern in other languages using Rust can be frustrating. Check out this blog post for more insights.

Struct.

Enum.

Traits.