Rust Procedural Macros By Example

안녕하세요. 제가 최근에 Rust 공부를 시작했는데요~
그래서 오늘은 Rust 1.29.0 부터 stable 이 된 Procedural Macros 에 대해서 포스팅해보도록 하겠습니다.
포스팅은 구구절절한 설명보다는 예제 코드 위주가 될 예정입니다!

Rust 의 매크로 시스템

Rust 의 매크로 시스템은 매우 강력한데요. 크게 다음과 같이 분류 할 수 있습니다.
  1. Declarative Macros
  2. Procedural Macros
    • Function-like macros
    • Derive mode macros
    • Attribute macros
첫 번째는 Declarative Macros 는 일반적으로 개발자들이 흔히 알고 있는 “선언적” 형태의 매크로 방식인데요. C/C++ 등의 타언어들과의 차이점은 문자열 전처리기 방식이 아니라 Abstract Syntax Tree 를 직접 건드리는 방식이라는 점입니다.
// 출처 : https://doc.rust-lang.org/rust-by-example/macros/dsl.html
macro_rules! calculate {
    (eval $e:expr) => {{
        {
            let val: usize = $e; // Force types to be integers
            println!("{} = {}", stringify!{$e}, val);
        }
    }};
}

fn main() {
    calculate! {
        eval 1 + 2 // hehehe `eval` is _not_ a Rust keyword!
    }
    calculate! {
        eval (1 + 2) * (3 / 4)
    }
}
Declarative Macros 는 이번 포스팅의 주제가 아니므로, 더 이상 언급하진 않겠습니다.
두 번째는 이번 포스팅의 주제인 Procedural Macros 인데요. 함수의 실행 으로서 매크로를 정의하는 방식입니다.
일단 큰 틀에서 쉽게 얘기하자면 아래와 같습니다.
원래의 AST --input--> "함수" --output--> 수정된 AST
“함수” 는 인풋으로 원래 소스코드의 AST(Abstract Syntax Tree) 를 받고요. 함수 내부에서 AST 를 수정해서 아웃풋으로 반환합니다. 그러면 이제 실제로 컴파일되는건 수정된 AST 가 되는 것이죠.
여기서 주목해야할 점은 저 “함수” 는 그냥 일반적인 Rust 코드로 작성된다는 점입니다. 즉, 일반적인 Rust 코드로서 Procedural Macro 를 정의할 수 있고, 이 "함수"는 컴파일 시점에 실행되게 됩니다. (일종의 컴파일러 플러그인같은 느낌입니다.) 실제 코드로 확인하자면 아래와 같은 식으로 Procedural Macro 를 정의하게 됩니다.
#[proc_macro]
pub fn some_macro(input: TokenStream) -> TokenStream
{
 // Do something
}
실제 코드상에서는 AST 자료구조가 아닌 Token Stream 이 input, output 으로 사용되는데요. AST 자료구조보다는 Token Stream 을 사용하는게 인터페이스상으로 더 안정적이기 때문이라고 합니다. (아무래도 AST 자료구조 인터페이스는 변경될 가능성이 더 클테니까요.) 기초 컴파일러 이론을 잘 모르신다면 Token 이라는 개념을 모르실 수도 있는데요. 위키의 “Token” 부분을 읽어보시면 이해가 되실 겁니다.

Cargo.toml 및 예제 환경

Procedural Macros 를 정의하기 위해서는 Cargo.toml 에 반드시 다음과 같이 명시해줘야만 합니다.
[lib]
proc-macro = true
그리고 이 포스팅의 예제에서 사용된 crate dependency 는 다음과 같습니다.
[dependencies]
proc-macro2 = "0.4"
syn = { version = "0.15", features = ["full", "extra-traits"] }
quote = "0.6"
또한 사용한 툴체인은 beta-x86_64-pc-windows-msvc - rustc 1.33.0-beta.6 (b203178b6 2019-02-05) 이고 에디션은 2018 입니다.

Function-like macros

이제부터 Procedural Macros 의 세부 분류중 하나인 Function-like macros 에 대해서 알아보겠습니다. 거두절미하고 간단한 예제를 통해 확인해봅시다.
//
// library user's code
//
use my_example::make_function;
make_function!();
fn main() {
    // 생성된 함수를 호출합니다.
    generated_function();
}

