Tipos, variables y funciones en Rust

Aprende Rust → Lección 3

Rust comprueba los tipos antes de ejecutar nada, pero casi nunca hay que escribirlos: los deduce del uso. El resultado es un lenguaje que se lee casi como uno dinámico y se equivoca como uno estático, es decir, en el momento de compilar. Esta lección cubre cómo se declaran los valores, qué tipos hay, cuándo la inferencia necesita ayuda y por qué Rust nunca convierte un número por su cuenta.

Las variables no varían, salvo que lo pidas

Un let en Rust no crea una variable en el sentido habitual, sino un nombre para un valor. Y ese nombre, por defecto, no se puede reasignar:

let, let mut y sombreado let x = 5; El nombre queda fijo. Reasignarlo con x = 6 es un error de compilación, no un aviso. let mut x = 5; El valor puede cambiar. Sigue siendo del mismo tipo para siempre: solo cambia su contenido. let x = x + 1; Sombreado: nombre nuevo. Tapa al anterior y puede tener otro tipo. No hace falta mut. La inmutabilidad por defecto no es una incomodidad: es lo que permite al compilador razonar sobre quién puede cambiar qué.
El sombreado se usa muchísimo para ir transformando un valor sin inventar nombres como entrada2 o entrada_limpia.

Las tres formas en funcionamiento:

fn main() {
    let a = 5;
    println!("a = {a}");

    let mut b = 5;
    b = 6;
    println!("b = {b}");

    let entrada = "  42  ";
    let entrada = entrada.trim();
    let entrada: i32 = entrada.parse().unwrap();
    println!("entrada = {}", entrada + 1);
}

Mira lo que ocurre con entrada. Empieza siendo un texto con espacios, luego un texto limpio y acaba siendo un número, todo con el mismo nombre y sin mut. Eso es sombreado: cada let crea un valor nuevo que tapa al anterior. Con mut no se podría, porque un valor mutable puede cambiar de contenido pero nunca de tipo.

Constantes

Para un valor que se conoce al escribir el programa y no cambia nunca está const. Exige anotar el tipo, se puede declarar fuera de toda función y por convención va en mayúsculas:

const MAX_REINTENTOS: u32 = 3;

fn main() {
    for intento in 1..=MAX_REINTENTOS {
        println!("intento {intento} de {MAX_REINTENTOS}");
    }
}

Esa convención de mayúsculas no es solo estética. Como vimos en la lección 5, dentro de un patrón un nombre en minúsculas crea una variable nueva en vez de comparar; uno en mayúsculas se reconoce como constante y sí compara.

Los tipos que vienen de fábrica

Tipo Qué guarda Notas
i8 … i128 Enteros con signo i32 es el que se usa si no dices nada
u8 … u128 Enteros sin signo Un u8 va de 0 a 255
isize, usize Enteros del tamaño de la máquina usize es el tipo de los índices y las longitudes
f32, f64 Coma flotante f64 es el predeterminado
bool true o false No se convierte a número; un 0 no es falso
char Un carácter Unicode Ocupa 4 bytes, no 1: cabe una «ñ» o un emoji
(i32, f64) Una tupla Tamaño fijo, tipos distintos en cada posición
[i32; 5] Un arreglo Tamaño fijo y conocido al compilar
&str, String Texto prestado y texto propio La distinción es la de la idea de propiedad

Dos detalles que ahorran tropiezos. El primero es usize: cuando indexas una colección o pides su longitud, el tipo que aparece es ese, y mezclarlo con un i32 requiere una conversión explícita. El segundo es que bool no es un número; en Rust no existe el if (contador) de otros lenguajes, la condición tiene que ser un booleano de verdad.

fn main() {
    let tupla: (i32, f64, char) = (500, 6.4, 'ñ');
    let (x, _, c) = tupla;
    println!("x = {x}, tercero = {c}, segundo = {}", tupla.1);

    let notas = [90, 75, 88, 100, 62];
    println!("{} notas, la primera es {}", notas.len(), notas[0]);

    let media: f64 = notas.iter().sum::<i32>() as f64 / notas.len() as f64;
    println!("media = {media:.2}");
}

En la última línea aparecen dos conversiones con as, y no están ahí por gusto: sum() da un i32 y len() da un usize, así que dividirlos directamente no compila. Rust no asciende tipos automáticamente como hacen C o Java.

La inferencia y dónde se queda corta

Casi nunca hace falta anotar el tipo de una variable local, porque el compilador lo deduce del valor y de lo que se hace con él después. Pero hay tres situaciones en las que la información no le llega, y entonces pide ayuda.

La más frecuente es parse, que puede devolver cualquier tipo numérico y necesita saber cuál quieres. Se puede decir de dos maneras, y las dos son habituales:

fn main() {
    let a: u16 = "8080".parse().unwrap();
    let b = "8080".parse::<u16>().unwrap();

    println!("{a} {b}");
}

La forma con dos puntos dobles y el tipo entre ángulos se conoce como turbofish, y sirve para los casos en los que no hay una variable que anotar, por ejemplo cuando el resultado se usa directamente. Las otras dos situaciones son parecidas: una colección vacía, donde todavía no hay elementos de los que deducir el tipo, y collect, que puede construir muchas colecciones distintas a partir del mismo iterador.

Conversiones: Rust nunca convierte por su cuenta

Esta es probablemente la fricción más común al llegar de otro lenguaje. Sumar un u8 y un u32 no compila, y no porque falte una característica, sino porque cada conversión tiene un coste o un riesgo y Rust quiere que sea visible. Hay cuatro herramientas y conviene no confundirlas.

