エラーと Option

Motoko では、エラー値を表現し処理するための方法が主に 3 つあります。

  • Option 値(何らかの エラーを示す、情報を持たない null 値を含む)

  • Result のバリアント(エラーに関する詳細な情報を提供する #err 値 の記述を含む)

  • Error 値(非同期コンテキストでは、例外処理のようにスロー(throw)したりキャッチ(catch)したりすることができ、数値コードとメッセージを含む)

API の例

Todo アプリケーションの API を構築していると想定し、ユーザーが Todo の 1 つに Done という印を付ける関数を公開したいとします。 問題をシンプルにするために、TodoId を受け取り、Todo を開いてから何秒経ったかを表す Int を返すことにします。 また、自分自身の Actor で実行していると仮定し、非同期の値を返すことにします。 問題が一切起きないとすると、次のような API になります。

func markDone(id : TodoId) : async Int

参照のため、このドキュメント内で使用する全ての型とヘルパー関数の全体記述を示します。

import Int "mo:base/Int";
import Hash "mo:base/Hash";
import Map "mo:base/HashMap";
import Time "mo:base/Time";
import Result "mo:base/Result";
import Error "mo:base/Error";
type Time = Int;
type Seconds = Int;

func secondsBetween(start : Time, end : Time) : Seconds =
  (end - start) / 1_000_000_000;

public type TodoId = Nat;

type Todo = { #todo : { text : Text; opened : Time }; #done : Time };
type TodoMap = Map.HashMap<TodoId, Todo>;

var idGen : TodoId = 0;
let todos : TodoMap = Map.HashMap(32, Int.equal, Hash.hash);

private func nextId() : TodoId {
  let id = idGen;
  idGen += 1;
  id
};

/// Creates a new todo and returns its id
public shared func newTodo(txt : Text) : async TodoId {
  let id = nextId();
  let now = Time.now();
  todos.put(id, #todo({ text = txt; opened = now }));
  id
};

問題が起こる場合

ここで、Todo に完了の印をつける処理が失敗する条件があることに気がつきました。

  • id が存在しない Todo に紐づけられている可能性がある

  • Todo にすでに完了の印がついている可能性がある

これから Motoko でこれらのエラーを取り扱う様々な方法について示し、コードを徐々に改善していきます。

どのようなエラー型が良いか

どうすべき ではない のか

エラーを報告する簡単で 良くない 方法の一つは、番兵 (Sentinel) を使用することです。例えば、 markDone 関数において -1 という値を使用して、何かが失敗したことを通知することにします。その場合、呼び出し側は戻り値をこの特別な値と照らし合わせてエラーを報告しなければなりません。しかし、エラー状態をチェックせずに、その値を使ってその後の処理を続けることはあまりにも簡単です。 これは、エラーの検出を遅らせたり見逃したりすることにつながるので、必ず避けるべきです。

定義:

public shared func markDoneBad(id : TodoId) : async Seconds {
  switch (todos.get(id)) {
    case (?(#todo(todo))) {
      let now = Time.now();
      todos.put(id, #done(now));
      secondsBetween(todo.opened, now)
    };
    case _ { -1 };
  }
};

呼び出し側:

public shared func doneTodo1(id : Todo.TodoId) : async Text {
  let seconds = await Todo.markDoneBad(id);
  if (seconds != -1) {
    "Congrats! That took " # Int.toText(seconds) # " seconds.";
  } else {
    "Something went wrong.";
  };
};

可能であれば、例外よりも Option/Result を優先する

Motoko では、エラーを通知する方法として OptionResult を使用することが推奨されています。 これらは同期・非同期のどちらのコンテキストでも動作し、API をより安全に使用することができます (成功と同様にエラーも考慮するようにクライアントに促すことができます)。 例外は、予期しないエラー状態を通知するためにのみ使用されるべきです。

Option によるエラー通知

A 型の値を返すかそうでなければエラー通知を行いたい関数は、 Option 型の ?A の値を返し、 null 値を使用してエラーを指定することができます。 今の例では、markDone 関数が async ?Seconds を返すことを意味します。

以下は、 markDone 関数の例です。

定義:

public shared func markDoneOption(id : TodoId) : async ?Seconds {
  switch (todos.get(id)) {
    case (?(#todo(todo))) {
      let now = Time.now();
      todos.put(id, #done(now));
      ?(secondsBetween(todo.opened, now))
    };
    case _ { null };
  }
};

呼び出し側:

public shared func doneTodo2(id : Todo.TodoId) : async Text {
  switch (await Todo.markDoneOption(id)) {
    case null {
      "Something went wrong."
    };
    case (?seconds) {
      "Congrats! That took " # Int.toText(seconds) # " seconds."
    };
  };
};

この方法の主な欠点は、起こりうるすべてのエラーを、情報を持たない一つの null 値にひとまとめにしてしまうことです。 呼び出し側は Todo を完了させることに失敗した理由に興味があるかもしれませんが、その情報は失われています。つまり、ユーザーには "何かがうまくいかなかった" としか伝えられません。 エラーを知らせるために Option 値を返すのは、失敗の原因が 1 つだけで、その原因が呼び出し側で容易に判断できる場合だけにすべきです。 この良い使用例のひとつは、HashMap の参照に失敗した場合です。

Result 型によるエラーリポート

エラーを知らせるために Option 型を使用することの欠点を解決するために、今度はより多機能な Result 型を見てみましょう。 Option は組み込みの型ですが、 Result は以下のようにバリアント型として定義されています。

type Result<Ok, Err> = { #ok : Ok; #err : Err }

2 つ目の型引数である Err により、Result 型ではエラーを記述するために使用する型を選択することができます。 そこで、markDone 関数がエラーを通知するために使用する TodoError 型を定義することにします。

public type TodoError = { #notFound; #alreadyDone : Time };

これを用いて markDone の 3 つ目のバージョンを書きます。

定義:

public shared func markDoneResult(id : TodoId) : async Result.Result<Seconds, TodoError> {
  switch (todos.get(id)) {
    case (?(#todo(todo))) {
      let now = Time.now();
      todos.put(id, #done(now));
      #ok(secondsBetween(todo.opened, now))
    };
    case (?(#done(time))) {
      #err(#alreadyDone(time))
    };
    case null {
      #err(#notFound)
    };
  }
};

呼び出し側:

public shared func doneTodo3(id : Todo.TodoId) : async Text {
  switch (await Todo.markDoneResult(id)) {
    case (#err(#notFound)) {
      "There is no Todo with that ID."
    };
    case (#err(#alreadyDone(at))) {
      let doneAgo = secondsBetween(at, Time.now());
      "You've already completed this todo " # Int.toText(doneAgo) # " seconds ago."
    };
    case (#ok(seconds)) {
      "Congrats! That took " # Int.toText(seconds) # " seconds."
    };
  };
};

ご覧の通り、ユーザーに役立つエラーメッセージを表示することができるようになりました。

Option/Result を使用する

OptionResults は、(あなたがいたる所で例外処理を行うようなプログラミング言語から来た場合は特に)エラーについて異なる考え方をすることになります。 この章では、OptionResults を作成、再構築、変換、結合するさまざまな方法を見ていきます。

パターンマッチング

最初の、そして最も一般的な OptionResult の使用場面はパターンマッチングです。 ?Text 型の値があるとき、switch キーワードを使って潜在する Text の値にアクセスすることができます。

func greetOptional(optionalName : ?Text) : Text {
  switch (optionalName) {
    case (null) { "No name to be found." };
    case (?name) { "Hello, " # name # "!" };
  }
};
assert(greetOptional(?"Dominic") == "Hello, Dominic!");
assert(greetOptional(null) ==  "No name to be found");

ここで理解すべき重要なことは、Motoko は Option の値にアクセスするときは必ず、値が見つからない場合を考慮させるということです。

Result の場合もパターンマッチングを使うことができます。ただし、#err の場合は、(単なる null ではなく)情報を持つ値も取得できるという違いがあります。

func greetResult(resultName : Result<Text, Text>) : Text {
  switch (resultName) {
    case (#err(error)) { "No name: " # error };
    case (#ok(name)) { "Hello, " # name };
  }
};
assert(greetResult(#ok("Dominic")) == "Hello, Dominic!");
assert(greetResult(#err("404 Not Found")) == "No name: 404 Not Found");

高階関数

パターンマッチングは、複数の Option 値を扱う場合は特に、退屈で冗長になることがあります。 Motoko 標準ライブラリ は、OptionalResult モジュールの高階関数群を公開することで、エラー処理を人間工学的に改善します。

Option と Result の相互変換

Option と Result の間を行ったり来たりしたいことがあります。 例えば HashMap の参照に失敗すると null が返され、それはそれで良いのですが、呼び出し元はより多くのコンテキストを持っていて、その検索の失敗を意味のある Result に変換できるかもしれません。 他には、Result が提供する追加情報は必要なく、単にすべての #err ケースを null に変換したいという場面もあります。 このような場合のために、 Motoko 標準ライブラリ では Result モジュールに fromOptiontoOption という関数を用意しています。

非同期エラー

Motoko でエラーを処理する最後の方法は、非同期の Error 処理を使うことです。これは他の言語でおなじみの例外処理に制限を設けたものです。 他の言語の例外処理とは異なり、Motoko の エラー の値は shared 関数または async 式の本体といった非同期コンテキストでのみ、スロー(throw)とキャッチ(catch)を行うことができます。非 shared 関数は構造化されたエラー処理を行うことができません。 つまり、throw でエラー値を投げて shared 関数を終了したり、try で別の Actor 上の shared 関数を呼び出して失敗を Error 型で catch することはできますが、これらのエラー処理を非同期のコンテキスト以外の通常のコードで使用することはできません。

非同期の Error は一般的に、回復できないような予期しない失敗を知らせる目的で、かつあなたの API を多くの人が利用しない場合にのみ使用されるべきです。もし失敗が呼び出し側で処理されるべきものであれば、代わりに Result を返すことで、シグネチャ(signature)でそれを明示する必要があります。完全を期すために、例外を含む markDone の例を以下に示します。

定義:

public shared func markDoneException(id : TodoId) : async Seconds {
  switch (todos.get(id)) {
    case (?(#todo(todo))) {
      let now = Time.now();
      todos.put(id, #done(now));
      secondsBetween(todo.opened, now)
    };
    case (?(#done(time))) {
      throw Error.reject("Already done")
    };
    case null {
      throw Error.reject("Not Found")
    };
  }
};

呼び出し側:

public shared func doneTodo4(id : Todo.TodoId) : async Text {
  try {
    let seconds = await Todo.markDoneException(id);
    "Congrats! That took " # Int.toText(seconds) # " seconds.";
  } catch (e) {
    "Something went wrong.";
  }
};