Rust

Rust

Tutorial Rust: Estructuras (Structs)

Aprende a usar estructuras en Rust: definición, métodos, tuple structs y unit structs para modelar datos eficientemente.

Aprende Rust y certifícate

Definición y campos

Las estructuras (o structs) en Rust son una herramienta fundamental para crear tipos de datos personalizados que agrupan valores relacionados bajo un único nombre. A diferencia de las colecciones estándar como vectores o hashmaps, las estructuras nos permiten definir exactamente qué campos contendrá nuestro tipo y darles nombres significativos.

Declaración de una estructura

Para definir una estructura en Rust, utilizamos la palabra clave struct seguida del nombre que queremos darle (por convención en PascalCase) y los campos que contendrá entre llaves:

struct Persona {
    nombre: String,
    edad: u32,
    activo: bool,
}

En este ejemplo, hemos creado una estructura llamada Persona con tres campos:

  • nombre: de tipo String
  • edad: de tipo u32 (entero sin signo de 32 bits)
  • activo: de tipo bool (booleano)

Cada campo tiene un nombre y un tipo específico, separados por dos puntos. Los campos se separan entre sí mediante comas.

Creación de instancias

Una vez definida la estructura, podemos crear instancias específicas asignando valores a cada uno de sus campos:

fn main() {
    let usuario = Persona {
        nombre: String::from("Ana García"),
        edad: 28,
        activo: true,
    };
    
    // Podemos acceder a los campos usando la notación de punto
    println!("Nombre: {}", usuario.nombre);
    println!("Edad: {}", usuario.edad);
    println!("¿Activo?: {}", usuario.activo);
}

Al crear una instancia, debemos proporcionar valores para todos los campos de la estructura. Rust no permite crear instancias con campos faltantes.

Acceso y modificación de campos

Para acceder a los campos de una estructura, utilizamos la notación de punto (instancia.campo). Si la instancia es mutable, también podemos modificar sus campos:

fn main() {
    let mut usuario = Persona {
        nombre: String::from("Ana García"),
        edad: 28,
        activo: true,
    };
    
    // Modificamos la edad
    usuario.edad = 29;
    
    println!("Edad actualizada: {}", usuario.edad);
}

Es importante destacar que para modificar cualquier campo, la instancia completa debe ser declarada como mutable usando mut.

Inicialización abreviada de campos

Cuando tenemos variables con el mismo nombre que los campos de la estructura, Rust nos permite usar una sintaxis abreviada para inicializar esos campos:

fn crear_usuario(nombre: String, edad: u32) -> Persona {
    // En lugar de escribir nombre: nombre, edad: edad
    Persona {
        nombre,
        edad,
        activo: true,
    }
}

Esta sintaxis hace que el código sea más conciso y legible cuando los nombres coinciden.

Actualización basada en otra instancia

Rust proporciona una sintaxis de actualización de estructuras que permite crear una nueva instancia a partir de otra, cambiando solo algunos campos:

fn main() {
    let usuario1 = Persona {
        nombre: String::from("Ana García"),
        edad: 28,
        activo: true,
    };
    
    // Creamos usuario2 basado en usuario1, cambiando solo el nombre
    let usuario2 = Persona {
        nombre: String::from("Carlos López"),
        ..usuario1  // Copia el resto de campos de usuario1
    };
    
    println!("Usuario 2 - Nombre: {}, Edad: {}", usuario2.nombre, usuario2.edad);
}

La sintaxis ..usuario1 indica a Rust que debe copiar los valores de los campos restantes desde usuario1. Esta característica es especialmente útil cuando queremos crear una nueva instancia que difiere solo en algunos campos.

Estructuras en funciones

Las estructuras pueden utilizarse como parámetros y valores de retorno en funciones:

fn cumplir_anios(persona: &mut Persona) {
    persona.edad += 1;
}

fn es_mayor_de_edad(persona: &Persona) -> bool {
    persona.edad >= 18
}

