Danh sách bài viết

Bài 281: Macro Hygiene — Namespace Clean

Bài 281 của series Rust Cơ Bản — bài cuối Group 34 Macros Cơ Bản, phân tích macro hygiene. Macro trong C/C++ qua tiền xử lý #define nổi tiếng với một lớp bug nguy hiểm: biến cục bộ trong macro va chạm tên với biến ở scope gọi, hoặc identifier bị evaluate nhiều lần gây side-effect bất ngờ. Rust giải bài toán này bằng macro hygiene — cơ chế bảo đảm identifier sinh ra từ macro nằm trong namespace riêng, không leak ra scope của caller, và ngược lại không bị shadow bởi identifier mà caller định nghĩa. Bài phân tích hygiene của macro_rules! qua các ví dụ thực tế: biến let temp nội bộ KHÔNG đụng temp ở caller, $x:ident capture từ caller VẪN tham chiếu đúng (semi-hygienic), nhưng proc-macro mặc định non-hygienic nên cần dùng full path ::std::option::Option trong macro body. Đây là kiến thức bản lề trước khi sang Group 35 Unsafe Rust.

10/06/2026
9 phút đọc
1 lượt xem
1

Mục Tiêu Bài Học

Sau bài học, bạn sẽ:

  • Hiểu khái niệm macro hygiene — identifier sinh từ macro có namespace riêng, không leak ra scope gọi.
  • Thấy được bug điển hình của #define trong C/C++ khi biến cục bộ trong macro đụng tên biến ở caller.
  • Viết ví dụ Rust chứng minh biến let temp trong macro_rules! KHÔNG xung đột với temp ở caller.
  • Phân biệt biến nội bộ (hygienic) với identifier capture qua $x:ident (semi-hygienic — vẫn tham chiếu đúng đến binding của caller).
  • Nhận diện limitation: proc-macro mặc định KHÔNG hygienic — biết cách phòng vệ bằng full path tuyệt đối ::std::option::Option.
  • Áp dụng best practice viết macro robust khi user shadow type stdlib hoặc thiếu use import.
2

Macro Hygiene Là Gì

Macro hygiene (vệ sinh macro) là thuộc tính của hệ thống macro đảm bảo identifier do macro tạo ra không va chạm với identifier ở scope nơi macro được gọi. Mỗi token sinh từ macro mang theo syntactic context (ngữ cảnh cú pháp) riêng — compiler dùng context này khi resolve tên, nên hai biến cùng tên nhưng khác context được xem là hai binding khác nhau.

Khái niệm này do Eugene Kohlbecker đặt ra cho Scheme năm 1986, sau đó được Rust kế thừa. Trong Rust, macro_rules! được thiết kế hygienic by default: viết let x = ... trong macro body không bao giờ "che" mất biến x mà caller đã có. Đây là khác biệt cốt lõi so với macro preprocessor C/C++ — nơi mọi expansion là text substitution thuần, không hiểu scope.

Hygiene có 2 chiều:

  • Identifier nội bộ KHÔNG leak ra caller. let temp trong macro body chỉ tồn tại trong expansion, biến temp của caller vẫn nguyên giá trị.
  • Identifier ở caller KHÔNG che mất identifier ở macro body. Caller định nghĩa biến cùng tên không ảnh hưởng đến logic bên trong macro.

Nhờ hygiene, người viết macro không phải đặt tên biến nội bộ kiểu __macro_internal_temp_xyz_123 chỉ để tránh đụng tên với caller — code macro sạch như viết function thường.

3

Vấn Đề Nếu KHÔNG Hygienic (C/C++)

Ví dụ kinh điển trong C — macro MIN tính giá trị nhỏ hơn giữa hai biểu thức:

// C — preprocessor #define, KHÔNG hygienic
#define MIN(a, b) ((a) < (b) ? (a) : (b))

int main(void) {
    int a = 5;
    int b = 3;
    int c = MIN(a, b);  // OK: c = 3

    // Bug 1: side effect chạy 2 lần
    int i = 0;
    int x = MIN(i++, 10);
    // Expand: ((i++) < (10) ? (i++) : (10))
    // i được increment 2 lần khi a < b!

    return 0;
}

