Help us understand the problem. What is going on with this article?

Erlang/OTP: タプル・モジュールでインターフェイスと実装モジュールを分離する

More than 5 years have passed since last update.

Erlangでは、関数呼び出しの際に、モジュール名:関数名(引数, …) と書く代わりに、任意のタプル {モジュール名, 任意の要素, …} を使って、{モジュール名, 任意の要素, …}:関数名(引数, …) と書くことができる。このような書き方を、タプル・モジュール(tuple module)と呼ぶ。

タプル・モジュールで呼び出された関数には、最後の引数にそのタプルがバインドされる。

例:

my_module.erl
-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を用いた実装モジュールが使われる。

どちらの実装モジュールを使う場合でも、

  1. 呼び出し側のコードに一切変更が必要ない
  2. 呼び出し側では実装モジュールの内容について、何も知る必要がない

ところが、ポイントだ。

こうすれば、例えば、環境や設定によって実装モジュールを切り替える、といったことが、エレガントに実現できる。

ordmapsのAPI

ordmaps のspecを見てみよう。とりあえず以下の関数を用意した。

ordmaps.erl
-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/0ordmap/0key/0 などの定義はこうなる。

ordmaps.erl
-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 で置き換えられるので、こう書くのと同じだ。

ordmaps.erl
-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 を定義しよう。

ordmaps.erl
-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

実装モジュール側ではこのように定義すればよい。

ordmaps_impl_gb_trees.erl
-spec new() -> ordmaps:data().
new() ->
    gb_trees:empty().

ordmaps_impl_maps:new/0

mapによる実装はこのとおり。#{} は空のmap。

ordmaps_impl_maps.erl
-spec new() -> ordmaps:data().
new() ->
    #{}.

ordmaps:add/3

次は ordmaps:add/3 の定義。

ordmaps.erl
-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番目の引数としてタプル・モジュールが渡されるので、ImplModData にバインドする。ImplMod:add/3 で実装モジュール側の関数を呼び出すと、更新されたデータ構造が返ってくるので、タプル・モジュールの data フィールドにセットして、呼び出し元に返す。

ordmaps_impl_gb_trees:add/3

実装モジュール側の定義。特に難しいところはない。

ordmaps_impl_gb_trees.erl
-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 によるキーの存在チェックが入っているものの、単純な実装だ。

ordmaps_impl_maps.erl
-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より以前のバージョンでコンパイルするには、以下のようにする。

  1. tuple-module/src/ordmaps_impl_maps.erlを削除する
  2. 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を定義する。

ordmaps.erl
-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を実装することを宣言する。

ordmaps_impl_gb_trees.erl
-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のステートマシーンによるテスト)は必須アイテムとなっている。とても便利なツールなので、ぜひ活用してもらいたい。

tatsuya6502
Erlang/OTPやRustで分散DBを開発してます。プログラミングは中学生の頃から、30年ほど独学でやってきてます。大学はアメリカ東海岸にある美大でした。現在は上海在住
https://blog.rust-jp.rs/tatsuya6502/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした