Exploração de Tipos de Dados em Rust

Beginner

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

Introdução

Bem-vindo a Tipos de Dados. Este laboratório faz parte do Livro do Rust. Você pode praticar suas habilidades em Rust no LabEx.

Neste laboratório, exploraremos o conceito de tipos de dados em Rust, onde cada valor recebe um tipo específico para determinar como ele é tratado e, em casos onde múltiplos tipos são possíveis, anotações de tipo devem ser adicionadas para fornecer as informações necessárias ao compilador.

Este é um Lab Guiado, que fornece instruções passo a passo para ajudá-lo a aprender e praticar. Siga as instruções cuidadosamente para completar cada etapa e ganhar experiência prática. Dados históricos mostram que este é um laboratório de nível iniciante com uma taxa de conclusão de 83%. Recebeu uma taxa de avaliações positivas de 100% dos estudantes.

Tipos de Dados

Cada valor em Rust é de um determinado tipo de dado (data type), que informa ao Rust que tipo de dado está sendo especificado, para que ele saiba como trabalhar com esses dados. Veremos dois subconjuntos de tipos de dados: escalares e compostos.

Tenha em mente que Rust é uma linguagem de tipagem estática (statically typed), o que significa que ela deve conhecer os tipos de todas as variáveis em tempo de compilação. O compilador geralmente pode inferir qual tipo queremos usar com base no valor e em como o usamos. Em casos onde muitos tipos são possíveis, como quando convertemos uma String para um tipo numérico usando parse em "Comparando o Palpite com o Número Secreto", devemos adicionar uma anotação de tipo, assim:

let guess: u32 = "42".parse().expect("Not a number!");

Se não adicionarmos a anotação de tipo : u32 mostrada no código anterior, Rust exibirá o seguinte erro, o que significa que o compilador precisa de mais informações de nós para saber qual tipo queremos usar:

$ cargo build
   Compiling no_type_annotations v0.1.0 (file:///projects/no_type_annotations)
error[E0282]: type annotations needed
 --> src/main.rs:2:9
  |
2 |     let guess = "42".parse().expect("Not a number!");
  |         ^^^^^ consider giving `guess` a type

Você verá diferentes anotações de tipo para outros tipos de dados.

Tipos Escalares

Um tipo escalar (scalar) representa um único valor. Rust possui quatro tipos escalares primários: inteiros, números de ponto flutuante, booleanos e caracteres. Você pode reconhecê-los de outras linguagens de programação. Vamos mergulhar em como eles funcionam em Rust.

Tipos Inteiros

Um inteiro é um número sem uma componente fracionária. Usamos um tipo inteiro no Capítulo 2, o tipo u32. Esta declaração de tipo indica que o valor associado a ele deve ser um inteiro sem sinal (tipos de inteiro com sinal começam com i em vez de u) que ocupa 32 bits de espaço. A Tabela 3-1 mostra os tipos inteiros embutidos em Rust. Podemos usar qualquer uma dessas variantes para declarar o tipo de um valor inteiro.

Tabela 3-1: Tipos Inteiros em Rust

Comprimento Com Sinal Sem Sinal
8-bit i8 u8
16-bit i16 u16
32-bit i32 u32
64-bit i64 u64
128-bit i128 u128
arch isize usize

Cada variante pode ser com ou sem sinal e tem um tamanho explícito. Com sinal e sem sinal referem-se a se é possível que o número seja negativo - em outras palavras, se o número precisa ter um sinal com ele (com sinal) ou se ele será sempre positivo e, portanto, pode ser representado sem um sinal (sem sinal). É como escrever números no papel: quando o sinal importa, um número é mostrado com um sinal de mais ou um sinal de menos; no entanto, quando é seguro assumir que o número é positivo, ele é mostrado sem sinal. Números com sinal são armazenados usando a representação de complemento de dois.

Cada variante com sinal pode armazenar números de -(2^(n-1)) a 2^(n-1) - 1 inclusive, onde n é o número de bits que essa variante usa. Então, um i8 pode armazenar números de -(2^7) a 2^7 - 1, que é igual a -128 a 127. Variantes sem sinal podem armazenar números de 0 a 2^n - 1, então um u8 pode armazenar números de 0 a 2^8 - 1, que é igual a 0 a 255.

Além disso, os tipos isize e usize dependem da arquitetura do computador em que seu programa está sendo executado, o que é denotado na tabela como "arch": 64 bits se você estiver em uma arquitetura de 64 bits e 32 bits se você estiver em uma arquitetura de 32 bits.

Você pode escrever literais inteiros em qualquer uma das formas mostradas na Tabela 3-2. Observe que literais numéricos que podem ser de vários tipos numéricos permitem um sufixo de tipo, como 57u8, para designar o tipo. Literais numéricos também podem usar _ como um separador visual para tornar o número mais fácil de ler, como 1_000, que terá o mesmo valor que se você tivesse especificado 1000.

Tabela 3-2: Literais Inteiros em Rust

Literais numéricos Exemplo
Decimal 98_222
Hexadecimal 0xff
Octal 0o77
Binário 0b1111_0000
Byte (apenas u8) b'A'

Então, como você sabe qual tipo de inteiro usar? Se você não tiver certeza, os padrões do Rust são geralmente bons lugares para começar: os tipos inteiros são, por padrão, i32. A principal situação em que você usaria isize ou usize é ao indexar algum tipo de coleção.

Overflow de Inteiro

Digamos que você tenha uma variável do tipo u8 que pode conter valores entre 0 e 255. Se você tentar alterar a variável para um valor fora dessa faixa, como 256, ocorrerá overflow de inteiro, o que pode resultar em um de dois comportamentos. Quando você está compilando no modo de depuração, o Rust inclui verificações de overflow de inteiro que fazem com que seu programa entre em pânico em tempo de execução se esse comportamento ocorrer. Rust usa o termo panicking quando um programa sai com um erro; discutiremos pânicos com mais profundidade em "Erros Irrecuperáveis com panic!".

Quando você está compilando no modo de lançamento com a flag --release, o Rust não inclui verificações de overflow de inteiro que causam pânicos. Em vez disso, se ocorrer overflow, o Rust executa two's complement wrapping. Em resumo, valores maiores que o valor máximo que o tipo pode conter "envolvem" para o mínimo dos valores que o tipo pode conter. No caso de um u8, o valor 256 se torna 0, o valor 257 se torna 1 e assim por diante. O programa não entrará em pânico, mas a variável terá um valor que provavelmente não é o que você esperava que tivesse. Confiar no comportamento de wrapping do overflow de inteiro é considerado um erro.

Para lidar explicitamente com a possibilidade de overflow, você pode usar estas famílias de métodos fornecidos pela biblioteca padrão para tipos numéricos primitivos:

  • Wrap em todos os modos com os métodos wrapping_*, como wrapping_add.
  • Retornar o valor None se houver overflow com os métodos checked_*.
  • Retornar o valor e um booleano indicando se houve overflow com os métodos overflowing_*.
  • Saturar nos valores mínimo ou máximo do valor com os métodos saturating_*.

Tipos de Ponto Flutuante

Rust também possui dois tipos primitivos para números de ponto flutuante (floating-point numbers), que são números com casas decimais. Os tipos de ponto flutuante do Rust são f32 e f64, que têm 32 bits e 64 bits de tamanho, respectivamente. O tipo padrão é f64 porque em CPUs modernas, é aproximadamente a mesma velocidade que f32, mas é capaz de mais precisão. Todos os tipos de ponto flutuante são com sinal.

Crie um novo projeto chamado data-types:

cargo new data-types
cd data-types

Aqui está um exemplo que mostra números de ponto flutuante em ação:

Nome do arquivo: src/main.rs

fn main() {
    let x = 2.0; // f64

    let y: f32 = 3.0; // f32
}

Números de ponto flutuante são representados de acordo com o padrão IEEE-754. O tipo f32 é um float de precisão simples, e f64 tem precisão dupla.

Operações Numéricas

Rust suporta as operações matemáticas básicas que você esperaria para todos os tipos de números: adição, subtração, multiplicação, divisão e resto. A divisão de inteiros trunca em direção a zero para o inteiro mais próximo. O código a seguir mostra como você usaria cada operação numérica em uma declaração let:

Nome do arquivo: src/main.rs

fn main() {
    // adição
    let sum = 5 + 10;

    // subtração
    let difference = 95.5 - 4.3;

    // multiplicação
    let product = 4 * 30;

    // divisão
    let quotient = 56.7 / 32.2;
    let truncated = -5 / 3; // Resulta em -1

    // resto
    let remainder = 43 % 5;
}

Cada expressão nestas declarações usa um operador matemático e é avaliada como um único valor, que é então vinculado a uma variável. O Apêndice B contém uma lista de todos os operadores que o Rust fornece.

O Tipo Booleano

Como na maioria das outras linguagens de programação, um tipo booleano em Rust tem dois valores possíveis: true e false. Booleanos têm um byte de tamanho. O tipo booleano em Rust é especificado usando bool. Por exemplo:

Nome do arquivo: src/main.rs

fn main() {
    let t = true;

    let f: bool = false; // com anotação de tipo explícita
}

A principal forma de usar valores booleanos é através de condicionais, como uma expressão if. Abordaremos como as expressões if funcionam em Rust em "Fluxo de Controle".

O Tipo Caractere

O tipo char do Rust é o tipo alfabético mais primitivo da linguagem. Aqui estão alguns exemplos de declaração de valores char:

Nome do arquivo: src/main.rs

fn main() {
    let c = 'z';
    let z: char = 'ℤ'; // com anotação de tipo explícita
    let heart_eyed_cat = '😻';
}

Observe que especificamos literais char com aspas simples, em oposição aos literais de string, que usam aspas duplas. O tipo char do Rust tem quatro bytes de tamanho e representa um Valor Escalar Unicode (Unicode Scalar Value), o que significa que pode representar muito mais do que apenas ASCII. Letras acentuadas; caracteres chineses, japoneses e coreanos; emoji; e espaços de largura zero são todos valores char válidos em Rust. Os Valores Escalares Unicode variam de U+0000 a U+D7FF e U+E000 a U+10FFFF inclusive. No entanto, um "caractere" não é realmente um conceito no Unicode, então sua intuição humana sobre o que é um "caractere" pode não corresponder ao que um char é em Rust. Discutiremos este tópico em detalhes em "Armazenando Texto Codificado em UTF-8 com Strings".

Tipos Compostos

Tipos compostos podem agrupar múltiplos valores em um tipo. Rust tem dois tipos compostos primitivos: tuplas e arrays.

O Tipo Tupla

Uma tupla é uma forma geral de agrupar um número de valores com uma variedade de tipos em um tipo composto. Tuplas têm um comprimento fixo: uma vez declaradas, elas não podem crescer ou diminuir de tamanho.

Criamos uma tupla escrevendo uma lista de valores separados por vírgulas dentro de parênteses. Cada posição na tupla tem um tipo, e os tipos dos diferentes valores na tupla não precisam ser os mesmos. Adicionamos anotações de tipo opcionais neste exemplo:

Nome do arquivo: src/main.rs

fn main() {
    let tup: (i32, f64, u8) = (500, 6.4, 1);
}

A variável tup se vincula à tupla inteira porque uma tupla é considerada um único elemento composto. Para obter os valores individuais de uma tupla, podemos usar correspondência de padrões (pattern matching) para desestruturar um valor de tupla, assim:

Nome do arquivo: src/main.rs

fn main() {
    let tup = (500, 6.4, 1);

    let (x, y, z) = tup;

    println!("The value of y is: {y}");
}

Este programa primeiro cria uma tupla e a vincula à variável tup. Em seguida, usa um padrão com let para pegar tup e transformá-la em três variáveis separadas, x, y e z. Isso é chamado de desestruturação (destructuring) porque quebra a tupla única em três partes. Finalmente, o programa imprime o valor de y, que é 6.4.

Também podemos acessar um elemento da tupla diretamente usando um ponto (.) seguido pelo índice do valor que queremos acessar. Por exemplo:

Nome do arquivo: src/main.rs

fn main() {
    let x: (i32, f64, u8) = (500, 6.4, 1);

    let five_hundred = x.0;

    let six_point_four = x.1;

    let one = x.2;
}

Este programa cria a tupla x e, em seguida, acessa cada elemento da tupla usando seus respectivos índices. Como na maioria das linguagens de programação, o primeiro índice em uma tupla é 0.

A tupla sem nenhum valor tem um nome especial, unit (unidade). Este valor e seu tipo correspondente são ambos escritos () e representam um valor vazio ou um tipo de retorno vazio. Expressões implicitamente retornam o valor unitário se não retornarem nenhum outro valor.

O Tipo Array

Outra forma de ter uma coleção de múltiplos valores é com um array (vetor). Diferente de uma tupla, cada elemento de um array deve ter o mesmo tipo. Diferente de arrays em algumas outras linguagens, arrays em Rust têm um comprimento fixo.

Escrevemos os valores em um array como uma lista separada por vírgulas dentro de colchetes:

Nome do arquivo: src/main.rs

fn main() {
    let a = [1, 2, 3, 4, 5];
}

Arrays são úteis quando você quer que seus dados sejam alocados na pilha (stack) em vez do heap (vamos discutir a pilha e o heap mais no Capítulo 4) ou quando você quer garantir que sempre terá um número fixo de elementos. Um array não é tão flexível quanto o tipo vetor, no entanto. Um vetor (vector) é um tipo de coleção semelhante fornecido pela biblioteca padrão que pode crescer ou diminuir de tamanho. Se você não tiver certeza se deve usar um array ou um vetor, é provável que deva usar um vetor. O Capítulo 8 discute vetores com mais detalhes.

No entanto, arrays são mais úteis quando você sabe que o número de elementos não precisará mudar. Por exemplo, se você estivesse usando os nomes dos meses em um programa, provavelmente usaria um array em vez de um vetor porque sabe que ele sempre conterá 12 elementos:

let months = ["January", "February", "March", "April", "May", "June", "July",
              "August", "September", "October", "November", "December"];

Você escreve o tipo de um array usando colchetes com o tipo de cada elemento, um ponto e vírgula e, em seguida, o número de elementos no array, assim:

let a: [i32; 5] = [1, 2, 3, 4, 5];

Aqui, i32 é o tipo de cada elemento. Após o ponto e vírgula, o número 5 indica que o array contém cinco elementos.

Você também pode inicializar um array para conter o mesmo valor para cada elemento, especificando o valor inicial, seguido por um ponto e vírgula e, em seguida, o comprimento do array em colchetes, como mostrado aqui:

let a = [3; 5];

O array chamado a conterá 5 elementos que serão todos definidos para o valor 3 inicialmente. Isso é o mesmo que escrever let a = [3, 3, 3, 3, 3]; mas de uma forma mais concisa.

Acessando Elementos de Array

Um array é um único bloco de memória de um tamanho conhecido e fixo que pode ser alocado na pilha (stack). Você pode acessar elementos de um array usando indexação, assim:

Nome do arquivo: src/main.rs

fn main() {
    let a = [1, 2, 3, 4, 5];

    let first = a[0];
    let second = a[1];
}

Neste exemplo, a variável chamada first receberá o valor 1 porque esse é o valor no índice [0] no array. A variável chamada second receberá o valor 2 do índice [1] no array.

Acesso Inválido a Elemento de Array

Vamos ver o que acontece se você tentar acessar um elemento de um array que está além do final do array. Digamos que você execute este código, semelhante ao jogo de adivinhação no Capítulo 2, para obter um índice de array do usuário:

Nome do arquivo: src/main.rs

use std::io;

fn main() {
    let a = [1, 2, 3, 4, 5];

    println!("Por favor, insira um índice de array.");

    let mut index = String::new();

    io::stdin()
        .read_line(&mut index)
        .expect("Falha ao ler a linha");

    let index: usize = index
        .trim()
        .parse()
        .expect("O índice inserido não era um número");

    let element = a[index];

    println!(
        "O valor do elemento no índice {index} é: {element}"
    );
}

Este código compila com sucesso. Se você executar este código usando cargo run e inserir 0, 1, 2, 3 ou 4, o programa imprimirá o valor correspondente naquele índice no array. Se, em vez disso, você inserir um número além do final do array, como 10, verá uma saída como esta:

thread 'main' panicked at 'index out of bounds: the len is 5 but the index is
10', src/main.rs:19:19
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

O programa resultou em um erro de tempo de execução (runtime) no ponto de uso de um valor inválido na operação de indexação. O programa saiu com uma mensagem de erro e não executou a instrução println! final. Quando você tenta acessar um elemento usando indexação, o Rust verificará se o índice que você especificou é menor que o comprimento do array. Se o índice for maior ou igual ao comprimento, o Rust entrará em pânico. Essa verificação precisa acontecer em tempo de execução, especialmente neste caso, porque o compilador não pode saber qual valor um usuário inserirá quando executar o código mais tarde.

Este é um exemplo dos princípios de segurança de memória do Rust em ação. Em muitas linguagens de baixo nível, esse kind de verificação não é feito, e quando você fornece um índice incorreto, memória inválida pode ser acessada. O Rust protege você contra esse tipo de erro saindo imediatamente em vez de permitir o acesso à memória e continuar. O Capítulo 9 discute mais sobre o tratamento de erros do Rust e como você pode escrever código legível e seguro que não entre em pânico nem permita acesso inválido à memória.

Resumo

Parabéns! Você concluiu o laboratório de Tipos de Dados. Você pode praticar mais laboratórios no LabEx para aprimorar suas habilidades.