Rust logic and traits
One of the most cryptic aspects of Rust is its system of traits and implementation of functionality for associating methods with structures. To understand how it works, I propose, as an example, developing a simple blockchain that allows algorithms to be applied to data structures.
Pre-requirements
To follow this tutorial, you need to have a basic knowledge of Rust. If not, I recommend that you read my previous tutorials on Rust.
Now, let’s get to the important thing. I start with the link to the full code for the impatient.
Data and logic
Rust data structures focus on defining the information they store but not on the logic that is applied to that information.
To do this, functions that act on the data structures can be defined, as we saw in previous examples. But, a key to programming is putting together those things that are related, and separating those that are not.
For example, we see the Blockchain
structure, which lacks functionality.
/// A Struct to represent a **chain** of [`Block`] nodes.
struct Blockchain {
/// The nodes of the chain as a vector of [`Block`] structs.
blocks: Vec<Block>,
/// The timestamp of the last change.
timestamp: u128,
/// A calculated hash used to self validate.
hash: String,
}
Add functionality to a data type
In Rust, you can add functionality to an existing data type using an implementation. This allows the capabilities of a data type to be added without modifying its original definition.
For example, in this case, functionality is implemented for the Blockchain
structure so that it can create a new block for the chain, check if it is valid, and obtain the latest block.
/// Implement functionality the `Block` struct.
impl Blockchain {
/// Creates a new blockchain with a genesis block.
fn new() -> Blockchain {
let mut blockchain = Blockchain {
blocks: vec![],
timestamp: get_timestamp(),
hash: "".to_string(),
};
blockchain.mine("Genesis block".to_string());
blockchain.sign();
println!("✨ Created a new blockchain {:#?}", blockchain);
blockchain
}
/// Adds a [`Block`] to the [`Blockchain`].
/// - The [`Blockchain`] hash is updated after adding the block.
/// - The block is only added to the [`Blockchain`] if it is valid.
fn add_block(&mut self, block: Block) {
let block_clone = block.clone();
self.blocks.push(block);
self.timestamp = get_timestamp();
self.hash = self.sign();
if self.is_valid() {
println!("📘 Added block {:#?}", block_clone);
} else {
println!("📕 Removing invalid Block {:#?}", block_clone);
self.blocks.pop();
}
}
/// Returns the last block of the [`Blockchain`]
/// - Being an [`Option`], it returns none when the [`Blockchain`] is empty.
fn last_block(&self) -> Option<Block> {
if self.blocks.is_empty() {
return None;
} else {
let last_block: Block = self.blocks[self.blocks.len() - 1].clone();
return Some(last_block);
}
}
}
Similarities and differences with Object Oriented Programming
This pair of concepts, struct
, and impl
, are similar to classes and methods in Object-Oriented Programming. However, Rust is not an object-oriented language and does not use concepts such as inheritance, while polymorphism treats it very differently.
Introduction to Traits
Traits are a fundamental feature of Rust that allows you to define common behaviors for different types of data. Once defined, generic or specific implementations can be developed for certain types of data.
Traits in Rust are similar to interfaces in other programming languages, such as Java or C#, but focus more on logic than data.
Definition of Traits
A Trait is declared with the keyword trait
followed by the name and a list of methods that define the specific behavior of any data type that implements it.
For example, both the blockchain and each of its nodes must be signed and validated, so a Trait Signature
can be defined that defines the sign
and is_valid
methods.
/// Sign and validate structs where it is applied.
trait Signature {
/// Signs the struct returning a calculated hash of its content and metadata.
fn sign(&self) -> String;
/// Checks if the struct is valid by returning a boolean.
fn is_valid(&self) -> bool;
}
Traits Implementation
To implement a Trait, the keyword impl
is followed by the name and the type of data it is being implemented. Below is an example of how the Signature
Trait is implemented for the Block
structure.
/// Implement the [`Signature`] trait for the [`Block`] struct.
impl Signature for Block {
/// Signs a block by hashing it.
fn sign(&self) -> String {
let mut hasher = DefaultHasher::new();
self.hash(&mut hasher);
format!("{:x}", hasher.finish())
}
/// Checks if the block is valid by recalculating the hash
fn is_valid(&self) -> bool {
let hash = self.sign();
if self.hash != hash {
println!(
"💔 Block {} hash {} is not the expected {}",
self.index, self.hash, hash
);
return false;
}
true
}
}
You can consult the complete code on GitHub and see how that same trait is also implemented for the Blockchain
structure.
Implementation of imported traits
In addition to implementing your own traits, you can override the behavior of existing traits. For example, you can implement the Debug
trait so that the Block
structure is displayed in a more user-friendly way.
/// Implement the [`Debug`] trait for the [`Block`] struct.
impl fmt::Debug for Block {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
F,
"Block {} created at timestamp: {}, signed with hash: {} }}",
self.index, self.timestamp, self.hash
)
}
}
Likewise, you can make the Blockchain
structure printable by asking Rust to derive the Debug
trait for it.
/// A Struct to represent a **chain** of [`Block`] nodes.
#[derive(Debug)]
struct Blockchain {
/// The nodes of the chain as a vector of [`Block`] structs.
blocks: Vec<Block>,
/// The timestamp of the last change.
timestamp: u128,
/// A calculated hash used to self validate.
hash: String,
}
Use of Traits
Once implemented, its methods can be invoked on any instance of the structure to which they are applied. They can also be used as a data type in arguments and returns of functions and methods using the dyn
instruction.
As an example, I show you a function that checks and prints whether a block or chain of blocks is valid or not.
/// Utility function to check if a [`Signature`] is valid
/// - Prints a message with the result.
fn check_signature(signature: &dyn Signature) -> bool {
if signature.is_valid() {
println!("💚 The signature is valid");
true
} else {
println!("💔 The signature is not valid");
false
}
}
Conclusions
Rust is a multi-paradigm programming language, but it is not especially object-oriented. Instead, offer the trait system as a powerful feature that allows you to define common or specific behaviors for different types of data.