15  El tipo String

En la Sección 5.2.1 vimos que &str es una referencia a una cadena ya existente, normalmente una cadena literal o una cadena prestada temporalmente por otro objeto. Posteriormente, en la Sección 9.1, presentamos el tipo String, cómo crear cadenas de este tipo y los métodos push() y push_str(), que permiten modificar su contenido durante la ejecución del programa.

En este capítulo estudiaremos el tipo String con más detalle. Aprenderemos cómo crear y modificar cadenas, qué operaciones pueden realizarse sobre ellas y, al mismo tiempo, veremos algunos aspectos fundamentales del sistema de propiedad y de la gestión de memoria de Rust.

Aunque String representa texto, el acceso a caracteres individuales requiere algunas consideraciones adicionales debido a la codificación Unicode. Por ello, ese tema se tratará en una sección posterior, una vez comprendido el funcionamiento básico del tipo.

15.1 Creación de cadenas

Existen varias formas de crear un valor de tipo String. Las más habituales son:

  • String::new(), que crea una cadena vacía.

  • String::from(), que crea un String a partir de una cadena literal o de otra referencia de tipo &str.

  • El método to_string(), que convierte un valor en un String. Está disponible para muchos tipos, como números, caracteres o valores booleanos; en general, para cualquier tipo que implemente el rasgo (trait) Display. Estudiaremos los traits más adelante.

Los siguientes ejemplos muestran estos tres casos:

fn main() {
    let s1 = String::new();
    let s2 = String::from("Hola");
    let s3 = "Adiós".to_string();
    let n = 42;
    let s4 = n.to_string();
    let s5 = true.to_string();

    println!("{}",s1);
    println!("{}",s2);
    println!("{}",s3);
    println!("{}",s4);
    println!("{}",s5);
}

Las expresiones String::from("Hola") y "Hola".to_string() producen el mismo resultado: crean un String a partir de una cadena literal. Sin embargo, lo hacen de formas diferentes:

  • String::from() es una función asociada específica del tipo String, cuyo propósito es construir un objeto de este tipo a partir de un valor dado.

  • to_string() es un método mucho más general, disponible para cualquier tipo que implemente el rasgo Display.

Ambas formas son habituales y la elección entre una u otra es, principalmente, una cuestión de estilo.

15.2 Concatenación de cadenas

El tipo String ofrece varias formas de combinar cadenas. Las más habituales son:

  • El operador +, que crea un nuevo String concatenando dos cadenas.

  • El operador +=, que añade una cadena al final de un String existente.

  • La macro format!, que construye un String a partir de una plantilla, de forma similar a println!, pero devolviendo la cadena en lugar de escribirla por la salida estándar.

Comenzaremos estudiando el operador +, ya que nos permitirá introducir algunos aspectos importantes del sistema de propiedad de Rust.

15.2.1 El operador +

Como en muchos otros lenguajes, el operador + permite concatenar cadenas. Pero en Rust existen dos tipos para representarlas: String y &str. ¿Sobre cuál de ellos está definido este operador? Probemos a concatenar dos String.

fn main() {
    let s1 = String::from("Hola");
    let s2 = String::from(", mundo");

    let s3 = s1 + s2;  // ¡Error!. El segundo operando no puede ser un String

    println!("s1:{}", s1);
    println!("s2:{}", s2);
    println!("s3:{}", s3);
}

El compilador da un error y nos informa de que el segundo operando debería ser de tipo &str. Probemos entonces a concatenar dos &str.

fn main() {
    let s1 = "Hola";
    let s2 = ", mundo.";

    let s3 = s1 + s2;   // ¡Error!. + no puede concatenar dos &str

    println!("s1:{}", s1);
    println!("s2:{}", s2);
    println!("s3:{}", s3);
}

También genera un error. ¿Cómo es posible? El mensaje del compilador indica que el operador + no está definido para dos valores de tipo &str. El motivo es que este operador, internamente, es un método cuya firma simplificada es:

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

Ahora podemos entender por qué fallan los ejemplos previos:

  • El primer operando ha de ser un String.
  • El segundo operando, un &str.

En esta definición también debemos fijarnos en algo importante:

  • El primer operando es self, no &self. Como vimos en la Sección 11.1 , esto significa que el método consume el primer operando. La operación de concatenación consume la cadena s1. Internamente, Rust reutiliza el almacenamiento de s1 para construir el resultado, añadiendo el contenido de la segunda cadena. De este modo evita copiar innecesariamente el contenido del primer operando, lo que hace la operación más eficiente. Si por alguna razón necesitáramos conservar s1, podríamos clonarla.

El siguiente ejemplo ya produce el resultado deseado.

