wandres.dev
STRINGS Y SLICES · String vs &str

Texto UTF-8: chars, bytes y por qué no se indexa

Toda cadena en Rust es UTF-8 válido, y esa garantía tiene consecuencias. Cómo iterar con chars frente a bytes, por qué un char es un escalar Unicode de cuatro bytes y no un byte, y por qué el compilador te prohíbe indexar un String por posición numérica.

⏱ 17 min

Aquí se rompe la intuición que traes de otros lenguajes: una cadena no es un array de caracteres que puedas indexar con un número. En Rust toda cadena es UTF-8 válido, un invariante grabado en el tipo, y de esa garantía se sigue todo lo demás: que un carácter ocupe entre uno y cuatro bytes, que iterar por chars() sea distinto de iterar por bytes(), y que s[0] sencillamente no compile. No es una limitación caprichosa: es Rust negándose a mentirte sobre lo que de verdad es el texto humano.

🎯 Al terminar esta lección sabrás
  • Entender que todo String y &str es UTF-8 válido por invariante de tipo.
  • Distinguir un char (escalar Unicode de 4 bytes) de un u8 (byte).
  • Iterar un texto con chars(), bytes() y char_indices() según convenga.
  • Explicar por qué indexar por posición numérica está prohibido en compilación.

Todo texto es UTF-8 válido

UTF-8 es una codificación de ancho variable: un carácter ASCII como a ocupa un byte, pero é ocupa dos, tres y un emoji cuatro. Rust adopta UTF-8 como representación única y garantizada de sus cadenas. No es una convención que puedas romper: el tipo str promete que sus bytes forman una secuencia UTF-8 válida, y construir un String desde bytes arbitrarios exige pasar por una función falible que verifica el invariante.

fn main() {
    let s = "café €";       // bytes UTF-8: c,a,f = 1 byte; é = 2; espacio = 1; € = 3
    println!("{} bytes", s.len());          // 8, NO 6
    println!("{} escalares", s.chars().count()); // 6

    // Bytes que no son UTF-8 -> no puede haber un &str: la conversión es falible
    let crudos = vec![0xFF, 0xFE];
    assert!(String::from_utf8(crudos).is_err()); // Err: el invariante lo impide
}

Fíjate en la primera sorpresa: s.len() devuelve bytes, no caracteres. La longitud de una cadena en Rust es siempre su tamaño en memoria, no su cuenta de símbolos, porque el tamaño es lo único que se conoce en tiempo constante. Contar caracteres es otra operación, más cara, y Rust te obliga a pedirla aparte.

El invariante también gobierna la entrada. Si tienes bytes crudos —de un fichero, de la red— y quieres tratarlos como texto, String::from_utf8 los valida y devuelve un Result: Ok si son UTF-8 legal, Err si no. Cuando prefieras no fallar, String::from_utf8_lossy sustituye cada secuencia inválida por el carácter de reemplazo y te da siempre una cadena. En ambos casos, cruzar de “bytes cualesquiera” a “texto” es un acto explícito y verificado, nunca la reinterpretación silenciosa del char* de C.

Un char no es un byte

Un char en Rust es un escalar Unicode: un valor de 32 bits (cuatro bytes) que representa un punto de código, desde U+0000 hasta U+10FFFF (excluyendo los sustitutos). No es un byte, no es un u8, y no coincide con “una posición en la cadena”. Cuando iteras con chars(), Rust decodifica el UTF-8 sobre la marcha y te entrega escalares completos, agrupando los bytes que corresponda.

fn main() {
    let s = "né";
    for c in s.chars() {   // escalares Unicode decodificados
        println!("char {c} ocupa {} bytes", c.len_utf8());
    } // n -> 1 byte, é -> 2 bytes

    for b in s.bytes() {   // los bytes crudos, sin decodificar
        print!("{b} ");    // 110 (n)  195 233 (los dos bytes de é)
    }
}

Elegir la vista correcta importa. bytes() te da la representación física, útil para I/O, hashing o comparar longitudes en memoria. chars() te da la vista lógica de escalares, útil para clasificar, buscar o transformar símbolos. Confundirlas es el origen de bugs sutiles: contar bytes() creyendo contar letras hincha el número en cuanto aparece un acento.

El char, por ser un escalar completo, trae métodos que un byte no puede ofrecer con corrección Unicode: clasificar, cambiar de caja, convertir a dígito. Todos operan sobre el símbolo lógico, no sobre su representación en bytes.

let c = 'Ñ';
println!("{}", c.is_alphabetic());   // true
println!("{}", c.to_lowercase());     // ñ
println!("{:?}", '7'.to_digit(10));   // Some(7)
🔢

bytes · la vista física

s.bytes() recorre los u8 crudos del buffer. Rápido y de tamaño exacto, pero un carácter puede ocupar varios. Es lo que mide len().

🔤

chars · la vista lógica

s.chars() decodifica UTF-8 y entrega escalares char completos. Es la vista de símbolos, y recorrerla cuesta O(n) porque hay que decodificar.

Por qué no puedes indexar por posición

Este código no compila, y es una de las decisiones de diseño más deliberadas de Rust:

let s = String::from("café");
// let primera = s[0];   // ERROR E0277: `String` no implementa `Index<usize>`

La razón es que no existe una respuesta buena a “¿qué es s[0]?”. Como UTF-8 es de ancho variable, indexar por byte en tiempo constante podría caer en mitad de un carácter y devolver medio símbolo, un sinsentido. Indexar por carácter sería correcto pero costaría O(n), porque habría que decodificar desde el principio para llegar a la posición pedida: una operación cara disfrazada de barata s[i], exactamente el tipo de trampa de rendimiento que Rust rechaza esconder. Ante dos malas opciones —incorrecta o engañosamente cara— Rust elige una tercera: prohibir el atajo y obligarte a decir qué querías.

