Almacenando texto codificado en UTF-8 con Strings

Hemos hablado de strings en el Capítulo 4, pero las veremos con más detalle Los nuevos Rustaceans suelen quedarse atascados en las cadenas por una combinación de tres razones: la propensión de Rust a exponer posibles errores, los strings son 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 se viene de otros lenguajes de programación.

Discutiremos strings en el contexto de las colecciones porque las strings 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 cada tipo de colección tiene, como crear, actualizar y leer. También discutiremos las formas en que String es diferente de las otras colecciones, es decir, cómo indexar en un String se complica por las diferencias entre cómo las personas y las computadoras interpretan los datos de String.

¿Qué es un string?

Bien primero definamos lo que queremos decir con el término string. Rust solo tiene un tipo de string en el lenguaje principal, que es el string slice str que generalmente se ve en su forma prestada &str. En el Capítulo 4, hablamos sobre string slices, 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 trozos de cadena.

El tipo String, que es proporcionado por la biblioteca estándar en lugar de codificado en el lenguaje principal, es un tipo de cadena que puede crecer, mutable, de propiedad, codificado en UTF-8. Cuando los Rustaceans se refieren a "strings" en Rust, pueden estar refiriéndose a cualquiera de los tipos String o str, no solo a uno de esos tipos. Aunque esta sección trata principalmente de String, ambos tipos se usan mucho en la biblioteca estándar de Rust, y tanto String como las rebanadas de cadena son codificadas en UTF-8.

Creando un nuevo String

Muchas de las mismas operaciones disponibles con Vec<T> también están disponibles con String, ya que 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, que se muestra en el listado 8-11.

fn main() {
    let mut s = String::new();
}

Listado 8-11: Creando un nuevo y vacío String

Esta línea crea un nuevo String vacío llamado s, el cual podemos luego cargar con datos. A menudo, tendremos algunos datos iniciales que queremos comenzar en el string. Para eso, usamos el método to_string, que está disponible en cualquier tipo que implemente el trait Display, como lo hacen los String Literals. El listado 8-12 muestra dos ejemplos.

fn main() {
    let data = "initial contents";

    let s = data.to_string();

    // the method also works on a literal directly:
    let s = "initial contents".to_string();
}

Listado 8-12: Usando el método to_string para crear un String a partir de un string literal

Este código crea un string que contiene initial contents.

Podemos también usar la función String::from para crear un String a partir de un string literal. El código en el listado 8-13 es equivalente al código del listado 8-12 que usa to_string.

fn main() {
    let s = String::from("initial contents");
}

Listado 8-13: Usando la función String::from para crear un String a partir de un string literal

Debido a que los strings se usan para muchas cosas, podemos usar muchas APIs genéricas diferentes para strings, lo que nos proporciona muchas opciones. Algunos de ellos pueden parecer redundantes, ¡pero todos tienen su lugar! En este caso, String::from y to_string hacen lo mismo, por lo que elegir depende del estilo y la legibilidad.

Recuerda que los strings son UTF-8 codificados, por lo que podemos incluir cualquier dato codificado correctamente en ellos, Como se muestra en el listado 8-14.

fn main() {
    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");
}

Listado 8-14: Almacenamiento de saludos en diferentes idiomas en strings

Todos estos strings son valores válidos de String.

Actualizando un String

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

Agregando a un String con push_str y push

Podemos hacer crecer un String usando el método push_str para agregar un string slice, como se muestra en el listado 8-15.

fn main() {
    let mut s = String::from("foo");
    s.push_str("bar");
}

Listado 8-15: Agregando un string slice a un String usando el método push_str

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

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

Listado 8-16: Uso de un string slice después de agregar su contenido a un 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 al String. El listado 8-17 agrega la letra l a un String usando el método push.

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

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

Como resultado, s contendrá lol.

Concatenacion con el operador + o la Macro format!

A veces, necesitarás combinar dos strings. Sin embargo, no es tan simple como usar el operador + con dos referencias a String. El código en el listado 8-18 no compilará:

fn main() {
    let s1 = String::from("Hello, ");
    let s2 = String::from("world!");
    let s3 = s1 + &s2; // note s1 has been moved here and can no longer be used
}

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

El string s3 contendrá Hello, world!. La razón por la que s1 ya no es válido después de la adición, y la razón por la que 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 algo como esto:

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

En la biblioteca estándar, verás add 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 String. Discutiremos los genéricos en el Capítulo 10. Esta firma nos da las pistas que necesitamos para entender las partes complicadas del operador +.

Primero, s2 tiene un &, lo que significa que estamos agregando una referencia del segundo string al primer string. Esto se debe al parámetro s en la función add: solo podemos agregar un &str a un String; no podemos agregar dos valores 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é compila el listado 8-18?

La razón por la que podemos usar s2 en la llamada a add es que el compilador puede convertir el argumento &String en un &str. Cuando
llamamos al método add, Rust usa una coerción de dereferencia, que aquí convierte &s2 en &s2[..]. Discutiremos la coerció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 seguirá siendo un String válido después de esta operación.

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

Si necesitamos concatenar múltiples strings, el comportamiento del operador + se vuelve difícil de manejar:

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

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

En este punto, s contendrá tic-tac-toe. Con todos los caracteres + y " es difícil ver qué está pasando. Para una combinación de cadenas más complicada, podemos usar la macro format!:

fn main() {
    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 un 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.

Indexando en Strings

En muchos otros lenguajes de programación, acceder a caracteres individuales en un string referenciándolos por índice es una operación válida y común. Sin embargo, si intentas acceder a partes de un String usando la sintaxis de indexación en Rust, obtendrás un error. Considera el código inválido en el listado 8-19.

fn main() {
    let s1 = String::from("hello");
    let h = s1[0];
}

Listado 8-19: Intentando usar la sintaxis de indexación con un String

Este código dará como resultado el siguiente error:

$ cargo run
   Compiling collections v0.1.0 (file:///projects/collections)
error[E0277]: the type `str` cannot be indexed by `{integer}`
 --> src/main.rs:3:16
  |
3 |     let h = s1[0];
  |                ^ string indices are ranges of `usize`
  |
  = help: the trait `SliceIndex<str>` is not implemented for `{integer}`, which is required by `String: Index<_>`
  = note: you can use `.chars().nth()` or `.bytes().nth()`
          for more information, see chapter 8 in The Book: <https://doc.rust-lang.org/book/ch08-02-strings.html#indexing-into-strings>
  = help: the trait `SliceIndex<[_]>` is implemented for `usize`
  = help: for that trait implementation, expected `[_]`, found `str`
  = note: required for `String` to implement `Index<{integer}>`

For more information about this error, try `rustc --explain E0277`.
error: could not compile `collections` (bin "collections") due to 1 previous error

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

Representación Interna

Un String es un wrapper sobre un Vec<u8>. Veamos algunos de nuestros strings de ejemplo UTF-8 correctamente codificados del listado 8-14. Primero, este:

fn main() {
    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");
}

En este caso, len será 4, lo que significa que el vector que almacena el string “Hola” tiene 4 bytes de largo. Cada una de estas letras toma un byte cuando se codifica en UTF-8. La siguiente línea, sin embargo, puede sorprenderte. (Nota que este string comienza con la letra cirílica Ze mayúscula, no con el número árabe 3.)

fn main() {
    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");
}

Si tu te preguntas que tan largo es el string, podrías decir 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 ese string toma 2 bytes de almacenamiento. Por lo tanto, un índice en los bytes del string no siempre se correlacionará con un valor escalar Unicode válido. Para demostrarlo, considera este código inválido de Rust:

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

Tu Ahora sabes que answer no será З, la primera letra. Cuando codificado en UTF-8, el primer byte de З es 208 y el segundo es 151, por lo que parecería que answer 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 pidieran la primera letra de esta cadena; sin embargo, esos son los únicos datos que Rust tiene en el índice de bytes 0. Los usuarios generalmente no quieren que se devuelva el valor de byte, incluso si la cadena contiene solo letras latinas: si &"hello"[0] fuera un código válido que devolviera el valor de byte, devolvería 104, no h.

La respuesta, entonces, es que para evitar devolver un valor inesperado y causar errores que podrían no descubrirse de inmediato, Rust no compila este código en absoluto y evita malentendidos al comienzo del proceso de desarrollo.

Bytes, valores escalares y grupos de grafemas

Otro punto sobre UTF-8 es que hay tres formas relevantes de ver las cadenas desde la perspectiva de Rust: como bytes, valores escalares y grupos de grafemas (lo más parecido a lo que llamaríamos letras).

Si observamos la palabra “नमस्ते” en escritura Devanagari, se almacena como un vector de valores u8 que se ve así:

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

Eso es 18 bytes y es como las computadoras almacenan los datos. Si los observamos como valores escalares Unicode, que es lo que es el tipo char de Rust, esos bytes se ven así:

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

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

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

Rust proporciona diferentes formas de interpretar los datos de string sin procesar que las computadoras almacenan para que cada programa pueda elegir la interpretación que necesita, sin importar en qué idioma humano estén los datos.

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

Slicing Strings

La indexación en un String suele ser una mala idea porque no está claro cuál debería ser el tipo de retorno de la operación de indexación de string: un valor de byte, un carácter, un grupo de grafemas o una rebanada de string. Si realmente necesita usar índices para crear rebanadas de string, por lo tanto, Rust le pide que sea más específico.

En lugar de indexar usando [] con un solo número, puede usar [] con un rango para crear un string slice conteniendo bytes particulares:

#![allow(unused)]
fn main() {
let hello = "Здравствуйте";

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

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

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

$ cargo run
   Compiling collections v0.1.0 (file:///projects/collections)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.43s
     Running `target/debug/collections`
thread 'main' panicked at src/main.rs:4:19:
byte index 1 is not a char boundary; it is inside 'З' (bytes 0..2) of `Здравствуйте`
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Debemos tener cuidado cuando creamos string slices, porque hacerlo puede bloquear su programa.

Métodos para iterar sobre Strings

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

#![allow(unused)]
fn main() {
for c in "Зд".chars() {
    println!("{c}");
}
}

Este código imprimirá lo siguiente:

З
д

Alternativamente, el método bytes devuelve cada byte sin procesar, que puede ser apropiado para su dominio:

#![allow(unused)]
fn main() {
for b in "Зд".bytes() {
    println!("{b}");
}
}

Este código imprimirá los cuatro bytes que componen el string:

208
151
208
180

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

Obtener grupos de grafemas a partir de cadenas, como en el caso del alfabeto Devanagari, es complejo, por lo que esta funcionalidad no está proporcionada por la biblioteca estándar. Hay paquetes disponibles en crates.io si necesitas esta funcionalidad.

Los Strings no son tan simples

Para resumir, los strings son complicados. Los diferentes lenguajes de programación hacen diferentes elecciones sobre cómo presentar esta complejidad al programador. Rust ha elegido hacer que el manejo correcto de los datos String sea el comportamiento predeterminado para todos los programas de Rust, lo que significa que los programadores tienen que pensar más en el manejo de datos UTF-8 por adelantado. Este compromiso expone más de la complejidad de las cadenas de lo que parece en otros lenguajes de programación, pero evita que tenga que manejar errores que involucran caracteres no ASCII más adelante en su ciclo de vida de desarrollo.

La buena noticia es que la biblioteca estándar ofrece mucha funcionalidad construida a partir de los tipos String y &str para ayudar a manejar estas situaciones complejas correctamente. Asegúrese de consultar la documentación para obtener métodos útiles como contains para buscar en un string y replace para sustituir partes de un string por otro string.

Pasemos a algo un poco menos complejo: ¡Hash Maps!