Erlangでは、関数呼び出しの際に、モジュール名:関数名(引数, …)
と書く代わりに、任意のタプル {モジュール名, 任意の要素, …}
を使って、{モジュール名, 任意の要素, …}:関数名(引数, …)
と書くことができる。このような書き方を、タプル・モジュール(tuple module)と呼ぶ。
タプル・モジュールで呼び出された関数には、最後の引数にそのタプルがバインドされる。
例:
-module(my_module).
-export([my_fun/3]).
my_fun(X, Y, Tup) ->
{X, Y, Tup}.
実行結果
> {my_module, a, b}:my_fun(1, 2).
{1,2,{my_module,a,b}}.
この仕組みを使って、共通のインターフェイス(API)を提供するモジュールと、そのAPIを実装するモジュールを分離してみよう。
ordmaps:汎用的なOrdered Map
ここでは、例として、要素がキーの昇順でソートされたmapを提供する汎用的なAPI ordmaps
を定義する。そして、そのAPIを、stdlibの gb_trees
と、Erlang/OTP 17の組み込みデータ型の map
の2種類のデータ構造で実装する。
ordmaps モジュールの使用例
01: M0 = ordmaps:new(gb_tree),
02: M1 = M0:add(b, 1),
03: M2 = M1:add(a, 1),
04: M3 = M2:update(b, 2),
05: ?assertEqual({ok, 1}, M3:find(a)),
06: ?assertEqual([a, b], M3:keys()), %% キーがソートされている。
01行のように、ordmaps:new(gb_tree)
とすると、gb_trees
による実装モジュールが使われる。また、ordmaps:new(map)
とすれば、mapを用いた実装モジュールが使われる。
どちらの実装モジュールを使う場合でも、
- 呼び出し側のコードに一切変更が必要ない
- 呼び出し側では実装モジュールの内容について、何も知る必要がない
ところが、ポイントだ。
こうすれば、例えば、環境や設定によって実装モジュールを切り替える、といったことが、エレガントに実現できる。
ordmapsのAPI
ordmaps
のspecを見てみよう。とりあえず以下の関数を用意した。
-module(ordmaps).
-export(
[new/1,
add/3,
update/3,
find/2,
remove/2,
keys/1,
to_list/1
]).
-spec new(map_type()) -> ordmap().
-spec add(key(), value(), ordmap()) -> ordmap() | no_return().
-spec update(key(), value(), ordmap()) -> ordmap() | no_return().
-spec find(key(), ordmap()) -> {ok, value()} | not_found.
-spec remove(key(), ordmap()) -> ordmap().
-spec keys(ordmap()) -> [key()].
-spec to_list(ordmap()) -> [key()].
型 map_type/0
、ordmap/0
、key/0
などの定義はこうなる。
-record(?MODULE, {
impl_mod :: module(),
data :: term()
}).
-type map_type() :: map | gb_tree.
-type ordmap() :: #?MODULE{}.
-type key() :: term().
-type value() :: term().
-type data() :: map() | gb_tree().
?MODULE
の部分は、コンパイル時にモジュール名 ordmaps
で置き換えられるので、こう書くのと同じだ。
-record(ordmaps, {
impl_mod :: module(),
data :: term()
}).
-type ordmap() :: #ordmaps{}.
このレコードが、タプル・モジュールになる。impl_mod
には、実装モジュールの名前を、data
には実装モジュールが使用するデータ(gb_tree、または、mapデータ)をバインドする。
例:gb_treesの場合
{ordmaps,
ordmaps_impl_gb_trees,
{3,{{b,name},"b",
{{b,count},2,
{{a,name},"a",nil,...},
nil},
nil}}}
例:mapの場合
{ordmaps,
ordmaps_impl_maps,
#{{a,name} => "a",{b,count} => 2,{b,name} => "b"}}
ordmapsモジュールと実装モジュールの関数定義
ordmaps:new/1
まず、ordmaps:new/1
を定義しよう。
-spec new(map_type()) -> ordmap().
new(map) ->
ImplMod = ordmaps_impl_maps,
Data = ImplMod:new(),
#?MODULE{impl_mod=ImplMod, data=Data};
new(gb_tree) ->
ImplMod = ordmaps_impl_gb_trees,
Data = ImplMod:new(),
#?MODULE{impl_mod=ImplMod, data=Data}.
引数 map_type
によって、実装モジュール ordmaps_impl_maps
または ordmaps_impl_gb_trees
を選択し、一方のモジュールの new/0
を呼んで、空のデータ構造を作成する。最後にそのデータ構造と実装モジュール名をタプル・モジュールにセットして、呼び出し側に返す。
ordmaps_impl_gb_trees:new/0
実装モジュール側ではこのように定義すればよい。
-spec new() -> ordmaps:data().
new() ->
gb_trees:empty().
ordmaps_impl_maps:new/0
mapによる実装はこのとおり。#{}
は空のmap。
-spec new() -> ordmaps:data().
new() ->
#{}.
ordmaps:add/3
次は ordmaps:add/3
の定義。
-spec add(key(), value(), ordmap()) -> ordmap() | no_return().
add(Key, Val, #?MODULE{impl_mod=ImplMod, data=Data}=Ordmap) ->
Data1 = ImplMod:add(Key, Val, Data),
Ordmap#?MODULE{data=Data1}.
3番目の引数としてタプル・モジュールが渡されるので、ImplMod
と Data
にバインドする。ImplMod:add/3
で実装モジュール側の関数を呼び出すと、更新されたデータ構造が返ってくるので、タプル・モジュールの data
フィールドにセットして、呼び出し元に返す。
ordmaps_impl_gb_trees:add/3
実装モジュール側の定義。特に難しいところはない。
-spec add(ordmaps:key(), ordmaps:value(), ordmaps:data())
-> ordmaps:data() | no_return().
add(Key, Val, Tree) ->
gb_trees:insert(Key, Val, Tree).
ordmaps_impl_maps:add/3
map側の定義。こちらは maps:is_key/2
によるキーの存在チェックが入っているものの、単純な実装だ。
-spec add(ordmaps:key(), ordmaps:value(), ordmaps:data())
-> ordmaps:data() | no_return().
add(Key, Val, Map) ->
case maps:is_key(Key, Map) of
true ->
error({key_exists, Key});
false ->
maps:put(Key, Val, Map)
end.
あとは、これの繰り返しだ。
完成したプログラム
完成したプログラムを GitHubの tatsuya6502/erlang-examples/ に置いたので、ぜひ実行してみてほしい。
$ git clone https://github.com/tatsuya6502/erlang-examples.git
コードだけ読みたい場合は、このリンクから:
今回は Erlang/OTP 17の map型を使用したので、実装モジュールの ordmaps_impl_maps モジュールをコンパイルするには、Erlang/OTP 17AのRC1以降のインストールが必要になる。その方法については、こちらに書いたので参考にしてほしい。
逆に、17より以前のバージョンでコンパイルするには、以下のようにする。
- tuple-module/src/ordmaps_impl_maps.erlを削除する
- tuple-module/rebar.config 中の
{require_otp_vsn, "17"}
を{require_otp_vsn, "R15|R16|17"}
に変更する。
ビルドとEUnitケースの実行
make clean eunit
でテストケースが実行される。テストケースの内容については、上記の ordmaps_tests.erl へのリンクを参照。
$ cd erlang-examples/tuple-module/
$ make clean eunit
...
test/eunit/ordmaps_test.erl:37:<0.58.0>: M = {ordmaps,ordmaps_impl_maps,#{}}
test/eunit/ordmaps_test.erl:48:<0.58.0>: M6 = {ordmaps,ordmaps_impl_maps,#{{a,name} => "a",{b,count} => 2,{b,name} => "b"}}
test/eunit/ordmaps_test.erl:37:<0.58.0>: M = {ordmaps,ordmaps_impl_gb_trees,{0,nil}}
test/eunit/ordmaps_test.erl:48:<0.58.0>: M6 = {ordmaps,ordmaps_impl_gb_trees,
{3,{{b,name},"b",{{b,count},2,{{a,name},"a",nil,...},nil},nil}}}
All 2 tests passed
仕上げ1:behaviourを定義して、コンパイラーによるチェックを強化する
このままでも問題なく動くのだが、もし、実装モジュール側で、関数の実装を忘れてしまったり、関数名やarity(引数の数)を間違えてしまったらどうなるだろうか? 例えば、add(Key, Val, Map)
とするところ、add(Key, Val)
と定義してしまったら? Erlangは動的型付き言語なので、このようなエラーがあっても、コンパイル時に検出できない。プログラムを実行して、初めてエラーに気付く。
テストケースをしっかり書けば問題ない、という意見もあるが、ここは、コンパイラーに検出させたいところだ。Erlangには、これを実現する仕組みの一つとして、behaviorがある。
ordmapsのbehaviour定義
まず ordmaps
モジュールに、callbackを定義する。
-callback new() -> data().
-callback add(key(), value(), data()) -> data() | no_return().
-callback update(key(), value(), data()) -> data() | no_return().
-callback find(key(), data()) -> {ok, value()} | key_not_found.
-callback remove(key(), data()) -> data().
-callback keys(data()) -> [key()].
-callback to_list(data()) -> [{key(), value()}].
なお、この callback を使った表記法は Erlang/OTP R15B で導入されたので、それより以前のバージョンでは使用できない。それらのバージョンでは、behaviour_info/0
という関数を定義すればよい。
実装モジュール側で、behaviourを実装することを宣言
実装モジュール側では、ordmaps
のbehaviourを実装することを宣言する。
-behaviour(ordmaps).
こうすることで、関数の実装漏れ、関数名やarityの間違いを、コンパイラーが検出できるようになる。
TODO: エラー検出の例
仕上げ2:specを書いて、Dialyzerでチェックする
残念ながら、コンパイラーは関数のarity(引数の数)までしかチェックできないので、behaviourによる方法だけでは、型の間違いまでは検出できない。例えば add(Key, Val, Map)
とするところを、add(Map, Key, Val)
と定義してしまっても、コンパイルは通ってしまう。
このようなエラーは、Dialyzerで検出できる。DialyzerはErlang/OTPに付属する静的コード解析ツールで、関数の引数や戻り値の型を中心に、様々なチェックを行うことができる。ただし、Dialyzerで精度の高い解析を行うには、type定義や、関数のspecをできるだけ具体的に書く必要がある。GitHub上の完成したプログラムでは、このあたりがしっかり定義されているので、参考にしてもらいたい。個人的には、コードを書く前に、specを書くのがお気に入りのやりかただ。
Dialyzerによるチェックは make dialyze
で実行できる。もし、HOMEディレクトリに .dialyzer_plt.17
ファイルがない場合は、その構築にしばらく時間がかかるので、コーヒーでも飲みながら待ってほしい。
$ cd erlang-examples/tuple-module
$ make dialyze
...
dialyzer --plt /home/tatsuya/.dialyzer_plt.17 -Wunmatched_returns -r ./ebin
Checking whether the PLT /home/tatsuya/.dialyzer_plt.17 is up-to-date... yes
Proceeding with analysis... done in 0m0.39s
done (passed successfully)
TODO: エラー検出の例
まとめ
タプル・モジュールを使うことで、インターフェイスから実装を分離する方法を紹介した。ある程度の規模のシステムになると、このような手法を使う場面が多くあるはずだ。
また、behaviourやDialyzerを使って、ビルド時にモジュール間の実装エラーを検出する方法も解説した。私のいるチームでは、Dialyzerによるチェック(と、ここでは紹介しなかった Erlang QuickCheckのステートマシーンによるテスト)は必須アイテムとなっている。とても便利なツールなので、ぜひ活用してもらいたい。