Exploración de los Tipos de Datos en Rust

Beginner

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

Introducción

Bienvenido a Tipos de Datos. Esta práctica es parte del Rust Book. Puedes practicar tus habilidades de Rust en LabEx.

En esta práctica, exploraremos el concepto de tipos de datos en Rust, donde cada valor se le asigna un tipo específico para determinar cómo se maneja, y en casos donde pueden haber múltiples tipos, se deben agregar anotaciones de tipo para proporcionar la información necesaria al compilador.

Este es un Guided Lab, que proporciona instrucciones paso a paso para ayudarte a aprender y practicar. Sigue las instrucciones cuidadosamente para completar cada paso y obtener experiencia práctica. Los datos históricos muestran que este es un laboratorio de nivel principiante con una tasa de finalización del 83%. Ha recibido una tasa de reseñas positivas del 100% por parte de los estudiantes.

Tipos de Datos

Todo valor en Rust es de un cierto tipo de datos, que le dice a Rust qué tipo de datos se está especificando para que sepa cómo trabajar con esos datos. Veremos dos subconjuntos de tipos de datos: escalares y compuestos.

Tenga en cuenta que Rust es un lenguaje fuertemente tipado, lo que significa que debe conocer los tipos de todas las variables en tiempo de compilación. El compilador suele poder inferir qué tipo queremos usar basado en el valor y cómo lo usamos. En casos en los que son posibles muchos tipos, como cuando convertimos un String a un tipo numérico usando parse en "Comparando la suposición con el número secreto", debemos agregar una anotación de tipo, como esta:

let guess: u32 = "42".parse().expect("Not a number!");

Si no agregamos la anotación de tipo : u32 mostrada en el código anterior, Rust mostrará el siguiente error, lo que significa que el compilador necesita más información de nosotros para saber qué tipo queremos usar:

$ cargo build
   Compiling no_type_annotations v0.1.0 (file:///projects/no_type_annotations)
error[E0282]: type annotations needed
 --> src/main.rs:2:9
  |
2 |     let guess = "42".parse().expect("Not a number!");
  |         ^^^^^ consider giving `guess` a type

Verá diferentes anotaciones de tipo para otros tipos de datos.

Tipos Escalares

Un tipo escalar representa un solo valor. Rust tiene cuatro tipos escalares principales: enteros, números de punto flotante, booleanos y caracteres. Es posible que los reconozcas de otros lenguajes de programación. Vamos a ver cómo funcionan en Rust.

Tipos Enteros

Un entero (integer) es un número sin componente fraccionario. Usamos un tipo entero en el Capítulo 2, el tipo u32. Esta declaración de tipo indica que el valor asociado debe ser un entero sin signo (los tipos de enteros con signo comienzan con i en lugar de u) que ocupa 32 bits de espacio. La Tabla 3-1 muestra los tipos enteros integrados en Rust. Podemos usar cualquiera de estas variantes para declarar el tipo de un valor entero.

Tabla 3-1: Tipos Enteros en Rust

Longitud Con signo Sin signo


8 bits i8 u8
16 bits i16 u16
32 bits i32 u32
64 bits i64 u64
128 bits i128 u128
arch isize usize

Cada variante puede ser con o sin signo y tiene un tamaño explícito. Con signo (signed) y sin signo (unsigned) se refieren a si es posible que el número sea negativo; en otras palabras, si el número necesita tener un signo (con signo) o si solo será positivo y, por lo tanto, puede representarse sin un signo (sin signo). Es como escribir números en papel: cuando el signo importa, un número se muestra con un signo más o un signo menos; sin embargo, cuando es seguro asumir que el número es positivo, se muestra sin signo. Los números con signo se almacenan utilizando la representación de complemento a dos (two's complement).

Cada variante con signo puede almacenar números desde -(2^(n-1)) hasta 2^(n-1) - 1 inclusive, donde n es el número de bits que usa esa variante. Así, un i8 puede almacenar números desde -(2^7) hasta 2^7 - 1, que es igual a -128 a 127. Las variantes sin signo pueden almacenar números desde 0 hasta 2^n - 1, por lo que un u8 puede almacenar números desde 0 hasta 2^8 - 1, que es igual a 0 a 255.

Además, los tipos isize y usize dependen de la arquitectura de la computadora en la que se ejecuta su programa, lo que se denota en la tabla como "arch": 64 bits si está en una arquitectura de 64 bits y 32 bits si está en una arquitectura de 32 bits.

Puede escribir literales enteros en cualquiera de las formas que se muestran en la Tabla 3-2. Tenga en cuenta que los literales numéricos que pueden ser de múltiples tipos numéricos permiten un sufijo de tipo, como 57u8, para designar el tipo. Los literales numéricos también pueden usar _ como separador visual para que el número sea más fácil de leer, como 1_000, que tendrá el mismo valor que si hubiera especificado 1000.

Tabla 3-2: Literales Enteros en Rust

Literales numéricos Ejemplo


Decimal 98_222
Hexadecimal 0xff
Octal 0o77
Binario 0b1111_0000
Byte (solo u8) b'A'

Entonces, ¿cómo saber qué tipo de entero usar? Si no está seguro, los valores predeterminados de Rust son generalmente buenos lugares para comenzar: los tipos enteros por defecto son i32. La principal situación en la que usaría isize o usize es al indexar algún tipo de colección.

Desbordamiento de Entero (Integer Overflow)

Digamos que tiene una variable de tipo u8 que puede contener valores entre 0 y 255. Si intenta cambiar la variable a un valor fuera de ese rango, como 256, ocurrirá un desbordamiento de entero (integer overflow), lo que puede resultar en uno de dos comportamientos. Cuando está compilando en modo de depuración (debug), Rust incluye comprobaciones de desbordamiento de enteros que hacen que su programa entre en pánico (panic) en tiempo de ejecución si ocurre este comportamiento. Rust usa el término pánico (panicking) cuando un programa sale con un error; discutiremos los pánicos con más detalle en "Errores Irrecuperables con panic!".

Cuando está compilando en modo de lanzamiento (release) con la bandera --release, Rust no incluye comprobaciones de desbordamiento de enteros que causan pánicos. En cambio, si ocurre un desbordamiento, Rust realiza un envoltorio de complemento a dos (two's complement wrapping). En resumen, los valores mayores que el valor máximo que el tipo puede contener "se envuelven" al mínimo de los valores que el tipo puede contener. En el caso de un u8, el valor 256 se convierte en 0, el valor 257 se convierte en 1, y así sucesivamente. El programa no entrará en pánico, pero la variable tendrá un valor que probablemente no sea el que esperaba que tuviera. Confiar en el comportamiento de envoltorio del desbordamiento de enteros se considera un error.

Para manejar explícitamente la posibilidad de desbordamiento, puede usar estas familias de métodos proporcionadas por la biblioteca estándar para tipos numéricos primitivos:

  • Envolver en todos los modos con los métodos wrapping_*, como wrapping_add.
  • Devolver el valor None si hay desbordamiento con los métodos checked_*.
  • Devolver el valor y un booleano que indica si hubo desbordamiento con los métodos overflowing_*.
  • Saturar en los valores mínimo o máximo del valor con los métodos saturating_*.

Tipos de Punto Flotante

Rust también tiene dos tipos primitivos para números de punto flotante, que son números con puntos decimales. Los tipos de punto flotante de Rust son f32 y f64, que tienen un tamaño de 32 bits y 64 bits, respectivamente. El tipo predeterminado es f64 porque en las CPUs modernas, tiene aproximadamente la misma velocidad que f32 pero es capaz de mayor precisión. Todos los tipos de punto flotante son con signo.

Crea un nuevo proyecto llamado data-types:

cargo new data-types
cd data-types

Aquí hay un ejemplo que muestra números de punto flotante en acción:

Nombre del archivo: src/main.rs

fn main() {
    let x = 2.0; // f64

    let y: f32 = 3.0; // f32
}

Los números de punto flotante se representan de acuerdo con el estándar IEEE-754. El tipo f32 es un flotante de precisión simple, y f64 tiene doble precisión.

Operaciones Numéricas

Rust admite las operaciones matemáticas básicas que esperarías para todos los tipos de números: suma, resta, multiplicación, división y residuo. La división entera se trunca hacia cero al entero más cercano. El siguiente código muestra cómo usar cada operación numérica en una declaración let:

Nombre del archivo: src/main.rs

fn main() {
    // suma
    let sum = 5 + 10;

    // resta
    let difference = 95.5 - 4.3;

    // multiplicación
    let product = 4 * 30;

    // división
    let quotient = 56.7 / 32.2;
    let truncated = -5 / 3; // Da como resultado -1

    // residuo
    let remainder = 43 % 5;
}

Cada expresión en estas declaraciones utiliza un operador matemático y se evalúa a un solo valor, que luego se asocia a una variable. El Apéndice B contiene una lista de todos los operadores que Rust proporciona.

El Tipo Booleano

Como en la mayoría de los otros lenguajes de programación, un tipo booleano en Rust tiene dos valores posibles: true y false. Los booleanos tienen un tamaño de un byte. El tipo booleano en Rust se especifica usando bool. Por ejemplo:

Nombre del archivo: src/main.rs

fn main() {
    let t = true;

    let f: bool = false; // con anotación de tipo explícita
}

La principal forma de usar valores booleanos es a través de condicionales, como una expresión if. Veremos cómo funcionan las expresiones if en Rust en "Control Flow".

El Tipo Carácter

El tipo char de Rust es el tipo alfabético más primitivo del lenguaje. Aquí hay algunos ejemplos de declaración de valores char:

Nombre del archivo: src/main.rs

fn main() {
    let c = 'z';
    let z: char = 'ℤ'; // con anotación de tipo explícita
    let heart_eyed_cat = '😻';
}

Tenga en cuenta que especificamos los literales char con comillas simples, en contraste con los literales de cadena, que usan comillas dobles. El tipo char de Rust tiene un tamaño de cuatro bytes y representa un Valor Escalar Unicode, lo que significa que puede representar mucho más que solo ASCII. Letras con acento; caracteres chinos, japoneses y coreanos; emoji; y espacios de ancho cero son todos valores char válidos en Rust. Los Valores Escalares Unicode van desde U+0000 hasta U+D7FF y U+E000 hasta U+10FFFF inclusive. Sin embargo, un "carácter" no es realmente un concepto en Unicode, por lo que su intuición humana sobre lo que es un "carácter" puede no coincidir con lo que es un char en Rust. Discutiremos este tema en detalle en "Almacenar texto codificado en UTF-8 con cadenas".

Tipos Compuestos

Los tipos compuestos pueden agrupar múltiples valores en un solo tipo. Rust tiene dos tipos compuestos primitivos: tuplas y arrays.

El Tipo Tupla

Una tupla es una forma general de agrupar una serie de valores de varios tipos en un solo tipo compuesto. Las tuplas tienen una longitud fija: una vez declaradas, no pueden crecer o contraerse en tamaño.

Creamos una tupla escribiendo una lista separada por comas de valores dentro de paréntesis. Cada posición en la tupla tiene un tipo, y los tipos de los diferentes valores en la tupla no tienen que ser los mismos. Hemos agregado anotaciones de tipo opcionales en este ejemplo:

Nombre del archivo: src/main.rs

fn main() {
    let tup: (i32, f64, u8) = (500, 6.4, 1);
}

La variable tup se asocia con toda la tupla porque una tupla se considera un solo elemento compuesto. Para extraer los valores individuales de una tupla, podemos usar la coincidencia de patrones para desestructurar un valor de tupla, como esto:

Nombre del archivo: src/main.rs

fn main() {
    let tup = (500, 6.4, 1);

    let (x, y, z) = tup;

    println!("El valor de y es: {y}");
}

Este programa primero crea una tupla y la asocia con la variable tup. Luego, utiliza un patrón con let para tomar tup y convertirla en tres variables separadas, x, y y z. Esto se llama desestructuración porque divide la tupla única en tres partes. Finalmente, el programa imprime el valor de y, que es 6.4.

También podemos acceder directamente a un elemento de tupla usando un punto (.) seguido del índice del valor que queremos acceder. Por ejemplo:

Nombre del archivo: src/main.rs

fn main() {
    let x: (i32, f64, u8) = (500, 6.4, 1);

    let five_hundred = x.0;

    let six_point_four = x.1;

    let one = x.2;
}

Este programa crea la tupla x y luego accede a cada elemento de la tupla usando sus respectivos índices. Al igual que en la mayoría de los lenguajes de programación, el primer índice en una tupla es 0.

La tupla sin ningún valor tiene un nombre especial, unit. Este valor y su tipo correspondiente se escriben ambos () y representan un valor vacío o un tipo de retorno vacío. Las expresiones devuelven implícitamente el valor unitario si no devuelven ningún otro valor.

El Tipo Array

Otra forma de tener una colección de múltiples valores es con un array. A diferencia de una tupla, cada elemento de un array debe tener el mismo tipo. A diferencia de los arrays en algunos otros lenguajes, los arrays en Rust tienen una longitud fija.

Escribimos los valores en un array como una lista separada por comas dentro de corchetes cuadrados:

Nombre del archivo: src/main.rs

fn main() {
    let a = [1, 2, 3, 4, 5];
}

Los arrays son útiles cuando quieres que tus datos se alojen en la pila en lugar de la memoria dinámica (hablaremos más sobre la pila y la memoria dinámica en el Capítulo 4) o cuando quieres asegurarte de siempre tener un número fijo de elementos. Sin embargo, un array no es tan flexible como el tipo vector. Un vector es un tipo de colección similar proporcionado por la biblioteca estándar que se puede permitir que crezca o contraiga en tamaño. Si no estás seguro de si usar un array o un vector, es probable que debas usar un vector. El Capítulo 8 discute los vectores en más detalle.

Sin embargo, los arrays son más útiles cuando sabes que el número de elementos no necesitará cambiar. Por ejemplo, si estuvieras usando los nombres de los meses en un programa, probablemente usarías un array en lugar de un vector porque sabes que siempre contendrá 12 elementos:

let months = ["January", "February", "March", "April", "May", "June", "July",
              "August", "September", "October", "November", "December"];

Escribes el tipo de un array usando corchetes cuadrados con el tipo de cada elemento, un punto y coma, y luego el número de elementos en el array, como esto:

let a: [i32; 5] = [1, 2, 3, 4, 5];

Aquí, i32 es el tipo de cada elemento. Después del punto y coma, el número 5 indica que el array contiene cinco elementos.

También puedes inicializar un array para que contenga el mismo valor para cada elemento especificando el valor inicial, seguido de un punto y coma, y luego la longitud del array en corchetes cuadrados, como se muestra aquí:

let a = [3; 5];

El array nombrado a contendrá 5 elementos que todos se establecerán inicialmente en el valor 3. Esto es lo mismo que escribir let a = [3, 3, 3, 3, 3]; pero de una manera más concisa.

Accediendo a los Elementos de un Array

Un array es un solo bloque de memoria de un tamaño conocido y fijo que se puede asignar en la pila. Puedes acceder a los elementos de un array usando índices, como esto:

Nombre del archivo: src/main.rs

fn main() {
    let a = [1, 2, 3, 4, 5];

    let first = a[0];
    let second = a[1];
}

En este ejemplo, la variable llamada first obtendrá el valor 1 porque ese es el valor en el índice [0] del array. La variable llamada second obtendrá el valor 2 del índice [1] en el array.

Acceso a Elementos de Array Inválido

Veamos qué pasa si intentas acceder a un elemento de un array que está más allá del final del array. Digamos que ejecutas este código, similar al juego de adivinanza del Capítulo 2, para obtener un índice de array del usuario:

Nombre del archivo: src/main.rs

use std::io;

fn main() {
    let a = [1, 2, 3, 4, 5];

    println!("Please enter an array index.");

    let mut index = String::new();

    io::stdin()
     .read_line(&mut index)
     .expect("Failed to read line");

    let index: usize = index
     .trim()
     .parse()
     .expect("Index entered was not a number");

    let element = a[index];

    println!(
        "The value of the element at index {index} is: {element}"
    );
}

Este código se compila correctamente. Si ejecutas este código usando cargo run y ingresas 0, 1, 2, 3 o 4, el programa imprimirá el valor correspondiente en ese índice del array. Si en cambio ingresas un número más allá del final del array, como 10, verás una salida como esta:

thread'main' panicked at 'index out of bounds: the len is 5 but the index is
10', src/main.rs:19:19
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

El programa resultó en un error en tiempo de ejecución en el momento de usar un valor inválido en la operación de indexación. El programa salió con un mensaje de error y no ejecutó la última declaración println!. Cuando intentas acceder a un elemento usando indexación, Rust verificará que el índice que has especificado sea menor que la longitud del array. Si el índice es mayor o igual que la longitud, Rust se detendrá abruptamente. Esta comprobación tiene que ocurrir en tiempo de ejecución, especialmente en este caso, porque el compilador no puede posiblemente saber qué valor un usuario ingresará cuando ejecute el código más adelante.

Este es un ejemplo de cómo se aplican los principios de seguridad de memoria de Rust. En muchos lenguajes de bajo nivel, este tipo de comprobación no se realiza, y cuando se proporciona un índice incorrecto, se puede acceder a memoria no válida. Rust te protege contra este tipo de error saliendo inmediatamente en lugar de permitir el acceso a la memoria y continuar. El Capítulo 9 discute más sobre el manejo de errores de Rust y cómo puedes escribir código legible y seguro que no se detenga abruptamente ni permita el acceso a memoria no válida.

Resumen

¡Felicitaciones! Has completado el laboratorio de Tipos de Datos. Puedes practicar más laboratorios en LabEx para mejorar tus habilidades.