Kiểu Dữ Liệu

Mỗi giá trị trong Rust thuộc về một kiểu dữ liệu nhất định, điều này cho Rust biết loại dữ liệu đang được chỉ định để nó biết cách làm việc với dữ liệu đó. Chúng ta sẽ xem xét hai tập con kiểu dữ liệu: kiểu vô hướng (scalar) và kiểu phức hợp (compound).

Hãy nhớ rằng Rust là một ngôn ngữ kiểu tĩnh, nghĩa là nó phải biết kiểu của tất cả các biến tại thời điểm biên dịch. Trình biên dịch thường có thể suy ra kiểu mà chúng ta muốn sử dụng dựa trên giá trị và cách chúng ta sử dụng nó. Trong các trường hợp có nhiều kiểu có thể xảy ra, chẳng hạn như khi chúng ta chuyển đổi một String thành một kiểu số bằng cách sử dụng parse trong phần "So sánh số đoán với số bí mật" ở Chương 2, chúng ta phải thêm chú thích kiểu, như sau:

#![allow(unused)]
fn main() {
let guess: u32 = "42".parse().expect("Not a number!");
}

Nếu chúng ta không thêm chú thích kiểu : u32 như trong đoạn mã trên, Rust sẽ hiển thị lỗi sau, có nghĩa là trình biên dịch cần thêm thông tin từ chúng ta để biết kiểu nào chúng ta muốn sử dụng:

$ cargo build
   Compiling no_type_annotations v0.1.0 (file:///projects/no_type_annotations)
error[E0284]: type annotations needed
 --> src/main.rs:2:9
  |
2 |     let guess = "42".parse().expect("Not a number!");
  |         ^^^^^        ----- type must be known at this point
  |
  = note: cannot satisfy `<_ as FromStr>::Err == _`
help: consider giving `guess` an explicit type
  |
2 |     let guess: /* Type */ = "42".parse().expect("Not a number!");
  |              ++++++++++++

For more information about this error, try `rustc --explain E0284`.
error: could not compile `no_type_annotations` (bin "no_type_annotations") due to 1 previous error

Bạn sẽ thấy các chú thích kiểu khác nhau cho các kiểu dữ liệu khác.

Kiểu Vô Hướng

Một kiểu vô hướng biểu diễn một giá trị đơn lẻ. Rust có bốn kiểu vô hướng cơ bản: số nguyên, số thực dấu phẩy động, Boolean và ký tự. Có thể bạn đã quen thuộc với những kiểu này từ các ngôn ngữ lập trình khác. Chúng ta hãy xem cách chúng hoạt động trong Rust.

Kiểu Số Nguyên

Số nguyên là một số không có phần thập phân. Chúng ta đã sử dụng một kiểu số nguyên trong Chương 2, kiểu u32. Khai báo kiểu này cho biết rằng giá trị được liên kết với nó phải là một số nguyên không dấu (các kiểu số nguyên có dấu bắt đầu bằng i thay vì u) chiếm 32 bit không gian lưu trữ. Bảng 3-1 cho thấy các kiểu số nguyên tích hợp trong Rust. Chúng ta có thể sử dụng bất kỳ biến thể nào trong số này để khai báo kiểu của một giá trị số nguyên.

Bảng 3-1: Các Kiểu Số Nguyên trong Rust

Độ dàiCó dấuKhông dấu
8-biti8u8
16-biti16u16
32-biti32u32
64-biti64u64
128-biti128u128
phụ thuộc vào kiến trúcisizeusize

Mỗi biến thể có thể có dấu hoặc không dấu và có một kích thước rõ ràng. Có dấukhông dấu đề cập đến việc liệu số đó có thể âm hay không—nói cách khác, liệu số đó cần có dấu kèm theo (có dấu) hay chỉ bao giờ cũng dương và do đó có thể được biểu diễn mà không cần dấu (không dấu). Giống như viết số trên giấy: khi dấu quan trọng, số được hiển thị với dấu cộng hoặc dấu trừ; tuy nhiên, khi có thể giả định rằng số đó là dương, nó được hiển thị không có dấu. Số có dấu được lưu trữ bằng cách sử dụng biểu diễn bù hai.

Mỗi biến thể có dấu có thể lưu trữ số từ −(2n − 1) đến 2n − 1 − 1 bao gồm cả hai đầu, trong đó n là số bit mà biến thể đó sử dụng. Vì vậy, i8 có thể lưu trữ số từ −(27) đến 27 − 1, tương đương từ −128 đến 127. Các biến thể không dấu có thể lưu trữ số từ 0 đến 2n − 1, nên u8 có thể lưu trữ số từ 0 đến 28 − 1, tương đương từ 0 đến 255.

Ngoài ra, các kiểu isizeusize phụ thuộc vào kiến trúc của máy tính mà chương trình của bạn đang chạy: 64 bit nếu bạn đang sử dụng kiến trúc 64 bit và 32 bit nếu bạn đang sử dụng kiến trúc 32 bit.

Bạn có thể viết số nguyên theo bất kỳ dạng nào được hiển thị trong Bảng 3-2. Lưu ý rằng các số nguyên có thể thuộc nhiều kiểu số khác nhau cho phép hậu tố kiểu, chẳng hạn như 57u8, để chỉ định kiểu. Số nguyên cũng có thể sử dụng _ như một dấu phân cách trực quan để làm cho số dễ đọc hơn, chẳng hạn như 1_000, sẽ có giá trị giống như khi bạn chỉ định 1000.

Bảng 3-2: Các Số Nguyên trong Rust

Dạng sốVí dụ
Thập phân98_222
Thập lục phân0xff
Bát phân0o77
Nhị phân0b1111_0000
Byte (u8 chỉ)b'A'

Vậy làm thế nào để biết kiểu số nguyên nào để sử dụng? Nếu bạn không chắc chắn, mặc định của Rust thường là nơi tốt để bắt đầu: các kiểu số nguyên mặc định là i32. Trường hợp chính mà bạn sẽ sử dụng isize hoặc usize là khi đánh chỉ số cho một số loại bộ sưu tập.

Tràn số nguyên

Giả sử bạn có một biến kiểu u8 có thể lưu giá trị từ 0 đến 255. Nếu bạn cố gắng thay đổi biến thành một giá trị ngoài phạm vi đó, chẳng hạn như 256, tràn số nguyên sẽ xảy ra, có thể dẫn đến một trong hai hành vi. Khi bạn biên dịch ở chế độ debug, Rust bao gồm các kiểm tra tràn số nguyên khiến chương trình của bạn panic khi chạy nếu hành vi này xảy ra. Rust sử dụng thuật ngữ panicking khi một chương trình kết thúc với lỗi; chúng ta sẽ thảo luận sâu hơn về panic trong phần "Lỗi không thể khôi phục với panic!" trong Chương 9.

Khi bạn biên dịch ở chế độ phát hành với cờ --release, Rust không bao gồm các kiểm tra tràn số nguyên gây ra panic. Thay vào đó, nếu xảy ra tràn, Rust thực hiện bao quanh bù hai. Nói ngắn gọn, các giá trị lớn hơn giá trị tối đa mà kiểu có thể lưu trữ "bao quanh" về giá trị tối thiểu mà kiểu có thể lưu trữ. Trong trường hợp của u8, giá trị 256 trở thành 0, giá trị 257 trở thành 1, và cứ thế. Chương trình sẽ không panic, nhưng biến sẽ có giá trị có lẽ không phải là giá trị mà bạn mong đợi nó phải có. Việc dựa vào hành vi bao quanh của tràn số nguyên được coi là một lỗi.

Để xử lý rõ ràng khả năng tràn, bạn có thể sử dụng các họ phương thức sau được cung cấp bởi thư viện chuẩn cho các kiểu số nguyên nguyên thủy:

  • Bao quanh trong mọi chế độ với các phương thức wrapping_*, như wrapping_add.
  • Trả về giá trị None nếu có tràn với các phương thức checked_*.
  • Trả về giá trị và một Boolean cho biết liệu có tràn hay không với các phương thức overflowing_*.
  • Bão hòa ở giá trị tối thiểu hoặc tối đa của giá trị với các phương thức saturating_*.

Kiểu Số Thực Dấu Phẩy Động

Rust cũng có hai kiểu nguyên thủy cho số thực dấu phẩy động, là số có dấu thập phân. Kiểu số thực dấu phẩy động của Rust là f32f64, có kích thước lần lượt là 32 bit và 64 bit. Kiểu mặc định là f64 vì trên CPU hiện đại, nó có tốc độ gần tương đương với f32 nhưng có khả năng chính xác cao hơn. Tất cả các kiểu số thực dấu phẩy động đều có dấu.

Đây là một ví dụ thể hiện số thực dấu phẩy động trong hành động:

Tên tập tin: src/main.rs

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

    let y: f32 = 3.0; // f32
}

