Armazenando Texto Codificado em UTF-8 com Strings

Beginner

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

Introdução

Bem-vindo(a) a Armazenando Texto Codificado em UTF-8 com Strings. Este laboratório faz parte do Livro de Rust. Você pode praticar suas habilidades em Rust no LabEx.

Neste laboratório, discutiremos as complexidades das strings em Rust, particularmente em relação à codificação UTF-8, bem como as operações e diferenças do tipo String em comparação com outras coleções.

Armazenando Texto Codificado em UTF-8 com Strings

Falamos sobre strings no Capítulo 4, mas agora vamos analisá-las com mais profundidade. Novos Rustaceans geralmente ficam presos em strings por uma combinação de três razões: a propensão do Rust para expor possíveis erros, as strings serem uma estrutura de dados mais complicada do que muitos programadores lhes dão crédito e UTF-8. Esses fatores se combinam de uma forma que pode parecer difícil quando você vem de outras linguagens de programação.

Discutimos strings no contexto de coleções porque as strings são implementadas como uma coleção de bytes, além de alguns métodos para fornecer funcionalidade útil quando esses bytes são interpretados como texto. Nesta secçã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 é diferente das outras coleções, nomeadamente como a indexação em uma String é complicada pelas diferenças entre como as pessoas e os computadores interpretam os dados String.

O Que é uma String?

Primeiramente, definiremos o que queremos dizer com o termo string. Rust tem apenas um tipo de string na linguagem principal, que é a fatia de string str, que geralmente é vista em sua forma emprestada &str. No Capítulo 4, falamos sobre fatias de string (string slices), que são referências a alguns dados de string codificados em UTF-8 armazenados em outro lugar. Literais de string, por exemplo, são armazenados no binário do programa e, portanto, são fatias de string.

O tipo String, que é fornecido pela biblioteca padrão do Rust em vez de ser codificado na linguagem principal, é um tipo de string codificado em UTF-8, mutável, de propriedade e expansível. Quando os Rustaceans se referem a "strings" em Rust, eles podem estar se referindo tanto ao String quanto ao tipo de fatia de string &str, e não apenas a um desses tipos. Embora esta seção seja em grande parte sobre String, ambos os tipos são amplamente utilizados na biblioteca padrão do Rust, e tanto String quanto as fatias de string são codificadas em UTF-8.

Criando uma Nova String

Muitas das mesmas operações disponíveis com Vec<T> também estão disponíveis com String, porque String é realmente implementado como um wrapper em torno de um vetor de bytes com algumas garantias, restrições e capacidades extras. Um exemplo de uma função que funciona da mesma forma com Vec<T> e String é a função new para criar uma instância, mostrada na Listagem 8-11.

let mut s = String::new();

Listagem 8-11: Criando uma nova String vazia

Esta linha cria uma nova string vazia chamada s, na qual podemos então carregar dados. Frequentemente, teremos alguns dados iniciais com os quais queremos começar a string. Para isso, usamos o método to_string, que está disponível em qualquer tipo que implemente o trait Display, como os literais de string fazem. A Listagem 8-12 mostra dois exemplos.

let data = "initial contents";

let s = data.to_string();

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

Listagem 8-12: Usando o método to_string para criar uma String a partir de um literal de string

Este 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 na Listagem 8-13 é equivalente ao código na Listagem 8-12 que usa to_string.

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

Listagem 8-13: Usando a função String::from para criar uma String a partir de um literal de string

Como as strings são usadas para tantas coisas, podemos usar muitas APIs genéricas diferentes para strings, fornecendo-nos muitas opções. Algumas delas podem parecer redundantes, mas todas têm seu lugar! Neste caso, String::from e to_string fazem a mesma coisa, então qual você escolhe é uma questão de estilo e legibilidade.

Lembre-se que as strings são codificadas em UTF-8, então podemos incluir quaisquer dados devidamente codificados nelas, como mostrado na Listagem 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");

Listagem 8-14: Armazenando saudações em diferentes idiomas em strings

Todos estes são valores String válidos.

Atualizando uma String

Uma String pode crescer em tamanho e seu conteúdo pode mudar, assim como o conteúdo de um Vec<T>, se você inserir mais dados nela. Além disso, você pode convenientemente usar o operador + ou a macro format! para concatenar valores String.

Anexando a uma String com push_str e push

Podemos aumentar uma String usando o método push_str para anexar uma fatia de string (string slice), como mostrado na Listagem 8-15.

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

Listagem 8-15: Anexando uma fatia de string a uma String usando o método push_str

Após estas duas linhas, s conterá foobar. O método push_str recebe uma fatia de string porque não queremos necessariamente assumir a propriedade do parâmetro. Por exemplo, no código na Listagem 8-16, queremos ser capazes de usar s2 após anexar seu conteúdo a s1.

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

