</>PatchNote
목록으로

2024-09-22

HSPACE Rust 특강 #10 — 매크로

RustHSPACE 2024MacrosMetaprogramming

C 매크로에 데어본 사람이라면 "매크로"라는 단어부터 경계하게 된다. Rust의 매크로는 텍스트 치환이 아니라 토큰 트리를 다루는 문법 확장이라 성격이 완전히 다르다. 이번 회차는 그 차이를 확인하는 시간이었다.

매크로의 기초

println! 처럼 ! 가 붙은 건 전부 매크로다. 왜 함수가 아니라 매크로여야 할까?

println!("{}, {}", a, b);
println!("{} {} {}", x, y, z);

인자 개수가 가변이고, 포맷 문자열을 컴파일 시점에 검사해야 하기 때문이다. Rust 함수는 가변 인자를 지원하지 않으므로 이건 매크로여야만 한다. 그리고 매크로라서 println!("{}", ) 처럼 인자가 모자라면 컴파일 에러가 난다. C의 printf 에서 런타임에 터지던 문제가 컴파일 시점으로 옮겨온 것이다.

정의는 macro_rules! 로 한다.

macro_rules! my_vec {
    () => {
        Vec::new()
    };
    ($($x:expr),*) => {
        {
            let mut v = Vec::new();
            $( v.push($x); )*
            v
        }
    };
}

match 와 닮았다. 다만 매칭 대상이 값이 아니라 토큰이다.

매크로 전개

매크로는 컴파일 초기에 전개된다. 그리고 전개 결과가 문법적으로 올바른 Rust여야 한다. C 매크로처럼 아무 텍스트나 만들어낼 수 없다.

assert_eq!(gcd(6, 10), 2);

// 대략 이렇게 전개된다
match (&gcd(6, 10), &2) {
    (left_val, right_val) => {
        if !(*left_val == *right_val) {
            panic!("assertion failed: `(left == right)` ...");
        }
    }
}

& 로 빌려서 매칭하는 게 중요하다. 값을 이동시키지 않으므로 assert_eq! 이후에도 피연산자를 쓸 수 있다.

전개 결과를 직접 보고 싶으면 cargo expand 를 쓰거나, 앞서 소개한 Rust Playground의 Expand macros 를 쓴다. 매크로가 뜻대로 안 나올 때는 이걸 보는 게 제일 빠르다.

의도치 않은 결과

강의에서 이 부분을 따로 다룬 게 좋았다. 매크로에는 함정이 있다.

1. 인자가 여러 번 평가될 수 있다

macro_rules! max {
    ($a:expr, $b:expr) => {
        if $a > $b { $a } else { $b }
    };
}

max!(expensive_call(), 0)   // expensive_call()이 두 번 실행될 수 있다

C 매크로의 고전적 문제가 여기서도 그대로다. 해결책은 임시 변수에 한 번 묶는 것이다.

macro_rules! max {
    ($a:expr, $b:expr) => {{
        let a = $a;
        let b = $b;
        if a > b { a } else { b }
    }};
}

2. 연산자 우선순위

$a:expr 로 캡처한 것은 하나의 표현식 노드로 취급되므로, C에서처럼 괄호를 덕지덕지 칠 필요가 없다. max!(1 + 2, 3)if 1 + 2 > 3 로 잘못 결합되지 않는다. 토큰이 아니라 파싱된 표현식을 다루기 때문이다. 이 점은 C 매크로보다 확실히 낫다.

3. 블록과 세미콜론

매크로 본문에서 {{ }} 를 쓰는 이유가 여기 있다. 바깥 중괄호는 매크로 본문의 구분자이고, 안쪽 중괄호가 실제로 생성되는 블록 표현식이다.

반복

매크로의 진짜 힘은 반복 문법에 있다.

$( ... )*      // 0회 이상
$( ... )+      // 1회 이상
$( ... )?      // 0회 또는 1회
$( ... ),*     // 쉼표로 구분해 0회 이상

첫 회차 예제에 있던 이 매크로가 좋은 예다.

macro_rules! impl_from_num_for_json {
    ( $( $t:ident )* ) => {
        $(
            impl From<$t> for Json {
                fn from(arg: $t) -> Json {
                    Json::Number(arg as f64)
                }
            }
        )*
    };
}

