Devin.KR

선언형 매크로 - macro_rules! 로 반복 줄이기

개발자KR 조회 0

이 장에서 배우는 것

앞 장까지는 타입 시스템 안에서 중복을 줄이는 방법을 다뤘다. 제네릭과 트레이트는 타입이 달라도 같은 코드를 쓰게 해 준다. 그러나 호출 모양 자체가 반복되는 경우가 있다. 이름만 다른 열거형 변형을 나열하고, 같은 insert 호출을 여러 번 적고, 조건이 틀리면 곧바로 return Err(...) 하는 식이다. 이런 반복은 함수로 묶기 어렵다. 이 장에서는 코드 조각을 입력받아 코드 조각을 내놓는 선언형 매크로(declarative macro)인 macro_rules! 를 창고 프로그램에 적용한다.

  • macro_rules! 의 규칙 구조(매처와 트랜스크라이버)를 읽고 쓴다.
  • 프래그먼트 지정자(ident, expr, literal, tt 등)를 목적에 맞게 고른다.
  • $(...),* 와 $(...)+ 로 반복되는 입력을 받아 반복되는 코드를 만든다.
  • 위생성(hygiene)이 지역 변수 이름 충돌을 어떻게 막는지, 그리고 무엇을 막지 못하는지 설명한다.
  • 매크로가 필요한 경우와 함수로 충분한 경우를 가르고, 절차적 매크로가 무엇인지 개요를 안다.

문제 상황

창고 프로그램에 입고, 출고, 조정 세 종류의 명령이 있다. 지금까지는 종류를 열거형으로 두고, 화면에 보여 줄 한글 이름은 match 로 따로 적었다. 종류를 하나 늘리려면 열거형, match, 전체 목록 배열 세 곳을 모두 고쳐야 하고, 한 곳을 빼먹으면 목록과 열거형이 어긋난다.

초기 재고를 만드는 코드도 비슷하다. 품목이 열 개면 insert 를 열 번 쓰고 String::from 을 열 번 쓴다. 검증 코드는 더 번거롭다. 수량이 0이면 거부하고, 재고가 모자라면 거부하는 검사가 함수마다 if ... { return Err(format!(...)); } 로 반복된다. 이 검사는 return 을 포함하므로 도우미 함수로 빼면 호출한 함수를 끝내지 못한다.

이 세 가지를 매크로로 줄여 보는 것이 이 장의 목표다. 공식 설명은 Rust Reference 의 macros by example 항목에 있고, 이 장은 그 내용을 창고 예제로 다시 풀어 쓴 것이다.

매크로 규칙의 구조

선언형 매크로는 이름 하나와 규칙 여럿으로 이루어진다. 규칙은 (매처) => { 트랜스크라이버 } 꼴이다. 컴파일러는 호출 인자를 토큰으로 쪼갠 뒤 규칙을 위에서부터 차례로 시도하고, 처음 맞는 규칙의 트랜스크라이버로 코드를 만든다. 이 전개는 타입 검사보다 앞서 일어나므로, 매크로 입력은 값이 아니라 토큰이고 타입을 알지 못한다.

매크로는 호출 토큰을 규칙에 맞춰 코드로 바꾸고, 그 결과를 타입 검사하기 전에 전개한다.

매처 안의 $이름:지정자 는 입력의 일부를 잡아 이름을 붙인다. 지정자는 잡을 수 있는 문법 조각의 종류를 정한다. 자주 쓰는 것은 아래와 같다.

자주 쓰는 프래그먼트 지정자와 매칭 대상
지정자매칭 대상예주의
ident식별자Receive변수·타입·변형 이름을 만들 때 쓴다
expr표현식a + 1뒤에 올 수 있는 토큰이 =>, ,, ; 로 제한된다
literal리터럴 하나"A-100", 5변수나 연산식은 받지 못한다
ty타입Vec<u32>제네릭 인자까지 한 덩어리로 잡는다
tt토큰 트리 하나(a, b)가장 느슨해서 인자를 그대로 넘길 때 쓴다

이 밖에 pat, block, stmt, path, item, vis, lifetime, meta 가 있다. 지정자는 좁게 고를수록 잘못된 호출에서 오류가 일찍, 읽기 쉽게 난다.