Số thực dấu phẩy động được biểu diễn theo tiêu chuẩn IEEE-754.

Phép Toán Số Học

Rust hỗ trợ các phép toán cơ bản mà bạn mong đợi cho tất cả các loại số: cộng, trừ, nhân, chia và lấy phần dư. Phép chia số nguyên cắt cụt về phía số không đến số nguyên gần nhất. Đoạn mã sau cho thấy cách bạn sử dụng mỗi phép toán trong một câu lệnh let:

Tên tập tin: src/main.rs

fn main() {
    // addition
    let sum = 5 + 10;

    // subtraction
    let difference = 95.5 - 4.3;

    // multiplication
    let product = 4 * 30;

    // division
    let quotient = 56.7 / 32.2;
    let truncated = -5 / 3; // Results in -1

    // remainder
    let remainder = 43 % 5;
}

Mỗi biểu thức trong các câu lệnh này sử dụng một toán tử toán học và đánh giá thành một giá trị duy nhất, sau đó được gắn với một biến. Phụ lục B chứa danh sách tất cả các toán tử mà Rust cung cấp.

Kiểu Boolean

Cũng giống như trong hầu hết các ngôn ngữ lập trình khác, kiểu Boolean trong Rust có hai giá trị có thể có: truefalse. Boolean có kích thước một byte. Kiểu Boolean trong Rust được chỉ định bằng bool. Ví dụ:

Tên tập tin: src/main.rs

fn main() {
    let t = true;

    let f: bool = false; // with explicit type annotation
}

Cách chính để sử dụng giá trị Boolean là thông qua các điều kiện, chẳng hạn như biểu thức if. Chúng ta sẽ tìm hiểu cách biểu thức if hoạt động trong Rust trong phần "Luồng Điều Khiển".

Kiểu Ký Tự

Kiểu char của Rust là kiểu bảng chữ cái nguyên thủy nhất của ngôn ngữ. Dưới đây là một số ví dụ về khai báo giá trị char:

Tên tập tin: src/main.rs

fn main() {
    let c = 'z';
    let z: char = 'ℤ'; // with explicit type annotation
    let heart_eyed_cat = '😻';
}

Lưu ý rằng chúng ta chỉ định các ký tự bằng dấu nháy đơn, khác với chuỗi, sử dụng dấu nháy kép. Kiểu char của Rust có kích thước bốn byte và đại diện cho một giá trị scalar của Unicode, nghĩa là nó có thể đại diện cho nhiều hơn chỉ là ASCII. Các chữ cái có dấu; ký tự tiếng Trung, tiếng Nhật và tiếng Hàn; biểu tượng cảm xúc; và khoảng trắng có độ rộng bằng không đều là giá trị char hợp lệ trong Rust. Giá trị scalar của Unicode nằm trong khoảng từ U+0000 đến U+D7FFU+E000 đến U+10FFFF bao gồm. Tuy nhiên, "ký tự" không thực sự là một khái niệm trong Unicode, vì vậy trực giác của con người về "ký tự" có thể không khớp với khái niệm char trong Rust. Chúng ta sẽ thảo luận chi tiết về chủ đề này trong "Lưu Trữ Văn Bản Mã Hóa UTF-8 với Chuỗi" ở Chương 8.

Kiểu Phức Hợp

Kiểu phức hợp có thể nhóm nhiều giá trị thành một kiểu. Rust có hai kiểu phức hợp nguyên thủy: bộ giá trị (tuple) và mảng.

Kiểu Bộ Giá Trị

