Objetos de Trato para Valores Heterogéneos

RustRustBeginner
Practicar Ahora

This tutorial is from open-source community. Access the source code

💡 Este tutorial está traducido por IA desde la versión en inglés. Para ver la versión original, puedes hacer clic aquí

Introducción

Bienvenido a Using Trait Objects That Allow for Values of Different Types. Esta práctica es parte del Rust Book. Puedes practicar tus habilidades de Rust en LabEx.

En esta práctica, exploraremos cómo utilizar objetos de trato para permitir valores de diferentes tipos en una biblioteca, específicamente en el contexto de una herramienta de interfaz gráfica de usuario (GUI).


Skills Graph

%%%%{init: {'theme':'neutral'}}%%%% flowchart RL rust(("Rust")) -.-> rust/DataTypesGroup(["Data Types"]) rust(("Rust")) -.-> rust/ControlStructuresGroup(["Control Structures"]) rust(("Rust")) -.-> rust/AdvancedTopicsGroup(["Advanced Topics"]) rust(("Rust")) -.-> rust/BasicConceptsGroup(["Basic Concepts"]) rust(("Rust")) -.-> rust/FunctionsandClosuresGroup(["Functions and Closures"]) rust(("Rust")) -.-> rust/DataStructuresandEnumsGroup(["Data Structures and Enums"]) rust/BasicConceptsGroup -.-> rust/variable_declarations("Variable Declarations") rust/DataTypesGroup -.-> rust/integer_types("Integer Types") rust/DataTypesGroup -.-> rust/string_type("String Type") rust/ControlStructuresGroup -.-> rust/for_loop("for Loop") rust/FunctionsandClosuresGroup -.-> rust/function_syntax("Function Syntax") rust/FunctionsandClosuresGroup -.-> rust/expressions_statements("Expressions and Statements") rust/DataStructuresandEnumsGroup -.-> rust/method_syntax("Method Syntax") rust/AdvancedTopicsGroup -.-> rust/traits("Traits") rust/AdvancedTopicsGroup -.-> rust/operator_overloading("Traits for Operator Overloading") subgraph Lab Skills rust/variable_declarations -.-> lab-100442{{"Objetos de Trato para Valores Heterogéneos"}} rust/integer_types -.-> lab-100442{{"Objetos de Trato para Valores Heterogéneos"}} rust/string_type -.-> lab-100442{{"Objetos de Trato para Valores Heterogéneos"}} rust/for_loop -.-> lab-100442{{"Objetos de Trato para Valores Heterogéneos"}} rust/function_syntax -.-> lab-100442{{"Objetos de Trato para Valores Heterogéneos"}} rust/expressions_statements -.-> lab-100442{{"Objetos de Trato para Valores Heterogéneos"}} rust/method_syntax -.-> lab-100442{{"Objetos de Trato para Valores Heterogéneos"}} rust/traits -.-> lab-100442{{"Objetos de Trato para Valores Heterogéneos"}} rust/operator_overloading -.-> lab-100442{{"Objetos de Trato para Valores Heterogéneos"}} end

Usando Objetos de Trato que Permiten Valores de Diferentes Tipos

En el Capítulo 8, mencionamos que una limitación de los vectores es que solo pueden almacenar elementos de un solo tipo. Creamos un arreglo en la Lista 8-9 donde definimos un enum SpreadsheetCell que tenía variantes para almacenar enteros, flotantes y texto. Esto significa que podíamos almacenar diferentes tipos de datos en cada celda y todavía tener un vector que representara una fila de celdas. Esta es una solución perfectamente buena cuando nuestros elementos intercambiables son un conjunto fijo de tipos que conocemos cuando nuestro código se compila.

Sin embargo, a veces queremos que el usuario de nuestra biblioteca sea capaz de extender el conjunto de tipos que son válidos en una situación particular. Para mostrar cómo podríamos lograr esto, crearemos un ejemplo de herramienta de interfaz gráfica de usuario (GUI) que itera a través de una lista de elementos, llamando a un método draw en cada uno para dibujarlo en la pantalla, una técnica común para herramientas GUI. Crearemos un crat de biblioteca llamado gui que contiene la estructura de una biblioteca GUI. Este crat podría incluir algunos tipos para que las personas los usen, como Button o TextField. Además, los usuarios de gui querrán crear sus propios tipos que se pueden dibujar: por ejemplo, un programador podría agregar una Image y otro podría agregar un SelectBox.

