Kiểu dữ liệu

Mọi giá trị trong Rust đều có một kiểu dữ liệu xác định, dựa vào kiểu dữ liệu Rust sẽ biết phải làm việc với dữ liệu đó như thế nào. Chúng ta sẽ xem xét hai tập con của kiểu dữ liệu: vô hướng và kết hợp.

Hãy nhớ rằng Rust là ngôn ngữ định kiểu tĩnh (statically typed), tức là Rust phải biết được kiểu dữ liệ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 thường có thể suy luận kiểu dữ liệu mà chúng ta đang dùng dựa trên giá trị và cách chúng ta sử dụng giá trị đó. Trong các trường hợp có nhiều kiểu, chẳng hạn như khi chúng ta chuyển đổi String sang kiểu số bằng cách sử dụng parse trong phần “So sánh số dự đoán với số bí mật” trong Chương 2, chúng ta phải chú thích rõ kiểu dữ liệ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 dữ liệu vào, Rust sẽ hiển thị lỗi như bên dưới, trình biên dịch cần thêm thông tin để biết chúng ta đang sử dụng kiểu dữ liệu nào:

$ 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

For more information about this error, try `rustc --explain E0282`.
error: could not compile `no_type_annotations` due to previous error

Các loại dữ liệu khác nhau sẽ chú thích khác nhau.

Kiểu dữ liệu vô hướng

Một kiểu dữ liệu vô hướng (scalar) đại diện cho một giá trị duy nhất. Rust có 4 kiểu vô hướng chính: số nguyên (integers), số thực dấu phẩy động (floating-point numbers), Booleans và ký tự (characters). Bạn có thể thấy chúng quen thuộc ở các ngôn ngữ lập trình khác. Hãy cùng tìm hiểu cách chúng hoạt động trong Rust.

Kiểu số nguyên (interger)

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

Bảng 3-1: Các kiểu số nguyên trong Rust

Độ dàiSignedUnsigned
8-biti8u8
16-biti16u16
32-biti32u32
64-biti64u64
128-biti128u128
archisizeusize

Mỗi variant có thể có dấu hoặc không dấu và có kích thước rõ ràng. Signedunsigned chỉ ra rằng số đó liệu có thể có giá trị âm hay không, một số có dấu (signed) có thể chứa giá trị âm và dương, trong khi một số không có dấu (unsigned) sẽ chỉ chứa giá trị dương. Giống như việc viết số trên giấy: khi dấu là quan trọng, một số sẽ được ghi kèm với dấu cộng hoặc dấu trừ; tuy nhiên, thông thường khi viết số dương thường không có dấu. Các số có dấu được lưu trữ sử dụng two’s complement representation.

Mỗi signed variant có dấu có thể lưu trữ các số từ -(2n - 1) đến 2n - 1 - 1, trong đó n là số bits mà variant sử dụng. Do đó, i8 có thể chứa các số từ -(27) đến 27 - 1, tương ứng từ -128 đến 127. Unsigned variants có thể chứa các số từ 0 đến 2n - 1, do đó u8 chứa các số từ 0 đến 28 - 1, tương ứng từ 0 đến 255.

Ngoài ra, kiểu isize and usize phụ thuộc vào cấu hình máy tính mà chương trình bạn đang chạy, các kiểu này được ký hiệu trong bảng là “arch”: 64 bits nếu bạn đang dùng máy tính cấu hình 64-bit và 32 bits nếu bạn đang dùng cấu hình 32-bit.

Bạn có thể viết số nguyên ở bất kỳ dạng nào như trong Bảng 3-2. Lưu ý rằng các ký tự số (number literals) có thể là nhiều kiểu số cho phép một hậu tố để chỉ định kiểu dữ liệu, chẳng hạn như 57u8. Number literals cũng có thể sử dụng _ để phân tách số cho dễ đọc hơn, ví dụ 1_000 sẽ có cùng giá trị như khi bạn khai báo 1000.

Bảng 3-2: Integer Literals trong Rust

Number literalsExample
Decimal98_222
Hex0xff
Octal0o77
Binary0b1111_0000
Byte (u8 only)b'A'

Vì vậy làm cách nào bạn biết nên sử dụng loại số nguyên nào? Nếu bạn không chắc chắn về điều đó, kiểu dữ liệu mặc định của Rust thường hữu ích: ví dụ, kiểu dữ liệu mặc định của số nguyên trong Rust là i32. Còn isizeusize bạn sẽ sử dụng khi cần lập chỉ mục một số loại collection.

Tràn số nguyên (Integer Overflow)

Giả sử bạn có một biến có kiểu dữ liệu u8 có thể lưu giá trị từ 0 đến 255. Nếu bạn cố gắng thay đổi giá trị của biến vượt ra khỏi phạm vi trên, chẳng hạn như 256, integer overflow 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 sẽ bao gồm các kiểm tra về integer overflow có phải là nguyên nhân khiến chương trình của bạn panic ở thời gian chạy nếu hành vi này xảy ra. Rust sử dụng thuật ngữ panicking khi thoát một chương trình bị lỗi, chúng ta sẽ thảo luận panics sâu hơn trong phần “Các lỗi không thể phục hồi với panic! trong Chương 9.

Khi bạn biên dịch trong chế độ release với cờ --release, Rust sẽ không bao gồm các kiểm tra về integer overflow. Thay vào đó, nếu overflow xảy ra, Rust thực hiện two’s complement wrapping. Tóm lại, các giá trị lớn hơn giá trị mà kiểu dữ liệu có thể chứa sẽ “wrap around” vào giá trị nhỏ nhất mà kiểu dữ liệu có thể lưu giữ. Trong trường hợp u8, giá trị 256 trở thành 0, giá trị 257 trở thành 1, v..v.. Chương trình sẽ không panic, nhưng biến sẽ có một giá trị mà bạn không mong đợi. Đây được coi là một lỗi.

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

  • Wrap trong tất cả các chế độ bằng các phương thức wrapping_*, như wrapping_add
  • Trả về giá trị None nếu overflow xảy ra bằng phương thức checked_*
  • Trả về giá trị và một boolean cho biết liệu overflow có xảy ra hay không bằng phương thức overflowing_*
  • Saturate ở các giá trị tối thiểu hoặc tối đa bằng phương thức saturating_*

Kiểu dấu phẩy động

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

Dưới đây là một ví dụ về sử dụng số thực dấu phẩy động:

Filename: 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 standard. Kiểu float f32 có độ chính xác đơn và f64 có độ chính xác gấp đôi.

Các phép toán số học

Rust hỗ trợ tất cả các phép toán cơ bản cho tất cả kiểu số: cộng, trừ, nhân, chia và phần dư. Phép chia số nguyên được làm tròn đến số nguyên gần nhất. Code bên dưới chỉ bạn cách sử dụng mỗi phép toán trong câu lệnh let:

Filename: 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 floored = 2 / 3; // Results in 0

    // remainder
    let remainder = 43 % 5;
}

Mỗi biểu thức trong các câu lệnh sử dụng một toán tử toán học và cho ra kết quả là một giá trị đơn được gán vào một biến. Phụ lục B chứa danh sách bao gồm tất cả các toán tử mà Rust cung cấp.

Kiểu Boolean

Như trong hầu hết các ngôn ngữ lập trình khác, kiểu Boolean trong Rust chứa hai giá trị: truefalse. Booleans có kích thước 1 byte. Sử dụng bool để chỉ ra kiểu Boolean trong Rust. Ví dụ:

Filename: src/main.rs

fn main() {
    let t = true;

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

Giá trị Boolean được sử dụng chủ yếu trong các câu điều kiện, chẳng hạn như biểu thức if. Chúng ta sẽ đề cập về biểu thức if được sử dụng như thế nào trong Rust trong phần “Control Flow”.

Kiểu ký tự

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

Filename: src/main.rs

fn main() {
    let c = 'z';
    let z = 'ℤ';
    let heart_eyed_cat = '😻';
}

Lưu ý rằng chúng ta chỉ định ký tự char bằng dấu nháy đơn, trong khi ký tự chuỗi sử dụng dấu nháy kép. Kiểu char trong Rust có kích thước 4 bytes và đại diện cho một Giá trị Vô hướng Unicode (Unicode Scalar Value), tức là nó có thể đại diện cho nhiều thứ hơn ASCII. Tấy cả các chữ cái có dấu; chữ Trung Quốc, Nhật Bản và Hàn Quốc; emoji và zero-width spaces đều là giá trị char hợp lệ trong Rust. Unicode Scalar Values nằm trong khoảng từ U+0000 đến U+D7FFU+E000 đến U+10FFFF. Tuy nhiên, một “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ự” là gì có thể không trùng khớp với char trong Rust. Chúng ta sẽ thảo luận chi tiết về chủ đề này trong “Storing UTF-8 Encoded Text with Strings” ở Chương 8.

Kiểu kết hợp (Compound Types)

Compound types có thể nhóm nhiều giá trị vào một kiểu. Rust có hai kiểu kết hợp nguyên thủy là: tuples và arrays.

Kiểu Tuple

Tuple là cách thông thường nhóm một số giá trị tương ứng với những kiểu dữ liệu khác nhau lại. Tuples có độ dài cố định: một khi được khai báo, chúng ta không thể tăng hoặc giảm kích thước của chúng.

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

Filename: src/main.rs

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

Toàn bộ tuple được gán cho biến tup , vì một tuple được coi là một phần tử kết hợp đơn lẻ. Để lấy các giá trị riêng lẻ ra khỏi tuple, chúng ta có thể sử dụng pattern matching để destructure giá trị như sau:

Filename: 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 ban đầu tạo ra một a tuple và gán nó vào biến tup. Sau đó sử dụng pattern với let để biến tup trở thành ba biến riêng biệt, x, yz. Cách làm này được gọi là destructuring, bởi vì nó chia một tuple đơn lẻ thành ba phần riêng biệt. Cuối cùng, chương trình in ra giá trị của y6.4.

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

Filename: 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 tạo ra một tuple x, sau đó tạo các biến mới cho từng phần tử bằng cách sử dụng chỉ số ứng với chúng. Như với hầu hết các ngôn ngữ lập trình, vị trí đầu tiên trong tuple là 0.

Tuple không chứa bất kì giá trị nào là một kiểu đặc biệt chỉ chứa một giá trị, (). Kiểu này được gọi là unit type và giá trị đó được gọi là unit value. Các biểu thức sẽ trả về unit value nếu chúng không trả về bất kì giá trị nào khác.

Kiểu Mảng (Array)

Một cách khác để có một collection với nhiều giá trị là sử dụng array. Không giống như tuple, mọi phần tử của array phải có kiểu dữ liệu giống nhau. Không giống như array trong các ngôn ngữ khác, các array trong Rust có độ dài cố định.

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

Filename: src/main.rs

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

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

Tuy nhiên, array sẽ hữu ích hơn khi bạn đã biết được số lượng phần tử mà bạn cần. Ví dụ, nếu bạn sử dụng tên của các tháng trong chương trình, bạn nên sử dụng array hơn là vector bởi vì bạn biết chắc chắn rằng nó sẽ luôn 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 array bằng cách sử dụng dấu ngoặc vuông với bên trong là kiểu của phần tử, dấu chấm phẩy và sau đó là số lượng phần tử trong array, như sau:

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

Ở đây i32 là kiểu dữ liệu của tất cả phần tử. Số 5nằm sau dấu chấm phẩy cho biết rằng array chứ 5 phần tử.

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

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

Array a sẽ chứa 5 phần tử có cùng giá trị ban đầu là 3. Tương tự như khi chúng ta viết let a = [3, 3, 3, 3, 3]; nhưng ngắn gọn hơn.

Truy cập các phần tử của array

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

Filename: 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 first sẽ nhận giá trị 1, bởi vì đó là giá trị tại chỉ mục [0] trong array. Biến second sẽ nhận giá trị 2 từ chỉ mục [1] trong array.

Truy cập phần tử array không hợp lệ

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

Filename: 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 {} is: {}",
        index, element
    );
}

Code này sẽ biên dịch thành công. Nếu bạn chạy 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ỉ mục trong array đó. Thay vào đó nếu bạn nhập một số vượt quá phần cuối của array, ví dụ như 10, bạn sẽ thấy đầu ra như sau:

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

Chương trình sẽ dẫn đến lỗi runtime tại điểm mà bạn sử dụng giá trị không hợp lệ trong thao tác lập chỉ mục. Chương trình thoát ra với 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ử sử dụng lập chỉ mục, Rust sẽ kiểm tra xem chỉ mục mà bạn chỉ định có nhỏ đơn độ dài của array hay không. Nếu chỉ mục lớn hơn hoặc bằng chiều dài của array, Rust sẽ panic. Kiểm tra này diễn ra tại thời điểm runtime, nhất là trong trường hợp này, bởi vì trình biên dịch không thể biết người dùng sau đó sẽ nhập giá trị nào khi họ chạy code.

Đây là một ví dụ về nguyên tắc an toàn bộ nhớ của Rust đang hoạt động. Trong nhiều ngôn ngữ cấp 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ỉ mục không chính xác, bộ nhớ không hợp lệ có thể được truy cập. Rust bảo vệ bạn tránh khỏi lỗi này bằng cách thoát chương trình ngay lập tức, thay vì cho phép truy cập bộ nhớ và tiếp tục chạy. Chương 9 sẽ thảo luận nhiều hơn về cách xử lý lỗi của Rust.