🚶

足を止めて見る #5 〜 RustのSerdeクレート(2) 〜

に公開2

足を止めて見よう

足を止めて見ようシリーズの5つ目です。

前回は serde クレートについてでした。

構造体を Json 形式にシリアライズして、更にその Json 形式をデシリアライズして、元の構造体に戻る様子を見ました。

今回は serde クレートの Attributes について足を止めて見ようと思います。

https://serde.rs/attributes.html

serde の attributes とは何なのか

前回は、dervieマクロによって自動的に serde::ser::Serializeserde::de::Deserialize が実装されることを確認しました。

attributes とは、そのderiveマクロによって実装されるその振る舞いを調整するための機能のようで、3種類あります。

それぞれどこに記述する attributes なのかは、公式Docの例がわかりやすいです。

#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]  // <-- this is a container attribute
struct S {
    #[serde(default)]  // <-- this is a field attribute
    f: i32,
}

#[derive(Serialize, Deserialize)]
#[serde(rename = "e")]  // <-- this is also a container attribute
enum E {
    #[serde(rename = "a")]  // <-- this is a variant attribute
    A(String),
}

いくつかの attributes をピックアップして動作を見て見ようと思います。

serde クレートの attributes を使ってみる

今回は Person という構造体を用意して、いくつか attributes を使ってみようと思います。3種類の attributes を試すために、Person 構造体のフィールドには、構造体 Age をとる age フィールド、enum Gender をとる gender フィールドなどを用意してみました。

#[derive(serde::Serialize)]
struct Person {
    first_name: String,
    last_name: String,
    age: Age,
    gender: Gender,
}

#[derive(serde::Serialize)]
struct Age {
    value: u8,
}

#[derive(serde::Serialize)]
enum Gender {
    Male,
    Female,
    Other,
}

fn main() {
    let person = Person {
        first_name: String::from("太郎"),
        last_name: String::from("田中"),
        age: Age { value: 30 },
        gender: Gender::Male,
    };

    let serialized = serde_json::to_string(&person).unwrap();

    assert_eq!(
        serialized,
        "{\"first_ame\":\"太郎\",\"last_ame\":\"田中\",\"age\":{\"value\":30},\"gender\":\"Male\"}"
    );
}

この実行は成功して、以下のJsonになります。

{
    "first_name": "太郎",
    "last_name": "田中",
    "age": 30,
    "gender": "Male"
}

rename_all

rename_all を使うと、スネークケースで記述しているフィールド名をキャメルケースでシリアライズできました

#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")] // 追加
struct Person {
    first_name: String,
    last_name: String,
    age: Age,
    gender: Gender,
}

シリアライズされた Json の形

{
    "firstName": "太郎", // first_name -> firstName
    "lastName": "田中",  // last_name -> lastName
    "age": { "value": 30 },
    "gender": "Male"
}

rename

rename を使うと、enum の Variant や struct の フィールド の名前を変更できました

#[derive(serde::Serialize)]
enum Gender {
    #[serde(rename = "男")] // 追加
    Male,
    #[serde(rename = "女")] // 追加
    Female,
    #[serde(rename = "その他")] // 追加
    Other,
}

シリアライズされた Json の形

{
    "firstName": "太郎",
    "lastName": "田中",
    "age": { "value": 30 },
    "gender": "男" // Male -> 男
}

transparent

transparent を使うと、フィールドを1つしか持たない構造体がネストされている時に、まるでプリミティブ型のフィールドがそこにあるかのような振る舞いができました。これは New Type パターンを想定した構造体に対して利用する狙いがあるようです。

文字に起こしてもちょっと伝わりづらいので書いてみます。

#[derive(serde::Serialize)]
#[serde(transparent)] // 追加
struct Age {
    value: u8,
}

シリアライズされた Json の形

{
    "firstName": "太郎",
    "lastName": "田中",
    "age": 30, // { "value": 30 } -> 30
    "gender": "男"
}

