#35 매크로

Pt J·2020년 9월 27일
3

[完] Rust Programming

목록 보기
38/41
post-thumbnail

이 시리즈는 Rust 공식문서를 통해 공부한 흔적임을 밝힙니다.

우리는 매크로라고 불리는 녀석을 자주 사용해왔지만 이에 대해 제대로 설명한 적이 없다.
println!, panic!과 같이, 함수와 비슷하지만 이름 뒤에 !가 붙어 있는 녀석들 말이다.
// 사실 이제 곧 다루겠지만 그런 형태의 매크로만 매크로인 것은 아니다.

매크로와 함수

매크로는 언뜻 보기에 함수와 많이 닮아 있다.
따라서 이 둘을 비교하는 시간을 좀 갖겠다.

매크로는 다른 코드를 작성하는 코드다.
이런 작업을 meta-programming이라고 한다.
매크로를 사용하면 개발자가 직접 그 코드를 작성하는 것보다 생산적이다.

매크로와 함수의 가장 큰 차이 중 하나는 매개변수다.
함수는 그 시그니처에 정해져 있는 매개변수의 자료형과 개수를 정확하게 따라야 한다.
반면 매크로는 그 수가 가변적이다.
가령 println!만 해도 println!("hello");처럼 하나의 인자를 전달할 수도 있고
println!("hello {}", name);처럼 두 개의 인자를 전달할 수도 있고
얼만든지 많은 인자를 전달할 수 있다.

그리고 실행 시간에 호출되는 함수와 달리
매크로는 컴파일러가 코드의 의미를 해석하기 전에 그것이 나타내는 코드로 대치된다.
이는 함수가 하지 못하는 몇몇 기능들을 하게 해준다.
주어진 자료형에 어떤 트레이트를 구현한다거나?

물론 Rust 코드로 Rust 코드를 작성하는 만큼 그 정의는 함수보다 복잡하다.
그만큼 이해하고 관리하기 어렵고, 그렇기 때문에 이에 대한 설명을 미뤄왔던 것이다.

매크로와 함수의 또 다른 차이점은
함수는 어느 곳에서든 선언하고 그 코드가 조금 길어지더라도 어느 곳에서든 호출할 수 있지만
매크로는 반드시 범위 안으로 가져오거나 새로 정의하여 사용해야 한다.
표준 라이브러리에서 제공하는 매크로는 Prelude에 포함되어 있어
따로 가져오는 작업 없이 바로 사용해왔지만 말이다.

매크로의 분류

Rust의 매크로는 크게 선언적 매크로와 절차적 매크로로 구분되며
절차적 매크로는 또 세 가지 세부 분류가 존재한다.

선언적 매크로 Declarative Macro

일반적으로 사용되는 매크로는 주로 선언적 매크로다.
그냥 매크로macro라고 하면 이 녀석을 의미하곤 한다.
"macro_rules! 매크로"라고도 불린다.

선언적 매크로의 구현은 match 표현식과 유사한 구조로 이루어진다.
Rust 코드가 매크로에 전달한 리터럴을 코드의 구조에 해당하는 패턴과 비교하여
그것이 일치한다면 매크로는 해당 패턴과 연관된 코드로 대치된다.

매크로는 macro_rules!를 통해 선언할 수 있다.
표준 라이브러리의 vec! 매크로를 단순화하여 구현하면 다음과 같다.
물론 실제 vec! 매크로는 이것보다 최적화된 코드로 이루어져 있다.

#[macro_export]
macro_rules! vec {
    ( $( $x:expr ),* ) => {
        {
            let mut temp_vec = Vec::new();
            $(
                temp_vec.push($x);
            )*
            temp_vec
        }
    };
}

#[macro_export]는 매크로도 크레이트와 함께 내보낸다는 의미로
이것이 선언된 크레이트를 가져올 때 이 매크로도 함께 가져와진다.
macro_rules! 뒤에는 매크로의 이름이 온다.
매크로 이름 다음에 오는 중괄호 블록 안에 매크로의 본문이 작성되는데
패턴 => 연관코드 형식으로 match 표현식과 상당히 유사하다.
vec!에는 단 하나의 패턴이 존재하므로 모든 경우 이 패턴과 일치해야 한다.
만약 vec! 매크로를 사용했는데 이 패턴에 일치하지 않는다면 오류가 발생한다.

매크로의 패턴은 우리가 알던 패턴과 차이가 있는데
값이 아니라 코드 구조를 비교해야 하기 때문이다.
매크로 패턴 문법은 공식 문서에서 확인할 수 있다.

