절차적 매크로, syn, quote, Attribute

절차적 매크로는 함수(procedure)처럼 생겼다고 해서 절차적 매크로(procedural macro)입니다.

#[foo(bar = 10)] 등의 모습은 속성이며, 이는 Attribute (속성) 문법입니다. 여기서 만들 절차적 매크로는 #[derive(Foo)] 등의 Derive 속성입니다.

일반적인 매크로와의 차이점이라 하면, 일반적인 매크로는 패턴에 맞게 대치하는 반면, 절차적 매크로는 코드를 추가합니다.

절차적 매크로를 만들기위해, proc-macro (절차적 매크로) 크레이트를 생성해줍니다.

왜 따로 절차적 매크로 크레이트를 분리해야 하냐는 질문이 분명 있을 겁니다:

절차적 매크로 크레이트는 일반적인 크레이트처럼 생겼지만, 컴파일 시 문법 (AST 등)를 수정한다는 점에서, 일반적인 크레이트 보단 컴파일러 플러그인 정도에 가깝습니다.

이에 대해 한 가지 더 재밌는 사실을 알 수 있는데, 절차적 매크로 크레이트는, 빌드 시 절차적 매크로가 아닌 크레이트들과 연결되지 않습니다. 때문에, 굳이 타겟 아키텍처에 맞게 빌드할 필요는 없습니다.

