terça-feira, 13 de abril de 2021

Armazenamento de texto codificado em UTF-8 com strings em Rust

Falamos sobre strings no Capítulo 4, mas vamos examiná-las com mais profundidade agora. Os novos Rustáceos geralmente ficam presos em strings por uma combinação de três motivos: propensão de Rust em expor possíveis erros, strings sendo uma estrutura de dados mais complicada do que muitos programadores acreditam e UTF-8. Esses fatores se combinam de uma maneira que pode parecer difícil quando você vem de outras linguagens de programação.

É útil discutir strings no contexto de coleções porque 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 seção, vamos falar sobre as operações em String que cada tipo de coleção tem, como criação, atualização e leitura. Também discutiremos as maneiras pelas quais String é diferente de outras coleções, ou seja, 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?

Definiremos primeiro o que queremos dizer com o termo string. Rust tem apenas um tipo de string na linguagem central, que é a fatia de string str que geralmente é vista em sua forma emprestada &str. No Capítulo 4, falamos sobre fatias de string, 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 codificado na linguagem principal, é um tipo de string codificado em UTF-8 que pode ser ampliado, mutável e possuído. Quando Rustáceos se referem a "strings" em Rust, eles geralmente se referem aos tipos de String e fatia de string &str e não apenas um desses tipos. Embora esta seção seja amplamente sobre String, ambos os tipos são usados intensamente na biblioteca padrão do Rust e as String e fatias de string são codificadas em UTF-8.

A biblioteca padrão do Rust também inclui uma série de outros tipos de Strings, tais como OsString, OsStr, CString, e CStr. Os crates de bibliotecas podem fornecer ainda mais opções para armazenar dados de string. Veja como todos esses nomes terminam em String ou Str? Eles se referem a variantes próprias e emprestadas, assim como os tipos String e str que você viu anteriormente. Esses tipos de string podem armazenar texto em codificações diferentes ou ser representados na memória de uma maneira diferente, por exemplo. Não discutiremos esses outros tipos de string neste capítulo; consulte a documentação da API para saber mais sobre como usá-los e quando cada um é apropriado.

Criação de uma nova string

Muitas das mesmas operações disponíveis com Vec<T> também estão disponíveis com String, começando com a função new para criar uma string, mostrada na Listagem 8-11.

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

Listagem 8-11: Criando uma nova e vazia String

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

fn main() {
    let data = "conteúdo inicial";

    let s = data.to_string();

    // the method also works on a literal directly:
    let s = "conteúdo inicial".to_string();
}

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

Este código cria uma string contendo conteúdo inicial.

Também podemos usar a função String::from para criar uma String partir de uma string literal. 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("conteúdo inicial");
}

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

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

Lembre-se de que as strings são codificadas em UTF-8, portanto, podemos incluir quaisquer dados codificados corretamente nelas, conforme mostrado na 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");
}

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

Todos esses são valores String válidos.

Atualizando uma string

A String pode aumentar de tamanho e seu conteúdo pode mudar, assim como o conteúdo de Vec<T>, se você inserir mais dados nele. Além disso, você pode usar convenientemente 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, conforme mostrado na Listagem 8-15.

fn main() {
    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 essas duas linhas, s conterá foobar. O método push_str usa uma fatia de string porque não queremos necessariamente nos apropriar do parâmetro. Por exemplo, o código na Listagem 8-16 mostra que seria lamentável se não pudéssemos usar s2 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);
}

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 poderíamos imprimir seu valor na última linha. No entanto, este código funciona como esperávamos!

O método push pega um único caractere como parâmetro e o adiciona a String. A Listagem 8-17 mostra o código que adiciona a letra "l" a uma String usando o método push.

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

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

Como resultado deste código, s conterá lol.

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

Frequentemente, você desejará combinar duas strings existentes. Uma maneira é usar o operador +, conforme mostrado na Listagem 8-18.

fn main() {
    let s1 = String::from("Hello, ");
    let s2 = String::from("world!");
    let s3 = s1 + &s2; // nota s1 foi movido aqui e não pode mais ser usado
}

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

A string s3 conterá Hello, world! como resultado deste código. O motivo de s1 não ser mais válido após a adição e o motivo pelo qual usamos uma referência 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 {

Esta não é a assinatura exata que está na biblioteca padrão: na biblioteca padrão, add é definida usando genéricos. Aqui, estamos olhando para a assinatura de add com tipos concretos substituídos pelos genéricos, que é o que acontece quando chamamos esse método com valores String. Discutiremos os genéricos no Capítulo 10. Essa assinatura nos dá as pistas de que precisamos para entender as partes complicadas do operador +.

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

A razão pela qual podemos usar &s2 na chamada de add é que o compilador pode forçar o argumento &String a se tornar um &str. Quando chamamos o método add, Rust usa uma coação de referencial, que aqui transforma &s2 em &s2[..]. Discutiremos a coerção desreferencial com mais detalhes no Capítulo 15. Como add não assume a propriedade do parâmetro s, s2 ainda será um valor String válido após esta operação.

Em segundo lugar, podemos ver na assinatura que add toma posse de self, porque self não têm um &. Isso significa que s1 na Listagem 8-18 será movido para a chamada add e não será mais válido depois disso. Portanto, embora let s3 = s1 + &s2; pareça que irá copiar ambas as strings e criar uma nova, esta instrução realmente assume a propriedade de s1, anexa uma cópia do conteúdo de s2 e, em seguida, 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 várias strings, o comportamento do operador + ficará complicado:

fn main() {
    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 combinações de strings 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);
}

