Tipos de datos
Cada valor en Rust es de un cierto tipo de dato, que le dice a Rust qué tipo de dato se está especificando para que sepa cómo trabajar con ese dato. Veremos dos subconjuntos de tipos de datos: escalares y compuestos.
Tenga en cuenta que Rust es un lenguaje estáticamente tipado, lo que significa
que debe conocer los tipos de todas las variables en tiempo de compilación. El
compilador generalmente puede inferir qué tipo queremos usar en función del
valor y cómo lo usamos. En los casos en que muchos tipos son posibles, como
cuando convertimos un String
en un tipo numérico usando parse
en la sección
“Comparando la Adivinanza con el Número Secreto”
del capítulo 2, debemos agregar una anotación de tipo, como
esta:
#![allow(unused)] fn main() { 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[E0284]: type annotations needed
--> src/main.rs:2:9
|
2 | let guess = "42".parse().expect("Not a number!");
| ^^^^^ ----- type must be known at this point
|
= note: cannot satisfy `<_ as FromStr>::Err == _`
help: consider giving `guess` an explicit type
|
2 | let guess: /* Type */ = "42".parse().expect("Not a number!");
| ++++++++++++
For more information about this error, try `rustc --explain E0284`.
error: could not compile `no_type_annotations` (bin "no_type_annotations") due to 1 previous error
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. Puede reconocerlos de otros lenguajes de programación. Vamos a ver cómo funcionan en Rust.
Tipos de Enteros
Un entero es un número sin componente fraccionario. Usamos un tipo de entero
en el capítulo 2, el tipo u32
. Esta declaración de tipo indica que el valor
con el que está 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 de 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
Tamaño | Signed | Unsigned |
---|---|---|
8-bit | i8 | u8 |
16-bit | i16 | u16 |
32-bit | i32 | u32 |
64-bit | i64 | u64 |
128-bit | i128 | u128 |
arch | isize | usize |
Cada variante puede ser signed (con signo) o unsigned (sin signo) y tiene un tamaño explícito. Signed y unsigned se refieren a si es posible que el número sea negativo, es decir, si el número necesita tener un signo con él (signed) o si solo será positivo y por lo tanto puede representarse sin signo (unsigned). 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 suponer que el número es positivo, se muestra sin signo. Los números con signo se almacenan usando la representación de complemento a dos.
Cada variante con signo puede almacenar números de -(2n - 1)
a 2n - 1 - 1, donde n es el número de bits que usa la variante.
Así, un i8
puede almacenar números de -(27) a 27 - 1,
lo que equivale a -128 a 127. Las variantes sin signo pueden almacenar números
de 0 a 2n - 1, por lo que un u8
puede almacenar números de 0 a 28 - 1,
lo que equivale 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, 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
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 un
separador visual para facilitar la lectura del número, 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 |
Hex | 0xff |
Octal | 0o77 |
Binario | 0b1111_0000 |
Byte (u8 solamente) | b'A' |
Entonces, ¿cómo sabe qué tipo de entero usar? Si no está seguro, los valores
predeterminados de Rust son generalmente buenos lugares para comenzar: los
tipos enteros se configuran predeterminadamente en i32
. La situación
principal en la que usaría isize
o usize
es cuando indexa algún tipo de
colección.
Desbordamiento de enteros
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, se producirá un desbordamiento de enteros, que puede resultar en uno de dos comportamientos. Cuando está compilando en modo de depuración, Rust incluye comprobaciones para el desbordamiento de enteros que hacen que su programa se desborde en tiempo de ejecución si ocurre este comportamiento. Rust usa el término desbordamiento cuando un programa sale con un error; discutiremos los desbordamientos con más profundidad en la sección “Errores irrecuperables conpanic!
” del Capítulo 9.Cuando está compilando en modo de lanzamiento con la bandera
--release
, Rust no incluye comprobaciones para el desbordamiento de enteros que provocan desbordamientos. En su lugar, si ocurre un desbordamiento, Rust realiza una envoltura de complemento a dos. 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 se desbordará, pero la variable tendrá un valor que probablemente no sea el que esperaba que tuviera. Depender del comportamiento de la envoltura del desbordamiento de enteros se considera un error.Para manejar explícitamente la posibilidad de desbordamiento, puede usar estas familias de métodos proporcionados 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
None
si 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ínimos o máximos 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 32 bits y 64 bits de tamaño, respectivamente. El tipo
predeterminado es f64
porque en CPUs modernas, es aproximadamente la misma
velocidad que f32
pero es capaz de más precisión. Todos los tipos de punto
flotante son con signo.
Aquí hay un ejemplo que muestra números de punto flotante en acción:
Nombre de 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 punto flotante de precisión simple, y f64
tiene
doble precisión.
Operaciones numéricas
Rust admite las operaciones matemáticas básicas que esperaría para todos los
tipos de números: adición, sustracción, multiplicación, división y resto.
La división entera se trunca hacia cero al entero más cercano. El siguiente
código muestra cómo usaría cada operación numérica en una declaración let
:
Nombre de archivo: src/main.rs
fn main() { // addition let sum = 5 + 10; // subtraction let difference = 95.5 - 4.3; // multiplication let product = 4 * 30; // division let quotient = 56.7 / 32.2; let truncated = -5 / 3; // Results in -1 // remainder let remainder = 43 % 5; }
Cada expresión en estas instrucciones usa un operador matemático y se evalúa a un solo valor, que luego se vincula 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 posibles valores: true
y false
. Los booleanos tienen un
byte de tamaño. El tipo booleano en Rust se especifica usando bool
. Por
ejemplo:
Nombre de archivo: src/main.rs
fn main() { let t = true; let f: bool = false; // with explicit type annotation }
La forma principal de usar valores booleanos es a través de condicionales, como
una expresión if
. Cubriremos cómo funcionan las expresiones if
en Rust en
la sección “Control de flujo”.
El tipo de carácter
El tipo char
de Rust es el tipo alfabético más primitivo del lenguaje. Estos son algunos ejemplos de declaración de valores char
:
Nombre de archivo: src/main.rs
fn main() { let c = 'z'; let z: char = 'ℤ'; // with explicit type annotation let heart_eyed_cat = '😻'; }
Tenga en cuenta que especificamos literales char
con comillas simples, en
oposición a 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 ASCII. Letras
acentuadas; Caracteres chinos, japoneses y coreanos; Emojis; y espacios de ancho
cero son todos valores char
válidos en Rust. Los valores escalar de Unicode
van desde U+0000
a U+D7FF
y U+E000
a 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” en el capítulo 8.
Tipos compuestos
Tipos compuestos pueden agrupar múltiples valores en un solo tipo. Rust tiene dos tipos compuestos primitivos: tuplas y arreglos.
El Tipo Tupla
Una tupla es una forma general de agrupar varios valores de distintos tipos en un tipo compuesto. Las tuplas tienen una longitud fija: una vez declaradas, su tamaño no puede aumentar ni disminuir.
Creamos una tupla escribiendo una lista de valores separados por comas dentro de paréntesis. Cada posición de la tupla tiene un tipo, y los tipos de los distintos valores de la tupla no tienen por qué ser iguales. En este ejemplo hemos añadido anotaciones de tipo opcionales:
Nombre de archivo: src/main.rs
fn main() { let tup: (i32, f64, u8) = (500, 6.4, 1); }
La variable tup
se vincula a toda la tupla porque una tupla se considera un
único elemento compuesto. Para obtener los valores individuales de una tupla,
podemos utilizar la concordancia de patrones para desestructurar un valor de
tupla, así:
Nombre de archivo: src/main.rs
fn main() { let tup = (500, 6.4, 1); let (x, y, z) = tup; println!("The value of y is: {y}"); }
Este programa primero crea una tupla y la vincula a la variable tup
. Luego
usa un patrón con let
para tomar tup
y convertirla en tres variables
separadas, x
, y
y z
. Esto se llama desestructuración porque rompe la
única tupla en tres partes. Finalmente, el programa imprime el valor de y
,
que es 6.4
.
También podemos acceder directamente a un elemento de la tupla usando un punto
(.
) seguido del índice del valor que queremos acceder. Por ejemplo:
Nombre de 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 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 están escritos ambos como ()
y representan un valor
vacío o un tipo de retorno vacío. Las expresiones devuelven implícitamente el
valor unit si no devuelven ningún otro valor.
El Tipo Arreglo
Otra forma de tener una colección de múltiples valores es con un arreglo. A diferencia de una tupla, cada elemento de un arreglo debe tener el mismo tipo. A diferencia de los arreglos en algunos otros lenguajes, los arreglos en Rust tienen una longitud fija.
Escribimos los valores en un arreglo como una lista separada por comas dentro de corchetes:
Nombre de archivo: src/main.rs
fn main() { let a = [1, 2, 3, 4, 5]; }
Los arreglos son útiles cuando desea que sus datos se asignen en el stack (pila) en lugar del heap (montículo) al igual que los otros tipos que hemos visto hasta ahora (hablaremos más sobre el stack y el heap en el Capítulo 4) o cuando desea asegurarse de que siempre tenga un número fijo de elementos. Sin embargo, un arreglo no es tan flexible como el tipo vector. Un vector es un tipo de colección similar proporcionado por la biblioteca estándar que puede crecer o reducir su tamaño. Si no está seguro de si debe usar un arreglo o un vector, es probable que deba usar un vector. El Capítulo 8 discute los vectores en más detalle.
Sin embargo, los arreglos son más útiles cuando sabe que el número de elementos no cambiará. Por ejemplo, si está utilizando los nombres del mes en un programa, probablemente usaría un arreglo en lugar de un vector porque sabe que siempre contendrá 12 elementos:
#![allow(unused)] fn main() { let months = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; }
Escribe el tipo de un arreglo usando corchetes con el tipo de cada elemento, un punto y coma y luego el número de elementos en el arreglo, así:
#![allow(unused)] fn main() { 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 arreglo contiene cinco elementos.
También puede inicializar un arreglo para contener el mismo valor para cada elemento especificando el valor inicial, seguido de un punto y coma y luego la longitud del arreglo en corchetes, como se muestra aquí:
#![allow(unused)] fn main() { let a = [3; 5]; }
El arreglo llamado a
contendrá 5
elementos que inicialmente se establecerán
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 del Arreglo
Un arreglo es un trozo de memoria de tamaño fijo y conocido que puede asignarse a la pila. Se puede acceder a los elementos de una arreglo utilizando la indexación, de la siguiente manera:
Nombre de 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]
en el arreglo. La variable llamada second
obtendrá el valor 2
del índice [1]
en el arreglo.
Acceso Inválido a los Elementos del Arreglo
Veamos qué sucede si intenta acceder a un elemento de un arreglo que está más allá del final del arreglo. Digamos que ejecuta este código, similar al juego de adivinanzas del Capítulo 2, para obtener un índice de arreglo del usuario:
Nombre de 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 con éxito. Si ejecuta este código usando cargo run
y
ingresa 0
, 1
, 2
, 3
o 4
, el programa imprimirá el valor
correspondiente en ese índice en el arreglo. Si en cambio ingresa un número
más allá del final del arreglo, como 10
, verá una salida como esta:
thread 'main' panicked at src/main.rs:19:19:
index out of bounds: the len is 5 but the index is 10
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
El programa dio lugar a un error en tiempo de ejecución al momento de utilizar
un valor no válido en la operación de indexación. El programa salió con un
mensaje de error y no ejecutó la sentencia final println!
. Cuando intentas
acceder a un elemento utilizando la indexación, Rust comprobará que el índice
que has especificado es menor que la longitud del array. Si el índice es mayor o
igual que la longitud, Rust entrará en pánico. Esta comprobación tiene que
ocurrir en tiempo de ejecución, especialmente en este caso, porque el compilador
no puede saber qué valor introducirá el usuario cuando ejecute el código más
tarde.
Este es un ejemplo de los principios de seguridad de memoria de Rust en acción. En muchos lenguajes de bajo nivel, este tipo de comprobación no se hace, y cuando proporcionas un índice incorrecto, se puede acceder a memoria invá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 entre en pánico ni permita el acceso a memoria inválida.