반복 $(...)*

반복은 $( 패턴 ) 구분자 연산자 로 쓴다. 연산자는 *(0번 이상), +(1번 이상), ?(0번 또는 1번)이고 구분자는 생략할 수 있다. 매처에서 잡은 변수는 트랜스크라이버에서도 같은 깊이의 $( ... ) 안에서 써야 한다. 매처가 $($sku:expr => $qty:expr),+ 라면 트랜스크라이버의 $( map.insert($sku, $qty); )+ 는 잡힌 쌍마다 한 번씩 풀린다.

끝에 쉼표를 허용하려면 $(,)? 를 덧붙인다. 이 관용구를 빼면 줄마다 쉼표를 찍는 스타일로 호출했을 때 마지막 쉼표에서 규칙이 맞지 않는다.

위생성과 매크로를 쓸지 정하는 기준

매크로가 만든 지역 변수와 호출한 쪽의 지역 변수는 이름이 같아도 서로 다른 변수로 취급된다. 이 성질이 위생성이다. 완성 코드의 stock! 은 안쪽에서 map 이라는 변수를 만들고, main 은 호출 인자로 자기 map 을 넘긴다. 매크로 안의 map 이 호출자의 map 을 가리지 않으므로 의도대로 동작한다.

매크로 안의 map 과 호출자의 map 은 이름이 같아도 서로 다른 변수다.

위생성은 지역 변수와 라벨에만 적용된다. 항목(함수, 타입, 매크로 이름)은 위생 대상이 아니라서 매크로가 정의한 enum 이나 fn 은 호출한 모듈에서 그대로 보인다. 반대로, 매크로가 호출자의 지역 변수를 몰래 만들어 주는 일은 불가능하다. 호출자가 쓸 변수 이름은 $name:ident 로 받아야 한다.

함수로 충분한 때

매크로는 읽기도 디버깅도 어렵다. 오류 메시지가 전개된 코드를 가리키고, 편집기의 도움도 약하다. 그래서 함수와 제네릭으로 풀리면 그쪽이 먼저다.

함수와 매크로 중 무엇을 고를지 판단하는 기준
상황함수매크로
같은 로직을 여러 타입에 적용제네릭으로 충분하다필요 없다
인자 개수가 호출마다 다름슬라이스를 받아야 한다반복으로 직접 받는다
호출한 함수를 끝내는 return, ?표현할 수 없다가능하다
열거형 변형·구조체 필드 목록에서 코드 생성표현할 수 없다가능하다
식을 문자열로 보기(stringify!)값만 받는다토큰을 그대로 받는다

판단 순서는 단순하다. 먼저 함수와 제네릭으로 써 보고, 인자 개수가 가변이거나 제어 흐름을 건드리거나 선언 자체를 생성해야 할 때만 매크로를 쓴다.

절차적 매크로 개요

선언형 매크로는 패턴 매칭으로 토큰을 치환하는 방식이라 입력 문법이 정해진 틀을 벗어나기 어렵다. 절차적 매크로(procedural macro)는 토큰 스트림을 받아 토큰 스트림을 돌려주는 Rust 함수로, 일반 코드로 입력을 분석한다. 세 종류가 있다.

  • derive 매크로: #[derive(Debug)] 처럼 타입 정의를 읽어 impl 을 만든다.
  • 속성 매크로: #[route(...)] 처럼 항목에 붙어 그 항목을 바꾼다.
  • 함수형 매크로: name!(...) 꼴로 호출하되 내부는 임의의 Rust 코드로 짠다.

절차적 매크로는 proc-macro = true 로 표시한 별도 크레이트에 있어야 하고, 입력 파싱에는 보통 syn, 코드 생성에는 quote 같은 외부 크레이트를 쓴다. 이 책은 표준 라이브러리만 쓰므로 코드는 다루지 않고, 직접 만들어야 할 일이 생겼을 때 어떤 도구를 찾아야 하는지만 짚는다.

완성 코드