fn main() {
    let mut usuario = Persona {
        nombre: String::from("Ana García"),
        edad: 17,
        activo: true,
    };
    
    println!("¿Es mayor de edad?: {}", es_mayor_de_edad(&usuario));
    
    cumplir_anios(&mut usuario);
    println!("Después de cumplir años: {}", usuario.edad);
    println!("¿Es mayor de edad ahora?: {}", es_mayor_de_edad(&usuario));
}

En este ejemplo, pasamos referencias a la estructura para evitar transferir la propiedad. Usamos &mut Persona cuando necesitamos modificar la estructura y &Persona cuando solo necesitamos leerla.

Visibilidad de campos

Por defecto, los campos de una estructura son privados fuera del módulo donde se define la estructura. Podemos hacer que los campos sean públicos usando la palabra clave pub:

pub struct Rectangulo {
    pub ancho: f64,
    pub alto: f64,
}

En este caso, tanto la estructura Rectangulo como sus campos ancho y alto son públicos y pueden ser accedidos desde otros módulos.

Estructuras sin campos nombrados

Rust también permite definir estructuras sin especificar nombres para sus campos, pero esto se verá en la sección de "Tuple y unit structs".

Las estructuras son una herramienta poderosa para modelar datos en Rust, permitiéndonos crear tipos personalizados que reflejan exactamente la información que necesitamos manejar en nuestros programas. En la siguiente sección, veremos cómo añadir comportamiento a nuestras estructuras mediante la implementación de métodos.

Métodos con impl

Las estructuras en Rust no solo sirven para agrupar datos relacionados, sino que también pueden tener comportamiento asociado a través de métodos. Los métodos son funciones que operan en el contexto de una estructura, permitiéndonos encapsular la lógica relacionada con nuestros datos.

Para añadir métodos a una estructura, utilizamos bloques impl (abreviatura de "implementation"). Esta sintaxis separa claramente la definición de datos (la estructura) de su comportamiento (los métodos).

Definiendo métodos con impl

La sintaxis básica para implementar métodos en una estructura es la siguiente:

struct Rectangulo {
    ancho: f64,
    alto: f64,
}

impl Rectangulo {
    // Método que calcula el área
    fn area(&self) -> f64 {
        self.ancho * self.alto
    }
}

fn main() {
    let rect = Rectangulo {
        ancho: 5.0,
        alto: 3.0,
    };
    
    println!("El área del rectángulo es: {}", rect.area());
}

En este ejemplo, hemos definido un método area() para nuestra estructura Rectangulo. Observa cómo:

  1. El método se define dentro de un bloque impl Rectangulo { ... }
  2. El primer parámetro es &self, que representa una referencia a la instancia en la que se llama el método
  3. Llamamos al método usando la notación de punto: rect.area()

El parámetro self

El primer parámetro de un método siempre representa la instancia sobre la que se llama el método. Existen tres variantes principales:

  • &self: Toma una referencia inmutable a la instancia. Útil cuando solo necesitamos leer datos.
fn perimetro(&self) -> f64 {
    2.0 * (self.ancho + self.alto)
}
  • &mut self: Toma una referencia mutable a la instancia. Necesario cuando queremos modificar la estructura.
fn duplicar_tamanio(&mut self) {
    self.ancho *= 2.0;
    self.alto *= 2.0;
}
  • self: Toma posesión (ownership) de la instancia. Útil cuando el método consume la estructura.
fn convertir_a_cadena(self) -> String {
    format!("Rectangulo de {}x{}", self.ancho, self.alto)
}

Ejemplo completo con diferentes tipos de self

Veamos un ejemplo que utiliza las tres variantes:

struct Rectangulo {
    ancho: f64,
    alto: f64,
}

impl Rectangulo {
    // Método con &self (referencia inmutable)
    fn area(&self) -> f64 {
        self.ancho * self.alto
    }
    
    // Método con &mut self (referencia mutable)
    fn escalar(&mut self, factor: f64) {
        self.ancho *= factor;
        self.alto *= factor;
    }
    
