wandres.dev
STRINGS Y SLICES · String vs &str

String frente a str: el dueño y la vista

Rust tiene dos tipos de cadena y no es un accidente histórico. String posee un buffer en el heap y puede crecer; str es una vista prestada e inmutable. Por qué existen los dos, cuándo usar cada uno, y por qué casi toda función pide una referencia str.

⏱ 16 min

El primer desconcierto de quien llega a Rust y toca texto es descubrir que hay dos tipos de cadena: String y &str. No es redundancia ni deuda histórica: es el ownership aplicado a las cadenas. String es el dueño de un buffer en el heap que puede crecer y mutar; &str es una vista prestada e inmutable sobre bytes que pertenecen a otro. Entender esta pareja —y por qué casi ninguna función bien escrita pide un String— es entender de golpe cómo Rust organiza todos sus datos.

🎯 Al terminar esta lección sabrás
  • Distinguir String (dueño en el heap) de &str (vista prestada de dos palabras).
  • Entender por qué un literal "texto" tiene tipo &'static str.
  • Explicar la coerción de deref que convierte un &String en &str.
  • Saber por qué las funciones toman &str y devuelven String.

Dos tipos para una idea: dueño y préstamo

Un String guarda en el stack tres palabras: un puntero al buffer del heap, la longitud en bytes y la capacidad reservada. Es, literalmente, un Vec<u8> con una garantía extra grabada en su tipo: sus bytes son siempre UTF-8 válido. En una plataforma de 64 bits, size_of::<String>() vale 24, tres usize juntos. Es el dueño: crece, muta y libera su buffer al morir.

Un &str es un puntero gordo (fat pointer) de dos palabras: la dirección del primer byte y la longitud de la porción, en bytes. No lleva capacidad porque no posee nada, y por eso no puede crecer. size_of::<&str>() vale 16. Un &str no apunta a un String: apunta directamente a los bytes, sean del heap de un String, del segmento de solo lectura del binario o del stack.

fn main() {
    let duena: String = String::from("hola"); // dueña: ptr + len + cap, buffer en el heap
    let vista: &str = &duena;                  // vista: ptr + len, mira los bytes de `duena`

    println!("{duena} mide {} bytes", duena.len());
    println!("la vista dice {vista}");
} // aquí muere `duena` y se libera su buffer; `vista` ya había terminado su préstamo
📦

String · el dueño

Tres palabras en el stack (ptr, len, cap) y un buffer propio en el heap. Crece, muta y se libera solo al salir de scope. Úsalo cuando necesites poseer, acumular o modificar el texto.

👁️

str · la vista

Dos palabras (ptr, len). No posee ni un byte: mira una porción contigua de UTF-8 de otro. Inmutable y de coste cero. Úsalo para leer sin adueñarte.

Que String sea “un Vec<u8> con invariante” no es una metáfora: comparte su estrategia de crecimiento. Además del puntero y la longitud, guarda una capacidad, cuántos bytes tiene reservados, que suele ser mayor que los que usa. Cuando añades texto y no cabe, String reserva un buffer mayor —típicamente el doble—, copia lo viejo y libera lo anterior. Ese realojo amortizado es lo que hace que acumular carácter a carácter salga, de media, a coste constante, y es exactamente la razón por la que un dueño necesita tres palabras y una vista solo dos.

let mut s = String::with_capacity(8);  // reserva 8 bytes por adelantado, longitud 0
s.push_str("hola");                     // cabe: no realoja
println!("len {} / cap {}", s.len(), s.capacity()); // len 4 / cap 8
s.push_str(" y mucho más texto");       // no cabe: realoja a un buffer mayor y copia

El literal ya es una vista, no un dueño

Un literal como "hola" no es un String. Su tipo es &'static str: una vista a bytes que el compilador incrusta en la sección de solo lectura del binario. Esos bytes viven tanto como el proceso —de ahí el lifetime 'static— y por eso no se reservan ni se liberan: ya estaban ahí, horneados en el ejecutable. Escribir un literal no toca el heap.

let saludo = "hola";               // &'static str: cero reservas, apunta al binario
let poseido = saludo.to_string();  // AHORA sí: copia esos bytes a un buffer propio en el heap

Esta distinción explica un malentendido habitual: crear un String a partir de un literal siempre copia. El literal vive en memoria inmutable y compartida; para tener algo que puedas hacer crecer necesitas tu propio buffer, y conseguirlo cuesta una reserva y un memcpy. No es gratis, y por eso Rust te obliga a pedirlo explícitamente en lugar de convertir a tus espaldas.

Hay una consecuencia sutil y agradable: como un &str no posee nada, copiarlo es trivial —duplica dos palabras, puntero y longitud, sin tocar el heap— y por eso &str es Copy. Puedes pasar la misma vista a diez funciones sin un solo clone ni un move que te compliquen la vida; todas comparten los mismos bytes de solo lectura. El dueño, en cambio, no es Copy: ceder un String es moverlo, porque solo puede haber un responsable de liberar ese buffer.

Por qué toda función bien escrita pide un str

Aquí está la pieza que lo une todo: String implementa Deref<Target = str>. Gracias a esa coerción de deref, allí donde se espera un &str, un &String se convierte solo, sin coste ni sintaxis. La consecuencia es una regla de diseño casi universal: acepta &str, no &String.

fn saludar(nombre: &str) {    // pide la vista más general que existe
    println!("Hola, {nombre}");
}

fn main() {
    let poseido = String::from("Ada");
    saludar(&poseido);        // &String -> &str por coerción de deref
    saludar("Turing");        // &'static str encaja directo, sin reservar nada
}