Một bộ giá trị là một cách chung để nhóm một số giá trị có nhiều kiểu khác nhau vào một kiểu phức hợp. Bộ giá trị có độ dài cố định: một khi được khai báo, chúng không thể tăng hoặc giảm kích thước.

Chúng ta tạo một bộ giá trị bằng cách viết một danh sách các giá trị được phân tách bằng dấu phẩy bên trong dấu ngoặc đơn. Mỗi vị trí trong bộ giá trị có một kiểu, và các kiểu của các giá trị khác nhau trong bộ giá trị không nhất thiết phải giống nhau. Chúng tôi đã thêm chú thích kiểu tùy chọn trong ví dụ này:

Tên tập tin: src/main.rs

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

Biến tup gắn với toàn bộ bộ giá trị vì bộ giá trị được coi là một phần tử phức hợp đơn lẻ. Để lấy các giá trị riêng lẻ từ một bộ giá trị, chúng ta có thể sử dụng pattern matching để phá hủy cấu trúc một giá trị bộ giá trị, như sau:

Tên tập tin: src/main.rs

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

    let (x, y, z) = tup;

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

Chương trình này đầu tiên tạo ra một bộ giá trị và gắn nó với biến tup. Sau đó, nó sử dụng một pattern với let để lấy tup và biến nó thành ba biến riêng biệt, x, yz. Điều này được gọi là phá hủy cấu trúc vì nó chia một bộ giá trị thành ba phần. Cuối cùng, chương trình in giá trị của y, là 6.4.

Chúng ta cũng có thể truy cập trực tiếp một phần tử của bộ giá trị bằng cách sử dụng dấu chấm (.) theo sau là chỉ số của giá trị mà chúng ta muốn truy cập. Ví dụ:

Tên tập tin: 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;
}

Chương trình này tạo ra bộ giá trị x và sau đó truy cập vào mỗi phần tử của bộ giá trị bằng cách sử dụng các chỉ số tương ứng của chúng. Như với hầu hết các ngôn ngữ lập trình, chỉ số đầu tiên trong bộ giá trị là 0.

Bộ giá trị không có giá trị nào có một tên đặc biệt, unit. Giá trị này và kiểu tương ứng của nó đều được viết là () và đại diện cho một giá trị trống hoặc một kiểu trả về trống. Biểu thức ngầm định trả về giá trị unit nếu chúng không trả về bất kỳ giá trị nào khác.

Kiểu Mảng

Một cách khác để có một tập hợp gồm nhiều giá trị là với một mảng. Không giống như bộ giá trị, mọi phần tử của mảng phải có cùng kiểu. Không giống như mảng trong một số ngôn ngữ khác, mảng trong Rust có độ dài cố định.

Chúng ta viết các giá trị trong một mảng như một danh sách được phân tách bằng dấu phẩy bên trong dấu ngoặc vuông:

Tên tập tin: src/main.rs

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

Mảng hữu ích khi bạn muốn dữ liệu của mình được phân bổ trên stack, giống như các kiểu khác mà chúng ta đã thấy cho đến nay, thay vì trên heap (chúng ta sẽ thảo luận thêm về stack và heap trong Chương 4) hoặc khi bạn muốn đảm bảo rằng bạn luôn có một số lượng phần tử cố định. Mảng không linh hoạt như kiểu vector, tuy nhiên. Một vector là một kiểu tập hợp tương tự được cung cấp bởi thư viện chuẩn được phép tăng hoặc giảm kích thước vì nội dung của nó nằm trên heap. Nếu bạn không chắc liệu nên sử dụng một mảng hay một vector, khả năng là bạn nên sử dụng một vector. Chương 8 thảo luận chi tiết hơn về vector.

Tuy nhiên, mảng hữu ích hơn khi bạn biết số lượng phần tử sẽ không cần thay đổi. Ví dụ, nếu bạn đang sử dụng tên của các tháng trong một chương trình, bạn có lẽ sẽ sử dụng một mảng thay vì một vector vì bạn biết nó sẽ luôn chứa 12 phần tử:

#![allow(unused)]
fn main() {
let months = ["January", "February", "March", "April", "May", "June", "July",
              "August", "September", "October", "November", "December"];
}