아래 프로그램은 매크로 네 개를 쓴다. stock! 은 초기 재고 맵을 만들고, define_kinds! 는 열거형과 이름 함수와 전체 목록을 한 번에 만든다. cmd! 는 읽기 쉬운 명령 문법을 제공하고, ensure! 는 조건이 틀리면 호출 함수에서 오류를 반환한다. log_line! 은 서식 인자를 그대로 넘기는 방법을 보여 준다.

use std::collections::HashMap;

macro_rules! stock {
    () => {
        HashMap::<String, u32>::new()
    };
    ($($sku:expr => $qty:expr),+ $(,)?) => {{
        let mut map = HashMap::<String, u32>::new();
        $( map.insert(String::from($sku), $qty); )+
        map
    }};
}

macro_rules! define_kinds {
    ($($name:ident => $label:expr),+ $(,)?) => {
        #[derive(Debug, Clone, Copy, PartialEq)]
        enum Kind {
            $($name),+
        }

        impl Kind {
            const ALL: &'static [Kind] = &[$(Kind::$name),+];

            fn label(self) -> &'static str {
                match self {
                    $(Kind::$name => $label),+
                }
            }
        }
    };
}

define_kinds! {
    Receive => "입고",
    Ship => "출고",
    Adjust => "조정",
}

struct Command {
    kind: Kind,
    sku: &'static str,
    qty: u32,
}

macro_rules! cmd {
    (receive $sku:literal, $qty:literal) => {
        Command { kind: Kind::Receive, sku: $sku, qty: $qty }
    };
    (ship $sku:literal, $qty:literal) => {
        Command { kind: Kind::Ship, sku: $sku, qty: $qty }
    };
    (adjust $sku:literal, $qty:literal) => {
        Command { kind: Kind::Adjust, sku: $sku, qty: $qty }
    };
}

macro_rules! ensure {
    ($cond:expr, $($msg:tt)+) => {
        if !$cond {
            return Err(format!($($msg)+));
        }
    };
}

macro_rules! log_line {
    ($tag:ident, $($arg:tt)*) => {
        println!("[{}] {}", stringify!($tag), format!($($arg)*))
    };
}

fn apply(stock: &mut HashMap<String, u32>, c: &Command) -> Result<(), String> {
    let cur = stock.get(c.sku).copied().unwrap_or(0);
    let next = match c.kind {
        Kind::Receive => {
            ensure!(c.qty > 0, "{}: 입고 수량이 0이다", c.sku);
            cur + c.qty
        }
        Kind::Ship => {
            ensure!(c.qty > 0, "{}: 출고 수량이 0이다", c.sku);
            ensure!(cur >= c.qty, "{}: 재고 {} 부족, 요청 {}", c.sku, cur, c.qty);
            cur - c.qty
        }
        Kind::Adjust => c.qty,
    };
    stock.insert(c.sku.to_string(), next);
    Ok(())
}

fn main() {
    let map = 7;
    let mut inv = stock! { "A-100" => 5, "B-200" => map, };
    let empty = stock!();
    log_line!(info, "초기 품목 {}개, 빈 창고 {}개", inv.len(), empty.len());

    let cmds = vec![
        cmd!(receive "A-100", 10),
        cmd!(ship "B-200", 3),
        cmd!(ship "A-100", 20),
        cmd!(receive "C-300", 0),
        cmd!(adjust "B-200", 1),
    ];

    for c in &cmds {
        match apply(&mut inv, c) {
            Ok(()) => log_line!(ok, "{} {} x{}", c.kind.label(), c.sku, c.qty),
            Err(e) => log_line!(warn, "거부: {}", e),
        }
    }

    let mut rows: Vec<_> = inv.iter().collect();
    rows.sort();
    for (sku, qty) in rows {
        println!("{sku}: {qty}");
    }

    let labels: Vec<&str> = Kind::ALL.iter().map(|k| k.label()).collect();
    println!("종류: {}", labels.join(", "));
}

줄별 해설

stock!

