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.
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
u8que 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 unu8, 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_*, comowrapping_add.- Devolver el valor
Nonesi hay desbordamiento con los métodoschecked_*.- 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 sí 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.