Las cuatro formas de convertir n as u8 Entre tipos numéricos. Trunca sin avisar. puede perder datos u32::from(x) Cuando el valor siempre cabe en el destino. nunca falla u8::try_from(x) Cuando puede no caber: tú decides qué hacer. devuelve Result texto.parse() Desde una cadena de texto a un número. devuelve Result La regla práctica: usa as solo cuando sepas que el valor cabe, y try_from cuando no lo sepas.
Las tres últimas son explícitas sobre el riesgo. La primera no: as es la única que puede estropear un número en silencio.

El peligro de as se ve mejor con un número concreto que con una explicación:

use std::convert::TryFrom;

fn main() {
    let grande: u32 = 300;

    let truncado = grande as u8;
    println!("as          -> {truncado}");

    match u8::try_from(grande) {
        Ok(v) => println!("try_from    -> {v}"),
        Err(e) => println!("try_from    -> falla: {e}"),
    }

    let seguro: u32 = u32::from(7u8);
    println!("from        -> {seguro}");
}

El 300 no cabe en un u8, que llega hasta 255. Con as el programa no se queja y se queda con 44, que es lo que sobra al descartar los bits que no caben. Con try_from obtienes un Err y decides tú qué hacer, que es justo el Result de la lección 7. Son resultados muy distintos para la misma intención, y el que elijas debería depender de si de verdad sabes que el valor cabe.

Lo que pasa cuando un entero se desborda. Rust no deja que el desbordamiento pase inadvertido, pero se comporta distinto según cómo compiles: con cargo run, en modo de depuración, el programa se detiene con attempt to add with overflow; con cargo run --release no hay comprobación y el valor da la vuelta. Si los dos operandos son literales ni siquiera llega a ejecutarse: el compilador lo detecta y rechaza el programa con this arithmetic operation will overflow. Para un comportamiento concreto y garantizado en ambos modos están los métodos explícitos; sobre 250u8 + 10, checked_add devuelve None, saturating_add se queda en 255 y wrapping_add da 4.

Funciones: los tipos sí se escriben

La inferencia funciona dentro de una función, no en su frontera. Los parámetros y el valor de retorno llevan tipo obligatoriamente, y eso es deliberado: la firma es un contrato que se puede leer sin mirar el cuerpo, y evita que un cambio interno altere en silencio cómo se usa desde fuera.

fn area(ancho: f64, alto: f64) -> f64 {
    ancho * alto
}

fn saludar(nombre: &str) {
    println!("Hola, {nombre}");
}

fn main() {
    saludar("Ada");
    println!("{}", area(3.0, 4.0));
}

Fíjate en que area no lleva return. La última expresión de un bloque, escrita sin punto y coma, es el valor del bloque. Y saludar no declara tipo de retorno porque no devuelve nada; en realidad devuelve (), la tupla vacía, que es la forma que tiene Rust de decir «ningún valor útil».

El punto y coma que rompe el programa

Esa regla tiene una consecuencia que desconcierta a todo el mundo al principio. Estas dos funciones se diferencian en un carácter, y solo una compila:

fn bien(x: i32) -> i32 {
    x + 1
}

fn mal(x: i32) -> i32 {
    x + 1;
}

Con el punto y coma, x + 1 deja de ser el valor del bloque y pasa a ser una instrucción cuyo resultado se descarta. La función entonces no devuelve nada, es decir devuelve (), y el compilador protesta con mismatched types: expected i32, found (). Cuando veas ese mensaje, lo primero que hay que mirar es el punto y coma de la última línea.

Casi todo es una expresión. Esa misma regla se aplica a if, a match y a los bloques sueltos, así que en Rust se escribe let estado = if activo { "sí" } else { "no" }; en lugar de declarar la variable vacía y rellenarla en cada rama. Es el motivo de que apenas se vea el operador ternario de otros lenguajes: no hace falta.
fn clasificar(nota: u32) -> &'static str {
    if nota >= 90 {
        "excelente"
    } else if nota >= 70 {
        "aprobado"
    } else {
        "insuficiente"
    }
}

fn main() {
    for n in [95, 80, 50] {
        println!("{n}: {}", clasificar(n));
    }
}

Las tres ramas devuelven el mismo tipo, que es lo que exige el compilador para poder darle un tipo al if entero. Si una devolviera un número y otra un texto, no habría forma de decir qué tipo tiene la expresión y el programa no compilaría.

En resumen

Lo que quieres hacer Qué escribir
Dar nombre a un valor que no cambia let x = …;
Poder cambiarle el contenido let mut x = …;
Reutilizar el nombre con otro tipo otro let, que lo sombrea
Un valor fijo conocido al compilar const NOMBRE: Tipo = …;
Indexar o medir una colección usize
Convertir sabiendo que cabe u32::from(x) o x.into()
Convertir sin saber si cabe u8::try_from(x)
Convertir desde texto texto.parse::<u16>()
Decirle el tipo a parse o collect anotar la variable o usar el turbofish
Devolver un valor de una función la última expresión, sin punto y coma

La idea que conviene llevarse es que en Rust el tipo no es papeleo, es la información con la que el compilador trabaja por ti. Cuanto más preciso seas al elegirlo —un u8 donde solo caben valores pequeños, un tipo propio en vez de un f64 suelto—, más errores detecta antes de que el programa llegue a ejecutarse.

Para verlo explicado

Miniatura del vídeo «3. Curso de programación en RUST: TIPOS de DATOS», del canal Luis Serrano Donaire

«3. Curso de programación en RUST: TIPOS de DATOS», del canal Luis Serrano Donaire (13:43). Material de otro canal que recomendamos como complemento, no una producción de decodigo.com. El reproductor solo se carga al pulsar, así que YouTube no recibe nada tuyo hasta entonces. También puedes verlo en YouTube.