매크로
함수와 매크로의 차이를 이해하고 macro_rules 선언형 매크로와 derive·속성형·함수형 절차 매크로의 생성 방식을 익힙니다.
이 교재 내내 println! 같은 매크로를 써 왔지만, 매크로가 무엇이고 어떻게 동작하는지는 자세히 살펴보지 않았습니다. 매크로(macro)라는 말은 러스트의 여러 기능을 함께 가리킵니다. macro_rules!로 만드는 선언적(declarative) 매크로와, 세 종류의 절차적(procedural) 매크로입니다.
| 종류 | 하는 일 |
|---|---|
| 선언적 매크로 | macro_rules!로 정의하며, 패턴에 맞춰 코드를 만들어 냅니다. |
커스텀 #[derive] 매크로 | 구조체, 열거형, 유니온에 쓰는 derive 속성으로 더해지는 코드를 지정합니다. |
| 속성형 매크로 | 어떤 아이템에든 쓸 수 있는 사용자 정의 속성을 정의합니다. |
| 함수형 매크로 | 함수 호출처럼 보이지만 인수로 받은 토큰을 다룹니다. |
차례로 이야기하겠지만, 먼저 함수가 있는데 왜 매크로가 필요한지 봅니다.
매크로와 함수의 차이
근본적으로 매크로는 다른 코드를 쓰는 코드를 쓰는 방법이며, 이것을 메타프로그래밍(metaprogramming)이라고 합니다. 부록 C 파생 가능한 트레이트에서는 여러 트레이트의 구현을 만들어 주는 derive 속성을 이야기합니다. 이 교재 내내 println!과 vec! 매크로도 썼습니다. 이 매크로들은 모두 펼쳐져(expand), 직접 쓴 코드보다 더 많은 코드를 만들어 냅니다.
메타프로그래밍은 써야 하고 유지 보수해야 하는 코드의 양을 줄이는 데 쓸모가 있으며, 이것은 함수의 역할 가운데 하나이기도 합니다. 하지만 매크로에는 함수에 없는 추가 능력이 있습니다.
함수의 시그니처는 함수가 가진 매개변수의 수와 타입을 선언해야 합니다. 반면 매크로는 개수가 정해지지 않은 매개변수를 받을 수 있습니다. 문자열 리터럴 하나로 println!("hello")를 부를 수도, 인수 둘로 println!("hello {}", name)을 부를 수도 있습니다. 또 매크로는 컴파일러가 코드의 의미를 해석하기 전에 펼쳐지므로, 예를 들어 주어진 타입에 트레이트를 구현할 수 있습니다. 함수는 런타임에 불리고 트레이트는 컴파일 시점에 구현되어야 하므로, 함수로는 그렇게 할 수 없습니다.
함수 대신 매크로를 구현하는 단점은, 매크로 정의가 함수 정의보다 복잡하다는 것입니다. 러스트 코드를 쓰는 러스트 코드를 쓰기 때문입니다. 이런 간접 때문에, 매크로 정의는 대개 함수 정의보다 읽고 이해하고 유지 보수하기가 더 어렵습니다.
매크로와 함수의 또 다른 중요한 차이는, 파일에서 매크로를 부르기 전에 매크로를 정의하거나 스코프로 가져와야 한다는 것입니다. 함수는 어디에든 정의하고 어디서든 부를 수 있는 것과 다릅니다.
일반적인 메타프로그래밍을 위한 macro_rules!를 사용한 선언적 매크로
러스트에서 가장 널리 쓰이는 매크로의 형태는 선언적 매크로입니다. 예제 매크로(macros by example), macro_rules! 매크로, 또는 그냥 매크로라고도 부릅니다. 핵심적으로 선언적 매크로는 러스트의 match 표현식과 비슷한 것을 쓰게 해 줍니다. match 제어 흐름 구조에서 이야기했듯, match 표현식은 표현식을 받아 그 표현식의 결과 값을 패턴과 비교하고, 맞는 패턴에 연결된 코드를 실행하는 제어 구조입니다. 매크로도 값을 특정 코드에 연결된 패턴과 비교합니다. 이 경우 값은 매크로에 넘긴 러스트 소스 코드의 리터럴이고, 패턴은 그 소스 코드의 구조와 비교되며, 맞을 때 각 패턴에 연결된 코드가 매크로에 넘긴 코드를 대체합니다. 이 모든 일은 컴파일 중에 일어납니다.
매크로를 정의하려면 macro_rules! 구문을 씁니다. vec! 매크로가 어떻게 정의되는지 보며 macro_rules!를 쓰는 방법을 살펴봅니다. 벡터와 값 목록에서 특정 값을 담은 새 벡터를 만드는 데 vec! 매크로를 쓰는 방법을 이야기했습니다. 예를 들어 다음 매크로는 정수 세 개를 담은 새 벡터를 만듭니다.
let v: Vec<u32> = vec![1, 2, 3];vec! 매크로로 정수 둘을 담은 벡터나 문자열 슬라이스 다섯 개를 담은 벡터도 만들 수 있습니다. 값의 수나 타입을 미리 알 수 없으므로, 함수로는 같은 일을 할 수 없을 것입니다.
다음 코드는 vec! 매크로의 정의를 조금 단순하게 줄인 것입니다.
#[macro_export]
macro_rules! vec {
( $( $x:expr ),* ) => {
{
let mut temp_vec = Vec::new();
$(
temp_vec.push($x);
)*
temp_vec
}
};
}표준 라이브러리의 실제 vec! 매크로의 정의에는, 필요한 메모리의 양을 미리 할당하는 코드가 들어 있습니다. 그 코드는 최적화이므로, 예를 단순하게 하려고 여기서는 넣지 않았습니다.
#[macro_export] 표시는, 매크로가 정의된 크레이트를 스코프로 가져올 때마다 이 매크로를 쓸 수 있게 해야 한다고 알립니다. 이 표시가 없으면 매크로를 스코프로 가져올 수 없습니다.
그다음 macro_rules!와 정의하는 매크로의 이름을 느낌표 없이 써서 매크로 정의를 시작합니다. 이 경우 이름은 vec이며, 매크로 정의의 본문을 나타내는 중괄호가 뒤따릅니다.
vec! 본문의 구조는 match 표현식의 구조와 비슷합니다. 여기에는 ( $( $x:expr ),* ) 패턴을 가진 갈래 하나가 있고, 그 뒤에 =>와 이 패턴에 연결된 코드 블록이 있습니다. 패턴이 맞으면 연결된 코드 블록이 만들어집니다. 이 매크로에는 패턴이 하나뿐이므로, 맞는 방법도 하나뿐입니다. 다른 어떤 패턴도 오류가 됩니다. 더 복잡한 매크로에는 갈래가 여럿 있을 것입니다.
매크로 정의에서 유효한 패턴의 문법은, 패턴 문법에서 다룬 패턴의 문법과 다릅니다. 매크로의 패턴은 값이 아니라 러스트 코드의 구조에 맞춰지기 때문입니다. 앞의 코드의 패턴 조각들이 무엇을 뜻하는지 봅니다. 매크로 패턴의 전체 문법은 Rust Reference를 보세요.
먼저 괄호 한 쌍으로 패턴 전체를 감쌉니다. 패턴과 맞는 러스트 코드를 담을 변수를 매크로 시스템 안에서 선언하기 위해 달러 기호 $를 씁니다. 달러 기호는 이것이 보통의 러스트 변수가 아니라 매크로 변수라는 것을 분명히 합니다. 그다음 대체 코드에서 쓰려고 괄호 안의 패턴에 맞는 값을 캡처하는 괄호 한 쌍이 옵니다. $() 안에는 $x:expr이 있으며, 이것은 어떤 러스트 표현식에든 맞고 그 표현식에 $x라는 이름을 줍니다.
$() 뒤의 쉼표는, $() 안의 코드에 맞는 코드 사이마다 리터럴 쉼표 구분자 문자가 있어야 한다는 것을 나타냅니다. *는 이 패턴이 * 앞에 오는 것에 0번 이상 맞는다는 것을 지정합니다.
이 매크로를 vec![1, 2, 3];으로 부르면, $x 패턴은 세 표현식 1, 2, 3에 세 번 맞습니다.
이제 이 갈래에 연결된 코드 본문의 패턴을 봅니다. $()* 안의 temp_vec.push()는, 패턴에서 $()에 맞은 각 부분마다, 패턴이 맞은 횟수에 따라 0번 이상 만들어집니다. $x는 맞은 각 표현식으로 바뀝니다. 이 매크로를 vec![1, 2, 3];으로 부르면, 이 매크로 호출을 대체해 만들어지는 코드는 다음과 같습니다.
{
let mut temp_vec = Vec::new();
temp_vec.push(1);
temp_vec.push(2);
temp_vec.push(3);
temp_vec
}어떤 타입의 인수든 몇 개든 받아, 지정한 원소를 담은 벡터를 만드는 코드를 만들어 낼 수 있는 매크로를 정의했습니다. 아래 그림은 이 확장 과정을 단계별로 보여 줍니다.
macro_rules 토큰 확장 흐름
vec![1,2,3]의 입력 토큰을 expr 반복 matcher가 캡처하고 push 세 문장이 있는 블록으로 확장하는 구체 변환이다. 생성과 실행을 나누고, 간소화 패턴의 마지막 쉼표 미허용을 보존하면 컴파일 때 하는 일을 추적할 수 있다.
RUST · 입력 → 매칭 → 코드 생성
vec![1, 2, 3]은 세 push 문을 만드는 코드로 확장된다
본문의 간소화한 vec 매크로는 표현식 목록을 매칭하고, 매칭된 조각을 반복해서 코드에 넣습니다.
간소화 matcher는 마지막 쉼표를 허용하지 않는다. 허용하려면 $(,)? 등의 별도 패턴이 필요하다.
코드·조건·비교표와 보충 설명
1 · 호출의 입력 토큰
vec![1, 2, 3]매크로는 런타임 값 1·2·3을 먼저 계산해 받는 함수가 아니라, 호출에 적힌 표현식 토큰을 입력으로 받습니다.
2 · matcher가 표현식 세 개를 캡처
$( $x:expr ),*$x:expr은 표현식 조각, 쉼표는 반복 사이 구분자, *는 0회 이상 반복입니다. 이 호출에서는 $x에 1, 2, 3이 순서대로 매칭됩니다.
3 · 반복되는 출력 템플릿
$(
temp_vec.push($x);
)*캡처한 표현식 하나마다 push 문 하나를 생성합니다. 매칭되는 규칙이 없다면 매크로 확장 오류가 발생합니다.
4 · 생성된 코드 조각
{
let mut v = Vec::new();
v.push(1);
v.push(2);
v.push(3);
v
}읽기 편하게 임시 변수 이름을 v로 적었습니다. 이 블록의 마지막 표현식이 벡터를 반환하며, 확장된 코드도 타입 검사를 받습니다.
마지막 쉼표는 별도 패턴이 필요하다
이 간소화한 $( $x:expr ),*만으로는 vec![1, 2, 3,]을 받지 못합니다. 끝 쉼표를 허용하려면 $(,)? 같은 조각을 더합니다. 표준 라이브러리의 실제 vec!는 이 간소화한 정의와 다릅니다.
매크로 확장은 컴파일 과정에서 코드를 생성합니다. 생성된 push 호출은 프로그램을 실행할 때 수행됩니다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 입력 토큰 간소화한 vec 매크로가 표현식 토큰을 받는다. |
|
|
| 매칭·생성 expr 반복이 세 표현식을 캡처해 push 문을 만든다. |
|
|
| 확장 코드 검사 Vec 블록이 타입 검사를 받고 실행 시에 push한다. |
|
|
macro_rules 토큰 확장 흐름
vec![1,2,3]의 입력 토큰을 expr 반복 matcher가 캡처하고 push 세 문장이 있는 블록으로 확장하는 구체 변환이다. 생성과 실행을 나누고, 간소화 패턴의 마지막 쉼표 미허용을 보존하면 컴파일 때 하는 일을 추적할 수 있다.
RUST · 입력 → 매칭 → 코드 생성
vec![1, 2, 3]은 세 push 문을 만드는 코드로 확장된다
본문의 간소화한 vec 매크로는 표현식 목록을 매칭하고, 매칭된 조각을 반복해서 코드에 넣습니다.
간소화 matcher는 마지막 쉼표를 허용하지 않는다. 허용하려면 $(,)? 등의 별도 패턴이 필요하다.
코드·조건·비교표와 보충 설명
1 · 호출의 입력 토큰
vec![1, 2, 3]매크로는 런타임 값 1·2·3을 먼저 계산해 받는 함수가 아니라, 호출에 적힌 표현식 토큰을 입력으로 받습니다.
2 · matcher가 표현식 세 개를 캡처
$( $x:expr ),*$x:expr은 표현식 조각, 쉼표는 반복 사이 구분자, *는 0회 이상 반복입니다. 이 호출에서는 $x에 1, 2, 3이 순서대로 매칭됩니다.
3 · 반복되는 출력 템플릿
$(
temp_vec.push($x);
)*캡처한 표현식 하나마다 push 문 하나를 생성합니다. 매칭되는 규칙이 없다면 매크로 확장 오류가 발생합니다.
4 · 생성된 코드 조각
{
let mut v = Vec::new();
v.push(1);
v.push(2);
v.push(3);
v
}읽기 편하게 임시 변수 이름을 v로 적었습니다. 이 블록의 마지막 표현식이 벡터를 반환하며, 확장된 코드도 타입 검사를 받습니다.
마지막 쉼표는 별도 패턴이 필요하다
이 간소화한 $( $x:expr ),*만으로는 vec![1, 2, 3,]을 받지 못합니다. 끝 쉼표를 허용하려면 $(,)? 같은 조각을 더합니다. 표준 라이브러리의 실제 vec!는 이 간소화한 정의와 다릅니다.
매크로 확장은 컴파일 과정에서 코드를 생성합니다. 생성된 push 호출은 프로그램을 실행할 때 수행됩니다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 입력 토큰 간소화한 vec 매크로가 표현식 토큰을 받는다. |
|
|
| 매칭·생성 expr 반복이 세 표현식을 캡처해 push 문을 만든다. |
|
|
| 확장 코드 검사 Vec 블록이 타입 검사를 받고 실행 시에 push한다. |
|
|
vec![1, 2, 3]에서 매크로가 받는 것은 미리 계산한 값 1, 2, 3이 아니라 호출에 적힌 표현식 토큰입니다. 이 토큰이 $( $x:expr ),* 패턴에 맞으면 세 표현식을 캡처하고, 맞는 규칙이 없으면 확장 오류가 납니다. 캡처한 목록으로 push($x) 템플릿을 되풀이해 세 문장을 만들고, Vec::new()와 세 push가 담긴 블록을 생성합니다. 여기까지가 컴파일 중에 일어나는 일이며, 만들어진 블록은 타입 검사를 받은 뒤 실행될 때 실제로 Vec 값을 만듭니다. 이 단순한 패턴은 마지막 쉼표를 허용하지 않으므로, vec![1, 2, 3,]까지 받으려면 $(,)? 같은 패턴을 더해야 합니다.
macro_rules!에는 몇 가지 이상한 모서리 사례가 있습니다. macro 키워드로 정의하는 새 선언적 매크로(declarative macros 2.0)도 있지만, 나이틀리의 불안정한 기능 decl_macro로만 쓸 수 있고 아직 안정화되지 않았습니다. 대부분의 러스트 프로그래머는 매크로를 직접 작성하기보다 이미 있는 매크로를 사용하는 일이 더 많으므로, macro_rules!는 여기까지만 이야기합니다. 매크로를 작성하는 방법을 더 배우려면, 온라인 문서나 The Little Book of Rust Macros 같은 자료를 참고하세요.
더 깊이: 매크로가 만든 이름은 어디서 찾을까
매크로가 펼쳐 놓은 코드 속 이름이 부르는 쪽의 이름과 겹치면 어느 쪽을 가리킬까요? Reference에 따르면 macro_rules! 매크로는 혼합 위생(mixed-site hygiene)이라는 규칙을 따릅니다. 루프 라벨, 블록 라벨, 지역 변수는 매크로를 정의한 곳에서 찾고, 함수나 타입 같은 나머지 이름은 매크로를 부른 곳에서 찾습니다. 그래서 매크로 안에서 let x = ...;로 만든 변수는 부르는 쪽의 x와 섞이지 않으며, 매크로가 만든 지역 변수는 다른 호출과도 공유되지 않습니다.
나머지 이름을 부른 곳에서 찾는다면, 다른 크레이트에 내보낸 매크로가 자기 크레이트의 함수를 부르기 어렵습니다. 그래서 $crate라는 특별한 메타변수가 있으며, 이것은 매크로를 정의한 크레이트를 가리키므로 $crate::helper()처럼 경로의 맨 앞에 써서 부르는 곳의 스코프에 없는 아이템을 찾을 수 있습니다. 다만 $crate가 공개 범위를 넓혀 주지는 않으므로, 그렇게 가리킨 아이템도 부르는 곳에서 볼 수 있는 공개 아이템이어야 합니다.
조각 지정자의 뜻도 에디션에 따라 조금 다릅니다. 2024 에디션의 expr는 _ 식과 const { ... } 블록까지 맞추지만, 그 전 에디션에서는 이 둘을 맨 위에서 맞추지 않았습니다. 예전 동작이 필요한 매크로는 expr_2021 지정자를 씁니다.
속성에서 코드를 생성하기 위한 절차적 매크로
매크로의 두 번째 형태는 함수처럼 동작하는, 한 종류의 프로시저인 절차적 매크로입니다. 절차적 매크로는 어떤 코드를 입력으로 받아 그 코드를 다루고, 어떤 코드를 출력으로 만들어 냅니다. 선언적 매크로처럼 패턴에 맞춰 코드를 다른 코드로 바꾸는 것과 다릅니다. 절차적 매크로의 세 종류는 커스텀 derive, 속성형, 함수형이며, 모두 비슷한 방식으로 동작합니다.
절차적 매크로를 만들 때, 정의는 특별한 크레이트 타입을 가진 자기만의 크레이트에 있어야 합니다. 이것은 복잡한 기술적 까닭 때문이며, 앞으로 없애고 싶어 하는 제약입니다. 다음 코드는 절차적 매크로를 정의하는 방법을 보여 주며, some_attribute는 특정 매크로의 종류를 쓰는 자리 표시자입니다.
use proc_macro::TokenStream;
#[some_attribute]
pub fn some_name(input: TokenStream) -> TokenStream {
}절차적 매크로를 정의하는 함수는 TokenStream을 입력으로 받아 TokenStream을 출력으로 만듭니다. TokenStream 타입은 러스트에 들어 있는 proc_macro 크레이트에 정의되어 있으며, 토큰의 연속을 나타냅니다. 이것이 매크로의 핵심입니다. 매크로가 다루는 소스 코드가 입력 TokenStream이 되고, 매크로가 만드는 코드가 출력 TokenStream이 됩니다. 함수에는 또 어떤 종류의 절차적 매크로를 만드는지 지정하는 속성이 붙습니다. 같은 크레이트에 여러 종류의 절차적 매크로를 둘 수 있습니다.
절차적 매크로의 여러 종류를 봅니다. 커스텀 derive 매크로로 시작하고, 그다음 다른 형태들이 어떻게 다른지 작은 차이를 설명합니다.
커스텀 파생(derive) 매크로 작성 방법
hello_macro라는 크레이트를 만들어 봅니다. 이 크레이트는 hello_macro라는 연관 함수 하나를 가진 HelloMacro라는 트레이트를 정의합니다. 사용자가 자기 타입마다 HelloMacro 트레이트를 구현하게 하는 대신, 사용자가 타입에 #[derive(HelloMacro)]를 표시하면 hello_macro 함수의 기본 구현을 얻도록 절차적 매크로를 제공합니다. 기본 구현은 Hello, Macro! My name is TypeName!을 출력하며, TypeName은 이 트레이트가 정의된 타입의 이름입니다. 다시 말해, 다른 프로그래머가 우리 크레이트로 다음과 같은 코드를 쓸 수 있게 하는 크레이트를 만듭니다. 아래 그림은 이렇게 만들 절차적 매크로가 코드를 받고 돌려주는 흐름을 보여 줍니다.
절차적 매크로 TokenStream 파이프라인
별도 proc-macro crate로 입력 TokenStream을 보내 parse·검사·변환하고 출력 TokenStream을 호출 위치의 코드로 돌려 타입 검사를 받는 변환 흐름이다. 세 진입 형식을 연속 실행시키지 않고 HelloMacro derive 한 호출을 사례로 삼는다.
RUST · ARCHITECTURE
별도 매크로 크레이트가 TokenStream을 코드로 변환한다
절차적 매크로는 별도 proc-macro crate에서 TokenStream을 받아 TokenStream으로 돌려주며, 정의한 proc-macro crate 자체에서는 사용할 수 없습니다.
call-site crate 입력
사용자가 호출한 derive, 속성형, 함수형 매크로에 맞는 입력 토큰을 컴파일러가 전달합니다. 각 형태의 입력과 출력 역할은 아래에서 구분합니다.
별도 proc-macro crate
crate root의 공개 entry가 TokenStream을 받아 parse·검사·변환하고 TokenStream을 반환합니다.
call-site로 복귀
생성된 syntax를 호출 crate의 compiler가 분석합니다. 정의한 proc-macro crate 자체에서는 그 매크로를 사용할 수 없습니다.
구현 도구 경계
본문의 derive 구현은 syn::parse로 구조를 읽고 ast.ident에서 Pancakes를 얻습니다. quote!로 impl HelloMacro for Pancakes를 생성하고 TokenStream으로 변환합니다.
derive · 입력 1개
struct·enum·union 정의를 받습니다. 입력 아이템을 유지하고, 뒤에 새 아이템을 추가합니다. 본문 예제는 Pancakes의 트레이트 구현을 생성합니다.
속성형 · 입력 2개
attr는 속성 인자, item은 대상 아이템입니다. 반환 토큰이 속성이 붙은 아이템을 대체합니다.
함수형 · 입력 1개
sql!(…)의 괄호 안 토큰을 받습니다. 반환 토큰이 그 매크로 호출을 대체합니다.
- call-site crate의 입력과 생성 코드
- 별도 proc-macro crate의 TokenStream 변환
call-site crate의 세 종류 입력이 별도 proc-macro crate의 TokenStream 변환을 거쳐 생성 코드로 돌아옵니다. 정의한 proc-macro crate 자체에서는 그 매크로를 사용할 수 없습니다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| derive 호출 HelloMacro derive의 토큰을 별도 crate에 준다. |
|
|
| parse·변환 proc-macro 함수가 입력으로 생성 토큰을 만든다. |
|
|
| 호출 위치 컴파일 생성 코드가 돌아와 이름·타입 검사를 받는다. |
|
|
절차적 매크로 TokenStream 파이프라인
별도 proc-macro crate로 입력 TokenStream을 보내 parse·검사·변환하고 출력 TokenStream을 호출 위치의 코드로 돌려 타입 검사를 받는 변환 흐름이다. 세 진입 형식을 연속 실행시키지 않고 HelloMacro derive 한 호출을 사례로 삼는다.
RUST · ARCHITECTURE
별도 매크로 크레이트가 TokenStream을 코드로 변환한다
절차적 매크로는 별도 proc-macro crate에서 TokenStream을 받아 TokenStream으로 돌려주며, 정의한 proc-macro crate 자체에서는 사용할 수 없습니다.
call-site crate 입력
사용자가 호출한 derive, 속성형, 함수형 매크로에 맞는 입력 토큰을 컴파일러가 전달합니다. 각 형태의 입력과 출력 역할은 아래에서 구분합니다.
별도 proc-macro crate
crate root의 공개 entry가 TokenStream을 받아 parse·검사·변환하고 TokenStream을 반환합니다.
call-site로 복귀
생성된 syntax를 호출 crate의 compiler가 분석합니다. 정의한 proc-macro crate 자체에서는 그 매크로를 사용할 수 없습니다.
구현 도구 경계
본문의 derive 구현은 syn::parse로 구조를 읽고 ast.ident에서 Pancakes를 얻습니다. quote!로 impl HelloMacro for Pancakes를 생성하고 TokenStream으로 변환합니다.
derive · 입력 1개
struct·enum·union 정의를 받습니다. 입력 아이템을 유지하고, 뒤에 새 아이템을 추가합니다. 본문 예제는 Pancakes의 트레이트 구현을 생성합니다.
속성형 · 입력 2개
attr는 속성 인자, item은 대상 아이템입니다. 반환 토큰이 속성이 붙은 아이템을 대체합니다.
함수형 · 입력 1개
sql!(…)의 괄호 안 토큰을 받습니다. 반환 토큰이 그 매크로 호출을 대체합니다.
- call-site crate의 입력과 생성 코드
- 별도 proc-macro crate의 TokenStream 변환
call-site crate의 세 종류 입력이 별도 proc-macro crate의 TokenStream 변환을 거쳐 생성 코드로 돌아옵니다. 정의한 proc-macro crate 자체에서는 그 매크로를 사용할 수 없습니다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| derive 호출 HelloMacro derive의 토큰을 별도 crate에 준다. |
|
|
| parse·변환 proc-macro 함수가 입력으로 생성 토큰을 만든다. |
|
|
| 호출 위치 컴파일 생성 코드가 돌아와 이름·타입 검사를 받는다. |
|
|
매크로를 부르는 크레이트에서 파생, 속성형, 함수형 가운데 하나의 형태로 매크로를 부르면, 컴파일러가 그 형태에 맞는 입력 토큰을 TokenStream으로 넘깁니다. 별도의 절차적 매크로 크레이트의 루트에 있는 공개 함수가 그 TokenStream을 받아 파싱하고 검사하고 변환한 뒤, 새 TokenStream을 돌려줍니다. 돌려받은 토큰은 호출한 위치에 더해지거나 그 자리를 대체하고, 호출한 크레이트의 컴파일러가 그것을 분석해 이름을 해석하고 타입을 검사하며 확장된 코드까지 컴파일합니다. 절차적 매크로는 그것을 정의한 크레이트 자체에서는 쓸 수 없고, 다른 크레이트에서 불러 씁니다.
use hello_macro::HelloMacro;
use hello_macro_derive::HelloMacro;
#[derive(HelloMacro)]
struct Pancakes;
fn main() {
Pancakes::hello_macro();
}다 만들고 나면 이 코드는 Hello, Macro! My name is Pancakes!를 출력할 것입니다. 첫 단계는 다음처럼 새 라이브러리 크레이트를 만드는 것입니다.
cargo new hello_macro --lib다음으로 HelloMacro 트레이트와 그 연관 함수를 정의합니다. hello_macro는 self를 받지 않는 연관 함수이므로, 나중에 Pancakes::hello_macro()처럼 구현한 타입의 이름으로 부릅니다.
pub trait HelloMacro {
fn hello_macro();
}트레이트와 그 함수가 생겼습니다. 이 시점에서 크레이트의 사용자는 다음처럼 트레이트를 구현해 원하는 기능을 얻을 수 있습니다.
use hello_macro::HelloMacro;
struct Pancakes;
impl HelloMacro for Pancakes {
fn hello_macro() {
println!("Hello, Macro! My name is Pancakes!");
}
}
fn main() {
Pancakes::hello_macro();
}하지만 사용자는 hello_macro와 함께 쓰고 싶은 타입마다 구현 블록을 써야 합니다. 이 일을 덜어 주고 싶습니다.
또 hello_macro 함수에, 트레이트가 구현된 타입의 이름을 출력하는 기본 구현을 줄 수도 아직 없습니다. 러스트에는 리플렉션(reflection) 기능이 없으므로 런타임에 타입의 이름을 찾아볼 수 없습니다. 컴파일 시점에 코드를 만들어 낼 매크로가 필요합니다.
다음 단계는 절차적 매크로를 정의하는 것입니다. 이 글을 쓰는 시점에 절차적 매크로는 자기만의 크레이트에 있어야 합니다. 나중에는 이 제약이 없어질 수도 있습니다. 크레이트와 매크로 크레이트를 구성하는 관례는 이렇습니다. foo라는 크레이트의 커스텀 derive 절차적 매크로 크레이트는 foo_derive라고 부릅니다. hello_macro 프로젝트 안에서 hello_macro_derive라는 새 크레이트를 시작합니다.
cargo new hello_macro_derive --lib두 크레이트는 밀접하게 관련되어 있으므로, hello_macro 크레이트의 폴더 안에 절차적 매크로 크레이트를 만듭니다. hello_macro의 트레이트 정의를 바꾸면, hello_macro_derive의 절차적 매크로 구현도 바꿔야 할 것입니다. 두 크레이트는 따로 배포해야 하며, 이 크레이트들을 쓰는 프로그래머는 둘 다 의존성으로 더하고 둘 다 스코프로 가져와야 합니다. 대신 hello_macro 크레이트가 hello_macro_derive를 의존성으로 쓰고 절차적 매크로 코드를 다시 내보내게 할 수도 있습니다. 하지만 지금처럼 두 크레이트를 나눠 두면, derive 기능을 원하지 않는 프로그래머도 hello_macro만 쓸 수 있습니다.
hello_macro_derive 크레이트를 절차적 매크로 크레이트로 선언해야 합니다. 곧 보겠지만 syn과 quote 크레이트의 기능도 필요하므로, 이것들을 의존성으로 더합니다. hello_macro_derive의 Cargo.toml에 다음을 더합니다.
[lib]
proc-macro = true
[dependencies]
syn = "2.0"
quote = "1.0"절차적 매크로를 정의하기 시작하려면, 다음 코드를 hello_macro_derive 크레이트의 src/lib.rs에 둡니다. impl_hello_macro 함수의 정의를 더하기 전까지는 이 코드가 컴파일되지 않는다는 점을 알아 두세요.
use proc_macro::TokenStream;
use quote::quote;
#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
// 다룰 수 있는 구문 트리로 러스트 코드를 표현합니다
let ast = syn::parse(input).unwrap();
// 트레이트 구현을 만듭니다
impl_hello_macro(&ast)
}코드를 TokenStream을 파싱하는 hello_macro_derive 함수와, 구문 트리를 변환하는 impl_hello_macro 함수로 나누었다는 점을 보세요. 이렇게 하면 절차적 매크로를 쓰기가 더 편해집니다. 바깥 함수(여기서는 hello_macro_derive)의 코드는 보거나 만드는 거의 모든 절차적 매크로 크레이트에서 같을 것입니다. 안쪽 함수(여기서는 impl_hello_macro)의 본문에 지정하는 코드는 절차적 매크로의 목적에 따라 다를 것입니다.
새로운 크레이트 세 개를 소개했습니다. proc_macro, syn, quote입니다. proc_macro 크레이트는 러스트와 함께 오므로, Cargo.toml의 의존성에 더하지 않아도 됩니다. proc_macro 크레이트는 우리 코드에서 러스트 코드를 읽고 다룰 수 있게 해 주는 컴파일러의 API입니다.
syn 크레이트는 러스트 코드를 문자열에서 우리가 연산을 할 수 있는 자료 구조로 파싱합니다. quote 크레이트는 syn의 자료 구조를 다시 러스트 코드로 바꿉니다. 이 크레이트들 덕분에 다루고 싶은 어떤 종류의 러스트 코드든 훨씬 쉽게 파싱할 수 있습니다. 러스트 코드의 완전한 파서를 쓰는 것은 간단한 일이 아닙니다.
hello_macro_derive 함수는, 우리 라이브러리의 사용자가 타입에 #[derive(HelloMacro)]를 지정했을 때 불립니다. 이것이 가능한 까닭은, 여기서 hello_macro_derive 함수에 proc_macro_derive를 표시하고, 우리 트레이트 이름과 맞는 HelloMacro라는 이름을 지정했기 때문입니다. 이것이 대부분의 절차적 매크로가 따르는 관례입니다.
hello_macro_derive 함수는 먼저 input을 TokenStream에서, 해석하고 연산할 수 있는 자료 구조로 바꿉니다. 여기서 syn이 쓰입니다. syn의 parse 함수는 TokenStream을 받아, 파싱한 러스트 코드를 나타내는 DeriveInput 구조체를 돌려줍니다. 다음은 struct Pancakes; 문자열을 파싱해 얻는 DeriveInput 구조체의 관련된 부분입니다.
DeriveInput {
// --생략--
ident: Ident {
ident: "Pancakes",
span: #0 bytes(95..103)
},
data: Struct(
DataStruct {
struct_token: Struct,
fields: Unit,
semi_token: Some(
Semi
)
}
)
}이 구조체의 필드들은, 파싱한 러스트 코드가 ident(식별자, 곧 이름)가 Pancakes인 유닛 구조체라는 것을 보여 줍니다. 이 구조체에는 모든 종류의 러스트 코드를 설명하는 필드가 더 있습니다. 자세한 정보는 DeriveInput에 대한 syn 문서를 확인하세요.
곧 impl_hello_macro 함수를 정의할 텐데, 여기서 넣고 싶은 새 러스트 코드를 만들어 냅니다. 하지만 그전에, derive 매크로의 출력도 TokenStream이라는 점을 알아 두세요. 돌려준 TokenStream은 우리 크레이트의 사용자가 쓴 코드에 더해지므로, 사용자가 크레이트를 컴파일하면 수정된 TokenStream에서 제공하는 추가 기능을 얻습니다.
syn::parse 함수를 부른 것이 실패하면 hello_macro_derive 함수가 패닉을 내도록 unwrap을 부른다는 것을 눈치챘을 것입니다. 절차적 매크로 API에 맞추려면 proc_macro_derive 함수가 Result가 아니라 TokenStream을 돌려주어야 하므로, 절차적 매크로는 오류가 나면 패닉을 내야 합니다. 이 예는 단순하게 하려고 unwrap을 썼습니다. 프로덕션 코드에서는 panic!이나 expect로 무엇이 잘못되었는지 더 구체적인 오류 메시지를 주어야 합니다.
이제 주석을 단 러스트 코드를 TokenStream에서 DeriveInput 인스턴스로 바꾸는 코드가 있으니, 다음처럼 주석을 단 타입에 HelloMacro 트레이트를 구현하는 코드를 만들어 봅니다.
fn impl_hello_macro(ast: &syn::DeriveInput) -> TokenStream {
let name = &ast.ident;
let generated = quote! {
impl HelloMacro for #name {
fn hello_macro() {
println!("Hello, Macro! My name is {}!", stringify!(#name));
}
}
};
generated.into()
}ast.ident로, 주석을 단 타입의 이름(식별자)을 담은 Ident 구조체 인스턴스를 얻습니다. 앞의 구조체는, 사용자의 코드에 대해 impl_hello_macro 함수를 실행하면 얻는 ident의 ident 필드 값이 "Pancakes"라는 것을 보여 줍니다. 그래서 name 변수는 Ident 구조체 인스턴스를 담게 되며, 이것을 출력하면 문자열 "Pancakes"가 됩니다.
quote! 매크로로 돌려주고 싶은 러스트 코드를 정의할 수 있습니다. 컴파일러는 quote! 매크로의 실행 결과를 직접 받는 것과 다른 것을 기대하므로, 그것을 TokenStream으로 바꿔야 합니다. 이 중간 표현을 소비해 필요한 TokenStream 타입의 값을 돌려주는 into 메서드를 불러 그렇게 합니다.
quote! 매크로는 아주 멋진 템플릿 기법도 제공합니다. #name을 넣으면, quote!가 그것을 name 변수의 값으로 바꿉니다. 보통의 매크로가 동작하는 방식과 비슷하게 반복도 할 수 있습니다. 자세한 소개는 quote 크레이트의 문서를 확인하세요.
절차적 매크로가, 사용자가 주석을 단 타입에 #name으로 얻을 수 있는 HelloMacro 트레이트의 구현을 만들게 하고 싶습니다. 트레이트 구현에는 함수 hello_macro 하나가 있으며, 그 본문에 제공하고 싶은 기능이 들어 있습니다. Hello, Macro! My name is를 출력한 뒤 주석을 단 타입의 이름을 출력하는 것입니다.
여기서 쓴 stringify! 매크로는 러스트에 들어 있습니다. 이 매크로는 1 + 2 같은 러스트 표현식을 받아, 컴파일 시점에 그 표현식을 "1 + 2" 같은 문자열 리터럴로 바꿉니다. 이것은 format!이나 println!과 다릅니다. 그 매크로들은 표현식을 평가하고 그 결과를 String으로 바꿉니다. #name 입력이 문자 그대로 출력할 표현식일 수도 있으므로 stringify!를 씁니다. stringify!를 쓰면 컴파일 시점에 #name을 문자열 리터럴로 바꿔 할당도 아낄 수 있습니다.
이 시점에서 cargo build는 hello_macro와 hello_macro_derive 모두에서 성공적으로 끝날 것입니다. 이 크레이트들을 앞의 main 코드에 이어, 절차적 매크로가 동작하는 것을 봅니다. 프로젝트 폴더에서 cargo new pancakes로 새 바이너리 프로젝트를 만듭니다. pancakes 크레이트의 Cargo.toml에 hello_macro와 hello_macro_derive를 의존성으로 더해야 합니다. 이 hello_macro와 hello_macro_derive 버전을 crates.io에 배포한다면 보통의 의존성이 될 것이고, 그렇지 않다면 다음처럼 path 의존성으로 지정할 수 있습니다.
[dependencies]
hello_macro = { path = "../hello_macro" }
hello_macro_derive = { path = "../hello_macro/hello_macro_derive" }앞의 #[derive(HelloMacro)] 코드를 src/main.rs에 두고 cargo run을 실행하면, Hello, Macro! My name is Pancakes!가 출력되어야 합니다. 절차적 매크로의 HelloMacro 트레이트 구현이, pancakes 크레이트가 구현하지 않아도 들어갔습니다. #[derive(HelloMacro)]가 트레이트 구현을 더한 것입니다.
Hello, Macro! My name is Pancakes!다음으로 다른 종류의 절차적 매크로가 커스텀 derive 매크로와 어떻게 다른지 봅니다.
속성형 매크로
속성형(attribute-like) 매크로는 커스텀 derive 매크로와 비슷하지만, derive 속성에 쓸 코드를 만드는 대신 새 속성을 만들게 해 줍니다. 또 더 유연합니다. derive는 구조체, 열거형, 유니온에만 쓸 수 있지만, 속성은 함수 같은 다른 아이템에도 쓸 수 있습니다. 다음은 속성형 매크로를 쓰는 예입니다. 웹 애플리케이션 프레임워크를 쓸 때 함수에 주석을 다는 route라는 속성이 있다고 합시다.
#[route(GET, "/")]
fn index() {이 #[route] 속성은 프레임워크가 절차적 매크로로 정의할 것입니다. 매크로 정의 함수의 시그니처는 다음과 같을 것입니다.
#[proc_macro_attribute]
pub fn route(attr: TokenStream, item: TokenStream) -> TokenStream {여기에는 TokenStream 타입의 매개변수가 둘 있습니다. 첫째는 속성의 내용인 GET, "/" 부분입니다. 둘째는 속성이 붙은 아이템의 본문으로, 이 경우 fn index() {}와 함수의 나머지 본문입니다.
그 밖에는 속성형 매크로도 커스텀 derive 매크로와 같은 방식으로 동작합니다. proc-macro 크레이트 타입으로 크레이트를 만들고, 원하는 코드를 만드는 함수를 구현합니다.
함수형 매크로
함수형(function-like) 매크로는 함수 호출처럼 보이는 매크로를 정의합니다. macro_rules! 매크로처럼 함수보다 유연합니다. 예를 들어 개수가 정해지지 않은 인수를 받을 수 있습니다. 하지만 macro_rules! 매크로는, 앞의 일반적인 메타프로그래밍을 위한 macro_rules!를 사용한 선언적 매크로에서 이야기한 match와 비슷한 문법으로만 정의할 수 있습니다. 함수형 매크로는 TokenStream 매개변수를 받으며, 그 정의는 다른 두 종류의 절차적 매크로처럼 러스트 코드로 그 TokenStream을 다룹니다. 함수형 매크로의 예는 다음처럼 불릴 수 있는 sql! 매크로입니다.
let sql = sql!(SELECT * FROM posts WHERE id=1);이 매크로는 안의 SQL 문을 파싱하고 문법적으로 올바른지 검사할 것입니다. 이것은 macro_rules! 매크로가 할 수 있는 것보다 훨씬 복잡한 처리입니다. sql! 매크로는 다음처럼 정의될 것입니다.
#[proc_macro]
pub fn sql(input: TokenStream) -> TokenStream {이 정의는 커스텀 derive 매크로의 시그니처와 비슷합니다. 괄호 안의 토큰을 받아, 만들고 싶은 코드를 돌려줍니다.
정리
휴! 이제 자주 쓰지는 않겠지만, 아주 특정한 상황에서 쓸 수 있다는 것을 알게 될 러스트의 기능 몇 가지를 도구 상자에 넣었습니다. 오류 메시지의 제안이나 다른 사람의 코드에서 보게 되었을 때 이 개념과 문법을 알아볼 수 있도록, 여러 복잡한 주제를 소개했습니다. 이번 장을 해결책을 찾는 데 쓸 참고 자료로 쓰세요.
- 매크로는 컴파일 시점에 코드를 만드는 메타프로그래밍으로, 개수가 정해지지 않은 인수를 받거나 타입에 트레이트를 구현할 수 있지만, 정의가 복잡하고 쓰기 전에 정의하거나 가져와야 합니다.
macro_rules!로 만드는 선언적 매크로는$( $x:expr ),*같은 패턴을 코드 구조에 맞추고,$( ... )*로 템플릿을 되풀이해 코드를 만들며,#[macro_export]로 크레이트 바깥에 내보냅니다.- 절차적 매크로는 별도의
proc-macro크레이트에서TokenStream을 받아TokenStream을 돌려주는 함수로 정의하며, 커스텀derive, 속성형, 함수형의 세 종류가 있습니다. - 커스텀
derive매크로는#[proc_macro_derive(이름)]을 붙인 함수에서syn으로 입력을 파싱하고quote!로 트레이트 구현 코드를 만들어 돌려줍니다. - 속성형 매크로는
#[proc_macro_attribute]로 속성의 내용과 아이템을 받고, 함수형 매크로는#[proc_macro]로 괄호 안의 토큰을 받습니다.
연습 문제
1. 무엇으로 펼쳐질까
다음 매크로를 square_all![2, 3]으로 부르면 어떤 코드로 펼쳐지나요? square_all![]로 부르면 어떻게 되나요?
macro_rules! square_all {
( $( $x:expr ),* ) => {
vec![ $( $x * $x ),* ]
};
}해설 보기
$x가 2와 3에 각각 맞으므로 vec![2 * 2, 3 * 3]으로 펼쳐지고, 실행하면 [4, 9]가 됩니다. *는 0번 이상 맞으므로 square_all![]도 패턴에 맞아 vec![]로 펼쳐지지만, 원소가 없어 타입을 추론할 수 없으므로 let v: Vec<i32> = square_all![];처럼 타입을 적어야 컴파일됩니다. 또 $x가 두 번 들어가므로 square_all![f()]라면 f()가 두 번 실행된다는 점도 알아 두어야 합니다.
2. 어떤 매크로를 쓸까
(가) 구조체의 필드 이름을 모두 출력하는 메서드를 #[derive(...)] 하나로 더하고 싶다. (나) 함수 위에 붙여 실행 시간을 재는 코드를 감싸고 싶다. (다) 개수가 정해지지 않은 키와 값의 쌍으로 HashMap을 만드는 짧은 문법이 필요하다. 각각 어떤 매크로가 알맞나요?
해설 보기
(가)는 구조체의 정의를 읽어 트레이트 구현을 만들어야 하므로 커스텀 derive 절차적 매크로가 알맞습니다. (나)는 함수라는 아이템에 붙여 그 본문을 바꾸는 것이므로 속성형 매크로를 씁니다. (다)는 $( $k:expr => $v:expr ),* 같은 패턴으로 충분하므로, 별도 크레이트가 필요 없는 macro_rules! 선언적 매크로가 가장 간단합니다.
이것으로 18장을 마칩니다. 다음 장의 싱글스레드 웹 서버부터는 지금까지 배운 것을 모아 멀티스레드 웹 서버를 만드는 마지막 프로젝트를 진행합니다.