Motokoプログラミング2

2023年2月19日

Motokoプログラミング1の続きである。

さて、自力で一からプログラミングを学ぶよりも、サンプルを動かした方が楽である。https://github.com/dfinity/examplesに様々なサンプルがあるようなので、これに挑戦してみる。

個別のサンプルごとではなく、すべてをひとまとめのリポジトリなので、一度だけcloneする。

git clone https://github.com/dfinity/examples.git

examplesというフォルダができているので、その下のmotokoの下の各サンプルについて見ていく。ただし、あまりに単純なものについては省略。

simple-to-do

単純なTO-DO管理である。入力した文言に番号をつけ、ハッシュテーブルに格納しておき、すべてを表示したり、番号を指定して「済」マークをつけ、済のものを削除したりする。

これを起動すると、次のようなバックエンドのUIが表示できる。一般ユーザ向けのフロントエンドは無いようだ。

また、VSCode+Motokoプラグインの機能により、ライブラリ定義等をすぐに表示させることができる。調べたい場所を右クリックして「定義に移動」する。この例では、Hashと名付けたライブラリ(import Hash “mo:base/Hash”;)のHash型を調べている。

以下で、このプログラムが何をしているのかコメントをつけてみる。

import Map "mo:base/HashMap";
import Hash "mo:base/Hash";
import Nat "mo:base/Nat";
import Iter "mo:base/Iter";
import Text "mo:base/Text";

