5

Generics and Const Generics in Rust

 1 year ago
source link: https://sanjuvi.github.io/Blog/posts/Rust%20Generics/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

Generics and Const Generics in Rust

Theory
Author

Sanjeevi

Published

April 22, 2023

Normally we write a function or any user-defined type for solving a particular task by accepting Concrete type i.e the type is known when we pass it to them.

fn sum_i32(i: &[i32]) -> i32 {
    i.iter().sum()
}
fn sum_f32(i: &[f32]) -> f32 {
    i.iter().sum()
}
fn sum_i8(i: &[i8]) -> i8 {
    i.iter().sum()
}
    println!("{}", sum_i8(&[1i8, 4, 6, 7]));
    println!("{}", sum_i32(&[1, 2, 3, 4]));
    println!("{}", sum_f32(&[1.0f32, 2.0, 3.0, 4.0]));

In above code is used to sum the elements of a given slice. The problem here is, there are a total of ten Integers types, and two Floating types exist in Rust.

If we were to write the sum function for each type then we have totally had twelve implementations just for the summing task. What about other tasks like finding an element in a slice, the same amount of implementation is needed for every type we care about. Not only do we have more code for a simple task but also Testing them is also nontrivial since we have to write a different test for the same type and for other types which increases the code size even more which in turn increases the possibility of bugs.

This is the same for any task that involves the same algorithm for different types. To remedy this, a programming language must provide a way to abstract over algorithms by defining generically rather than just Concretely like our above code.

Each language provides some way to define them, here we see how Rust is able to define them generically and also the implication of that implementation. Before going into rust generics we have to talk about some glossaries related to Generics.

  • Generic type or Abstract type: They are like a placeholder for the actual type when we call them.
  • Concrete Type: The actual type of type when we call them.For example when we call the sum_u32 function with a slice of u32s where there is no abstraction.
  • Static Dispatch:Note that not all languages like know the type at compile time like python,perl, ruby. Knowing the type at compile time causes the compiler to generate the optimized code without any runtime overhead.This is called Monomorphization i.e the compiler generates each specific implementation for the type being called at compile time. This is not specific to generics, actually, a compiler generates the optimized code if the type is known at compile time which is why statically typed language is more efficient than dynamically typed language where the type is only known at runtime so does the error which is caught at compile time in a statically typed language. The downside of static dispatch is that they increase the compile time and binary size because the compiler generates more code behind the hood even though they look simple in our source code.
  • Dynamic or Virtual Dispatch: As the name suggests, the types are only known at runtime. This reduces the binary size and compiles time since we don’t need to generate any code until the runtime i.e when the actual code is running. To correctly call the methods at runtime, the language has a pointer to them. In Rust this is accomplished by fat pointer, a two-word size i.e 16 bytes or 128bits.No matter how many methods they have, the size is always 16 bytes at compile time.

Generics is a way to reuse the code or algorithm if they share some common operation and properties. And avoid the much boilerplate code significantly thus reducing the source code size which in turn represents less code maintenance and fewer bugs.

Advantage of Generics:

Benefits of Generics in the Context of Rust.

  • Provide some form of abstraction since we don’t have to know the details of implementation only need to know how to use them. Rust abstractions are zero cost i.e what you don’t use you don’t have to pay any overhead.
  • Achieves polymorphism because it can accept different types rather than a single concrete type.
  • Type safety: The trait bounds protect us from passing an invalid type to the API either at compile time via static dispatch or runtime via dynamic dispatch.
  • Testing is less trivial than the nongeneric version since we only check output for a different type with the same implementation. If bugs are identified through the testing then we just update the single implementation to reflect them in each type we used. Which decreases the development time.
  • Type inference: Rust is able to infer type most of the time without more explicit types.
  • Code is more organized.
  • Aid in better API design.

All the codes below are statically dispatched.

The Actual generic implementation of the above code is,

use std::iter::Sum;
fn main() {
   println!("i8 Sum: {} \nu16 Sum: {} \nusize Sum: {} \nf64 Sum: {} \nf32 Sum: {} " 
    ,generic_sum(&[1i8, 4, 6, 7]) //The actual type is known at this line and the same for all calls.
    ,generic_sum(&[1u16, 5, 9, 56])
    ,generic_sum(&[9usize, 34, 53, 57])
    ,generic_sum(&[1.9, 4.6, 6.7, 7.9])
    ,generic_sum(&[1.0f32, 2.0, 3.0, 4.0]));
}
//Generic Definition of the sum of elements.
fn generic_sum<T:Sum<T> + Copy>(i:&[T])->T{
    i.iter().copied().sum()
}

