절차적 매크로

절차적 매크로 (Procedural Macros)

C의 #define 매크로처럼, Rust에서도 코드로 코드를 만들어내는 장치가 있어요. 그중 절차적 매크로(procedural macro) 는 "함수를 실행해서 구문 확장을 만들어내는" 방식이에요. 쉽게 말해 AST에서 AST로 변환하는 함수라고 생각할 수 있어요.

출처: Rust Reference - Procedural macros

본문

절차적 매크로는 세 가지 형태가 있어요.

  1. 함수형(function-like) 매크로custom!(...)
  2. 파생(derive) 매크로#[derive(CustomDerive)]
  3. 속성(attribute) 매크로#[CustomAttribute]

절차적 매크로는 컴파일 타임에 코드를 실행하면서 Rust 구문을 소비하고 생성해요.

절차적 매크로는 crate 타입이 proc-macrocrate의 루트에 정의되어야 해요. 그리고 자기가 정의된 crate 안에서는 사용할 수 없고, 다른 crate에서 import한 뒤에만 쓸 수 있어요.

Cargo에서 절차적 매크로 crate는 manifest에 proc-macro 키로 정의해요.

proc-macro = true

함수로서 절차적 매크로는 반드시 다음 중 하나를 해야 해요. 구문을 돌려주거나, panic하거나, 무한 루프에 들어가거나요. 돌려준 구문은 매크로 종류에 따라 기존 구문을 대체하거나 추가해요. panic은 컴파일러가 잡아서 컴파일 에러로 바꿔요. 다만 무한 루프는 컴파일러가 잡지 못하고 컴파일러를 멈춰 버려요, 그래서 주의해야 해요.

절차적 매크로는 컴파일 중에 실행되기 때문에 컴파일러와 같은 자원을 갖게 돼요. 표준 입력·오류·출력도, 파일 접근도 컴파일러가 쓰는 것과 같아요. 그래서 보안 관점에서 Cargo의 빌드 스크립트와 같은 수준의 보안 고려가 필요해요.

절차적 매크로에는 오류를 보고하는 두 가지 방법이 있어요. 첫 번째는 panic을 일으키는 것이고, 두 번째는 compile_error 매크로 호출을 만들어내는 거예요.

proc_macro crate

절차적 매크로 crate는 거의 항상 컴파일러가 제공하는 proc_macro crate를 링크해요. 이 crate는 절차적 매크로를 작성하는 데 필요한 타입들과, 더 쉽게 만들 수 있는 편의 기능을 제공해요.

이 crate는 주로 TokenStream 타입을 담고 있어요. 절차적 매크로는 AST 노드 대신 토큰 스트림을 다뤄요. 그게 컴파일러와 매크로 양쪽 모두에게 오래 안정적인 인터페이스예요. 토큰 스트림은 대략 Vec<TokenTree>와 같다고 볼 수 있는데, TokenTree는 대략 어휘적 토큰(lexical token) 한 조각이에요. 예를 들어 fooIdent 토큰, .Punct 토큰, 1.2Literal 토큰이에요. TokenStream 타입은 Vec<TokenTree>와 달리 클론 비용이 저렴해요.

모든 토큰에는 Span 이 연결돼 있어요. Span은 수정할 수 없지만 만들 수는 있는 불투명한 값이에요. Span은 프로그램 안의 소스 코드 영역을 나타내며 주로 오류 보고에 사용돼요. Span 자체는 수정할 수 없지만, 다른 토큰에서 Span을 얻어와 어떤 토큰에든 연결된 Span을 바꿀 수는 있어요.

절차적 매크로의 위생(hygiene)

절차적 매크로는 위생적이지 않아요(unhygienic). 즉 매크로가 만들어낸 출력 토큰 스트림이 그냥 옆 코드에 인라인으로 적힌 것처럼 동작해요. 그래서 외부 항목의 영향을 받기도 하고 외부 import에 영향을 주기도 해요.

매크로 작성자는 이런 제약 때문에 매크로가 가능한 한 많은 컨텍스트에서 동작하도록 신경 써야 해요. 보통은 라이브러리의 항목에 절대 경로를 사용하거나(예: Option대신 ::std::option::Option), 생성된 함수 이름이 다른 함수와 충돌하지 않도록 짓는 식이에요(예: foo대신 __internal_foo).

proc_macro 속성

