原文:https://elixir-lang.org/blog/2024/08/28/typing-lists-and-tuples/
(監訳: 山崎 進 @zacky1972 )
私たちは、Elixirプログラミング言語の型システムに取り組んできました。この型システムは、健全な漸新的型付けを提供します。つまり、静的コードと動的コードを安全にインターフェイスできプログラムが型チェックを行う場合、実行時に型エラーは発生しません。
型エラーを強調することが重要です。現在大規模に使用されている型システムは、実行時エラーがないことを保証するものではなく、型付けエラーのみを保証します。多くのプログラミング言語は、空のリストの「先頭」にアクセスするとエラーが発生します。ほとんどの言語は、ゼロ除算や実数領域での負の数の対数を計算するときにエラーを発生させます。また、メモリの割り当てに失敗したり、数値がオーバーフロー/アンダーフローしたりするときにエラーが発生する場合があります。
言語の設計者と保守担当者は、型付けエラーとして表現できるものの境界と、それがライブラリの設計にどのような影響を与えるかを概説する必要があります。この記事の目的は、Elixirの進行中の型システム作業におけるリストとタプルのコンテキストで、これらの決定のいくつかを強調することです。
この記事で、「発生する」と「例外」という言葉は、予期しない何かが起こったことを表すものであり、制御フローのメカニズムを表すものではありません。他のプログラミング言語では、これを「パニック」または「フォールト」と呼ぶ場合があります。
リストの先頭
プログラミング言語を設計していて、リストの先頭 (最初の要素) を返す head関数を提供したいとします。3つのオプションを検討できます。
多くのプログラミング言語で見られる最初のオプションは、空のリストが与えられた場合に例外を発生させることです。Elixirでの実装は次のようになります。
$ list(a) -> a
def head([head | _]), do: head
def head([]), do: raise "empty list"
型システムは空のリストと空でないリストを区別できないため、コンパイル時には型違反は検出されませんが、空のリストの場合は実行時にエラーが発生します。
別の方法としては、関数が失敗する可能性がある (または失敗しない可能性がある) ことを適切にエンコードして、オプション型を返す方法があります。
$ list(a) -> option(a)
def head([head | _]), do: {:ok, head}
def head([]), do: :none
このアプローチは少し冗長かもしれません。オプション型を返すと、基本的に呼び出し元は返されたオプションに対してパターンマッチを行う必要があります。多くのプログラミング言語ではオプション値を構成する関数が提供されていますが、追加のラップを取り除いて、代わりにリストに対して直接パターンマッチを行うこともできます。つまり、次のようにコードを書く…
case head(list) do
{:ok, head} -> # ヘッドがあります
:none -> # 必要な処理を行います
end
…代わりにこのように記述できます。
case list do
[head | _] -> # ヘッドがあります
[] -> # 必要な処理を行います
end
上記の両方の例は、型システムが空のリストと空でないリストを区別できないため、実行時に処理する必要があるという制限があります。この制限を取り除けば、head
を次のように定義できます。
$ non_empty_list(a) -> a
def head([head | _]), do: head
これで、空のリストが引数として指定されると、コンパイル時に型違反が発生します。オプションのタグ付けはなく、実行時例外もありません。これで双方にとって満足する結果でしょうか?
上記の問題は、リストが空でないことを証明するのは言語ユーザーの責任であることです。たとえば、次のコードを想像してください:
list = convert_json_array_to_elixir_list(json_array_as_string)
head(list)
上記の例では、convert_json_array_to_elixir_list
が空のリストを返す可能性があるため、コンパイル時に型違反が発生します。これを解決するには、head
を呼び出す前に、convert_json_array_to_elixir_list
の結果が空のリストでないことを証明する必要があります:
list = convert_json_array_to_elixir_list(json_array_as_string)
if list == [] do
raise "empty list"
end
head(list)
しかし、この時点では、パターンマッチングを使用して、もう一度head
を削除したほうがよいでしょう:
case convert_json_array_to_elixir_list(json_array_as_string) do
[head | _] -> # head があります
[] -> # 必要な操作を実行します
end
ほとんどの人は、より多くの情報を型システムにエンコードするとメリットしかないと予想しますが、ここには矛盾があります。型にエンコードするほど、プログラムで証明しなければならないことが増える可能性があります。
開発者によって特定のイディオムが他のイディオムよりも好まれる場合もありますが、ここでは明らかに優れたアプローチが1つあるとは考えていません。開発者がリストが空でないことを前提としている場合、head
で実行時エラーを発生させるのが最も実用的なアプローチかもしれません。option
を返すと、ユーザーに結果を明示的に処理させることで例外をなくすことができますが、特にユーザーが空のリストを前提としていない場合は、パターンマッチングに比べて定型句が多くなります。そして最後に、正確な型を追加すると、開発者が証明しなければならないことが増える可能性があります。
Elixirでは?
集合論型のおかげで、Elixirの型システムでは、空のリストと空でないリストを区別することになるでしょう。なぜなら、それらのパターンマッチングは一般的な言語の慣用句だからです。さらに、String.split/2
などのElixirのいくつかの関数は、空でないリストを返すことが保証されており、関数の戻り値の型にうまくエンコードできます。
Elixirには、Erlangから継承された関数hd
(head用)とtl
(tail用) があり、これらは有効なガードです。これらは空でないリストのみを引数として受け入れますが、これは型システムによっても強制されるようになります。
これは、ほとんどすべてのユースケースをカバーしますが、1つだけ例外があります。空であることが証明されていないリストの最初の要素にアクセスしたい場合はどうなるでしょうか。これらのケースにはパターンマッチングと条件文を使用できますが、上記のように、次のような一般的な定型句につながる可能性があります。
if list == [] do
raise "unexpected empty list"
end
幸いなことに、Elixirでは有効な入力に対して実行時エラーが発生する可能性をエンコードするために!
サフィックスを使用するのが一般的です。このような状況では、List.first!
(および末尾のバリアントの場合は List.drop_first!
も)を導入する場合があります。
タプルへのアクセス
リストについて説明したので、次はタプルについてお話しします。ある意味、タプルはリストよりも難しいです。その理由は次の2つです。
1. リストはすべての要素が同じ型(list(integer())
またはlist(integer()
またはfloat())
)を持つコレクションですが、タプルは各要素の型を持ちます。
2. タプルには、elem(tuple, 0)
のように先頭と末尾ではなくインデックスでネイティブにアクセスします。
次のv1.18リリースでは、Elixirの新しい型システムがタプル型をサポートし、中括弧で囲んで記述されます。たとえば、File.read/1関数の戻り型は{:ok, binary()}
または{:error, posix()}
になります。これは、現在のtypespecsと非常によく似ています。
タプル型では最小サイズも指定できます。{atom(), integer(), ...}
と記述することもできます。これは、タプルに少なくとも2つの要素があり、最初の要素がatom()
で、2番目の要素がinteger()
であることを意味します。この定義は、パターンとガードでの型推論に必要です。結局のところ、ガード is_integer(elem(tuple, 1))
は、タプルに少なくとも 2 つの要素があり、2番目の要素が整数であることを示しますが、他の要素とタプル全体のサイズについては何も示しません。
タプルのサポートがmain
に統合されたため(訳註: Elixirレポジトリのmain
ブランチのことか?) 、elem(tuple, index)
などのタプル操作によってどのような種類のコンパイル時警告と実行時例外が発行されるかなどの質問に答える必要があります。現在、次の場合にエラーが発生することがわかっています:
1. elem({:ok, "hello"}, 3)
のようにインデックスが範囲外の場合
2. elem({:ok, 123}, -1)
のようにインデックスが負の場合
elem(tuple, index)
と入力する場合、1つのオプションは、「すべてのランタイム エラーを回避する」ことを指針として、elem
が{:ok, value}
または:none
などのオプションタイプを返すようにすることです。範囲外エラーの場合はこれで問題ありませんが、インデックスが負の場合も:none
を返す必要がありますか? どちらも範囲外であると主張することもできます。一方、タプルのサイズによっては正のインデックスが正しい場合もありますが、負のインデックスは常に無効です。この観点から、常に無効な値を:none
としてエンコードすると、開発者のエクスペリエンスに悪影響を及ぼし、論理的なバグが (大声で) 爆発する代わりに隠れてしまう可能性があります。
もう1つのオプションは、これらのプログラムを無効にすることです。elem/2
を言語から完全に削除し、パターンマッチング (またはtuple.0
などのリテラル表記の追加) によってのみタプルにアクセスできるようにすれば、型チェッカーですべてのバグをキャッチできます。ただし、Erlangの配列などの一部のデータ構造は動的なタプルアクセスに依存しているため、それらを実装することはできなくなります。
さらに別のオプションは、整数自体を型システムの値としてエンコードすることです。Elixir の型システムが:ok
と:error
の値を型としてサポートするのと同じように、13や-42などの各整数も型としてサポートできます (または、neg_integer()
、zero()
、pos_integer()
などの特定のサブセット)。このようにして、型システムは型チェック中にインデックスの可能な値を認識し、複雑な式をelem(tuple, index)
に渡して、インデックスが無効な場合は入力エラーを発行できます。ただし、より多くの情報を型にエンコードすると、他の多くの場合、開発者はそれらのインデックスが境界内にあることも証明しなければならない場合があることに注意してください。
繰り返しになりますが、さまざまなトレードオフがあり、現在のElixirの使用とセマンティクスに最も適したものを選択する必要があります。
Elixirでは?
Elixirで採用しているアプローチは2つあります。
-
インデックスがリテラル整数の場合、タプル要素に対して正確なアクセスを実行します。つまり、タプルのサイズが少なくとも 2 であることを証明できれば
elem(tuple, 1)
は機能しますが、そうでない場合は型エラーが発生します。 -
インデックスがリテラル整数でない場合、関数は動的型シグネチャにフォールバックします。
2番目のポイントについて詳しく説明しましょう。
基本的なレベルでは、elem
をtuple(a)
,integer() -> a
という型シグネチャで記述できます。ただし、このシグネチャの問題は、型システム (およびユーザー) にランタイム エラーの可能性を伝えないことです。幸い、Elixirは段階的な型システムを提供するため、型シグネチャをdynamic({...a})
,integer() -> dynamic(a)
としてエンコードできます。引数と戻り値の型をdynamic
としてエンコードすることで、完全に静的なプログラムを求める開発者には入力エラーが通知されますが、言語の動的機能に依存している既存の開発者は引き続きそのようにすることができ、それらの選択は型にエンコードされるようになりました。
全体として、
-
静的プログラム(
dynamic()
型を使用しないプログラム) の場合、elem/2
は最初の引数が既知の形状のタプルであり、2番目の引数が0以上でタプルサイズ未満のリテラル整数であることを検証します。これにより、実行時例外が発生しないことが保証されます。 -
段階的プログラムは、現在と同じセマンティクス (および実行時例外) を持ちます。
まとめ
この記事が、Elixirに段階的な型システムを導入する際の設計上の決定事項の概要を説明できたことを願っています。タプルとリストのサポートはほとんどの型システムで「必須」の機能ですが、Elixirに導入することで、型システムがいくつかの言語イディオムとどのように相互作用するかを理解するチャンスとなり、将来の決定の基盤も提供されました。最も重要なポイントは次のとおりです。
1. 型の安全性は双方のコミットメントです。型システムでより正確な型を使用してさらに多くのバグを見つけたい場合は、プログラムに特定の型違反がないことを頻繁に証明する必要があります。
2. すべてが型としてエンコードされるわけではないため、例外は重要です。オプション型が存在する場合でも、elem(tuple, index)
が負のインデックスに対して:none
を返すと、開発者にとってメリットがありません。
3. Elixirのサフィックス!
を使用する規則有効なドメイン (入力タイプ) のランタイム例外の可能性をエンコードすることは、型システムをうまく補完します。これは、静的プログラムが予期しないシナリオで:none/:error
を例外に変換する定型句を回避するのに役立つためです。
4. 関数シグネチャでdynamic()
を使用することは、関数が動的な動作をし、ランタイムエラーが発生する可能性があることを通知するための Elixirの型システムで利用可能なメカニズムであり、完全に静的なままにしたいプログラムで違反を報告できるようにします。他の静的言語がAny
またはDynamic
タイプを介して動的な動作を提供する方法に似ています。
型システムは、CNRS と Remote のパートナーシップのおかげで実現しました。開発作業は現在、Fresha (採用中)、Starfish*、および Dashbit によって後援されています。
型付け(typing)を楽しんでください!