Bỏ qua để đến nội dung

Feature của Cargo và biên dịch có điều kiện (#[cfg]) trong Rust

Hãy nghĩ tới việc gọi một chiếc pizza. Ai cũng có đế bánh và sốt cà chua. Sau đó bạn chọn thêm: nấm, thêm phô mai, ô liu. Nhà bếp chỉ làm đúng những gì bạn gọi, nên bạn không phải trả tiền (hay chờ đợi) cho những thứ bạn không muốn.

Code Rust cũng hoạt động như vậy. Với biên dịch có điều kiện (conditional compilation, #[cfg]), bạn nói với trình biên dịch “chỉ build phần này trên Windows” hoặc “chỉ khi chạy test”. Với feature của Cargo (Cargo feature), những người dùng crate của bạn chọn các phần tuỳ chọn họ cần, như hỗ trợ JSON. Code không được chọn sẽ hoàn toàn không được biên dịch. Nó không làm chậm quá trình build, và không có mặt trong chương trình cuối cùng.

Trang này xây tiếp trên Cargo và crate (Cargo.toml, cargo add) và Công cụ và cài đặt Rust. Nếu những từ đó còn lạ, hãy xem qua hai trang đó trước.

#[cfg(...)] là một thuộc tính (attribute) bạn đặt lên một hàm, một struct, một dòng use, một module hay thậm chí một câu lệnh. Nếu điều kiện đúng, code được giữ lại. Nếu sai, trình biên dịch xoá nó đi hoàn toàn, như thể bạn chưa từng viết.

#[cfg(target_os = "linux")]
fn os_name() -> &'static str {
"Linux"
}
#[cfg(target_os = "windows")]
fn os_name() -> &'static str {
"Windows"
}
#[cfg(target_os = "macos")]
fn os_name() -> &'static str {
"macOS"
}
fn main() {
println!("Xin chào từ {}!", os_name());
}

Kết quả:

Xin chào từ Linux!
▶ Tự chạy thử

Rust Playground chạy trên Linux, nên nó in ra Xin chào từ Linux!. Trên laptop Windows của bạn, cùng đoạn code đó in ra Xin chào từ Windows!. Ba hàm trùng tên nhau, nhưng không sao: trên bất kỳ máy nào, chỉ một trong số chúng còn lại.

Có một cái bẫy nhỏ ở đây. Trên FreeBSD, không điều kiện nào trong ba điều kiện đúng, nên os_name không tồn tại và quá trình build dừng lại với lỗi E0425: “cannot find function os_name in this scope” (không tìm thấy hàm os_name trong phạm vi này). Các phần tiếp theo chỉ bạn cách thêm phương án dự phòng.

cfg!(): một giá trị true hoặc false đơn giản

Phần tiêu đề “cfg!(): một giá trị true hoặc false đơn giản”

Đôi khi bạn không muốn xoá code. Bạn chỉ muốn đặt một câu hỏi. Macro cfg!() nhận cùng các điều kiện đó, nhưng trả về một bool bình thường:

fn main() {
let bits = if cfg!(target_pointer_width = "64") { 64 } else { 32 };
println!("Đây là chương trình {bits}-bit");
println!("usize lớn nhất là {}", usize::MAX);
if cfg!(target_os = "windows") {
println!("Đường dẫn trông như C:\\Users\\ana");
} else {
println!("Đường dẫn trông như /home/ana");
}
}

Kết quả:

Đây là chương trình 64-bit
usize lớn nhất là 18446744073709551615
Đường dẫn trông như /home/ana
▶ Tự chạy thử

target_pointer_width là kích thước của một địa chỉ bộ nhớ: "64" trên gần như mọi máy tính hiện đại, "32" trên một số thiết bị nhỏ. Nó cũng là kích thước của usize, đó là lý do usize::MAX lớn như vậy ở đây.

Vậy khi nào dùng cái nào?