Este código também define s como tic-tac-toe. A macro format! funciona da mesma forma que 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 não se apropria de nenhum de seus parâmetros.

Indexando 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 Stringsintaxe de indexação em Rust, obterá um erro. Considere o código inválido na Listagem 8-19.

Esse código não compila Esse código não compila.

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

Listagem 8-19: Tentativa de usar sintaxe de indexação com uma string

Este código resultará no seguinte erro:

$ cargo run
   Compiling collections v0.1.0 (file:///projects/collections)
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`

error: aborting due to previous error

For more information about this error, try `rustc --explain E0277`.
error: could not compile `collections`

To learn more, run the command again with --verbose.

O erro e a nota contam a história: Strings Rust não suportam indexação. Mas porque 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 Vec<u8>. Vejamos algumas de nossas sequências de exemplo UTF-8 devidamente codificadas da Listagem 8-14. Primeiro, 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");
}

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 leva 1 byte quando codificada em UTF-8. Mas e quanto à seguinte linha? (Observe que esta string começa com a letra maiúscula cirílica Ze, não o número arábico 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");
}

Quando perguntado sobre o comprimento da string, você dirá 12. No entanto, 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 se correlacionará com um valor escalar Unicode válido. Para demonstrar, considere este código Rust inválido:

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

Qual deve ser o valor de answer? Deveria ser З, a primeira carta? Quando codificado em UTF-8, o primeiro byte de З é 208 e o segundo é 151, então answer deveria ser 208, mas 208 não é um caractere válido por si só. O retorno 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 Rust tem no índice de byte 0. Os usuários geralmente não querem que o valor do byte seja retornado, mesmo se a string contiver apenas letras latinas: se &"hello"[0] fosse um código válido que retornasse o valor do byte, ele retornaria 104, não h. Para evitar retornar um valor inesperado e causar bugs que podem não ser descobertos imediatamente, o Rust não compila esse código e evita mal-entendidos no início do processo de desenvolvimento.

Bytes e valores escalares e clusters de grafemas! Oh meu!

Outro ponto sobre o UTF-8 é que existem três maneiras relevantes de olhar para as strings da perspectiva de 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 na escrita Devanagari, ela é armazenada como um vetor de valores u8 semelhante a este:

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

São 18 bytes e é como os computadores armazenam esses dados. Se olharmos para eles como valores escalares Unicode, que são o tipo char de 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 si próprios. Finalmente, se olharmos para eles como grupos de grafemas, obteremos o que uma pessoa chamaria de quatro letras que compõem a palavra em hindi:

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

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

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

Fatias de corte

Indexar em uma string geralmente é uma má ideia porque não está claro qual deve ser o tipo de retorno da operação de indexação de string: um valor de byte, um caractere, um cluster de grafema ou uma fatia de string. Portanto, Rust pede que você seja mais específico se você realmente precisa usar índices para criar fatias de string. Para ser mais específico em sua indexação e indicar que deseja uma fatia de string, 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:


#![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. Anteriormente, mencionamos que cada um desses caracteres tinha 2 bytes, o que significa que s será Зд.

O que aconteceria se usássemos &hello[0..1]? A resposta: Rust entraria em pânico em tempo de execução da mesma forma que se um índice inválido fosse acessado em um vetor:

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

Você deve usar intervalos para criar fatias de string com cuidado, porque isso pode travar seu programa.

Métodos para iteração de strings

Felizmente, você pode acessar elementos em uma string de outras maneiras.

Se você precisar executar operações em valores escalares Unicode individuais, a melhor maneira de fazer isso é usar o charsmétodo. Chamar chars“नमस्ते” separa e retorna seis valores de tipo char, e você pode iterar sobre o resultado para acessar cada elemento:


#![allow(unused)]
fn main() {
for c in "नमस्ते".chars() {
    println!("{}", c);
}
}

Este código imprimirá o seguinte:

न
म
स
्
त
े

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


#![allow(unused)]
fn main() {
for b in "नमस्ते".bytes() {
    println!("{}", b);
}
}

Este código imprimirá os 18 bytes que compõem essa String:

224
164
// --recorte--
165
135

Mas lembre-se de que os valores escalares Unicode válidos podem ser compostos por mais de 1 byte.

Obter clusters de grafemas de strings é complexo, portanto, essa funcionalidade não é fornecida pela biblioteca padrão. Os crates estão disponíveis em crates.io se esta for a funcionalidade de que você precisa.

Strings não são tão simples

Para resumir, as strings são complicadas. Linguagens de programação diferentes fazem escolhas diferentes sobre como apresentar essa complexidade ao programador. O Rust optou por tornar o tratamento correto dos dados String o comportamento padrão para todos os programas Rust, o que significa que os programadores precisam pensar mais no manuseio de dados UTF-8 antecipadamente. Essa compensação expõe mais da complexidade das strings do que é aparente em outras linguagens de programação, mas evita que você tenha que lidar com erros envolvendo caracteres não ASCII posteriormente em seu ciclo de vida de desenvolvimento.

Vamos mudar para algo um pouco menos complexo: mapas hash!

Traduzido por Acervo Lima. O original pode ser acessado aqui.

Licença

0 comentários:

Postar um comentário