Peter Hamiltonさんの2月9日付のブログ記事Types, Tuples, Records, Maps & Structsの翻訳です。
そういえばこれらの使い分け、結構微妙です。どうしてこうなったのか。
先にPeterさんの結論を書いておくと「現時点でよく使われているのはタプル。だけど構造体が一番いいんだけどなあ」です。
Elixirではいくつかのデータタイプについて数多くの混乱があります。ここまで入り組んでしまった経緯を知ることはどうしてこれほど多数のオプションがあるのか理解するために重要です。そこには長きに渡るErlangの存在がありました。
Type (型)
全てのプログラムは型、つまり「データの種類」について何らかの概念を持っています。いくつかの言語、例えばHaskellなどは、コンパイラが強力な安全性を保証するために利用できるほどリッチな型システムを持っています。他の言語、例えばRubyなどでは非常に流動的な型システムを持っており実行時の動的なメタプログラミングを可能にしています。ほとんどの言語で共通な一つの問題として、いかにして得られる結果の型によって適切に処理を分岐させるかというものがあります。以下のコードで考えてみましょう:
result = do_something();
if (is_success(result)) {
foo(result);
} else {
bar(result);
}
ではこのスニペットをいくつかのプログラミング言語/プログラミングパラダイムで試してみます。
Ruby(オブジェクト指向)
class Success
def new(result)
@result = result
end
def process
foo(result)
end
end
class Failure
def new(result)
@result = result
end
def process
bar(@result)
end
end
# do_somethingは成功(Success)または失敗(Failure)を返す
result = do_something
result.process
Scala(関数型)
do_something match {
case Success(result) => foo(result)
case Failure(result) => bar(result)
}
オブジェクト指向の環境ではポリモーフィズムを有効に使うことで結果により処理を分岐させられます。
関数型の環境ではパターンマッチングによって結果による分岐を行えます。
Tuple(タプル)
ではElixirを見てみましょう。Elixirは関数型言語なので一般的なイディオムとしては次のようになります:
case do_something do
{:success, result} -> foo(result)
{:failure, result} -> bar(result)
end
ScalaとElixirの違いに気が付きましたか。ADT1を使う代わりにElixirでは単に型情報をタプルの初めのエントリにエンコードしています。このような特定のユースケースではこのやり方はうまくいきます。実行時に使えるだけの情報が含まれています。
Record(レコード)
その昔、Erlangでは唯一タプルだけが型情報を効率よくエンコードする方法でした。タプルの中に型情報をエンコードするようになってみたらアクセスが面倒くさくなります。例えば次のようなケース{:blog_post, author, title, subject}
です。ブログの投稿のタイトルを得ようとしたらelem(post, 2)
2を使う必要があります。どうみてもぎごちないなやり方です。
レコードはその問題を解決するためにErlangに追加されました。コードの中で例えばblog_post(post, :title)
のようにすることでコンパイル時にelem(post, 2)
のように変換されます。これで多少は見た目がよくなってメンテナンスもしやすくなりました。データ構造を{:blog_post, date, author, title, subject}
に変更すればコンパイラはblog_post(post, :title)
を新しいデータ構造を反映するためelem(post, 3)
に書き換えてくれるでしょう。
defrecord :success, [:data]
defrecord :failure, [:data]
case do_something do
success(data: data) -> foo(data)
failure(data: data) -> bar(data)
end
これで問題がないわけではありません。レコードの形式を変えると実行中のコードとの整合性が取れなくなることがあり、ホットコードリローディング3をぶち壊してしまいます。これは一種の分散型のポリモーフィズムです。もし全ての構造がスロット2にdata
という要素を持っているならばthing.data
の呼び出しは複数のレコードで動作するでしょう。要素の置き場所についての要求は考慮することができますが、実際にはその解決のためにさらに問題が発生するでしょう。
Map(マップ)
マップはErlangに2013年に追加されました。マップは中に含んでいるkey及びvalueをパターンマッチできるという点でユニークです。タプルを使う代わりに%{type: :success, result: result}
のように使えます。これはタプル及びレコード型での{:success, result}
と同型(isomorphic)です。
case do_something do
%{type: :success, result: result} -> foo(result)
%{type: :error, result: result} -> bar(result)
end
実のところ、それは真ではありません。マップは中に含んでいるkeyのサブセットとマッチできてしまうからです。%{type: :success, result: result}
は%{type: :success, result: result, debug: debug}
とマッチさせるのにも使えます。技術的にはパターンマッチングによる{:success, result}
から%{type: :success, result: result}
へのマップは準同型(homomorphism)であるといえます。
しかしこれは実用面ではかなりまずいです。もし有効な戻り値の型がひとつより多い場合、それらが共通で持っているkeyだけをマッチさせないといけないからです。
Struct(構造体)
構造体はElixirの際立った特徴です。パターンマッチングの能力とエンコードされた「型」の使い方を標準化するために作られました。Struct%Success{result: result}
は実のところ%{__struct__: Success, result: result}
のシンタックスシュガーに過ぎません。加えて構造体の中には有効なkeyを定義し、正しく使えているかどうか確実にするためのヘルパー関数を置くことができます4。
case do_something do
%Success{result: result} -> foo(result)
%Failure{result: result} -> bar(result)
end
構造体は言語中で第一級市民であるため、プロトコルとも一緒に使うと有用です。データ型についての一般的な表記法を与えるのでプロトコルを使ってポリモーフィズムを提供できます。
defprotocol Process do
def process(result)
end
defimpl Process, for: Success do
def process(%{result: result}) do
foo(result)
end
end
defimpl Process, for: Failure do
def process(%{result: result}) do
bar(result)
end
end
# do_something は %Success{} または %Failure{} を返す
result = do_something
Process.process(result)
Rubyの例と似ているように見えます。これは他の解より重いですが強力かつ柔軟です。
どれを使えばいいのか?
「より良い方法」と「イディオム的な方法」の間には難しいトレードオフがあります。構造体とタプルを使う場合を比較すると個人的にはたいていの局面で構造体の方がより優れていると感じています。とは言うものの一貫性というものには多くの価値があるものです。{:ok, res}
が標準的なライブラリの多くの戻り値で使われており5、構造体とマッチさせるのは面倒に思うのは理解しています。でもこれは構造体がまだ新しいからだと信じています。もっと多くの場面でタプルの代わりに構造体が使われればいいのになと期待しています。そのほうが優位な点が多くまた拡張性も大きくなるからです。
では何を使えばいいでしょうか。いつものことですが要求事項のバランスを取りましょう。抽象度を高くしておけば考えが変わった時に与えるインパクトは小さくなります。あなたのコードを使った他の開発者からのフィードバックをもらってください。もし迷った時は、物事をシンプルにしておく。これで間違いはめったに起きません。
-
Algebraic Data Types 代数的データ型 ↩
-
elem/2
タプルを第一引数に、0ベースのインデックスを第二引数に取りインデックス番目の要素を返す。 ↩ -
動作中にコードを再読み込みして切り替える機能。 ↩
-
マップと違って
s = %Hogehoge{}
とするだけで定義されたkeyとvalue(初期値定義可能)を持った構造体が作れますし、未定義のkeyを指定するとエラーが返るなどのチェック機能はあるんですがヘルパー関数って何かよくわからなかった、すんません。 ↩ -
Erlang/OTP由来のものが多いのでこれはしかたないですね。まだElixirはErlangという巨人の肩にちょこんと乗っかってるだけの状態ですから。 ↩