// Define the actor
actor Assistant {

  /* Cの構造体のようなものらしい
     https://internetcomputer.org/docs/current/motoko/main/overview
     のType Systemを参照のこと
   */
  type ToDo = {
    description: Text;
    completed: Bool;
  };

  /* 自然数をテキスト化してハッシュコードを作成して返す。Hash.Hashは32ビット自然数であることがわかる */
  func natHash(n : Nat) : Hash.Hash { 
    Text.hash(Nat.toText(n))
  };

  /* ハッシュテーブルの定義。キーは自然数、値はToDo 
    HashMapはいわゆるクラスであり、オブジェクト指向が使えることがわかる。

  Map.HashMapの定義は以下
    public class HashMap<K, V>(
      initCapacity : Nat,
      keyEq : (K, K) -> Bool,
      keyHash : K -> Hash.Hash
    ) {
    これはジェネリックであり、Kがキー、Vが値の型を示す。コンストラクタには3つの引数があり、
    自然数型、二つのキー型の値を入力してBoolを返すfunc、一つのキー型の値を入力してHash.Hashを返すfuncになっている。
  */
  var todos = Map.HashMap<Nat, ToDo>(0, Nat.equal, natHash);

  /* 次のID*/
  var nextId : Nat = 0;

  /* すべてのToDoを取得する。[ToDo]というのがToDoの配列を意味する */
  public query func getTodos() : async [ToDo] {
    Iter.toArray(todos.vals());
  };

  /* 新たなToDoを作成して、そのIDを返す*/
  // Returns the ID that was given to the ToDo item
  public func addTodo(description : Text) : async Nat {
    let id = nextId;
    // ToDoタイプのデータ作成時の初期化に注目
    todos.put(id, { description = description; completed = false });
    nextId += 1;
    id
  };

  /* 指定されたIDのToDoを「済」にする */
  public func completeTodo(id : Nat) : async () {
    /* 式は値を持ち、最後の式が値を返してしまうが、
       このfunc自体は値を返さないため、式の値を無視するためにignoreをつけるらしい
       実際にこの関数が返しているのは、()で示されるunit typeというものらしい
       "do ?"の意味は後述
    */
    ignore do ? {
      let description = todos.get(id)!.description;
      todos.put(id, { description; completed = true });
    }
  };

  /* すべてのToDoを文字列化したものを返す */
  public query func showTodos() : async Text {
    var output : Text = "\n___TO-DOs___";
    // ハッシュマップのvalsで値のみを取り出してループする
    for (todo : ToDo in todos.vals()) {
      output #= "\n" # todo.description;
      if (todo.completed) { output #= " ✔"; };
    };
    output # "\n"
  };

  /* 済のものを削除する */
  public func clearCompleted() : async () {
    // 既存のハッシュマップを入力し、済を削除し、新たなハッシュマップを作成する
    // funcの_は特別な記号ではなく、keyと書いてもよい。単に無視するために_と書いてあるようだ。
    // ?todoの意味は後述
    todos := Map.mapFilter<Nat, ToDo, ToDo>(todos, Nat.equal, natHash, 
              func(_, todo) { if (todo.completed) null else ?todo });
  };
}

オプションブロックとヌルブレーク

先のプログラム中での「?」の意味としては、例えば、Java言語におけるOptionalの意味と思われる。Java言語が規定された当時には、こういった考えはなかったので、JavaではライブラリとしてOptionalが与えられているが、Motokoでは言語仕様に含まれている。

どういうものかと言えば、例えば整数値であるべきなのに何の値も無いことをnullで表したりするが、それを扱うプログラムでは整数値を期待するため、NullPointerExceptionが発生してしまう。これを避けるためには、nullかどうかチェックをしなければならないが、nullを扱うより簡便な方法としてOptionalが導入された。詳細はJavaのOptionalを見てほしい。

要するに、JavaではOptional<T>というふうに書いていたが、Motokoでは?Tと簡単に書け、なおかつその処理も簡単に書けるようになっている。

Motokoのドキュメントには以下のようにある。

https://internetcomputer.org/docs/current/motoko/main/control-flowの「Option blocks and null breaks」。

以下はほぼ機械翻訳


他の多くの高級言語と同様に、Motokoではnull値を選択することができ、?Tという形式のオプション型を使ってnull値の発生可能性を追跡します。これは、可能な限りnull値を使用しないようにすることと、必要なときにnull値の可能性を考慮することの両方が理由です。

後者としては、値がnullかどうかを冗長なswitch式でテストする唯一の方法であれば面倒ですが、Motokoはオプションブロックとnullブレークという専用の構文でオプションタイプの処理を簡素化しています。

オプションのブロック、「do ? <block>」 は、ブロック <block> が型 T のとき、型 ?T の値を生成し、重要な点としては、<block> からのブレークの可能性を導入します。「do ? <block>」 の中での、ヌルブレーク 「<exp> !」は、無関係なオプション型、?U の式、「<exp>」 の結果がnullであるかをテストします。「<exp>」の結果がnullの場合、制御は直ちに「do ? <block>」を値nullで抜けます。そうでなければ、「<exp>」の結果はオプション値?vでなければならず、「<exp> !」の評価はその内容であるv (of type U)で進められます。

現実的な例として、自然数、除算、ゼロテストから構築された数値表現(Variant型としてエンコードされる)を評価する簡単な関数の定義を示します。

type Exp = {
  #Lit : Nat;
  #Div : (Exp, Exp);
  #IfZero : (Exp, Exp, Exp);
};

func eval(e : Exp) : ? Nat {
  do ? {
    switch e {
      case (#Lit n) { n };
      case (#Div (e1, e2)) {
        let v1 = eval e1 !;
        let v2 = eval e2 !;
        if (v2 == 0)
          null !
        else v1 / v2
      };
      case (#IfZero (e1, e2, e3)) {
        if (eval e1 ! == 0)
          eval e2 !
        else
          eval e3 !
      };
    };
  };
}

0による除算をトラップせずに防ぐため、eval関数はオプションの結果を返し、失敗した場合はnullを使用します。

再帰的な呼び出しのたびに、!を使ってnullかどうかをチェックし、結果がnullの場合は直ちに外側のdo ?を抜けます。

(オプションブロックの簡潔さを説明する練習として、evalをラベル付きの式で書き直し、nullの区切りごとに明示的にスイッチを入れてみるのもよいでしょう)。


上のプログラムにコメントをつけてみる

// 式とは、自然数、あるいは除算、あるいは三項演算子(0のとき前者、それ以外は後者)とする
type Exp = {
  #Lit : Nat;
  #Div : (Exp, Exp);
  #IfZero : (Exp, Exp, Exp);
};

// 式を評価して結果値を返すが、値が存在しない場合もある
func eval(e : Exp) : ? Nat {
  // 式を評価するが、値が存在しない場合はすぐにreturnする
  do ? {
    switch e {
      // 自然数をそのまま返す
      case (#Lit n) { n };
      case (#Div (e1, e2)) {
        // 前者・後者の値をそれぞれ求めるが、その時点で既に値が無い場合はすぐに抜ける
        let v1 = eval e1 !;
        let v2 = eval e2 !;
        // 後者が0の場合はnullを返す。そうでなければ除算結果を返す。
        if (v2 == 0)
          null !
        else v1 / v2
      };
      case (#IfZero (e1, e2, e3)) {
        // 最初の式の値を求めるが、この時点で値無しの場合は抜ける。そうでなければ、0と比較
        if (eval e1 ! == 0)
          eval e2 !
        else
          eval e3 !
      };
    };
  };
}

Motokoプログラミング3に続く。