Almacenar texto codificado en UTF-8 con cadenas

Beginner

This tutorial is from open-source community. Access the source code

Introducción

Bienvenido a Almacenar texto codificado en UTF-8 con cadenas. Esta práctica es parte del Rust Book. Puedes practicar tus habilidades de Rust en LabEx.

En esta práctica, discutiremos las complejidades de las cadenas en Rust, particularmente en relación con la codificación UTF-8, así como las operaciones y diferencias del tipo String en comparación con otras colecciones.

Almacenar texto codificado en UTF-8 con cadenas

Hablamos sobre cadenas en el Capítulo 4, pero ahora las revisaremos con más detalle. Los nuevos Rustaceans comúnmente se atascan con las cadenas por una combinación de tres razones: la tendencia de Rust a exponer posibles errores, las cadenas siendo una estructura de datos más complicada de lo que muchos programadores le dan crédito, y UTF-8. Estos factores se combinan de una manera que puede parecer difícil cuando vienes de otros lenguajes de programación.

Discutiremos las cadenas en el contexto de las colecciones porque las cadenas se implementan como una colección de bytes, más algunos métodos para proporcionar funcionalidad útil cuando esos bytes se interpretan como texto. En esta sección, hablaremos sobre las operaciones en String que tiene cada tipo de colección, como crear, actualizar y leer. También discutiremos las maneras en las que String es diferente de las otras colecciones, es decir, cómo la indexación en una String se complica por las diferencias entre cómo las personas y las computadoras interpretan los datos de String.

¿Qué es una cadena?

Primero definiremos lo que queremos decir con el término cadena. Rust solo tiene un tipo de cadena en el lenguaje principal, que es la porción de cadena str que por lo general se ve en su forma prestada &str. En el Capítulo 4, hablamos sobre porciones de cadena, que son referencias a algunos datos de cadena codificados en UTF-8 almacenados en otro lugar. Las literales de cadena, por ejemplo, se almacenan en el binario del programa y por lo tanto son porciones de cadena.

El tipo String, que es proporcionado por la biblioteca estándar de Rust en lugar de codificado en el lenguaje principal, es un tipo de cadena codificada en UTF-8 que es creciente, mutable, poseída. Cuando los Rustaceans se refieren a "cadenas" en Rust, pueden estar refiriéndose a los tipos String o la porción de cadena &str, no solo a uno de esos tipos. Aunque esta sección se ocupa en gran medida de String, ambos tipos se utilizan ampliamente en la biblioteca estándar de Rust, y tanto String como las porciones de cadena están codificadas en UTF-8.

Creando una nueva cadena

Muchas de las mismas operaciones disponibles con Vec<T> también están disponibles con String porque String se implementa en realidad como un envoltorio alrededor de un vector de bytes con algunas garantías, restricciones y capacidades adicionales. Un ejemplo de una función que funciona de la misma manera con Vec<T> y String es la función new para crear una instancia, como se muestra en la Lista 8-11.

let mut s = String::new();

Lista 8-11: Creando una nueva cadena vacía

Esta línea crea una nueva cadena vacía llamada s, en la que luego podemos cargar datos. A menudo, tendremos algunos datos iniciales con los que queremos comenzar la cadena. Para eso, usamos el método to_string, que está disponible en cualquier tipo que implemente el trato Display, como lo hacen las literales de cadena. La Lista 8-12 muestra dos ejemplos.

let data = "initial contents";

let s = data.to_string();

// el método también funciona directamente en una literal:
let s = "initial contents".to_string();

Lista 8-12: Usando el método to_string para crear una String a partir de una literal de cadena

Este código crea una cadena que contiene initial contents.

También podemos usar la función String::from para crear una String a partir de una literal de cadena. El código de la Lista 8-13 es equivalente al código de la Lista 8-12 que usa to_string.

let s = String::from("initial contents");

Lista 8-13: Usando la función String::from para crear una String a partir de una literal de cadena

Debido a que las cadenas se usan para muchas cosas, podemos usar muchas diferentes API genéricas para cadenas, lo que nos proporciona muchas opciones. Algunas de ellas pueden parecer redundantes, pero todas tienen su lugar. En este caso, String::from y to_string hacen lo mismo, por lo que elegir cualquiera de ellos es una cuestión de estilo y legibilidad.

Recuerda que las cadenas están codificadas en UTF-8, por lo que podemos incluir cualquier dato codificado correctamente en ellas, como se muestra en la Lista 8-14.