impl_from_num_for_json!(u8 i8 u16 i16 u32 i32 u64 i64 usize isize f32 f64);

12개 타입에 대한 impl 블록을 한 줄로 생성한다. 손으로 쓰면 12번 복사·붙여넣기고, 새 타입을 추가할 때 빠뜨리기 쉽다. 보일러플레이트 제거가 매크로의 가장 정직한 용도다.

프래그먼트 타입

$name:kind 에서 kind 가 프래그먼트 타입이고, 무엇을 캡처할지 정한다.

프래그먼트매칭 대상
expr표현식2 + 2, f(x)
ident식별자foo, u32
ty타입Vec<String>, i32
pat패턴Some(x), _
literal리터럴42, "hi"
block블록{ ... }
stmt문장let x = 1
item항목fn f() {}, struct S;
path경로std::io::Read
tt토큰 트리 하나뭐든지

tt 가 가장 유연하지만 그만큼 구조 정보가 없다. 재귀 매크로에서 주로 쓴다.

프래그먼트 타입을 잘못 고르면 이상한 에러가 난다. 예를 들어 $t:expr 로 받은 것은 이미 표현식 노드라서 그 안을 다시 매칭할 수 없다. 이럴 때 tt 로 받아 재귀하는 패턴을 쓴다.

재귀

매크로는 자기 자신을 부를 수 있다.

macro_rules! count {
    () => { 0 };
    ($head:tt $($tail:tt)*) => { 1 + count!($($tail)*) };
}

기본 재귀 한도는 128단계다. 넘으면 #![recursion_limit = "256"] 로 올릴 수 있다.

json! 매크로 만들기

이번 회차의 하이라이트. JSON 리터럴을 Rust 코드에 그대로 쓸 수 있게 하는 매크로다.

#[derive(Clone, PartialEq, Debug)]
enum Json {
    Null,
    Boolean(bool),
    Number(f64),
    String(String),
    Array(Vec<Json>),
    Object(Box<HashMap<String, Json>>),
}
#[macro_export]
macro_rules! json {
    (null) => {
        $crate::Json::Null
    };
    ([ $( $element:tt ),* ]) => {
        $crate::Json::Array(vec![ $( json!($element) ),* ])
    };
    ({ $( $key:tt : $value:tt ),* }) => {
        {
            let mut fields = $crate::macros::Box::new($crate::macros::HashMap::new());
            $(
                fields.insert($crate::macros::ToString::to_string($key), json!($value));
            )*
            $crate::Json::Object(fields)
        }
    };
    ($other:tt) => {
        $crate::Json::from($other)
    };
}

이걸로 이렇게 쓸 수 있다.

let value = json!({
    "math_works": (4 - 2 == 2),
    "en": HELLO,
    HELLO: "bonjour!"
});

배열과 객체가 재귀적으로 처리되는 게 핵심이다. json!([...]) 안의 각 원소에 다시 json! 을 부르므로 중첩이 얼마든 깊어져도 된다. 그리고 마지막 규칙 ($other:tt) => { Json::from($other) } 덕에 숫자·문자열·불리언이 From 구현으로 자동 변환된다 — 그 From 구현을 아까 그 반복 매크로가 만들어줬다.

여기서 왜 $element:tt 이지 $element:expr 가 아닌지가 중요하다. expr 로 받으면 [1, 2] 같은 배열 리터럴이 하나의 표현식으로 굳어버려서 안을 다시 매칭할 수 없다. tt 로 받아야 재귀적으로 열어볼 수 있다.

범위 한정과 위생

위생(hygiene) 은 Rust 매크로가 C 매크로와 가장 다른 지점이다.

macro_rules! using_a {
    ($e:expr) => {
        {
            let a = 42;
            $e
        }
    };
}

let a = 10;
let x = using_a!(a + 1);   // 11이다. 43이 아니다!

매크로 안의 a 와 호출자의 a서로 다른 변수로 취급된다. 컴파일러가 식별자마다 문법적 문맥(syntax context)을 기록하기 때문이다. C 매크로에서 임시 변수 이름을 __tmp_xyz_123 처럼 짓던 관행이 필요 없어진다.

