T rust learning - generics data types

We can use generics to create definitions for items such as function signatures or structures, which can then be used for many different specific data types. First, let's look at how to use generics to define functions, structures, enumerations, and methods. We will then discuss how generics affect code performance.

In function definition
For example, if we define two functions to calculate the maximum value and the maximum string, and the parameters passed in are required to be an array, we may implement them as follows:

fn largest_i32(list: &[i32]) -> i32 {
    let mut largest = list[0];

    for &item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn largest_char(list: &[char]) -> char {
    let mut largest = list[0];

    for &item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let result = largest_i32(&number_list);
    println!("The maximum number is:{}", result); //100

    let char_list = vec!['y', 'm', 'a', 'q'];

    let result = largest_char(&char_list);
    println!("The maximum characters are:{}", result); //y
}

From the above example, we can see that although the parameter types passed in by the two function methods are inconsistent, their behavior is the same or similar. So should we extract a public function class? If so, this method can be called generic:
fn largest<T>(list: &[T]) -> T {

We read this definition as: the largest function is generic on some type T. This function has a parameter named list, which is part of the value of type T. The largest function will return a value of the same type T.

The following example shows the definition of the maximum combination function using a common data type in a signature. Listing also shows how to call a function using an i32 value slice or a char value. Note that the code has not yet been compiled.

fn largest<T>(list: &[T]) -> T {
    let mut largest = list[0];

    for &item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let result = largest(&number_list);
    println!("The largest number is {}", result);

    let char_list = vec!['y', 'm', 'a', 'q'];

    let result = largest(&char_list);
    println!("The largest char is {}", result);
}

If we compile the code now:

D:\learn\cargo_learn>cargo run
   Compiling cargo_learn v0.1.0 (D:\learn\cargo_learn)
error[E0369]: binary operation `>` cannot be applied to type `T`
 --> src\main.rs:5:17
  |
5 |         if item > largest {
  |            ---- ^ ------- T
  |            |
  |            T
  |
help: consider restricting type parameter `T`
  |
1 | fn largest<T: std::cmp::PartialOrd>(list: &[T]) -> T {
  |             ^^^^^^^^^^^^^^^^^^^^^^

error: aborting due to previous error

For more information about this error, try `rustc --explain E0369`.
error: could not compile `cargo_learn`.

To learn more, run the command again with --verbose.

std::cmp::PartialOrd is mentioned in the comment, which is a feature. At present, this error indicates that the largest body does not apply to all types that t may apply. Because we want to compare the values of type T in the body, we can only use the types whose values can be sorted. For comparison, the standard library has the std::cmp::PartialOrd feature, which we can implement on type.

In structure definition
We can also use the < > syntax to define a structure to use generic type parameters in one or more fields. The following example shows how to define a point < T > structure to hold any type of x and y coordinate values:

struct Point<T> {
    x: T,
    y: T,
}

fn main() {
    let integer = Point { x: 5, y: 10 };
    let float = Point { x: 1.0, y: 4.0 };
}

The syntax for using generics in a structure definition is similar to that used in a function definition. First, we declare the name of the type parameter in angle brackets after the structure name. Then, we can use generic types in the structure definition, otherwise we will specify specific data types.

Note that since we only use one common type to define point < T >, this definition means that the point < T > structure is common on some types T, and the fields x and y are of the same type, no matter what type. If we create an instance of point < T > with different types of values, as shown in the following example, our code will not compile:

struct Point<T> {
    x: T,
    y: T,
}

fn main() {
    let wont_work = Point { x: 5, y: 4.0 };
}

Since the passed in x and y are not of the same type, during compilation:

$ cargo run
 --> src/main.rs:7:38
  |
7 |     let wont_work = Point { x: 5, y: 4.0 };
  |                                      ^^^ expected integer, found floating-point number

error: aborting due to previous error

To define a Point structure where x and y are generic but can have different types, we can use multiple generic type parameters. For example, in the following example, we can change the definition of Point to be common on types T and u, where x is type T and Y is type U:

struct Point<T, U> {
    x: T,
    y: U,
}

fn main() {
    let both_integer = Point { x: 5, y: 10 };
    let both_float = Point { x: 1.0, y: 4.0 };
    let integer_and_float = Point { x: 5, y: 4.0 };
}

All Point instances can now be displayed! You can use as many generic type parameters as you want in the definition, but using multiple generic type parameters makes the code difficult to read. When a large number of generic types are required in the code, this may indicate that the code needs to be reorganized into smaller parts.

In the enumeration definition
Let's take a look at an instance of enumeration type:

#![allow(unused_variables)]
fn main() {
    enum Option<T> {
        Some(T),
        None,
    }
}

Using this enumeration, you can define any type of Some, such as: let Y: option < I8 > = Some (5);, Similar to struc, we can define the generics of enumeration types accordingly:

#![allow(unused_variables)]
fn main() {
    enum Result<T, E> {
        Ok(T),
        Err(E),
    }
}

The Result enumeration is generic to both types T and E, and has two variants: Ok (value with type T) and Err (value with type E). This definition makes it easy to use the Result enumeration anywhere. A type of operation (E) may return a successful or failed value (T). In fact, this is the file used to open the file in the previous error handling. When the file is successfully opened, t is filled with std::fs::File type, and E is filled with std::io::Error. There is a problem when opening the file.

When multiple structures or enumerations are identified in code, they differ only in the type of value they hold, so you can avoid duplication by using generic types.

In method definition
We can implement methods on structures and enumerations (as we did in Chapter 5), and we can also use generic types in their definitions. As shown in the following example:

struct Point<T> {
    x: T,
    y: T,
}

impl<T> Point<T> {
    fn x(&self) -> &T {
        &self.x
    }
}

fn main() {
    let p = Point { x: 5, y: 10 };

    println!("p.x = {}", p.x());
}

D:\learn\rust_test>cargo run
   Compiling rust_test v0.1.0 (D:\learn\rust_test)
warning: field is never read: `y`
 --> src\main.rs:3:5
  |
3 |     y: T,
  |     ^^^^
  |
  = note: `#[warn(dead_code)]` on by default

warning: 1 warning emitted

    Finished dev [unoptimized + debuginfo] target(s) in 0.54s
     Running `target\debug\rust_test.exe`
p.x = 5

Note that we must declare t after impl so that we can use it to specify the method we want to implement on the Point < T > type. By declaring t as a generic type after impl, Rust can recognize that the type in angle brackets in Point is a generic type rather than a specific type.

For example, we can implement methods only on point < f32 > instances, not on point < T > instances with any generic type. In the following example, we use the concrete type f32, which means that we do not declare any type after impl.

impl Point<f32> {
    fn distance_from_origin(&self) -> f32 {
        (self.x.powi(2) + self.y.powi(2)).sqrt()
    }
}

This code means that the point < f32 > type will have a name called distance_ from_ The method of origin, while other instances of point < T > (where t is not f32 type) will not define this method. This method measures the distance between our point and the point at the coordinates (0.0, 0.0) and uses mathematical operations that are only applicable to floating-point types.

The generic type parameters in the structure definition are not always the same as those we use in the method signature of the structure. For example, the Point < T, u > structure in the following example defines method mixing. This method takes another Point as a parameter, which may be different from the type of self Point we call. This method creates a new Point instance using the x value from the Point (type T) and the y value from the incoming Point (type W):

struct Point<T, U> {
    x: T,
    y: U,
}

impl<T, U> Point<T, U> {
    fn mixup<V, W>(self, other: Point<V, W>) -> Point<T, W> {
        Point {
            x: self.x,
            y: other.y,
        }
    }
}

fn main() {
    let p1 = Point { x: 5, y: 10.4 };
    let p2 = Point { x: "Hello", y: 'c' };

    let p3 = p1.mixup(p2);

    println!("p3.x = {}, p3.y = {}", p3.x, p3.y);
}
D:\learn\rust_test>cargo run
   Compiling rust_test v0.1.0 (D:\learn\rust_test)
    Finished dev [unoptimized + debuginfo] target(s) in 0.47s
     Running `target\debug\rust_test.exe`
p3.x = 5, p3.y = c

The purpose of this example is to demonstrate a situation where some common parameters are declared with impl, while others are declared with method definition. Here, the generic parameters T and U are declared after impl because they are used with the struct definition. The general parameters V and W are declared after fn mixing because they are only related to methods.

Code performance using generics

We may want to know whether there is a run-time cost when using generic type parameters. The good news is that Rust implements generics in such a way that code that uses generic types does not run slower than code that uses specific types.

Rust does this by singletting code that uses generics at compile time. Singleton is the process of converting general code into specific code by filling in the specific types used during compilation.

In this process, the compiler does the opposite of the steps used to create generic functions in the following example: the compiler looks at all locations where generic code is called and generates code for the specific type that calls generic code.

Let's take a look at how an example of option < T > enumeration using the standard library works:

#![allow(unused_variables)]
fn main() {
    let integer = Some(5);
    let float = Some(5.0);
}

When Rust compiles this code, it performs singleton. In this process, the compiler reads the value used in the option < T > instance and identifies two options < T >: i32 and f64. In this way, it extends the general definition of option < T > to Option_i32 and option_ To replace the general definition with a specific one.

The monomer version of the code is shown below. General option < T > is replaced by a specific definition created by the compiler:

enum Option_i32 {
    Some(i32),
    None,
}

enum Option_f64 {
    Some(f64),
    None,
}

fn main() {
    let integer = Option_i32::Some(5);
    let float = Option_f64::Some(5.0);
}

Because Rust compiles generic code into code of the type specified in each instance, we don't have to pay any runtime cost for using generic code. When the code runs, its performance is the same as when we manually copy each definition. The simplification process makes Rust's generics very efficient at run time.

Tags: Rust

Posted by majocmatt on Mon, 23 May 2022 04:27:24 +0300