proc_macro 속성은 함수형(function-like) 절차적 매크로를 정의해요.

다음 매크로 정의는 입력을 무시하고 자신의 스코프에 answer 함수를 만들어내요.

#![crate_type = "proc-macro"]
extern crate proc_macro;
use proc_macro::TokenStream;

#[proc_macro]
pub fn make_answer(_item: TokenStream) -> TokenStream {
    "fn answer() -> u32 { 42 }".parse().unwrap()
}

이걸 바이너리 crate에서 사용해서 "42"를 표준 출력으로 찍을 수 있어요.

extern crate proc_macro_examples;
use proc_macro_examples::make_answer;

make_answer!();

fn main() {
    println!("{}", answer());
}
  • 구문: proc_macro 속성은 MetaWord 구문을 사용해요.
  • 허용 위치: proc_macro 속성은 proc_macro crate에서 가져온 TokenStream을 쓰는, 타입이 fn(TokenStream) -> TokenStreampub 함수에만 적용할 수 있어요. 반드시 Rust ABI여야 하고, 다른 함수 한정자(qualifier)는 허용되지 않아요. 그리고 crate의 루트에 있어야 해요.
  • 반복 사용: proc_macro 속성은 한 함수에 한 번만 지정할 수 있어요.
  • 네임스페이스: proc_macro 속성은 crate 루트의 매크로 네임스페이스에, 함수와 같은 이름으로 매크로를 공개적으로 정의해요.
  • 동작: 함수형 매크로 호출은 매크로 호출의 구분 기호 안에 있는 것을 입력 TokenStream 인자로 넘기고, 매크로 호출 전체를 함수의 출력 TokenStream으로 대체해요.
  • 호출 위치: 함수형 절차적 매크로는 어떤 매크로 호출 위치에서든 호출될 수 있어요. 여기에는 문장(statement), 표현식, 패턴, 타입 표현식, 그리고 extern 블록 안 항목을 포함한 항목 위치, 고유/트레이트 구현, 트레이트 정의가 포함돼요.

proc_macro_derive 속성

함수에 proc_macro_derive 속성을 적용하면 derive 속성이 호출할 수 있는 derive 매크로를 정의해요. 이런 매크로는 struct, enum, union 정의의 토큰 스트림을 받아 그 뒤에 새 항목을 만들어낼 수 있어요. derive 매크로 helper 속성을 선언하고 사용할 수도 있어요.

이 derive 매크로는 입력을 무시하고 함수를 정의하는 토큰을 덧붙여요.

#![crate_type = "proc-macro"]
extern crate proc_macro;
use proc_macro::TokenStream;

#[proc_macro_derive(AnswerFn)]
pub fn derive_answer_fn(_item: TokenStream) -> TokenStream {
    "fn answer() -> u32 { 42 }".parse().unwrap()
}

사용하는 쪽에서는 이렇게 쓰면 돼요.

extern crate proc_macro_examples;
use proc_macro_examples::AnswerFn;

#[derive(AnswerFn)]
struct Struct;

fn main() {
    assert_eq!(42, answer());
}

proc_macro_derive 속성의 구문은 다음과 같아요.

ProcMacroDeriveAttribute → proc_macro_derive ( DeriveMacroName ( , DeriveMacroAttributes )? ,? )

DeriveMacroName → IDENTIFIER

DeriveMacroAttributes → attributes ( ( IDENTIFIER ( , IDENTIFIER )* ,? )? )

derive 매크로의 이름은 DeriveMacroName으로 주어져요. 선택적인 attributes 인자에 대해서는 helper 속성 절에서 설명할게요.

proc_macro_derive 속성은 crate 루트에 정의된, Rust ABI를 가진 pub 함수에만 적용할 수 있어요. 타입은 proc_macro crate의 TokenStream을 쓰는 fn(TokenStream) -> TokenStream이어야 해요. 함수는 const일 수 있고 extern으로 Rust ABI를 명시할 수 있지만, 다른 한정자는 쓸 수 없어요(예: asyncunsafe는 안 돼요). proc_macro_derive 속성은 한 함수에 한 번만 쓸 수 있고, crate 루트의 매크로 네임스페이스에 derive 매크로를 공개적으로 정의해요.

