Một chương trình ví dụ sử dụng struct

Để hiểu khi nào chúng ta nên dùng struct, hãy viết một chương trình tính diện tích hình chữ nhật. Ta sẽ bắt đầu bằng các biến riêng lẻ, rồi refactor chương trình cho đến khi dùng struct.

Hãy tạo một dự án nhị phân mới với Cargo tên là rectangles để nhận chiều rộng và chiều cao của một hình chữ nhật (đơn vị pixel) và tính diện tích của nó. Liệt kê 5-8 cho thấy một chương trình ngắn làm đúng điều đó trong src/main.rs của dự án.

Filename: src/main.rs
fn main() {
    let width1 = 30;
    let height1 = 50;

    println!(
        "The area of the rectangle is {} square pixels.",
        area(width1, height1)
    );
}

fn area(width: u32, height: u32) -> u32 {
    width * height
}
Listing 5-8: Tính diện tích hình chữ nhật khi truyền riêng lẻ chiều rộng và chiều cao

Bây giờ, chạy chương trình bằng cargo run:

$ cargo run
   Compiling rectangles v0.1.0 (file:///projects/rectangles)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.42s
     Running `target/debug/rectangles`
The area of the rectangle is 1500 square pixels.

Đoạn mã này đã tính được diện tích hình chữ nhật bằng cách gọi hàm area với từng kích thước, nhưng ta có thể làm cho mã rõ ràng và dễ đọc hơn nữa.

Vấn đề của đoạn mã thể hiện trong chữ ký của area:

fn main() {
    let width1 = 30;
    let height1 = 50;

    println!(
        "The area of the rectangle is {} square pixels.",
        area(width1, height1)
    );
}

fn area(width: u32, height: u32) -> u32 {
    width * height
}

Hàm area được kỳ vọng tính diện tích của một hình chữ nhật, nhưng hàm ta viết có hai tham số, và không có chỗ nào trong chương trình thể hiện rõ rằng hai tham số này có liên quan đến nhau. Sẽ dễ đọc và dễ quản lý hơn nếu nhóm chiều rộng và chiều cao lại với nhau. Ta đã bàn về một cách làm điều đó trong phần “Kiểu tuple” của Chương 3: dùng tuple.

Refactor với tuple

Liệt kê 5-9 cho thấy một phiên bản khác của chương trình dùng tuple.

Filename: src/main.rs
fn main() {
    let rect1 = (30, 50);

    println!(
        "The area of the rectangle is {} square pixels.",
        area(rect1)
    );
}

fn area(dimensions: (u32, u32)) -> u32 {
    dimensions.0 * dimensions.1
}
Listing 5-9: Chỉ định chiều rộng và chiều cao của hình chữ nhật bằng một tuple

Theo một khía cạnh, chương trình này tốt hơn. Tuple cho phép ta thêm một chút cấu trúc, và giờ ta chỉ truyền một đối số. Nhưng ở khía cạnh khác, phiên bản này kém rõ ràng hơn: tuple không đặt tên cho các phần tử, nên ta phải đánh chỉ số vào các phần của tuple, làm cho phép tính kém hiển nhiên.

Nhầm lẫn giữa chiều rộng và chiều cao không ảnh hưởng đến phép tính diện tích, nhưng nếu muốn vẽ hình chữ nhật lên màn hình thì lại quan trọng! Ta sẽ phải nhớ rằng width là chỉ số 0 của tuple và height là chỉ số 1. Điều này còn khó hơn cho người khác khi họ dùng mã của ta. Bởi vì ta chưa truyền đạt ý nghĩa của dữ liệu trong mã, nên giờ dễ phát sinh lỗi hơn.

Refactor với struct: Thêm ý nghĩa

Ta dùng struct để thêm ý nghĩa bằng cách gắn nhãn cho dữ liệu. Ta có thể chuyển tuple đang dùng thành một struct có tên cho toàn bộ lẫn tên cho từng phần, như trong Liệt kê 5-10.

Filename: src/main.rs
struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };

    println!(
        "The area of the rectangle is {} square pixels.",
        area(&rect1)
    );
}

fn area(rectangle: &Rectangle) -> u32 {
    rectangle.width * rectangle.height
}
Listing 5-10: Định nghĩa struct Rectangle

