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ñoSignedUnsigned
8-biti8u8
16-biti16u16
32-biti32u32
64-biti64u64
128-biti128u128
archisizeusize

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éricosEjemplo
Decimal98_222
Hex0xff
Octal0o77
Binario0b1111_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 con panic! 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 un u8, 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_*, 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í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) (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.