Vì macro chỉ là text substitution, i++ bị viết lặp 2 lần trong expansion. Khi i < 10 đúng, branch true lại chạy i++ lần nữa — i tăng từ 0 thành 2 thay vì 1. Đây gọi là multiple evaluation — bug rất khó debug vì code gọi macro nhìn vô hại.

Vấn đề thứ hai — biến nội bộ va chạm tên:

// C — biến nội bộ "temp" leak ra caller
#define SWAP(a, b) do { \
    int temp = (a);     \
    (a) = (b);          \
    (b) = temp;         \
} while (0)

int main(void) {
    int x = 1, y = 2;
    SWAP(x, y);          // OK

    int temp = 99;
    int other = 5;
    SWAP(temp, other);   // Bug: macro shadow biến temp của caller
    // Expand: int temp = (temp); (temp) = (other); (other) = temp;
    // Compile error hoặc behavior sai
    return 0;
}

Khi caller có biến tên temp, macro SWAP(temp, other) expand ra code đụng tên — kết quả không như mong đợi. Cách tránh duy nhất trong C là name mangling thủ công (đặt tên xấu xí kiểu __swap_temp_xyz) hoặc dùng GCC extension __typeof__ với block — đều verbose và không bulletproof. Rust hygiene giải quyết tận gốc lớp bug này.

4

Rust Hygienic: Biến Trong Macro KHÔNG Leak

Viết lại tinh thần SWAP bằng Rust macro_rules! để thấy hygiene ở thực tế:

macro_rules! say_hi {
    ($name:expr) => {{
        let temp = format!("Hi, {}!", $name);
        println!("{}", temp);
    }};
}

fn main() {
    let temp = "outer scope temp value";
    say_hi!("Rust");
    // In ra: Hi, Rust!
    // Biến `temp` ở scope ngoài KHÔNG bị macro che mất
    println!("temp ngoài = {}", temp);
    // In ra: temp ngoài = outer scope temp value
}

Macro say_hi! khai báo let temp = ... bên trong. Caller cũng có biến temp riêng. Sau khi macro expand, hai temp tồn tại song song — compiler phân biệt qua syntactic context, không xung đột. Print biến temp ngoài vẫn ra giá trị gốc, hoàn toàn không bị nhiễm.

Nếu Rust không hygienic, biến temp trong macro body sẽ shadow biến temp của caller — đúng như bug C. Hygiene đảm bảo không cần nghĩ về tên biến nội bộ khi viết macro.

Ngược chiều: caller định nghĩa biến cùng tên với biến nội bộ macro cũng không ảnh hưởng logic macro:

macro_rules! double_and_print {
    ($x:expr) => {{
        let result = $x * 2;
        println!("{}", result);
    }};
}

fn main() {
    let result = "this is caller's result, a string";
    double_and_print!(21);  // In: 42
    // Macro tự dùng biến `result` của nó, không đụng `result` của caller
    println!("{}", result); // In: this is caller's result, a string
}

Caller có biến result kiểu &str; macro tự tạo result kiểu integer bên trong. Compile sạch, không cảnh báo. Đây là điều bất khả thi với macro C — vì C không có concept "context".

5

$x:ident Vẫn Hoạt Động (Semi-Hygienic)

Câu hỏi tự nhiên: nếu identifier hoàn toàn cô lập, làm sao macro nhận identifier từ caller và dùng đúng binding của caller? Đáp án — hygiene của macro_rules!semi-hygienic: identifier đi qua tham số macro (matched bởi $x:ident hoặc $x:expr) giữ nguyên context của caller, nên reference đúng binding ngoài.

macro_rules! increment {
    ($var:ident) => {
        $var += 1;
    };
}

fn main() {
    let mut counter = 10;
    increment!(counter);   // tham chiếu đúng `counter` ở caller
    println!("{}", counter); // In: 11
}

$var:ident bắt token counter từ caller. Khi expand thành counter += 1;, identifier counter mang context của caller, nên modify đúng biến counter caller định nghĩa. Đây là behavior cần thiết — không thì macro không bao giờ thao tác được trên biến caller.