매크로의 패턴은 괄호로 시작하며 $() 안의 패턴과 일치하는 값을 캡처에 코드로 대체한다.
$x:expr는 전달된 표현식에 무조건 일치하며 $x라는 이름이 붙는다.
expr는 표현식을 의미하며, :를 기준으로 앞은 이름, 뒤는 조건이라고 볼 수 있겠다.
정확히는 $이름: 프래그먼트지정자인데, 자세한 건 위의 공식 문서에서 확인할 수 있다.

그 뒤에 오는 쉼표는 선택적으로 쉼표 구분 문자가 $()와 일치하는 코드 뒤에 올 수 있음을 의미하며
*은 그것 앞에 오는 패턴이 0번 이상 반복된다는 것을 의미한다.
(0번으로 해당 패턴이 없을 수도 있다.)

만약 우리가 vec![1, 2, 3]라고 매크로를 호출하게 되면
1, 2, 3 이렇게 세 개의 표현식이 쉼표를 구분 문자로 하여 전달된 것으로
$x가 총 3번 일치하는 코드라고 할 수 있다.
패턴이 일치하므로 이 매크로는 해당 패턴에 연관된 코드로 대치될 것이다.

연관된 코드를 살펴보면 새 벡터를 생성한 후
$( temp_vec.push($x); )*라는 코드가 그 뒤를 잇는 것을 확인할 수 있다.
이 안의 코드는 패턴의 $()에 표현식이 일치할 때마다 생성된다.
$x가 N번 일치했다면 이 코드도 N번 생성되고
생성될 때마다 패턴과 일치하는 $x의 실제 값이 $x 위치에 대입된다.

vec![1, 2, 3]였을 경우,
1이라는 표현식을 만났을 때 temp_vec.push(1);라는 코드가,
2이라는 표현식을 만났을 때 temp_vec.push(2);라는 코드가,
3이라는 표현식을 만났을 때 temp_vec.push(3);라는 코드가 생성되는 식이다.

즉, 생성되는 전체 코드는 다음과 같다.

{
    let mut temp_vec = Vec::new();
    temp_vec.push(1);
    temp_vec.push(2);
    temp_vec.push(3);
    temp_vec
}

이로써 매크로에는 인자를 유동적으로 전달할 수 있다.

사실 우리는 매크로를 작성하는 것보다 기존에 있는 것을 호출하는 경우가 훨씬 많으므로
이러한 문법까지 일일이 알고 있을 필요는 없긴 하다.

절차적 매크로 Procedural Macro

입력으로 전달된 코드를 다른 코드로 대체하는 선언적 매크로와 달리
절차적 매크로는 입력으로 전달된 코드에 필요한 작업을 수행한 후 코드를 반환한다.

절차적 매크로는 반드시 그것을 위한 특별한 크레이트 안에서 정의해야 하며
그 크레이트는 크레이트_derive 형태의 이름을 가진다.
이러한 제약을 제거하는 것을 목표로 Rust 팀이 노력하고 있겠지만 아직은 그렇다.

절차적 매크로는 다음과 같은 형태로 정의할 수 있다.

use proc_macro;

#[매크로와 유관한 특성]
pub fn 매크로이름(input: TokenStream) -> TokenStream {
    // snip
}

TokenStreamproc_macro에 정의되어 있으며 일련의 토큰을 나타낸다.
그리고 이 함수에 붙은 특성은 어떠한 종류의 절차적 매크로를 작성하는지 나타낸다.

derive 매크로 Custom derive Macro

derive 매크로는 #[derive(트레이트이름)] 애노테이션을 통해
어떤 자료형에 어떤 트레이트의 default 구현을 사용할 수 있게 하는 용도로 사용된다.
해당 트레이트의 다른 메서드는 구현할 필요 없이 말이다.

간단한 예제를 통해 어떻게 사용할 수 있는지 알아보자.
Hello, Macro! My name is 자료형이름!을 출력하는 함수를 가진 트레이트를 작성할 것이다.

peter@hp-laptop:~/rust-practice/chapter19$ cargo new hello_macro --lib
     Created library `hello_macro` package
peter@hp-laptop:~/rust-practice/chapter19$ cd hello_macro/
peter@hp-laptop:~/rust-practice/chapter19/hello_macro$ vi src/lib.rs

src/lib.rs

pub trait HelloMacro {
    fn hello_macro();
}

우리가 지금까지 배운 방식대로라면
HelloMacro 트레이트를 구현하는 어떤 자료형을 만들어
impl HelloMacro for 자료형 코드블록에서 hello_macro 함수를 구현해야 한다.
하지만 모든 자료형에서 이걸 구현하기 보다는
한 번의 구현으로 모든 자료형에서 써먹을 수 있었으면 좋겠다.
그런데 Rust는 실행 시간에 어떤 자료의 자료형을 확인할 수 없다.
따라서 이 기능을 위해서는 절차형 매크로를 사용해야 한다.

