Rust - Procedural Macros (Derive Macro)

Yazımızın devamında tekrarlamamak açısından Procedural Macros terimini PM olarak kullanacağız.


Nedir?

Bir çok projemizde yazdığımız logicler tekrarlanmak zorunda kalabiliyor veya istediğimiz yönde şekillendiremiyor olabiliriz.  

PM'ler, Rust projesinin compile zamanında çalışan kod bloklarıdır. Bu sayede yazdığınız macro, standart bir koda parse edilip compile sürecine dahil edilecektir. Rust dünyasında sıklıkla karşınıza çıkacak ve kullanmak durumunda kalacağınızdan emin olabilirsiniz. Temel anlamda 3 farklı tipi bulunuyor.

Function-like macros

custom!(...)


Derive macros

#[derive(CustomDerive)]


Attribute macros

#[CustomAttribute]

Yukarıda bulunan her macro TokenStream alışverişi yapmaktadır. TokenStream, Rust Compiler'ın Lexing işleminin sonucunu size ulaştırır. Lexing ise kullandığınız dilin compile edildiği sıradaki ilk aşamasıdır. Gramer yapısını parse eder ve anlamlı bir hale getirir.

 assert_eq!(
 	lex("500 + 499"),
 	Ok(vec![Number(500), Operation(Add), Number(499)])
);

Default Macros

Rust standartlarına göre bir Struct'ı print etmek istiyorsanız, Struct'ın Rust Core içerisinde bulunan Debug Traiti'ni implement etmiş olması gerekmektedir.

Yukarıda bahsetmiş olduğumuz Derive Macroları kullanarak implement edebilirsiniz.

#[derive(Debug)]
struct Struct {
    ...
}

Macrolar olmasaydı nasıl bir görüntü oluşacaktı?

impl fmt::Debug for Struct {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        ...
    }
}

Bunu, oluşturduğunuz ve print etmek durumunda kaldığınız her bir Struct için yazdığınızı düşünün. (Print aksiyonunu sadece örnek için kullandık)

Custom Implementation

Kendimiz için bir Derive Macro oluşturalım ve bizim için değerli bir iş yapsın.

#[derive(Debug, SerializeMacro)]
#[TableName = "lookup"]
pub struct Lookup {
    pub lookup_id: Uuid,
    pub lookup_description: Option<String>,
    pub lookup_name: Option<String>,
    pub lookup_type: Option<String>,
    pub lookup_value: Option<String>,
}
use proc_macro::{TokenStream, Ident};
use quote::{quote};
use syn::{DeriveInput};
use syn::parse::{Parse, ParseStream};
use syn::token::Token;

#[proc_macro_derive(SerializeMacro, attributes(TableName))]
pub fn serialize_macro(input: TokenStream) -> TokenStream {
    let input: DeriveInput = syn::parse(input).unwrap();

    let name = &input.ident;

    let mut table_name: Option<String> = None;

    for attr in &input.attrs {
        match attr.parse_meta().unwrap() {
            syn::Meta::NameValue(val) => {
                if val.path.is_ident("TableName") {
                    if let syn::Lit::Str(lit) = &val.lit {
                        table_name = Some(lit.value());
                    }
                }
            }
            _ => ()
        }
    }

    let table_name = table_name.expect("TableName attr not found");

    let expanded = quote! {
         impl TSerializeMacro for #name {
            fn serialize_to_query(&self) -> String {
                let mut keys: String = String::new();
                let mut values: String = String::new();
                for (key, value) in serde_json::json!(self).as_object().unwrap() {
                    keys.push_str(format!("{}, ", key).as_str());
                    if(key.contains("id")) {
                        values.push_str("uuid(), ");
                    } else {
                        values.push_str(format!("'{}', ", value.to_string()).as_str());
                    }
                }

                values = values.replace("\"", "");

                let last_values = &values[..values.len() - 2];
                let last_keys = &keys[..keys.len() - 2];

                String::from(format!("INSERT INTO projectX.{} ({}) VALUES({})", #table_name, last_keys, last_values))
            }
        }
    };
    TokenStream::from(expanded)
}

İlk önce kullandığımız librarylerden bahsedelim.


syn Gelen Token Stream'i, quote ise Macromuzun asıl çalışacağı bölümü Syntax Tree'ye parse etmemizi sağlıyor.

Macro tanımlaması yaparken yine Rust içerisinde standart olarak bulunan macrolardan biri olan proc_macro_derive'ı kullanacağız. Tabii ki macro feature'ını kullanabilmeniz için Cargo.toml dosyasından etkinleştirmemiz gerekmektedir.

[lib]
proc-macro = true

let input: DeriveInput = syn::parse(input).unwrap();

Bahsettiğimiz syn kütüphanesi ile macro üzerinden Syntax Tree'ye erişebilmemizi sağlayan parse işlemini yapıyoruz.

let name = &input.ident;

Macroyu implement ettiğiniz Struct ya da Enum ismini tanımlıyoruz.

  let mut table_name: Option<String> = None;

    for attr in &input.attrs {
        match attr.parse_meta().unwrap() {
            syn::Meta::NameValue(val) => {
                if val.path.is_ident("TableName") {
                    if let syn::Lit::Str(lit) = &val.lit {
                        table_name = Some(lit.value());
                    }
                }
            }
            _ => ()
        }
    }

Tanımladığımız Derive Macro'lara değer de geçebilmekteyiz. Yukarıda, attrs field'ını parse ettikten sonra is_ident ile key kontrolü yapıp, değeri değişkenimize tanımlıyoruz.

    let expanded = quote! {
         impl TSerializeMacro for #name {
            fn serialize_to_query(&self) -> String {
                let mut keys: String = String::new();
                let mut values: String = String::new();
                for (key, value) in serde_json::json!(self).as_object().unwrap() {
                    keys.push_str(format!("{}, ", key).as_str());
                    if(key.contains("id")) {
                        values.push_str("uuid(), ");
                    } else {
                        values.push_str(format!("'{}', ", value.to_string()).as_str());
                    }
                }

                values = values.replace("\"", "");

                let last_values = &values[..values.len() - 2];
                let last_keys = &keys[..keys.len() - 2];

                String::from(format!("INSERT INTO {} ({}) VALUES({})", #table_name, last_keys, last_values))
            }
        }
    };
    TokenStream::from(expanded)

Burada ise quote librarysinde bulunan quote! macrosu ile Struct'a uygulamak istediğimiz implementation'ı tanımlıyoruz.

Ben örnek olarak yazdığım macroda her struct'dan table name alıp, insert query yapısına çeviriyorum.
"INSERT INTO lookup (lookup_description, lookup_id, lookup_name, lookup_type, lookup_value) VALUES('description', uuid(), 'isActive', 'bool', 'true')"

Son olarak nasıl kullandığımıza bakalım.

let lookup = Lookup {...};
let query = value.serialize_to_query();

Keyifli kodlamalar.