Rust: Structs y Traits

Rust ofrece por defecto los tipos de datos básicos que ofrece cualquier lenguaje de programación, pero al igual que en otros lenguajes, se pueden definir tipos propios más complejos con struct.

Rust no es un lenguaje orientado a objetos, y en su documentación no se encuentra nada sobre clases, objetos, instancias, etc. Sin embargo, hay conceptos similares cuando nos adentramos en la definición de nuevos tipos con struct y también con los traits, donde sí tenemos cosas como la herencia.

Structs, "Estructuras" y tipos de datos complejos

Los tipos de datos propios en Rust se definen con la palabra clave struct. Para quien venga de C/C++ todo este código le será familiar.

struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let p = Point{x: 0, y: 0};
    println!("Punto: ({}, {})", p.x, p.y);
}

ejecutar

También existen estructuras de tipo tupla, Tuple Structs, que no son más que estructuras donde los atributos van sin nombre, por índice.

struct Point(i32, i32);

fn main() {
    let p = Point(0, 0);
    println!("Punto: ({}, {})", p.0, p.1);
}

ejecutar

Los atributos de las estructuras son privados por defecto, esto quiere decir que no son accesibles cuando se usan desde un módulo diferente al de su definición, en el módulo de su definición, todos los atributos son accesibles. Para hacer un atributo accesible a todo el mundo tan sólo hay que añadir la palabra clave pub delante, en la definición.

struct Point {
    pub x: i32,
    pub y: i32,
}

Métodos

Además de los atributos normales de las estructuras, también se pueden definir métodos que implementa esta estructura. Esto es muy similar a los métodos de la programación orientada a objetos, y en realidad serán llamados de forma similar, con el ".".

Para implementar métodos para una estructura se utiliza la palabra clave impl.

struct Point {
    x: i32,
    y: i32,
}

impl Point {
    fn new() -> Point {
        Point{x: 0, y: 0}
    }

    fn distance(&self, p: &Point) -> f32 {
        let d1 = (self.x - p.x).pow(2);
        let d2 = (self.y - p.y).pow(2);

        f32::sqrt((d1 + d2) as f32)
    }
}

impl Point {
    fn moveup(&mut self) {
        self.y += 1;
    }
}

fn main() {
    let mut p1 = Point::new();
    p1.moveup();

    let p2 = Point{x: 24, y: -30};

    println!("distancia: {}", p2.distance(&p1));
}

ejecutar

En este ejemplo hay varias cosas interesantes. Lo primero es el método new, que es un método estático, osea, no se llama desde una estructura instanciada, sino con el nombre de la estructura y el operador ::. En este caso se usa como un constructor.

Luego se implementa el método para calcular la distancia, que recibe un primer parámetro sin tipo, pero llamado self, este parámetro es la estructura en sí, y como en python es explícito, hay que ponerlo en la lista de argumentos. En este caso se define como una referencia, pero al igual que todas las definiciones de variables en Rust dependerá del uso que queramos darle, si vamos a modificar debería ser un &mut self o si queremos que vaya por copia/movimiento un simple self.

La otra cosa interesante de este ejemplo es que hay dos declaraciones de impl para la estructura Point, y en realidad puede haber cuantas se quiera, incluso se pueden añadir métodos a estructuras desde módulos diferentes, pero no desde crates diferentes.

Con esto casi podríamos decir que tenemos lo mismo que en otros lenguajes orientados a objetos, pero no tenemos herencia, polimorfismo ni nada de eso, para ello existen los Traits.

Traits, "Rasgos" de tipos

Los traits no son más que una lista de métodos con o sin implementación que las estructuras o los tipos deben implementar para cumplir ese trait.

Se definen con la palabra clave trait y una lista de funciones que definen la interfaz y luego se implementa con la construcción impl TRAIT for TYPE.

struct Point {
    x: i32,
    y: i32,
}

trait Distance {
    fn distance(&self, p: &Point) -> f32;
}

impl Distance for Point {
    fn distance(&self, p: &Point) -> f32 {
        let d1 = (self.x - p.x).pow(2);
        let d2 = (self.y - p.y).pow(2);

        f32::sqrt((d1 + d2) as f32)
    }
}

fn main() {
    let p1 = Point{x: 0, y: 0};
    let p2 = Point{x: 24, y: -30};

    println!("distancia: {}", p2.distance(&p1));
}

ejecutar

