原文:https://dashbit.co/blog/data-evolution-with-set-theoretic-types
(監訳: 山崎 進 @zacky1972 )
最近私 1 はElixirをCやRustのネイティブコードと統合するプロジェクトに取り組んでいます。Rustのライブラリの1つは次の構造体を定義します。(わかりやすくするためにフィールドは削除されています。)
struct Schema {
name: CString,
format: CString,
metadata: Option<Vec<u8>>,
dictionary: Option<*mut Schema>
}
上記の構造は、名前がnullになる可能性があるというCの仕様に従っていないことがわかりました。
残念ながら、私が使用したCライブラリは名前がnullに設定されたデータを生成していたため、Rustライブラリと相互運用することができませんでした。
これを発見したのは私にとって不運でした。このライブラリはRustエコシステム内で長年にわたりいくつかのプロジェクトでよく使用されていますが、これまで誰もこの問題に遭遇したことはありませんでした。理想的な解決策は、おそらく名前をOption型にラップすることによって、構造体フィールドの型を変更することです。
struct Schema {
name: Option<CString>,
format: CString,
metadata: Option<Vec<u8>>,
dictionary: Option<*mut Schema>
}
しかしながらこれを行うと、ライブラリの既存のすべてのユーザーが実質的に機能しなくなります。私に数年後には明らかに誰も遭遇しなかったシナリオに基づいて、ライブラリにこのような重大な変更を提案する資格があるのか。このような変化は、ライブラリのエコシステムを事実上2分することになります。
あるいは、現在の型定義と互換性を維持しながら、仕様には従わないまま、nullを空の文字列に変換することもできます。これは、この特定のケースで採用された選択肢です。
正直に言うと、これはどの選択肢も最適ではない難しい問題です。この記事の目的は、多くの静的型付け言語が、ライブラリが後方互換性のある方法で公開データ定義を進化させることを許さないという問題に対して、集合論的型がどのように対処できるかを探ることです。提案されたソリューションは、コンパイラによって自動的に検証され、タイプセーフになることを目的としています。
Elixirプログラミング言語に集合論的な型システムを導入する研究が進行中です。このブログ投稿はElixir型と集合論型の両方に関連していますが、言語に対する公式の提案ではありません。ディスカッションの場を広げ、フィードバックを収集するために、Dashbitブログに公開しています。
重大な変更:ライブラリとアプリケーション
CStringからOption<CString>への名前の変更は、私たち自身のアプリケーション内では、コードのすべての利用者を統制しているため、受け入れられるだろうが、ライブラリでそれが起こると、変更のダウンストリームを引き起こすことになります。2この記事では、ライブラリの名前を"data_schema"と仮定します。
"data_schema"が、上記のスキーマタイプなどのユーザー向けタイプを後方互換性のない方法で変更する場合、その作成者は新しいメジャーバージョンをリリースする必要があります。新しいメジャーバージョンは、分岐点になる可能性があります。"data_schema"の新しいバージョンに依存するライブラリは、古いバージョンを受け入れません。"data_schema"に依存する古いライブラリは更新する必要があり、新しいメジャーバージョンのリリースも強制される可能性があり、問題が連鎖的に発生します。途中のパッケージがメンテナンスされていない場合、ダウンストリームの更新が停止し、複雑になる可能性があります。
一方で、開発者である私たちは、データ定義が日々進化しているという事実に対処しています。経験豊富なWebアプリケーション開発者に相談すると、「データベース列の名前は決して変更しないでください」と言われるでしょう。代わりに、新しい列を追加し、データをコピーし、古い列を削除する必要があります。
同様に、Erlang VMは分散システムの構築に使用されますが、これには古いノードと新しいノードが相互に対話する必要もあります。また、ホットコードアップグレードも実行されるため、システムをダウンさせることなく実稼働環境で実行されているコードを変更できます。これには、コードベースが古いバージョンと新しいバージョンの両方のデータを処理する必要があります。
データのバージョン管理の例や使用法は数多くあるにもかかわらず、私たちの型システムは、同じことを行うためのサポートをほとんど提供していないことがよくあります。データ定義を変更すると、古いバージョンのデータはすべて存在しなくなり、すべてのコードを直ちに書き直す必要があると想定されます。
Elixirで問題を再現する
問題をElixirに移しましょう。Elixirチームは、集合論的型の下で構造体がどのように宣言するかをまだ定義していませんが、現在Elixir構文として有効なアイデアをいくつか検討することができます。
Elixirで型付き構造体を次のように定義すると仮定してください (これをv1と呼びます)。
defmodule Schema do
defstruct do
name :: string()
end
end
Elixirでは、新しい構造体を%Schema{name: "mycolumn"}としてインスタンス化できるようになりました。
そして、スキーマ名を大文字に変換する便利な関数があるとします。これは非常にばかげていますが、問題部分を調査するのに十分です。
defmodule SchemaHelpers do
$ Schema.t() -> Schema.t()
def upcase_name(%{name: name} = schema) do
%{schema | name: String.upcase(name)}
end
end
%Schema{name: "mycolumn"}を指定して呼び出すと、%Schema{name: "MYCOLUMN"}が返されます。
スキーマ定義を変更してnilをサポートするとどうなるでしょうか?これをv2と呼びましょう。
defmodule Schema do
defstruct do
name :: string() or nil
end
end
上記の変更により、SchemaHelpers.upcase_name/1が型指定違反を報告することが期待できるようになります。名前はnilにできるようになりましたが、nil値を指定するとString.upcase/1関数は失敗します。
これは正しい…のでしょうか?
スキーマのv1では、nameをstring()のみにすることができました。静的型付け言語では、スキーマのすべての使用にstring()名があることが効果的に証明されています。v2で名前をnilにすることを許可しても、定義上、名前がnilであるスキーマのインスタンスは存在しないため、まだソフトウェアにバグが発生することはありません。
言い換えれば、既存のコードはすべて正しいままですが、ほとんどの型システムは、nil値を受け取る可能性があるため、すぐに間違っているという判定をしてしまいます。もっと良い方法があるはずです。それがこの記事で学ぶことです。
特に、型安全性を維持しながら、古いバージョンと新しいバージョンのスキーマが共存できるメカニズムを提供したいと考えています。幸いなことに、Elixirの型システムを使用すると、構造サブタイピングを通じてこれを探索できます。それでは、さらに詳しく見てみましょう。
構造サブタイプによるデータのインスタンス化
簡単に言うと、構造サブタイプでは、スキーマ定義ではなくスキーマ値に基づいて型が割り当てられます。
型が名前で表される公称型システムでは、通常、次のElixirコードは型スキーマを持ちます。
%Schema{name: "mycolumn"}
フィールド名が何を表すかを正確に知りたい場合は、スキーマ定義を調べて、v1を使用している場合はstring()であることを確認します。v2を使用している場合は、スキーマのインスタンス化時にフィールド名に常に文字列が指定されていても、string()またはnilになります。
ただし、構造サブタイプを使用すると、上記の定義はSchema.t(name: string()) 型を取得します。これは、スキーマ定義で指定されたフィールド型のサブタイプである限り有効です。スキーマ定義をより幅広い型に変更しても、インスタンス化されたデータは変更されず、型の非互換性も発生しません。したがって、現時点では、名前がnilのSchemaインスタンスはありません。
リビジョンによる型チェック
上で見たように、構造的部分型分類により、スキーマ定義が変更された場合でも、スキーマフィールドをインスタンス化するときにその型を保持することができます。しかし、関数シグネチャについてはどうでしょうか?
upcase_name関数はそのピッタリな例です:
defmodule SchemaHelpers do
$ Schema.t() -> Schema.t()
def upcase_name(%{name: name} = schema) do
%{schema | name: String.upcase(name)}
end
end
nameの型が突然string()またはnilに変更された場合、この関数はnil値を処理できないため、型指定違反を報告します。ここで、nilも処理できるように上記の関数を修正しましょう。
defmodule SchemaHelpers do
$ Schema.t() -> Schema.t()
def upcase_name(%{name: nil} = schema) do
schema
end
def upcase_name(%{name: name} = schema) do
%{schema | name: String.upcase(name)}
end
end
コードは修正されましたが、良くない点があります。Schema.t(name: string())型のstruct%Schema{name: "mycolumn"}を指定すると、関数シグネチャは、新しいSchema.t()を返すと書いてあります。Schema.t(name: string()またはnil)と入力します。つまり、構造体をv1としてインスタンス化できても、構造体に対して何らかの操作を行うとすぐに、その型はstring()またはnil型を持つv2に"アップグレード"されます。これにより、さらに型指定違反が発生し、ライブラリを使用するコードベースに重大な変更が発生する可能性があります。
それよりも、upcase_name関数がスキーマのバージョンを保存できれば理想的です。v1型のスキーマが指定された場合、v1型のスキーマが返されます。v2型のスキーマが指定された場合、v2型のスキーマが返されます。事実、関数の実装を見ると、これはすでに保証されています。ただし、関数シグネチャはこのプロパティをエンコードしません。
この記事では、明示的なバージョン管理メカニズム (リビジョンと呼びましょう) を構造体に導入することで、この問題に対処することを提案します。したがって、この場合、更新されたスキーマ構造体は次のようになります。
defmodule Schema do
defstruct do
name :: string()
revision 2 do
name :: string() or nil
end
end
end
リビジョンなしで宣言されたフィールドは、リビジョン1に属するとみなされます。さらに、これ以降、Schema.t()は常に最新リビジョンのフィールドタイプを返すとしますが、次のようにフィールドタイプを明示的に指定できることを覚えておいてください。Schema.t(名前: string())。
リビジョン2(r2)がリビジョン1(r1)のスーパータイプであり、型システムが強制できるものである限り、r2で記述されたすべてのコードはr1とr2の両方で機能すると概ね言い切れるでしょう。r1用に書かれたコードは、r1でのみ機能します。
次の課題は、upcase_nameが r1が与えられた場合はr1を返し、r2が与えられた場合はr2を返すことを証明することです。直感的に、私たちは次のことを望んでいます。
・r1型のスキーマ、つまり名前フィールドがstring()である場合、string()名を持つスキーマを返します。
・r1ではなくr2のスキーマタイプが指定された場合、nameフィールドはnilのみにすることができ、string()またはnilのいずれかの名前を持つスキーマを返すことができます (結局のところ、r2がr1にダウングレードしても気にしません)。
幸いなことに、交差型のおかげで、上記のロジックは関数シグネチャに正確にエンコードできます。
$ Schema.t(name: string()) -> Schema.t(name: string())
$ Schema.t(name: nil) -> Schema.t()
上記の定義では、構造体がr1に一致する場合、r1を返します。それ以外の場合、nameフィールドにnilの可能性があるr2の追加部分を含む構造体を受け取ると、r2を返します。これにより、リビジョン保持プロパティと呼ばれるものを強制できるようになります。
ほとんどの静的型付け言語を扱う開発者は、後方互換性を保つためには、入力の型を拡張することしか許可されていないことを認識しています。ただし、表現力豊かな型システムでは、新しい入力型に対して行う限り、出力型を拡張するオプションもあり、それがまさに上記の型の機能です。最初からnameフィールドがnilだった場合、nil名の構造体のみを返すことができます。
最も重要な点は、上記のシグネチャを書く必要がないということです。リビジョンに明示的にタグを付けたため、Elixirコンパイラは$ Schema.t() -> Schema.t()を、リビジョン保存プロパティを強制する関数シグネチャに自動的に書き換えることができます。簡単に言うと:スキーマの古いバージョンと新しいバージョンの両方をサポートでき、正確性を保証するためにすべての作業がコンパイラと型システムによって実行されるため、ライブラリ作成者は開発者に安全で優れた体験を提供できます。
どのように機能するのかを深く掘り下げる前に、もう少し例を見てみましょう。
複数フィールドのリビジョン
例をもう少し複雑にしてみましょう。これが構造体の定義だったと仮定してください。
defmodule Schema do
defstruct do
name :: string()
age :: integer()
revision 2 do
name :: string() or nil
age :: integer() or nil
end
end
end
$ Schema.t() -> Schema.t()シグネチャがupcase_name/1で使用されているとすると、リビジョン保存プロパティを検証するためにコンパイラーが自動的に生成する必要があるシグネチャは何でしょうか?
それは次のとおりです。
$ Schema.t(name: string(), age: integer()) -> Schema.t(name: string(), age: integer())
$ Schema.t(name: nil) or Schema.t(age: nil) -> Schema.t()
これは以前のものとよく似ていますが、より多くのフィールドが含まれるようになりました。r1を受信した場合 (name は文字列、age は整数)、r1を返します。それ以外の場合、r1ではなくr2によってエンコードされたフィールドは、r2を返します。
Schema.t(name: nil, age: nil)ではなく、Schema.t(name: nil) またはSchema.t(age: nil)と書いたことに注意してください。後者では両方のフィールドがnilである必要がありますが、フィールドのいずれかがnilである構造体はr2に属する必要があります。したがって、Schema.t(name: nil) または Schema.t(age: nil)になります。
したがって、リビジョン保存特性を証明するには、リビジョンごとに検証する必要がある可能性の数が、リビジョンされたフィールドの量だけ増加します。新しいリビジョンで変更された各フィールドは、証明するための新しい"共用体"を1つ追加します。これは型チェックの時に影響を与える可能性があります。
複数のリビジョンフィールドのパフォーマンスが問題になるかどうかに関係なく、アプリケーションでどのリビジョンを許可するかをユーザーが明示的に制御できるようにすることを提示します。特定のアプリケーションが長期間にわたって複数のリビジョンに依存することはありそうもないことです。それらは一時的なものであることを意味しています。
それでは、アプリケーションで使用されるリビジョンを制御する方法の例をいくつか見てみましょう。
明示的なリビジョン管理
data_schemaライブラリの作成者として、ライブラリが提供するすべてのリビジョンと互換性があることを証明したいため、mix.exsの構成を次のように設定します。
revisions: %{
Schema => [1, 2]
}
これにより、コンパイラと型システムはr1とr2が共存する必要があると想定されるため、ユーザーがリビジョン保存プロパティを通じてコードベースを安全にアップグレードできることがスキーマ構造体の作成者に保証されます。
一方、data_schemaを使用するアプリケーションは、アップグレード時にr1のみをサポートすることから開始することができます。
revisions: %{
Schema => [1]
}
data_schemaが両方のリビジョンで動作することが証明されたので、リビジョンをサブセットに限定できます。その後、アプリケーション開発者がr2に移行する準備ができたら、リビジョンを変更するか、構成を完全に削除します。理想的には、アプリケーション開発者は複数のリビジョンを同時に操作する必要はありません。このメカニズムは主にライブラリ作成者に権限を与えるためにありますが、大規模な更新の場合は複数のリビジョンが便利な場合があります。
推移的な依存関係
シナリオをもう少し複雑にしてみましょう。ライブラリに破壊的な変更を加える場合の最大の問題は、そのライブラリに依存する他のライブラリをすべて破壊してしまい、エコシステムに亀裂が生じることです。
そこで、次のような"depends_on_data_schema"という新しい依存関係を導入すると想像してください。
my_app -> depends_on_data_schema -> data_schema
スキーマのリビジョンを構成すると、すべてのライブラリに適用されるため、この状態が有効であることがわかります。
my_app -> depends_on_data_schema -> data_schema
r1 r1 r1
同様に:
my_app -> depends_on_data_schema -> data_schema
r2 r2 r2
ただし、言っておきたいのは依存関係ツリーを下っていくときにリビジョンが削除されない限り、リビジョンの組み合わせが許可されます。具体的には、次のようにプロジェクトをコンパイルできます。
my_app -> depends_on_data_schema -> data_schema
r1 r1 r1-r2
または次のようにします。
my_app -> depends_on_data_schema -> data_schema
r1 r1-r2 r1-r2
しかし、次のようにはなりません。
my_app -> depends_on_data_schema -> data_schema
r2 r1 r1-r2
これにより、依存関係を段階的にアップグレードできるようになります。実際、my_appは、互いに依存しない2つの異なるライブラリ(1つはr1に依存し、もう1つはr2に依存)と通信することもでき、型チェッカーはそれらの境界が尊重されていることを検証できます。
r2からr1にダウンキャストすることもできます。たとえば、フィールドを空の文字列に設定することで、Rustライブラリによって行われる選択を模倣して、r2をr1にダウンキャストできます。
$ Schema.t() -> Schema.t(name: string())
def from_r2_to_r1(%{name: nil}), do: %{schema | name: ""}
def from_r2_to_r1(%{name: string} = schema), do: schema
あるいは、名前がnilになることはあり得ず、それ以外の場合は実行時に失敗する (アンラップと同等)と仮定し、r2をr1にダウンキャストしたい場合は、次のように書くこともできます。
$ Schema.t() -> Schema.t(name: string())
def from_r2_to_r1(%{name: nil}), do: raise "not allowed"
def from_r2_to_r1(%{name: string} = schema), do: schema
この署名では、名前が常にstring()型になるようにオーバーライドされることを除いて、指定されたスキーマのすべてのフィールドを保持することが示されています。
ダウンキャストは実際に役立つでしょうか?それはまだわかりません。
形式化のピンチ
これまでのところ、スキーマの進化を改訂し、各リビジョンがスーパータイプであることを保証することで、コンパイラーと型システムが連携して、コードが複数のリビジョンにわたって動作することが保証されることがわかりました。それは舞台裏でどのように機能するのでしょうか?
このセクションは、型に興味のある人向けであり、必ず読む必要はありません。実際、このブログ投稿のほとんどは、将来この機能を使用したいだけの人にはおそらく必要ありません。
コンパイラが型シグネチャ内のSchema.t()を見つけると、リビジョンごとに新しい交差(つまり、新しい矢印) が追加されます。それぞれの新条項には以下のものがあります。
・以前のリビジョンのドメインを除く、現在のリビジョンに設定されたドメイン
・以前のリビジョンのコードメインを結合した現在のリビジョンに設定されたコードメイン
簡単に言えば、スキーマに3つのバージョンr1、r2、r3がある場合、型シグネチャは次のようになります。
$ domain_r1 -> codomain_r1
$ domain_r2 and not domain_r1 -> codomain_r1 or codomain_r2
$ domain_r3 and not domain_r2 and not domain_r1 -> codomain_r1 or codomain_r2 or codomain_r3
ここのdomain_r1は、Schema.tのすべてのインスタンスがr1などに置き換えられた型署名のドメインです。
最初は複雑に聞こえるかもしれませんが、これらはすべて標準的な集合演算に要約されます。いくつかの例を見てみましょう。
r1 = Schema.t(name: string())およびr2 = Schema.t(name: string() または nil)のスキーマを考えます。関数シグネチャ$ Schema.t() -> Schema.t()は次のようになります。
# domain_r1 -> codomain_r1
$ Schema.t(name: string()) -> Schema.t(name: string())
# domain_r2 and not domain_r1 -> codomain_r1 or codomain_r2
$ Schema.t(name: string() or nil) and not Schema.t(name: string()) ->
Schema.t(name: string() or nil) or Schema.t(name: string())
これは非常に冗長ですが、幸いなことに、実際にはユーザーが目にするものではありません。r2はr1のスーパータイプであるため、型システムによりこれらの操作の多くが簡素化されます。
最初の矢印はすでに最も単純な形になっています。2番目のスキーマでは、Schema.t(name: string())ではなくSchema.t(name: string() or nil)がSchema.t(name: nil)と同等です(string()またはnilという名前のすべてのスキーマ)ただし、名前がstring()のスキーマはnil名を持つスキーマのみになります)。さらに、Schema.t(name: string() or nil) または Schema.t(name: string())はSchema.t(name: string() or nil)と同じです。これらの簡略化を適用し、フィールドをデフォルトの型から除外すると、最初に書いたものになります。
$ Schema.t(name: string()) -> Schema.t(name: string())
$ Schema.t(name: nil) -> Schema.t()
反変性34は何か?
この点について人々が抱くかもしれない質問の1つは、反変性はどうなるのでしょうか?というものです。スキーマを受け取り、別のスキーマを受け取り、さらに別のスキーマを返す関数を返す高階関数がある場合はどうなるでしょうか?
次の型シグネチャを持つことになります。
$ Schema.t() -> (Schema.t() -> Schema.t())
上記のドメインとコードメインのルールを適用すると、次の2つの矢印が表示されます。ここで、r1とr2はそれぞれのスキーマバージョンを表します。
$ r1 -> (r1 -> r1)
$ r2 and not r1 -> (r2 -> r2) or (r1 -> r1)
最初の矢印は、いつものように、すでに簡略化された形になっています。2番目はどうでしょうか?
もう一度、r1ではなくr2が見つかりました。これはSchema.t(name: nil)であることがわかります。これにより、2 番目の矢印の領域が単純化されます。そのコードメインはどうなるのでしょうか?
矢印の結合がある場合、Elixirでは実行時に関数が予期する型をチェックすることができないため、(a -> a) または (b -> b)の唯一の有効な適用は、aとbの両方を満たす引数です。したがって、それらのドメイン間の共通部分 (別名入力) を計算し、コードメインの和集合 (別名出力) を返す必要があります。この場合、(r2 -> r2)または(r1 -> r1)があるため、入力の共通部分は最小の型r1になり、和集合は最大の型r2になり、(r1 -> r2)が残ります。これは元の型よりも正確さは劣りますが、Elixirの意味論を反映したタイプです。
r1とr2をそれぞれのスキーマに置き換えると、最終的なシグネチャは次のようになります。
$ Schema.t(name: string()) ->
(Schema.t(name: string()) -> Schema.t(name: string()))
$ Schema.t(name: nil) ->
(Schema.t(name: string()) -> Schema.t())
ここで型システムが生成した定義には、非常に洗練されたものがあります。なぜなら、それが可能な限り安全な定義を提供するからです。返される関数は、r1のスキーマのみを受け付けず(つまり、その入力に対して厳密です)、可能な限り最も広範なスキーマを返します(つまり、出力に対しては広範です)。これらの定義は自動的に導出され、リビジョン保持プロパティを保証するためにコードの型チェックの対象となる意味論です。
データの進化
最後に議論すべきトピックが1つあります。それは、リビジョンを使用するときに構造体の定義にどのような変更が可能かということです。
新しいリビジョンは以前のリビジョンのスーパータイプである必要があるため、実行できる操作は次のとおりです。
・フィールドを以前より広くします(スーパータイプ)。
・デフォルト値を使用して新しいフィールドを追加します。
・フィールドを非推奨としてマークします (将来の最新バージョンでは削除される可能性があるため)。非推奨フィールドはオプションとしてマークされ、新しいコードが古いフィールドとの互換性を保ちつつ、それらを完全にインスタンス化することを避けることができるようになります。
新しいフィールドを追加すると、リビジョンは以前のリビジョンのサブタイプになりますが、そのフィールドにデフォルト値がある場合、以前のリビジョンでは実際にそのフィールドはオプションタイプであったと考えることができます。したがって、実質的にフィールドを追加するリビジョンは、フィールドを必須型にすることと同じであり、これはスーパー型です。
そして、コンパイラは実際にリビジョンがこれらのルールに従っていることを保証します。それ以外の変更 (フィールドの削除、サブタイプや不連続型への変更など) は破壊的変更となります。これは制限があるように見えますが、後方互換性を保ちたいすべてのElixirライブラリ (および他のプログラミング言語) は現在、すでにそのような制約の下にあります。リビジョンは、データの進化を斬新的かつ型安全にすることで、現状を効果的に改善する必要があります。これはElixirエコシステムにとって重要で、言語とPhoenixなどの主要なフレームワークが10年以上後方互換性を保っています。
まとめ
この記事で、多くの言語に存在するデータのバージョン管理の問題を紹介し、考えられる解決策の1つを概説しました。全体として、上記で概説したアイデアの安全性を公式化して証明することや、ここで概説したことのどこまでが実用的であるかを自問自答するなど、課題が待ち受けています。
データのバージョン管理のゴールは、ライブラリ作成者が破壊的な変更を頻繁に課すことなくスキーマを進化させるためのより多くのメカニズムを提供することです。アプリケーション開発者は、既存のコードベースとその型をすぐに更新したいため、この機能の使用は限定的でしょう。ただし、耐久性のあるデータや分散システムでは利用できるかもしれません。
理論的な観点から見ると、これを機能させるために必要な唯一の機能は、和集合、交差、否定を使用した構造的なサブタイピングであり、これらはすべてElixirの集合論的型システムですぐに利用できます。構造体のバージョン管理自体(リビジョンとも呼ばれます)はコンパイラーによって完全に処理できるため、実装が非常にアクセスしやすくなります。型システムの役割は、単にこれを可能にする基盤を提供することです。
提案とソリューションの最初の形式化については、Giuseppe Castagna、Guillaume Duboc、Xuejing Huang に多大な感謝を申し上げます。また、ドラフトについてフィードバックをくださったRichard Feldman、Leandro Ostera、Louis Pilfoldにも感謝します。全ての意見は、私自身5の個人的なものです。