No implementaremos una biblioteca GUI de pleno funcionamiento para este ejemplo, pero mostraremos cómo se encajarían las piezas. Al momento de escribir la biblioteca, no podemos conocer y definir todos los tipos que otros programadores podrían querer crear. Pero sí sabemos que gui necesita llevar un registro de muchos valores de diferentes tipos, y necesita llamar a un método draw en cada uno de estos valores de diferentes tipos. No necesita saber exactamente lo que pasará cuando llamamos al método draw, solo que el valor tendrá ese método disponible para que lo llamemos.

Para hacer esto en un lenguaje con herencia, podríamos definir una clase llamada Component que tenga un método llamado draw en ella. Las otras clases, como Button, Image y SelectBox, heredarían de Component y, por lo tanto, heredarían el método draw. Podrían cada una sobrescribir el método draw para definir su comportamiento personalizado, pero el marco podría tratar a todos los tipos como si fueran instancias de Component y llamar a draw en ellos. Pero como Rust no tiene herencia, necesitamos otra forma de estructurar la biblioteca gui para permitir que los usuarios la extiendan con nuevos tipos.

Definiendo un Trato para el Comportamiento Común

Para implementar el comportamiento que queremos que tenga gui, definiremos un trato llamado Draw que tendrá un método llamado draw. Luego podemos definir un vector que toma un objeto de trato. Un objeto de trato apunta tanto a una instancia de un tipo que implementa nuestro trato especificado como a una tabla utilizada para buscar métodos de trato en ese tipo en tiempo de ejecución. Creamos un objeto de trato especificando algún tipo de puntero, como una referencia & o un puntero inteligente Box<T>, luego la palabra clave dyn, y luego especificando el trato relevante. (Hablaremos de la razón por la cual los objetos de trato deben usar un puntero en "Tipos de Tamaño Dinámico y el Trato Sized".) Podemos usar objetos de trato en lugar de un tipo genérico o concreto. Dondequiera que usemos un objeto de trato, el sistema de tipos de Rust asegurará en tiempo de compilación que cualquier valor utilizado en ese contexto implementará el trato del objeto de trato. En consecuencia, no necesitamos conocer todos los posibles tipos en tiempo de compilación.

Hemos mencionado que, en Rust, evitamos llamar a structs y enums "objetos" para distinguirlos de los objetos de otros lenguajes. En un struct o enum, los datos en los campos del struct y el comportamiento en los bloques impl están separados, mientras que en otros lenguajes, los datos y el comportamiento combinados en un solo concepto a menudo se etiquetan como objeto. Sin embargo, los objetos de trato son más parecidos a los objetos en otros lenguajes en el sentido de que combinan datos y comportamiento. Pero los objetos de trato difieren de los objetos tradicionales en que no podemos agregar datos a un objeto de trato. Los objetos de trato no son tan útil en general como los objetos en otros lenguajes: su propósito específico es permitir la abstracción a través del comportamiento común.

La Lista 17-3 muestra cómo definir un trato llamado Draw con un método llamado draw.

Nombre de archivo: src/lib.rs

pub trait Draw {
    fn draw(&self);
}

Lista 17-3: Definición del trato Draw

Esta sintaxis debería sonar familiar a partir de nuestras discusiones sobre cómo definir tratados en el Capítulo 10. A continuación viene una sintaxis nueva: la Lista 17-4 define un struct llamado Screen que contiene un vector llamado components. Este vector es del tipo Box<dyn Draw>, que es un objeto de trato; es un sustituto para cualquier tipo dentro de un Box que implemente el trato Draw.

Nombre de archivo: src/lib.rs

pub struct Screen {
    pub components: Vec<Box<dyn Draw>>,
}

Lista 17-4: Definición del struct Screen con un campo components que contiene un vector de objetos de trato que implementan el trato Draw

En el struct Screen, definiremos un método llamado run que llamará al método draw en cada uno de sus components, como se muestra en la Lista 17-5.

Nombre de archivo: src/lib.rs