Tổng kết quy tắc:

  • Identifier do macro tự tạo (vd let temp, let result) → context riêng → không leak.
  • Identifier truyền từ caller qua $x:ident → giữ context caller → tham chiếu đúng binding ngoài.

Sự phân biệt này dựa vào nguồn token: token gõ tay trong macro body có context macro, token đến từ argument có context caller. Compiler track context qua từng token nên không nhầm lẫn.

6

Limitations: Proc-Macro Non-Hygienic

Hygiene của macro_rules! built-in vào compiler. Với procedural macro (đã giới thiệu ở Bài 279, 280) câu chuyện khác — proc-macro sinh TokenStream trực tiếp, và mọi token mặc định mang Span::call_site() — tức context của caller. Hậu quả: mọi tên (kể cả Vec, Option, Result) được resolve trong scope caller, không scope macro.

Ví dụ anti-pattern thường gặp khi viết proc-macro thiếu kinh nghiệm:

// Trong proc-macro crate — anti-pattern
#[proc_macro]
pub fn make_vec(_input: TokenStream) -> TokenStream {
    // Sinh ra code: `Vec::new()`
    "Vec::new()".parse().unwrap()
}

Nhìn vô hại — nhưng caller có thể đã shadow Vec:

use my_macro::make_vec;

struct Vec;  // user định nghĩa Vec riêng, shadow std::vec::Vec

fn main() {
    let v = make_vec!();  // compile error: `Vec` không có method `new`
    // Hoặc tệ hơn: gọi vào struct Vec của user thay vì std::vec::Vec
}

Vì proc-macro emit token Vec::new() không hygienic, identifier Vec resolve trong scope caller — gặp struct Vec; của user là dính bug. Tương tự nếu caller thiếu use std::vec::Vec; hoặc đang ở module có type Vec = MyVec;.

Đây là lý do mọi tutorial proc-macro nghiêm túc đều khuyến nghị dùng full path tuyệt đối — kỹ thuật ở mục tiếp theo.

7

Best Practice: Full Path Trong Macro Body

Cách phòng vệ chuẩn: viết full path tuyệt đối bắt đầu bằng :: cho mọi type/function đến từ crate ngoài. Path tuyệt đối bypass quá trình name resolution trong scope caller, đảm bảo luôn trỏ đúng crate đích.

Fix ví dụ proc-macro ở trên:

#[proc_macro]
pub fn make_vec(_input: TokenStream) -> TokenStream {
    // Full path tuyệt đối — bypass mọi shadow ở caller
    "::std::vec::Vec::new()".parse().unwrap()
}

Bây giờ dù caller có struct Vec; hay quên use, expansion vẫn resolve về std::vec::Vec chuẩn — code generated chạy đúng.

Cùng nguyên tắc cho macro_rules! — dù hygienic về identifier nội bộ, đường dẫn type vẫn cần full path khi macro public:

// macro_rules! viết robust, dùng full path
macro_rules! ok_or_bail {
    ($expr:expr) => {
        match $expr {
            ::core::option::Option::Some(v) => v,
            ::core::option::Option::None => return,
        }
    };
}

fn main() {
    let opt = Some(42);
    let value = ok_or_bail!(opt);
    println!("{}", value);
}

Dùng ::core::option::Option (hoặc ::std::option::Option) thay vì Some/None trần. Nếu caller làm điều quái dị như enum Option { A, B } trong scope, macro vẫn tham chiếu đúng core::option::Option chuẩn.

Lưu ý: với crate dùng no_std, prefer ::core:: hơn ::std::. Nếu macro nằm trong crate mycrate và emit tên local, dùng $crate::path::to::Type — variable đặc biệt $crate luôn resolve về crate chứa macro.

// Trong crate `mycrate`
macro_rules! make_error {
    ($msg:expr) => {
        $crate::error::AppError::new($msg)
    };
}

Dù caller chưa use mycrate::error::AppError;, macro vẫn tự reference đúng vì $crate expand thành tên đầy đủ của crate gốc.

8

