10  Structs

Hasta ahora hemos trabajado principalmente con tipos básicos, colecciones y funciones. Sin embargo, en programas reales es habitual necesitar agrupar varios datos relacionados bajo una misma entidad lógica.

Por ejemplo, si estamos representando un usuario de una aplicación, podríamos almacenar su nombre, correo electrónico, número de accesos y estado de actividad en variables independientes. Sin embargo, estas variables forman parte de un mismo concepto: el usuario.

Los structs permiten definir tipos de datos propios compuestos por varios campos. Cada campo puede tener un nombre y un tipo diferente, permitiendo modelar de forma clara y segura las entidades que aparecen en nuestros programas.

Desde un punto de vista conceptual, un struct es un tipo definido por el usuario que agrupa varios campos relacionados bajo una misma entidad lógica. Desempeña un papel similar al de una clase en otros lenguajes, aunque Rust no utiliza herencia y, como veremos en el Capítulo 11, organiza los métodos de forma diferente.

10.1 Definición de un struct

Un struct se especifica con:

  • La palabra reservada struct.
  • Su nombre. Como vimos en la Sección 4.2, el convenio establece que se use PascalCase, esto es, escribir con mayúscula la primera letra de cada componente del identificador.
  • La lista de campos que lo componen, entre llaves y separados por comas.
  • Cada campo se define con un identificador y un tipo, separados por dos puntos.

A partir de esta información, el compilador determina la representación del tipo en memoria y verifica que los valores almacenados en cada campo sean compatibles con su tipo declarado.

struct Ruta {
    destino: String,
    prefijo: u8,
    siguiente_salto: String,
    interface: String,
}

fn main() {
    let ruta1 = Ruta {
        destino: String::from("192.168.1.0"),
        prefijo: 24,
        siguiente_salto: String::from("10.0.0.1"),
        interface: String::from("eth0"),
    };

    println!("Destino: {}", ruta1.destino);
    println!("prefijo: {}", ruta1.prefijo);
    println!("siguiente_salto: {}", ruta1.siguiente_salto);
    println!("interface: {}", ruta1.interface);
}

Una vez definido el tipo, pueden crearse instancias proporcionando valores para cada uno de sus campos: la sintaxis es similar a la de los registros (records) en otros lenguajes. Los nombres de los campos se escriben explícitamente junto con sus valores. Obsérvese que, al crear una instancia, los campos se inicializan mediante :, no mediante =. El operador : asocia cada campo con el valor que tendrá la nueva instancia, mientras que = se utiliza para inicializar o modificar variables y campos.

Observaciones sobre este ejemplo:

  • Hemos mantenido el orden de los campos, aunque no es necesario.
  • Los campos destino y siguiente_salto son de tipo String, puesto que será en el struct donde se almacenen las direcciones. Usaríamos &str si únicamente necesitáramos hacer referencia a cadenas almacenadas en otro lugar.
  • En una configuración más avanzada, podríamos usar un tipo específico para las direcciones IP, para garantizar que son direcciones legales. En esta versión, por simplicidad, son cadenas de caracteres.

Para acceder a los campos de una instancia se utiliza el operador punto:

println!("{}", ruta1.interface);

La expresión anterior accede al campo interface de la variable ruta1.

10.2 Inicialización abreviada de campos

Es frecuente crear funciones cuyo cometido sea construir y devolver una instancia de un determinado struct.

Supongamos una función que crea una entrada de encaminamiento a partir de los valores recibidos como parámetros:

fn crear_ruta(
    destino: String,
    prefijo: u8,
    siguiente_salto: String,
    interfaz: String,
) -> Ruta {
    Ruta {
        destino: destino,
        prefijo: prefijo,
        siguiente_salto: siguiente_salto,
        interfaz: interfaz,
    }
}

Cuando un campo se llama igual que el parámetro de la función que le da valor, Rust permite utilizar una sintaxis abreviada:

fn crear_ruta(
    destino: String,
    prefijo: u8,
    siguiente_salto: String,
    interfaz: String,
) -> Ruta {
    Ruta {
        destino,
        prefijo,
        siguiente_salto,
        interfaz,
    }
}