입력 TokenStreamderive 속성이 적용된 항목의 토큰 스트림이에요. 출력 TokenStream은 (비어 있을 수도 있는) 항목들의 집합이어야 해요. 이 항목들은 입력 항목 뒤에, 같은 모듈이나 블록 안에 추가돼요.

Derive 매크로 helper 속성

derive 매크로는 helper 속성을 선언해서, derive 매크로가 적용된 항목의 스코프 안에서 사용할 수 있게 할 수 있어요. 이 속성들은 비활성(inert) 이에요. 목적은 이 속성을 선언한 매크로가 쓰는 것이지만, 어떤 매크로든 볼 수 있어요.

helper 속성은 proc_macro_derive 속성의 attributes 목록에 그 식별자를 추가해서 선언해요.

다음은 helper 속성을 선언하고 그다음 무시하는 예시예요.

#![crate_type="proc-macro"]
extern crate proc_macro;
use proc_macro::TokenStream;

#[proc_macro_derive(WithHelperAttr, attributes(helper))]
pub fn derive_with_helper_attr(_item: TokenStream) -> TokenStream {
    TokenStream::new()
}

사용하는 쪽에서는 이렇게 써요.

#[derive(WithHelperAttr)]
struct Struct {
    #[helper] field: (),
}

derive 매크로 호출이 어떤 항목에 적용되면, 그 derive 매크로가 도입한 helper 속성은 1) 해당 항목에 적용되고 어휘적으로 derive 매크로 호출 뒤에 오는 속성들과, 2) 그 항목 안의 필드와 변형(variant)에 적용된 속성들에 대해 스코프에 들어와요.

참고: rustc는 현재 매크로가 도입되기 전에 derive helper를 사용하는 것을 허용해요. 순서가 어긋난 이런 사용은 다른 속성 매크로를 가릴(shadow) 수 없어요. 이 동작은 폐기(deprecated)되었고 제거 예정이에요.

#[helper] // Deprecated, hard error in the future.
#[derive(WithHelperAttr)]
struct Struct {
   field: (),
}

proc_macro_attribute 속성

proc_macro_attribute 속성은 외부 속성(outer attribute)으로 쓸 수 있는 속성 매크로를 정의해요.

다음 속성 매크로는 입력 스트림을 그대로 내보내서, 사실상 no-op 속성이 돼요.

#![crate_type = "proc-macro"]
extern crate proc_macro;
use proc_macro::TokenStream;

#[proc_macro_attribute]
pub fn return_as_is(_attr: TokenStream, item: TokenStream) -> TokenStream {
    item
}

다음은 컴파일러 출력에서 속성 매크로가 보는 TokenStream을 문자열로 찍어 보여주는 예시예요.

// my-macro/src/lib.rs
extern crate proc_macro;
use proc_macro::TokenStream;
#[proc_macro_attribute]
pub fn show_streams(attr: TokenStream, item: TokenStream) -> TokenStream {
    println!("attr: \"{attr}\"");
    println!("item: \"{item}\"");
    item
}
// src/lib.rs
extern crate my_macro;

use my_macro::show_streams;

// Example: Basic function.
#[show_streams]
fn invoke1() {}
// out: attr: ""
// out: item: "fn invoke1() {}"

// Example: Attribute with input.
#[show_streams(bar)]
fn invoke2() {}
// out: attr: "bar"
// out: item: "fn invoke2() {}"

// Example: Multiple tokens in the input.
#[show_streams(multiple => tokens)]
fn invoke3() {}
// out: attr: "multiple => tokens"
// out: item: "fn invoke3() {}"

// Example: Delimiters in the input.
#[show_streams { delimiters }]
fn invoke4() {}
// out: attr: "delimiters"
// out: item: "fn invoke4() {}"
  • 구문: proc_macro_attribute 속성은 MetaWord 구문을 사용해요.
  • 허용 위치: proc_macro crate의 TokenStream을 쓰는, 타입이 fn(TokenStream, TokenStream) -> TokenStreampub 함수에만 적용할 수 있어요. 반드시 Rust ABI여야 하고, 다른 한정자는 허용되지 않아요. crate의 루트에 있어야 해요.
  • 반복 사용: proc_macro_attribute 속성은 한 함수에 한 번만 지정할 수 있어요.
  • 네임스페이스: proc_macro_attribute 속성은 crate 루트의 매크로 네임스페이스에, 함수와 같은 이름으로 속성을 정의해요.
  • 사용 위치: 속성 매크로는 항목(item), extern 블록 안의 항목, 고유/트레이트 구현, 트레이트 정의에만 쓸 수 있어요.
  • 첫 번째 TokenStream 인자는 속성 이름 뒤에 오는, 바깥 구분 기호는 제외한 구분된 토큰 트리예요. 적용된 속성이 속성 이름만 있거나 속성 이름 뒤에 빈 구분 기호가 오는 경우 이 TokenStream은 비어 있어요.
  • 두 번째 TokenStream은 항목의 나머지 부분으로, 그 항목의 다른 속성들까지 포함해요.
  • 속성이 적용된 항목은 반환된 TokenStream 안의 0개 이상의 항목으로 대체돼요.

