このブログの更新は Twitterアカウント @m_hiyama で通知されます。
Follow @m_hiyama

メールでのご連絡は hiyama{at}chimaira{dot}org まで。

はじめてのメールはスパムと判定されることがあります。最初は、信頼されているドメインから差し障りのない文面を送っていただけると、スパムと判定されにくいと思います。

参照用 記事

Erlang実験室:ok/error方式はやっぱりダメなんだよ

「Erlang実験室:武士道と云ふは死ぬ事と見付けたり」において、例外を使わずに、ok/error方式でエラーを伝達するのは弊害が多い、と言いました。いい例が見つかったので、その例で説明します。

内容:

  1. 例の説明
  2. caseの入れ子がぁーー
  3. スッキリしたようだが
  4. 何が悪いんだ
  5. ラップされた関数を使ってみると
  6. 最後の注意

●例の説明

プロセスの内部状態Stateを、ファイルに書き出したかったのです(実話)。ファイルに追記(append)するのではなくて、毎回上書きします。ファイルのオープンのときに中身を捨てる(サイズをゼロにする)方法が見つかりませんでした(あったら教えて)。削除してから新規作成でもいいのですが、次のようにしてみました。冗長かも知れませんが、それはいいとします。

  1. file:openで、ファイルを開ける。
  2. file:positionで、書き込み開始位置をオフセット0にする。
  3. io:fwriteで、書き込む。
  4. file:truncateで、書き込んだ場所より後を切り落とす。
  5. file:closeで、ファイルを閉じる。

これから作る関数を dump_state/1 とします*1。dump_stateは、成功すればokを、失敗すれば{error, Reason}を返す仕様(ok/error方式)にします。

●caseの入れ子がぁーー

各ライブラリ関数のマニュアルを見ながら、戻り値を律儀にチェックするコードを書くと:


-define(DUMP_FILE, "state.dump").

%% @doc 状態Stateをファイルに書き出す
%% @spec (term()) -> ok | {error, Reason}
dump_state_0(State) ->
try
case file:open(?DUMP_FILE, [write]) of
{error, Reason} ->
{error, Reason};
{ok, Dev} ->
case file:position(Dev, 0) of
{error, Reason} ->
{error, Reason};
{ok, 0} ->
io:fwrite(Dev, "~p.~n", [State]), % 戻り値は常にok
% ここで、ランタイムエラーの可能性あり
case file:truncate(Dev) of
{error, Reason} ->
{error, Reason};
ok ->
file:close(Dev) % 最後の戻り値
end
end
end
catch
C:Reason2 ->
{C, Reason2}
end.

ウーム、グチャグチャで何やってるかわかりません。[追記]ちょっとミスしていたので書き換えた、余計ゴチャゴチャになった(苦笑)[/追記]

●スッキリしたようだが

こんなとき伝統的な技法では、パターンマッチを使って、成功のケースだけを記述します。


dump_state_1(State) ->
case (catch dump_state_1_sub(State)) of
{'EXIT', {Reason, _StackTrace}} ->
{error, Reason};
{error, Reason} ->
{error, Reason};
ok ->
ok
end.
dump_state_1_sub(State) ->
{ok, Dev} = file:open(?DUMP_FILE, [write]),
{ok, 0} = file:position(Dev, 0),
ok = io:fwrite(Dev, "~p.~n", [State]),
ok = file:truncate(Dev),
file:close(Dev).

dump_state_1_subを見ると、確かに素直に手順を記述しています。がしかし、実際にエラーが起きると、(最後のcloseを除いて)その原因はbadmatch(パターンマッチの失敗)になってしまいます*2。ほんとの原因がエラー値で報告されることはありません。デバッグ時に混乱を招いたり、原因追及の証拠が失われたりします。

この手法は、実は悪しき伝統、バッドノウハウだったのです。

●何が悪いんだ

fileモジュールの関数がok/error方式なので、それを利用する側でも適切に例外機構を使うことができないのです。fileモジュールの各関数に対して、きれいに例外を投げるようにラップをかぶせましょう。


%% @doc fileモジュール関数に対するラップ関数群
-module(file_wrap).

-export([open/2, position/2, truncate/1, close/1]).
% -compile(export_all).

open(Filename, Modes) ->
case file:open(Filename, Modes) of
{ok, IoDevice} ->
IoDevice;
{error, Reason} ->
throw(Reason)
end.

%%
%% 以下省略
%%

●ラップされた関数を使ってみると

こうなります。


dump_state_2(State) ->
try
dump_state_2_sub(State)
catch
Reason ->
{error, Reason}
end.
dump_state_2_sub(State) ->
Dev = file_wrap:open(?DUMP_FILE, [write]),
file_wrap:position(Dev, 0),
io:fwrite(Dev, "~p.~n", [State]),
file_wrap:truncate(Dev),
file_wrap:close(Dev).

例外をどうするかは利用者にまかせ、dump_state_2 ではなくて、dump_state_2_sub のほうを dump_state として公開したほうがいいでしょう。

ラップされた関数は、値を含むタプルではなくて値そのものをストレートに返すので、次のような表現もできます。


io:fwrite(file_wrap:open(?DUMP_FILE, [write]), "~p.~n", [State]),

値がそのまま使えるメリットは大きくて、関数型らしく「式を使ったコーディング」ができます。今回の例では、Devを使い回すので、1つの式にするのは難しいですがね。

ちなみに、上の例のtry式ではランタイムエラーは捕まえていません。badmatchやbadargはすり抜けていきますが、ランタイムエラーはバグや不測の災害ですから、関数ロジック内では捕まえないほうが健全です。

●最後の注意

何でも極端に走る人がいるので、注意しておきます。例外を使わないでok/errorのほうが良いときもあります

  • gen_serverとのRPC通信などは、ok/errorを使います。例外という事象を {error, Reason}というタームにエンコーディングして伝達する方式です。例外のエンコード/デコード(例外に戻す)は、必要があれば通信の両端で行います。
  • 公開しない関数で、ok/errorのほうが便利だと感じたら、別に躊躇する必要はありません。try構文よりcase分岐のほうが単純で読みやすくなることはあります。

*1:プロセスの状態だけでなく、任意のタームをファイルに書き出す関数です。名前はdump_termとかのほうが適切ですね。

*2:[追記] io:fwriteがランタイムエラーを起こしたときはbadmatch以外のエラーになりますね。