In our earlier exploration of the Result
and Option
enums, you encountered using <T, E>
and <T>
within angle brackets. These constructs are known as generics. Generics are a powerful feature in programming that allows you to write functions, types, and structures that can work with different data types while maintaining type safety and code reusability.
Generics in Rust
Generics in Rust enable you to write code that can work with multiple data types without duplicating logic. This enhances code flexibility and maintainability by avoiding the need to write similar functions or structures for different types. The T
and E
placeholders you saw in Result<T, E>
and <T>
within Option<T>
are examples of generic type parameters.
Using Generics in Functions
Let's start by looking at how generics are used in functions:
fn print_twice<T>(value: T) {
println!("{:?}", value);
println!("{:?}", value);
}
fn main() {
print_twice(42);
print_twice("Hello, Generics!");
}
In this example, print_twice
takes a single argument value
of type T
, representing any data type. This function can work with both integers and strings without needing separate implementations. It can also work in any data type fed into it. Try this out.
Defining a Generic Struct
You can also define structs with generic fields:
// generic-typed struct with multiple types.
pub struct ExchangeTx<T, U> {
pub amount: T,
pub exchange_rate: U,
}
pub fn use_exchange_tx() {
let dollar_to_transfer = ExchangeTx {
amount: 500, exchange_rate: 940.80
};
println!("Your dollar value is {}, and the current exchange rate is {}", dollar_to_transfer.amount, dollar_to_transfer.exchange_rate);
}
Here, ExchangeTx
is a generic struct that can hold two values of potentially different types.
Implementing Traits with Generics
Traits are features that specify the functionalities available to a particular generic type. In programming languages like Go, traits are very similar to interfaces. A trait can be bound to a generic type to make it behave in a certain way.
Traits in Rust can also make use of generics. For example, the Display
trait, which allows types to be formatted as strings, often uses generics:
use std::fmt::Display;
// Display Trait with Generics.
impl<T: Display, U:Display> ExchangeTx<T, U> {
pub fn display_exchange_tx_info(&self) {
let dollar_to_convert = ExchangeTx {
amount: 500 as f64, exchange_rate: 940.85
};
println!("Your dollar value is {}, and the current exchange rate is {}", self.amount, self.exchange_rate);
}
}
fn main() {
// Create an instance of ExchangeTx.
let exchange_tx = ExchangeTx {
amount: 500 as f64,
exchange_rate: 940.85,
};
// Call the method to display exchange transaction info.
exchange_tx.display_exchange_tx_info();
}
In this case, the display_exchange_tx_info
function takes any type that implements the Display
trait as its argument. The Clone
trait is another popularly used trait.
Constraints with Trait Bounds
You can place constraints on generic types using trait bounds to specify which traits the type must implement. This ensures that the generic code only works with types that meet those constraints:
fn show_display<T: Display>(item: T) {
println!("{}", item);
}
fn main() {
let string = "Hello, Generics!";
show_display(string);
let number = 42;
// show_display(number); // Uncommenting this line will result in an error
}
We have been using the provided traits on our generic types. Let’s create our custom trait and then see how to implement methods for generic types bounded by that trait.
For this example, let's consider a scenario where we're dealing with different types of databases in a backend system.
We'll create a trait called Database
that defines methods for connecting to the database, querying, and saving data. Then, we'll implement this trait for two types of databases: SqlDatabase
and NoSqlDatabase
.
// Define a custom trait for different types of databases.
pub trait Database {
fn connect(&self) -> bool;
fn query(&self, query: &str) -> Vec<String>;
fn save(&self, data: &str) -> bool;
}
// Implement the Database trait for an SQL database.
pub struct SqlDatabase {
pub connection_string: String,
}
// Implement the generic trait's methods for the SqlDatabase type.
impl Database for SqlDatabase {
fn connect(&self) -> bool {
println!("Connected to SQL database: {}", self.connection_string);
true
}
fn query(&self, query: &str) -> Vec<String> {
println!("Executing SQL query: {}", query);
vec!["Result 1".to_string(), "Result 2".to_string()]
}
fn save(&self, data: &str) -> bool {
println!("Saving data to SQL database: {}", data);
true
}
}
// Implement the Database trait for a NoSQL database.
pub struct NoSqlDatabase {
pub endpoint: String,
}
impl Database for NoSqlDatabase {
fn connect(&self) -> bool {
println!("Connected to NoSQL database: {}", self.endpoint);
true
}
fn query(&self, query: &str) -> Vec<String> {
println!("Executing NoSQL query: {}", query);
vec!["Document 1".to_string(), "Document 2".to_string()]
}
fn save(&self, data: &str) -> bool {
println!("Saving data to NoSQL database: {}", data);
true
}
}
fn main() {
let sql_db = SqlDatabase {
connection_string: "mysql://localhost:3306".to_string(),
};
sql_db.connect();
sql_db.query("SELECT * FROM users");
sql_db.save("New user data");
let nosql_db = NoSqlDatabase {
endpoint: "mongodb://localhost:27017".to_string(),
};
nosql_db.connect();
nosql_db.query("db.collection.find()");
nosql_db.save("New document");
}
In this example, we define a custom trait called Database
with three methods: connect
, query
, and save
. We then implement this trait for two different types of databases: SqlDatabase
and NoSqlDatabase
. Each implementation provides its logic for connecting, querying, and saving data. The main
function demonstrates how you can use these implementations to interact with different databases.
Custom traits like Database
allow you to define a common interface for different types, enabling polymorphism and code reuse in your backend system.
Note: If you put a pub
keyword before a trait definition, you do not need to put it before the methods or functions that implement the trait. If you are writing the program inside a single main.rs
file, you do not need pub
anywhere.
Generics are a fundamental concept in Rust that enables you to write versatile, reusable, and type-safe code. They are particularly useful when creating functions, structs, or traits that can work with various data types while maintaining code efficiency and readability. Generics are a key tool for building flexible and maintainable code in Rust.