다만 위생은 지역 변수 이름에만 적용된다. 타입, 함수, 매크로 이름 같은 경로는 호출자 문맥에서 해석된다. 그래서 매크로가 라이브러리 밖에서 쓰일 때 문제가 생긴다.

macro_rules! bad_json {
    (null) => { Json::Null };   // 사용자 코드에 Json이 import되어 있어야 한다
}

해결책이 $crate 다.

macro_rules! json {
    (null) => { $crate::Json::Null };   // 항상 이 크레이트의 Json을 가리킨다
}

$crate 는 "이 매크로가 정의된 크레이트"로 전개된다. 그래서 json! 예제에서 $crate::macros::Box, $crate::macros::ToString 처럼 표준 타입까지 경로로 적고, macros 모듈에서 그것들을 pub use 해두는 것이다.

pub use std::boxed::Box;
pub use std::collections::HashMap;
pub use std::string::ToString;

처음엔 과하다고 생각했는데, 사용자가 Box 라는 이름의 자기 타입을 정의했을 수도 있다는 걸 생각하면 필요한 방어다.

매크로 가져오기와 내보내기

#[macro_export] 를 붙이면 매크로가 크레이트 루트에 놓인다.

#[macro_export]
macro_rules! json { /* ... */ }

사용자는 use my_crate::json; 으로 가져온다. 매크로는 모듈 계층과 다르게 동작한다는 점을 기억해야 한다.

같은 크레이트 안에서는 정의가 사용보다 먼저 나와야 한다. 함수와 달리 매크로는 텍스트 순서를 탄다. 그래서 mod macros;lib.rs 맨 위에 두는 관례가 있다.

매크로와 트레잇 함께 쓰기

매크로로 트레잇 구현을 대량 생성하는 패턴은 표준 라이브러리도 쓴다. 앞의 impl_from_num_for_json! 이 그 예다.

다만 강의에서 짚은 균형점이 있다. derive 로 되는 건 derive 로 하고, 제네릭으로 되는 건 제네릭으로 하라. 매크로는 그 둘로 안 되는 경우 — 문법 자체를 바꾸고 싶을 때 — 에 쓴다. 매크로가 늘어나면 에러 메시지가 나빠지고, IDE 지원이 떨어지고, 읽는 사람이 전개 결과를 상상해야 한다.

매칭 중에 발생하는 구문 오류 피하기

매크로 규칙은 위에서부터 순서대로 시도되는데, 한 규칙이 매칭에 실패하면 다음으로 넘어간다. 문제는 매칭에 성공한 뒤 전개 결과가 잘못된 경우다. 이때는 다음 규칙을 시도하지 않고 그냥 에러가 난다.

그래서 규칙 순서가 중요하다. 구체적인 패턴을 먼저, 일반적인 패턴을 나중에 둔다. json! 에서 (null), ([...]), ({...}) 를 먼저 두고 ($other:tt) 를 마지막에 두는 이유다. $other:tt 를 위에 두면 모든 걸 다 삼켜버린다.

macro_rules!에서 더 나아가기

macro_rules!(선언적 매크로)로 안 되는 것들이 있다. 그때는 절차적 매크로를 쓴다. 이건 별도의 크레이트에서 TokenStream 을 받아 TokenStream 을 돌려주는 함수를 작성하는 방식이라, 임의의 Rust 코드로 분석과 생성을 할 수 있다.

  • 파생 매크로#[derive(Serialize)] 같은 것. serde 가 대표적이다
  • 속성 매크로#[tokio::main], #[test] 처럼 항목을 변형
  • 함수형 매크로sqlx::query! 처럼 호출 형태지만 절차적으로 처리

synquote 크레이트를 쓰면 파싱과 코드 생성이 훨씬 편하다. 오류 처리 회차에서 쓴 thiserror#[derive(Error)] 도 절차적 매크로다.

과제

세 문제 모두 macro_rules! 로 문법을 만드는 과제였다.

1. hashmap!vec!HashMap 판.