fn main() {
    let s1 = String::from("¡Hola");
    let s2 = String::from(", mundo");
    let s3 = s1 + &s2;    // String y &String. Correcto.

    // println!("s1:{}", s1);   // s1 se ha consumido. Ya no existe.
    println!("s2:{}", s2);
    println!("s3:{}", s3);

    let s3 = s3 + "!";     // String y cadena literal (&str). Correcto.
    println!("s3:{}", s3);
}

En estas dos asignaciones, el operador + recibe un segundo operando de tipo &str:

  let s3 = s1 + &s2
  let s3 = s3 + "!";  
  • En el primer caso, &s2 es de tipo &String, que Rust convierte automáticamente en &str.

  • En el segundo caso, "!" ya es una cadena literal y, por tanto, tiene tipo &str.

15.2.2 El operador +=

El operador += añade una cadena al final de un String existente. A diferencia del operador +, no crea un nuevo String, sino que modifica el primero. Su funcionamiento es muy similar al del método push_str(), presentado en la Sección 9.1. De hecho, ambas expresiones producen el mismo efecto: la cadena original se modifica añadiendo al final el contenido de un &str.

fn main() {
    let mut s = String::from("¡Hola");

    s.push_str(", mundo"); // Añade el &str al string.
    s += "! "; // De nuevo, añade el &str al string. 

    println!("{}", s);
}

Al igual que ocurría con el operador +, el segundo operando ha de ser de tipo &str, por tanto, si tenemos un String será necesario prestar una referencia anteponiendo &.

fn main() {
    let mut s1 = String::from("¡Hola");
    let s2 = String::from(", mundo!");

    s1 += &s2;

    println!("s1:{}",s1);
    println!("s2:{}",s2);
}

A diferencia de +, el operador += no consume el primer String, sino que lo recibe por referencia mutable y lo modifica directamente. Por ello, después de la operación tanto s1 como s2 siguen siendo válidos.

15.2.3 La macro format!

Cuando queremos concatenar varias cadenas, el operador + puede hacer que el código resulte poco legible. En estos casos suele ser preferible utilizar la macro format!. Como todas las macros de Rust, su nombre acaba en !.

Su funcionamiento es muy similar al de println!.

  • Al igual que println!, format! admite cadenas literales con marcadores ({}) y una lista de valores que sustituyen a dichos marcadores.

  • La diferencia es que, en lugar de escribir el resultado por la salida estándar, format! devuelve un String con el texto generado.

fn main() {
    let direccion = "192.168.1.5";
    let puerto = 22;

    let s = format!("Endpoint: {}:{}", direccion, puerto);

    println!("{}",s);
}

A diferencia del operador +, una ventaja importante de format! es que no consume ninguna de las cadenas utilizadas para construir el resultado. Por tanto, es una forma muy práctica de construir una nueva cadena a partir de varios valores.

fn main() {
    let s1 = String::from("Hola");
    let s2 = String::from("mundo");

    let saludo = format!("!{}, {}!", s1, s2);

    println!("{}",s1);
    println!("{}",s2);
    println!("{}",saludo);
}

15.3 Recorrido de una cadena

15.3.1 Codificaciones

Durante muchos años, aproximadamente hasta la década de 1990, era habitual asumir que un carácter ocupaba exactamente un byte. Esta suposición era válida para codificaciones como ASCII, que permitían representar letras, números, signos de puntuación y otros caracteres habituales mediante un único byte.

Sin embargo, esa aproximación dejó de ser suficiente cuando fue necesario representar simultáneamente caracteres procedentes de distintos alfabetos y sistemas de escritura.

Hoy es habitual que un mismo documento contenga, por ejemplo, texto en español, griego, árabe, chino y japonés, además de símbolos matemáticos o emojis. Esto era imposible con ASCII y con las numerosas codificaciones derivadas de él, como ISO-8859-1 (Latin-1), para los idiomas de Europa occidental; ISO-8859-5, para el alfabeto cirílico; ISO-8859-7, para el griego; ISO-8859-6, para el árabe; o Windows-1252, muy utilizada en Microsoft Windows. Cada una de ellas solo podía representar un conjunto limitado de caracteres.

Para resolver este problema se desarrolló Unicode. Puede entenderse como una gran tabla que asigna un número a prácticamente cualquier carácter utilizado en los lenguajes humanos, naturales o artificiales.

Una vez asignados esos números, todavía es necesario decidir cómo almacenarlos en memoria mediante bytes. Existen varias formas de hacerlo, denominadas codificaciones Unicode. La más utilizada en la actualidad es UTF-8, que es la empleada por Rust para almacenar las cadenas. En UTF-8, un mismo carácter puede ocupar uno, dos, tres o cuatro bytes. Por este motivo conviene distinguir entre recorrer una cadena por caracteres y recorrerla por bytes.