impl Screen {
    pub fn run(&self) {
        for component in self.components.iter() {
            component.draw();
        }
    }
}

Lista 17-5: Un método run en Screen que llama al método draw en cada componente

Esto funciona de manera diferente a la definición de un struct que utiliza un parámetro de tipo genérico con límites de trato. Un parámetro de tipo genérico solo puede ser sustituido por un tipo concreto a la vez, mientras que los objetos de trato permiten que múltiples tipos concretos llenen el objeto de trato en tiempo de ejecución. Por ejemplo, podríamos haber definido el struct Screen utilizando un tipo genérico y un límite de trato, como en la Lista 17-6.

Nombre de archivo: src/lib.rs

pub struct Screen<T: Draw> {
    pub components: Vec<T>,
}

impl<T> Screen<T>
where
    T: Draw,
{
    pub fn run(&self) {
        for component in self.components.iter() {
            component.draw();
        }
    }
}

Lista 17-6: Una implementación alternativa del struct Screen y su método run utilizando genéricos y límites de trato

Esto nos restringe a una instancia de Screen que tiene una lista de componentes todos del tipo Button o todos del tipo TextField. Si solo tendrás colecciones homogéneas, es preferible utilizar genéricos y límites de trato porque las definiciones se monomorfizarán en tiempo de compilación para utilizar los tipos concretos.

Por otro lado, con el método que utiliza objetos de trato, una instancia de Screen puede contener un Vec<T> que contiene un Box<Button> así como un Box<TextField>. Veamos cómo funciona esto, y luego hablaremos de las implicaciones de rendimiento en tiempo de ejecución.

Implementando el Trato

Ahora agregaremos algunos tipos que implementen el trato Draw. Proporcionaremos el tipo Button. Una vez más, implementar en realidad una biblioteca GUI está fuera del alcance de este libro, por lo que el método draw no tendrá ninguna implementación útil en su cuerpo. Para imaginar cómo podría ser la implementación, un struct Button podría tener campos para width, height y label, como se muestra en la Lista 17-7.

Nombre de archivo: src/lib.rs

pub struct Button {
    pub width: u32,
    pub height: u32,
    pub label: String,
}

impl Draw for Button {
    fn draw(&self) {
        // código para realmente dibujar un botón
    }
}

Lista 17-7: Un struct Button que implementa el trato Draw

Los campos width, height y label en Button difirán de los campos en otros componentes; por ejemplo, un tipo TextField podría tener esos mismos campos más un campo placeholder. Cada uno de los tipos que queremos dibujar en la pantalla implementará el trato Draw pero usará código diferente en el método draw para definir cómo dibujar ese tipo particular, como lo tiene Button aquí (sin el código GUI real, como se mencionó). El tipo Button, por ejemplo, podría tener un bloque impl adicional que contenga métodos relacionados con lo que sucede cuando un usuario hace clic en el botón. Este tipo de métodos no se aplicará a tipos como TextField.

Si alguien que usa nuestra biblioteca decide implementar un struct SelectBox que tiene campos width, height y options, también implementarán el trato Draw en el tipo SelectBox, como se muestra en la Lista 17-8.

Nombre de archivo: src/main.rs

use gui::Draw;

struct SelectBox {
    width: u32,
    height: u32,
    options: Vec<String>,
}

impl Draw for SelectBox {
    fn draw(&self) {
        // código para realmente dibujar una caja de selección
    }
}

Lista 17-8: Otro crat que usa gui e implementa el trato Draw en un struct SelectBox

El usuario de nuestra biblioteca ahora puede escribir su función main para crear una instancia de Screen. A la instancia de Screen, pueden agregar una SelectBox y un Button poniendo cada uno en un Box<T> para convertirse en un objeto de trato. Luego pueden llamar al método run en la instancia de Screen, que llamará a draw en cada uno de los componentes. La Lista 17-9 muestra esta implementación.

Nombre de archivo: src/main.rs

use gui::{Button, Screen};

fn main() {
    let screen = Screen {
        components: vec![
            Box::new(SelectBox {
                width: 75,
                height: 10,
                options: vec![
                    String::from("Yes"),
                    String::from("Maybe"),
                    String::from("No"),
                ],
            }),
            Box::new(Button {
                width: 50,
                height: 10,
                label: String::from("OK"),
            }),
        ],
    };

    screen.run();
}