#[macro_export]
macro_rules! hashmap {
    () => {
        ::std::collections::HashMap::new()
    };
    ( $( $key:expr => $value:expr ),* $(,)? ) => {
        {
            let mut map = ::std::collections::HashMap::new();
            $(
                map.insert($key, $value);
            )+
            map
        }
    };
}
let map = hashmap!(1 => "one", 2 => "two");
let map = hashmap!(1 => "one", 2 => "two",);   // 후행 쉼표도 OK
let empty: HashMap<u32, u32> = hashmap!();

$(,)? 하나로 후행 쉼표를 선택적으로 허용하는 게 포인트다. 이게 없으면 hashmap!(1 => "one",) 이 컴파일되지 않는다. 실제 vec! 도 같은 기법을 쓴다.

이 과제에서 좋았던 건 src/invalid/ 아래 실패해야 하는 케이스들이 함께 주어진다는 점이었다 — no-comma.rs, double-commas.rs, only-arrow.rs, missing-argument.rs 등. 매크로를 만들 때 "무엇을 받아들이는가"만큼 "무엇을 거부하는가" 도 설계해야 한다는 걸 알려주는 구성이다.

2. for_2d! — 2중 for 루프를 하나의 문법으로.

macro_rules! for_2d {
    ($x_name:ident <$x_type:ty> in $x_expr:expr,
     $y_name:ident <$y_type:ty> in $y_expr:expr,
     $block:block) => {
        for $x_name in $x_expr {
            let $x_name: $x_type = $x_name;
            for $y_name in $y_expr {
                let $y_name: $y_type = $y_name;
                $block
            }
        }
    };
}
for_2d!(row <i32> in 1..5, col <i32> in 2..7, {
    (Coordinate { x: col, y: row }).show()
});

let values = [1, 3, 5];
for_2d!(x <u16> in values, y <u16> in values, {
    (Coordinate { x: x.into(), y: y.into() }).show()
});

ident, ty, expr, block 네 가지 프래그먼트 타입이 한 규칙에 다 나온다. 그리고 <i32> 같은 건 Rust 문법에 없는 형태인데, 매크로가 자기만의 문법을 정의할 수 있다는 걸 보여준다.

let $x_name: $x_type = $x_name; 이 재미있다. 루프 변수를 같은 이름으로 다시 묶으면서 타입을 못 박는다. 앞 회차에서 배운 섀도잉을 매크로 안에서 쓰는 것이다.

3. graph! — 인접 리스트 문법.

macro_rules! graph {
    ( $($from:literal -> ( $( $to:literal),* );)* ) => {
        {
            let mut vec = Vec::new();
            $( $(vec.push(($from, $to));)* )*
            vec
        }
    }
}
let my_graph = graph!(
    1 -> (2, 3, 4, 5);
    2 -> (1, 3);
    3 -> (2);
    4 -> ();
    5 -> (1, 2, 3);
);

중첩 반복이 핵심이다. 바깥 $( ... ;)* 가 각 줄을, 안쪽 $( $to:literal ),* 가 한 줄의 목적지들을 받는다. 전개할 때도 $( $(...)* )* 로 두 겹을 다시 편다. 4 -> () 처럼 안쪽이 0개여도 잘 동작한다.

->; 는 Rust 문법에서 다른 뜻을 가진 토큰인데, 매크로 패턴에서는 그냥 구분자 토큰으로 쓰인다. 매크로가 다루는 게 의미가 아니라 토큰 트리라는 걸 확실히 보여주는 부분이었다.

정리

  • Rust 매크로는 텍스트가 아니라 토큰 트리를 다뤄서 우선순위 문제가 없다
  • 프래그먼트 타입이 무엇을 캡처할지 정한다. 재귀하려면 tt
  • 반복($(...)*)과 중첩 반복으로 보일러플레이트를 없앤다
  • 위생 덕에 지역 변수 이름이 충돌하지 않는다. 다만 경로에는 $crate 가 필요
  • 규칙은 순서대로 매칭되므로 구체적인 것부터 쓴다
  • macro_rules! 로 안 되면 절차적 매크로

다음 편은 비동기 프로그래밍 Part 1 — 퓨처, async/await, 그리고 스레드와의 차이.