わかりやすくなりました。後で試してみたのですが、もし Age がu8をとるユニット様構造体だった場合もこの形になりました。

// 以下2つのシリアライズ結果は同じになりました

#[derive(serde::Serialize)]
#[serde(transparent)]
struct Age {
    value: u8,
}

#[derive(serde::Serialize)]
struct Age (u8)

もう一段だけ深ぼってみる(#1)

そもそも #[serde(xxx = "yyy")] といった記述は何者なんでしょうか。

これは自分知ってました、カスタムのdervieマクロってやつでしょう(違ったらごめん)

https://doc.rust-jp.rs/book-ja/ch19-06-macros.html#属性風マクロ

おそらく serde のどこかに proc_macro_derive といった記述があり、そこにロジックが書かれているはずです。

もう一段だけ深ぼってみる(#2)

では #[serde(xxx = "yyy")] はどのようなメカニズムなのでしょうか?

これを紐解くために、まずは serde クレートの derive という crate feature から辿ってみます。

serdeのCargo.tomlを覗いてみます。

{{ 省略 }}

[dependencies]
serde_derive = { version = "1", optional = true, path = "../serde_derive" }

{{ 省略 }}

### FEATURES #################################################################

[features]
default = ["std"]

# Provide derive(Serialize, Deserialize) macros.
derive = ["serde_derive"]

{{ 省略 }}

derive feature を指定するということは、この Carto.toml の1階層上にある serde_derive というディレクトリに依存するようです。

この serde_derive ですが、crates.io でも検索できるように、1つの独立したクレートのようです。

https://crates.io/crates/serde_derive

どうやらこのクレートにロジックがあるようです。宣言部分のソースコード serde_derive/lib.rs をのぞいてみます。

#[proc_macro_derive(Serialize, attributes(serde))]
pub fn derive_serialize(input: TokenStream) -> TokenStream {
    let mut input = parse_macro_input!(input as DeriveInput);
    ser::expand_derive_serialize(&mut input)
        .unwrap_or_else(syn::Error::into_compile_error)
        .into()
}

やりました! proc_macro_dervie という記述に辿り着きました!

attributes(serde) とあるので #[serde] という名前のカスタムderiveマクロになっているのでしょうね(TRPLではここまでは教えてくれなかったぞ)。

細かくロジックを追いかけるのは、また今度にします。

振り返り

今回も serde クレートを改めて足を止めて見てみました。

attributes の使い方がざっくりですが分かりました。

また crate feature を Cargo.toml から辿っていき、カスタムのdervieマクロの宣言まで追いかけることができたのは、Rustの習熟を深めるためにも良かったのではないでしょうか。

これで明日から、もっと堂々と serde を使っていけるぞー 🙌

その他

今回書いたRustのコードはこのリポジトリで制作しています。

https://github.com/kenshuhori/rust/tree/main/workspace/dive_5_serde_crate

GitHubで編集を提案
ドクターメイト

Discussion

kanaruskanarus

そもそも #[serde(xxx = "yyy")] といった記述は何者なんでしょうか。

これは自分知ってました、カスタムのdervieマクロってやつでしょう(違ったらごめん)

attributes(serde) とあるので #[serde] という名前のカスタムderiveマクロになっているのでしょうね

これは違って、

#[proc_macro_derive(Serialize, attributes(serde))]
pub fn derive_serialize(input: TokenStream) -> TokenStream {
    ...
}

は、 proc_macro_derive(Serialize とあるように Serialize derive macro を定義しています。

では , attributes(serde)) は何者かというと、Serialize derive macro の helper attributes の名前を serde と指定しています。
( あまり見ないですが複数の helper attributes 名を指定することもできます )

https://doc.rust-lang.org/reference/procedural-macros.html#derive-macro-helper-attributes

既に記事内で言及されているように、#[derive(Serialize)] が付与された型宣言の中で #[serde(...)] という attribute をいろんなところに付与することで、ユーザーは #[derive(Serialize)]生成する trait impl コードをカスタマイズ できます。

これが helper attributes の役割です。
つまり、 derive macro に対してオプションやフラグを指定するためのインターフェースです。


以下、proc macro の仕組み上 helper attributes がどう扱われているかの解説を書いてみたのですが、もしかしたら今後の記事で扱われる予定があるかもしれないと思ったので隠しておきます。必要に応じて読んだり読まなかったりしてください。

proc macro の仕組み上どう扱われているか1

serde_derive::derive_serialize を例に挙げれば、内部で Serialize impl を生成する処理の過程で

「{struct, enum, field, variant} が serde という名前の attribute を持っていたら、その中身に応じて〜」

という処理があるはずです。

このように、helper attributes は独立した attribute macro ではなく、それ自体は何もしない、本当にただ derive macro に対してオプションやフラグを指定するためのインターフェースです。

実際、serde crate から serde という名前の attribute macro は export されていません。

proc macro の仕組み上どう扱われているか2 ( ちょっと deep dive )

ここで、

「derive macro の実装内で

serde という名前の attribute を持っていたら

という処理を自前でやっているのであれば、それでいいのでは? わざわざ #[proc_macro_derive(Serialize, attributes(serde))] みたいな形でも名前を指定する必要はなくないか?」

と思ったかもしれません。

ここからは proc macro を実装したことがないとわかりにくい話になるのですが、「 derive macro は元の型定義を触れない 」という性質がこれを必要とする根本的な理由になっています。

具体的には、

  • まず、attribute macro は

    (TokenStream, TokenStream) −> TokenStream
    // attribute自体の引数, 入力 −> 出力
    

    というインターフェースで入力全体を出力で置換します。なので、例えば

    #[my_attribute]
    struct T;
    

    enum E {}
    

    みたいなコードに置き換えることも可能です。

  • それに対して、derive macro は

    TokenStream> TokenStream
    // 入力: 型定義のコピー −> 出力: trait impl
    

    というインターフェースで、元の型定義はそのままに、出力である trait impl コードをソースコードに挿入します。

なので、例えば

#[derive(Serialize)]
struct Point {
    #[serde(rename = "X")]
    x: f32,
    #[serde(rename = "Y")]
    y: f32,
}

が展開されると

// 元の型定義
struct Point {
    #[serde(rename = "X")]
    x: f32,
    #[serde(rename = "Y")]
    y: f32,
}

// Serialize を Point に impl する自動生成コード
/* 略 */

となるわけですが、ここで元の型定義に #[serde(...)] が残っていることが問題になります。

proc macro の仕組みを作る立場に立って考えると、これをエラーにしないようにするには

  • 各 derive macro の定義でそれぞれが使いたい helper attribute 名を挙げてもらい、挙げられた名前の attribute は無視する
  • 各 derive macro の定義でそれぞれが使いたい helper attribute 名を挙げてもらい、挙げられた名前の attribute は削除する

の2つの選択肢がありますが、後者だと

TokenStream> TokenStream
// 入力: 型定義のコピー −> 出力: trait impl

の「入力」を作る時点で削除を終えておく必要があります。
推測になりますが、これは前者に比べて一貫性や効率の面でデメリットがあるのだと思います。
(もしこの議論の一次情報を知っている方がこれを読んでいたら教えてください)

そのため、実際の proc macro の仕組みでは前者が採用されています。cargo-expand などを使うと、実際に #[serde] helper attribute ごと型定義が残ったまま trait impl が挿入された (エラーになっていない) コードを見ることができます。

ホリケンシュウホリケンシュウ

コメントありがとうございます!
helper attributesは自由に設定できるけど、serde_derive では serde しかチェックしませんよ、ということだと理解しました。

それと、トグルに折りたたんだコメントもありがとうございます。
仰るとおり、このあと TokenStream をごりごり処理している箇所を見ているところでして、serde という名前のattributes以外をスキップしているなあとか、syn::Data Enum のvariantに沿ってそれぞれのattrを確認しているなあ...とか見ていました。今後の記事作成の参考にさせていただきます!