🦀

Rustのnewtypeパターンを簡潔に:newer-type crateの紹介

に公開

https://crates.io/crates/newer-type

はじめに

この記事では、私が作成したnewer-type crateについて紹介します。このcrateは、Rustのnewtypeパターンにおけるトレイト実装を自動化し、開発者の負担を大幅に軽減するライブラリです。

Rustにおけるnewtypeパターンは、型安全性を高めるための強力な手法です。既存の型をラップして新しい型を作ることで、異なる用途の値を型レベルで区別し、プログラムのバグを防ぐことができます。しかし、従来のnewtypeパターンには一つの大きな課題がありました:ラップ元の型のトレイトを再実装するためのボイラープレートコードが大量に必要になることです。

動機

Rustには#[derive]マクロという便利な機能があり、CloneDebugPartialEqなどの基本的なトレイトを自動実装できます。

#[derive(Clone, Debug, PartialEq)]
struct MyString(String);

しかし、#[derive]マクロは標準ライブラリの限られたトレイトにのみ対応しており、独自に定義したカスタムトレイトや外部crateのトレイトには使用できません。

この制約には技術的な理由があります。#[derive]マクロは言語レベルで組み込まれた特別な機能であり、各トレイトに対して個別に実装が必要です。Rustコンパイラは、CloneDebugなどの基本的なトレイトについては自動実装の方法を「知っている」のですが、任意のユーザー定義トレイトについては知る由もありません。また、トレイトによって実装方法が大きく異なるため、汎用的な自動実装メカニズムを提供するのは困難でした。

この制約により、開発者は手動でトレイトを実装するか、ボイラープレートコードを書き続けるしかありませんでした。

newer-type crateは、この問題を解決するためのエレガントなソリューションです。手続きマクロを使用して、任意のトレイトの実装を自動化し、コードの記述量を大幅に削減します。

例えば、最小文字数を型レベルで保証するパスワード型を考えてみましょう:

struct ValidPassword<const N: usize>(String);

この型は、パスワードがN文字以上であることを保証しますが、それ以外はStringそのものとして振る舞うべきです。

例えば、ToHashトレイトを定義してStringに実装した場合:

use newer_type::{implement, target};

#[target]
trait ToHash {
    fn to_hash(&self) -> u64;
}

impl ToHash for String {
    fn to_hash(&self) -> u64 {
        todo!()
    }
}

newer-type crateを使えば、ValidPasswordにもこのトレイトを自動実装できます:

#[implement(ToHash, Clone, PartialEq, Display)]
struct ValidPassword<const N: usize>(String);

仕組みの概要

newer-type crateは**デリゲーション(委譲)**という手法を使って各トレイトを自動実装します。
以下の構造体と列挙型の例で動作を説明します:

use newer_type::implement;
use newer_type_std::{clone::Clone, fmt::Display};

// 構造体の例
#[implement(Clone, Display)]
struct Person {
    name: String,
    _age: u32,
}

// 列挙型の例
#[implement(Clone)]
enum Status {
    Active(String),
    Inactive(String),
    _Hidden(u32),
}

構造体でも列挙体でも、_で始まるフィールドは無視され、そうでないフィールドにトレイトの処理が委譲されます。もしそのようなフィールドが複数ある場合はコンパイルエラーになります。そのような場合は、フィールドレベルで#[implement]#[implement(Trait)]を使用します:

#[implement]
struct Config {
    #[implement(Clone, Display)]
    database_url: String,
    #[implement(Hash)]
    cache_size: usize,
    timeout: u64,
}

技術的詳細

newer-type crateの実装では、主に3つのパターンを採用しています。

export declarative macro パターン(仮)

トレイトのデリゲーションを実装するには、そのトレイトの全メソッドのシグネチャが必要です。しかし、Rustには指定されたトレイトに対してそのような情報を提供するリフレクション機能がありません。そこで、トレイト定義の場所からシグネチャ情報を運搬する仕組みが必要になります。