Ở đây, ta đã định nghĩa một struct tên là Rectangle. Bên trong dấu ngoặc nhọn, ta định nghĩa các trường widthheight, cả hai đều có kiểu u32. Sau đó, trong main, ta tạo một instance cụ thể của Rectangle có chiều rộng 30 và chiều cao 50.

Hàm area giờ được định nghĩa với một tham số, ta đặt tên là rectangle, có kiểu là một tham chiếu mượn bất biến đến một instance của struct Rectangle. Như đã nói trong Chương 4, ta muốn mượn struct thay vì chuyển quyền sở hữu của nó. Cách này giúp main giữ quyền sở hữu và tiếp tục dùng rect1, đó là lý do ta dùng & trong chữ ký hàm và khi gọi hàm.

Hàm area truy cập các trường widthheight của instance Rectangle (lưu ý rằng truy cập các trường của một struct đang được mượn không di chuyển giá trị của trường, đó là lý do bạn thường thấy mượn struct). Chữ ký hàm area giờ nói đúng điều ta muốn: tính diện tích của Rectangle bằng các trường widthheight của nó. Điều này thể hiện rằng chiều rộng và chiều cao có liên hệ với nhau, và đặt tên mô tả cho các giá trị thay vì dùng chỉ số tuple 01. Rất rõ ràng!

Thêm chức năng hữu ích với các trait dẫn xuất (derived)

Sẽ hữu ích nếu ta có thể in một instance của Rectangle khi debug chương trình và xem giá trị của toàn bộ các trường. Liệt kê 5-11 thử dùng macro println! như ta đã dùng trong các chương trước. Tuy nhiên, cách này sẽ không hoạt động.

Filename: src/main.rs
struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };

    println!("rect1 is {rect1}");
}
Listing 5-11: Thử in một instance Rectangle

Khi biên dịch mã này, ta nhận được lỗi với thông điệp chính:

error[E0277]: `Rectangle` doesn't implement `std::fmt::Display`

Macro println! có thể làm nhiều kiểu định dạng, và theo mặc định, dấu ngoặc nhọn báo cho println! dùng định dạng gọi là Display: đầu ra dành cho người dùng cuối. Các kiểu nguyên thủy ta đã thấy mặc định triển khai Display vì chỉ có một cách bạn muốn hiển thị 1 hay bất kỳ kiểu nguyên thủy nào khác cho người dùng. Nhưng với struct, cách println! nên định dạng đầu ra kém rõ ràng vì có nhiều khả năng hiển thị: Có cần dấu phẩy không? Có in dấu ngoặc nhọn không? Có nên hiển thị tất cả các trường không? Do sự mơ hồ này, Rust không cố đoán ý ta, và struct không có sẵn phần triển khai Display để dùng với println! và placeholder {}.

Nếu tiếp tục đọc lỗi, ta sẽ thấy ghi chú hữu ích này:

   = help: the trait `std::fmt::Display` is not implemented for `Rectangle`
   = note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead

Hãy thử xem! Lời gọi macro println! giờ sẽ trông như println!("rect1 is {rect1:?}");. Đặt bộ chỉ định :? vào trong ngoặc nhọn báo cho println! rằng ta muốn định dạng đầu ra kiểu Debug. Trait Debug cho phép ta in struct theo cách hữu ích cho lập trình viên để thấy giá trị khi ta đang debug mã.

Biên dịch mã với thay đổi này. Chà! Ta vẫn gặp lỗi:

error[E0277]: `Rectangle` doesn't implement `Debug`

Nhưng một lần nữa, trình biên dịch đưa ra ghi chú hữu ích:

   = help: the trait `Debug` is not implemented for `Rectangle`
   = note: add `#[derive(Debug)]` to `Rectangle` or manually `impl Debug for Rectangle`

Rust có hỗ trợ in thông tin debug, nhưng ta phải chủ động bật nó để khả dụng cho struct của mình. Để làm vậy, thêm thuộc tính bên ngoài #[derive(Debug)] ngay trước định nghĩa struct, như trong Liệt kê 5-12.

Filename: src/main.rs
#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };

    println!("rect1 is {rect1:?}");
}
Listing 5-12: Thêm thuộc tính để dẫn xuất trait Debug và in instance Rectangle bằng định dạng debug