So Sánh Bảng: Rust vs C Macro

                       │ C/C++ #define       │ Rust macro_rules!   │ Rust proc-macro
───────────────────────┼─────────────────────┼─────────────────────┼─────────────────────
Cơ chế                 │ text substitution   │ token-tree match    │ TokenStream xử lý
Hygiene identifier     │ Không               │ Có (built-in)       │ Không (Span::call_site)
Biến nội bộ leak       │ Có — bug điển hình  │ Không leak          │ Có thể leak
Multiple evaluation    │ Có (i++ chạy 2 lần) │ Không (expand 1 lần)│ Không
Path resolution        │ N/A (textual)       │ Theo scope caller   │ Theo scope caller
Phòng vệ shadow type   │ Đặt tên xấu xí      │ ::core::Option::Some│ ::std::vec::Vec::new
Toolchain              │ preprocessor cpp    │ stable rustc        │ stable rustc + crate
Error message          │ Khó debug (textual) │ Tương đối tốt       │ Yêu cầu thư viện span

Bảng cho thấy macro_rules! là sweet spot — hygienic tự động, expand-once, error tương đối rõ. Proc-macro mạnh hơn nhưng đánh đổi hygiene, đòi hỏi developer chủ động dùng full path. C/C++ #define hoàn toàn không có safety net — vì vậy mọi project C lớn đều có guideline "tránh macro phức tạp, prefer inline function".

9

Tổng Kết

  • Hygiene = identifier sinh từ macro có namespace riêng, không leak ra caller và không bị caller shadow.
  • C/C++ #define không hygienic — biến nội bộ va chạm tên, side effect bị evaluate nhiều lần.
  • Rust macro_rules! hygienic built-in: let temp trong macro không đụng temp ở caller.
  • Semi-hygienic: identifier truyền qua $x:ident giữ context caller, tham chiếu đúng binding ngoài.
  • Proc-macro mặc định không hygienic — token emit ra mang Span::call_site(), resolve trong scope caller.
  • Best practice: full path tuyệt đối ::std::vec::Vec::new(), ::core::option::Option, hoặc $crate::path cho type local crate.
  • Phòng vệ chống user shadow stdlib hoặc thiếu use import — không tốn perf, chỉ tốn vài ký tự.
  • Đây là bài cuối Group 34 Macros Cơ Bản. Đã đi từ macro_rules! declarative, common macros, proc-macro overview, derive, đến hygiene.
10

Bài Tập Củng Cố

Tự trả lời, đáp án ở cuối:

  1. Giải thích khác biệt giữa "text substitution" của #define C/C++ và "token-tree match" của macro_rules! Rust. Vì sao khác biệt này dẫn đến chỗ một bên hygienic, một bên không?
  2. Viết macro Rust swap_via_temp!($a:ident, $b:ident) tương đương SWAP của C, dùng biến let temp nội bộ. Chứng minh bằng caller có biến temp riêng — macro vẫn chạy đúng và biến temp ngoài không bị đụng.
  3. Macro increment!($var:ident) ở mục 5 hoạt động được vì sao? Nếu Rust hygienic tuyệt đối, identifier truyền vào có còn tham chiếu đúng đến biến của caller không? Giải thích "semi-hygienic".
  4. Bạn viết proc-macro #[proc_macro] pub fn my_default(...) emit code Default::default(). User dùng macro trong scope đã use anyhow::Default; (giả định). Bug gì xảy ra? Fix bằng cách nào?
  5. Khi nào dùng ::std::..., khi nào dùng ::core::..., và khi nào dùng $crate::... trong macro body? Đưa 1 ví dụ cho mỗi trường hợp.
  6. Một junior dev cho rằng "Rust hygienic nên không cần dùng full path trong proc-macro". Phản biện lập luận này, dẫn chứng cụ thể bug có thể xảy ra.
