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.
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 }
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.
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 }
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.
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 }
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 width
và height
, 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 width
và height
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 width
và height
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 0
và 1
. 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.
struct Rectangle {
width: u32,
height: u32,
}
fn main() {
let rect1 = Rectangle {
width: 30,
height: 50,
};
println!("rect1 is {rect1}");
}
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.
#[derive(Debug)] struct Rectangle { width: u32, height: u32, } fn main() { let rect1 = Rectangle { width: 30, height: 50, }; println!("rect1 is {rect1:?}"); }
Debug
và in instance Rectangle
bằng định dạng debugGiờ 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ớiprintln!
in ra luồng đầu ra chuẩn (stdout
). Ta sẽ nói thêm vềstderr
vàstdout
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.