매크로는 소스 줄 수를 줄이지만 프로그램을 항상 단순하게 만들지는 않습니다. 함수보다 읽고 추적하기 어려운 간접 계층이 생기기 때문입니다. 반복되는 대상이 런타임 연산이라면 함수나 이터레이터가 먼저입니다. Rust 구문이나 항목 구조를 반복 생성해야 할 때에만 작은 매크로를 검토하는 편이 낫습니다.
이 글은 Rust 2024 edition과 rustc·Cargo 1.98.1을 기준으로 합니다. 외부 크레이트 없이 Endpoint Monitor의 상수 세 개와 레지스트리 하나를 같은 선언에서 만드는 macro_rules! 매크로를 살펴봅니다.
1. 함수로 충분한가
런타임 값으로 엔드포인트 하나를 만드는 일에는 매크로가 필요하지 않습니다. 예제의 Endpoint::new와 format_runtime_endpoint처럼 입력을 받아 값을 돌려주는 함수가 타입 검사, 제어 흐름, 도구 지원을 그대로 드러냅니다.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Endpoint {
pub name: &'static str,
pub url: &'static str,
}
impl Endpoint {
pub const fn new(name: &'static str, url: &'static str) -> Self {
Self { name, url }
}
}
pub fn format_runtime_endpoint(name: &str, url: &str) -> String {
format!("{name} -> {url}")
}
컴파일 시점의 고정 목록에서 이름 있는 pub const 항목과 ALL_ENDPOINTS를 함께 만들어야 한다면 조건이 달라집니다. 함수는 값을 만들 수 있어도 HOME, HEALTH, METRICS 같은 식별자나 모듈 항목을 새로 만들 수 없습니다. 함수의 이 한계 때문에 여기서는 좁은 항목 매크로를 사용합니다. 이름 있는 상수가 필요 없다면 런타임 슬라이스와 생성자만 두는 설계가 더 분명합니다.
2. 작은 macro_rules! 매크로
예제의 매크로 정의는 다음과 같습니다.
macro_rules! define_endpoints {
($( $const_name:ident => ($label:literal, $url:literal) ),+ $(,)?) => {
$(
pub const $const_name: Endpoint = Endpoint::new($label, $url);
)+
pub const ALL_ENDPOINTS: &[Endpoint] = &[
$( $const_name, )+
];
};
}
=> 왼쪽은 matcher, 오른쪽 블록은 transcriber입니다. $const_name:ident는 식별자 구문을, $label:literal과 $url:literal은 리터럴 구문을 받습니다. fragment specifier는 런타임 타입이 아닙니다. $url:literal이 &str 타입의 값을 뜻하는 것이 아니라 이 자리에 리터럴 구문을 받겠다는 뜻이며 타입의 유효성은 확장 뒤에 검사됩니다. 완전한 표현식을 받는 자리에는 expr을 쓸 수 있고 item과 ty도 각각 항목과 타입이라는 구문 범주를 나타냅니다.
$( ... ),+에서 +는 쉼표로 구분된 항목이 하나 이상 필요하다는 뜻입니다. 끝의 $(,)?는 마지막 쉼표를 선택적으로 허용합니다. matcher는 토큰을 구문대로 맞추며 모호성을 풀기 위한 임의의 선행 탐색을 하지 않습니다. 식별자, 리터럴, 쉼표, =>만 쓰는 호출 형태를 유지한 이유입니다.
호출은 작은 정적 테이블처럼 읽힙니다.
use crate::Endpoint;
define_endpoints! {
HOME => ("homepage", "https://example.com/"),
HEALTH => ("health", "https://example.com/health"),
METRICS => ("metrics", "https://example.com/metrics"),
}
컴파일러는 크레이트 AST를 만드는 동안 매크로 호출을 해석하고 확장합니다. 확장된 AST를 HIR로 낮춘 뒤 타입 추론, trait solving, 타입 검사를 진행합니다. 별도의 텍스트 전처리기가 먼저 도는 방식은 아닙니다. 이름 해석과 확장은 서로 맞물리고 출력 토큰은 다시 AST 조각으로 파싱됩니다.
3. 확장 결과를 정직하게 읽기
stable rustc 1.98.1과 Cargo에는 완전히 확장된 Rust 소스를 출력하는 문서화된 안정 명령이 없습니다. cargo rustc가 마지막 컴파일러 호출에 인자를 전달할 수는 있지만 -Zunpretty=expanded를 전달한다고 unstable rustc 옵션이 stable 기능으로 바뀌지는 않습니다.
예제는 대신 별도 모듈에 사람이 관리하는 등가 전사를 둡니다. 아래 코드는 검사와 테스트를 위한 hand-maintained equivalent transcription이며 컴파일러가 출력한 expansion text가 아닙니다.
//! This is a hand-maintained equivalent transcription for inspection and testing.
//! It is not compiler-emitted expansion text.
use crate::Endpoint;
pub const HOME: Endpoint = Endpoint::new("homepage", "https://example.com/");
pub const HEALTH: Endpoint = Endpoint::new("health", "https://example.com/health");
pub const METRICS: Endpoint = Endpoint::new("metrics", "https://example.com/metrics");
pub const ALL_ENDPOINTS: &[Endpoint] = &[HOME, HEALTH, METRICS];
호출의 각 행이 상수 하나가 되고 같은 식별자가 레지스트리 슬라이스에 들어가는지 나란히 검토할 수 있습니다. parity 테스트는 매크로 모듈과 명시적 모듈의 상수와 슬라이스를 값 단위로 비교합니다. 두 표현에 같은 순서, 라벨, URL, 길이 불변식도 적용합니다. 이 검사가 확인하는 범위는 관찰 가능한 값의 등가성까지입니다. rustc 내부 AST, hygiene context, 컴파일러가 만드는 지원 코드의 텍스트 동일성은 증명하지 않습니다.
cargo-expand 1.0.126은 필요할 때만 고려할 수 있는 제3자 디버깅 보조 도구입니다. 이 버전은 cargo rustc --profile=check -- -Zunpretty=expanded를 감싸고 구현에서 RUSTC_BOOTSTRAP=1을 설정합니다. 텍스트 변환이 손실을 일으키므로 결과가 컴파일되거나 같은 동작을 보장하는 소스는 아닙니다. 따라서 이 글의 stable 명령에서는 설치하거나 실행하지 않습니다.
4. 문맥·위생·진단
매크로 호출은 놓인 문맥에 맞는 구문으로 확장되어야 합니다. vec!는 표현식이 필요한 자리에서 표현식으로 확장되는 익숙한 예입니다. define_endpoints!는 모듈 범위의 항목 문맥에서 pub const 항목들을 만듭니다.
macro_rules!는 mixed-site hygiene를 사용합니다. 지역 변수와 label은 정의 위치를 따르고 그 밖의 많은 이름은 호출 위치를 따릅니다. hygiene가 모든 이름 충돌을 막아 주는 것은 아닙니다. 호출자가 건넨 HOME 같은 공개 항목 이름은 주변 모듈에 의도적으로 들어가므로 기존 이름과 충돌할 수 있습니다.
$crate는 매크로를 정의한 크레이트를 가리키며 export한 매크로가 호출 위치에 import되지 않은 helper를 $crate::path::Helper처럼 찾을 때 쓸 수 있습니다. 가시성 규칙을 우회하지는 않습니다. 이 예제의 매크로는 한 모듈에서만 비공개로 쓰고 Endpoint를 호출 위치에서 해석하므로 $crate와 #[macro_export]가 필요 없습니다.
matcher가 =>를 기대하는 자리에 쉼표를 넣으면 실제 rustc 1.98.1 진단은 다음과 같습니다.
include!("../../src/endpoint_macro.rs");
struct Endpoint;
impl Endpoint {
const fn new(_name: &'static str, _url: &'static str) -> Self {
Self
}
}
define_endpoints! {
HOME, ("homepage", "https://example.com/"),
}
error: no rules expected `,`
--> tests/compile_fail/bad_separator.rs:12:9
|
12 | HOME, ("homepage", "https://example.com/"),
| ^ no rules expected this token in macro call
|
::: tests/compile_fail/../../src/endpoint_macro.rs:1:1
|
1 | macro_rules! define_endpoints {
| ----------------------------- when calling this macro
|
note: while trying to match `=>`
--> tests/compile_fail/../../src/endpoint_macro.rs:2:27
|
2 | ($( $const_name:ident => ($label:literal, $url:literal) ),+ $(,)?) => {
| ^^
error: aborting due to 1 previous error
예상하지 않은 쉼표를 호출 줄에서 가리키고 매크로 정의에서 맞추려던 =>도 함께 보여 줍니다. 다만 모든 매크로 오류가 호출 위치 하나만 가리킨다고 일반화하면 안 됩니다. rustc 진단은 macro invocation span과 선택적인 definition-site span을 포함하는 expansion 정보를 보존할 수 있으며 생성 코드의 오류에는 확장 출처 note가 붙을 수 있습니다.
5. 함수·매크로·derive 선택
| 필요한 일 | 먼저 고를 도구 | Endpoint Monitor에서의 적용 |
|---|---|---|
| 런타임 값에 같은 연산 적용 | 함수, 이터레이터, generic, trait | Endpoint::new와 설정 목록 순회 |
| 고정 목록에서 Rust 항목 반복 생성 | 작은 선언적 매크로 | 이름 있는 상수와 ALL_ENDPOINTS 동기화 |
| 타입 구조로 표준 trait 구현 | 기존 #[derive(...)] |
Endpoint의 Debug, Clone, Copy, PartialEq, Eq |
| 사용자 구문·검증·impl 생성 | 이득을 확인한 뒤 procedural macro | 이 예제의 범위 밖 |
derive는 타입의 필드나 variant에서 기계적으로 정해지는 trait 구현에 적합합니다. 상수 선언 목록을 생성하는 문제와는 다릅니다. 이미 있는 표준 derive로 계약을 표현할 수 있다면 custom derive를 만들 필요가 없습니다.
함수형, custom derive, attribute procedural macro는 컴파일 시점에 token stream을 받아 token stream을 만듭니다. 별도의 proc-macro 크레이트에 정의해야 하며 정의한 크레이트 안에서는 사용할 수 없습니다. proc macro는 unhygienic하므로 작성자가 경로와 생성 이름을 조심해야 합니다. 이 특성을 mixed-site hygiene를 쓰는 macro_rules!까지 확대해서 설명해서는 안 됩니다. 이번 예제에 procedural macro를 도입하면 별도 크레이트, token parser, 생성 진단, 추가 테스트가 필요하지만 얻는 교육 효과는 작습니다.
6. 보이는 반복만 제거하기
프로젝트 디렉터리에서 실행하는 전체 검증 명령은 다음과 같습니다. 마지막 rustc 명령은 의도한 matcher 오류로 종료 코드가 0이 아니어야 정상입니다.
cd examples/article-20-macros
cargo fmt --all -- --check
cargo check --all-targets --all-features
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
cargo run --quiet
rustc --edition=2024 --crate-type=lib tests/compile_fail/bad_separator.rs
Rust 1.98.1과 Cargo 1.98.1에서 다섯 Cargo 명령은 종료 코드 0을 반환해야 합니다. compile-fail 검사를 포함하며 parity 파일 안의 테스트 두 개를 합치면 테스트 함수는 모두 6개입니다. 실행 출력은 다음과 같이 고정됩니다.
endpoint: homepage https://example.com/
endpoint: health https://example.com/health
endpoint: metrics https://example.com/metrics
이 매크로가 감추는 표면은 상수 세 개와 슬라이스 하나뿐입니다. 호출부는 여전히 표처럼 읽히고 생성 결과는 명시적 전사와 parity 테스트로 확인할 수 있습니다. 이름 있는 항목과 레지스트리를 한 선언에서 동기화할 필요가 사라지면 매크로도 걷어내고 함수와 데이터로 돌아가는 편이 낫습니다. 짧은 소스보다 보이는 구조를 우선하는 기준입니다.
전체 소스 코드
이 글의 전체 실행 가능한 소스는 GitHub의 Chapter 20 프로젝트에서 확인할 수 있습니다.
출처
- The Rust Programming Language 1.98.1: Macros
- The Rust Reference 1.98.1: Macros by Example
- The Rust Reference 1.98.1: Macros
- The Rust Reference 1.98.1: Procedural Macros
- The Rust Reference 1.98.1: Name Resolution
- Rust Compiler Development Guide: Macro Expansion
- Rust Compiler Development Guide: Overview of the Compiler
- The Rust Programming Language 1.98.1: Nightly Rust
- The Cargo Book 1.98.1: cargo rustc
- The rustc book 1.98.1: JSON Output
- cargo-expand 1.0.126 README
- cargo-expand 1.0.126 source
답글 남기기