- Giới thiệu thói quen viết mã giúp ngăn chặn lỗi từ sớm bằng cách tận dụng tích cực hệ thống kiểu và trình biên dịch của Rust
- Đưa ra các ví dụ về mùi mã (Code Smell) dễ gây lỗi như đánh chỉ mục vector, lạm dụng
Default, match không đầy đủ, tham số boolean không cần thiết, đồng thời giải thích các phương án thay thế
- Nguyên tắc cốt lõi là thiết kế cấu trúc để trình biên dịch ép buộc các bất biến, bằng cách tận dụng pattern matching, trường riêng tư, thuộc tính
#[must_use], v.v.
- Trình bày cụ thể các kỹ thuật phòng thủ ở cấp độ mã nguồn thực tế như dùng
TryFrom, phân rã đầy đủ struct, tính khả biến tạm thời, xác thực trong hàm khởi tạo
- Những mẫu này rất cần thiết để đảm bảo tính ổn định khi refactor và nâng cao khả năng bảo trì lâu dài
Tổng quan về lập trình phòng thủ
- Những chỗ có chú thích
// this should never happen là vị trí mà các bất biến ngầm định đã bị phá vỡ
- Trong đa số trường hợp, lập trình viên không lường hết mọi điều kiện biên hoặc các thay đổi mã trong tương lai
- Trình biên dịch Rust đảm bảo an toàn bộ nhớ, nhưng lỗi trong logic nghiệp vụ vẫn hoàn toàn có thể xảy ra
- Các mẫu thói quen nhỏ (idiom) rút ra từ nhiều năm kinh nghiệm thực tế có thể cải thiện đáng kể chất lượng mã
Code Smell: đánh chỉ mục vector
- Dạng
if !vec.is_empty() { let x = &vec[0]; } có rủi ro panic lúc chạy vì việc kiểm tra độ dài và đánh chỉ mục bị tách rời
- Nếu dùng pattern matching trên slice (
match vec.as_slice()), trình biên dịch sẽ buộc kiểm tra mọi trạng thái
- Có thể xử lý tường minh mọi trường hợp như vector rỗng, một phần tử, phần tử trùng lặp, v.v.
- Đây là ví dụ tiêu biểu của việc thiết kế để trình biên dịch đảm bảo bất biến
Code Smell: sử dụng Default bừa bãi
..Default::default() gây ra vấn đề dễ bỏ sót khi thêm trường mới và thiết lập giá trị ngầm định
- Nếu khởi tạo tường minh tất cả các trường, trình biên dịch sẽ buộc phải thiết lập các trường mới
- Có thể phân rã struct giá trị mặc định rồi ghi đè có chọn lọc bằng dạng
let Foo { field1, field2, .. } = Foo::default();
- Giúp cân bằng giữa việc giữ giá trị mặc định và ghi đè một cách tường minh
Code Smell: cài đặt Trait mong manh
- Khi so sánh bằng cách phân rã đầy đủ các trường của struct, việc thêm trường mới sẽ tạo lỗi biên dịch để cảnh báo
- Ví dụ: trong cài đặt
PartialEq, dùng let Self { size, toppings, .. } = self;
- Nếu thêm trường mới như
extra_cheese, logic so sánh sẽ buộc phải được rà soát lại
- Nguyên lý này cũng áp dụng tương tự cho các trait khác như
Hash, Debug, Clone
Code Smell: cần TryFrom thay vì From
- Nếu phép chuyển đổi không phải lúc nào cũng thành công, hãy diễn đạt khả năng thất bại bằng
TryFrom thay vì From
- Việc dùng
unwrap_or_else là dấu hiệu che giấu thất bại tiềm ẩn, trong khi cách fail fast an toàn hơn
Code Smell: match không đầy đủ
- Các mẫu catch-all như
_ => {} có nguy cơ bỏ sót khi thêm variant mới
- Nếu liệt kê tường minh mọi variant, trình biên dịch sẽ cảnh báo khi thiếu xử lý case mới
- Cùng một logic vẫn có thể được nhóm lại theo dạng
Variant3 | Variant4
Code Smell: lạm dụng placeholder _
- Chỉ dùng
_ sẽ khiến không rõ biến nào đã bị lược bỏ
- Viết như
has_fuel: _, has_crew: _ sẽ cải thiện khả năng đọc nhờ tên gọi tường minh
Pattern: tính khả biến tạm thời (Temporary Mutability)
- Khi dữ liệu chỉ nên khả biến trong lúc khởi tạo, có thể dùng dạng
let mut data = ...; data.sort(); let data = data;
- Nếu tận dụng phạm vi khối, có thể tránh để lộ biến tạm ra bên ngoài
- Ví dụ:
let data = { let mut d = get_vec(); d.sort(); d };
- Trong quá trình khởi tạo dùng nhiều biến tạm, cách này giúp phân tách phạm vi rõ ràng
Pattern: buộc xác thực trong hàm khởi tạo
- Khi tạo struct, cần ép buộc phải đi qua logic xác thực
- Nếu thêm trường
_private: (), bên ngoài sẽ không thể khởi tạo trực tiếp
- Thuộc tính
#[non_exhaustive] có tác dụng chặn việc khởi tạo từ ngoài crate và báo hiệu khả năng mở rộng trong tương lai
- Nếu muốn ép buộc cả trong module nội bộ, có thể dùng cấu trúc module lồng nhau với kiểu riêng tư (Seal)
- Vì
Seal chỉ tồn tại ở bên trong, không thể khởi tạo trực tiếp ngoài new()
- Nếu để các trường ở chế độ riêng tư và cung cấp getter, có thể duy trì trạng thái bất biến
- Tiêu chí áp dụng
- Chặn mã bên ngoài:
_private hoặc #[non_exhaustive]
- Chặn mã nội bộ: module riêng tư +
Seal
- Chuyển logic xác thực thành bảo đảm ở cấp độ trình biên dịch
Pattern: tận dụng thuộc tính #[must_use]
#[must_use] giúp ngăn việc bỏ qua các giá trị trả về quan trọng
- Ví dụ:
#[must_use = "Configuration must be applied to take effect"]
- Nếu người dùng bỏ qua giá trị trả về, trình biên dịch sẽ phát cảnh báo
- Đây là biện pháp phòng thủ đơn giản nhưng mạnh mẽ, cũng được dùng rộng rãi trong thư viện chuẩn như với
Result
Code Smell: tham số boolean
- Dạng
fn process_data(..., compress: bool, encrypt: bool, validate: bool) có ý nghĩa không rõ ràng và dễ lỗi do sai thứ tự
- Có thể dùng
enum Compression, enum Encryption, v.v. để biểu đạt ý định một cách tường minh
- Nếu có nhiều tuỳ chọn, nên dùng struct tham số (Params struct)
- Các phương thức cấu hình sẵn như
ProcessDataParams::production() giúp tăng khả năng tái sử dụng
- Khi thêm tuỳ chọn mới, ảnh hưởng tới các chỗ gọi hiện có sẽ được giảm thiểu
Tự động hoá bằng Clippy Lints
- Các mẫu phòng thủ quan trọng có thể được tự động kiểm tra bằng lint của Clippy
indexing_slicing: cấm đánh chỉ mục trực tiếp
fallible_impl_from: khuyến nghị TryFrom thay vì From
wildcard_enum_match_arm: cấm mẫu _
fn_params_excessive_bools: cảnh báo quá nhiều tham số boolean
must_use_candidate: đề xuất các ứng viên cho #[must_use]
- Có thể áp dụng cho toàn dự án bằng
#![deny(clippy::...)] hoặc cấu hình trong Cargo.toml
Kết luận
- Cốt lõi của lập trình phòng thủ trong Rust là tận dụng tích cực hệ thống kiểu và trình biên dịch để biến các bất biến thành thứ tường minh và có thể được kiểm chứng
- Những mẫu này góp phần đảm bảo ổn định khi refactor, giảm thiểu khả năng phát sinh lỗi và tăng cường khả năng bảo trì lâu dài
- Đây là cách tiếp cận hiện thực hoá nguyên tắc “lỗi tốt nhất là lỗi không thể biên dịch”
1 bình luận
Ý kiến Hacker News
Bài viết hay. Tuy vậy, ví dụ PizzaOrder tạo cảm giác nhồi quá nhiều mối quan tâm vào một struct
Nếu mục đích là loại
ordered_atkhỏi phép so sánh, tôi nghĩ tách thành hai structPizzaDetailsvàPizzaOrdersẽ tốt hơnLàm vậy sẽ giúp việc chỉ so sánh
detailskhi triển khaiPartialEqtrở nên rõ ràng hơnNếu thời điểm đặt hàng khác nhau thì đó không phải cùng một đơn hàng, nên việc định nghĩa chúng là bằng nhau ở cấp độ kiểu dữ liệu là nguy hiểm
Đặt
PartialEqtrênPizzaDetailsthì ổn, nhưng logic so sánh đơn hàng nên nằm trong một hàm nghiệp vụ riêngPizzaDetails, thay đổi đó có thể ảnh hưởng đến logic loại bỏ pizza trùng lặpLý tưởng nhất là struct chỉ nên được dùng để gom dữ liệu lại với nhau
Để thay đổi không ảnh hưởng sang nơi khác, cũng có thể cân nhắc đặt một kiểu riêng như
PizzaComparatorhoặcPizzaFlavorSẽ hay nếu giống Protobuf, có thể đặt chú thích trường như
{important_to_flavour=true}cho từng fieldVí dụ nếu muốn so sánh chuỗi mà không phân biệt chữ hoa chữ thường thì sẽ tách kiểu thế nào?
Một điểm thực sự tuyệt vời ở Rust là có rất nhiều trường hợp không cần lập trình phòng thủ
Nhờ quy tắc ownership và reference, ta có thể được đảm bảo rằng quyền truy cập đến một đối tượng cụ thể là duy nhất trong toàn bộ chương trình
Reference không thể là null, và smart pointer cũng không thể là null
Hệ thống kiểu cũng đảm bảo rằng nếu chuyển quyền sở hữu của
selfthì sau đó không thể tiếp tục gọi phương thức nữaNhờ vậy, tính an toàn luồng, vòng đời, khả năng sao chép v.v. đều được kiểm chứng toàn cục ở thời điểm biên dịch
Ở các ngôn ngữ khác, phải giữ tính bất biến theo phong cách hàm mới có được lợi ích đó, còn Rust thì ép buộc bằng hệ thống kiểu
Chủ đề của bài là những lỗi logic mà borrow checker cũng không bắt được
Tôi thấy tránh lập chỉ mục trực tiếp vào mảng hay vector là một lựa chọn khôn ngoan
Đúng ngày xảy ra sự cố unwrap của Cloudflare, tôi cũng phát hiện một lỗi slice vượt quá cuối vector
Từ đó tôi chuyển sang cách tiếp cận dựa trên iterator và cảm thấy an toàn hơn nhiều
unwraptrong Rust giống nhưasserttrong C. Nó chỉ có nhiệm vụ báo cho ta biết khi có vấn đề xảy raTrong Rust, ta vẫn hoàn toàn có thể viết ra bug
Một trong những thói quen mà lập trình viên Rust cần đề phòng là thêm phụ thuộc crate không cần thiết
Rust có xu hướng khuyến khích kiểu thói quen này. Ví dụ, việc Rust Book dùng crate
randtrong ví dụ cơ bản cũng tạo ra bầu không khí như vậyTất nhiên đây là một lựa chọn mang tính chiến lược để có thể dễ dàng thay thế các gói liên quan đến mật mã, nhưng việc nó trở thành thói quen vẫn là vấn đề
Nhưng sau này tôi hiểu được dụng ý của nó và đã đổi suy nghĩ
Phần triển khai đẳng thức từng phần khá thú vị
Một điều nữa tôi tò mò là cách dùng enum khi muốn tránh tham số boolean
Tôi thường dùng struct bọc quanh bool, nhưng thấy tiếc vì không thể dùng nó như bool thông thường
Tôi tự hỏi có cách nào để dùng enum như bool hay không
Tôi xử lý bằng cách gom logic cần thiết vào Trait, hoặc thêm các phương thức dùng chung trong khối
impl <Enum>Làm vậy vừa dễ đọc, vừa có thể định nghĩa rõ hành vi cho từng thành viên
impl Deref, nhưng tôi không chắc đó có phải ý hay hay khôngCâu lệnh
matchtrong ví dụ đầu tiên có cảm giác hơi quá tayVec.first()hoặcVec.iter().nth(0)rõ ràng hơn và đúng với ý định hơnmatchlại thành ra giải pháp phức tạp hơn vấn đềNếu có thể bỏ
ifthì cũng có thể bỏmatch, nên về mặt an toàn không có khác biệt gìfirst()ngắn gọn và rõ ràng hơn nhiềumatchvẫn có ý nghĩa ở chỗ nó thúc đẩy ta xử lý cả trường hợp “có từ một phần tử trở lên”Nói cách khác, nó thể hiện nguyên tắc tránh tách rời phần kiểm tra và phần mã phụ thuộc vào nó
Mỗi lần đọc những bài như thế này, tôi lại tự hỏi vì sao không có một đội chuyên trách theo dõi các mẫu mã
Sẽ rất tốt nếu có một nhóm giống SOC hay QA để quan sát các pattern trong codebase về dài hạn
Các công cụ tự động phát hiện code smell có giới hạn của chúng
Họ phụ trách quản lý quy tắc lint, tài liệu hóa, đào tạo lập trình viên và bảo trì thư viện dùng chung
Khi nhiều đội lặp lại cùng một vấn đề, họ sẽ thiết kế API cốt lõi để hợp nhất nó
Tuy nhiên, thực tế là khi mã nguồn lên đến hàng triệu dòng thì việc quản lý trở nên cực kỳ khó khăn
Tôi đang suy nghĩ làm sao để khuyến khích các mẫu lập trình tốt này trong nội bộ nhóm
Trong lúc review code, nó thường biến thành “tranh cãi về phong cách” và trở nên thiếu hiệu quả
Nhưng kỳ lạ là khi linter đưa ra cảnh báo, những cuộc tranh cãi như vậy gần như biến mất
Việc trait
TryFromđược thêm vào ở phiên bản 1.34 thực sự rất hữu íchCó lẽ đoạn mã dùng
unwrap_or_else()là tàn dư từ thời kỳ trước đóTài liệu về trait From giờ đây giải thích rất rõ khi nào nên triển khai nó
unwrap_or_else()nghe khá buồn cười, như thể đang “ra lệnh kiểu đe dọa cho máy tính” vậyTôi nghĩ những mẫu lập trình phòng thủ như thế này cũng sẽ giúp cải thiện chất lượng sinh mã AI ở quy mô lớn
Phản hồi cụ thể từ Clippy hay trình biên dịch Rust có thể đóng vai trò rất lớn trong việc giúp các tác nhân AI giảm sai sót và định hướng đúng hơn