크레이트이름이라는 이름의 크레이트를 절차적 매크로로 재정의하려면
크레이트이름_derive라는 이름의 크레이트를 만들어 여기에 작성하고
절차적 매크로를 정의하기 위한 크레이트임을 명시해주어야 한다.

peter@hp-laptop:~/rust-practice/chapter19/hello_macro$ cargo new hello_macro_derive --lib
     Created library `hello_macro_derive` package
peter@hp-laptop:~/rust-practice/chapter19/hello_macro$ vi hello_macro_derive/Cargo.toml

hello_macro_derive/Cargo.toml

[package]
name = "hello_macro_derive"
version = "0.1.0"
authors = ["Peter J <peter.j@kakao.com>"]
edition = "2018"

[lib]
proc-macro = true

[dependencies]
syn = "1.0.42"
quote = "1.0.7"

proc-macro는 이 크레이트가 절차적 매크로를 위한 크레이트임을 명시하기 위함이며
synquote는 Rust 코드를 구문분석하고, 다시 Rust 코드로 변환하기 위한 녀석들로,
우리가 구현할 hello_macro의 기능 상 필요한 의존 패키지들이다.
syn을 통해 Rust 코드를 구문분석하여 작업을 수행하기 위한 추상 구문 트리 자료구조로 변환하고
이것에 대한 적절한 처리를 한 뒤 quote을 통해 자료구조를 다시 Rust 코드로 변환하는 방식이다.

절차적 매크로는 보통 외부함수와 내부함수로 구성되는데,
외부함수는 인자로 전달된 TokenStream을 구문분석하여 내부함수에 전달하고
내부함수에서 기능에 따른 본격적인 작업이 이루어지며
내부함수가 반환한 TokenStream을 외부 함수가 그대로 반환하는 구조다.

peter@hp-laptop:~/rust-practice/chapter19/hello_macro$ vi hello_macro_derive/src/lib.rs

hello_macro_derive/src/lib.rs

extern crate proc_macro;

use crate::proc_macro::TokenStream;
use quote::quote;
use syn;

#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
    let ast = syn::parse(input).unwrap();
    impl_hello_macro(&ast)
}