#[cfg(...)] cfg!(...)
Nó làm gì Xoá code khi điều kiện sai Trở thành true hoặc false
Code ở nhánh “sai” có còn được biên dịch không? Không, nó biến mất Có, cả hai nhánh phải biên dịch được ở mọi nơi
Dùng cho Code không thể biên dịch ở nơi khác (API chỉ có trên một hệ điều hành, crate tuỳ chọn) Những khác biệt nhỏ về hành vi

Dòng ở giữa rất quan trọng. Những thứ chỉ có trên Unix như std::os::unix::fs::PermissionsExt không tồn tại trên Windows, nên code dùng chúng phải nằm sau #[cfg(unix)]. Một if cfg!(unix) là không đủ, vì trình biên dịch trên Windows vẫn sẽ cố biên dịch nhánh đó.

Bạn có thể dựng điều kiện lớn từ những điều kiện nhỏ:

  • not(a): đúng khi a sai
  • any(a, b, ...): đúng khi ít nhất một điều kiện đúng (như “hoặc”)
  • all(a, b, ...): đúng khi tất cả đều đúng (như “và”)

Đây lại là ví dụ hệ điều hành, lần này có phương án dự phòng để biên dịch được ở mọi nơi:

#[cfg(any(target_os = "linux", target_os = "macos"))]
fn shell() -> &'static str {
"sh"
}
#[cfg(target_os = "windows")]
fn shell() -> &'static str {
"cmd"
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
fn shell() -> &'static str {
"không rõ"
}
fn main() {
println!("Shell mặc định: {}", shell());
if cfg!(all(unix, target_pointer_width = "64")) {
println!("Một hệ thống Unix 64-bit");
}
}

Kết quả:

Shell mặc định: sh
Một hệ thống Unix 64-bit
▶ Tự chạy thử

unix và windows là những tên ngắn tiện dụng. unix đúng trên Linux, macOS, các hệ BSD và những hệ thống tương tự. Đây là các điều kiện bạn sẽ gặp nhiều nhất:

Điều kiện Đúng khi…
target_os = "linux" / "windows" / "macos" Bạn build cho hệ điều hành đó
unix / windows Bạn build cho bất kỳ hệ giống Unix nào / cho Windows
target_arch = "x86_64" / "aarch64" / "wasm32" Bạn build cho loại vi xử lý đó (WebAssembly là wasm32)
target_pointer_width = "64" Địa chỉ (và usize) có 64 bit
debug_assertions Đây là bản debug (cargo build), không phải --release
test Bạn đang chạy cargo test
feature = "json" Feature json của Cargo đang bật (xem bên dưới)

Muốn xem danh sách đầy đủ cho máy của bạn? Hãy chạy rustc --print cfg. Trên một máy Linux 64-bit, nó in ra những dòng như target_os="linux", target_family="unix" và debug_assertions. Bạn sẽ thấy #[cfg(windows)] và #[cfg(unix)] rất nhiều trong code FFI, vì thư viện C thường khác nhau giữa các hệ thống.

Bạn đã dùng một điều kiện mà không để ý. Mọi module test ở trang Kiểm thử đều bắt đầu bằng #[cfg(test)]:

pub fn add_points(total: u32, points: u32) -> u32 {
total + points
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn adds_points() {
assert_eq!(add_points(10, 5), 15);
}
}

cargo test bật test, nên module được build và các test của nó chạy. Một lệnh cargo build bình thường bỏ qua toàn bộ module, nên chương trình thật của bạn không mang theo chút code test nào. #[cfg] dùng được trên mọi module như vậy (xem Module), ví dụ #[cfg(windows)] mod windows_paths;.

#[cfg_attr(condition, attribute)] có nghĩa là: “nếu điều kiện đúng, thêm thuộc tính này vào”. Một cách dùng phổ biến là một derive mà bạn chỉ muốn có trong một số trường hợp:

#[cfg_attr(debug_assertions, derive(Debug))]
struct Player {
name: String,
score: u32,
}
fn main() {
let player = Player { name: "Ana".to_string(), score: 120 };
// chỉ có trong bản debug; in ra stderr, không phải stdout
#[cfg(debug_assertions)]
eprintln!("debug: {:?}", player);
println!("{} có {} điểm", player.name, player.score);
}

Kết quả:

