関数型言語のオプティクス
Opticsは、関数型言語の一般的なAPI設計の一種です。これは純粋関数風のコンセプトであり、Rustではあまり使われません。
とはいえ、このコンセプトを探求することは、Rust APIにおける他のパターン、例えばvisitors 、を理解するのに役立つかもしれません。これらもニッチなユースケースがあります。
これは非常に大きなトピックであり、その能力を完全に理解するには、言語設計に関する実際の書籍が必要になるでしょう。しかし、Rustでの適用はもっと簡単です。
コンセプトの関連部分を説明するために、Serde
-APIを例として使用します。これは、単にAPIドキュメントから理解することが、多くの人によって難しいものの一つだからです。
その過程で、オプティクスと呼ばれる、ある別のパターンをカバーします。 Iso 、 Poly Iso、 Prism です。
APIの例: Serde
APIを読むだけで、Serde の仕組みを理解しようとすることは難しく、初めてなら尚の事です。Deserializer
トレイトについて考えてみましょう。新しいデータフォーマットをパースするライブラリによって実装されるものです:
pub trait Deserializer<'de>: Sized {
type Error: Error;
fn deserialize_any<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>;
fn deserialize_bool<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>;
// あとは省略
}
そして以下が、ジェネリックに渡される Visitor
トレイトの定義です:
pub trait Visitor<'de>: Sized {
type Value;
fn visit_bool<E>(self, v: bool) -> Result<Self::Value, E>
where
E: Error;
fn visit_u64<E>(self, v: u64) -> Result<Self::Value, E>
where
E: Error;
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: Error;
// あとは省略
}
ここでは多くの型消去が行われており、複数のレベルでの関連型が行ったり来たりしています。
これを理解するためには、lensの概念を念頭に置き、総称的に渡される Visitor
型の定義を見てみる必要があります:
これを理解するひとつの方法が、関数型言語の概念である オプティクス(Optics) を考察することです。
これは、振る舞いとプロパティの合成を行うための方法であり、Rustにて共通するパターン (エラー処理、型変換など) を容易にするように設計されたものです。
これらの値は、デシリアライズされる Rust の値の一部を表します。 失敗した場合は Error
型を返します。 Deserializer
によって、メソッドが呼び出されたときに決定されたエラー型です。
これはおそらく、それらAPIが達成しようとするもの、つまりは合成可能性の特異性、を明るみに出すことでしょう。
基本オプティクス
Iso
Iso は、2つの型間の値変換器です。これは非常にシンプルなものですが、概念的に重要な構成要素です。
例として、文書のコンコルダンス (用語索引) として使用されるカスタム・ハッシュ・テーブル構造があるとします1。これはキー (単語) には文字列を使用し、値 (例えばファイルオフセット) にはインデックスのリストを使用します。
重要な機能は、このフォーマットをディスクにシリアライズできることです。「クイック&ダーティ」なアプローチは、JSON形式の文字列との相互の変換を実装することでしょう。(当面、エラーは無視し、後に扱います)
関数型言語のユーザーが期待する正規系で記述すると:
case class ConcordanceSerDe {
serialize: Concordance -> String
deserialize: String -> Concordance
}
Isoはこのように、異なる型の値を変換する関数のペア: serialize
と deserialize
です。
単純な実装:
#![allow(unused)] fn main() { use std::collections::HashMap; struct Concordance { keys: HashMap<String, usize>, value_table: Vec<(usize, usize)>, } struct ConcordanceSerde {} impl ConcordanceSerde { fn serialize(value: Concordance) -> String { todo!() } // 無効なコンコルダンスは空 fn deserialize(value: String) -> Concordance { todo!() } } }
これは馬鹿げたものに見えるかもしれません。Rustでは、この種の振る舞いは一般的にトレイトで行います。なにせ、標準ライブラリには FromStr
と ToString
があります。
しかし、そこで次のテーマ: Poly Iso が登場します。
Poly Isos
前の例では、単純に2つの固定型の値を相互に変換するだけでした。この次のブロックは、その上にジェネリクスを加えるもので、より興味深いものです。
Poly Iso は、ある操作が、ある単一の型を返しつつ、任意の型に対しジェネリックなものとなれるようにします。
これでパースに近づけました。エラーケースを無視して、基本的なパーサーが何をするか考えてみよう。繰り返しますが、これが正規形です:
case class Serde[T] {
deserialize(String) -> T
serialize(T) -> String
}
ここで私たちは、最初のジェネリック型、変換される T
を手にしました。
Rustでは、これは標準ライブラリの2つのトレイト: FromStr
とToString
の組にて実装可能です。Rustのバージョンはエラーも処理します:
pub trait FromStr: Sized {
type Err;
fn from_str(s: &str) -> Result<Self, Self::Err>;
}
pub trait ToString {
fn to_string(&self) -> String;
}
Isoとは異なり、Poly Isoは複数の型に適用でき、それらを汎用的に返します。これは、基本的な文字列パーサーに求められるものです。
一見したところ、これはパーサーを記述するための良い選択肢であるように思えます。実際にやってみましょう:
use anyhow;
use std::str::FromStr;
struct TestStruct {
a: usize,
b: String,
}
impl FromStr for TestStruct {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<TestStruct, Self::Err> {
todo!()
}
}
impl ToString for TestStruct {
fn to_string(&self) -> String {
todo!()
}
}
fn main() {
let a = TestStruct {
a: 5,
b: "hello".to_string(),
};
println!("Our Test Struct as JSON: {}", a.to_string());
}
極めて論理的なものに見えます。しかしながら、ここには2つの問題があります。
第一に、to_string
はAPIのユーザーに「これはJSONです」ということを示しません。すべての型がJSON表現に対応している必要がありますが、Rust標準ライブラリの型の多くがすでに対応するものでありません。これを使用するのはあまり適格でないでしょう。これは、独自のトレイトで簡単に解決できます。
ですが、第二の微妙な問題があります。スケーリングです。
すべての型に手作業で to_string
を書くのであれば、これは機能します。しかし、自分の型をシリアライズ可能にしたいすべての人が、たくさんのコード – そしておそらくは別のJSONライブラリ – を自分で書かなければならないのであれば、これはあっという間にめちゃくちゃなことになってしまうでしょう!
これの解答は、Serdeの2つの重要なイノベーションの1つです。データシリアライズ言語間に共通する構造としてRustデータを表現する、独立したデータモデルです。これにより、Rustのコード生成能力を活かして、 Visitor
と呼ばれる、中間的な変換型を生成することが可能となります。
つまり、正規形では (これも簡潔のためにエラー処理は省略):
case class Serde[T] {
deserialize: Visitor[T] -> T
serialize: T -> Visitor[T]
}
case class Visitor[T] {
toJson: Visitor[T] -> String
fromJson: String -> Visitor[T]
}
その結果は、Poly Iso と Iso が (それぞれ) 1つになります。この2つは、どちらもトレイトで実装可能です:
#![allow(unused)] fn main() { trait Serde { type V; fn deserialize(visitor: Self::V) -> Self; fn serialize(self) -> Self::V; } trait Visitor { fn to_json(self) -> String; fn from_json(json: String) -> Self; } }
Rustの構造体を独立した形式に変換するための統一されたルールがあるために、型T
に関連する Visitor
を作成するコード生成も可能です:
#[derive(Default, Serde)] // "Serde" の derive は trait impl ブロックを生成します
struct TestStruct {
a: usize,
b: String,
}
// ユーザは、このマクロを記述して、関連する Visitor 型を生成します
generate_visitor!(TestStruct);
では、次のようにするのでしょうか?
fn main() {
let a = TestStruct { a: 5, b: "hello".to_string() };
let a_data = a.serialize().to_json();
println!("Our Test Struct as JSON: {a_data}");
let b = TestStruct::deserialize(
generated_visitor_for!(TestStruct)::from_json(a_data));
}
これは変換が、結局のところ対称的でないことを明らかにしました!紙上ではともかく、自動生成コードを使うと、String
からの変換に必要となる実際の型の名前が隠されています。型名を得るために、 generated_visitor_for!
マクロのようなものが必要になります。
この方法は歪ですが、しかし機能しています…私たちが、分かっていても触れたくないものに手を付けるまでは。
現在サポートしているフォーマットはJSONだけです。より多くのフォーマットをサポートするにはどうすればよいでしょうか?
現在の設計では、すべてのコード生成を完全に書き直し、新しい Serde トレイトを作成する必要があります。これは非常に恐ろしいことで、拡張性がまったくありません!
これを解決するには、もっと強力なものが必要です。
Prism
フォーマットを考慮に入れるには、次のような正規形が必要です:
case class Serde[T, F] {
serialize: T, F -> String
deserialize: String, F -> Result[T, Error]
}
この構成は Prism と呼ばれます。Poly Isoより「一段上」ジェネリックなものです (この場合、「交差する」型Fがキーです) 。
残念ながら、Visitor
はトレイトなので (各具象が独自のカスタムコードを必要とするため) 、Rustがサポートしていないジェネリックの型境界のようなものが必要になります。
幸いなことに、私たちには Visitor
タイプがまだあります。 Visitor
は何をするものでしょうか?各データ構造がそれ自身をパースする方法を定義可能にしようとするものです。
では、ジェネリックなフォーマット用のインターフェースをもう一つ追加できるとしたらどうでしょう?そうすれば、Visitor
は単なる実装のディテールに過ぎず、2つのAPIの「橋渡し」になるでしょう。
正規形では:
case class Serde[T] {
serialize: F -> String
deserialize F, String -> Result[T, Error]
}
case class VisitorForT {
build: F, String -> Result[T, Error]
decompose: F, T -> String
}
case class SerdeFormat[T, V] {
toString: T, V -> String
fromString: V, String -> Result[T, Error]
}
なんと、下部にあるのは、トレイトとして実装可能なPoly Isoのペアです!
こうして私たちは Serde API を得ました:
- シリアライズされる各型は
Serde
クラスに対応するDeserialize
かSerialize
を実装します - それらはVisitorトレイトを実装したある型 (各方向に1つずつの2つ) を持ちます。これは通常 (常にではありませんが) deriveマクロによって生成されるコードによって行われます。これには、データの型とSerdeデータモデルのフォーマットとの間でコンストラクトやデストラクトを行うロジックが含まれています。
Deserializer
を実装した型は、Visitor
によって「駆動」されて、フォーマットに固有のすべてのディテールを処理します。
この分割とRust型消去は、まさにPrismを間接的に実現するためのものです。
これは Deserializer
トレイトで確認することが可能です
pub trait Deserializer<'de>: Sized {
type Error: Error;
fn deserialize_any<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>;
fn deserialize_bool<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>;
// あとは省略
}
そしてVisitorは:
pub trait Visitor<'de>: Sized {
type Value;
fn visit_bool<E>(self, v: bool) -> Result<Self::Value, E>
where
E: Error;
fn visit_u64<E>(self, v: u64) -> Result<Self::Value, E>
where
E: Error;
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: Error;
// あとは省略
}
そして、マクロによって実装された Deserialize
トレイト:
pub trait Deserialize<'de>: Sized {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>;
}
これは抽象的なものでしたから、具体的な例を見てみましょう。
Serde はどのようにJSONの断片を前述の struct Concordance
にデシリアライズするでしょうか?
- ユーザーは、データをデシリアライズするため、ライブラリ関数を呼び出します。これは JSONフォーマットに基づく
Deserializer
を生成します。 - 構造体のフィールドに基づき、それを表現するために必要とされる汎用データモデルの各型 (
Vec
(リスト)、u64
、String
) の生成方法を知っているVisitor
が生成 (詳細は後述) されます。 - デシリアライザーは、アイテムをパースするに際し、
Visitor
を呼び出します。 Visitor
は、見つかったアイテムが期待されたものであるかどうかを示し、もしそうでなければ、デシリアライズに失敗したことを示すエラーを示します。
上記の非常に単純な構造体の場合、期待されるパターンは次のようになります:
- マップ ( _Serde_の
HashMap
やJSONのディクショナリに相当するもの) への訪問を開始。 - 「keys」という文字列キーを訪問。
- マップの値の訪問を開始。
- 各アイテムについて、文字列キーと続いて整数値を訪問。
- マップの終端を訪問。
- マップをデータ構造の
keys
フィールドに格納。 - 「value_table」という文字列キーを訪問。
- リスト値の訪問を開始。
- 各アイテムについて、整数を訪問。
- リストの終端を訪問
- リストを
value_table
フィールドに格納。 - マップの終端を訪問。
org/stable/src/alloc/string.rs.html#2235-2240)
例: https://docs.rs/stm32f30x-hal/0.1.0/stm32f30x_hal/gpio/gpioa/struct.PA0.html
Serde solves this usability challenge with a derive macro:
use serde::Deserialize;
#[derive(Deserialize)]
struct IdRecord {
name: String,
customer_id: String,
}
このマクロは単に、構造体に Deserialize
と呼ばれるトレイトを実装させる impl ブロックを生成します。
これは、構造体自体の作成方法を決定する関数です。コードは構造体のフィールドに基づいて生成されます。構文解析ライブラリ (私たちの例ではJSON構文解析ライブラリ) が呼び出されると、Deserializer
が作成され、それをパラメータとして Type::deserialize
が呼び出されます。
これは非常に大きなトピックであり、その能力を完全に理解するには、言語設計に関する実際の書籍が必要になるでしょう。しかし、Rustでの適用はもっと簡単です。
完全な例については、Serde documentation を参照してください。
その結果、デシリアライズされる型はAPIの 「トップレイヤー」 のみを実装し、ファイルフォーマットは 「ボトムレイヤー」 のみを実装すればよいことになります。ジェネリック型がそれらの橋渡しをするために、各ピースは残りのエコシステムと「単純に動作する」ことができます。
その過程で、オプティクスと呼ばれる、ある別のパターンをカバーします。 Iso 、 Poly Iso、 Prism です。
このトピックについてもっと知りたい方は、以下のセクションをご覧ください。
あわせて読みたい
- lens-rs クレートは、これらの例よりもすっきりとしたインターフェイスを持つ、プリビルドなlensの実装です
- serde 自体が、これらのコンセプトを、エンドユーザが詳細を知ることなく、直感的に理解可能なもの (すなわち構造体の定義) にするものです
- luminanceは、レンズAPIの設計を使用した、コンピュータグラフィックスを描画するためのクレートです。手続き型マクロを持ち、このマクロは、異なるピクセルタイプのバッファに対する完全なprismを汎用性を保ち作成します
- Scala における lens についての記事 は、Scalaの経験なしでもとても読みやすいものです。
- 論文:Profunctor Optics: Modular Data Accessors
- Musliは、似たような構造を異なるアプローチ、たとえば visitor を使わずに、で使おうとするライブラリである
(https://web.archive.org/web/20221128190041/https://www.schoolofhaskell.com/school/to-infinity-and-beyond/pick-of-the-week/a-little-lens-starter-tutorial)