//
// proc-macro crate's code
//
extern crate proc_macro;
use proc_macro::TokenStream;
#[proc_macro]
pub fn make_function(_: TokenStream) -> TokenStream {
    println!("make_function() called in compile time!");
    // 함수의 정의를 담은 문자열을 파싱해서 TokenStream 으로 만들어 반환합니다.
    "fn generated_function() {
        println!(\"I'm the generated function by procedural macro.\");
    }".parse().unwrap()
}

아주 간단한 예제입니다. 사진에서 보시다시피 make_function() 함수는 컴파일타임에 실행됩니다.
그러면 이제부터 이 간단한 예제를 계속 발전시켜나가 볼텐데요.
먼저, 저 매크로에 생성되는 함수의 이름을 사용자가 설정할 수 있도록 하는 기능을 추가해보겠습니다~
//
// library user's code
//
use my_example::make_function;

// fn generated_function() { --snip-- }
make_function!();
// fn foobar() { --snip-- }
make_function!(foobar);

fn main() {
    generated_function();
    foobar();
}

//
// proc-macro crate's code
//
extern crate proc_macro;
use proc_macro::TokenStream;
use quote::quote;

#[proc_macro]
pub fn make_function(arg: TokenStream) -> TokenStream {
    // syn crate 를 활용해 매크로 인자를 파싱합니다.
    // Identifier 로 파싱이 가능한 경우 인자를 함수명으로 사용하고,
    // 불가능한 경우, "generated_function" 을 함수명으로 사용합니다.
    let func_name: syn::Ident = match syn::parse(arg) {
        Ok(func_name) => func_name,
        Err(..) => syn::Ident::new("generated_function", proc_macro2::Span::call_site())
    };
    // quote! 를 활용하면 소스코드를 TokenStream 으로 변환할 수 있습니다.
    let tokens = quote!{
        fn #func_name () {
            println!("I'm the generated function by procedural macro.");
        }
    };
    tokens.into()
}
새로운게 등장했죠? 주석을 통해 기본적으로 설명을 드리긴 했는데요. syn::Ident 는 Identifier 의 약자로서 키워드나 변수명을 나타내는 구조체입니다.
syn::Ident::new("generated_function", proc_macro2::Span::call_site())
를 보면, 첫 번째 인자로 Identifier 의 이름이 들어가고 두 번째 인자로 proc_macro2::Span::call_site() 가 들어가는데, proc_macro2::Span 는 소스코드의 특정 영역을 의미하는 구조체입니다. 여기서 call_site() 에 주목해야하는데 이 부분에 대한 설명은 여기를 읽어보시면 됩니다. 읽다보면 hygiene 이라는 용어가 등장하는데요. 이 부분은 요기를 읽어보시면 대략적인 개념을 잡으실 수 있으십니다. 아무튼 그래서 이 예제에서는 make_function() 매크로가 생성한 함수를 매크로 외부에서 호출할 수 있어야하기 때문에 call_site() 를 사용하였습니다.
그 외에 부족한 설명은 quote, syn 을 참고하시면 될 것 같습니다~
자, 그러면 다음으로 이제 생성되는 함수의 인자 타입을 사용자가 설정할 수 있도록 하는 기능을 추가해보겠습니다.
//
// library user's code
//
use my_example::make_function;

make_function!();
make_function!(foo, u32, f64,);
make_function!(bar, String, u32, f64);
//make_function!(todo, &str); // 지원 안됨.

fn main() {
    generated_function();
    foo(1, 1.23);
    bar(String::from("test"), 1, 1.23);
}

//
// proc-macro crate's code
//
extern crate proc_macro;
use quote::quote;

struct ParsedArguments {
    func_name: proc_macro2::Ident,
    arg_types: Vec<proc_macro2::Ident>,
}