The type T is a generic type and after the colon, they are called trait bounds which says not just any type but only type that implements both the Sum and Copytrait. This is the reason why rust code like the one below won’t compile in the first place since not all types can be added. Here rust provides safety at compile time and error messages give more context to the problem, unlike C++ templates.


fn add<T>(x:T,y:T)->T{
   return x+y;
}

The above code force you use the constrain the types which save us from runtime errors.

We can define as many types as we want in the function signature. But it’s better to use a few types though.

fn add_mul<A, B>(x: A, y: A, w: B, z: B) -> B
where
    A: Add<A> + Copy,
    B: Mul<B, Output = B> + Copy,
{
    let var = x + y;
    w * z
}

Const Generics are generic over values rather than types. This is useful in a situation where we want the values to be generic instead of specific values when defining tasks. The const generic only supports Integers, Bool, Char types in the stable channel.

use std::fmt::{Debug, Display};
use std::iter::Sum;
use std::ops::{Add, Mul};
fn main() {
     //This is how we specify the type generic type explicitly.
   //In this code rust won't be able to infer the type themselves.
    const_generics1::<true, &str>("string");
    const_generics1::<false, i32>(67);

    //In this code rust is able to infer both the type and value.
    const_generics2([1, 2, 3, 4]);
    const_generics2(['a', 'b', 'c']);//[i32;4]
    let m: [char; 3] = ['a', 'b', 'a'];//[char;3]
}
fn const_generics1<const A: bool, T: Display>(i: T) {
    if A {
        println!("This is True");
    } else {
        println!("This is false");
    }
    println!("{}", i);
}
fn const_generics2<T, const N: usize>(i: [T; N])
where
    T: Debug,
{
    println!("{:?}", i);
}

Const generics are value not type, meaning we can’t use them in places where the type is required.In array type [T;N], the T is a Type and N is value.

//Using const values as type is a compile error.
fn const_generics3<const T:char,const N:usize>(i:[T;N]){}

Here we can’t use the const generics in the T place but can be used in the Nplace. Using const generics we can encode invariants to our type without catching them in runtime i.e the errors are caught at compile time. For example, the code here uses a const generics to prevent the error when adding different CUDA(Compute Unified Device Architecture) device data which is a runtime error in Python -Pytorch but they are compiled error in rust.

Implementing GPU Library in Rust is far more robust than a C implementation and is also able to use high-level language features like closure, and iterators in Low-level GPU code without causing memory error.

All the codes below are Dynamically dispatched.

For some types, we can’t know the size at compile time to statically dispatch them. Instead, they are used behind a pointer to dispatch dynamically.

Trait Objects are unsized thus they must be used behind a pointer to have size at compile time since Rust needs to know the size of all types at compile time. Here the word object has nothing to do with an object in Object Oriented Programming.

use std::mem::size_of;
fn main() {
    //let unsize1: ToString;
    //let unsize2: Fn(&str) -> bool;

    //Any type that implements Display trait.
    let sized1: &dyn ToString;
    sized1 = &45;
    let sized2: Box<String>;
    sized2 = Box::new(String::from(
        "Bytes are counted for memory-constrained devices",
    ));
    println!("{:?}", size_of::<&dyn ToString>());
    println!("{:?}", size_of::<Box<dyn Fn(&str) -> bool>>());
    println!("{:?}", size_of::<Box<String>>());
}

If we comment out the first two lines we get a compile error saying “doesn’t have a size known at compile-time”.

The size of sized1 and sized2 is 16 bytes. This is overhead for sized1 because a plain reference to an i32 is 8 bytes and a non-reference type is 4 bytes. For the sized2 this is an advantage since the plain String type takes 24 bytes but the Box<String> takes 8 bytes.

Note that Box of trait object takes 16 bytes but Box of plain types takes 8 bytes. There is a trade-off here. If you want no runtime overhead then choose static dispatch. If you care about binary size over efficiency choose dynamic dispatch. These are choosable not default options.

just because the type is known at runtime, it doesn’t mean a lack of type safety.

  • We can’t construct a type that doesn’t implement a particular type you care about at compile time.
  • We can’t call a method that is not associated with the traits at compile time.

Generics and Const Generics can be used in structs, enums, and traits too but they are not covered here.


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK