[Rust Book] 3.2. Kiểu dữ liệu

Kiểu dữ liệu

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

Hãy nhớ rằng Rust là một ngôn ngữ có kiểu tĩnh (statically typed), có nghĩa là nó phải biết kiểu của tất cả các biến lúc biên dịch. Trình biên dịch có thể suy luận ra kiểu chúng ta muốn dựa trên giá trị và cách chúng ta dùng nó. Trong trường hợp có thể có nhiều kiểu, ví dụ như khi chúng ta chuyển đổi một String sang một kiểu số bằng parse trong phần [“So sánh số đoán với số bí mật”] (comparing-the-guess-to-the-secret-number) ở Chương 2, chúng ta phải khai báo kiểu, giống như sau:

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

Nếu chúng ta không khai báo kiểu ở đây, Rust sẽ hiển thị lỗi bên dưới, có nghĩa trình biên dịch cần nhiều thông tin hơn từ chúng ta để biết kiểu chúng ta muốn dùng:

$ 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

Bạn sẽ thấy những khai báo kiểu khác nhau cho những kiểu dữ liệu khác nhau.

Kiểu vô hướng (Scalar Types)

Một kiểu scalar biểu thị một giá trị đơn lẻ. Rust có bốn kiểu scalar chính: số nguyên (integer), số thực, Booleans và ký tự. Bạn có lẽ đã nhận ra những kiểu này từ những ngôn ngữ lập trình khác. Hãy cùng xem chúng hoạt động thế nào trong Rust.

Kiểu số nguyên (Integer Types)

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

Bảng 3-1: Các kiểu integer trong Rust

Độ dài Có dấu Không dấu
8-bit i8 u8
16-bit i16 u16
32-bit i32 u32
64-bit i64 u64
128-bit i128 u128
arch isize usize

Mỗi loại có thể là có dấu (signed) cũng như không dấu (unsigned) và có kích thước riêng. SignedUnsiged nói đến việc liệu số có thể là số âm hay không, nói cách khác, liệu số có cần có dấu (signed) hay không, hay nó luôn dương và không cần dấu (unsigned). Giống như viết số trên giấy: khi việc có dấu là quan trọng, số 1 sẽ được viết với một dấu cộng hoặc một dấu trừ; tuy nhiên, khi nó an toàn để giả định rằng số là số dương, nó sẽ không cần dấu. Số có dấu được lưu trữ bằng biểu diễn bù hai.

Mỗi loại có dấu có thể lưu số từ -(2n - 1) tới 2n - 1 - 1, n là số bit loại đó dùng. Vì vậy i8 có thể chứa các số từ -(27) tới 27 - 1, tương đương từ -128 to 127. Những loại không dấu có thể lưu những số từ 0 tới 2n - 1, vì vậy u8 có thể lưu những số từ 0 đến 28 - 1, tương đương 0 tới 255.

Thêm vào đó, kiểu isizeusize phụ thuộc vào máy tính bạn đang chạy: 64 bit nếu nó là kiến trúc 64-bit và 32 bit nếu nó là kiến trúc 32-bit.

Bạn có thể viết chữ số integer dưới các dạng như trong Bảng 3-2. Lưu ý rằng những số mà có thể thuộc về nhiều loại số khác nhau cho phép sử dụng hậu tố khai báo kiểu, ví dụ 57u8. Số cũng có thể sử dụng _ như dấu phân cách để dễ đọc hơn, ví dụ viết 1_000 cũng có giá trị như 1000.

Bảng 3-2: Viết chữ số integer trong Rust

Number literals Example
Decimal 98_222
Hex 0xff
Octal 0o77
Binary 0b1111_0000
Byte (u8 only) b'A'

Vậy làm thế nào bạn biết được nên dùng kiểu integer nào? Nếu bạn không chắc chắn, bạn có thể để mặc định của Rust là i32. Tình huống chủ yếu khi phân vân giữa isize hay usize là khi đánh chỉ mục một số tập hợp.

Tràn số integer (Integer Overflow)

Giả sử bạn đang có một biến u8 với khoảng giá trị từ 0 đến 255. Nếu bạn cố gán cho biến một giá trị ngoài khoảng đó, 256 chẳng hạn, integer overflow sẽ xảy ra. Rust có một vài quy tắc khá thú vị liên quan đến hành vi này. Khi bạn biên dịch ở chế độ gỡ rối (debug mode), Rust bao gồm cả việc kiểm tra tràn số, thứ có thể làm cho chương trình của bạn panic lúc chạy. Rust dùng cụm từ panicking khi một chương trình do lỗi mà thoát; chúng ta sẽ thảo luận về panic sâu hơn ở phần “Lỗi không thể hồi phục với panic!

ở Chương 9.

Khi bạn biên dịch ở chế độ phát hành (release mode) với cờ --release, Rust không kiểm tra việc tràn số. Thay vào đó, nếu tràn số xảy ra, Rust thực hiện phép bù hai (two’s complement wrapping). Nói ngắn gọn, giá trị lớn hơn giá trị lớn nhất mà kiểu đó có thể biểu diễn được “làm tròn” về giá trị nhỏ nhất của kiểu đó. Trong trường hợp u8, 256 trở thành 0, 256 trở thành 1 và cứ thế. Chương trình sẽ không panic, nhưng biến sẽ có giá trị không như bạn mong muốn. Tràn số được coi là một lỗi.

Để xử lý nguy cơ tràn số, bạn có thể dùng những phương pháp standard library đã cung cấp cho những kiểu số cơ bản (primitive numeric type):

  • Wrap trong tất cả các chế độ với các phương thức wrapping_*, ví dụ wrapping_add
  • Trả về giá trị None nếu có tràn số với các phương thức checked_*
  • Trả về giá trị và một giá trị boolean biểu thị việc có bị tràn số hay không > với các phương thức overflowing_*
  • Bão hòa tại giá trị cực tiểu hoặc cực đại với các phương thức saturating_*

Kiểu số thực dấu phẩy động (Floating-Point Types)

Rust cũng có hai kiểu cơ bản cho số thực, cụ thể hơn là số với phần thập phân. Những kiểu số thực của Rust là f32f64, tương ứng là 32 bit và 64 bit. Kiểu mặc định là f64 vì trên những CPU hiện đại nó có tốc độ tương đương như f32 nhưng lại có khả năng biểu diễn chính xác hơn. Tất cả kiểu số thực đều có dấu (signed).

Đây là một ví dụ về số thực trong thực tế:

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 chuẩn IEEE-754. Kiểu f32 là một số thực có độ chính xác đơn (single-precision) còn f64 có độ chính xác kép (double precision).

Phép toán số học

Rust hỗ trợ những phép toán số học cơ bản cho tất cả các kiểu số: cộng, trừ, nhân, chia và chia lấy dư. Phép chia số nguyên sẽ làm tròn kết quả xuống số nguyên gần nhất. Đoạn code sau cho bạn thấy cách dùng từng phép toán trong statement let.

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 những statement này sử dụng một phép toán số học và tính ra một giá trị rồi gán vào một biến. Appendix B có chứa danh sách những phép toán mà Rust cung cấp.

Kiểu Boolean

Như hầu hết các ngôn ngữ lập trình khác, một kiểu Boolean trong Rust có hai giá trị khả dĩ: truefalse. Những biến Boolean có kích thước là một byte. Kiểu Boolean trong Rust là bool. Ví dụ:

fn main() {
    let t = true;

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

Các giá trị Boolean thường được sử dụng thông qua các điều kiện, ví dụ như if. Chúng ta sẽ nói về cách hoạt động của if trong Rust ở phần “Luồng điều khiển”.

Kiểu ký tự

Kiểu char của Rust là kiểu alphabet cơ bản nhất của ngôn ngữ, đoạn code sau cho bạn thấy một cách để sử dụng nó.

Filename: src/main.rs

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

Chú ý ký tự kiểu char được đặt trong nháy đơn, trái với chuỗi được đặt trong nháy kép. Kiểu char của Rust có kích thước bốn byte và biểu diễn một giá trị Unicode Scalar, có nghĩa là nó có thể biểu diễn nhiều hơn là chỉ ASCII. Ký tự có dấu, chữ Trung, Nhật, Hàn; emoji; và zero-width space đều là những giá trị char hợp lệ trong Rust. Những giá trị Unicode Scalar có biên từ U+0000 tới U+D7FF và bao gồm U+E000 tới U+10FFFF. Tuy nhiên, một “character” không thực sự là một khái niệm trong Unicode, nên cảm nhận của bạn về việc một “character” là gì có thể không giống với một char là gì trong Rust. Chúng ta sẽ thảo luận chủ đề này ở “Lưu trữ văn bản mã hóa UTF-8 với Strings” trong Chương 8.

Kiểu phức hợp (Compound Types)

Kiểu phức hợp có thể nhóm nhiều giá trị vào một kiểu. Rust có hai kiểu phức hợp cơ bản: tuple và array.

Kiểu Tuple

Một tuple là một cách chung để nhóm một số các giá trị thuộc những kiểu khác nhau vào một kiểu phức hợp. Tuple có độ dài cố định: một khi đã khai báo, bạn không thể tăng hay giảm kích thước của nó.

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

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

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

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 tạo một tuple và gán nó vào biến tup. Nó sau đó sử dụng một pattern với let để lấy tup và biến nó thành ba biến riêng rẽ, x, yz. Điều này gọi là destructuring, bởi vì nó phá vỡ một tuple thành ba phần. Cuối cùng, chương trình in ra giá trị của y, 6.4.

Chúng ta có thể truy cập trực tiếp một phần tử tuple 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ụ:

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 tuple x, và truy cập vào mỗi phần tử trong tuple bằng cách sử dụng các chỉ số (index) tương ứng của chúng. Như hầu hết các ngôn ngữ lập trình, chỉ số đầu tiên trong một tuple là 0.

Tuple mà không chứa giá trị nào có một cái tên đặc biệt, unit. Giá trị 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. này được gọi là unit type và giá trị của nó được gọi là unit value. Expression trả về unit value nếu nó không trả về bất kì giá trị nào khác.

Mảng (Array)

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

Trong Rust, những giá trị trong một mảng được viết như một danh sách phân cách bởi dấu phẩy nằm trong ngoặc vuông:

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

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

Tuy nhiên, mảng sẽ hữu ích hơn nếu bạn biết rằng số phần tử sẽ không thay đổi. Ví dụ nếu bạn đang dùng tên các tháng trong năm, bạn có thể dùng mảng thay vì vector vì bạn biết rằng nó sẽ luôn có 12 phần tử:

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

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

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

Ở đây, i32 là kiểu của các phần tử. Sau dấu chấm phẩy, số 5 chỉ ra rằng mảng chứa năm phần tử.

Nếu bạn muốn tạo một mảng với các phần tử có cùng giá trị, bạn có thể chỉ định giá trị khởi tạo, tiếp theo là một dấu chấm phẩy, và theo sau là độ dài của mảng trong ngoặc vuông:

let a = [3; 5];

Mảng có tên a sẽ chứa 5 phần tử và đều được đặt giá trị khởi tạo là 3. Điều này tương tự như 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ử của mảng

Một mảng là một đoạn bộ nhớ với kích thước cố định được biết trước có thể phân bổ trên stack. Bạn có thể truy cập các phần tử của một mảng bằng chỉ số (index), như thế này:

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, bởi 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ử không hợp lệ trong mảng

Điều gì sẽ xảy ra nếu bạn cố truy cập một phần tử của một mảng mà nó đã đi quá kết thúc của mảng đó? Giả sử bạn sửa ví dụ như sau, sử dụng đoạn code tương tự như trò chơi đoán số trong Chương 2 để lấy một chỉ số mảng từ người dùng:

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}");
}

Đoạn code này biên dịch thành công. Nếu bạn chạy đoạn code này với cargo run và nhập 0, 1, 2, 3 hoặc 4, chương trình sẽ in ra giá trị tương ứng của chỉ số đó trong mảng. Nhưng nếu bạn nhập vào một số vượt quá kết thúc mảng, ví dụ 10, bạn sẽ thấy output 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 dẫn đến một lỗi thực thi (runtime error) tại thời điểm sử dụng một giá trị không hợp lệ trong phép toán lấy chỉ số. Chương trình kết thúc với một tin nhắn lỗi và không thực thi lệnh println! cuối cùng. Khi bạn định truy cập một phần tử sử dụng chỉ số, Rust sẽ kiểm tra liệu chỉ số bạn dùng có nhỏ hơn độ dài của mảng hay không. Nếu chỉ số lớn hơn hoặc bằng chiều dài của mảng, Rust sẽ panic. Phép kiểm tra này phải được xảy ra lúc thực thi (runtime), đặc biệt trong trường hợp này, bởi vì trình biên dịch không thể biết giá trị nào người dùng sẽ nhập vào khi họ chạy code.

Đây là một ví dụ trong những nguyên tắc an toàn của Rust. 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 đúng, 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ạy. Chương 9 sẽ nói nhiều hơn về việc xử lý lỗi của Rust.

Source

https://doc.rust-lang.org/stable/book/ch03-02-data-types.html

Bài liên quan

comments powered by Disqus