15.3.2 chars() y bytes()

Los métodos chars() y bytes() permiten recorrer una cadena de dos formas distintas:

  • El método chars() devuelve un iterador cuyos elementos son de tipo char. Recorre la cadena carácter a carácter.

  • El método bytes() devuelve un iterador cuyos elementos son de tipo u8. Recorre la representación UTF-8 de la cadena byte a byte.

En la mayoría de los programas utilizaremos chars(), ya que normalmente nuestro objetivo es procesar texto y no su representación en memoria.

fn main() {
    let s = String::from("¡Hola!");

    println!("Recorrido por caracteres:");
    for c in s.chars() {
        print!("{}, ", c);
    }
    println!();

    println!("Recorrido por bytes:");
    for b in s.bytes() {
        print!("{}, ", b);
    }
    println!();
}

Obsérvese que el recorrido por caracteres produce seis valores, mientras que el recorrido por bytes produce siete. El motivo es que el carácter ¡ ocupa dos bytes en la codificación UTF-8.

15.3.3 Acceso por índice

En muchos lenguajes es posible acceder a los caracteres de una cadena mediante un índice. Por ejemplo cadena[i]. Si intentamos hacer algo similar en Rust el compilador genera un error indicando que un valor de tipo String no puede indexarse mediante el operador [].

fn main() {
    let s = String::from("¡Hola!");

    for i in 0..s.len() {
        print!("{}", s[i]);  // ¡Error! En Rust no se puede usar [i] con cadenas
    }
    println!();
}

¿Por qué? La explicación está relacionada con lo que acabamos de ver. En Rust, un String se almacena utilizando la codificación UTF-8, en la que un carácter puede ocupar entre uno y cuatro bytes. Por tanto, el byte situado en una posición determinada no tiene por qué corresponder al comienzo de un carácter. En consecuencia, un índice entero no basta para identificar un carácter de la cadena.

15.4 Longitud de una cadena

¿Qué entendemos por longitud de una cadena? ¿Su número de bytes o su número de caracteres? Según la respuesta que necesitemos, usaremos el método correspondiente:

  • El método len() devuelve la longitud de una cadena en bytes.

  • El método chars() devuelve un iterador que recorre los caracteres de la cadena uno a uno. El método count() cuenta cuántos caracteres produce ese iterador. La expresión chars().count() devuelve, por tanto, la longitud en caracteres.

En muchos casos ambos valores coinciden, ya que los caracteres ASCII ocupan un único byte. Sin embargo, cuando la cadena contiene caracteres que requieren varios bytes en UTF-8, la longitud en bytes es mayor que el número de caracteres. El siguiente ejemplo ilustra esta diferencia:

fn main() {
    let s1 = String::from("Hello!");
    println!("Cadena: {}", s1);
    println!("Longitud (bytes): {}", s1.len());
    println!("Número de caracteres: {}\n", s1.chars().count());

    let s2 = String::from("¡Hola!");
    println!("Cadena: {}", s2);
    println!("Longitud (bytes): {}", s2.len());
    println!("Número de caracteres: {}", s2.chars().count());
}
  • En una cadena que solo contenga caracteres ASCII (letras inglesas, dígitos y los símbolos de puntuación y operadores más habituales), la codificación UTF-8 coincide con ASCII y cada carácter ocupa exactamente un byte. Habrá tantos caracteres como bytes.

  • Pero la mayoría de caracteres no ingleses ocupan más de un byte. En este ejemplo hay un carácter español,¡, que ocupa dos bytes. Por tanto, son 6 caracteres pero 7 bytes.

15.5 Resumen

En este capítulo hemos estudiado el tipo String, la estructura que utiliza Rust para representar cadenas de texto cuyo contenido puede modificarse.

Las ideas principales son las siguientes:

  • String almacena una secuencia de bytes codificada en UTF-8 y puede aumentar o disminuir de tamaño dinámicamente.
  • Las cadenas pueden crearse de distintas formas, como mediante String::new(), String::from() o el método to_string().
  • Rust ofrece varias formas de concatenar cadenas, entre ellas los operadores + y += y la macro format!.
  • Debido a la codificación UTF-8, no es posible acceder a un carácter mediante un índice como en otros lenguajes.
  • Para recorrer una cadena pueden utilizarse los iteradores chars() y bytes(), según se desee trabajar con caracteres o con los bytes que los representan.
  • La longitud obtenida mediante len() corresponde al número de bytes almacenados, no necesariamente al número de caracteres.

Comprender cómo representa Rust las cadenas de texto resulta fundamental para escribir programas correctos y eficientes, especialmente cuando trabajan con texto internacional o con caracteres Unicode.