fn parse_arguments(args: proc_macro2::TokenStream) -> ParsedArguments {
    let mut parsed_arg = ParsedArguments {
        func_name: proc_macro2::Ident::new("generated_function", proc_macro2::Span::call_site()),
        arg_types: vec![],
    };

    let mut arg_vec = vec![];
    for arg in args.into_iter() {
        arg_vec.push(arg);
    }

    if arg_vec.len() > 0 {
        // 첫 번째 인자는 함수명을 의미하는 identifier 이여야만 한다.
        let func_name: proc_macro2::Ident = match &arg_vec[0] {
            proc_macro2::TokenTree::Ident(ref func_name) => func_name.clone(),
            _ => panic!("The first token must be an identifier."),
        };
        parsed_arg.func_name = func_name;

        // 그 다음부터는 comma (,) 와 인자 타입을 의미하는 identifier 가 반복해서 와야한다.
        let mut i = 1;
        while i < arg_vec.len() {
            // comma 인지 체크
            match &arg_vec[i] {
                proc_macro2::TokenTree::Punct(ref punct) => {
                    if punct.as_char() != ',' {
                        panic!("The token {} must be a comma.", i + 1);
                    }
                }
                _ => panic!("The token {} must be a comma.", i + 1),
            };

            if i + 1 >= arg_vec.len() {
                break;
            }

            // Identifier 인지 체크
            let type_name = match &arg_vec[i + 1] {
                proc_macro2::TokenTree::Ident(ref type_name) => type_name.clone(),
                _ => panic!("The token {} must be an identifier.", i + 2),
            };
            parsed_arg.arg_types.push(type_name);

            i += 2;
        }
    }

    parsed_arg
}

#[proc_macro]
pub fn make_function(args: proc_macro::TokenStream) -> proc_macro::TokenStream {
    let args = proc_macro2::TokenStream::from(args);
    let parsed_args = parse_arguments(args);
    let func_name = parsed_args.func_name;
    let arg_types = parsed_args.arg_types;

    let tokens = quote!{
        fn #func_name (#(_: #arg_types),*) {
            println!("I'm the generated function by procedural macro.");
        }
    };
    tokens.into()
}
다소 길어지긴 했지만, 차근차근 보시면 이해하실 수 있을 것 같습니다. 이전 예제에 비해 달라진 점은 proc_macro2 crate 를 많이 활용했다는 점인데요. proc_macro2 에 대한 설명은 Github Repo 을 참고하시면 될 것 같습니다. 그리고 quote! 쪽에 #(_: #arg_types),* 가 보이실텐데요. quote! 가 제공하는 기능중 하나인 Repetition 을 활용한 것입니다.
그러면 이번에는 위의 parse_arguments() 함수를 syn::parse 를 활용해서 리펙토링 해보도록 하겠습니다.
//
// library user's code
//
use my_example::make_function;

make_function!();
make_function!(foo, u32, f64,);
make_function!(bar, String, u32, f64);
make_function!(error, String, u32,, f64);  // 컴파일 에러
//make_function!(todo, &str); // 지원 안됨.

fn main() {
    generated_function();
    foo(1, 1.23);
    bar(String::from("test"), 1, 1.23);
}

//
// proc-macro crate's code
//
extern crate proc_macro;
use quote::quote;

struct ParsedArguments {
    func_name: syn::Ident,
    arg_types: Vec<syn::Ident>,
}

impl syn::parse::Parse for ParsedArguments {
    fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
        let mut parsed_args = ParsedArguments {
            func_name: proc_macro2::Ident::new("generated_function", proc_macro2::Span::call_site()),
            arg_types: vec![],
        };

        if !input.is_empty() {
            // 첫 번째 인자는 함수명을 의미하는 identifier 이여야만 한다.
            parsed_args.func_name = match input.parse() {
                Ok(func_name) => func_name,
                Err(err) => return Err(syn::Error::new(err.span(), format!(
                    "The first token must be an identifier."))),
            };

            // 그 다음부터는 comma (,) 와 인자 타입을 의미하는 identifier 가 반복해서 와야한다.
            let mut i = 1;
            while !input.is_empty() {
                // comma 인지 체크
                match input.parse::<syn::token::Comma>() {
                    Ok(_) => {},
                    Err(err) => return Err(syn::Error::new(err.span(), format!(
                        "The token {} must be a comma.", i + 1))),
                };
                i += 1;

                if input.is_empty() {
                    break;
                }

                // Identifier 인지 체크
                let type_name = match input.parse::<syn::Ident>() {
                    Ok(type_name) => type_name,
                    Err(err) => return Err(syn::Error::new(err.span(), format!(
                        "The token {} must be an identifier.", i + 1))),
                };
                parsed_args.arg_types.push(type_name);
                i += 1;
            }
        }