Bạn viết kiểu của một mảng bằng cách sử dụng dấu ngoặc vuông với kiểu của mỗi phần tử, dấu chấm phẩy, và sau đó là số phần tử trong mảng, như sau:

#![allow(unused)]
fn main() {
let a: [i32; 5] = [1, 2, 3, 4, 5];
}

Ở đây, i32 là kiểu của mỗi phần tử. Sau dấu chấm phẩy, số 5 cho biết mảng chứa năm phần tử.

Bạn cũng có thể khởi tạo một mảng để chứa cùng một giá trị cho mỗi phần tử bằng cách chỉ định giá trị ban đầu, theo sau là dấu chấm phẩy, và sau đó là độ dài của mảng trong dấu ngoặc vuông, như được hiển thị ở đây:

#![allow(unused)]
fn main() {
let a = [3; 5];
}

Mảng có tên a sẽ chứa 5 phần tử mà tất cả sẽ được thiết lập ban đầu với giá trị 3. Điều này tương đương với việc viết let a = [3, 3, 3, 3, 3]; nhưng theo cách ngắn gọn hơn.

Truy Cập Phần Tử Mảng

Một mảng là một khối bộ nhớ đơn với kích thước đã biết, cố định có thể được cấp phát trên stack. Bạn có thể truy cập các phần tử của mảng bằng cách sử dụng chỉ số, như sau:

Tên tập tin: src/main.rs

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

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

Trong ví dụ này, biến có tên first sẽ nhận giá trị 1 vì đó là giá trị tại chỉ số [0] trong mảng. Biến có tên second sẽ nhận giá trị 2 từ chỉ số [1] trong mảng.

Truy Cập Phần Tử Mảng Không Hợp Lệ

Hãy xem điều gì xảy ra nếu bạn cố gắng truy cập một phần tử của mảng ở ngoài cuối mảng. Giả sử bạn chạy mã này, tương tự như trò chơi đoán số ở Chương 2, để nhận chỉ số mảng từ người dùng:

Tên tập tin: src/main.rs

use std::io;

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

    println!("Please enter an array index.");

    let mut index = String::new();

    io::stdin()
        .read_line(&mut index)
        .expect("Failed to read line");

    let index: usize = index
        .trim()
        .parse()
        .expect("Index entered was not a number");

    let element = a[index];

    println!("The value of the element at index {index} is: {element}");
}

Mã này biên dịch thành công. Nếu bạn chạy mã này bằng cargo run và nhập 0, 1, 2, 3, hoặc 4, chương trình sẽ in ra giá trị tương ứng tại chỉ số đó trong mảng. Nếu thay vào đó bạn nhập một số ngoài cuối mảng, chẳng hạn như 10, bạn sẽ thấy đầu ra như sau:

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

Chương trình dẫn đến lỗi thời gian chạy ở điểm sử dụng một giá trị không hợp lệ trong phép toán đánh chỉ số. Chương trình kết thúc với một thông báo lỗi và không thực thi câu lệnh println! cuối cùng. Khi bạn cố gắng truy cập một phần tử bằng cách sử dụng chỉ số, Rust sẽ kiểm tra xem chỉ số mà bạn đã chỉ định có nhỏ hơn độ dài mảng hay không. Nếu chỉ số lớn hơn hoặc bằng độ dài, Rust sẽ panic. Kiểm tra này phải được thực hiện ở thời gian chạy, đặc biệt là trong trường hợp này, vì trình biên dịch không thể nào biết được người dùng sẽ nhập giá trị nào khi họ chạy mã sau này.

Đây là một ví dụ về nguyên tắc an toàn bộ nhớ của Rust trong hành động. Trong nhiều ngôn ngữ bậc thấp, loại kiểm tra này không được thực hiện, và khi bạn cung cấp một chỉ số không chính xác, bộ nhớ không hợp lệ có thể bị truy cập. Rust bảo vệ bạn khỏi loại lỗi này bằng cách thoát ngay lập tức thay vì cho phép truy cập bộ nhớ và tiếp tục. Chương 9 thảo luận thêm về xử lý lỗi của Rust và cách bạn có thể viết mã an toàn, dễ đọc mà không panic cũng không cho phép truy cập bộ nhớ không hợp lệ.