fn impl_hello_macro(ast: &syn::DeriveInput) -> TokenStream {
    let name = &ast.ident;
    let gen = quote! {
        impl HelloMacro for #name {
            fn hello_macro() {
                println!("Hello, Macro! My name is {}!", stringify!(#name));
            }
        }
    };
    gen.into()
}

syn::parse 함수는 TokenStream을 인자로 받아 DeriveInput을 반환하는데
DeriveInput 구조체에 대한 정보는 공식 문서를 통해 확인할 수 있다.

절차적 매크로의 오류는 회복 불가능한 오류이기 때문에 unwrap 메소드가 사용되었는데
사실 expect 메서드를 통해 오류 메시지를 명확히 해주는 편이 좋긴 하다.

quote! 매크로는 Rust 코드를 정의하며,
#변수이름 탬플릿으로 어떤 변수의 값을 가져다 사용할 수 있으며
이것의 반환값은 into 메서드를 통해 TokenStream으로 변환된다.

stringify! 매크로는 표현식을 문자열 리터럴로 변환하는 Rust 내장 매크로다.
이 변환은 컴파일 시간에 이루어져 표현식에 대한 메모리 할당이 발생하지 않는다.

우리가 작성한 두 개의 크레이트 모두 cargo build를 통해 정상 빌드할 수 있다.

peter@hp-laptop:~/rust-practice/chapter19/hello_macro$ cargo build
   Compiling hello_macro v0.1.0 (/home/peter/rust-practice/chapter19/hello_macro)
    Finished dev [unoptimized + debuginfo] target(s) in 0.11s
peter@hp-laptop:~/rust-practice/chapter19/hello_macro$ cd hello_macro_derive/
peter@hp-laptop:~/rust-practice/chapter19/hello_macro/hello_macro_derive$ cargo build
   Compiling proc-macro2 v1.0.23
   Compiling unicode-xid v0.2.1
   Compiling syn v1.0.42
   Compiling quote v1.0.7
   Compiling hello_macro_derive v0.1.0 (/home/peter/rust-practice/chapter19/hello_macro/hello_macro_derive)
    Finished dev [unoptimized + debuginfo] target(s) in 4.37s
peter@hp-laptop:~/rust-practice/chapter19/hello_macro/hello_macro_derive$ 

이제 우리가 만든 derive 매크로가 잘 작동하는지 테스트하기 위해
예제 크레이트를 생성해 매크로를 호출해보자.
매크로를 호출하기 위해서 Cargo.toml에 의존성 등록을 하는 것을 잊지 말도록 하자.

peter@hp-laptop:~/rust-practice/chapter19/hello_macro/hello_macro_derive$ cd ../..
peter@hp-laptop:~/rust-practice/chapter19$ cargo new pancakes
     Created binary (application) `pancakes` package
peter@hp-laptop:~/rust-practice/chapter19$ cd pancakes/
peter@hp-laptop:~/rust-practice/chapter19/pancakes$ vi Cargo.toml

Cargo.toml

[package]
name = "pancakes"
version = "0.1.0"
authors = ["Peter J <peter.j@kakao.com>"]
edition = "2018"

[dependencies]
hello_macro = { path = "../hello_macro" }
hello_macro_derive = { path = "../hello_macro/hello_macro_derive" }
peter@hp-laptop:~/rust-practice/chapter19/pancakes$ vi src/main.rs

src/main.rs

use hello_macro::HelloMacro;
use hello_macro_derive::HelloMacro;

#[derive(HelloMacro)]
struct Pancakes;

fn main() {
    Pancakes::hello_macro();
}
peter@hp-laptop:~/rust-practice/chapter19/pancakes$ cargo run
   Compiling proc-macro2 v1.0.23
   Compiling unicode-xid v0.2.1
   Compiling syn v1.0.42
   Compiling hello_macro v0.1.0 (/home/peter/rust-practice/chapter19/hello_macro)
   Compiling quote v1.0.7
   Compiling hello_macro_derive v0.1.0 (/home/peter/rust-practice/chapter19/hello_macro/hello_macro_derive)
   Compiling pancakes v0.1.0 (/home/peter/rust-practice/chapter19/pancakes)
    Finished dev [unoptimized + debuginfo] target(s) in 4.63s
     Running `target/debug/pancakes`
Hello, Macro! My name is Pancakes!
peter@hp-laptop:~/rust-practice/chapter19/pancakes$ 

우리가 원하는대로 출력된 것을 확인할 수 있다.

특성형 매크로 Attribute-like macro

derive 특성을 위한 코드를 생성하는 derive 매크로와 달리
특성형 매크로는 새로운 특성을 생성한다.
그리고 derive 특성은 자료형에만 붙어 트레이트의 default 함수를 사용하도록 하는 반면
특성형 매크로의 특성은 함수와 같은 다른 아이템에도 적용할 수 있다.

특성형 매크로는 #[proc_macro_attribute] 애노테이션과 함께 정의된다.

예를 들어, 다음과 같이 웹 애플리케이션 프레임워크를 위한
route 특성을 구현한다고 하면

#[route(GET, "/")]
fn index {
    // snip
}

이것은 다음과 같은 절차적 매크로로 정의된 것이다.

#[proc_macro_attribute]
pub fn route(attr: TokenStream, item: TokenStream) -> TokenStream {
    // snip
}

여기서 attr에는 특성에 포함되어 있는 GET, "/"가 들어가고
item에는 route 함수의 정의가 들어간다.
그리고 derive 매크로에서 했던 것처럼 크레이트를 생성하여
원하는 코드를 생성하는 코드를 구현하면 된다.

함수형 매크로 Function-like macro

함수형 매크로는 함수 호출과 유사하게 사용된다.
선언적 매크로와 마찬가지로 매크로이름! 형태로 함수처럼 사용하는 방식이다.
하지만 macro_rules!를 통해 match 문과 유사한 문법을 사용하는 선언적 매크로와 달리
함수형 매크로는 다른 절차적 매크로와 같이 TokenStream을 사용한다.
이로서 macro_rules!를 사용한 것보다 훨씬 복잡한 처리를 할 수 있다.

예를 들어 다음과 같은 함수형 매크로는

let sql = sql!(SELECT * FROM posts WHERE id=1);

다음과 같은 절차적 매크로로 정의되며 구문분석을 통해 문법적으로 올바른지 확인하여 실행한다.

#[proc_macro]
pub fn sql(input: TokenStream) -> TokenStream {
    // snip
}

본문은 역시 derive 매크로에서 했던 것처럼 작성하면 된다.

이 포스트의 내용은 공식문서의 19장 3절 Advanced Types & 19장 4절 Advanced Functions and Closures에 해당합니다.

profile
Peter J Online Space - since July 2020

0개의 댓글