Listagem 8-16: Usando uma fatia de string após anexar seu conteúdo a uma String

Se o método push_str assumisse a propriedade de s2, não seríamos capazes de imprimir seu valor na última linha. No entanto, este código funciona como esperaríamos!

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.

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

Listagem 8-17: Adicionando um caractere a um valor String usando push

Como resultado, s conterá lol.

Concatenação com o operador + ou a macro format!

Frequentemente, você vai querer combinar duas strings existentes. Uma maneira de fazer isso é usar o operador +, como mostrado na Listagem 8-18.

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

Listagem 8-18: Usando o operador + para combinar dois valores String em um novo valor String

A string s3 conterá Hello, world!. A razão pela qual s1 não é mais válido após a adição, e a razão pela qual usamos uma referência a s2, tem a ver com a assinatura do método que é 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 tipos concretos, que é o que acontece quando chamamos este método com valores String. Discutiremos genéricos no Capítulo 10. Esta assinatura nos dá as pistas que precisamos para entender as partes complicadas do operador +.

Primeiro, s2 tem um &, significando que estamos adicionando uma referência da segunda string à primeira string. Isso ocorre por causa do parâmetro s na função add: só podemos adicionar um &str a uma String; não podemos adicionar dois valores String juntos. Mas espere---o tipo de &s2 é &String, não &str, como especificado no segundo parâmetro para add. Então, por que a Listagem 8-18 compila?

A razão pela qual podemos usar &s2 na chamada para add é que o compilador pode coagir o argumento &String em um &str. Quando chamamos o método add, Rust usa uma coerção deref, que aqui transforma &s2 em &s2[..]. Discutiremos a coerção deref com mais profundidade no Capítulo 15. Como add não assume a propriedade do parâmetro s, s2 ainda será uma String válida após esta operação.

Segundo, podemos ver na assinatura que add assume a propriedade de self porque self não tem um &. Isso significa que s1 na Listagem 8-18 será movido para a chamada add e não será mais válido depois disso. Então, embora let s3 = s1 + &s2; pareça que vai copiar ambas as strings e criar uma nova, esta instrução na verdade assume a propriedade de s1, anexa uma cópia do conteúdo de s2 e então retorna a propriedade 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 múltiplas strings, o comportamento do operador + se torna complicado:

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

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

Neste ponto, s será tic-tac-toe. Com todos os caracteres + e ", é difícil ver o que está acontecendo. Para combinar strings de maneiras mais complicadas, podemos, em vez disso, usar a 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 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 usando format! é muito mais fácil de ler, e o código gerado pela macro format! usa referências para que esta chamada não assuma a propriedade de nenhum de seus parâmetros.

Indexação em Strings

Em muitas outras linguagens de programação, acessar caracteres individuais em uma string referenciando-os por índice é uma operação válida e comum. No entanto, se você tentar acessar partes de uma String usando a sintaxe de indexação em Rust, você receberá um erro. Considere o código inválido na Listagem 8-19.

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

Listagem 8-19: Tentando usar a sintaxe de indexação com uma String

Este código resultará no seguinte erro:

error[E0277]: the type `String` cannot be indexed by `{integer}`
 --> src/main.rs:3:13
  |
3 |     let h = s1[0];
  |             ^^^^^ `String` cannot be indexed by `{integer}`
  |
  = help: the trait `Index<{integer}>` is not implemented for
`String`

O erro e a nota contam a história: strings Rust não suportam indexação. Mas por quê? Para responder a essa pergunta, precisamos discutir como o Rust armazena strings na memória.

Representação Interna

Uma String é um wrapper sobre um Vec<u8>. Vamos analisar algumas de nossas strings de exemplo UTF-8 devidamente codificadas da Listagem 8-14. Primeiro, esta:

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

Neste 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 um byte quando codificada em UTF-8. A seguinte linha, no entanto, pode surpreendê-lo (observe que esta string começa com a letra cirílica maiúscula Ze, não com o número arábico 3):

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

Se lhe perguntassem qual o comprimento da string, você poderia dizer 12. Na verdade, a resposta do 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 se correlacionará com 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 pareceria que answer deveria, na verdade, ser 208, mas 208 não é um caractere válido por si só. Retornar 208 provavelmente não é o que um usuário gostaria se pedisse a primeira letra desta string; no entanto, esses são os únicos dados que o Rust tem no índice de byte 0. Os usuários geralmente não querem o valor do byte retornado, mesmo que a string contenha apenas letras latinas: se &"hello"[0] fosse um código válido que retornasse o valor do byte, ele retornaria 104, não h.

