¿Cuáles son las diferencias entre `String` y `str` de Rust?
¿Por qué Rust tiene ambos String
y str
? ¿Cuáles son las diferencias entre ellos y cuándo se debe utilizar uno sobre el otro? ¿Alguno de ellos está quedando obsoleto?
String
es el tipo de cadena de montón dinámico, como Vec
: úselo cuando necesite poseer o modificar sus datos de cadena.
str
es una secuencia inmutable de 1 bytes UTF-8 de longitud dinámica en algún lugar de la memoria. Como se desconoce el tamaño, sólo se puede manejar detrás de un puntero. Esto significa que str
lo más común es que 2 aparezca como &str
: una referencia a algunos datos UTF-8, normalmente llamados "segmento de cadena" o simplemente "segmento". Un segmento es solo una vista de algunos datos, y esos datos pueden estar en cualquier lugar, por ejemplo
En almacenamiento estático : un literal de cadena
"foo"
es un archivo&'static str
. Los datos se codifican en el ejecutable y se cargan en la memoria cuando se ejecuta el programa.Dentro de un montón asignado
String
:String
desreferencias a una&str
vista de losString
datos de.En la pila : por ejemplo, lo siguiente crea una matriz de bytes asignada por la pila y luego obtiene una vista de esos datos como
&str
:use std::str; let x: [u8; 3] = [b'a', b'b', b'c']; let stack_str: &str = str::from_utf8(&x).unwrap();
En resumen, utilícelo String
si necesita datos de cadena propios (como pasar cadenas a otros subprocesos o crearlos en tiempo de ejecución) y utilícelo &str
si solo necesita una vista de una cadena.
Esto es idéntico a la relación entre un vector Vec<T>
y un segmento &[T]
, y es similar a la relación entre por valor T
y por referencia &T
para tipos generales.
1 A str
es de longitud fija; no puede escribir bytes más allá del final ni dejar bytes no válidos al final. Dado que UTF-8 es una codificación de ancho variable, esto efectivamente obliga a todos str
los s a ser inmutables en muchos casos. En general, la mutación requiere escribir más o menos bytes que los que había antes (por ejemplo, reemplazar a
(1 byte) por ä
(2+ bytes) requeriría hacer más espacio en el archivo str
). Existen métodos específicos que pueden modificar un &mut str
archivo, principalmente aquellos que manejan solo caracteres ASCII, como make_ascii_uppercase
.
2 Los tipos de tamaño dinámico permiten cosas como Rc<str>
una secuencia de referencia contada en bytes UTF-8 desde Rust 1.2. Rust 1.21 permite crear fácilmente estos tipos.
Tengo experiencia en C++ y me resultó muy útil pensar String
en &str
términos de C++:
- Un Rust
String
es como unstd::string
; es dueño de la memoria y hace el trabajo sucio de administrar la memoria. - Un Rust
&str
es como unchar*
(pero un poco más sofisticado); nos señala el comienzo de un fragmento de la misma manera que puede obtener un puntero al contenido destd::string
.
¿Alguno de ellos va a desaparecer? No lo creo. Tienen dos propósitos:
String
Mantiene el buffer y es muy práctico de usar. &str
es liviano y debe usarse para "mirar" las cadenas. Puede buscar, dividir, analizar e incluso reemplazar fragmentos sin necesidad de asignar nueva memoria.
&str
Puede mirar dentro de a String
ya que puede apuntar a alguna cadena literal. El siguiente código necesita copiar la cadena literal en la String
memoria administrada:
let a: String = "hello rust".into();
El siguiente código le permite usar el literal sin una copia (aunque de solo lectura):
let a: &str = "hello rust";
Es str
lo que es análogo a String
, no su porción.
An str
es una cadena literal, básicamente un texto preasignado:
"Hello World"
Este texto debe almacenarse en algún lugar, por lo que se almacena en la sección de datos del archivo ejecutable junto con el código de máquina del programa, como una secuencia de bytes ([u8]).
┌─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┐
│ H │ e │ l │ l │ o │ │ W │ o │ r │ l │ d │
└─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘
┌─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┐
│ 72 │ 101 │ 108 │ 108 │ 111 │ 32 │ 87 │ 111 │ 114 │ 108 │ 100 │
└─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘
Dado que el texto puede tener cualquier longitud, su tamaño tiene un tamaño dinámico.
Ahora que almacenamos el texto, necesitamos una forma de acceder a él y ahí es donde entra en juego el segmento.
Un segmento , [T]
es una vista de un bloque de memoria. Sea mutable o no, un segmento siempre toma prestado y es por eso que siempre está detrás de un puntero ,.&
Expliquemos el significado de tener un tamaño dinámico.
Algunos lenguajes de programación, como C, añaden un byte cero ( \0
) al final de sus cadenas y mantienen un registro de la dirección inicial. Para determinar la longitud de una cadena, el programa debe recorrer los bytes sin procesar desde la posición inicial hasta encontrar este byte cero.
Sin embargo, Rust adopta un enfoque diferente: utiliza un segmento. Un segmento almacena la dirección donde str
comienza y cuántos bytes ocupa. Es mejor que agregar cero bytes porque el cálculo se realiza por adelantado durante la compilación.
El tamaño del texto se puede conocer de antemano, pero aún cambia con los datos subyacentes, lo que hace que su tamaño sea dinámico.
Si volvemos a la expresión "Hola mundo", devuelve un puntero grueso que contiene tanto la dirección de los datos reales como su longitud. Este puntero será nuestro identificador de los datos reales y también se almacenará en nuestro programa. Ahora los datos están detrás de un puntero y el compilador conoce su tamaño en el momento de la compilación.
Dado que el texto se almacena en el código fuente, será válido durante toda la vida útil del programa en ejecución y, por lo tanto, tendrá toda la static
vida útil.
Entonces, el valor de retorno de la expresión "Hola Palabra" debe reflejar estas dos características, y así es:
let s: &'static str = "Hello World";
Quizás se pregunte por qué su tipo se escribe como str
pero no como [u8]
, porque siempre se garantiza que los datos serán una secuencia UTF-8 válida. No todos los caracteres UTF-8 son de un solo byte, algunos ocupan 4 bytes. Entonces [u8] sería inexacto.
Si desmonta un programa Rust compilado e inspecciona el archivo ejecutable, verá varios str
mensajes de correo electrónico almacenados uno al lado del otro en la sección de datos sin ninguna indicación de dónde comienza uno y termina el otro.
El compilador va un paso más allá: si se utiliza texto estático idéntico en varias ubicaciones del programa, el compilador de Rust optimizará el programa creando un único bloque binario para todos los valores duplicados.
Por ejemplo, el compilador crea un único binario continuo con el contenido de "Hello World" para el siguiente código aunque usemos tres literales diferentes con "Hello World"
:
let x: &'static str = "Hello World";
let y: &'static str = "Hello World";
let z: &'static str = "Hello World";
String
, por otro lado, es un tipo especializado que almacena su valor como vector de u8. Eche un vistazo a cómo String
se define el tipo en el código fuente:
pub struct String {
vec: Vec<u8>,
}
Ser vectorial significa que está asignado al montón y cuyo tamaño se puede cambiar como cualquier otro valor vectorial.
Sin embargo, si observa con atención, verá que vec
el campo se mantiene privado. Al ser un medio privado, no podemos crear una instancia de String directamente sino a través de los métodos proporcionados. La razón por la que se mantiene privado es porque no todos los flujos de bytes producen caracteres utf-8 válidos y la interacción directa con los bytes subyacentes puede dañar los datos. A través de este compilador de acceso controlado se garantiza que los datos sean válidos y sigan siendo válidos.
La palabra especializada en la definición de tipo se refiere a esta característica, característica de no permitir el acceso arbitrario pero sí imponer ciertos controles sobre los datos a través de un acceso controlado para brindar ciertas garantías. Aparte de eso, es sólo un vector.
En resumen, a String
es un búfer de tamaño variable que contiene texto UTF-8. Este búfer se asigna en el montón, por lo que puede crecer según sea necesario o solicitado. Podemos llenar este búfer o cambiar su contenido como mejor nos parezca.
Hay varios métodos definidos en el tipo String para crear una instancia de String, nuevo es uno de ellos:
pub const fn new() -> String {
String { vec: Vec::new() }
}
Podemos usarlo para crear una cadena válida.
let s = String::new();
println("{}", s);
Lamentablemente no acepta parámetros de entrada. Por lo tanto, el resultado será válido pero será una cadena vacía, pero crecerá como cualquier otro vector cuando la capacidad no sea suficiente para contener el valor asignado. Pero el rendimiento de las aplicaciones se verá afectado, ya que el crecimiento requiere una reasignación.
Podemos llenar el vector subyacente con valores iniciales de diferentes fuentes:
De una cadena literal
let a = "Hello World";
let s = String::from(a);
Tenga en cuenta que str
todavía se crea un an y su contenido se copia al vector asignado del montón a través de String.from
. Si verificamos el binario ejecutable veremos bytes sin procesar en la sección de datos con el contenido "Hola mundo". Este es un detalle muy importante que algunas personas pasan por alto.
De partes crudas
let ptr = s.as_mut_ptr();
let len = s.len();
let capacity = s.capacity();
let s = String::from_raw_parts(ptr, len, capacity);
De un personaje
let ch = 'c';
let s = ch.to_string();
Del vector de bytes
let hello_world = vec![72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100];
// We know it is valid sequence, so we can use unwrap
let hello_world = String::from_utf8(hello_world).unwrap();
println!("{}", hello_world); // Hello World
Aquí tenemos otro detalle importante. Un vector puede tener cualquier valor, no hay garantía de que su contenido sea un UTF-8 válido, por lo que Rust nos obliga a tener esto en cuenta devolviendo un archivo Result<String, FromUtf8Error>
en lugar de un archivo String
.
Desde el búfer de entrada
use std::io::{self, Read};
fn main() -> io::Result<()> {
let mut buffer = String::new();
let stdin = io::stdin();
let mut handle = stdin.lock();
handle.read_to_string(&mut buffer)?;
Ok(())
}
O de cualquier otro tipo que implemente ToString
rasgo
Dado que String
es un vector subyacente, exhibirá algunas características de vector:
- un puntero: el puntero apunta a un búfer interno que almacena los datos.
- longitud: la longitud es el número de bytes almacenados actualmente en el búfer.
- Capacidad: La capacidad es el tamaño del buffer en bytes. Por lo tanto, la longitud siempre será menor o igual a la capacidad.
Y delega algunas propiedades y métodos a los vectores:
pub fn capacity(&self) -> usize {
self.vec.capacity()
}
La mayoría de los ejemplos usan String::from
, lo que confunde a la gente al pensar por qué crear una cadena a partir de otra cadena.
Es una lectura larga, espero que ayude.
str
, solo usado como &str
, es un segmento de cadena, una referencia a una matriz de bytes UTF-8.
String
es lo que solía ser ~str
, una matriz de bytes UTF-8 de propiedad cultivable.
En realidad son completamente diferentes. En primer lugar, a str
no es más que una cuestión de nivel de tipo; sólo se puede razonar a nivel de tipo porque es el llamado tipo de tamaño dinámico (DST). El tamaño que str
ocupa no se puede conocer en el momento de la compilación y depende de la información del tiempo de ejecución; no se puede almacenar en una variable porque el compilador necesita saber en el momento de la compilación cuál es el tamaño de cada variable. A str
es conceptualmente solo una fila de u8
bytes con la garantía de que forma UTF-8 válido. ¿Qué tan grande es la fila? Nadie lo sabe hasta el tiempo de ejecución, por lo que no se puede almacenar en una variable.
Lo interesante es que uno &str
o cualquier otro puntero a str
Me gusta Box<str>
existe en tiempo de ejecución. Este es el llamado "puntero gordo"; es un puntero con información adicional (en este caso, el tamaño del objeto al que apunta), por lo que es el doble de grande. De hecho, a &str
está bastante cerca de a String
(pero no de a &String
). A &str
son dos palabras; un puntero al primer byte de a str
y otro número que describe cuántos bytes tiene str
.
Al contrario de lo que se dice, a str
no tiene por qué ser inmutable. Si puede obtener a &mut str
como puntero exclusivo a str
, puede mutarlo y todas las funciones seguras que lo mutan garantizan que se cumpla la restricción UTF-8 porque si se viola entonces tenemos un comportamiento indefinido ya que la biblioteca asume que esta restricción es verdadero y no lo comprueba.
Entonces, ¿qué es un String
? Son tres palabras; dos son iguales que for &str
pero agrega una tercera palabra que es la capacidad del str
buffer en el montón, siempre en el montón (a str
no está necesariamente en el montón) lo administra antes de llenarlo y tiene que reasignarlo. String
Básicamente posee un como str
dicen; lo controla y puede cambiar su tamaño y reasignarlo cuando lo considere oportuno. Entonces, String
como se dijo, a está más cerca de a &str
que de a str
.
Otra cosa es un Box<str>
; esto también posee a str
y su representación en tiempo de ejecución es la misma que a &str
pero también posee str
diferente a &str
pero no puede cambiar su tamaño porque no conoce su capacidad, por lo que básicamente a Box<str>
puede verse como una longitud fija String
que no se puede cambiar de tamaño (puede conviértalo siempre en a String
si desea cambiar su tamaño).
Existe una relación muy similar entre [T]
y Vec<T>
excepto que no hay restricción UTF-8 y puede contener cualquier tipo cuyo tamaño no sea dinámico.
El uso de str
a nivel de tipo es principalmente para crear abstracciones genéricas con &str
; existe en el nivel de tipo para poder escribir rasgos cómodamente. En teoría, str
como tipo, no era necesario que existiera y solo &str
eso significaría que se tendría que escribir una gran cantidad de código adicional que ahora puede ser genérico.
&str
es muy útil poder tener múltiples subcadenas diferentes de a String
sin tener que copiar; como se dijo, a es String
propietario del str
montón que administra y si solo pudiera crear una subcadena de a String
con una nueva, String
tendría que copiarse porque todo en Rust solo puede tener un único propietario para ocuparse de la seguridad de la memoria. Entonces, por ejemplo, puedes cortar una cadena:
let string: String = "a string".to_string();
let substring1: &str = &string[1..3];
let substring2: &str = &string[2..4];
Tenemos dos subcadenas diferentes str
de la misma cadena. string
es el que posee el str
búfer completo real en el montón y las &str
subcadenas son solo punteros gruesos a ese búfer en el montón.