Lista 17-9: Usando objetos de trato para almacenar valores de diferentes tipos que implementan el mismo trato

Cuando escribimos la biblioteca, no sabíamos que alguien podría agregar el tipo SelectBox, pero la implementación de Screen fue capaz de operar en el nuevo tipo y dibujarlo porque SelectBox implementa el trato Draw, lo que significa que implementa el método draw.

Este concepto, de preocuparse solo por los mensajes a los que responde un valor en lugar del tipo concreto del valor, es similar al concepto de duck typing en los lenguajes de tipado dinámico: si camina como un pato y cuac como un pato, entonces debe ser un pato! En la implementación de run en Screen en la Lista 17-5, run no necesita saber cuál es el tipo concreto de cada componente. No verifica si un componente es una instancia de un Button o un SelectBox, simplemente llama al método draw en el componente. Al especificar Box<dyn Draw> como el tipo de los valores en el vector components, hemos definido Screen para necesitar valores en los que podemos llamar al método draw.

La ventaja de usar objetos de trato y el sistema de tipos de Rust para escribir código similar al código que usa duck typing es que nunca tenemos que verificar si un valor implementa un método particular en tiempo de ejecución o preocuparnos por obtener errores si un valor no implementa un método pero lo llamamos de todos modos. Rust no compilará nuestro código si los valores no implementan los tratados que necesitan los objetos de trato.

Por ejemplo, la Lista 17-10 muestra lo que sucede si intentamos crear un Screen con una String como componente.

Nombre de archivo: src/main.rs

use gui::Screen;

fn main() {
    let screen = Screen {
        components: vec![Box::new(String::from("Hi"))],
    };

    screen.run();
}

Lista 17-10: Intentando usar un tipo que no implementa el trato del objeto de trato

Obtendremos este error porque String no implementa el trato Draw:

error[E0277]: the trait bound `String: Draw` is not satisfied
 --> src/main.rs:5:26
  |
5 |         components: vec![Box::new(String::from("Hi"))],
  |                          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `Draw` is
not implemented for `String`
  |
  = note: required for the cast to the object type `dyn Draw`

Este error nos avisa de que ya sea que estemos pasando algo a Screen que no queríamos pasar y por lo tanto deberíamos pasar un tipo diferente, o que deberíamos implementar Draw en String para que Screen sea capaz de llamar a draw en ella.

Los Objetos de Trato Realizan el Despacho Dinámico

Recuerde en "Rendimiento del Código que Utiliza Genéricos" nuestra discusión sobre el proceso de monomorfización realizado por el compilador cuando usamos límites de trato en genéricos: el compilador genera implementaciones no genéricas de funciones y métodos para cada tipo concreto que usamos en lugar de un parámetro de tipo genérico. El código que resulta de la monomorfización está realizando despacho estático, que es cuando el compilador sabe qué método está llamando en tiempo de compilación. Esto se opone al despacho dinámico, que es cuando el compilador no puede decir en tiempo de compilación qué método está llamando. En casos de despacho dinámico, el compilador emite código que en tiempo de ejecución determinará qué método llamar.

Cuando usamos objetos de trato, Rust debe usar despacho dinámico. El compilador no conoce todos los tipos que podrían usarse con el código que está usando objetos de trato, por lo que no sabe qué método implementado en qué tipo llamar. En cambio, en tiempo de ejecución, Rust utiliza los punteros dentro del objeto de trato para saber qué método llamar. Esta búsqueda implica un costo en tiempo de ejecución que no ocurre con el despacho estático. El despacho dinámico también impide que el compilador elija insertar en línea el código de un método, lo que a su vez impide algunas optimizaciones. Sin embargo, obtuvimos una flexibilidad adicional en el código que escribimos en la Lista 17-5 y pudimos admitir en la Lista 17-9, por lo que es un trato que considerar.

Resumen

¡Felicitaciones! Has completado el laboratorio de Usar Objetos de Trato que Permiten Valores de Diferentes Tipos. Puedes practicar más laboratorios en LabEx para mejorar tus habilidades.