A resposta, então, é que, para evitar retornar um valor inesperado e causar bugs que podem não ser descobertos imediatamente, o Rust não compila este código e impede mal-entendidos no início do processo de desenvolvimento.

Bytes, Valores Escalares e Clusters de Grafemas! Oh, Meu Deus!

Outro ponto sobre UTF-8 é que existem, na verdade, três maneiras relevantes de analisar strings da perspectiva do Rust: como bytes, valores escalares e clusters de grafemas (a coisa mais próxima do que chamaríamos de letras).

Se olharmos para a palavra hindi "नमस्ते" escrita no script Devanagari, 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 são 18 bytes e é como os computadores, em última análise, armazenam esses dados. Se os olharmos como valores escalares Unicode, que são o que o tipo char do Rust é, esses bytes se parecem com isto:

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

Existem seis valores char aqui, mas o quarto e o sexto não são letras: são diacríticos que não fazem sentido por conta própria. Finalmente, se os olharmos como clusters de grafemas, obteríamos o que uma pessoa chamaria de as quatro letras que compõem a palavra hindi:

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

O Rust fornece diferentes maneiras de interpretar os dados brutos da string que os computadores armazenam, para que cada programa possa escolher a interpretação que precisa, independentemente da linguagem humana em que os dados estão.

Uma razão final pela qual o Rust não nos permite indexar em uma String para obter um caractere é que as operações de indexação devem sempre levar tempo constante (O(1)). Mas não é possível garantir esse desempenho com uma String, porque o Rust teria que percorrer o conteúdo desde o início até o índice para determinar quantos caracteres válidos existiam.

Fatiando Strings (Slicing Strings)

Indexar em uma string é frequentemente uma má ideia porque não está claro qual deve ser o tipo de retorno da operação de indexação da string: um valor de byte, um caractere, um cluster de grafemas ou uma fatia de string (string slice). Se você realmente precisar usar índices para criar fatias de string, o Rust pede que você seja mais específico.

Em vez de indexar usando [] com um único número, você pode usar [] com um intervalo para criar uma fatia de string contendo bytes específicos:

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

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

Aqui, s será um &str que contém os primeiros quatro bytes da string. Anteriormente, mencionamos que cada um desses caracteres tinha dois bytes, o que significa que s será Зд.

Se tentássemos fatiar apenas parte dos bytes de um caractere com algo como &hello[0..1], o Rust entraria em pânico em tempo de execução da mesma forma que se um índice inválido fosse acessado em um vetor:

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

Você deve ter cautela ao criar fatias de string com intervalos, porque fazê-lo pode travar seu programa.

Métodos para Iterar sobre Strings

A melhor maneira de operar em pedaços de strings é ser explícito sobre se você deseja caracteres ou bytes. Para valores escalares Unicode individuais, use o método chars. Chamar chars em "Зд" separa e retorna dois valores do tipo char, e você pode iterar sobre o resultado para acessar cada elemento:

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

Este código imprimirá o seguinte:

З
д

Alternativamente, o método bytes retorna cada byte bruto, o que pode ser apropriado para o seu domínio:

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

Este código imprimirá os quatro bytes que compõem esta string:

208
151
208
180

Mas certifique-se de lembrar que valores escalares Unicode válidos podem ser compostos por mais de um byte.

Obter clusters de grafemas de strings, como no script Devanagari, é complexo, portanto, essa funcionalidade não é fornecida pela biblioteca padrão. Crates estão disponíveis em https://crates.io se esta for a funcionalidade que você precisa.

Strings Não São Tão Simples

Em resumo, strings são complicadas. Diferentes linguagens de programação fazem escolhas diferentes sobre como apresentar essa complexidade ao programador. Rust escolheu tornar o tratamento correto de dados String o comportamento padrão para todos os programas Rust, o que significa que os programadores precisam pensar mais em lidar com dados UTF-8 desde o início. Essa troca expõe mais da complexidade das strings do que é aparente em outras linguagens de programação, mas impede que você tenha que lidar com erros envolvendo caracteres não-ASCII mais tarde em seu ciclo de vida de desenvolvimento.

A boa notícia é que a biblioteca padrão oferece muita funcionalidade construída a partir dos tipos String e &str para ajudar a lidar com essas situações complexas corretamente. Certifique-se de verificar a documentação para métodos úteis como contains para pesquisar em uma string e replace para substituir partes de uma string por outra string.

Vamos mudar para algo um pouco menos complexo: mapas de hash (hash maps)!

Resumo

Parabéns! Você concluiu o laboratório Armazenando Texto Codificado em UTF-8 com Strings. Você pode praticar mais laboratórios no LabEx para aprimorar suas habilidades.