    // Método con self (toma posesión)
    fn convertir_a_tupla(self) -> (f64, f64) {
        (self.ancho, self.alto)
    }
}

fn main() {
    let mut rect = Rectangulo {
        ancho: 5.0,
        alto: 3.0,
    };
    
    // Usando método con &self
    println!("Área: {}", rect.area());
    
    // Usando método con &mut self
    rect.escalar(2.0);
    println!("Área después de escalar: {}", rect.area());
    
    // Usando método con self (consume rect)
    let dimensiones = rect.convertir_a_tupla();
    println!("Dimensiones: ({}, {})", dimensiones.0, dimensiones.1);
    
    // Después de llamar a convertir_a_tupla, rect ya no es utilizable
    // El siguiente código causaría un error:
    // println!("Área: {}", rect.area());
}

Es importante entender que cuando usamos self (sin referencia), el método toma posesión de la instancia, lo que significa que después de llamar a ese método, la instancia original ya no se puede utilizar.

Funciones asociadas

Además de los métodos que operan sobre una instancia, también podemos definir funciones asociadas dentro de los bloques impl. Estas funciones no toman self como parámetro y se llaman usando el operador :: en lugar de la notación de punto.

Las funciones asociadas son útiles para crear constructores u otras funciones relacionadas con la estructura pero que no necesitan una instancia:

struct Rectangulo {
    ancho: f64,
    alto: f64,
}

impl Rectangulo {
    // Función asociada (no toma self)
    fn new(ancho: f64, alto: f64) -> Rectangulo {
        Rectangulo { ancho, alto }
    }
    
    // Función asociada para crear un cuadrado
    fn cuadrado(lado: f64) -> Rectangulo {
        Rectangulo {
            ancho: lado,
            alto: lado,
        }
    }
    
    // Método normal
    fn area(&self) -> f64 {
        self.ancho * self.alto
    }
}

fn main() {
    // Llamamos a las funciones asociadas con ::
    let rect = Rectangulo::new(5.0, 3.0);
    let cuadrado = Rectangulo::cuadrado(4.0);
    
    println!("Área del rectángulo: {}", rect.area());
    println!("Área del cuadrado: {}", cuadrado.area());
}

En este ejemplo, new y cuadrado son funciones asociadas que crean instancias de Rectangulo. La convención en Rust es usar new como nombre para el constructor principal, aunque no es obligatorio.

Múltiples bloques impl

Podemos tener múltiples bloques impl para una misma estructura. Esto es útil para organizar el código, especialmente en proyectos grandes:

struct Circulo {
    radio: f64,
}

// Métodos básicos
impl Circulo {
    fn new(radio: f64) -> Circulo {
        Circulo { radio }
    }
    
    fn area(&self) -> f64 {
        std::f64::consts::PI * self.radio * self.radio
    }
}

// Métodos adicionales
impl Circulo {
    fn perimetro(&self) -> f64 {
        2.0 * std::f64::consts::PI * self.radio
    }
    
    fn escalar(&mut self, factor: f64) {
        self.radio *= factor;
    }
}

fn main() {
    let mut c = Circulo::new(2.0);
    println!("Área: {}", c.area());
    println!("Perímetro: {}", c.perimetro());
    
    c.escalar(1.5);
    println!("Área después de escalar: {}", c.area());
}

Esta capacidad de separar la implementación en múltiples bloques impl facilita la organización del código y permite añadir métodos a una estructura de forma incremental.

Métodos con parámetros adicionales

Los métodos pueden tomar parámetros adicionales después de self:

struct Punto {
    x: f64,
    y: f64,
}

impl Punto {
    fn new(x: f64, y: f64) -> Punto {
        Punto { x, y }
    }
    
    fn distancia_a(&self, otro: &Punto) -> f64 {
        let dx = self.x - otro.x;
        let dy = self.y - otro.y;
        (dx * dx + dy * dy).sqrt()
    }
    