Ana có 120 điểm
▶ Tự chạy thử

Trong bản debug (mặc định của Playground), bạn còn thấy debug: Player { name: "Ana", score: 120 } ở phần “Standard Error” của kết quả. Chuyển Playground sang “Release” thì dòng đó biến mất, cùng với code Debug của Player. Hãy để ý rằng eprintln! cần #[cfg(debug_assertions)] của riêng nó. Thiếu nó, bản release sẽ build thất bại, vì ở đó Player không cài đặt Debug. Bạn có thể đọc thêm về bản debug và bản release ở trang Cargo và crate.

Feature của Cargo trong crate của riêng bạn

Phần tiêu đề “Feature của Cargo trong crate của riêng bạn”

Giờ đến thực đơn pizza. Một feature là một công tắc có tên mà bạn định nghĩa trong Cargo.toml, trong bảng [features]. Hãy dựng một chương trình nhỏ, pizza_order, với hai feature:

  • extra_cheese: một phần thêm miễn phí, được bật mặc định.
  • json: in đơn hàng ra dạng JSON. Nó cần các crate serde và serde_json, nên chúng chỉ được tải về và biên dịch khi có người yêu cầu json.
[package]
name = "pizza_order"
version = "0.1.0"
edition = "2024"
[dependencies]
serde = { version = "1", features = ["derive"], optional = true }
serde_json = { version = "1", optional = true }
[features]
default = ["extra_cheese"]
extra_cheese = []
json = ["dep:serde", "dep:serde_json"]

Từng dòng một:

  • optional = true nghĩa là “đừng dùng crate này trừ khi có feature yêu cầu”.
  • extra_cheese = [] tạo một feature không bật thêm thứ gì khác. Nó chỉ là một công tắc để code của bạn kiểm tra.
  • json = ["dep:serde", "dep:serde_json"] nói rằng: khi json bật, hãy bật luôn hai dependency tuỳ chọn này. dep: nghĩa là “đây là một dependency, không phải tên feature”.
  • default = [...] liệt kê các feature được bật khi không ai nói gì khác.

Trong code, bạn kiểm tra một feature bằng feature = "name":

src/main.rs
#[cfg_attr(feature = "json", derive(serde::Serialize))]
struct Order {
size: String,
toppings: Vec<String>,
}
fn main() {
let mut toppings = vec!["tomato".to_string(), "basil".to_string()];
if cfg!(feature = "extra_cheese") {
toppings.push("extra cheese".to_string());
}
let order = Order { size: "large".to_string(), toppings };
println!("Order: {} pizza with {}", order.size, order.toppings.join(", "));
#[cfg(feature = "json")]
println!("As JSON: {}", serde_json::to_string(&order).unwrap());
}

Code của dự án mẫu này và kết quả bên dưới được giữ nguyên tiếng Anh, vì đó là kết quả thật khi chạy đúng đoạn code này. Dòng JSON dùng #[cfg], không phải cfg!. Không có feature json, serde_json thậm chí không phải là dependency, nên dòng đó phải biến mất hoàn toàn. Phép kiểm tra phô mai thì biên dịch được trong cả hai trường hợp, nên cfg! là đủ.

Giờ hãy gọi pizza từ dòng lệnh. (-q chỉ để ẩn các dòng “Compiling…” của Cargo.) Lần lượt: chỉ feature mặc định; mặc định cộng json; không có gì thêm, kể cả mặc định; và mọi feature:

Terminal window
cargo run -q # default features only
cargo run -q --features json # defaults + json
cargo run -q --no-default-features # nothing extra, not even the defaults
cargo run -q --all-features # every feature

Đây là kết quả thật:

$ cargo run -q
Order: large pizza with tomato, basil, extra cheese
$ cargo run -q --features json
Order: large pizza with tomato, basil, extra cheese
As JSON: {"size":"large","toppings":["tomato","basil","extra cheese"]}
$ cargo run -q --no-default-features
Order: large pizza with tomato, basil
$ cargo run -q --all-features
Order: large pizza with tomato, basil, extra cheese
As JSON: {"size":"large","toppings":["tomato","basil","extra cheese"]}