Ambas versiones son equivalentes. La segunda es simplemente más compacta y suele ser la preferida cuando los nombres de los parámetros coinciden con los de los campos.

10.3 Creación de una instancia a partir de otra

También es frecuente querer crear una nueva instancia reutilizando la mayoría de los campos de otra ya existente.

Por ejemplo, a partir de una ruta podemos construir otra idéntica salvo por la interfaz de salida:

let ruta1 = Ruta {
    destino: String::from("192.168.1.0"),
    prefijo: 24,
    siguiente_salto: String::from("10.0.0.1"),
    interfaz: String::from("eth0"),
};

let ruta2 = Ruta {
    interfaz: String::from("eth1"),
    destino: ruta1.destino,
    prefijo: ruta1.prefijo,
    siguiente_salto: ruta1.siguiente_salto,
};

Este código funciona, pero resulta tedioso cuando el struct posee muchos campos.

Rust proporciona una sintaxis abreviada para copiar automáticamente los campos restantes desde otra instancia:

let ruta2 = Ruta {
    interfaz: String::from("eth1"),
    ..ruta1
};

La expresión ..ruta1 indica que todos los campos no especificados explícitamente deben obtenerse de ruta1.

Tras esta operación, algunos campos pueden haber sido movidos desde ruta1 hacia ruta2. En nuestro ejemplo, los campos de tipo String cambian de propietario, por lo que ruta1 ya no podrá utilizarse posteriormente de forma completa. Este comportamiento está directamente relacionado con el sistema de propiedad (ownership) de Rust.

10.4 Tuple structs

Rust permite definir structs cuyos campos no tienen nombre:

struct Puerto(u16);

Las instancias se crean mediante:

let ssh = Puerto(22);

Se accede a sus campos por posición:

println!("{}", ssh.0);

Aunque pueden contener cualquier número de campos, su uso más habitual es que tengan uno solo, para implementar el patrón de diseño newtype:

struct Puerto(u16);
struct TiempoEspera(u16);

Aunque ambos contienen internamente un valor u16, el compilador los considera tipos diferentes. Esto aporta seguridad de tipos, ya que evita mezclar accidentalmente valores con significados distintos:

fn conectar(
    puerto: Puerto,
    timeout: TiempoEspera,
) {
    // ...
}

La siguiente llamada es correcta:

conectar(
    Puerto(22),
    TiempoEspera(5000),
);

Sin embargo, si por error un programador escribiese los argumentos en orden distinto al previsto:

conectar(
    TiempoEspera(5000),
    Puerto(22),
);

el compilador produciría un error, ya que Puerto y TiempoEspera son tipos distintos. De esta forma es posible añadir significado semántico y aumentar la seguridad del código sin coste adicional en tiempo de ejecución.

10.5 Otras consideraciones sobre los struct

10.5.1 Mutabilidad

El ejemplo anterior es un struct inmutable. Para poder cambiar su valor lo haríamos mutable:


let mut ruta1 = Ruta {
      destino: String::from("192.168.1.0"),
// etc 

Teniendo en cuenta que esto hace mutables todos los campos del struct, en Rust no es posible que unos campos sean mutables y otros inmutables.

10.5.2 Propiedad de los campos

El acceso a un campo sigue las mismas reglas de propiedad (ownership) que cualquier otra variable. Si el campo es de un tipo que implementa el trait Copy, como u8, u32 o bool, su valor se copia:

let prefijo = ruta1.prefijo;

Sin embargo, si el campo es de un tipo que no implementa Copy, como String, su valor cambia de propietario:

let destino = ruta1.destino;

Tras esta operación ya no será posible utilizar el campo ruta1.destino, ya que la propiedad del valor ha sido transferida a la variable destino. Para evitar la transferencia de propiedad puede tomarse una referencia:

let destino = &ruta1.destino;

10.5.3 Mostrar un struct completo

Por defecto, Rust no sabe cómo mostrar una instancia completa de un tipo definido por el usuario. Para ello es necesario derivar el trait Debug, añadiendo el siguiente atributo inmediatamente antes de la definición del struct:

#[derive(Debug)]
struct Ruta {
    ...
}

A continuación, puede utilizar el especificador de formato {:?}:

 println!("struct completo: {:?}", ruta1);

Posteriormente veremos otros atributos, como derive, cfg, allow, test, etc. Todos siguen el mismo principio: un atributo se escribe inmediatamente antes del elemento al que aplica.

10.6 Ejemplo de uso de struct

Veamos ahora un ejemplo sencillo que combina todo lo anterior. Se trata de usar struct para contener las entradas de una tabla NAT.

  • Definimos un struct EntradaNat con todos los campos necesarios.

  • La función crea_entrada recibe como parámetros todos los campos individuales y devuelve un struct EntradaNat. En Rust es habitual usar funciones similares a esta.

  • La función escribe_entrada recibe un struct EntradaNat y escribe su contenido en pantalla.

  • En la función main:

    • Creamos, desde cero, una entrada NAT para vincular un puerto TCP de una dirección IP privada con un puerto TCP de una dirección IP pública.

    • Creamos otra entrada, igual a la anterior, pero para el protocolo UDP.

      Observa que es necesario clonar las cadenas con las direcciones IP. Prueba eliminar las llamadas a .clone(). Verás que entonces se produce un error de compilación: al mover la cadenas al segundo struct, dejan de estar disponibles en el primero.

      Sin embargo, los campos puerto_privado y puerto_publico no necesitan clonarse. Al ser de tipo u16, implementan el trait Copy, por lo que su valor se copia automáticamente.

struct EntradaNat {
    ip_privada: String,
    puerto_privado: u16,
    ip_publica: String,
    puerto_publico: u16,
    protocolo: String,
}

fn crea_entrada(
    ip_privada: String,
    puerto_privado: u16,
    ip_publica: String,
    puerto_publico: u16,
    protocolo: String,
) -> EntradaNat {
    EntradaNat {
        ip_privada,
        puerto_privado,
        ip_publica,
        puerto_publico,
        protocolo,
    }
}

fn escribe_entrada(entrada: &EntradaNat) {
    println!(
        "{}:{} -> {}:{} ({})",
        entrada.ip_privada,
        entrada.puerto_privado,
        entrada.ip_publica,
        entrada.puerto_publico,
        entrada.protocolo
    );
}

fn main() {
    let entrada_tcp = crea_entrada(
        String::from("192.168.1.10"),
        52341,
        String::from("203.0.113.1"),
        40001,
        String::from("TCP"),
    );

    let entrada_udp = EntradaNat {
        protocolo: String::from("UDP"),
        ip_privada: entrada_tcp.ip_privada.clone(),
        ip_publica: entrada_tcp.ip_publica.clone(),
        ..entrada_tcp
    };

    escribe_entrada(&entrada_tcp);
    escribe_entrada(&entrada_udp);
}

10.7 Resumen

En este capítulo hemos introducido los struct, el mecanismo que ofrece Rust para definir nuevos tipos de datos formados por varios campos relacionados.

Las ideas principales son las siguientes:

  • Un struct permite agrupar datos de distintos tipos bajo un único nombre.
  • Cada instancia de un struct almacena sus propios valores para cada uno de sus campos.
  • Los campos se acceden mediante la notación con el operador punto.
  • Rust ofrece una sintaxis abreviada para inicializar campos cuyo nombre coincide con el de una variable existente.
  • Es posible crear una nueva instancia a partir de otra mediante la sintaxis de actualización de estructuras (..).
  • Los tuple structs permiten definir nuevos tipos cuando los nombres de los campos no aportan información relevante.
  • Un struct puede contener cualquier tipo de dato, incluidos otros struct, lo que facilita la construcción de modelos de datos complejos.

Los struct constituyen el principal mecanismo de Rust para representar entidades del mundo real. En el siguiente capítulo veremos cómo asociar funciones a estos tipos mediante métodos, dotándolos de comportamiento además de datos.