Eliminación de duplicados mediante la extracción de una función

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 Eliminación de duplicados mediante extracción de una función. Esta práctica es parte del Rust Book. Puedes practicar tus habilidades de Rust en LabEx.

En esta práctica, aprenderemos cómo eliminar la duplicación de código mediante la extracción de una función y el uso de genéricos para operar en tipos abstractos.

Eliminación de duplicados mediante extracción de una función

Los genéricos nos permiten reemplazar tipos específicos con un marcador de posición que representa múltiples tipos para eliminar la duplicación de código. Antes de adentrarnos en la sintaxis de los genéricos, primero veamos cómo eliminar la duplicación de una manera que no involucre tipos genéricos mediante la extracción de una función que reemplaza valores específicos con un marcador de posición que representa múltiples valores. Luego aplicaremos la misma técnica para extraer una función genérica. Al ver cómo reconocer el código duplicado que se puede extraer en una función, comenzarás a reconocer el código duplicado que puede utilizar genéricos.

Comenzaremos con el programa corto de la Lista 10-1 que encuentra el número más grande en una lista.

Nombre de archivo: src/main.rs

fn main() {
  1 let number_list = vec![34, 50, 25, 100, 65];

  2 let mut largest = &number_list[0];

  3 for number in &number_list {
      4 if number > largest {
          5 largest = number;
        }
    }

    println!("El número más grande es {largest}");
}

Lista 10-1: Encontrando el número más grande en una lista de números

Almacenamos una lista de enteros en la variable number_list [1] y colocamos una referencia al primer número de la lista en una variable llamada largest [2]. Luego iteramos a través de todos los números de la lista [3], y si el número actual es mayor que el número almacenado en largest [4], reemplazamos la referencia en esa variable [5]. Sin embargo, si el número actual es menor o igual al número más grande visto hasta ahora, la variable no cambia y el código pasa al siguiente número de la lista. Después de considerar todos los números de la lista, largest debería referirse al número más grande, que en este caso es 100.

Ahora nos han encomendado la tarea de encontrar el número más grande en dos listas diferentes de números. Para hacer eso, podemos elegir duplicar el código de la Lista 10-1 y usar la misma lógica en dos lugares diferentes del programa, como se muestra en la Lista 10-2.

Nombre de archivo: src/main.rs

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let mut largest = &number_list[0];

    for number in &number_list {
        if number > largest {
            largest = number;
        }
    }

    println!("El número más grande es {largest}");

    let number_list = vec![102, 34, 6000, 89, 54, 2, 43, 8];

    let mut largest = &number_list[0];

    for number in &number_list {
        if number > largest {
            largest = number;
        }
    }

    println!("El número más grande es {largest}");
}

Lista 10-2: Código para encontrar el número más grande en dos listas de números

Aunque este código funciona, duplicar el código es tedioso y propenso a errores. También tenemos que recordar actualizar el código en múltiples lugares cuando queremos cambiarlo.

Para eliminar esta duplicación, crearemos una abstracción definiendo una función que opere en cualquier lista de enteros pasada como parámetro. Esta solución hace que nuestro código sea más claro y nos permite expresar el concepto de encontrar el número más grande en una lista de manera abstracta.

En la Lista 10-3, extraemos el código que encuentra el número más grande en una función llamada largest. Luego llamamos a la función para encontrar el número más grande en las dos listas de la Lista 10-2. También podríamos usar la función en cualquier otra lista de valores de i32 que podamos tener en el futuro.

Nombre de archivo: src/main.rs

fn largest(list: &[i32]) -> &i32 {
    let mut largest = &list[0];

    for item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let result = largest(&number_list);
    println!("El número más grande es {result}");

    let number_list = vec![102, 34, 6000, 89, 54, 2, 43, 8];

    let result = largest(&number_list);
    println!("El número más grande es {result}");
}

Lista 10-3: Código abstraído para encontrar el número más grande en dos listas

La función largest tiene un parámetro llamado list, que representa cualquier fragmento concreto de valores de i32 que podamos pasar a la función. Como resultado, cuando llamamos a la función, el código se ejecuta en los valores específicos que pasamos.

En resumen, estos son los pasos que dimos para cambiar el código de la Lista 10-2 a la Lista 10-3:

  1. Identificar el código duplicado.
  2. Extraer el código duplicado al cuerpo de la función y especificar las entradas y los valores de retorno de ese código en la firma de la función.
  3. Actualizar las dos instancias de código duplicado para llamar a la función en lugar de eso.