Bạn có thể kết hợp các cờ. --no-default-features --features json cho bạn JSON mà không có phô mai. Với nhiều feature, hãy ngăn cách bằng dấu phẩy: --features json,extra_cheese. Các cờ này cũng dùng được với cargo build, cargo check và cargo test.

Phần lớn thời gian, bạn đứng ở phía bên kia quầy: bạn dùng các crate có feature. Bạn đã thấy điều này ở trang Cargo và crate:

[dependencies]
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
serde_json = { version = "1", features = ["preserve_order"] }
  • Feature derive của serde cho bạn #[derive(Serialize, Deserialize)].
  • tokio không có feature mặc định nào. Tự nó gần như không cho bạn gì cả. full bật mọi thứ: runtime, #[tokio::main], bộ hẹn giờ, mạng, file và nhiều nữa. Lúc học thì vậy là tiện. Trong dự án thật, bạn có thể yêu cầu ít hơn, ví dụ features = ["rt-multi-thread", "macros"].
  • Feature preserve_order của serde_json giữ các khoá của object theo đúng thứ tự bạn chèn vào.

cargo add viết những dòng này giúp bạn: cargo add tokio --features full.

Vì sao các crate lại chia nhỏ như vậy? Vì mỗi phần bạn không dùng là phần code bạn không phải tải về, biên dịch hay phát hành. Một crate lớn như tokio sẽ làm mọi dự án nhỏ build chậm nếu lúc nào nó cũng biên dịch mọi thứ. Feature giữ cho thời gian biên dịch và kích thước file chạy nhỏ gọn.

Bạn cũng có thể tắt các feature mặc định của một crate và chỉ chọn thứ mình cần, bằng default-features = false:

[dependencies]
serde_json = { version = "1", default-features = false, features = ["alloc"] }

Để xem những feature nào đang bật trong dự án, hãy hỏi Cargo:

Terminal window
cargo tree -e features --features json --depth 1

Với pizza_order, lệnh này in ra:

pizza_order v0.1.0 (/home/ana/pizza_order)
├── serde feature "default"
├── serde feature "derive"
└── serde_json feature "default"

Không có --depth 1, bạn sẽ thấy cả cây, kể cả những feature mà các dependency của bạn bật trong dependency của chúng. Danh sách dài, nhưng đó là công cụ tốt nhất khi bạn thắc mắc “vì sao crate này lại được biên dịch?”.

Feature chỉ nên thêm vào. Bật một feature không bao giờ được xoá một hàm hay thay đổi việc mà một hàm có sẵn đang làm. Lý do là: nếu hai crate trong dự án của bạn cùng dùng serde_json, một crate yêu cầu preserve_order còn crate kia thì không, Cargo sẽ biên dịch serde_json một lần, với feature của cả hai được bật. Vì vậy mọi crate phải vẫn chạy đúng khi người khác bật thêm feature.

Tránh những feature không thể bật cùng nhau. Một thiết kế kiểu “feature postgres HOẶC feature sqlite, nhưng không bao giờ cả hai” sẽ hỏng ngay khi hai crate chọn hai cái khác nhau, và --all-features sẽ không còn chạy được. Hãy ưu tiên những feature có thể bật cùng lúc.

Ghi tài liệu cho các feature. Viết một danh sách ngắn trong README hoặc tài liệu của crate: tên feature, nó thêm gì, và nó có được bật mặc định không. Trên docs.rs, mỗi crate có một trang “Feature flags” (ví dụ của tokio, tiếng Anh), và nhiều crate đánh dấu các mục bằng dòng “Available on crate feature … only”.

Kiểm tra mọi tổ hợp trong CI. Code nằm sau một feature chỉ được biên dịch khi feature đó bật. Hãy thêm những lệnh sau vào CI để không có gì ẩn nấp ở đó:

Terminal window
cargo check --all-features
cargo check --no-default-features
cargo test --all-features

Với các crate lớn hơn, công cụ cargo-hack có thể kiểm tra riêng từng feature (cargo hack check --each-feature).