        Ok(parsed_args)
    }
}

#[proc_macro]
pub fn make_function(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
    let parsed_args = syn::parse_macro_input!(input as ParsedArguments);
    let func_name = parsed_args.func_name;
    let arg_types = parsed_args.arg_types;

    let tokens = quote!{
        fn #func_name (#(_: #arg_types),*) {
            println!("I'm the generated function by procedural macro.");
        }
    };
    tokens.into()
}
syn crate 의 syn::parse::Parse, syn::parse_macro_input 를 활용해서 리펙토링하고 나니 코드가 더 깔끔해졌습니다. (컴파일에러 메시지를 customize 하기 위해서 코드가 약간 복잡해진 것을 감안해주세요 ㅎㅎ) 뿐만아니라, 컴파일에러시 다음 사진과 같이 구체적으로 어디가 잘못됐는지 사용자에게 알려줄 수 있게 되었습니다.

자, 지금까지 차근차근 예제를 발전시켜나가면서 여러가지 내용들을 익혀봤는데요. 여기서 다룬 내용들은 Function-like Macros 에만 해당되는 내용은 아니고, Attribute MacrosDerive mode macros 에도 모두 적용가능한 내용들입니다~ 그러면 이제 다른 종류의 매크로들에 대해서도 간단하게 살펴보도록 하겠습니다.

Attribute Macros

이 것 역시 거두절미하고 바로 간단한 예제부터 보겠습니다.
//
// library user's code
//
use my_example::foobar;

// attr: ""
// item: "fn func_1() { }"
#[foobar]
fn func_1() {}

// attr: "a , b , c , 1 , 2"
// item: "fn func_2() { }"
#[foobar(a, b, c, 1, 2)]
fn func_2() {}

// attr: "x , y"
// item: "struct Struct;"
#[foobar(x, y)]
struct Struct;

fn main() {
}

//
// proc-macro crate's code
//
extern crate proc_macro;
use proc_macro::TokenStream;

#[proc_macro_attribute]
pub fn foobar(attr: TokenStream, item: TokenStream) -> TokenStream {
    println!("attr: \"{}\"", attr.to_string());
    println!("item: \"{}\"", item.to_string());
    item
}
Attribute Macros 의 경우에는 파라미터로 2개의 TokenStream 이 들어오는데요. 각각이 무엇을 의미하는지는 위 예제를 보면 알 수 있습니다.
그러면, 이번에는 실제로 쓸만한 걸 만들어 볼까요?? Python 에서의 function decorator 와 비슷한 것을 만들어보도록 하겠습니다.
//
// library user's code
//
use my_example::decorated;

#[decorated(decorator)]
fn foo(s: &str, x: u32, y: u32) -> u32 {
    println!("foo(\"{}\", {}, {}) -> {}", s, x, y, x + y);
    x + y
}

fn decorator<F>(f: F, s: &str, x: u32, y: u32) -> u32 where F: Fn(&str, u32, u32) -> u32 {
    let ret = f("twice!", x * 2, y * 2);
    println!("decorator<F>(f, \"{}\", {}, {}) -> {}", s, x, y, ret);
    ret
}

fn main() {
    // Output:
    //     foo("twice!", 2, 4) -> 6
    //     decorator<F>(f, "hello", 1, 2) -> 6
    foo("hello", 1, 2);
}

//
// proc-macro crate's code
//
// https://github.com/Manishearth/rust-adorn 를 참고하여 작성하였습니다.
extern crate proc_macro;
use proc_macro::TokenStream;
use quote::quote;

