Mẫu thiết kế trong Rust
Một mẫu thiết kế (design pattern) là một cách đã được kiểm chứng để giải quyết một vấn đề cứ lặp đi lặp lại. Mẫu thiết kế không phải thư viện để cài đặt. Chúng giống công thức nấu ăn hơn: “khi gặp vấn đề này, hãy tổ chức code theo cách này”.
Nhiều mẫu thiết kế từ Java hay C# trông khác hẳn trong Rust, hoặc hoàn toàn không cần đến. Rust có những mẫu ưa thích của riêng nó, dựa trên hệ thống kiểu mạnh, enum, trait và quyền sở hữu (ownership). Ý tưởng lớn đằng sau phần lớn các mẫu này đều giống nhau: để trình biên dịch bắt lỗi giúp bạn, để lỗi không bao giờ tới tay người dùng. Mỗi phần bên dưới bắt đầu bằng vấn đề, rồi mới đến mẫu thiết kế giải quyết nó.
Newtype: ý nghĩa khác nhau, kiểu khác nhau
Phần tiêu đề “Newtype: ý nghĩa khác nhau, kiểu khác nhau”Vấn đề. Một f64 trơn có thể mang bất kỳ ý nghĩa nào: mét, feet, giây, euro. Nếu một hàm nhận f64 tính bằng mét mà bạn lại truyền feet, trình biên dịch không giúp được gì, vì cả hai đều chỉ là f64. (Chính kiểu nhầm lẫn đơn vị này đã khiến NASA mất tàu Mars Climate Orbiter năm 1999.)
Mẫu thiết kế. Bọc giá trị trong một struct nhỏ xíu có một trường. Giờ Meters và Feet là hai kiểu khác nhau, và Rust không cho bạn trộn lẫn chúng:
#[derive(Debug, Clone, Copy)]struct Meters(f64);
#[derive(Debug, Clone, Copy)]struct Feet(f64);
impl Feet { fn to_meters(self) -> Meters { Meters(self.0 * 0.3048) }}
fn safe_altitude(height: Meters) -> bool { height.0 >= 300.0}
fn main() { let tower = Meters(330.0); let plane = Feet(900.0);
println!("Tháp có an toàn không? {}", safe_altitude(tower)); println!("900 feet bằng {:.1} mét", plane.to_meters().0); println!("Máy bay có an toàn không? {}", safe_altitude(plane.to_meters()));}Kết quả:
Tháp có an toàn không? true900 feet bằng 274.3 métMáy bay có an toàn không? falseNếu bạn quên đổi đơn vị và viết safe_altitude(plane), chương trình thậm chí không biên dịch được:
error[E0308]: mismatched types --> src/main.rs:13:34 |13 | println!("{}", safe_altitude(plane)); | ------------- ^^^^^ expected `Meters`, found `Feet` | | | arguments to this function are incorrectTrình biên dịch báo kiểu không khớp: nó mong đợi Meters nhưng lại nhận được Feet. Một newtype không tốn gì lúc chạy: trong bộ nhớ, Meters chính xác là một f64. Hãy dùng nó cho ID (UserId(u32) khác với OrderId(u32)), đơn vị đo, và chuỗi đã được kiểm tra hợp lệ. Nó cũng giúp bạn lách được orphan rule (xem Trait nâng cao).
Builder: nhiều tùy chọn, một kết quả rõ ràng
Phần tiêu đề “Builder: nhiều tùy chọn, một kết quả rõ ràng”Vấn đề. Có những struct có rất nhiều trường, và phần lớn là tùy chọn. Một hàm khởi tạo như Email::new("[email protected]", "Hi", "", vec![], false) rất khó đọc: false nào là cái gì? Và nếu địa chỉ bị thiếu hoặc sai thì sao?
Mẫu thiết kế. Một struct builder riêng thu thập các thiết lập bằng những phương thức nhỏ, có tên rõ ràng. Mỗi phương thức trả về builder, nên bạn có thể nối chuỗi (chain) các lời gọi. Một hàm build() cuối cùng kiểm tra mọi thứ và trả về một Result:
struct Email { to: String, subject: String, body: String, urgent: bool,}
#[derive(Default)]struct EmailBuilder { to: Option<String>, subject: Option<String>, body: String, urgent: bool,}
impl EmailBuilder { fn new() -> Self { EmailBuilder::default() }
fn to(mut self, address: &str) -> Self { self.to = Some(address.to_string()); self }
fn subject(mut self, subject: &str) -> Self { self.subject = Some(subject.to_string()); self }
fn body(mut self, text: &str) -> Self { self.body = text.to_string(); self }
fn urgent(mut self) -> Self { self.urgent = true; self }
fn build(self) -> Result<Email, String> { let to = self.to.ok_or("email cần có người nhận")?; if !to.contains('@') { return Err(format!("'{}' không phải là địa chỉ email", to)); } let subject = self.subject.unwrap_or_else(|| String::from("(không có tiêu đề)")); Ok(Email { to, subject, body: self.body, urgent: self.urgent }) }}
fn show(result: Result<Email, String>) { match result { Ok(e) => println!("Sẵn sàng: to={} subject={} body={:?} urgent={}", e.to, e.subject, e.body, e.urgent), Err(msg) => println!("Lỗi: {}", msg), }}
fn main() { let email = EmailBuilder::new() .subject("Đi ăn trưa?") .body("Pizza lúc 12 giờ nhé?") .urgent() .build(); show(email);
show(EmailBuilder::new().subject("Xin chào").build()); show(EmailBuilder::new().to("ana.example.com").build());}Kết quả:
Sẵn sàng: [email protected] subject=Đi ăn trưa? body="Pizza lúc 12 giờ nhé?" urgent=trueSẵn sàng: [email protected] subject=(không có tiêu đề) body="" urgent=falseLỗi: email cần có người nhậnLỗi: 'ana.example.com' không phải là địa chỉ emailMỗi hàm setter nhận mut self (nó lấy quyền sở hữu builder), đổi một trường, rồi trả builder lại. Đó là điều giúp chuỗi lời gọi hoạt động. Người gọi chỉ đặt những gì họ quan tâm, tên phương thức giải thích từng giá trị, và build() là nơi duy nhất kiểm tra các quy tắc. Bên trong nó, ok_or biến None (thiếu người nhận) thành một Err, và ? trả lỗi đó về sớm. Bạn sẽ gặp builder trong rất nhiều crate, ví dụ std::thread::Builder và std::process::Command (xem Biến môi trường và tiến trình).
Default và cú pháp cập nhật struct: lựa chọn gọn nhẹ
Phần tiêu đề “Default và cú pháp cập nhật struct: lựa chọn gọn nhẹ”Vấn đề. Builder khá nhiều code. Nếu struct của bạn không có quy tắc nào cần kiểm tra, chỉ có những giá trị khởi đầu hợp lý, thì builder là quá mức cần thiết.
Mẫu thiết kế. Cài đặt Default với những giá trị khởi đầu tốt, rồi dùng cú pháp cập nhật struct (struct update syntax) .. để chỉ đổi những trường bạn quan tâm:
#[derive(Debug, Clone)]struct ServerConfig { host: String, port: u16, workers: u32, verbose: bool,}
impl Default for ServerConfig { fn default() -> Self { ServerConfig { host: String::from("127.0.0.1"), port: 8080, workers: 4, verbose: false, } }}
fn main() { let dev = ServerConfig { verbose: true, ..Default::default() }; let prod = ServerConfig { host: String::from("0.0.0.0"), workers: 16, ..ServerConfig::default() }; // Bắt đầu từ một cấu hình khác thay vì từ giá trị mặc định: let staging = ServerConfig { port: 9090, ..prod.clone() };
println!("{:?}", dev); println!("{:?}", prod); println!("{:?}", staging);}Kết quả:
ServerConfig { host: "127.0.0.1", port: 8080, workers: 4, verbose: true }ServerConfig { host: "0.0.0.0", port: 8080, workers: 16, verbose: false }ServerConfig { host: "0.0.0.0", port: 9090, workers: 16, verbose: false }..something nghĩa là “lấy mọi trường mà tôi chưa liệt kê từ something”. Tại sao lại là prod.clone()? Cú pháp cập nhật struct move các trường còn lại, và host là một String. Nếu không clone, prod sẽ bị move một phần và bạn không thể in nó ra sau đó. Một quy tắc hay: dùng Default + .. khi mọi tổ hợp giá trị đều ổn, và dùng builder khi có những tổ hợp không hợp lệ.
Typestate: máy trạng thái được kiểm tra lúc biên dịch
Phần tiêu đề “Typestate: máy trạng thái được kiểm tra lúc biên dịch”Vấn đề. Có những việc phải diễn ra theo thứ tự. Một yêu cầu web cần URL trước khi bạn gửi nó đi. Với một struct bình thường, bạn sẽ kiểm tra lúc chạy (“URL đã được đặt chưa? nếu chưa, trả về lỗi”) và hy vọng mọi người gọi đều xử lý lỗi đó.
Mẫu thiết kế. Biến trạng thái thành một phần của kiểu. Một Request<NoUrl> đơn giản là không có phương thức send. Chỉ khi đặt URL thì nó mới trở thành Request<HasUrl>, và kiểu này có send:
// Hai trạng thái. Chúng không chứa dữ liệu (hoặc chỉ chứa những gì trạng thái đó cần).struct NoUrl;struct HasUrl(String);
struct Request<U> { url: U, headers: Vec<(String, String)>,}
impl Request<NoUrl> { fn new() -> Self { Request { url: NoUrl, headers: Vec::new() } }
// Đổi kiểu: Request<NoUrl> -> Request<HasUrl> fn url(self, url: &str) -> Request<HasUrl> { Request { url: HasUrl(url.to_string()), headers: self.headers } }}
// Có ở mọi trạng tháiimpl<U> Request<U> { fn header(mut self, key: &str, value: &str) -> Self { self.headers.push((key.to_string(), value.to_string())); self }}
// Chỉ có sau khi đã đặt URLimpl Request<HasUrl> { fn send(self) -> String { format!("GET {} với {} header", self.url.0, self.headers.len()) }}
fn main() { let response = Request::new() .header("Accept", "text/html") .url("https://example.com") .header("User-Agent", "rust-tutorial") .send();
println!("{}", response);}Kết quả:
GET https://example.com với 2 headerGiờ hãy thử gửi mà không có URL, Request::new().send(), và trình biên dịch sẽ chặn bạn lại:
error[E0599]: no method named `send` found for struct `Request<NoUrl>` in the current scope --> src/main.rs:22:35 |22 | let response = Request::new().send(); | ^^^^ method not found in `Request<NoUrl>` | = note: the method was found for `Request<HasUrl>`Thông báo nói rằng không có phương thức send cho Request<NoUrl>; nó chỉ có trên Request<HasUrl>. Lỗi này không thể tồn tại trong một chương trình biên dịch được. Không có kiểm tra lúc chạy và không tốn thêm chi phí: NoUrl chiếm 0 byte. Vì url() nhận self theo giá trị, Request<NoUrl> cũ bị dùng hết, nên không ai có thể vô tình tiếp tục dùng trạng thái “trước đó”. Mẫu này dùng generic theo một cách khá khéo; hãy thử sửa một chút trên Playground để cảm nhận cách nó hoạt động.
RAII và Drop guard: việc dọn dẹp không thể quên
Phần tiêu đề “RAII và Drop guard: việc dọn dẹp không thể quên”Vấn đề. Tài nguyên cần được dọn dẹp: đóng file, mở khóa lock, xóa thư mục tạm, in ra “xong”. Nếu việc dọn dẹp là một hàm bạn phải nhớ gọi, thế nào cũng có người quên, nhất là khi có return sớm hoặc có lỗi.
Mẫu thiết kế. Đặt việc dọn dẹp vào Drop. Rust tự động gọi drop khi một giá trị ra khỏi phạm vi (scope), trên mọi lối ra: kết thúc bình thường, return sớm, ?, thậm chí cả panic. Điều này gọi là RAII (“Resource Acquisition Is Initialization”: lấy tài nguyên và sở hữu một giá trị là cùng một bước). Một giá trị có nhiệm vụ duy nhất là dọn dẹp được gọi là guard:
struct Guard { name: String,}
impl Guard { fn new(name: &str) -> Guard { println!("bắt đầu {}", name); Guard { name: name.to_string() } }}
impl Drop for Guard { fn drop(&mut self) { println!("dọn dẹp {}", self.name); }}
fn risky(fail: bool) -> Result<(), String> { let _guard = Guard::new("việc rủi ro"); if fail { return Err(String::from("có lỗi xảy ra")); // guard vẫn dọn dẹp } println!("xong việc"); Ok(())}
fn main() { { let _block = Guard::new("khối"); println!("bên trong khối"); } // _block bị drop ở đây
println!("{:?}", risky(false)); println!("{:?}", risky(true));}Kết quả:
bắt đầu khốibên trong khốidọn dẹp khốibắt đầu việc rủi roxong việcdọn dẹp việc rủi roOk(())bắt đầu việc rủi rodọn dẹp việc rủi roErr("có lỗi xảy ra")Bạn vốn đã dùng mẫu này suốt. Một File tự đóng khi bị drop. Một MutexGuard (từ mutex.lock(), xem Lập trình đồng thời) mở khóa mutex khi bị drop. Một Box giải phóng bộ nhớ của nó khi bị drop (Con trỏ thông minh). Đó là lý do code Rust hầu như không bao giờ có lời gọi “close” hay “free”.
From và Into: đối số hàm thân thiện
Phần tiêu đề “From và Into: đối số hàm thân thiện”Vấn đề. Hàm của bạn nhận một String, nên người gọi có &str lúc nào cũng phải viết String::from("Ana") hoặc "Ana".to_string(). Hoặc hàm của bạn nhận một UserId, mà người gọi chỉ có một u32 trơn. Chuyện nhỏ, nhưng nó làm code rối mắt ở khắp nơi.
Mẫu thiết kế. Cài đặt From cho các kiểu của bạn, và nhận impl Into<T> trong các hàm. Khi đó người gọi có thể truyền bất cứ thứ gì chuyển đổi được:
#[derive(Debug)]struct UserId(u32);
impl From<u32> for UserId { fn from(n: u32) -> Self { UserId(n) }}
#[derive(Debug)]struct User { id: UserId, name: String,}
impl User { fn new(id: impl Into<UserId>, name: impl Into<String>) -> Self { User { id: id.into(), name: name.into() } }}
fn main() { let ana = User::new(1, "Ana"); // u32 và &str let ben = User::new(UserId(2), String::from("Ben")); // đã đúng kiểu sẵn
println!("{:?}", ana); println!("{:?}", ben);}Kết quả:
User { id: UserId(1), name: "Ana" }User { id: UserId(2), name: "Ben" }Hãy cài đặt From, đừng bao giờ cài Into trực tiếp; bạn sẽ có Into miễn phí. Mẹo này cũng là nền tảng của việc xử lý lỗi: ? gọi From::from trên lỗi, nên cài đặt From<io::Error> for MyError sẽ cho phép ? tự chuyển đổi lỗi giúp bạn (xem Lỗi tự định nghĩa). Dùng cách này cho những phép chuyển đổi không thể thất bại. Nếu một phép chuyển đổi có thể thất bại, hãy dùng TryFrom, vốn trả về một Result.
Máy trạng thái bằng enum và trạng thái không hợp lệ
Phần tiêu đề “Máy trạng thái bằng enum và trạng thái không hợp lệ”Vấn đề. Một đơn hàng đi từ “trong giỏ” sang “đã thanh toán” rồi “đã giao”. Lần thử đầu tiên thường trông như thế này:
struct Order { paid: bool, shipped: bool, tracking_number: Option<String>,}Nhưng cách này cho phép những điều vô lý: shipped: true mà paid: false, hoặc một mã vận đơn trên một đơn hàng chưa giao. Hàm nào cũng phải kiểm tra những tổ hợp bất khả thi này.
Mẫu thiết kế. Làm cho trạng thái không hợp lệ không thể biểu diễn được. Dùng một enum với mỗi trạng thái một biến thể (variant), và chỉ cho mỗi biến thể đúng những dữ liệu mà trạng thái đó thật sự có. Các bước chuyển trạng thái là những phương thức nhận trạng thái cũ và trả về trạng thái mới:
#[derive(Debug)]enum Order { Cart { items: Vec<String> }, Paid { items: Vec<String>, amount: u32 }, Shipped { tracking: String }, Cancelled,}
impl Order { fn pay(self, amount: u32) -> Order { match self { Order::Cart { items } => Order::Paid { items, amount }, other => other, // chỉ giỏ hàng mới thanh toán được; mọi thứ khác giữ nguyên } }
fn ship(self, tracking: &str) -> Order { match self { Order::Paid { .. } => Order::Shipped { tracking: tracking.to_string() }, other => other, } }
fn cancel(self) -> Order { match self { Order::Shipped { .. } => self, // quá muộn để hủy _ => Order::Cancelled, } }
fn status(&self) -> String { match self { Order::Cart { items } => format!("giỏ hàng có {} món", items.len()), Order::Paid { items, amount } => format!("đã trả {} cho {} món", amount, items.len()), Order::Shipped { tracking } => format!("đã giao, mã vận đơn {}", tracking), Order::Cancelled => String::from("đã hủy"), } }}
fn main() { let order = Order::Cart { items: vec![String::from("sách"), String::from("bút")] }; println!("{}", order.status());
let order = order.ship("XYZ"); // chưa thanh toán: không có gì xảy ra println!("{}", order.status());
let order = order.pay(25).ship("TRACK-42"); println!("{}", order.status());
let order = order.cancel(); // đã giao rồi println!("{}", order.status());
let other = Order::Cart { items: vec![] }.cancel(); println!("{}", other.status());}Kết quả:
giỏ hàng có 2 móngiỏ hàng có 2 mónđã giao, mã vận đơn TRACK-42đã giao, mã vận đơn TRACK-42đã hủyMột đơn hàng đã giao không thể thiếu mã vận đơn, vì biến thể Shipped luôn có một mã. Một giỏ hàng không thể có mã vận đơn, vì không có trường nào cho nó. Và vì match phải bao quát mọi biến thể, khi bạn thêm một trạng thái mới (ví dụ Refunded), trình biên dịch sẽ chỉ ra mọi chỗ bạn cần cập nhật.
Enum hay trait object? Các ngôn ngữ hướng đối tượng thường dùng một interface “State” với mỗi trạng thái một class. Trong Rust bạn có thể làm vậy với trait object (Box<dyn State>), và Rust Book có hướng dẫn cách làm. Đây là cách chọn:
- Enum: bạn biết trước mọi trạng thái. Bạn được
matchkiểm tra đầy đủ, và toàn bộ logic nằm ở một chỗ. Đây là lựa chọn thường gặp trong Rust. - Trait object: người khác (hoặc crate khác) cần thêm trạng thái mới mà không phải sửa code của bạn.
- Typestate (ở phần trước): bạn muốn gọi sai thứ tự là lỗi biên dịch, chứ không chỉ bị bỏ qua lúc chạy. Cách này chặt chẽ hơn, nhưng trạng thái phải được biết lúc biên dịch.
Lỗi thường gặp
Phần tiêu đề “Lỗi thường gặp”Viết let _ = guard thay vì let _guard = guard. Trông gần như giống nhau, nhưng hành vi khác nhau. Một cái tên bắt đầu bằng dấu gạch dưới (như _guard) giữ giá trị sống tới cuối phạm vi. Còn _ trơn hoàn toàn không phải là biến, nên giá trị bị drop ngay lập tức:
struct Guard;
impl Drop for Guard { fn drop(&mut self) { println!("guard đã bị drop"); }}
fn main() { let _ = Guard; println!("làm việc với `let _`");
let _g = Guard; println!("làm việc với `let _g`");}Kết quả:
guard đã bị droplàm việc với `let _`làm việc với `let _g`guard đã bị dropVới let _, việc dọn dẹp chạy trước công việc, và đó chính xác là điều sai với một lock hay một bộ đếm giờ. Với guard, hãy luôn đặt tên cho biến.
Dùng một mẫu thiết kế chỉ vì nó tồn tại. Một builder cho struct có hai trường, hay một typestate cho thứ chẳng bao giờ bị dùng sai, chỉ thêm code mà không làm gì an toàn hơn. Hãy bắt đầu đơn giản. Chỉ dùng một mẫu khi bạn cảm nhận được vấn đề mà nó giải quyết.
Tóm tắt
Phần tiêu đề “Tóm tắt”- Newtype (
struct Meters(f64)) cho cùng một dữ liệu những kiểu khác nhau, để trình biên dịch bắt được sự nhầm lẫn, mà lúc chạy không tốn gì. - Builder cho phép thiết lập dễ đọc, nối chuỗi được, với một
build()kiểm tra hợp lệ và trả vềResult. Default +..là lựa chọn nhẹ hơn khi không có gì cần kiểm tra. - Typestate đặt trạng thái vào kiểu (
Request<NoUrl>vàRequest<HasUrl>), nên gọi sai thứ tự sẽ không biên dịch được. - RAII / Drop guard tự động dọn dẹp trên mọi lối ra khỏi một phạm vi.
- From/Into cho phép hàm nhận nhiều kiểu đầu vào với
impl Into<T>. - Enum mô hình hóa máy trạng thái và làm cho trạng thái không hợp lệ không thể biểu diễn được; chỉ dùng trait object khi người khác cần thêm trạng thái.
Kiểm tra nhanh
Phần tiêu đề “Kiểm tra nhanh”Kiểm tra nhanh
1. Newtype struct Meters(f64) cho bạn điều gì so với một f64 trơn?
2. Trong mẫu builder trên trang này, build() trả về gì?
3. Trong ví dụ typestate, vì sao bạn không thể gọi send() trên Request::new()?
4. Khi nào việc dọn dẹp của một Drop guard chạy?
5. let _ = Guard; làm gì?
Điểm: 0 / 5
Đọc thêm
Phần tiêu đề “Đọc thêm”- Rust Design Patterns (tiếng Anh): cuốn sách miễn phí của cộng đồng về idiom, mẫu thiết kế và anti-pattern trong Rust, gồm cả newtype, builder và RAII guard.
- Implementing an Object-Oriented Design Pattern — The Rust Book (tiếng Anh): mẫu state làm bằng trait object, rồi làm lại bằng cách mã hóa trạng thái thành kiểu.
- Rust API Guidelines (tiếng Anh): lời khuyên chính thức để thiết kế API thân thiện, đúng phong cách Rust (chuyển đổi, builder, đặt tên).
- RAII — Rust by Example (tiếng Anh): cách quyền sở hữu và
Dropcho bạn khả năng tự động dọn dẹp. - The Typestate Pattern in Rust — Cliff L. Biffle (tiếng Anh): hướng dẫn sâu hơn, rất dễ đọc, về typestate với nhiều ví dụ hơn.