A continuación, usaremos estos mismos pasos con genéricos para reducir la duplicación de código. De la misma manera que el cuerpo de la función puede operar en una list abstracta en lugar de valores específicos, los genéricos permiten que el código opere en tipos abstractos.

Por ejemplo, digamos que tuviéramos dos funciones: una que encuentra el elemento más grande en un fragmento de valores de i32 y otra que encuentra el elemento más grande en un fragmento de valores de char. ¿Cómo eliminaremos esa duplicación? Vamos a averiguarlo!

Tipos de datos genéricos

Usamos genéricos para crear definiciones de elementos como firmas de funciones o structs, que luego podemos usar con muchos tipos de datos concretos diferentes. Primero veamos cómo definir funciones, structs, enums y métodos usando genéricos. Luego discutiremos cómo los genéricos afectan el rendimiento del código.

En definiciones de funciones

Al definir una función que utiliza genéricos, colocamos los genéricos en la firma de la función donde normalmente especificaríamos los tipos de datos de los parámetros y el valor de retorno. Hacer esto hace que nuestro código sea más flexible y proporciona más funcionalidad a los llamantes de nuestra función mientras evita la duplicación de código.

Continuando con nuestra función largest, la Lista 10-4 muestra dos funciones que ambas encuentran el valor más grande en un slice. Luego combinaremos estas en una sola función que utiliza genéricos.

Nombre de archivo: src/main.rs