    fn mover(&mut self, dx: f64, dy: f64) {
        self.x += dx;
        self.y += dy;
    }
}

fn main() {
    let p1 = Punto::new(0.0, 0.0);
    let p2 = Punto::new(3.0, 4.0);
    
    println!("Distancia: {}", p1.distancia_a(&p2));
    
    let mut p3 = Punto::new(1.0, 1.0);
    p3.mover(2.0, 3.0);
    println!("Punto movido: ({}, {})", p3.x, p3.y);
}

En este ejemplo, distancia_a toma &self y un parámetro adicional otro que es una referencia a otro Punto. El método mover toma &mut self y dos parámetros adicionales que indican cuánto mover el punto.

Encapsulación con métodos

Los métodos nos permiten encapsular la lógica relacionada con nuestras estructuras, proporcionando una interfaz clara para interactuar con ellas:

struct Contador {
    valor: u32,
    paso: u32,
    maximo: u32,
}

impl Contador {
    fn new(inicio: u32, paso: u32, maximo: u32) -> Contador {
        Contador {
            valor: inicio,
            paso,
            maximo,
        }
    }
    
    fn incrementar(&mut self) -> bool {
        if self.valor + self.paso <= self.maximo {
            self.valor += self.paso;
            true
        } else {
            false
        }
    }
    
    fn valor_actual(&self) -> u32 {
        self.valor
    }
    
    fn reiniciar(&mut self) {
        self.valor = 0;
    }
}

fn main() {
    let mut contador = Contador::new(0, 5, 20);
    
    while contador.incrementar() {
        println!("Valor: {}", contador.valor_actual());
    }
    
    println!("Llegamos al máximo. Reiniciando...");
    contador.reiniciar();
    println!("Valor después de reiniciar: {}", contador.valor_actual());
}

En este ejemplo, la estructura Contador encapsula la lógica de un contador que se incrementa en pasos hasta un valor máximo. Los métodos proporcionan una interfaz clara para interactuar con el contador, ocultando los detalles de implementación.

Los métodos con impl son una parte fundamental de la programación en Rust, ya que nos permiten combinar datos y comportamiento de manera organizada y segura, siguiendo principios de buena programación orientada a objetos sin necesidad de herencia o polimorfismo tradicional.

Tuple y unit structs

Además de las estructuras con campos nombrados que hemos visto hasta ahora, Rust ofrece dos variantes adicionales de estructuras que resultan útiles en situaciones específicas: las tuple structs (estructuras de tupla) y las unit structs (estructuras unitarias). Estas variantes proporcionan flexibilidad adicional para modelar nuestros datos según las necesidades concretas de nuestro programa.

Tuple structs

Las tuple structs son estructuras que contienen campos sin nombres, identificados únicamente por su posición. Combinan características de las tuplas y las estructuras tradicionales:

struct Color(i32, i32, i32);
struct Punto(f64, f64, f64);

fn main() {
    let negro = Color(0, 0, 0);
    let origen = Punto(0.0, 0.0, 0.0);
    
    // Accedemos a los campos por índice, como en las tuplas
    println!("Color RGB: ({}, {}, {})", negro.0, negro.1, negro.2);
    println!("Coordenadas: ({}, {}, {})", origen.0, origen.1, origen.2);
}

Como puedes ver, definimos una tuple struct usando la palabra clave struct, seguida del nombre y una lista de tipos entre paréntesis. Para acceder a los campos, utilizamos la notación de índice (.0, .1, etc.), igual que con las tuplas normales.

Casos de uso para tuple structs

Las tuple structs son especialmente útiles en los siguientes escenarios:

  • Cuando los nombres de los campos son obvios por el contexto y darles nombres explícitos sería redundante:
struct Centimetros(f64);
struct Kilogramos(f64);