규칙이 둘이다. 첫 규칙 () 는 빈 맵을 만든다. 타입을 HashMap::<String, u32> 로 못박았기 때문에 호출 쪽에서 타입 표기가 필요 없다. 둘째 규칙은 키 => 값 쌍을 쉼표로 이어 1개 이상 받고, 끝 쉼표는 선택이다. 트랜스크라이버가 이중 중괄호 {{ ... }} 인 것은 바깥 중괄호가 규칙 구분이고 안쪽이 블록 표현식이기 때문이다. 이렇게 해야 let 문들이 하나의 값으로 묶인다.

main 의 let map = 7; 은 위생성 시연이다. 호출 인자 map 은 호출자의 변수이고, 매크로 안의 let mut map 과 별개다. 그래서 B-200 의 초기 수량은 7이 된다.

define_kinds!

변형 이름은 ident, 라벨은 expr 로 받는다. 같은 반복 변수 $name 이 트랜스크라이버에서 세 번 쓰인다. 열거형 본문, ALL 배열, match 갈래다. 세 곳이 한 목록에서 나오므로 어긋날 수 없다. 변형을 추가하려면 호출부 한 줄만 고치면 된다. 항목은 위생 대상이 아니어서 Kind 를 매크로 밖에서 그대로 쓸 수 있다.

cmd!

첫 토큰 receive, ship, adjust 는 매처에 적은 그대로의 단어여서 규칙을 고르는 열쇠가 된다. 단어가 틀리면 어떤 규칙도 맞지 않아 컴파일 오류가 난다. 인자는 literal 로 받아 변수나 연산식을 막았다. 수량이 컴파일 시점에 눈에 보이는 명령 목록을 쓰는 용도라 이 제한이 오히려 장점이다.

ensure!

첫 인자는 조건 expr, 나머지는 tt 반복으로 받아 format! 에 그대로 넘긴다. return 은 매크로가 풀린 자리의 함수, 즉 apply 를 끝낸다. 함수로는 이렇게 호출자의 흐름을 끝낼 수 없다. 조건식 c.qty > 0 이 쓰는 c 는 호출자가 넘긴 식 안의 이름이므로 위생성 문제가 없다.

log_line!

태그는 ident 로 받아 stringify! 로 문자열로 바꾼다. 나머지 인자는 tt 반복으로 받아 format! 에 넘긴다. 서식 문자열 검사는 전개 뒤 format! 이 하므로 인자 개수가 틀리면 그 시점에 오류가 난다.

apply 와 main

apply 는 현재 수량 cur 를 읽고 종류별로 새 수량을 계산한 뒤 한 번만 insert 한다. 검증에 실패하면 insert 에 이르기 전에 반환하므로 거부된 명령은 재고를 바꾸지 않는다. C-300 이 최종 출력에 없는 이유다. main 은 HashMap 을 정렬한 Vec 으로 출력해 순서가 항상 같다.

실행 결과

$ cargo run
[info] 초기 품목 2개, 빈 창고 0개
[ok] 입고 A-100 x10
[ok] 출고 B-200 x3
[warn] 거부: A-100: 재고 15 부족, 요청 20
[warn] 거부: C-300: 입고 수량이 0이다
[ok] 조정 B-200 x1
A-100: 15
B-200: 1
종류: 입고, 출고, 조정

실무에서 자주 틀리는 것

1. 매크로가 호출자에게 변수를 만들어 준다고 기대한다

macro_rules! make_x {
    () => {
        let x = 1;
    };
}

fn main() {
    make_x!();
    println!("{x}");
}

컴파일 오류의 요지는 cannot find value `x` in this scope 이다. 매크로 안의 x 는 위생성 때문에 호출자에게 보이지 않는다. 이름은 호출자가 정하게 한다.

macro_rules! make_var {
    ($name:ident) => {
        let $name = 1;
    };
}

fn main() {
    make_var!(x);
    println!("{x}");
}

2. 인자를 여러 번 풀어 쓴다

macro_rules! square {
    ($x:expr) => {
        $x * $x
    };
}

fn main() {
    let mut n = 0;
    let mut next = || { n += 1; n };
    println!("{}", square!(next()));
}

컴파일은 되지만 next() 가 두 번 호출되어 1이 아니라 2가 출력된다. expr 은 값이 아니라 식 그대로 치환되기 때문이다. 한 번만 평가하려면 지역 변수에 담는다.