Gõ sai tên feature. Nếu bạn viết #[cfg(feature = "jsno")], code vẫn biên dịch được. Dòng JSON chỉ đơn giản là không bao giờ được in, kể cả với --features json. May mắn là Rust hiện nay sẽ cảnh báo bạn:

warning: unexpected `cfg` condition value: `jsno`
--> src/main.rs:17:11
|
17 | #[cfg(feature = "jsno")]
| ^^^^^^^^^^------
| |
| help: there is a expected value with a similar name: `"json"`
|
= note: expected values for `feature` are: `default`, `extra_cheese`, and `json`
= help: consider adding `jsno` as a feature in `Cargo.toml`
= note: `#[warn(unexpected_cfgs)]` on by default

Rustc báo giá trị jsno không nằm trong danh sách feature mong đợi, và gợi ý tên gần giống là "json". Đừng bỏ qua cảnh báo này. Trên dòng lệnh, Cargo còn khắt khe hơn: cargo run --features jsno dừng lại với error: the package 'pizza_order' does not contain this feature: jsno và gợi ý json.

Kiểm tra tên dependency thay vì tên feature. Vì có dep:serde, trong pizza_order không có feature nào tên là serde. Nếu bạn viết #[cfg_attr(feature = "serde", ...)], bạn nhận được cùng cảnh báo “unexpected cfg condition value”, lần này cho serde. Rồi cargo run --features json thất bại với E0277: “the trait bound Order: serde::Serialize is not satisfied” (Order không cài đặt serde::Serialize), vì derive chưa bao giờ được thêm vào. Hãy kiểm tra tên trong [features]: feature = "json".

Quên dep:. Một khi có feature nào viết dep:serde, bạn phải dùng dep: cho serde ở mọi chỗ. Thêm một feature mới yaml = ["serde"] và Cargo sẽ từ chối đọc file:

error: failed to parse manifest at `/home/ana/pizza_order/Cargo.toml`
Caused by:
feature `yaml` includes `serde`, but `serde` is an optional dependency without an implicit feature
Use `dep:serde` to enable the dependency.

Cargo báo feature yaml nhắc tới serde, nhưng serde là dependency tuỳ chọn không có feature ngầm định, và yêu cầu bạn dùng dep:serde. Cách sửa là yaml = ["dep:serde"]. Còn nếu bạn bỏ dep: ở mọi chỗ, Cargo lặng lẽ tạo một feature công khai cho mỗi dependency tuỳ chọn (ở đây --features serde sẽ chạy được). Đó là phong cách cũ. Dùng dep: giúp danh sách feature gọn gàng và có chủ đích.

Code không bao giờ được biên dịch. Giả sử bạn gõ sai serde_json::to_strng bên trong dòng #[cfg(feature = "json")]. cargo check báo mọi thứ đều ổn, vì dòng đó mặc định không được biên dịch. Chỉ cargo check --all-features mới tìm ra:

error[E0425]: cannot find function `to_strng` in crate `serde_json`
--> src/main.rs:18:41
|
18 | println!("As JSON: {}", serde_json::to_strng(&order).unwrap());
| ^^^^^^^^
|
help: a function with a similar name exists
|
18 | println!("As JSON: {}", serde_json::to_string(&order).unwrap());
| +

Rustc báo không tìm thấy hàm to_strng trong crate serde_json, và gợi ý to_string. Đây chính là lý do --all-features phải có trong CI.

1. Thư mục cấu hình cho mọi hệ điều hành

Phần tiêu đề “1. Thư mục cấu hình cho mọi hệ điều hành”

Hãy viết hàm config_dir() trả về nơi một ứng dụng ghi chú lưu thiết lập: "~/.config/notes" trên Linux, "~/Library/Application Support/notes" trên macOS, r"%APPDATA%\notes" trên Windows, và "./notes" trên mọi hệ thống khác. In kết quả trong main. Gợi ý: mỗi trường hợp một hàm với #[cfg(target_os = "...")], và not(any(...)) cho phương án dự phòng.