fn largest_i32(list: &[i32]) -> &i32 {
    let mut largest = &list[0];

    for item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn largest_char(list: &[char]) -> &char {
    let mut largest = &list[0];

    for item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let result = largest_i32(&number_list);
    println!("The largest number is {result}");

    let char_list = vec!['y','m', 'a', 'q'];

    let result = largest_char(&char_list);
    println!("The largest char is {result}");
}

Lista 10-4: Dos funciones que difieren solo en sus nombres y en los tipos en sus firmas

La función largest_i32 es la que extrajimos en la Lista 10-3 que encuentra el i32 más grande en un slice. La función largest_char encuentra el char más grande en un slice. Los cuerpos de las funciones tienen el mismo código, así que eliminemos la duplicación introduciendo un parámetro de tipo genérico en una sola función.

Para parametrizar los tipos en una nueva función única, necesitamos nombrar el parámetro de tipo, al igual que lo hacemos para los parámetros de valor de una función. Puedes usar cualquier identificador como nombre de parámetro de tipo. Pero usaremos T porque, por convención, los nombres de parámetros de tipo en Rust son cortos, a menudo solo una letra, y la convención de nombrado de tipos de Rust es CamelCase. Corto para tipo, T es la elección predeterminada de la mayoría de los programadores de Rust.

Cuando usamos un parámetro en el cuerpo de la función, tenemos que declarar el nombre del parámetro en la firma para que el compilador sepa qué significa ese nombre. Del mismo modo, cuando usamos un nombre de parámetro de tipo en una firma de función, tenemos que declarar el nombre del parámetro de tipo antes de usarlo. Para definir la función genérica largest, colocamos las declaraciones de nombres de tipo dentro de corchetes angulares, <>, entre el nombre de la función y la lista de parámetros, así:

fn largest<T>(list: &[T]) -> &T {

Leemos esta definición como: la función largest es genérica sobre algún tipo T. Esta función tiene un parámetro llamado list, que es un slice de valores del tipo T. La función largest devolverá una referencia a un valor del mismo tipo T.

La Lista 10-5 muestra la definición combinada de la función largest que utiliza el tipo de datos genérico en su firma. La lista también muestra cómo podemos llamar a la función con un slice de valores de i32 o char valores. Tenga en cuenta que este código todavía no se compilará, pero lo corregiremos más adelante en este capítulo.

Nombre de archivo: src/main.rs

fn largest<T>(list: &[T]) -> &T {
    let mut largest = &list[0];

    for item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let result = largest(&number_list);
    println!("The largest number is {result}");

    let char_list = vec!['y','m', 'a', 'q'];

    let result = largest(&char_list);
    println!("The largest char is {result}");
}

Lista 10-5: La función largest que utiliza parámetros de tipo genérico; esto todavía no se compila

Si compilamos este código ahora, obtendremos este error:

error[E0369]: binary operation `>` cannot be applied to type `&T`
 --> src/main.rs:5:17
  |
5 |         if item > largest {
  |            ---- ^ ------- &T
  |            |
  |            &T
  |
help: consider restricting type parameter `T`
  |
1 | fn largest<T: std::cmp::PartialOrd>(list: &[T]) -> &T {
  |             ++++++++++++++++++++++

El texto de ayuda menciona std::cmp::PartialOrd, que es un trait, y vamos a hablar sobre traits en la siguiente sección. Por ahora, sabe que este error establece que el cuerpo de largest no funcionará para todos los posibles tipos que T podría ser. Debido a que queremos comparar valores del tipo T en el cuerpo, solo podemos usar tipos cuyos valores pueden ser ordenados. Para habilitar las comparaciones, la biblioteca estándar tiene el trait std::cmp::PartialOrd que se puede implementar en tipos (vea el Apéndice C para más información sobre este trait). Siguiendo la sugerencia del texto de ayuda, restringimos los tipos válidos para T solo a aquellos que implementan PartialOrd y este ejemplo se compilará, porque la biblioteca estándar implementa PartialOrd tanto en i32 como en char.

En definiciones de structs

También podemos definir structs para usar un parámetro de tipo genérico en uno o más campos usando la sintaxis <>. La Lista 10-6 define un struct Point<T> para almacenar valores de coordenadas x e y de cualquier tipo.

Nombre de archivo: src/main.rs

1 struct Point<T> {
  2 x: T,
  3 y: T,
}

fn main() {
    let integer = Point { x: 5, y: 10 };
    let float = Point { x: 1.0, y: 4.0 };
}

Lista 10-6: Un struct Point<T> que almacena valores de x e y del tipo T

La sintaxis para usar genéricos en definiciones de structs es similar a la usada en definiciones de funciones. Primero declaramos el nombre del parámetro de tipo dentro de corchetes angulares justo después del nombre del struct [1]. Luego usamos el tipo genérico en la definición del struct donde de lo contrario especificaríamos tipos de datos concretos [23].

Tenga en cuenta que como solo hemos usado un tipo genérico para definir Point<T>, esta definición dice que el struct Point<T> es genérico sobre algún tipo T, y los campos x e y son ambos ese mismo tipo, sea cual sea ese tipo. Si creamos una instancia de un Point<T> que tiene valores de diferentes tipos, como en la Lista 10-7, nuestro código no se compilará.

Nombre de archivo: src/main.rs

struct Point<T> {
    x: T,
    y: T,
}

fn main() {
    let wont_work = Point { x: 5, y: 4.0 };
}

Lista 10-7: Los campos x e y deben ser del mismo tipo porque ambos tienen el mismo tipo de datos genérico T.

En este ejemplo, cuando asignamos el valor entero 5 a x, le decimos al compilador que el tipo genérico T será un entero para esta instancia de Point<T>. Luego, cuando especificamos 4.0 para y, que hemos definido para tener el mismo tipo que x, obtendremos un error de tipo no coincidente como este:

error[E0308]: mismatched types
 --> src/main.rs:7:38
  |
7 |     let wont_work = Point { x: 5, y: 4.0 };
  |                                      ^^^ expected integer, found floating-
point number

Para definir un struct Point donde x e y son ambos genéricos pero pueden tener diferentes tipos, podemos usar múltiples parámetros de tipo genérico. Por ejemplo, en la Lista 10-8, cambiamos la definición de Point para que sea genérica sobre los tipos T y U donde x es del tipo T y y es del tipo U.

Nombre de archivo: src/main.rs

struct Point<T, U> {
    x: T,
    y: U,
}

fn main() {
    let both_integer = Point { x: 5, y: 10 };
    let both_float = Point { x: 1.0, y: 4.0 };
    let integer_and_float = Point { x: 5, y: 4.0 };
}

Lista 10-8: Un Point<T, U> genérico sobre dos tipos para que x e y puedan ser valores de diferentes tipos

Ahora todas las instancias de Point mostradas están permitidas. Puede usar tantos parámetros de tipo genérico en una definición como desee, pero usar más de unos cuantos hace que su código sea difícil de leer. Si encuentra que necesita muchos tipos genéricos en su código, podría indicar que su código necesita ser reorganizado en piezas más pequeñas.

En definiciones de enums

Como lo hicimos con los structs, podemos definir enums para almacenar tipos de datos genéricos en sus variantes. Echemos otro vistazo al enum Option<T> que proporciona la biblioteca estándar y que usamos en el Capítulo 6:

enum Option<T> {
    Some(T),
    None,
}

Esta definición ahora debería tener más sentido para usted. Como puede ver, el enum Option<T> es genérico sobre el tipo T y tiene dos variantes: Some, que almacena un valor del tipo T, y una variante None que no almacena ningún valor. Al usar el enum Option<T>, podemos expresar el concepto abstracto de un valor opcional, y debido a que Option<T> es genérico, podemos usar esta abstracción sin importar el tipo del valor opcional.

Los enums también pueden usar múltiples tipos genéricos. La definición del enum Result que usamos en el Capítulo 9 es un ejemplo:

enum Result<T, E> {
    Ok(T),
    Err(E),
}

El enum Result es genérico sobre dos tipos, T y E, y tiene dos variantes: Ok, que almacena un valor del tipo T, y Err, que almacena un valor del tipo E. Esta definición hace conveniente usar el enum Result en cualquier lugar donde tengamos una operación que puede tener éxito (devolver un valor de algún tipo T) o fracasar (devolver un error de algún tipo E). De hecho, esto es lo que usamos para abrir un archivo en la Lista 9-3, donde T se llenó con el tipo std::fs::File cuando el archivo se abrió correctamente y E se llenó con el tipo std::io::Error cuando hubo problemas al abrir el archivo.

Cuando reconozca situaciones en su código con múltiples definiciones de struct o enum que difieren solo en los tipos de los valores que almacenan, puede evitar la duplicación usando tipos genéricos en lugar de eso.

En definiciones de métodos

Podemos implementar métodos en structs y enums (como lo hicimos en el Capítulo 5) y también usar tipos genéricos en sus definiciones. La Lista 10-9 muestra el struct Point<T> que definimos en la Lista 10-6 con un método llamado x implementado en él.

Nombre de archivo: src/main.rs

struct Point<T> {
    x: T,
    y: T,
}

impl<T> Point<T> {
    fn x(&self) -> &T {
        &self.x
    }
}

fn main() {
    let p = Point { x: 5, y: 10 };

    println!("p.x = {}", p.x());
}

Lista 10-9: Implementando un método llamado x en el struct Point<T> que devolverá una referencia al campo x del tipo T

Aquí, hemos definido un método llamado x en Point<T> que devuelve una referencia a los datos en el campo x.

Tenga en cuenta que tenemos que declarar T justo después de impl para poder usar T para especificar que estamos implementando métodos en el tipo Point<T>. Al declarar T como un tipo genérico después de impl, Rust puede identificar que el tipo en los corchetes angulares en Point es un tipo genérico en lugar de un tipo concrete. Podríamos haber elegido un nombre diferente para este parámetro genérico que el parámetro genérico declarado en la definición del struct, pero usar el mismo nombre es convencional. Los métodos escritos dentro de un impl que declara el tipo genérico se definirán en cualquier instancia del tipo, sin importar qué tipo concrete termine sustituyendo al tipo genérico.

También podemos especificar restricciones en los tipos genéricos al definir métodos en el tipo. Podríamos, por ejemplo, implementar métodos solo en instancias de Point<f32> en lugar de en instancias de Point<T> con cualquier tipo genérico. En la Lista 10-10 usamos el tipo concrete f32, lo que significa que no declaramos ningún tipo después de impl.

Nombre de archivo: src/main.rs

impl Point<f32> {
    fn distance_from_origin(&self) -> f32 {
        (self.x.powi(2) + self.y.powi(2)).sqrt()
    }
}

Lista 10-10: Un bloque impl que solo se aplica a un struct con un tipo concrete particular para el parámetro de tipo genérico T

Este código significa que el tipo Point<f32> tendrá un método distance_from_origin; otras instancias de Point<T> donde T no es del tipo f32 no tendrán este método definido. El método mide qué tan lejos está nuestro punto del punto en las coordenadas (0.0, 0.0) y utiliza operaciones matemáticas que solo están disponibles para tipos de punto flotante.

Los parámetros de tipo genérico en una definición de struct no siempre son los mismos que los que se usan en las firmas de métodos de ese mismo struct. La Lista 10-11 usa los tipos genéricos X1 y Y1 para el struct Point y X2 Y2 para la firma del método mixup para que el ejemplo sea más claro. El método crea una nueva instancia de Point con el valor de x del Point self (del tipo X1) y el valor de y del Point pasado como argumento (del tipo Y2).

Nombre de archivo: src/main.rs

struct Point<X1, Y1> {
    x: X1,
    y: Y1,
}

1 impl<X1, Y1> Point<X1, Y1> {
  2 fn mixup<X2, Y2>(
        self,
        other: Point<X2, Y2>,
    ) -> Point<X1, Y2> {
        Point {
            x: self.x,
            y: other.y,
        }
    }
}

fn main() {
  3 let p1 = Point { x: 5, y: 10.4 };
  4 let p2 = Point { x: "Hello", y: 'c' };

  5 let p3 = p1.mixup(p2);

  6 println!("p3.x = {}, p3.y = {}", p3.x, p3.y);
}

Lista 10-11: Un método que usa tipos genéricos diferentes de su definición de struct

En main, hemos definido un Point que tiene un i32 para x (con el valor 5) y un f64 para y (con el valor 10.4 [3]). La variable p2 es un struct Point que tiene una porción de cadena para x (con el valor "Hello") y un char para y (con el valor c [4]). Llamar a mixup en p1 con el argumento p2 nos da p3 [5], que tendrá un i32 para x porque x vino de p1. La variable p3 tendrá un char para y porque y vino de p2. La llamada a la macro println! [6] imprimirá p3.x = 5, p3.y = c.

El propósito de este ejemplo es demostrar una situación en la que algunos parámetros genéricos se declaran con impl y algunos se declaran con la definición del método. Aquí, los parámetros genéricos X1 y Y1 se declaran después de impl [1] porque van con la definición del struct. Los parámetros genéricos X2 y Y2 se declaran después de fn mixup [2] porque solo son relevantes para el método.

Rendimiento del código que utiliza genéricos

Es posible que te estés preguntando si hay un costo de tiempo de ejecución al usar parámetros de tipo genérico. La buena noticia es que usar tipos genéricos no hará que tu programa se ejecute más lentamente que con tipos concretos.

Rust logra esto mediante la monomorfización del código que utiliza genéricos en tiempo de compilación. La monomorfización es el proceso de convertir el código genérico en código específico rellenando los tipos concretos que se usan durante la compilación. En este proceso, el compilador hace lo contrario de los pasos que usamos para crear la función genérica en la Lista 10-5: el compilador examina todos los lugares donde se llama al código genérico y genera código para los tipos concretos con los que se llama al código genérico.

Veamos cómo funciona esto usando el enum genérico Option<T> de la biblioteca estándar:

let integer = Some(5);
let float = Some(5.0);

Cuando Rust compila este código, realiza la monomorfización. Durante ese proceso, el compilador lee los valores que se han usado en las instancias de Option<T> e identifica dos tipos de Option<T>: uno es i32 y el otro es f64. En consecuencia, expande la definición genérica de Option<T> en dos definiciones especializadas para i32 y f64, reemplazando así la definición genérica con las específicas.

La versión monomorfizada del código se ve similar a la siguiente (el compilador usa nombres diferentes a los que estamos usando aquí para fines de ilustración):

Nombre de archivo: src/main.rs

enum Option_i32 {
    Some(i32),
    None,
}

enum Option_f64 {
    Some(f64),
    None,
}

fn main() {
    let integer = Option_i32::Some(5);
    let float = Option_f64::Some(5.0);
}

El genérico Option<T> se reemplaza con las definiciones específicas creadas por el compilador. Debido a que Rust compila el código genérico en código que especifica el tipo en cada instancia, no pagamos ningún costo de tiempo de ejecución por usar genéricos. Cuando el código se ejecuta, funciona exactamente igual que si hubiéramos duplicado cada definición a mano. El proceso de monomorfización hace que los genéricos de Rust sean extremadamente eficientes en tiempo de ejecución.

Resumen

¡Felicitaciones! Has completado el laboratorio de Eliminación de duplicados mediante la extracción de una función. Puedes practicar más laboratorios en LabEx para mejorar tus habilidades.