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.
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
u8que 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 umu8, 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_*, comowrapping_add.- Retornar o valor
Nonese houver overflow com os métodoschecked_*.- 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.