Xem lời giải
#[cfg(target_os = "linux")]
fn config_dir() -> &'static str {
"~/.config/notes"
}
#[cfg(target_os = "macos")]
fn config_dir() -> &'static str {
"~/Library/Application Support/notes"
}
#[cfg(target_os = "windows")]
fn config_dir() -> &'static str {
r"%APPDATA%\notes"
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
fn config_dir() -> &'static str {
"./notes"
}
fn main() {
println!("Thiết lập được lưu trong {}", config_dir());
}

Kết quả:

Thiết lập được lưu trong ~/.config/notes
▶ Tự chạy thử

Trên bất kỳ máy nào cũng chỉ đúng một trong bốn hàm còn lại, nên main luôn tìm thấy một config_dir. Playground chạy Linux, nên bạn thấy đường dẫn của Linux. Một ứng dụng thật sẽ dùng crate như dirs để tìm những thư mục này cho chuẩn.

Bạn có một chương trình nhỏ leaderboard in ra bảng điểm. Hãy thêm feature export (tắt theo mặc định) kéo theo serde_json và thêm hàm export_json. Với --features export, chương trình phải in thêm bảng điểm dưới dạng JSON. Gợi ý: cho serde_json thành tuỳ chọn, dùng dep:serde_json trong feature, và đặt #[cfg(feature = "export")] lên cả hàm lẫn dòng gọi nó. Playground không đặt được feature của Cargo, nên hãy thử bài này bằng cargo new leaderboard trên máy của bạn.

Xem lời giải
[package]
name = "leaderboard"
version = "0.1.0"
edition = "2024"
[dependencies]
serde_json = { version = "1", optional = true }
[features]
export = ["dep:serde_json"]
src/main.rs
struct Score {
name: &'static str,
points: u32,
}
#[cfg(feature = "export")]
fn export_json(scores: &[Score]) -> String {
let list: Vec<serde_json::Value> = scores
.iter()
.map(|s| serde_json::json!({ "name": s.name, "points": s.points }))
.collect();
serde_json::Value::Array(list).to_string()
}
fn main() {
let scores = [
Score { name: "Ana", points: 120 },
Score { name: "Bo", points: 95 },
];
for s in &scores {
println!("{}: {} points", s.name, s.points);
}
#[cfg(feature = "export")]
println!("Export: {}", export_json(&scores));
}
$ cargo run -q
Ana: 120 points
Bo: 95 points
$ cargo run -q --features export
Ana: 120 points
Bo: 95 points
Export: [{"name":"Ana","points":120},{"name":"Bo","points":95}]

Không có dòng default = [...], nên export tắt trừ khi bạn yêu cầu. Không có nó, serde_json thậm chí không được tải về. Cả hàm lẫn lời gọi đều cần #[cfg], vì mỗi chỗ đều nhắc tới serde_json.

  • #[cfg(condition)] chỉ giữ code khi điều kiện đúng. Nếu không, code bị xoá trước khi biên dịch.
  • cfg!(condition) chỉ là một bool. Cả hai nhánh vẫn phải biên dịch được trên mọi nền tảng.
  • Kết hợp điều kiện bằng not(..), any(..) và all(..). Những điều kiện thường gặp là target_os, unix/windows, debug_assertions và test.
  • #[cfg_attr(condition, attr)] chỉ thêm một thuộc tính khi điều kiện đúng.
  • Feature của Cargo nằm trong [features]. Dùng optional = true cùng dep:name cho crate tuỳ chọn, và default = [...] cho những gì bật mặc định.
  • Feature chỉ nên thêm vào. Chạy cargo check --all-features trong CI, và để ý các cảnh báo unexpected_cfgs.

Kiểm tra nhanh

1. Chuyện gì xảy ra với một hàm được đánh dấu #[cfg(target_os = "windows")] khi bạn build trên Linux?

2. Vì sao if cfg!(unix) { ... } là không đủ cho code dùng API chỉ có trên Unix?

3. Trong Cargo.toml, json = ["dep:serde_json"] làm gì?

4. Bạn lỡ viết #[cfg(feature = "jsno")]. Chuyện gì xảy ra?

Điểm: 0 / 4