Si saludar pidiera &String, la segunda llamada no compilaría: obligarías a todo el que quisiera saludar a construir un String en el heap solo para pasártelo, aunque ya tuviera un literal perfecto. Pedir &str acepta ambos mundos con una sola firma. La otra cara del idioma es la simétrica: devuelve String cuando la función produce texto nuevo, porque el llamador necesita poseer y quizá seguir modificando lo que le entregas.

Esa misma coerción explica otro regalo silencioso: puedes llamar cualquier método de str directamente sobre un String. Cuando escribes poseido.len() o poseido.contains("d"), Rust no encuentra esos métodos en String —están definidos en str—, así que aplica deref automático, convierte &String en &str y los resuelve ahí. Un String hereda de facto toda la superficie de métodos de str sin que ninguno se duplique: la coerción de deref no solo aligera las firmas, también funde las dos API en una sola de cara a quien las usa.

flowchart LR
s[String con ptr len cap] -->|posee y libera| buf[Buffer UTF-8 en el heap]
v[str con ptr len] -->|mira sin poseer| buf
lit[str estatico con ptr len] -->|mira| ro[Bytes en el binario de solo lectura]
ℹ️
Para mutar el texto necesitas al dueño o un &mut String

Un &str es inmutable por naturaleza: es una vista compartida, y Rust no deja mutar a través de una referencia compartida. Existe &mut str, pero es tan restringido —no cambia la longitud, solo reordena bytes ya presentes— que casi nunca lo verás. Para modificar de verdad una cadena (añadir, truncar, insertar) necesitas el String por valor o un &mut String, porque cambiar el contenido puede exigir realojar el buffer, y solo el dueño puede hacerlo. Leer es cosa de la vista; escribir es cosa del dueño.

💡
Nunca escribas &String en una firma de parámetro

Un &String es estrictamente menos flexible que un &str y no gana nada: fuerza al llamador a tener un String real y luego hace dos indirecciones para llegar a los bytes. clippy te lo marcará con la lint ptr_arg. La regla mecánica: si solo vas a leer el texto, el parámetro es &str; si necesitas poseerlo o mutarlo, entonces sí pides String (por valor) o &mut String.

Cuándo cada uno, en una frase

Necesitas un String cuando el texto tiene que sobrevivirte, crecer o cambiar: acumular en un bucle, construir con format!, guardarlo en un struct que posee sus datos, devolverlo de una función que lo fabrica. Te basta un &str cuando solo vas a mirar: recibir un parámetro de lectura, devolver una porción de algo que el llamador ya posee, comparar, buscar, recortar. La duda “¿String o &str?” es en el fondo la pregunta de ownership de siempre —¿poseo o tomo prestado?— disfrazada de texto.

El caso de acumular lo condensa todo. El resultado tiene que poseer sus bytes porque crece en cada vuelta, así que es un String; cada pieza que le añades solo se lee, así que entra como &str. La firma cae sola en cuanto separas quién posee de quién solo mira:

fn unir(piezas: &[&str]) -> String {  // pide vistas, entrega un dueño nuevo
    let mut acc = String::new();      // el acumulador POSEE: va a crecer
    for p in piezas {
        acc.push_str(p);              // cada pieza solo se lee: basta la vista
    }
    acc                               // se mueve la PROPIEDAD al llamador
}

No hay que deliberar caso por caso: en cuanto etiquetas cada dato como “dueño” o “vista”, los tipos de la firma dejan de ser una elección y pasan a ser una consecuencia.

String y str son un caso de un patrón que recorre todo Rust

No memorices String y &str como una curiosidad de las cadenas: son una instancia de la estructura más profunda del lenguaje. Rust separa, para cada dato con tamaño dinámico, el dueño que gestiona la memoria de la vista que solo la lee, y esa pareja reaparece idéntica por todas partes: String y &str, Vec<T> y &[T], PathBuf y &Path, OsString y &OsStr, Box<T> y &T. En cada caso, el dueño es una estructura del stack con puntero, longitud y a veces capacidad, que posee un buffer del heap y lo libera al morir; la vista es un puntero —gordo cuando el tamaño es dinámico— que apunta a esos bytes sin adueñarse y cuyo lifetime el borrow checker ata al del dueño. Esta simetría no es casual: es ownership llevado hasta sus últimas consecuencias. El dueño responde a “¿quién libera?”; la vista responde a “¿quién puede leer, y hasta cuándo?”. Cuando interiorizas que &str es a String lo que &[T] es a Vec<T>, dejas de aprender API caso por caso y empiezas a deducirla: sabes que la función de lectura pedirá la vista, que la función que fabrica devolverá el dueño, y que convertir de vista a dueño siempre cuesta una reserva porque cruzar de “mirar” a “poseer” es, físicamente, copiar. Media biblioteca estándar se vuelve predecible en cuanto ves este patrón.

📝
Lo esencial de String frente a str

String es el dueño: tres palabras en el stack y un buffer propio en el heap que crece, muta y se libera solo. &str es la vista: dos palabras (puntero y longitud) que miran bytes ajenos sin poseerlos. Un literal "..." es &'static str, horneado en el binario, y pasar a String siempre copia. Regla de oro: acepta &str para leer, devuelve String para entregar texto nuevo, y no escribas nunca &String en una firma.

⚔️ Mide las dos caras de una cadena
  1. Imprime size_of::<String>() y size_of::<&str>() en tu plataforma y explica por qué difieren en una palabra.
  2. Escribe fn iniciales(nombre: &str) -> String y llámala con un literal y con un String; razona qué llamada usa coerción de deref.
  3. Cambia el parámetro a &String y observa qué llamada deja de compilar; explica qué flexibilidad perdiste.
  4. Comprueba con .as_ptr() que un &str de un String apunta dentro de su buffer, no a una copia.
  5. Enuncia, sin mirar, la analogía entre la pareja String/&str y la pareja Vec<T>/&[T].