Armazenando texto codificado em UTF-8 com strings
Falamos sobre strings no Capítulo 4, mas agora vamos examiná-las com mais profundidade. Pessoas novas em Rust frequentemente travam em strings por uma combinação de três razões: a tendência de Rust a expor erros possíveis, o fato de strings serem uma estrutura de dados mais complexa do que muita gente imagina e o UTF-8. Esses fatores se combinam de um jeito que pode parecer difícil para quem vem de outras linguagens de programação.
Discutimos strings no contexto de coleções porque strings são implementadas como
uma coleção de bytes, junto com alguns métodos que fornecem funcionalidades
úteis quando esses bytes são interpretados como texto. Nesta seção, falaremos
sobre as operações em String que todo tipo de coleção possui, como criar,
atualizar e ler. Também discutiremos as maneiras pelas quais String difere
das outras coleções, especialmente como a indexação em String é complicada
pelas diferenças entre a forma como pessoas e computadores interpretam dados de
String.
Definindo strings
Primeiro, vamos definir o que queremos dizer com o termo string. Rust tem
apenas um tipo de string no núcleo da linguagem, que é o string slice str,
geralmente visto em sua forma emprestada, &str. No Capítulo 4, falamos sobre
string slices, que são referências a dados de string codificados em UTF-8
armazenados em algum outro lugar. Literais de string, por exemplo, ficam
armazenados no binário do programa e, portanto, são string slices.
O tipo String, fornecido pela biblioteca padrão de Rust em vez de fazer parte
do núcleo da linguagem, é um tipo de string expansível, mutável, com ownership
e codificado em UTF-8. Quando pessoas da comunidade Rust se referem a
“strings” em Rust, podem estar falando tanto do tipo String quanto do tipo
string slice &str, e não apenas de um deles. Embora esta seção trate
principalmente de String, os dois tipos são bastante usados na biblioteca
padrão de Rust, e tanto String quanto string slices são codificados em UTF-8.
Criando uma nova string
Muitas das mesmas operações disponíveis para Vec<T> também estão disponíveis
para String, porque String é implementada como um invólucro em torno de um
vetor de bytes com algumas garantias, restrições e capacidades extras. Um
exemplo de função que funciona da mesma forma em Vec<T> e em String é a
função new, usada para criar uma instância, como mostra a Listagem 8-11.
fn main() {
let mut s = String::new();
}
String nova e vaziaEssa linha cria uma nova string vazia chamada s, na qual depois podemos
colocar dados. Muitas vezes, no entanto, já teremos alguns dados iniciais com
os quais queremos começar a string. Para isso, usamos o método to_string,
disponível em qualquer tipo que implemente a trait Display, como é o caso dos
literais de string. A Listagem 8-12 mostra dois exemplos.
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();
}
to_string para criar uma String a partir de um literal de stringEsse código cria uma string contendo initial contents.
Também podemos usar a função String::from para criar uma String a partir de
um literal de string. O código da Listagem 8-13 é equivalente ao código da
Listagem 8-12 que usa to_string.
fn main() {
let s = String::from("initial contents");
}
String::from para criar uma String a partir de um literal de stringComo strings são usadas para muitas coisas, podemos usar muitas APIs genéricas
diferentes para elas, o que nos dá bastante opção. Algumas podem parecer
redundantes, mas todas têm seu lugar! Neste caso, String::from e
to_string fazem a mesma coisa, então escolher entre uma e outra é uma questão
de estilo e legibilidade.
Lembre-se de que strings são codificadas em UTF-8, então podemos incluir nelas quaisquer dados corretamente codificados, como mostra a Listagem 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");
}
Todos esses são valores válidos de String.
Atualizando uma string
Uma String pode crescer de tamanho e seu conteúdo pode mudar, assim como o
conteúdo de um Vec<T> muda se você empurrar mais dados para dentro dele. Além
disso, também podemos usar convenientemente o operador + ou a macro
format! para concatenar valores de String.
Anexando com push_str ou push
Podemos fazer uma String crescer usando o método push_str para anexar um
string slice, como mostra a Listagem 8-15.
fn main() {
let mut s = String::from("foo");
s.push_str("bar");
}
String usando o método push_strDepois dessas duas linhas, s conterá foobar. O método push_str recebe um
string slice porque não queremos necessariamente tomar ownership do parâmetro.
Por exemplo, no código da Listagem 8-16, queremos continuar podendo usar s2
mesmo depois de anexar seu conteúdo a s1.
fn main() {
let mut s1 = String::from("foo");
let s2 = "bar";
s1.push_str(s2);
println!("s2 is {s2}");
}
StringSe o método push_str tomasse ownership de s2, não poderíamos imprimir seu
valor na última linha. No entanto, esse código funciona exatamente como
esperávamos!
O método push recebe um único caractere como parâmetro e o adiciona à
String. A Listagem 8-17 adiciona a letra l a uma String usando o método
push.
fn main() {
let mut s = String::from("lo");
s.push('l');
}
String com pushComo resultado, s conterá lol.
Concatenando com + ou format!
Frequentemente você vai querer combinar duas strings existentes. Uma forma de
fazer isso é usar o operador +, como mostra a Listagem 8-18.
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
}
+ para combinar dois valores String em um novo valor StringA string s3 conterá Hello, world!. O motivo pelo qual s1 deixa de ser
válida após a adição, e o motivo pelo qual usamos uma referência para s2, têm
a ver com a assinatura do método chamado quando usamos o operador +. O
operador + usa o método add, cuja assinatura se parece com isto:
fn add(self, s: &str) -> String {
Na biblioteca padrão, você verá add definido usando genéricos e tipos
associados. Aqui, substituímos isso por tipos concretos, que é o que acontece
quando chamamos esse método com valores String. Veremos genéricos no Capítulo
10. Essa assinatura já nos dá as pistas necessárias para entender as partes
mais complicadas do operador +.
Primeiro, s2 tem um &, o que significa que estamos adicionando uma
referência da segunda string à primeira string. Isso acontece por causa do
parâmetro s na função add: só podemos adicionar um string slice a uma
String; não podemos somar diretamente dois valores String. Mas espere: o
tipo de &s2 é &String, não &str, como especificado no segundo parâmetro
de add. Então por que a Listagem 8-18 compila?
A razão pela qual conseguimos usar &s2 na chamada a add é que o compilador
pode coagir o argumento &String para &str. Quando chamamos o método add,
Rust usa uma coerção de deref, que aqui transforma &s2 em &s2[..].
Falaremos sobre coerção de deref com mais profundidade no Capítulo 15. Como
add não toma ownership do parâmetro s, s2 continuará sendo uma String
válida depois dessa operação.
Em segundo lugar, podemos ver na assinatura que add toma ownership de self
porque self não tem &. Isso significa que s1, na Listagem 8-18, será
movida para a chamada de add e não será mais válida depois disso. Então,
embora let s3 = s1 + &s2; pareça que vai copiar as duas strings e criar uma
nova, essa instrução, na verdade, toma ownership de s1, anexa uma cópia do
conteúdo de s2 e então devolve ownership do resultado. Em outras palavras,
parece que está fazendo muitas cópias, mas não está; a implementação é mais
eficiente do que copiar.
Se precisarmos concatenar várias strings, o comportamento do operador + fica
difícil de lidar:
fn main() {
let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");
let s = s1 + "-" + &s2 + "-" + &s3;
}
Nesse ponto, s será tic-tac-toe. Com todos os caracteres + e ", fica
difícil enxergar o que está acontecendo. Para combinar strings de formas mais
complicadas, podemos usar a 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}");
}
Esse código também define s como tic-tac-toe. A macro format! funciona
como println!, mas, em vez de imprimir a saída na tela, ela retorna uma
String com o conteúdo. A versão do código que usa format! é bem mais fácil
de ler, e o código gerado por format! usa referências, de modo que essa
chamada não toma ownership de nenhum de seus parâmetros.
Indexando strings
Em muitas outras linguagens de programação, acessar caracteres individuais de
uma string por índice é uma operação válida e comum. No entanto, se você tentar
acessar partes de uma String com sintaxe de indexação em Rust, receberá um
erro. Considere o código inválido da Listagem 8-19.
fn main() {
let s1 = String::from("hi");
let h = s1[0];
}
StringEsse código produzirá o seguinte erro:
$ 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}`
= 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 following other types implement trait `SliceIndex<T>`:
`usize` implements `SliceIndex<ByteStr>`
`usize` implements `SliceIndex<[T]>`
= 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
A mensagem de erro conta a história: strings em Rust não dão suporte a indexação. Mas por que não? Para responder a essa pergunta, precisamos discutir como Rust armazena strings na memória.
Representação interna
Uma String é um invólucro sobre um Vec<u8>. Vamos olhar novamente para
algumas das strings de exemplo codificadas corretamente em UTF-8 da Listagem
8-14. Primeiro, esta aqui:
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");
}
Nesse caso, len será 4, o que significa que o vetor que armazena a string
"Hola" tem 4 bytes de comprimento. Cada uma dessas letras ocupa 1 byte quando
codificada em UTF-8. Já a linha a seguir pode surpreender você; observe que
essa string começa com a letra cirílica maiúscula Ze, e não com o número 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");
}
Se alguém perguntasse o comprimento dessa string, você talvez dissesse 12. Na verdade, a resposta de Rust é 24: esse é o número de bytes necessários para codificar “Здравствуйте” em UTF-8, porque cada valor escalar Unicode nessa string ocupa 2 bytes de armazenamento. Portanto, um índice nos bytes da string nem sempre corresponderá a um valor escalar Unicode válido. Para demonstrar, considere este código Rust inválido:
let hello = "Здравствуйте";
let answer = &hello[0];
Você já sabe que answer não será З, a primeira letra. Quando codificado em
UTF-8, o primeiro byte de З é 208 e o segundo é 151, então poderia
parecer que answer deveria ser 208, mas 208 não é um caractere válido por
si só. Retornar 208 provavelmente não é o que uma pessoa desejaria se
pedisse a primeira letra dessa string; no entanto, esse é o único dado que Rust
tem no índice de byte 0. Em geral, quem usa o programa não quer receber o
valor do byte, mesmo quando a string contém apenas letras latinas: se
&"hi"[0] fosse um código válido que retornasse o valor do byte, ele
retornaria 104, e não h.
A resposta, então, é que, para evitar retornar um valor inesperado e causar bugs que talvez não fossem descobertos imediatamente, Rust simplesmente não compila esse código, evitando mal-entendidos logo no início do processo de desenvolvimento.
Bytes, valores escalares e clusters de grafemas
Outro ponto sobre UTF-8 é que existem, na verdade, três formas relevantes de olhar para strings do ponto de vista de Rust: como bytes, como valores escalares e como clusters de grafemas, que são a coisa mais próxima do que chamaríamos de letras.
Se olharmos para a palavra hindi “नमस्ते”, escrita na escrita devanágari, ela é
armazenada como um vetor de valores u8 que se parece com isto:
[224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224, 164, 164,
224, 165, 135]
Isso representa 18 bytes e é a forma como os computadores armazenam esses
dados em última instância. Se olharmos para esses dados como valores escalares
Unicode, que é o que o tipo char de Rust representa, esses bytes ficam assim:
['न', 'म', 'स', '्', 'त', 'े']
Temos aqui seis valores char, mas o quarto e o sexto não são letras: são
marcas diacríticas que não fazem sentido sozinhas. Por fim, se olharmos para
isso como clusters de grafemas, obteremos o que uma pessoa consideraria as
quatro letras que formam a palavra hindi:
["न", "म", "स्", "ते"]
Rust oferece formas diferentes de interpretar os dados brutos de string que os computadores armazenam, para que cada programa possa escolher a interpretação de que precisa, independentemente da linguagem humana em que os dados estejam.
Um último motivo pelo qual Rust não nos permite indexar uma String para obter
um caractere é que se espera que operações de indexação sempre levem tempo
constante, O(1). Mas não é possível garantir esse desempenho com uma String,
porque Rust teria de percorrer o conteúdo desde o começo até o índice para
determinar quantos caracteres válidos existem ali.
Fatiando strings
Indexar uma string costuma ser uma má ideia porque não está claro qual deveria ser o tipo de retorno da operação de indexação: um valor de byte, um caractere, um cluster de grafemas ou um string slice. Por isso, se você realmente precisa usar índices para criar string slices, Rust exige que você seja mais específico.
Em vez de indexar usando [] com um único número, você pode usar [] com um
intervalo para criar um string slice contendo bytes específicos:
#![allow(unused)]
fn main() {
let hello = "Здравствуйте";
let s = &hello[0..4];
}
Aqui, s será um &str que contém os primeiros 4 bytes da string. Antes,
mencionamos que cada um desses caracteres tinha 2 bytes, o que significa que
s será Зд.
Se tentássemos fatiar apenas parte dos bytes de um caractere com algo como
&hello[0..1], Rust entraria em pânico em tempo de execução, da mesma forma
que faz quando acessamos um índice inválido em um vetor:
$ 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
Você deve tomar cuidado ao criar string slices com intervalos, porque isso pode fazer seu programa travar.
Iterando sobre strings
A melhor maneira de operar sobre partes de strings é ser explícito sobre se
você quer caracteres ou bytes. Para valores escalares Unicode individuais, use
o método chars. Chamar chars sobre “Зд” separa e retorna dois valores do
tipo char, e você pode iterar sobre o resultado para acessar cada elemento:
#![allow(unused)]
fn main() {
for c in "Зд".chars() {
println!("{c}");
}
}
Esse código imprimirá o seguinte:
З
д
Como alternativa, o método bytes retorna cada byte bruto, o que pode ser o
mais apropriado para o seu domínio:
#![allow(unused)]
fn main() {
for b in "Зд".bytes() {
println!("{b}");
}
}
Esse código imprimirá os 4 bytes que compõem essa string:
208
151
208
180
Mas lembre-se sempre de que valores escalares Unicode válidos podem ser compostos de mais de 1 byte.
Obter clusters de grafemas a partir de strings, como acontece com a escrita devanágari, é algo complexo, então essa funcionalidade não é fornecida pela biblioteca padrão. Há crates disponíveis em crates.io caso isso seja a funcionalidade de que você precisa.
Lidando com as complexidades das strings
Resumindo, strings são complicadas. Diferentes linguagens de programação fazem
escolhas diferentes sobre como apresentar essa complexidade para quem programa.
Rust escolheu tornar o tratamento correto de dados String o comportamento
padrão de todos os programas Rust, o que significa que programadores precisam
pensar mais cedo sobre como lidar com dados UTF-8. Essa troca expõe mais da
complexidade das strings do que fica aparente em outras linguagens, mas evita
que você tenha de lidar, mais tarde no ciclo de desenvolvimento, com erros
envolvendo caracteres não ASCII.
A boa notícia é que a biblioteca padrão oferece bastante funcionalidade
construída sobre os tipos String e &str para ajudar a tratar corretamente
essas situações complexas. Não deixe de consultar a documentação de métodos
úteis como contains, para procurar em uma string, e replace, para
substituir partes de uma string por outra.
Vamos mudar para algo um pouco menos complexo: hash maps!