Los tropiezos clásicos con strings
Los errores que todo principiante comete con cadenas en Rust y cómo evitarlos: indexar por posición, cortar en mitad de un carácter, guardar un &str sin lifetime, pedir un String cuando bastaba una vista, y clonar por reflejo para callar al borrow checker.
Las cadenas concentran, en un tipo cotidiano, casi todos los conceptos centrales de Rust: ownership, borrowing, lifetimes, tipos sin tamaño y Unicode. Por eso son donde más tropieza quien empieza, y también donde mejor se consolida lo aprendido. Esta lección recoge los cinco errores clásicos con texto —indexar, cortar mal, lifetimes del &str, pedir de más, clonar por reflejo— no como una lista de prohibiciones, sino como cinco lugares donde el compilador se niega a dejarte mentir sobre quién posee los bytes y qué son.
- Evitar el indexado por posición y el corte en mitad de un carácter con acceso comprobado.
- Reconocer cuándo un
&strguardado exige un lifetime y cuándo conviene poseer unString. - Dejar de pedir
Stringcuando el parámetro solo lee y le bastaba&str. - Sustituir el
.clone()reflejo por un préstamo cuando de verdad procede.
Indexar y cortar: los dos errores de acceso
El primer tropiezo lo hereda quien viene de C, Python o JavaScript: s[0] para leer el primer carácter. No compila, porque —como vimos— UTF-8 hace que la posición numérica sea ambigua. El segundo es más sutil: cortar con un rango que cae en mitad de un carácter, que sí compila pero revienta en ejecución.
let s = String::from("café");
// let c = s[0]; // ERROR E0277: String no implementa Index<usize>
// let mal = &s[0..3]; // compila, pero &"café"[0..4] haría panic: no es char boundary
let primero = s.chars().next(); // Option<char>: Some('c')
let seguro = s.get(0..3); // Option<&str>: corte COMPROBADO, None si parte un char
La cura del indexado es elegir explícitamente el eje: chars().nth(i) para el i-ésimo escalar, as_bytes()[i] para el byte crudo. La cura del corte que revienta es get(rango), que en vez de hacer panic devuelve Option<&str>: None cuando el rango no cae en fronteras de carácter, Some(&str) cuando sí. Prefiere get a la indexación con corchetes siempre que el rango venga de datos que no controlas.
&s[a..b] es cómodo pero entra en pánico si a o b no caen en fronteras de carácter o se salen de rango. En cualquier ruta donde el índice dependa de la entrada, usa s.get(a..b) y maneja el None. Reserva los corchetes para rangos que tú mismo garantizas correctos, como los que salen de char_indices() o find.
Lifetimes del &str: cuando la vista sobrevive al dato
El segundo gran grupo de errores nace de olvidar que un &str no posee sus bytes: es un préstamo, y el borrow checker exige que nunca sobreviva al dato del que sale. Dos síntomas clásicos. Uno: devolver un &str que apunta a un String local, que muere al terminar la función.
fn saludo_mal() -> &str { // ERROR E0106: missing lifetime specifier
let s = String::from("hola");
&s // apuntaría a memoria liberada al volver
}
fn saludo_bien() -> String { // devuelve el DUEÑO: nada puede colgar
String::from("hola")
}
El otro: guardar un &str dentro de un struct. En cuanto un campo es una vista, el struct deja de ser dueño de su texto y hereda una dependencia de vida que hay que declarar con un lifetime; si lo que querías era que el struct poseyera sus datos, el campo debe ser String.
struct VistaMal { nombre: &str } // ERROR E0106: falta lifetime; el campo no posee nada
struct Vista<'a> { nombre: &'a str } // OK: la vista vive lo que viva el dato prestado
struct Dueno { nombre: String } // más simple: el struct POSEE su texto, sin lifetimes
La regla práctica: guarda String cuando el struct deba poseer y sobrevivir por su cuenta; guarda &'a str solo cuando de verdad quieras una vista atada a un dato que vive fuera, y estés dispuesto a propagar el lifetime. Ante la duda, poseer con String es el camino sin fricción.
Existe un punto intermedio para cuando a veces posees y a veces solo miras: Cow<str> (clone on write), un tipo que envuelve o bien un &str prestado o bien un String propio, y solo reserva si de verdad tiene que modificar. Es la herramienta idiomática de las funciones que devuelven el texto de entrada intacto en el caso común y una versión cambiada en el raro —escapar caracteres solo si aparece alguno, por ejemplo— sin pagar una reserva cuando no hace falta. No lo necesitas al empezar, pero saber que existe disuelve el falso dilema de “o clono siempre o me peleo con los lifetimes”.
Ownership: no pidas String si te basta con una vista
El error simétrico al de la lección de tipos: pedir un String por valor en un parámetro que solo lee. Eso mueve la propiedad hacia la función, así que el llamador pierde su cadena y no puede volver a usarla, todo para una operación que ni siquiera necesitaba poseerla.
fn longitud_mal(s: String) -> usize { s.len() } // MUEVE: el llamador pierde su String
fn longitud_bien(s: &str) -> usize { s.len() } // PRESTA: el llamador conserva el suyo
fn main() {
let nombre = String::from("Ada");
let n = longitud_bien(&nombre);
println!("{nombre} mide {n}"); // `nombre` sigue vivo: solo lo prestamos
// con longitud_mal, esta línea no compilaría: `nombre` habría sido movido
}
Salvo que la función necesite quedarse con el texto —almacenarlo, mutarlo, devolverlo transformado— el parámetro correcto es &str. Pedir String por valor cuando bastaba una vista fuerza copias y clone() innecesarios río arriba y contagia rigidez a todo el que te llama.
Y cuando la función sí necesita quedarse con el texto pero quieres seguir aceptando literales y String por igual, el patrón idiomático no es pedir String a secas, sino un genérico impl Into<String>: quien te pasa un String te lo cede sin copiar, quien te pasa un &str paga una única reserva, cada cual el coste justo. Es el principio de siempre —no impongas más propiedad de la necesaria— llevado al caso en que la propiedad de verdad hace falta.
struct Usuario { nombre: String }
impl Usuario {
fn new(nombre: impl Into<String>) -> Usuario {
Usuario { nombre: nombre.into() } // &str reserva una vez; String se cede sin copiar
}
}
let a = Usuario::new("Ada"); // literal: una reserva
let b = Usuario::new(String::from("Turing")); // String: cesión directa, sin copia extra
Concatenar y comparar sin sorpresas
Dos últimos escollos de uso diario. Al concatenar con +, el operador consume el String de la izquierda y exige un &str a la derecha, una asimetría que confunde; para juntar varias piezas, format! es más claro y no consume nada. Al comparar, en cambio, no hay sorpresa: String y &str se comparan entre sí directamente gracias a PartialEq, sin conversiones manuales.
let hola = String::from("hola");
let saludo = hola + " mundo"; // `hola` se CONSUME; el &str va a la derecha
let mejor = format!("{saludo}!"); // sin consumir nada, más legible para varias piezas
let s = String::from("rust");
assert!(s == "rust"); // String vs &str: compara directo, sin .as_str()
assert!("rust" == s.as_str()); // y al revés también
Un corolario práctico: para comprobar si una cadena empieza o acaba de cierta forma no la troceas ni la conviertas, usa starts_with, ends_with o contains, que toman &str y no reservan nada. Y para quedarte con una parte, split, strip_prefix o trim te devuelven sub-slices &str gratis; solo conviertes a String con to_string en el momento en que necesitas guardar esa parte más allá de la vida del original.
Cuando el compilador se queja de un valor movido o prestado, la tentación es sembrar .clone() hasta que calle. A veces es correcto, pero muy a menudo tapa —con una copia cara del heap— un problema que se resolvía prestando: cambiar String por &str en un parámetro, tomar una referencia en vez de mover, reordenar el uso. Un .clone() reflejo sobre cadenas es deuda de rendimiento disfrazada de arreglo. Antes de clonar, pregúntate si necesitabas dos dueños o solo querías mirar el texto.
Repasa la lista de tropiezos y verás que ninguno es un capricho de la API de cadenas: cada uno es un concepto central del lenguaje asomando por su rincón más común. El indexado prohibido es la honestidad sobre Unicode: no hay carácter número i barato y correcto, así que Rust no finge que lo haya. El lifetime obligatorio en un &str guardado es el borrow checker recordándote que una vista no posee sus bytes y no puede sobrevivirles. El parámetro String que roba la propiedad es el ownership cobrándote por mover cuando solo querías prestar. El .clone() reflejo es la semántica de move malentendida, resuelta a golpe de copia en vez de con un préstamo. Por eso las cadenas son el mejor campo de pruebas de si de verdad has interiorizado Rust: son un tipo que usas en la primera hora, pero para usarlo bien tienes que tener a mano, simultáneamente, quién posee los bytes, quién los presta y hasta cuándo, si el tipo tiene tamaño conocido, y qué significa “un carácter”. Domina las cadenas —cuándo String y cuándo &str, cuándo cortar con get, cuándo poseer y cuándo prestar— y descubrirás que no aprendiste una API: interiorizaste, en miniatura y sin darte cuenta, el modelo mental completo del lenguaje. El resto de Rust deja de sorprenderte porque ya lo viste todo condensado aquí, en el humilde acto de manejar un poco de texto.
Cinco trampas, cinco conceptos: no indexes por posición (usa chars().nth o as_bytes); no cortes a ciegas (usa get para un corte comprobado que devuelve Option); no guardes un &str sin declarar su lifetime, y ante la duda posee con String; no pidas String por valor si solo vas a leer, pide &str; y no claves .clone() para callar al compilador cuando bastaba un préstamo. Cada regla es Rust negándose a que mientas sobre quién posee los bytes o sobre qué son.
- Intenta
s[0]sobre unStringy lee el E0277; luego obtén el primer escalar conchars().next()y el primer byte conas_bytes(). - Sustituye un
&s[a..b]que podría partir un carácter pors.get(a..b)y maneja elNone. - Escribe un
structcon un campo de texto de dos formas —Stringy&'a str— y explica cuándo elegirías cada una. - Cambia un parámetro
Stringpor&stren una función que solo lee y comprueba que el llamador conserva su cadena. - Encuentra en tu código el último
.clone()sobre una cadena y decide, con honestidad, si necesitabas dos dueños o te bastaba un préstamo.