関数型言語の Optics
Optics は、関数型言語で一般的な API 設計の一種です。これは純粋関数型の概念であり、Rust ではあまり頻繁には使われません。
それでも、この概念を探究することは、visitors など、Rust API における他のパターンを理解するのに役立つかもしれません。また、ニッチなユースケースもあります。
これはかなり大きなトピックであり、その機能を十分に掘り下げるには、言語設計に関する実際の書籍が必要になるでしょう。ただし、Rust における適用可能性はずっと単純です。
この概念の関連する部分を説明するために、Serde API を例として使用します。これは、多くの人にとって API ドキュメントだけから理解するのが難しいものだからです。
その過程で、Optics と呼ばれるいくつかの具体的なパターンを扱います。それらは The Iso、The Poly Iso、The 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;
// 残りは省略
}
ここでは、多段階の関連型が行き来しており、多くの型消去が行われています。
しかし、全体像は何でしょうか。単に Visitor が呼び出し元に必要な断片をストリーミング API で返すようにして、それで終わりにしないのはなぜでしょうか。なぜ追加の要素がすべて必要なのでしょうか。
これを理解する 1 つの方法は、optics と呼ばれる関数型言語の概念を見ることです。
これは、Rust で一般的なパターンである失敗、型変換などを容易にするように設計された、振る舞いと性質の合成を行う方法です。1
Rust 言語は、これらを直接扱うためのサポートがあまり優れていません。しかし、それらは言語自体の設計に現れており、その概念は Rust の API の一部を理解する助けになります。そのため、ここでは Rust がそれをどのように行っているかという観点から概念を説明しようとします。
これにより、それらの API が何を実現しているのか、つまり合成可能性の特定の性質に光が当たるかもしれません。
基本的な Optics
The Iso
Iso は、2 つの型の間で値を変換するものです。非常に単純ですが、概念的に重要な構成要素です。
例として、文書のコンコーダンスとして使われるカスタムのハッシュテーブル構造があるとします。2 これはキー(単語)に文字列を使用し、値(たとえばファイルオフセット)にインデックスのリストを使用します。
重要な機能は、この形式をディスクにシリアライズできることです。「手早く雑な」アプローチとしては、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]
}
結果は(それぞれ)1 つの Poly Iso と 1 つの Iso です。これらはいずれもトレイトで実装できます。
#![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 はトレイトの 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 よりジェネリクスの「1 段上」にあります(この場合、「交差する」型 F が鍵です)。
残念ながら、Visitor はトレイトであるため(各具体化には独自のカスタムコードが必要なので)、Rust がサポートしていない種類のジェネリック型境界が必要になります。
幸い、以前の Visitor 型はまだあります。Visitor は何をしているのでしょうか。それぞれのデータ構造が、自身を解析する方法を定義できるようにしようとしています。
では、汎用形式用にもう 1 つインターフェイスを追加できるとしたらどうでしょうか。その場合、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 は、先ほどの struct Concordance に JSON の一部をどのようにデシリアライズするのでしょうか。
- ユーザーはデータをデシリアライズするためにライブラリ関数を呼び出します。これにより、JSON 形式に基づく
Deserializerが作成されます。 - 構造体内のフィールドに基づいて、
Visitorが作成されます(これについては後ほど詳しく説明します)。これは、それを表現するために必要だった汎用データモデル内の各型、つまりVec(リスト)、u64、Stringを作成する方法を知っています。 - デシリアライザーは項目を解析しながら
Visitorを呼び出します。 Visitorは、見つかった項目が期待されたものかどうかを示し、そうでない場合はデシリアライズが失敗したことを示すエラーを発生させます。
上記の非常に単純な構造では、期待されるパターンは次のようになります。
- map(Serde における
HashMap、または JSON の dictionary に相当)の訪問を開始します。 - “keys” という文字列キーを訪問します。
- map の値の訪問を開始します。
- 各項目について、文字列キーを訪問し、次に整数値を訪問します。
- map の終端を訪問します。
- その map をデータ構造の
keysフィールドに格納します。 - “value_table” という文字列キーを訪問します。
- list の値の訪問を開始します。
- 各項目について、整数を訪問します。
- list の終端を訪問します。
- その list を
value_tableフィールドに格納します。 - map の終端を訪問します。
しかし、どの「観測」パターンが期待されるかは何によって決まるのでしょうか。
関数型プログラミング言語であれば、カリー化を使って型そのものに基づく各型のリフレクションを作成できます。Rust はそれをサポートしていないため、すべての型について、そのフィールドとプロパティに基づいて独自のコードを書く必要があります。
Serde は、この使いやすさの課題を derive マクロで解決しています。
use serde::Deserialize;
#[derive(Deserialize)]
struct IdRecord {
name: String,
customer_id: String,
}
このマクロは単に、その構造体が Deserialize というトレイトを実装するようにする impl ブロックを生成します。
これは、構造体そのものをどのように作成するかを決定する関数です。コードは構造体のフィールドに基づいて生成されます。解析ライブラリが呼び出されると(この例では JSON 解析ライブラリ)、それは Deserializer を作成し、それをパラメーターとして Type::deserialize を呼び出します。
deserialize のコードはその後 Visitor を作成し、その呼び出しは Deserializer によって「屈折」されます。すべてがうまくいけば、最終的にその Visitor は解析対象の型に対応する値を構築し、それを返します。
完全な例については、Serde ドキュメントを参照してください。
その結果、デシリアライズされる型は API の「最上位層」だけを実装し、 ファイル形式は「最下位層」だけを実装すればよくなります。その後、各部分は エコシステムの他の部分と「そのまま動作」できます。ジェネリック型がそれらの 橋渡しをするためです。
結論として、この API 設計で示されているように、Rust のジェネリックに着想を 得た型システムは、これらの概念に近づき、その力を活用できます。ただし、 そのジェネリクスの橋渡しを作成するために、手続き型マクロが必要になる場合も あります。
このトピックについてさらに学ぶことに興味がある場合は、次のセクションを確認してください。
関連項目
- 事前に構築されたレンズ実装である lens-rs クレート。 これらの例よりもすっきりしたインターフェイスを備えています
- Serde そのもの。これは、エンドユーザー(つまり構造体を定義する人)が 詳細を理解しなくても、これらの概念を直感的に扱えるようにします
- luminance は、コンピューターグラフィックスを 描画するためのクレートで、同様の API 設計を使用しています。これには、ジェネリックのままである さまざまなピクセル型のバッファーに対して完全なプリズムを作成する手続き型マクロも含まれます
- Scala におけるレンズに関する記事。 Scala の専門知識がなくても非常に読みやすい記事です。
- 論文: Profunctor Optics: Modular Data Accessors
- Musli は、異なるアプローチで同様の構造を 使用しようとするライブラリです。たとえば、visitor をなくしています