Los traits se pueden usar en las definiciones de las funciones o de las estructuras, en lugar de los tipos, para implementar funciones o tipos genéricos, con la sintaxis de tipo genérico <T: TRAIT>.

También se pueden definir implementaciones por defecto en los métodos de los traits, por lo que no es obligatorio ofrecer una implementación para esos métodos, si se ofrece una implementación estaremos sobreescribiendo ese método, si no se utilizará la implementación por defecto.

Por ejemplo, podemos definir un trait para tipos que tengan posición en un plano 2D, HasPosition, y podemos definir la distancia del trait Distance como un método que recibe un tipo que tiene posición como segundo argumento.

struct Point {
    x: i32,
    y: i32,
}

struct Circle {
    x: i32,
    y: i32,
    r: i32,
}

trait HasPosition {
    fn getx(&self) -> i32;
    fn gety(&self) -> i32;

    fn pos(&self) -> Point {
        Point { x: self.getx(), y: self.gety() }
    }
}

impl HasPosition for Circle {
    fn getx(&self) -> i32 { self.x }
    fn gety(&self) -> i32 { self.y }
}

impl HasPosition for Point {
    fn getx(&self) -> i32 { self.x }
    fn gety(&self) -> i32 { self.y }
}

trait Distance {
    fn distance<T: HasPosition>(&self, p: &T) -> f32;
}

impl Distance for Point {
    fn distance<T: HasPosition>(&self, p: &T) -> f32 {
        let d1 = (self.x - p.pos().x).pow(2);
        let d2 = (self.y - p.pos().y).pow(2);

        f32::sqrt((d1 + d2) as f32)
    }
}

fn main() {
    let p1 = Point{x: 0, y: 0};
    let p2 = Circle{x: 24, y: -30, r: 1};

    println!("distancia: {}", p1.distance(&p2));
}

ejecutar

En este ejemplo se definen dos estructuras, Point y Circle y dos traits, HasPosition y Distance, así podemos comparar distancias no sólo entre puntos, sino que también entre puntos y círculos, ya que estas dos estructuras implementan el trait HasPosition.

Los métodos getx y gety se tienen que implementar obligatoriamente, ya que no se ofrece una implementación por defecto, sin embargo, el método pos no se está sobreescribiendo en ninguna de las dos estructuras, y por lo tanto se usa la implementación por defecto del trait.

Combinación de Traits

En ocasiones queremos que un tipo implemente varios traits y esto se puede definir utilizando el símbolo "+":

fn f<T, K>(x: &T, y: &K) -> K
    where T: HasPosition,
          K: HasPosition + Distance + Clone {
    // Implementación de la función
}

En esta definición se puede ver cómo el tipo K tiene que implementar tres traits mientras que el tipo T sólo ha de implementar uno. Aquí también introduzco el uso de where para que la cabecera de la función quede más clara.

Herencia

Como he comentado antes, Rust no es un lenguaje orientado a objetos como tal, los structs y los traits no son clases, aunque el código pueda ser similar.

Aún así, con los traits se puede hacer algo similar a la herencia, utilizando el operador ":". Con esta definición se obliga a que si se implementa un trait también se tengan que implementar el resto de traits.

trait Distance: HasPosition {
    fn distance<T: HasPosition>(&self, p: &T) -> f32 {
        let p1 = self.pos();
        let p2 = p.pos();
        let d1 = (p1.x - p2.x).pow(2);
        let d2 = (p1.y - p2.y).pow(2);

        f32::sqrt((d1 + d2) as f32)
    }
}

impl Distance for Point {}
impl Distance for Circle {}

En este ejemplo se define el trait Distance, que depende del trait HasPosition.

En la herencia también se puede utilizar la combinación de traits con el operador "+".

Pero en realidad, no se puede considerar esto como herencia, ya que no hay posibilidad de llamar al método padre, por lo que no es posible encadenar llamadas de métodos por defecto cuando una estructura implementa un trait. Si se sobreescribe un método de un trait se tiene que escribir todo el código, no es posible utilizar la implementación por defecto para sobreescribir el método.

Conclusiones

Con las estructuras y los traits se puede escribir código reutilizable, definiendo funciones que reciban tipos genéricos que implementen una serie de interfaces (traits) de tal forma que el mismo código sirva para diferentes tipos.

En Rust todo el código de la librería estándar está fuertemente basado en traits y por lo tanto hay una serie de traits que es necesario conocer, porque sirven para implementar funcionamiento básico, como por ejemplo, Iterator, Copy, Clone, Debug.

Comments !