fn main() {
    let longitud = Centimetros(182.5);
    let peso = Kilogramos(75.3);
    
    println!("Longitud: {} cm", longitud.0);
    println!("Peso: {} kg", peso.0);
}
  • Para crear tipos distintos que tengan la misma estructura interna pero representen conceptos diferentes:
// Aunque ambos son f64, el sistema de tipos los trata como tipos diferentes
struct Celsius(f64);
struct Fahrenheit(f64);

fn convertir_a_fahrenheit(temp: Celsius) -> Fahrenheit {
    Fahrenheit(temp.0 * 9.0/5.0 + 32.0)
}

fn main() {
    let temp_c = Celsius(25.0);
    let temp_f = convertir_a_fahrenheit(temp_c);
    
    println!("{}°C = {}°F", temp_c.0, temp_f.0);
}

Este enfoque nos ayuda a evitar errores lógicos, ya que el compilador no permitirá mezclar accidentalmente valores de Celsius y Fahrenheit, aunque ambos contengan un f64.

Desestructuración de tuple structs

Al igual que las tuplas normales, las tuple structs pueden desestructurarse para extraer sus componentes:

struct RGB(u8, u8, u8);

fn main() {
    let color = RGB(255, 160, 0); // Naranja
    
    // Desestructuración
    let RGB(rojo, verde, azul) = color;
    
    println!("Componentes RGB: rojo={}, verde={}, azul={}", rojo, verde, azul);
}

También podemos usar patrones para extraer solo los componentes que necesitamos:

struct Punto3D(f64, f64, f64);

fn main() {
    let punto = Punto3D(1.0, 2.0, 3.0);
    
    // Extraemos solo las coordenadas x e y
    let Punto3D(x, y, _) = punto;
    
    println!("Coordenadas en el plano XY: ({}, {})", x, y);
}

Implementando métodos para tuple structs

Las tuple structs también pueden tener métodos, definidos de la misma manera que para las estructuras con campos nombrados:

struct Vector2D(f64, f64);

impl Vector2D {
    fn longitud(&self) -> f64 {
        (self.0 * self.0 + self.1 * self.1).sqrt()
    }
    
    fn normalizar(&self) -> Vector2D {
        let len = self.longitud();
        Vector2D(self.0 / len, self.1 / len)
    }
}

fn main() {
    let v = Vector2D(3.0, 4.0);
    
    println!("Longitud del vector: {}", v.longitud());
    
    let v_norm = v.normalizar();
    println!("Vector normalizado: ({}, {})", v_norm.0, v_norm.1);
    println!("Longitud del vector normalizado: {}", v_norm.longitud());
}

Unit structs

Las unit structs son estructuras que no tienen campos. Pueden parecer inútiles a primera vista, pero tienen aplicaciones prácticas importantes:

struct Saludo;

fn main() {
    let saludo = Saludo;
    println!("¡He creado una instancia de una unit struct!");
}

Una unit struct se define simplemente con la palabra clave struct seguida del nombre y un punto y coma.

Casos de uso para unit structs

Las unit structs son útiles principalmente en los siguientes escenarios:

  • Como marcadores de tipos cuando necesitamos un tipo distinto pero no necesitamos almacenar datos:
struct InactividadUsuario;
struct SesionExpirada;

fn manejar_error(error: InactividadUsuario) {
    println!("El usuario ha estado inactivo demasiado tiempo");
}

fn otro_manejador(error: SesionExpirada) {
    println!("La sesión ha expirado");
}

fn main() {
    let error = InactividadUsuario;
    manejar_error(error);
}
  • Para implementar comportamiento sin necesidad de estado:
struct Matematicas;

impl Matematicas {
    fn pi() -> f64 {
        3.14159265359
    }
    
    fn e() -> f64 {
        2.71828182846
    }
}

fn main() {
    // Llamamos a las funciones asociadas sin necesidad de crear una instancia
    println!("Pi: {}", Matematicas::pi());
    println!("e: {}", Matematicas::e());
}

En este ejemplo, Matematicas actúa como un espacio de nombres para agrupar funciones relacionadas, similar a las clases estáticas en otros lenguajes.

  • Como marcadores en patrones de diseño avanzados:
struct Singleton;

impl Singleton {
    fn instancia() -> &'static Singleton {
        static INSTANCIA: Singleton = Singleton;
        &INSTANCIA
    }
    
    fn hacer_algo(&self) {
        println!("Singleton haciendo algo...");
    }
}

fn main() {
    let s1 = Singleton::instancia();
    let s2 = Singleton::instancia();
    
    // Ambas variables apuntan a la misma instancia
    s1.hacer_algo();
    s2.hacer_algo();
}

Comparación entre los tipos de estructuras

Para entender mejor cuándo usar cada tipo de estructura, veamos una comparación:

  • Estructuras con campos nombrados: Ideales cuando tienes múltiples campos que necesitan nombres descriptivos.
struct Persona {
    nombre: String,
    edad: u32,
}
  • Tuple structs: Útiles cuando los nombres de los campos serían redundantes o cuando quieres crear un tipo distinto basado en tipos existentes.
struct Coordenada(f64, f64);
  • Unit structs: Perfectas cuando necesitas un tipo único pero no necesitas almacenar datos.
struct NoHayResultados;

Combinando diferentes tipos de estructuras

En un programa real, es común utilizar diferentes tipos de estructuras según las necesidades:

// Estructura con campos nombrados
struct Usuario {
    id: u64,
    nombre: String,
    activo: bool,
}

// Tuple struct para representar credenciales
struct Credenciales(String, String); // (usuario, contraseña)

// Unit struct para representar estados
struct NoAutenticado;
struct Autenticado;

impl Usuario {
    fn new(id: u64, nombre: String) -> Usuario {
        Usuario {
            id,
            nombre,
            activo: true,
        }
    }
    
    fn autenticar(&self, creds: Credenciales) -> Result<Autenticado, NoAutenticado> {
        // Simulamos verificación de credenciales
        if creds.0 == "admin" && creds.1 == "password123" {
            Ok(Autenticado)
        } else {
            Err(NoAutenticado)
        }
    }
}

fn main() {
    let usuario = Usuario::new(1, String::from("Carlos"));
    let credenciales = Credenciales(String::from("admin"), String::from("password123"));
    
    match usuario.autenticar(credenciales) {
        Ok(_) => println!("Usuario autenticado correctamente"),
        Err(_) => println!("Credenciales incorrectas"),
    }
}

En este ejemplo, utilizamos:

  • Una estructura con campos nombrados para Usuario
  • Una tuple struct para Credenciales
  • Unit structs para representar los estados Autenticado y NoAutenticado

Cada tipo de estructura se utiliza según su idoneidad para el caso específico, lo que demuestra la flexibilidad que Rust ofrece para modelar nuestros datos.

Las tuple structs y unit structs complementan a las estructuras tradicionales, proporcionando herramientas adicionales para expresar nuestras intenciones de forma clara y concisa en el código. Elegir el tipo adecuado de estructura para cada situación nos ayuda a escribir código más legible, mantenible y seguro.

Aprende Rust online

Otras lecciones de Rust

Accede a todas las lecciones de Rust y aprende con ejemplos prácticos de código y ejercicios de programación con IDE web sin instalar nada.

Accede GRATIS a Rust y certifícate

Ejercicios de programación de Rust

Evalúa tus conocimientos de esta lección Estructuras (Structs) con nuestros retos de programación de tipo Test, Puzzle, Código y Proyecto con VSCode, guiados por IA.

En esta lección

Objetivos de aprendizaje de esta lección

  • Comprender qué son las estructuras (structs) y cómo definirlas en Rust.
  • Aprender a crear y modificar instancias de estructuras.
  • Implementar métodos y funciones asociadas usando bloques impl.
  • Diferenciar entre estructuras con campos nombrados, tuple structs y unit structs.
  • Aplicar la encapsulación y organizar el código mediante métodos y visibilidad de campos.