let hello = String::from("السلام عليكم");
let hello = String::from("Dobrý den");
let hello = String::from("Hello");
let hello = String::from("שָׁלוֹם");
let hello = String::from("नमस्ते");
let hello = String::from("こんにちは");
let hello = String::from("안녕하세요");
let hello = String::from("你好");
let hello = String::from("Olá");
let hello = String::from("Здравствуйте");
let hello = String::from("Hola");

Lista 8-14: Almacenando saludos en diferentes idiomas en cadenas

Todas estas son valores válidos de String.

Actualizando una cadena

Una String puede crecer en tamaño y su contenido puede cambiar, al igual que el contenido de un Vec<T>, si se le agrega más datos. Además, se puede usar convenientemente el operador + o la macro format! para concatenar valores de String.

Anexando a una cadena con push_str y push

Podemos hacer crecer una String usando el método push_str para anexar una porción de cadena, como se muestra en la Lista 8-15.

let mut s = String::from("foo");
s.push_str("bar");

Lista 8-15: Anexando una porción de cadena a una String usando el método push_str

Después de estas dos líneas, s contendrá foobar. El método push_str toma una porción de cadena porque no necesariamente queremos tomar posesión del parámetro. Por ejemplo, en el código de la Lista 8-16, queremos poder usar s2 después de anexar su contenido a s1.

let mut s1 = String::from("foo");
let s2 = "bar";
s1.push_str(s2);
println!("s2 is {s2}");

Lista 8-16: Usando una porción de cadena después de anexar su contenido a una String

Si el método push_str tomara posesión de s2, no podríamos imprimir su valor en la última línea. Sin embargo, este código funciona como esperamos.

El método push toma un solo carácter como parámetro y lo agrega a la String. La Lista 8-17 agrega la letra l a una String usando el método push.

let mut s = String::from("lo");
s.push('l');

Lista 8-17: Agregando un carácter a un valor de String usando push

Como resultado, s contendrá lol.

Concatenación con el operador + o la macro format!

A menudo, querrás combinar dos cadenas existentes. Una forma de hacerlo es usar el operador +, como se muestra en la Lista 8-18.

let s1 = String::from("Hello, ");
let s2 = String::from("world!");
let s3 = s1 + &s2; // nota que s1 se ha movido aquí y ya no se puede usar

Lista 8-18: Usando el operador + para combinar dos valores de String en un nuevo valor de String

La cadena s3 contendrá Hello, world!. La razón por la cual s1 ya no es válida después de la adición, y la razón por la cual usamos una referencia a s2, tiene que ver con la firma del método que se llama cuando usamos el operador +. El operador + usa el método add, cuya firma se ve así:

fn add(self, s: &str) -> String {

En la biblioteca estándar, verás que add está definido usando genéricos y tipos asociados. Aquí, hemos sustituido tipos concretos, que es lo que sucede cuando llamamos a este método con valores de String. Discutiremos genéricos en el Capítulo 10. Esta firma nos da las pistas que necesitamos para entender los aspectos complicados del operador +.

En primer lugar, s2 tiene un &, lo que significa que estamos agregando una referencia de la segunda cadena a la primera cadena. Esto se debe al parámetro s en la función add: solo podemos agregar un &str a una String; no podemos agregar dos valores de String juntos. Pero espera, el tipo de &s2 es &String, no &str, como se especifica en el segundo parámetro de add. Entonces, ¿por qué la Lista 8-18 se compila?

La razón por la cual podemos usar &s2 en la llamada a add es que el compilador puede coaccionar el argumento &String en un &str. Cuando llamamos al método add, Rust usa una coacción de dereferencia, que aquí convierte &s2 en &s2[..]. Discutiremos la coacción de dereferencia con más detalle en el Capítulo 15. Debido a que add no toma posesión del parámetro s, s2 todavía será una String válida después de esta operación.

En segundo lugar, podemos ver en la firma que add toma posesión de self porque self no tiene un &. Esto significa que s1 en la Lista 8-18 se moverá a la llamada a add y ya no será válida después de eso. Entonces, aunque let s3 = s1 + &s2; parece que copiará ambas cadenas y creará una nueva, esta declaración en realidad toma posesión de s1, anexa una copia del contenido de s2 y luego devuelve la posesión del resultado. En otras palabras, parece que está haciendo muchas copias, pero no lo está; la implementación es más eficiente que la copia.

Si necesitamos concatenar múltiples cadenas, el comportamiento del operador + se vuelve complicado:

let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");

let s = s1 + "-" + &s2 + "-" + &s3;

En este punto, s será tic-tac-toe. Con todos los caracteres + y ", es difícil ver lo que está sucediendo. Para combinar cadenas de maneras más complicadas, en cambio, podemos usar la macro format!:

let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");

let s = format!("{s1}-{s2}-{s3}");

Este código también establece s en tic-tac-toe. La macro format! funciona como println!, pero en lugar de imprimir la salida en la pantalla, devuelve una String con el contenido. La versión del código que usa format! es mucho más fácil de leer, y el código generado por la macro format! usa referencias para que esta llamada no tome posesión de ninguno de sus parámetros.

Accediendo a los caracteres individuales de una cadena

En muchos otros lenguajes de programación, acceder a los caracteres individuales de una cadena mediante su índice es una operación válida y común. Sin embargo, si intentas acceder a partes de una String usando la sintaxis de índice en Rust, obtendrás un error. Considere el código no válido de la Lista 8-19.

let s1 = String::from("hello");
let h = s1[0];

Lista 8-19: Intentando usar la sintaxis de índice con una String

Este código generará el siguiente error:

error[E0277]: el tipo `String` no puede ser indexado por `{integer}`
 --> src/main.rs:3:13
  |
3 |     let h = s1[0];
  |             ^^^^^ `String` no puede ser indexado por `{integer}`
  |
  = ayuda: el trato `Index<{integer}>` no está implementado para
`String`

El error y la nota cuentan la historia: las cadenas de Rust no admiten indexación. Pero, ¿por qué no? Para responder a esa pregunta, necesitamos discutir cómo Rust almacena las cadenas en memoria.

Representación interna

Una String es una envolvente sobre un Vec<u8>. Echemos un vistazo a algunos de nuestros ejemplos de cadenas codificadas correctamente en UTF-8 de la Lista 8-14. Primero, este:

let hello = String::from("Hola");

En este caso, len será 4, lo que significa que el vector que almacena la cadena "Hola" tiene 4 bytes de longitud. Cada una de estas letras ocupa un byte cuando se codifica en UTF-8. La siguiente línea, sin embargo, puede sorprenderte (ten en cuenta que esta cadena empieza con la letra cyrílica mayúscula Ze, no el número árabe 3):

let hello = String::from("Здравствуйте");

Si te preguntasen cuánto es la longitud de la cadena, es posible que dijeras 12. De hecho, la respuesta de Rust es 24: ese es el número de bytes que se necesitan para codificar "Здравствуйте" en UTF-8, porque cada valor escalar Unicode en esa cadena ocupa 2 bytes de almacenamiento. Por lo tanto, un índice en los bytes de la cadena no siempre se correlacionará con un valor escalar Unicode válido. Para demostrarlo, considera este código de Rust no válido:

let hello = "Здравствуйте";
let answer = &hello[0];

Ya sabes que answer no será З, la primera letra. Cuando se codifica en UTF-8, el primer byte de З es 208 y el segundo es 151, así que parece que answer en realidad debería ser 208, pero 208 no es un carácter válido por sí solo. Devolver 208 probablemente no sea lo que un usuario querría si pidiera la primera letra de esta cadena; sin embargo, ese es el único dato que Rust tiene en el índice de byte 0. Generalmente, los usuarios no quieren que se les devuelva el valor del byte, incluso si la cadena contiene solo letras latinas: si &"hello"[0] fuera código válido que devolviera el valor del byte, devolvería 104, no h.

La respuesta, entonces, es que para evitar devolver un valor inesperado y causar errores que quizás no se descubran inmediatamente, Rust no compila este código en absoluto y evita los malentendidos al principio del proceso de desarrollo.

¡Bytes, valores escalares y clusters de grafemas! ¡Oh, mi!

Otro punto sobre UTF-8 es que en realidad hay tres maneras relevantes de ver las cadenas desde el punto de vista de Rust: como bytes, valores escalares y clusters de grafemas (lo más cercano a lo que llamaríamos letras).

Si miramos la palabra hindi "नमस्ते" escrita en el alfabeto devanagari, se almacena como un vector de valores de u8 que se ve así:

[224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224,
164, 164, 224, 165, 135]

Eso son 18 bytes y es cómo las computadoras almacenan ultimamente estos datos. Si los miramos como valores escalares Unicode, que es el tipo char de Rust, esos bytes se ven así:

['न', 'म', 'स', '्', 'त', 'े']

Hay seis valores de char aquí, pero el cuarto y el sexto no son letras: son diacríticos que no tienen sentido por sí solos. Finalmente, si los miramos como clusters de grafemas, obtendríamos lo que una persona llamaría las cuatro letras que forman la palabra hindi:

["न", "म", "स्", "ते"]

Rust proporciona diferentes maneras de interpretar los datos de cadena crudos que almacenan las computadoras para que cada programa pueda elegir la interpretación que necesita, sin importar el idioma humano del que se tratan los datos.

Una razón final por la cual Rust no nos permite indexar una String para obtener un carácter es que se espera que las operaciones de indexación siempre tomen un tiempo constante (O(1)). Pero no es posible garantizar ese rendimiento con una String, porque Rust tendría que recorrer el contenido desde el principio hasta el índice para determinar cuántos caracteres válidos había.

Tomando rebanadas de cadenas

Indexar en una cadena a menudo es una mala idea porque no está claro cuál debería ser el tipo de retorno de la operación de indexación de cadenas: un valor de byte, un carácter, un cluster de grafemas o una rebanada de cadena. Si realmente necesitas usar índices para crear rebanadas de cadena, por lo tanto, Rust te pide que seas más específico.

En lugar de indexar usando [] con un solo número, puedes usar [] con un rango para crear una rebanada de cadena que contenga bytes particulares:

let hello = "Здравствуйте";

let s = &hello[0..4];

Aquí, s será un &str que contiene los primeros cuatro bytes de la cadena. Antes, mencionamos que cada uno de estos caracteres era de dos bytes, lo que significa que s será Зд.

Si intentáramos tomar una rebanada solo de parte de los bytes de un carácter con algo como &hello[0..1], Rust se detendría en tiempo de ejecución de la misma manera que si se accediera a un índice no válido en un vector:

thread 'main' panicked at 'byte index 1 is not a char boundary;
it is inside 'З' (bytes 0..2) of `Здравствуйте`', src/main.rs:4:14

Debes tener cuidado al crear rebanadas de cadena con rangos, porque hacerlo puede detener tu programa.

Métodos para iterar sobre cadenas

La mejor manera de operar sobre partes de cadenas es ser explícito sobre si deseas caracteres o bytes. Para valores escalares Unicode individuales, utiliza el método chars. Llamar a chars en "Зд" separa y devuelve dos valores de tipo char, y puedes iterar sobre el resultado para acceder a cada elemento:

for c in "Зд".chars() {
    println!("{c}");
}

Este código imprimirá lo siguiente:

З
д

En alternativa, el método bytes devuelve cada byte crudo, lo que puede ser adecuado para tu dominio:

for b in "Зд".bytes() {
    println!("{b}");
}

Este código imprimirá los cuatro bytes que forman esta cadena:

208
151
208
180

Pero asegúrate de recordar que los valores escalares Unicode válidos pueden estar formados por más de un byte.

Obtener clusters de grafemas de cadenas, como con el alfabeto devanagari, es complejo, por lo que esta funcionalidad no está disponible en la biblioteca estándar. Hay cajas disponibles en https://crates.io si esta es la funcionalidad que necesitas.

Las cadenas no son tan simples

Para resumir, las cadenas son complicadas. Diferentes lenguajes de programación toman diferentes decisiones sobre cómo presentar esta complejidad al programador. Rust ha elegido hacer que el manejo correcto de los datos de String sea el comportamiento predeterminado para todos los programas de Rust, lo que significa que los programadores deben poner más atención en manejar los datos UTF-8 desde el principio. Esta compensación expone más de la complejidad de las cadenas que lo que es aparente en otros lenguajes de programación, pero te evita tener que manejar errores que involucren caracteres no ASCII más adelante en el ciclo de vida de tu desarrollo.

La buena noticia es que la biblioteca estándar ofrece mucha funcionalidad construida sobre los tipos String y &str para ayudar a manejar correctamente estas situaciones complejas. Asegúrate de revisar la documentación para métodos útiles como contains para buscar en una cadena y replace para sustituir partes de una cadena con otra cadena.

Pasemos a algo un poco menos complejo: los mapas hash.

Resumen

¡Felicidades! Has completado el laboratorio Almacenar texto codificado en UTF-8 con cadenas. Puedes practicar más laboratorios en LabEx para mejorar tus habilidades.