#[proc_macro_attribute]
pub fn decorated(attr: TokenStream, input: TokenStream) -> TokenStream {
    let decorator_name: syn::Ident = syn::parse(attr).unwrap();
    let mut original_func: syn::ItemFn = syn::parse(input).unwrap();

    // decorator 함수에 전달될 인자들의 이름이 저장되는 벡터
    let mut args_for_decorator = Vec::with_capacity(original_func.decl.inputs.len() + 1);

    let renamed_func_name = syn::Ident::new("_renamed_func", proc_macro2::Span::call_site());
    args_for_decorator.push(quote!(#renamed_func_name));

    // 원본 함수의 이름을 변경한다.
    let original_func_name =  std::mem::replace(&mut original_func.ident, renamed_func_name);

    // 새로 만들어질 함수의 인자들이 저장되는 벡터
    let mut new_args = vec!();
    let mut arg_no = 0;

    // 원본 함수의 인자들을 순회한다.
    for org_arg in original_func.decl.inputs.iter() {
        let arg_name = syn::Ident::new(format!("_arg_{}", arg_no).as_str(),
            proc_macro2::Span::call_site());

        match org_arg {
            // See: https://docs.rs/syn/0.15.26/syn/enum.FnArg.html
            syn::FnArg::Captured(ref cap) => {
                let type_name = &cap.ty;
                new_args.push(quote!(#arg_name: #type_name));
            }
            _ => panic!("Unexpected argument {:?}", org_arg)
        }
        args_for_decorator.push(quote!(#arg_name));
        arg_no += 1;
    }

    let attrs = &original_func.attrs;
    let vis = &original_func.vis;
    let constness = &original_func.constness;
    let unsafety = &original_func.unsafety;
    let abi = &original_func.abi;
    let generics = &original_func.decl.generics;
    let output = &original_func.decl.output;

    // 원본 함수의 이름을 가진 새로운 함수를 생성한다.
    // 새로운 함수는 기존 원본 함수와 똑같은 이름, 속성, constness 등을 가진다.
    // 원본 함수는 이름이 변경된 형태로 새로운 함수의 내부에 정의되게 된다.
    // 새로운 함수는 원본 함수를 첫 번째 인자로, 그리고 자신의 인자들을 그대로 나머지 인자로
    // 해서 decorator 함수를 호출한다.
    let tokens = quote!(
        #(#attrs),*
        #vis #constness #unsafety #abi fn #original_func_name #generics(#(#new_args),*) #output {
            #original_func
            #decorator_name(#(#args_for_decorator),*)
        }
    );

    tokens.into()
}
어느정도 그럴듯한 python 스타일의 decorator 가 만들어졌습니다! (위 코드는 adorn 의 코드를 참고하여 Rust 2018 에디션 기반으로 제가 새롭게 rewriting 하였습니다.)

Derive Mode Macros

위에 제가 작성한 Function-like MacrosAttribute Macros 의 예제들을 이해하셨다면 Derive Mode Macros 를 제가 굳히 설명드리는 건 불필요할 것 같습니다. 관심있으신 분들은 Rust 레퍼런스를 참고하여 직접 코드를 작성해보시면 될 것 같습니다!

마무리

Rust 의 매크로 시스템은 정말 엄청 강력한 것 같습니다. 언어의 기능을 확장하고 meta programming 을 하는데 매우 수월한 것 같습니다. C++ 에서도 compile-time meta programming 은 매우 인기있는(?) 주제인데요. C++을 주로 했던 사람 입장에서 Rust 의 Procedural Macros 는 그냥 일반 Rust 코드가 compile time 에 실행된다는 게 매우 충격적(?) 이고 부러웠습니다…ㅎㅎ
아, 그리고 이번 포스팅을 하면서 제가 처음으로 20줄이상의 Rust 코드를 짜봤는데요… Rust 문법도 안 익숙한 상태에서 바로 매크로를 만들어보려니까 다소 힘들었었네요;; ㅎㅎ 아무튼 저의 러스트 hello world 코드라서 뭔가 이상한 부분이 있을 수도 있는데 양해부탁드립니다… (댓글로 잘못된 점을 지적해주시면 더더욱 감사하겠습니다!)
앞으로 Rust 열심히 공부할께요…!

참고자료


이 포스팅은 삼성 소프트웨어 멤버십 기술 블로그에 동시에 포스팅됩니다.

댓글