9 Propiedad, préstamos y referencias
Uno de los aspectos más característicos de Rust es su sistema de propiedad. Gracias a él, el compilador puede garantizar la seguridad de la memoria y evitar problemas habituales en otros lenguajes, como los accesos a memoria liberada o las condiciones de carrera, sin necesidad de un recolector de basura.
Para conseguirlo, Rust controla quién es el propietario de cada dato, cuándo puede transferirse dicha propiedad y en qué situaciones es posible acceder temporalmente a un valor mediante préstamos y referencias. Aunque estas reglas pueden resultar extrañas al principio, constituyen la base sobre la que se apoya gran parte del lenguaje.
Antes de estudiar estas reglas introduciremos algunos conceptos previos:
El tipo
String, que permite crear cadenas cuyo contenido y longitud pueden modificarse durante la ejecución del programa. Gracias a él podremos comprender la necesidad de algunos de los mecanismos de gestión de memoria de Rust.Las dos zonas de memoria utilizadas habitualmente por los programas: stack (pila) y heap (montón). Esto nos permitirá comprender por qué algunos tipos de datos son más sencillos de gestionar que otros y qué papel desempeña la memoria dinámica.
9.1 Introducción al tipo String
Hasta ahora hemos trabajado con cadenas literales, como "Hola" o "Puerto". Su contenido está fijado en el código fuente y no puede modificarse.
Rust también dispone del tipo String, que permite crear cadenas cuyo contenido y longitud pueden variar durante la ejecución del programa. Un valor de tipo String puede crearse a partir de una cadena literal mediante la función asociada String::from():
let s = String::from("Hola");Si queremos modificar posteriormente la cadena, debemos declarar la variable como mutable:
let mut s = String::from("Hola");El método push() añade un carácter al final de la cadena y el método push_str() añade otra cadena al final:
fn main() {
let mut s = String::from("¡Hola");
s.push_str(", mundo"); // Añade cadena (se delimita con comilla doble)
s.push('!'); // Añade carácter (se delimita con comilla simple)
println!("{}", s);
}En este capítulo utilizaremos el tipo String como ejemplo para estudiar cómo Rust gestiona la memoria y qué papel desempeña el sistema de propiedad.
El acceso a caracteres individuales requiere algunas consideraciones adicionales relacionadas con la representación de texto en Unicode. Por este motivo pospondremos su estudio hasta el Capítulo 15 .
9.2 Stack y heap
La memoria stack almacena datos de tamaño conocido en tiempo de compilación. Su gestión es muy eficiente, ya que reservar y liberar memoria suele reducirse a desplazar un puntero. Tipos como i32, f64, bool o char suelen almacenarse completamente en la stack.
La memoria heap se utiliza para almacenar datos cuyo tamaño puede variar durante la ejecución del programa. En este caso es necesario solicitar memoria al sistema y llevar un registro de las zonas ocupadas y libres, por lo que las operaciones son más costosas que en la stack.
Por ejemplo, en el siguiente fragmento:
let x = 42;
let s = String::from("Hola");la variable x se almacena íntegramente en memoria stack. En cambio, la variable s utiliza memoria heap para almacenar los caracteres de la cadena.
En resumen:
Stack:
- Almacena datos de tamaño conocido en tiempo de compilación.
- Se gestiona automáticamente.
- Reservar y liberar memoria es muy rápido.
- Copiar valores suele ser una operación barata.
Heap:
- Permite almacenar datos cuyo tamaño puede variar durante la ejecución.
- Requiere solicitar memoria de forma dinámica.
- Su gestión es más compleja y costosa.
- Muchos tipos dinámicos, como
StringoVec<T>, utilizan memoria heap.
La diferencia entre ambos mecanismos de almacenamiento es fundamental para comprender el sistema de propiedad de Rust y el comportamiento de tipos como String, vectores y otras estructuras de datos dinámicas.
9.3 Propiedad
En Rust, cada valor tiene un propietario (owner). El propietario es la variable responsable de dicho valor y determina cuándo deben liberarse los recursos asociados. La propiedad está estrechamente relacionada con el ámbito (scope) de las variables. El ámbito determina la parte del programa en la que una variable existe y puede utilizarse.
Por ejemplo:
fn main() {
{
let s = String::from("Hola");
println!("{}", s);
}
println!("{}", s); // ¡ERROR! aquí 's' ya no existe
}La variable s se crea al ejecutar la sentencia let y puede utilizarse hasta el final del bloque delimitado por las llaves. Al abandonar dicho bloque, s sale de su ámbito y deja de existir.
Cuando el propietario de un valor sale de su ámbito, Rust destruye automáticamente dicho valor y libera los recursos asociados. Esta liberación se realiza de forma automática, sin necesidad de que el programador solicite explícitamente la destrucción del objeto.
Las reglas fundamentales del sistema de propiedad son:
- Cada valor tiene un propietario.
- Un valor solo puede tener un propietario a la vez.
- Cuando el propietario sale de su ámbito, el valor se destruye y sus recursos se liberan automáticamente.
9.4 Movimiento de valores.
La asignación de un valor a una nueva variable no siempre tiene el mismo efecto. Consideremos el siguiente ejemplo:
fn main() {
let i1 = 5;
let i2 = i1;
println!("{}", i1);
println!("{}", i2);
let s1 = String::from("Hola");
let s2 = s1;
println!("{}", s2);
println!("{}", s1); // ¡ERROR! s1 ya no es el propietario del valor
}La primera asignación no presenta ningún problema. Tras ejecutar:
let i2 = i1;ambas variables pueden seguir utilizándose normalmente. Sin embargo, la segunda asignación produce un error de compilación. Tras ejecutar:
let s2 = s1;la variable s1 deja de ser válida y ya no puede utilizarse. La diferencia entre ambos casos está relacionada con la forma en que se almacenan los datos en memoria.
Los enteros tienen un tamaño pequeño y conocido en tiempo de compilación, por lo que se almacenan íntegramente en memoria stack. Al realizar una asignación, Rust crea automáticamente una copia del valor.
Por el contrario, un valor de tipo String utiliza memoria heap para almacenar sus caracteres. En este caso, Rust no crea una copia completa de la cadena. En su lugar, transfiere la propiedad del valor a la nueva variable.
Este proceso se denomina movimiento (move), ya que la propiedad del valor se mueve desde una variable a otra. Tras la asignación, s2 pasa a ser la propietaria de la cadena y s1 deja de ser válida. De este modo Rust garantiza que cada valor tenga un único propietario en todo momento y evita problemas relacionados con la liberación de memoria.
En realidad, los enteros también obedecen las reglas de propiedad. La diferencia es que pertenecen a una categoría especial de tipos que se copian automáticamente, como veremos en la siguiente sección.
9.5 Copias automáticas: rasgo Copy
En la sección anterior vimos dos comportamientos distintos al realizar una asignación:
- Los enteros pueden seguir utilizándose después de la asignación.
- Los valores de tipo
Stringtransfieren su propiedad a la nueva variable.
La razón es que algunos tipos implementan un rasgo (trait) llamado Copy.
Un rasgo es un mecanismo mediante el cual Rust asocia ciertas capacidades o comportamientos a un tipo de datos. De momento nos basta saber que un tipo que implementa Copy puede copiarse automáticamente.
Cuando un tipo implementa Copy, una asignación crea una copia del valor en lugar de transferir su propiedad. Como consecuencia, la variable original sigue siendo válida después de la asignación.
Los tipos que implementan Copy suelen ser pequeños y almacenarse íntegramente en memoria stack. Algunos ejemplos son i32, u64, f64, bool y char. Por el contrario, tipos como String no implementan Copy. En estos casos, la asignación produce un movimiento de la propiedad, tal y como vimos en la sección anterior.
Pero cuidado: aunque muchos tipos que implementan el rasgo Copy se almacenan íntegramente en memoria stack, y muchos tipos que no lo implementan utilizan memoria heap, esta no es la regla que determina la existencia de dicho rasgo.
De forma simplificada, un tipo puede implementar Copy cuando duplicar los bits que lo representan en memoria produce un nuevo valor completamente válido e independiente del original.
9.6 Copias explícitas: método clone
En la sección anterior vimos que algunos tipos implementan el rasgo Copy, lo que permite realizar copias automáticas durante las asignaciones. Sin embargo, tipos como String no implementan dicho rasgo. Por tanto, una asignación no crea una copia de la cadena, sino que transfiere su propiedad a la nueva variable:
Si realmente necesitamos dos cadenas con el mismo contenido pero independientes, podemos utilizar el método clone(), que crea una nueva cadena en una zona distinta de memoria y le asigna un contenido igual al de la primera cadena. Como consecuencia, las modificaciones realizadas sobre una de las cadenas no afectan a la otra:
fn main() {
let mut s1 = String::from("Hola");
let mut s2 = s1.clone();
println!("s1: {}", s1); // No hemos perdido acceso a s1
println!("s2: {}", s2);
s1.push_str(", soy s1"); // Ahora tenemos dos cadenas independientes
s2.push_str(", soy s2");
println!("s1: {}", s1);
println!("s2: {}", s2);
}A diferencia de las copias automáticas proporcionadas por el rasgo Copy, el método clone() debe invocarse explícitamente. Esto se debe a que crear una copia independiente puede requerir operaciones adicionales y resultar más costoso.
9.7 Paso de valores a funciones
Las reglas de propiedad que hemos estudiado hasta ahora no solo se aplican a las asignaciones. El paso de argumentos a funciones sigue exactamente las mismas reglas. Consideremos el siguiente ejemplo:
fn mostrar_entero(i: i32) {
println!("{}", i);
}
fn mostrar_cadena(s: String) {
println!("{}", s);
}
fn main() {
let i = 7;
let s = String::from("hola");
mostrar_entero(i);
mostrar_cadena(s);
println!("{}", i); // Correcto
println!("{}", s); // ¡ERROR! 's' ha perdido la propiedad
}Al llamar a mostrar_entero(), el valor de i se copia automáticamente porque el tipo i32 implementa el rasgo Copy. Como consecuencia, la variable original sigue siendo válida tras la llamada.
Sin embargo, al llamar a mostrar_cadena(), la propiedad de la cadena se transfiere al parámetro s de la función. Dicho parámetro pasa a ser el nuevo propietario del valor y la variable s de main() deja de ser válida.
Podemos interpretar una llamada a función como la creación de una nueva variable cuyo valor se inicializa con el argumento proporcionado. Por este motivo, las mismas reglas que gobiernan las asignaciones también se aplican al paso de parámetros.
Una forma de resolver este problema es hacer que la función devuelva el parámetro que ha recibido, como en este ejemplo:
fn mostrar_cadena(s: String) -> String {
println!("{}", s);
s // La función devuelve la cadena
}
fn main() {
let mut s = String::from("hola");
s = mostrar_cadena(s);
println!("{}", s); // Correcto
}Pero este enfoque tiene varios inconvenientes:
Las funciones se vuelven más complejas.
Es necesario devolver valores que conceptualmente no son resultados.
Si la función tiene varios parámetros, hay que devolverlos todos, además de los valores generados por la función. Esto puede suponer tener que devolver grandes tuplas.
Rust tiene prevista una alternativa más cómoda: en muchos casos la función no necesita convertirse en propietaria del valor, sino únicamente acceder a él de forma temporal. Para ello Rust proporciona las referencias y los préstamos.
9.8 Referencias y préstamos.
En la Sección 9.6 vimos que el método clone() permite crear copias independientes de un valor. Sin embargo, esta solución puede resultar costosa, especialmente cuando los datos son grandes, ya que es necesario reservar nueva memoria y copiar su contenido.
Afortunadamente, en muchas situaciones no necesitamos una copia independiente. Con frecuencia basta con acceder temporalmente a un valor para leerlo o modificarlo. Para ello Rust proporciona los mecanismos de referencias y préstamos, que permiten utilizar un valor sin transferir su propiedad y sin realizar copias innecesarias.
Referencias y préstamos son dos aspectos de un mismo mecanismo:
Una referencia es un valor que permite acceder a otro valor sin convertirse en su propietario. Se declara anteponiendo el operador unario
&al valor original. Por convenio, no se escribe un espacio a la derecha del&, aunque el compilador lo admite.mostrar_cadena(&s); // Forma recomendada mostrar_cadena(& s); // También es correctoCuando una referencia se crea, se dice que el valor original ha sido prestado (borrowed) temporalmente.
En el siguiente ejemplo podemos ver cómo pasar una variable a una función sin perder su propiedad:
fn mostrar_cadena(s: &String) {
println!("{}", s);
}
fn main() {
let s = String::from("Hola");
mostrar_cadena(&s);
println!("{}", s); // Correcto
}La función
mostrar_cadenano recibe una cadena, sino una referencia a una cadena.Por tanto, la llamada a la función pasa una referencia a una cadena, en este caso,
s.
mostrar_cadena(&s);La función puede utilizar la cadena a través de la referencia, pero no se convierte en su propietaria. Por este motivo, la variable s sigue siendo válida después de la llamada.
9.9 Préstamos inmutables.
Los préstamos inmutables, como acabamos de ver, se crean mediante & y permiten leer el valor sin obtener la propiedad.
Tienen otras dos características muy importantes:
- No permiten modificar el valor.
- Puede haber cualquier número de préstamos inmutables simultáneos.
fn main() {
let s = String::from("Hola");
let prestamo1 = &s;
let prestamo2 = &s;
let prestamo3 = &s;
println!("{}", s);
println!("{}", prestamo1);
println!("{}", prestamo2);
println!("{}", prestamo3);
}En este ejemplo podemos ver cómo los préstamos inmutables permiten crear varias referencias al mismo valor sin transferir su propiedad. La variable original y todas las referencias pueden utilizarse simultáneamente para leer el valor.
9.10 Préstamos mutables.
Los préstamos mutables se crean anteponiendo &mut al valor original. Permiten que la referencia modifique dicho valor. Con dos limitaciones importantes:
- Solo puede existir un préstamo mutable activo a la vez.
- No puede coexistir con préstamos inmutables.
Ejemplo:
fn main() {
let mut s = String::from("Hola");
let prestamo1 = &mut s;
//s.push('!'); // Sería ilegal: ya hay un préstamo mutable activo
prestamo1.push('!');
println!("{}", prestamo1);
println!("{}", s);
}Veamos ahora un ejemplo similar pero con una función:
fn modificar_cadena(s: &mut String) {
s.push('!');
println!("Cadena prestada: {}", s);
}
fn main() {
let mut s = String::from("Hola");
modificar_cadena(&mut s);
// Recuperamos la cadena prestada, podemos modficarla
s.push('!');
println!("Cadena original: {}", s);
}- La función
mainpasa un préstamo mutable a la funciónmodificar_cadena. - Esta función modifica la cadena.
- Cuando concluye la función también concluye el préstamo, con lo que la variable original recupera el control exclusivo sobre el valor.
Obsérvese también que el modificador &mut aparece tanto en la llamada a la función como en la declaración de su parámetro, pero su uso y significado son distintos en cada caso.
En la llamada la expresión
&mut screa una referencia mutable al valor almacenado en la variables.modificar_cadena(&mut s);Mientras que en la cabecera de la función, el fragmento
&mut Stringforma parte del tipo del parámetro. Indica que la función espera recibir una referencia mutable a una cadena.fn modificar_cadena(s: &mut String)Por este motivo, la siguiente sintaxis no es válida:
fn modificar_cadena(&mut s: String) // ¡ERROR!Este es un error relativamente frecuente. El programador intenta añadir el modificador
&mutal nombre del parámetro, cuando en realidad debe formar parte de su tipo.El nombre del parámetro sigue siendo
s; su tipo es&mut String.
En resumen, Rust garantiza que mientras exista un préstamo mutable no pueda haber ningún otro acceso que permita modificar el mismo valor. Por tanto:
- Puede haber cualquier número de préstamos inmutables simultáneos.
- O bien puede haber un único préstamo mutable.
- Nunca pueden darse ambas situaciones al mismo tiempo.
O en otras palabras: muchos lectores o un único escritor.
9.11 Resumen
En este capítulo hemos introducido uno de los conceptos más importantes de Rust: el sistema de propiedad.
Las ideas fundamentales son las siguientes:
- Cada valor tiene un único propietario.
- Cuando el propietario sale de su ámbito, el valor se destruye automáticamente.
- La asignación y el paso de parámetros pueden transferir la propiedad de un valor (move).
- Algunos tipos implementan el rasgo
Copy, por lo que se copian automáticamente en lugar de mover su propiedad. - Cuando es necesario obtener una copia independiente de un objeto que no implementa
Copy, puede utilizarse el métodoclone(). - Las referencias permiten acceder temporalmente a un valor sin convertirse en su propietario.
- Puede haber cualquier número de préstamos inmutables o un único préstamo mutable, pero nunca ambas situaciones al mismo tiempo.
Estas reglas permiten que Rust garantice la seguridad de la memoria durante la compilación, evitando accesos inválidos, liberaciones múltiples y muchas condiciones de carrera, todo ello sin necesidad de utilizar un recolector de basura.