let s = "café";
let por_char = s.chars().nth(1);       // Option<char>, explícitamente O(n)
let por_byte = s.as_bytes()[1];        // u8 crudo, explícitamente por byte
let recorte  = &s[0..3];               // &str: corte por RANGO de bytes, no índice suelto

Sí puedes cortar con un rango de bytes, &s[0..3], porque un slice de cadena es un &str con su invariante. Pero el rango debe caer en fronteras de carácter: &"café"[0..3] es válido (caf), mientras que un corte que parta el é por la mitad es un panic en ejecución con “byte index is not a char boundary”. El corte es legal; partir un escalar, no.

Un tercer nivel: los grafemas

Ni siquiera el char es “lo que el usuario percibe como un carácter”. Un grafema —una letra con su tilde combinada, una bandera, un emoji con modificador de tono de piel— puede estar formado por varios escalares Unicode consecutivos. chars().count() los cuenta por separado, aunque para el humano sean uno solo.

let bandera = "🇪🇸";                       // se ve como UN símbolo
println!("{}", bandera.chars().count());  // 2: son dos escalares regionales combinados

let ene = "n\u{0303}";                     // 'n' + tilde combinante: se ve como ñ
println!("{}", ene.chars().count());       // 2, pese a percibirse como una sola letra

La biblioteca estándar deliberadamente no ofrece segmentación por grafemas: exige las tablas de la Unicode Text Segmentation, que cambian con cada versión del estándar y pesan demasiado para el núcleo. Cuando de verdad necesites “caracteres que ve el usuario” —truncar un nombre, mover un cursor— recurres a un crate como unicode-segmentation. Que Rust te obligue a importarlo es coherente con todo lo anterior: prefiere hacer visible que ese nivel existe y cuesta antes que fingir que len() o chars().count() responden a la pregunta.

A esto se suma la normalización: el mismo grafema é puede codificarse como un único escalar o como dos —una e seguida de una tilde combinante—, y ambas cadenas se ven idénticas pero no son iguales byte a byte ni comparten chars().count(). Comparar texto de fuentes distintas sin normalizar antes, con un crate como unicode-normalization, es otra fuente de bugs invisibles que Rust, fiel a su estilo, prefiere no esconder tras una falsa igualdad.

ℹ️
Para ASCII puro, bytes y escalares coinciden

Si tu texto es solo ASCII —dígitos, letras sin acento, signos básicos— cada carácter ocupa exactamente un byte, y entonces len(), bytes().count() y chars().count() dan el mismo número. Esa coincidencia es la que sostenía el viejo modelo de “la cadena es un array de caracteres”, y sigue siendo cierta en ese rincón. El problema no es que el modelo sea siempre falso, sino que deja de serlo en cuanto aparece el primer carácter no ASCII, y entonces todo el código que asumía O(1) e indexación por letra se rompe en silencio. Rust prefiere que ese supuesto no exista nunca a que estalle un día impredecible.

⚠️
len cuenta bytes; para símbolos, cuenta aparte

El desliz más común es tratar s.len() como “número de caracteres”. Devuelve bytes, y solo coincide con los caracteres si el texto es ASCII puro. Para contar escalares usa s.chars().count() (O(n)); para saber si algo cabe o para offsets de I/O usa len(). Y recuerda que ni siquiera “número de escalares” equivale a “número de símbolos que ve el usuario”: ese es un tercer nivel.

Rust se niega a mentir sobre lo que es una cadena

La prohibición de indexar no es una carencia: es honestidad computacional. Casi todos los lenguajes anteriores heredaron el modelo mental de que una cadena es un array de caracteres de tamaño fijo, indexable en O(1) —y ese modelo era cierto en la era ASCII, cuando un byte era una letra—. Unicode lo hizo falso, pero la mayoría de los lenguajes conservaron la mentira: exponen s[i] y por debajo, o bien indexan una unidad de codificación que no es un carácter (las surrogate pairs de UTF-16 en Java o JavaScript, donde un emoji cuenta como dos y length miente), o bien pagan un O(n) invisible. Rust rompe con esa herencia y te hace mirar de frente la estructura de tres capas del texto real: bytes (la representación física en memoria), escalares Unicode (los char, puntos de código), y grafemas (lo que un humano percibe como un carácter, que puede ser varios escalares combinados: una letra con tilde compuesta, una bandera, un emoji con tono de piel). Ninguna de las tres es “la” longitud ni “la” posición: son tres ejes distintos, y la pregunta “¿cuál es el carácter número i?” solo tiene sentido cuando eliges un eje. Rust, en lugar de elegir por ti y ocultar el coste, te obliga a nombrar el eje —bytes(), chars(), o un crate de segmentación de grafemas— y así el rendimiento y la corrección de tu código con texto dejan de depender de una ilusión. Trabajar con cadenas en Rust cuesta un poco más de ceremonia, y a cambio no te estalla en la cara el día que un usuario escribe su nombre con un emoji.

⚔️ Mira el texto por sus tres capas
  1. Toma la cadena "señor €" e imprime len(), chars().count() y explica la diferencia.
  2. Recorre é con bytes() y con chars() y anota cuántos elementos da cada uno.
  3. Intenta &"café"[0..2] de forma que partas el é y provoca el panic de “char boundary”; luego corrige el rango.
  4. Usa char_indices() para imprimir, de cada escalar, su desplazamiento en bytes y su valor.
  5. Explica, con las tres capas (bytes, escalares, grafemas), por qué “¿cuántos caracteres tiene?” es una pregunta ambigua.