선언적 매크로 토큰 vs 절차적 매크로 토큰

macro_rules(선언적) 매크로와 절차적 매크로는 토큰(정확히는 TokenTree)에 대해 비슷하지만 다른 정의를 사용해요.

macro_rules(선언적 매크로)의 토큰 트리는 다음과 같이 정의돼요.

  • 구분된 그룹((…), {…} 등)
  • 언어가 지원하는 모든 연산자 — 단일 문자와 여러 문자 연산자 둘 다(+, +=). 단 이 집합에 홑따옴표 '는 포함되지 않아요.
  • 리터럴("string", 1 등). 단 부정(예: -1)은 그런 리터럴 토큰의 일부가 아니라 별도의 연산자 토큰이에요.
  • 키워드를 포함한 식별자(ident, r#ident, fn)
  • 라이프타임('ident)
  • macro_rules 안의 메타변수 치환(metavariable substitution). 예를 들어 macro_rules! mac { ($my_expr: expr) => { $my_expr } }에서 확장 후의 $my_expr은, 전달된 표현식이 무엇이든 간에 단일 토큰 트리로 취급돼요.

절차적 매크로의 토큰 트리는 다음과 같이 정의돼요.

  • 구분된 그룹((…), {…} 등)
  • 언어가 지원하는 연산자에 쓰이는 모든 구두점 문자(+, 단 +=는 아님), 그리고 홑따옴표 ' 문자(보통 라이프타임에 쓰임)
  • 리터럴("string", 1 등)
  • 부정(예: -1)은 정수·부동소수점 리터럴의 일부로 지원돼요.
  • 키워드를 포함한 식별자(ident, r#ident, fn)

이 두 정의 사이의 불일치는 토큰 스트림이 절차적 매크로로 전달되거나 밖으로 나올 때 처리돼요. 아래 변환은 지연(lazily) 발생할 수 있으므로, 토큰이 실제로 검사되지 않으면 변환이 일어나지 않을 수도 있어요.

절차적 매크로로 전달될 때 (When passed to a proc-macro)

  • 모든 여러 문자 연산자는 단일 문자들로 분해돼요.
  • 라이프타임은 ' 문자와 식별자로 분해돼요.
  • 키워드 메타변수 $crate단일 식별자로 전달돼요.
  • 그 외 모든 메타변수 치환은 그 밑에 있는 토큰 스트림으로 표현돼요.
  • 이렇게 분해된 토큰 스트림은 파싱 우선순위를 보존해야 할 때 암시적 구분 기호(Delimiter::None)를 가진 구분된 그룹(Group) 으로 감쌀 수 있어요.
  • ttident 치환은 절대 그런 그룹으로 감싸지지 않고 항상 밑에 있는 토큰 트리로 표현돼요.

절차적 매크로에서 나올 때 (When emitted from a proc macro)

  • 구두점 문자는 해당되는 경우 여러 문자 연산자로 붙여져요.
  • 식별자와 합쳐진 홑따옴표 '라이프타임으로 붙여져요.
  • 음수 리터럴은 두 토큰(-와 리터럴)으로 변환되는데, 파싱 우선순위를 보존해야 할 때 암시적 구분 기호(Delimiter::None)를 가진 구분된 그룹으로 감쌀 수 있어요.

마지막으로, 선언적 매크로와 절차적 매크로 모두 문서 주석 토큰(예: /// Doc)을 지원하지 않아요. 그래서 매크로에 전달될 때 항상 그와 동등한 #[doc = r"str"] 속성을 나타내는 토큰 스트림으로 변환돼요.

더 알아보기 (Learn more)