macro_rules! square {
    ($x:expr) => {{
        let v = $x;
        v * v
    }};
}

3. 매크로를 쓰기 전에 정의한다고 생각한다

fn main() {
    greet!();
}

macro_rules! greet {
    () => { println!("안녕"); };
}

오류의 요지는 cannot find macro `greet` in this scope 이다. macro_rules! 는 소스 순서를 따라 정의 지점 뒤에서만 보인다. 사용하는 코드보다 위에 정의한다. 다른 모듈에서 쓰려면 정의에 #[macro_use] 나 #[macro_export] 를 붙이는 방법이 있지만, 한 파일 프로그램에서는 위치만 맞추면 된다.

macro_rules! greet {
    () => { println!("안녕"); };
}

fn main() {
    greet!();
}

4. 끝 쉼표를 빠뜨린다

macro_rules! sum {
    ($($x:expr),*) => { 0 $(+ $x)* };
}

fn main() {
    println!("{}", sum!(1, 2, 3,));
}

호출이 어떤 규칙과도 맞지 않는다는 오류가 난다(요지: unexpected end of macro invocation 계열). 구분자 뒤에는 반드시 식이 와야 하기 때문이다. 줄마다 쉼표를 찍는 스타일을 허용하려면 매처 끝에 $(,)? 를 둔다.

macro_rules! sum {
    ($($x:expr),* $(,)?) => { 0 $(+ $x)* };
}

한눈에 보기

이 장의 핵심 항목 정리
항목핵심쓰는 곳
규칙(매처) => { 트랜스크라이버 }, 위에서부터 첫 일치cmd!
반복$(...)sep op, * + ?stock!, define_kinds!
끝 쉼표매처 끝에 $(,)?stock!
위생성지역 변수는 분리, 항목 이름은 공유map 이름 충돌 방지
인자 전달$($arg:tt)* 로 그대로 넘김ensure!, log_line!
고르는 기준함수 먼저, 제어 흐름·선언 생성·가변 인자는 매크로표 참고
절차적 매크로derive·속성·함수형, 별도 크레이트개요만

연습 문제

  1. 인자를 하나 이상 받아 가장 큰 값을 돌려주는 max_of! 를 재귀 규칙으로 작성하라. 각 인자는 한 번만 평가되어야 한다.
  2. define_kinds! 의 Kind 에 Return => "반품" 을 추가하려면 몇 곳을 고쳐야 하는지 답하고, 그 뒤 apply 의 match 에서 컴파일러가 알려 주는 것을 설명하라.
  3. 다음 세 가지 중 함수로 충분한 것과 매크로가 필요한 것을 가르고 이유를 한 문장씩 적어라. (가) 두 수량의 합을 돌려주기 (나) 조건이 틀리면 호출 함수에서 Err 반환 (다) 품목 이름 목록으로 열거형 정의.

정답과 해설

1. 값을 지역 변수에 담아 한 번만 평가한다.

macro_rules! max_of {
    ($x:expr) => { $x };
    ($x:expr, $($rest:expr),+) => {{
        let a = $x;
        let b = max_of!($($rest),+);
        if a > b { a } else { b }
    }};
}

규칙 순서가 중요하다. 인자가 하나뿐인 규칙이 재귀의 끝이다. 둘째 규칙은 첫 인자와 나머지를 나눠 나머지로 자기 자신을 다시 호출한다. a, b 는 매크로 안의 이름이라 호출 인자와 충돌하지 않는다.

2. 고칠 곳은 호출부 한 줄이다. 열거형, ALL, label 의 match 가 모두 같은 목록에서 생성된다. 그 뒤 apply 의 match c.kind 는 Return 갈래가 없어 비포괄적 match 오류가 나고, 컴파일러가 빠진 변형을 알려 준다. 새 종류의 처리 규칙을 반드시 정하게 만드는 장치가 된다.

3. (가)는 함수다. 값만 있으면 되고 타입도 제네릭으로 풀린다. (나)는 매크로다. 호출한 함수의 return 을 건드려야 한다. (다)는 매크로다. 타입 선언 자체를 생성해야 하고 함수는 선언을 만들 수 없다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.