오늘의 예제 프로젝트 구조는 다음과 같습니다.
(예제를 무시하고 절차적 매크로 선언만 보고 싶다면, 무시해도 괜찮습니다.):

  • /foo 크레이트
    • ``/src`
      • main.rs
    • Cargo.toml
  • /foo_derive (절차적 매크로 크레이트)
    • /src
      • lib.rs
    • Cargo.toml

먼저 아래의 명령어를 입력하여, 크레이트를 생성해줍니다.:

$ cargo new foo --bin
$ cargo new foo_derive --lib

foo 크레이트의 Cargo.toml에서 foo_derive 의존성을 추가해주어야 합니다:

[dependencies]
foo_derive = { path = "../foo_derive" }

또한, 절차적 매크로는 proc-macro를 따로 추가해주어야 합니다. foo_derive 크레이트의 Cargo.toml:

# 생략

[lib]
proc-macro = true

rust analyzer 등을 사용한다면, foo_derive 크레이트에서 오류가 발생할 것입니다. 이는 매우 정상적이며, 절차적 매크로 크레이트에선 절차적 매크로만 선언되어야 합니다.

syn과 quote

그리고 몇 가지의 크레이트가 더 필요한데, syn 크레이트와 quote 크레이트가 필요합니다. 각각의 크레이트가 하는 일은 다음과 같습니다:

  • syn: TokenStream을 분석합니다. 또한, 우리가 러스트 코드에서 사용할 수 있는 AST 관련 데이터 등을 제공합니다.
  • quote: 러스트 코드를 받아, TokenStream으로 반환합니다.

각 크레이트에 대해 자세히 알고 싶다면, 각 크레이트의 문서를 참고하길 바랍니다.

필자는 현재의 최신 버전인 syn 1.0.102, quote 1.0.21를 사용합니다:

[dependencies]
quote = "1.0.21"
syn = "1.0.102"

이제 준비는 끝났습니다. 절차적 매크로 선언은 다음과 같습니다:

#![allow(unused)]
fn main() {
use proc_macro::TokenStream;

#[proc_macro_derive(FooMacro)]
pub fn foo_macro(input: TokenStream) -> TokenStream {
    input
}
}

이 아직 코드는 무의미합니다. 이제 아까 추가해두었던 synquote를 사용해봅시다. 우리가 만들 절차적 매크로는, FooMacro를 호출하면 Person 트레잇을 구현해주는 절차적 매크로입니다.

foo 크레이트의 main.rs:

use foo_derive::FooMacro;

trait Person {
    fn say_hello(&self);
}

#[derive(FooMacro)]
struct A {
    name: String,
}

fn main() {
    let a = A {
        name: "John".to_string(),
    };
    a.say_hello();
}

이제 synquote 크레이트가 필요합니다. synparse_macro_input 매크로를 이용해서, TokenStream을 분석합니다:

foo_derive 크레이트의 lib.rs:

#![allow(unused)]
fn main() {
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput};

#[proc_macro_derive(FooMacro)]
pub fn foo_macro(input: TokenStream) -> TokenStream {
    let DeriveInput { ident, .. } = parse_macro_input!(input as DeriveInput);

    let result = quote! {
        impl Person for #ident {
            fn say_hello(&self) {
                println!("Hello, my name is {}", self.name);
            }
        }
    };

    result.into()
}
}

여기서 DeriveInput 구조체는 다음과 같은 필드가 존재합니다:

  • attrs: #[foo] 같은 속성을 뜻합니다.
  • vis: pub, pub(crate) 같은 가시성을 뜻합니다.
  • ident: 그 아이템의 identifier (식별자)를 뜻합니다.
  • generics: 제네릭 또는 where 절을 뜻합니다.
  • data: 구조체의 경우 필드 등을 뜻합니다. (struct, enum, union)

여기서 우리가 사용한 것은 ident 입니다.

그리고 우리는 quote 크레이트를 사용하여, quote 매크로를 호출했습니다.

만약 quote 크레이트 없었다면, 우리는 직접 AST를 하나하나 구현했어야 했을 겁니다. 다행히 quote! {} 매크로는 TokenStream을 반환하므로, into를 호출하여 반환한다.

이제 foo 크레이트를 실행하면, 성공적으로 "Hello, my name is John" 이 출력되었을 것입니다. 예상했겠지만, Debug, Default 등의 매크로도 위와 같은 원리입니다. (물론 이들은 built-in이긴 합니다만)

실제로 Debug의 예시로, 아래의 둘 모두 똑같이 작동합니다:

#![allow(unused)]
fn main() {
#[derive(Debug)]
struct Foo {
    bar: usize
}
}

만약, Debug 매크로가 없었다면, 우리는 아래와 같이 일일이 구현해주었어야 할 것입니다:

#![allow(unused)]
fn main() {
struct Foo {
    bar: usize,
}

impl std::fmt::Debug for Foo {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("Foo").field("bar", &self.bar).finish()
    }
}
}

아까 우리는 DeriveInput에서 attrs가 존재하는 것을 확인했습니다.

속성(attrs)을 손수 만들어보고 싶은데, syn만 이용해선 힘들 겁니다.

이제, darling 크레이트를 사용해봅시다.

darling

그리 유명한 크레이트는 아니나, darling 크레이트는 속성을 쉽게 파싱 할 수 있는 유용한 크레이트입니다.

필자는 가장 최신 버전인 0.14.1을 사용했습니다:

#![allow(unused)]
fn main() {
[dependencies]
darling = "0.14.1"
quote = "1.0.21"
syn = "1.0.102"
}

darling 크레이트는 FromDeriveInput 절차적 매크로와, 그에 따른 darling 속성을 이용하여, attrs를 파싱 할 수 있습니다:

#![allow(unused)]
fn main() {
use darling::FromDeriveInput;
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput};

#[derive(FromDeriveInput, Default)]
#[darling(default, attributes(nickname))]
struct Attributes {
    nickname: Option<String>,
}

#[proc_macro_derive(FooMacro, attributes(nickname))]
pub fn foo_macro(input: TokenStream) -> TokenStream {
    let input = parse_macro_input!(input as DeriveInput);
    let attrs = Attributes::from_derive_input(&input).unwrap();

    let DeriveInput { ident, .. } = input;

    let my_name = match attrs.nickname {
        Some(nickname) => quote! {
            fn say_hello(&self) {
                println!("Hello, my name is {}, but you can call me {}.", self.name, #nickname);
            }
        },
        None => quote! {
            fn say_hello(&self) {
                println!("Hello, my name is {}.", self.name);
            }
        },
    };

    let result = quote! {
        impl Person for #ident {
            my_name
        }
    };

    result.into()
}
}

코드가 좀 복잡해졌습니다. 하지만 수정하기 전의 코드에서 조금의 코드만 추가되었을 뿐입니다.

FromDeriveInput를 사용했다면, proc_macro_derive 속성에도 attributes를 추가하여 이런 하위 속성이 있다고 명시해주어야 합니다.

그런데 만약 Attributes에서 Option<T>를 사용하지 않으면 어떻게 될까요?

눈치가 빠른 독자라면 Default 트레잇이 적용되었기 때문에, 빈 문자열이 반환됩니다. 이것을 방지하기 위해, Option<T>를 사용했습니다. 생각하지 못했다고 실망하진 맙시다.

이제 다음과 같은 코드가 가능해졌습니다:

use foo_derive::FooMacro;

trait Person {
    fn say_hello(&self);
}

#[derive(FooMacro)]
struct A {
    name: String,
}

#[derive(FooMacro)]
#[nickname(nickname = "Bob")]
struct B {
    name: String,
}

fn main() {
    let a = A {
        name: "John".to_string(),
    };
    a.say_hello();

    let b = B {
        name: "John".to_string(),
    };
    b.say_hello();
}