Rustのコンパイラプラグインで実装を型定義から自動生成しよう

しよう(提案)

この記事はRust Advent Calendar 2016 - Qiitaの9日目の記事となります。

コンパイラプラグイン

Rustはコンパイラであるrustcに対して、ユーザーがプラグインとして機能を追加できる仕組みがあります。 コンパイル時に処理を定義できる仕組みというと、マクロが思い浮かびますが、

macro_rules! foo {
    (x => $e:expr) => (println!("mode X: {}", $e));
    (y => $e:expr) => (println!("mode Y: {}", $e));
}

fn main() {
    foo!(y => 3);
}

実行結果

=> mode Y: 3

と上記リンクのドキュメントにあるように、マクロが限られた範囲のASTをパターンマッチして変更するのに対し、コンパイラプラグインでは、こんな感じで、Rustそのものを使ってソースコードのASTを処理し、新たなASTを構築できます。

例えば、Rustでよく使われるシリアライズライブラリであるserdeがありますが、serdeを構成するcrateのうちの1つ、serde_deriveは

#[derive(Serialize, Deserialize)]
struct Point {
    x: f64,
    y: f64,
}

のようにSerialize, Deserializeという属性を指定することで、それぞれser::Serialize, de:Deserializeトレイトに必要なメソッドのASTを、構造体の名前と型から生成する機能を持っています。

今回は、このアトリビュートを独自に定義し、コンパイラプラグインとして使うことのできるcrateを作成しました。

題材

mysqlライブラリである

https://github.com/blackbeam/rust-mysql-simple

のトレイト、FromRowを、構造体に対して実装しようと思います。

#[derive(FromMysqlRow)]
struct User {
    name: String,
    age: u64
}

のようにすると、SELECT文を発行した結果が格納されているmysql::Rowから、このUser構造体への変換メソッドが自動的に生成されます。

実装

github.com

実装は https://github.com/rail44/rust-mysql-derive/blob/master/src/lib.rs だけです。

この題材で実装を始めてから気付いたのですが、現在のrust-nightlyではRFC#1681により、上の方でリンクを貼ったコンパイラプラグインのドキュメントとはプラグイン登録のためのAPIが変わってしまっていました。 そのため、実装の上では既にnightly対応がなされているserdeの

https://github.com/serde-rs/serde/tree/master/serde_derive

https://github.com/serde-rs/serde/tree/master/serde_codegen

https://github.com/serde-rs/serde/tree/master/serde_codegen_internals

3つのcrateを参考にしました。

新しいマクロシステム(macros 1.1と呼ぶらしい)では、lib.rsで

#![feature(proc_macro, proc_macro_lib)]

extern crate proc_macro;

と宣言をした上で

https://github.com/rail44/rust-mysql-derive/blob/master/src/lib.rs#L12

#[proc_macro_derive(FromMysqlRow)]
pub fn derive_from_mysql_row(input: TokenStream) -> TokenStream {
    let source = input.to_string();
    let ast = syn::parse_macro_input(&source).unwrap();
    let expanded = expand_from_mysql_row(&ast);
    expanded.parse().unwrap()
}

のように #[proc_macro_derive(...)]アトリビュートを付けた関数を定義することで、独自のアトリビュートを作成し、外部からも使うことができるようになります。

serdeではTokenStreamをパースした上でASTの追加を行っています。 そのパースのためのcrateが

github.com

として公開されていたため、ありがたく使わせて頂きました。 expand_from_mysql_row関数がASTを生成する本体の実装になっていますが、大体syn crateのドキュメント通りです。ありがたや。

結果

テストコードでは

https://github.com/rail44/rust-mysql-derive/blob/master/tests/test.rs

#[derive(FromMysqlRow, Debug, PartialEq, Eq)]
struct User {
    name: String,
    age: u64
}

と書くだけで、

    let users: Vec<User> = pool
        .prep_exec("SELECT * FROM rust_mysql_derive.users", ())
        .unwrap()
        .map(|opt_row| mysql::from_row(opt_row.unwrap()))
.collect();

のように、クエリの結果を構造体に変換できるようになっています。

mysql crateのドキュメントにあるような方法 mysql::Row - Rust

pool.prep_exec("SELECT * FROM tmp.Users", ()).map(|mut result| {
    let mut row = result.next().unwrap().unwrap();
    let id: u32 = row.take("id").unwrap();
    let name: String = row.take("name").unwrap();
    let age: u32 = row.take("age").unwrap();
    let email: String = row.take("email").unwrap();

    assert_eq!(1, id);
    assert_eq!("John", name);
    assert_eq!(17, age);
    assert_eq!("foo@bar.baz", email);
});

に比べて簡単に見えます。見えますよね?たぶん見える。

$ docker run -ti --rm -p 3306:3306 -e MYSQL_ROOT_PASSWORD=my-secret-pw mariadb

などとすれば cargo test でテストが走ります。

この程度の実装なら型毎に手で書けばよくない?

僕もそう思います