Đáp án
  1. C/C++ #define chạy ở preprocessor — chỉ thay text trước khi compile, không hiểu syntax/scope. macro_rules! Rust match token tree (cấu trúc cú pháp đã parse), mỗi token mang syntactic context theo dõi từ đâu sinh ra. Vì compiler biết token nào đến từ macro body, token nào từ caller, nó có thể đặt chúng vào namespace riêng → hygiene. Preprocessor C không có khái niệm token có context — text substitution không phân biệt được.
  2. macro_rules! swap_via_temp {
        ($a:ident, $b:ident) => {{
            let temp = $a;
            $a = $b;
            $b = temp;
        }};
    }
    fn main() {
        let mut x = 1; let mut y = 2;
        let temp = 99;
        swap_via_temp!(x, y);
        println!("x={}, y={}, temp={}", x, y, temp);
        // In: x=2, y=1, temp=99
    }
    Biến temp trong macro có context macro, biến temp caller có context caller — hai binding tách biệt, không che nhau. Bug C đã bị Rust khử triệt để.
  3. Hoạt động được vì hygiene Rust là semi-hygienic: identifier nội bộ macro (do macro tự gõ) có context macro, nhưng identifier truyền qua tham số (matched bởi $var:ident) giữ nguyên context của caller. Khi $var được "bind" với token counter từ caller, token counter đó mang context caller — sau khi expand, counter += 1 reference đúng binding của caller. Nếu hygienic tuyệt đối, identifier truyền vào cũng bị "đổi context" và không tham chiếu được biến caller — macro không thao tác được trên data caller, mất tính hữu dụng.
  4. Bug: proc-macro emit token Default::default() mang Span::call_site() — identifier Default resolve trong scope caller. Caller có use anyhow::Default; (giả định) sẽ shadow ::core::default::Default chuẩn. Macro gọi sai trait — hoặc compile error, hoặc gọi vào trait khác nguy hiểm hơn. Fix: emit full path ::core::default::Default::default() — path tuyệt đối bắt đầu bằng :: bypass name resolution trong scope caller, luôn resolve về core crate chuẩn.
  5. ::std::... cho project std (default) — vd ::std::vec::Vec::new(). ::core::... cho macro muốn tương thích no_std environment (embedded, kernel) — vd ::core::option::Option::None. $crate::... cho type/function nằm chính trong crate chứa macro — vd $crate::error::AppError::new(...) đảm bảo reference đúng crate gốc dù caller chưa use mycrate::.... Quy tắc: ưu tiên ::core:: hơn ::std:: khi tương thích, và $crate bắt buộc cho symbol nội crate.
  6. Phản biện: hygiene của macro_rules! chỉ áp dụng cho identifier nội bộ (biến do macro khai báo) — KHÔNG áp dụng cho path resolution. Còn proc-macro thì mặc định emit token với Span::call_site() — hoàn toàn không hygienic. Bug cụ thể: caller có struct Vec; riêng, hoặc type Option = MyOption;, hoặc thiếu use std::vec::Vec; trong scope module → mọi Vec::new() hay Option::Some(x) không-full-path trong expansion sẽ resolve sai. Real-world: serde, tokio, thiserror đều dùng full path tuyệt đối trong proc-macro generated code chính vì lý do này — không phải "thừa", là phòng vệ thiết yếu.
11

Bài Tiếp Theo

Đây là bài cuối Group 34 Macros Cơ Bản. Bạn đã đi từ macro_rules! declarative, so sánh với functions, common macros built-in (println!, vec!, format!, dbg!, assert!), procedural macros 3 loại, derive macro example với serde, đến hygiene của bài này. Đủ nền tảng để đọc hiểu code Rust phổ thông dùng nhiều macro, và viết được macro đơn giản phục vụ tự động hoá nội bộ.

Group tiếp theo (Group 35: Unsafe Rust) bước sang một chủ đề nhạy cảm: unsafe block — cơ chế cho phép tạm tắt một số kiểm tra của borrow checker để làm việc với raw pointer, FFI, hoặc low-level data structure.

Bài 282: Tại Sao Có unsafe — 5 Superpower — giải thích 5 quyền mà unsafe mở (deref raw pointer, call unsafe fn, access static mut, impl unsafe trait, access union field), điểm quan trọng là borrow checker và type checker vẫn hoạt động trong unsafe block, và motto "minimize unsafe surface" — chỉ unsafe khi không có cách safe tương đương.