ユーザーはnewtypeに実装したいトレイトをトレイトパスで指定しますが、このパスはuse文により再インポート(再利用)されている可能性があります。
そのため、トレイトのパスがcrateのどこから見ても一意であることを前提とした方式ではうまくいきません。そして、Rustのマクロの独立性も問題となります。
すなわちRustのマクロの実行はすべて独立であり、離れた場所にあるマクロ同士が状態を共有することはできません[1]
そのため、デリゲーションメソッドの動作を決定するために、シグネチャ情報を運搬する必要があるのですが、情報の運搬はRust言語のコンテキストで行われる必要があり、マクロ側の小細工で情報を伝達することはできないのです。ここで重要なのは、Stringsyn::Signatureなどの通常のRust型では、手続きマクロ内でRust式を評価できないため役に立たないということです。

このパターン(名前募集中)がこれらの問題を解決します。#[target]マクロを適用したトレイトは、実装に必要なメタデータを含む宣言マクロを自動生成します。この宣言マクロには、トレイトの全メソッドシグネチャが埋め込まれており、#[implement]マクロが実行時にこの情報を取得して適切な実装コードを生成できます。

type-leak パターン

トレイト定義の場所から#[implement]マクロが使用される場所へのシグネチャ定義の運搬が成功したとしても、まだいくつかの問題が残ります。問題は型コンテキストの違いです。

例えば、トレイト定義で使用されるOption<T>を考えてみましょう。このcrateが単純に型パスをコピー・ペーストしただけでは、同じスペルでも同じ型を表現しない可能性があります。なぜなら、ユーザーが#[implement]コンテキストで独自のOption<T>型を定義し、Optionの名前をオーバーライドしている可能性があるからです。

このパターンは、型の表記だけでなく、その型コンテキストと一緒に型表現を運搬します。型情報を意図的に「リーク」させることで、コンパイル時に正しい型情報を与える仕組みです。手続きマクロの実行時点では型情報が完全に解決されていないため、生成するコードの中に型を導出するダミーの型定義を埋め込みます。これにより、異なる型コンテキスト間で正しく型情報を運搬できるようになります。

$crate forwarding パターン

このパターンは先ほど説明した2つのパターンとは独立しており、Rustでマクロcrateを実装する際に一般的に有用な技法です。

newer-type crateは手続きマクロを提供しますが、生成されるコードは同crateで定義された型やトレイトを使用します。この種のマクロcrateでは、通常「コア機能crate」と「マクロ実装crate」の2つのcrateで構成される設計が採用されます[2]。しかし、この分離により2つの問題が発生します。

1つ目の問題は依存関係の可視性です。Rustでは、crateはCargo.tomldependenciesセクションで直接参照されているcrateの定義しか使用できません。そのため、生成されたコードが使用する定義が、そのコードを使用するcrateから直接依存していないcrateに存在する場合(これは実は稀なケースですが)、コンパイラが定義を見つけられないことがあります。

2つ目の問題はcrateのリネームです。例えば、以下のようにCargo.tomlでcrateがリネームされている場合:

[dependencies]
my_newer_type = { package = "newer-type", version = "0.1" }

この場合、定義へのパスが分かっていても、::newer_type::SomeTypeのような絶対パスが正しく動作しない可能性があります。

この$crate forwardingパターンは、マクロが展開される際にマクロ定義元のcrateコンテキストを保持する仕組みです。Rustの宣言マクロでは、$crateキーワードを使用することで、マクロが定義されたcrateを参照できます。手続きマクロから宣言マクロを生成し、その宣言マクロ内で$crateを使用することで、適切なcrateパスを確実に解決できるようになります。

まとめ

newer-type crateは、Rustのnewtypeパターンをより実用的にする優れたツールです。ボイラープレートコードを削減しながら、型安全性の恩恵を最大限に活用できます。

newtypeパターンを頻繁に使用する方や、型安全性を重視した設計を行う方にとって、このcrateは開発効率を大幅に向上させる価値のあるツールといえるでしょう。

参考リンク

脚注
  1. Rustのproc-macroは(safe Rustを使う限り)独立に見える環境で実行されるうえ、インクリメンタルコンパイルの可能性も考えると、そもそもマクロが同じプロセスで実行されるかですら疑わしいです。proc-stateマクロの使用が1つの解決策となりますが、ここではこれは使わないものとします。 ↩︎

  2. この設計パターンが必要な理由は、Rustでは1つのcrateが手続きマクロと通常のRustアイテム定義(構造体、トレイト、関数など)の両方をエクスポートできないという制約があるためです。 ↩︎

GitHubで編集を提案

Discussion