Giờ khi chạy chương trình, ta sẽ không còn lỗi và sẽ thấy đầu ra sau:

$ cargo run
   Compiling rectangles v0.1.0 (file:///projects/rectangles)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.48s
     Running `target/debug/rectangles`
rect1 is Rectangle { width: 30, height: 50 }

Tuyệt! Đây không phải là đầu ra đẹp nhất, nhưng nó hiển thị giá trị của tất cả các trường cho instance này, điều rất hữu ích khi debug. Khi có các struct lớn hơn, có đầu ra dễ đọc hơn sẽ hữu ích; trong các trường hợp đó, ta có thể dùng {:#?} thay cho {:?} trong chuỗi println!. Trong ví dụ này, dùng kiểu {:#?} sẽ cho đầu ra sau:

$ cargo run
   Compiling rectangles v0.1.0 (file:///projects/rectangles)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.48s
     Running `target/debug/rectangles`
rect1 is Rectangle {
    width: 30,
    height: 50,
}

Một cách khác để in giá trị với định dạng Debug là dùng macro dbg!, macro này nhận quyền sở hữu của một biểu thức (trái với println!, vốn nhận tham chiếu), in ra tệp và số dòng nơi lệnh gọi dbg! xuất hiện trong mã cùng với giá trị thu được của biểu thức đó, và trả lại quyền sở hữu của giá trị.

Lưu ý: Gọi macro dbg! sẽ in ra luồng lỗi chuẩn (stderr), trái với println! in ra luồng đầu ra chuẩn (stdout). Ta sẽ nói thêm về stderrstdout trong phần “Ghi thông báo lỗi ra luồng lỗi chuẩn thay vì luồng đầu ra chuẩn” ở Chương 12.

Đây là ví dụ khi ta quan tâm đến giá trị gán cho trường width, cũng như giá trị của cả struct trong rect1:

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let scale = 2;
    let rect1 = Rectangle {
        width: dbg!(30 * scale),
        height: 50,
    };

    dbg!(&rect1);
}

Ta có thể đặt dbg! quanh biểu thức 30 * scale và, vì dbg! trả lại quyền sở hữu giá trị của biểu thức, trường width sẽ nhận cùng giá trị như khi không có lời gọi dbg! ở đó. Ta không muốn dbg! lấy quyền sở hữu của rect1, nên ta dùng tham chiếu đến rect1 trong lần gọi tiếp theo. Đầu ra của ví dụ này trông như sau:

$ cargo run
   Compiling rectangles v0.1.0 (file:///projects/rectangles)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.61s
     Running `target/debug/rectangles`
[src/main.rs:10:16] 30 * scale = 60
[src/main.rs:14:5] &rect1 = Rectangle {
    width: 60,
    height: 50,
}

Ta có thể thấy phần đầu của đầu ra đến từ dòng 10 của src/main.rs nơi ta đang debug biểu thức 30 * scale, và giá trị kết quả là 60 (định dạng Debug cho số nguyên chỉ in giá trị của chúng). Lời gọi dbg! ở dòng 14 của src/main.rs in giá trị của &rect1, tức struct Rectangle. Đầu ra này dùng định dạng Debug kiểu “đẹp” cho kiểu Rectangle. Macro dbg! có thể rất hữu ích khi bạn cố tìm hiểu mã của mình đang làm gì!

Ngoài trait Debug, Rust còn cung cấp một số trait để ta dùng với thuộc tính derive nhằm bổ sung hành vi hữu ích cho các kiểu tùy biến. Các trait đó và hành vi của chúng được liệt kê trong Phụ lục C. Ta sẽ học cách tự triển khai các trait này với hành vi tùy biến cũng như cách tạo trait của riêng bạn trong Chương 10. Cũng có nhiều thuộc tính khác ngoài derive; để biết thêm, xem phần “Attributes” trong Rust Reference.

Hàm area của ta hiện rất đặc thù: nó chỉ tính diện tích hình chữ nhật. Sẽ hữu ích nếu gắn hành vi này chặt chẽ hơn với struct Rectangle vì nó không áp dụng cho kiểu khác. Hãy xem ta có thể tiếp tục refactor mã bằng cách biến hàm area thành một phương thức area được định nghĩa trên kiểu Rectangle của chúng ta như thế nào.