Lógica y rasgos de Rust, traits

Lógica y rasgos de Rust, traits

Uno de los aspectos más crípticos de Rust es su sistema de rasgos (traits) e implementación de funcionalidad para asociar métodos con estructuras. Para comprender cómo funciona te propongo como ejemplo una desarrollar un blockchain sencilla que permita aplicar algoritmos a las estructuras de datos.

Pre requisitos

Para seguir este tutorial necesitas tener un conocimiento básico de Rust. Si no es así, te recomiendo que leas mis tutoriales anteriores sobre Rust.

Ahora sí, vamos a por lo importante. Empiezo con el enlace al código completo para impacientes.

Datos y lógica

Las estructuras de datos de Rust, se centran en definir la información que almacenan, pero no en la lógica que se aplica a esa información.

Para ello, se pueden definir funciones que actúen sobre las estructuras de datos, tal como vimos en ejemplos anteriores. Pero, una clave de la programación es juntar aquellas cosas que están relacionadas, y separar aquellas que no lo están.

Vemos como ejemplo la estructura Blockchain, que carece de toda funcionalidad.

/// 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,
}

Agregar funcionalidad a un tipo de dato

En Rust, se puede agregar funcionalidad a un tipo de dato existente mediante una implementación. Esto permite agregarle capacidades de un tipo de dato sin modificar su definición original.

Por ejemplo en este caso, se implementa funcionalidad para la estructura Blockchain de forma que pueda crear un nuevo bloque para la cadena, verificar si es válida, y obtener el último bloque.

/// 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);
        }
    }
}

Similitudes y diferencias con la Programación Orientada a Objetos

Este par de conceptos, struct y impl, son similares a las clases y métodos en la POO. Sin embargo, Rust no es un lenguaje orientado a objetos, y no usa conceptos como la herencia, mientras que el polimorfismo lo trata de forma muy diferente.

Introducción a los Traits

Los Traits son una característica fundamental de Rust que permite definir comportamientos comunes para diferentes tipos de datos. Una vez definidos se pueden desarrollar implementaciones genéricas o específicas para ciertos tipos de datos.

Los Traits en Rust son similares a las interfaces en otros lenguajes de programación, como Java o C#, pero centrándose más en la lógica que en los datos.

Definición de Traits

Un Trait se declara con la palabra clave trait seguida del nombre y una lista de métodos que definen el comportamiento específico que ha de tener cualquier tipo de dato que lo implemente.

Por ejemplo, tanto la cadena de bloques como cada uno de sus nodos deben ser firmados y validados, por lo que se puede definir un trait Signature que defina los métodos sign e is_valid.

/// 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 returning a boolean.
    fn is_valid(&self) -> bool;
}

Implementación de Traits

Para implementar un trait, se utiliza la palabra clave impl seguida del nombre y el tipo de dato al que se le está implementando. A continuación, se muestra un ejemplo de cómo se implementa el trait Signature para la estructura Block.

/// 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
    }
}

Puedes consultar el código completo en GitHub y ver cómo ese mismo trait se implementa también para la estructura Blockchain.

Implementación de traits importados

Además de implementar tus propios traits puedes sobrescribir el comportamiento de otros ya existentes. Por ejemplo, puedes implementar el trait Debug para la estructura Block se muestren de una forma más amigable.

/// 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
        )
    }
}

Así mismo, puedes hacer que la estructura Blockchain sea imprimible pidiendo a Rust que derive el trait Debug para ella.

/// 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,
}

Uso de Traits

Una vez implementados, se pueden invocar sus métodos sobre cualquier instancia de la estructura a la que se aplican. Y se pueden utilizar como tipo de dato en argumentos y retorno de funciones y métodos mediante la instrucción dyn.

Como ejemplo, te muestro una función que verifica e imprime si un bloque o una cadena de bloques es válida o no.

/// 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
    }
}

Resumen

Rust es un lenguaje de programación multiparadigma pero no especialmente Orientado a Objetos. En su lugar ofre el sistema de rasgos como una poderosa característica que permite definir comportamientos comunes o específicos para diferentes tipos de datos.