ローカルオブジェクトとクラス
Motoko において object
とは、ローカルのステート(var
に束縛された変数)を、そのステートにアクセスしたり更新したりする public
メソッドを組み込んでカプセル化したものです。
Motoko プログラムは、他の型付け言語と同様に、ステートをカプセル化して抽象的な型を持つオブジェクトを生成できることによる利便性を享受しています。
しかしながら、可変(Mutable)なステートを含む Motoko オブジェクトは 共有不可能( Not shareable) となっています。これはセキュリティを指向するための重要な設計上の決定です。
もしもミュータブルなステートを含むオブジェクトが共有可能(Shareable)だとすると、概念的にはオブジェクトのコードを Actor 間で移動させてリモートで実行することや、リモートのロジックにステートを共有することになり、セキュリティ上のリスクとなります。(ただし、オブジェクトが純粋な record 型である場合には、ミュータブルなステートではなくなるため、共有可能です。)
このセキュリティ上必要な制限を補うために、Actor
オブジェクトは 共有可能 ですが、常にリモートで実行されます。
これは共有可能な Motoko データとのみ通信します。
ローカルのオブジェクトは、自分自身とはそれほど制限されずに対話し、他のオブジェクトも含めた任意の Motoko データをお互いのメソッドに渡すことができます。
共有不可能な点を除くと、その他のほとんどの点でローカルオブジェクト (およびクラス) は Actor オブジェクト (およびクラス) とよく似ています。
ミュータブルなステートでは、var
に束縛された変数や(ミュータブルな)配列の割り当てといった形で、プライベートなミュータブルステートの宣言の仕方について紹介しています。
この章では、オブジェクト指向プログラミングで簡単なオブジェクトを実装するのと同じように、ミュータブルなステートを使って簡単なオブジェクトを実装します。
この言語サポートを実行例を使って説明し、次の章へと続きます。 以下の例は、Motoko プログラムの進化の道程を表しています。 各 オブジェクト は、その重要性が高いのであれば、(ローカル) オブジェクト を Actor オブジェクト にリファクタリングすることで、Internet Computer の Service へとリファクタリングできる可能性があります。
オブジェクトクラス。あるタスクを実行するために、関連するオブジェクト 群 が必要になることがよくあります。
オブジェクトが似たような動作をする場合、初期のステートはカスタマイズ可能にしつつ、同じ設計図に基づいてオブジェクトを作成することは理にかなっています。
この目的のため、Motoko は class
定義と呼ばれる構文構造を提供しており、同じ型で同じ実装のオブジェクトを簡単に作ることができます。
オブジェクトについて説明した後に、これらを紹介します。
例:カウンタオブジェクト
次のように、オブジェクト値である counter
の オブジェクト宣言 を考えてみましょう。
object counter {
var count = 0;
public func inc() { count += 1 };
public func read() : Nat { count };
public func bump() : Nat {
inc();
read()
};
};
この宣言により、counter
という名前のオブジェクトのインスタンスが導入され、その全体の実装は上のようになります。
この例では、開発者は3つの public 関数である inc
、read
、bump
を、
キーワード public
を使用してオブジェクトの本体で宣言しています。
オブジェクトの本体は、ブロック式のように宣言のリストで構成されています。
これらの3つの関数に加えて、オブジェクトは1つの(プライベートな)ミュータブル変数 count
を持ち、現在のカウントの値(初期値は 0)を保持します。
オブジェクト型
この counter
オブジェクトは、次のような オブジェクト型 を持ち、これはフィールド型のペアを中括弧({
と }
)で囲んだリストとして書かれます。
{
inc : () -> () ;
read : () -> Nat ;
bump : () -> Nat ;
}
各フィールドの型は、識別子、コロン :
、フィールドの内容を表す型によって構成されています。ここでは、各フィールドは関数なので、アロー 型の形式(_ → _
)になっています。
object
の宣言では、変数 count
が public
と private
のどちらにも明示的に宣言されていません。
デフォルトでは、オブジェクトブロック内のすべての宣言は private
となり、ここでの count
も同様です。
結果的に、count
の型はオブジェクトの型には現れず、その名前も存在も外部からはアクセスできません。
フィールドにアクセスできないということには強力なメリットがあります。 実装の詳細を公開しないことで、オブジェクトは より一般的な 型(より少ないフィールド)を持ちます。その結果、couter オブジェクトと同じ型で実装が異なるオブジェクトと、フィールドを使用せずに交換することができます。
例: byteCounter
オブジェクト
上記の点を説明するために、counter
宣言のバリエーションである byteCounter
を考えてみましょう。
import Nat8 "mo:base/Nat8";
object byteCounter {
var count : Nat8 = 0;
public func inc() { count += 1 };
public func read() : Nat { Nat8.toNat(count) };
public func bump() : Nat { inc(); read() };
};
このオブジェクトは counter
と同じ型を持っているので、型チェックの観点からは counter
と交換可能です。
{
inc : () -> () ;
read : () -> Nat ;
bump : () -> Nat ;
}
しかし、はじめに示した counter
とはフィールドの実装は同じではありません。
このバージョンでは、オーバーフローしない通常の自然数 Nat
を使用するのではなく、サイズが常に 8 ビットである自然数 (Nat8
型) を使用します。
このようにすることで、inc
オペレーションがオーバーフローを起こして失敗する可能性があります。以前のバージョンでは決してオーバーフローすることはありませんでしたが、その代わりにプログラムのメモリを埋め尽くしてしまうという、別のアプリケーションエラーを引き起こす可能性があります。
いずれの実装にもある程度の複雑さがありますが、どちらも同じ型となっています。
一般的に、(オブジェクトや Service の)2つの実装間で同じ型となるようにすると、内部の実装の複雑さを隠蔽し、それを使用するアプリケーションの残りの部分から取り除きます。
ここでは、2つのオブジェクトに共通の型(Nat
)は、数値の表現方法に関する選択を抽象化しています。
上の例は単純なものでしたが、一般的には実装の選択はより複雑で、より興味深いものになるでしょう。
オブジェクトのサブタイピング
Motoko におけるオブジェクトのサブタイピングの役割と使い方を説明するために、より一般的な(パブリックな操作が少ない)型で、よりシンプルなカウンタを実装してみましょう。
object bumpCounter {
var c = 0;
public func bump() : Nat {
c += 1;
c
};
};
オブジェクト bumpCounter
は次のようなオブジェクト型を持ち、ただ1つの操作である bump
を公開しています。
{
bump : () -> Nat ;
}
この型は最も一般的な操作を公開し、特定の動作のみを許可します。 例えば、カウンタの値は増加させることしかできず、減少させたり、任意の値に設定することはできません。
システムの他の部分では、より多くの操作を備えた より一般的でない バージョンを実装して使用することができます。
fullCounter : {
inc : () -> () ;
read : () -> Nat ;
bump : () -> Nat ;
write : Nat -> () ;
}
ここでは、はじめに示したものよりも一般性が低い fullCounter
というカウンタを考えます。
このカウンタには inc
、read
、bump
に加えて write
が含まれており、呼び出し側は、現在のカウント値を 0
に戻すなど、任意の値に変更することができます。
オブジェクトのサブタイピング:Motoko では、オブジェクトにはサブタイピングによって関係付けられる型があります。標準的には、よりフィールドの多い型 は、より一般的でない型 となります(サブタイプ)。例えば、上の例で示した型は、次のような関係があるとまとめることができます。
-
最も一般的な型は以下です。
{ bump : () -> Nat }
-
中間の一般性を持つ型は以下です。
{
inc : () -> () ;
read : () -> Nat ;
bump : () -> Nat ;
}
-
最も一般的でない型は以下です。
{
inc : () -> () ;
read : () -> Nat ;
bump : () -> Nat ;
write : Nat -> () ;
}
関数が最初の型({ bump: () → Nat }
) のオブジェクトを受け取ることを期待している場合、上記の型の いずれも 、この(最も一般的な)型と等しいかそのサブタイプであるため、何の問題もありません。
しかし、最も一般的でない最後の型のオブジェクトを受け取ることを期待している場合、他の2つの型は十分ではありません。なぜなら、ある関数が当然アクセスできると期待している write
操作を、他の2つの型はどちらも持っていないためです。
オブジェクトクラス
Motoko では、オブジェクトはステートをカプセル化したものであり、オブジェクトの class
は、共通の名前を持つ2つのエンティティのパッケージです。
ゼロから始まるカウンタの class
を例に考えてみましょう。
class Counter() {
var c = 0;
public func inc() : Nat {
c += 1;
return c;
}
};
この定義の価値は、新しいカウンタとしてインスタンスを生成(construct)できることです。 生成したカウンタは、それぞれが独自のステートで始まり、初期値はゼロになります。
let c1 = Counter();
let c2 = Counter();
これらはそれぞれ独立しています。
let x = c1.inc();
let y = c2.inc();
(x, y)
オブジェクトを返す関数を書くことでも同じ結果を得ることができます。
func Counter() : { inc : () -> Nat } =
object {
var c = 0;
public func inc() : Nat { c += 1; c }
};
この コンストラクタ関数 の戻り値の型(オブジェクト型)に注目してください。
{ inc : () -> Nat }
この型を例えば Counter
と名付け、次のように型宣言に使用することができます。
type Counter = { inc : () -> Nat };
実際、上に示した class
キーワードの構文は、Counter
に対するこれら 2 つの定義の略記に他なりません。2つの定義とは、オブジェクトを構築するファクトリ関数 Counter
と、これらのオブジェクトの型 Counter
のことです。クラスはこの利便性以上の新しい機能を提供するものではありません。
クラスコンストラクタ
オブジェクトクラスは、0 個以上のデータ引数と、0 個以上の型引数を持つことができるコンストラクタ関数を定義しています。
上の Counter
の例では、それぞれ 0 個です。
型引数がある場合は、そのクラスの型とコンストラクタ関数の両方をパラメータ化します。
データ引数がある場合は、クラスのコンストラクタ関数(のみ)をパラメータ化します。
データ引数
カウンターを 0 以外の値で初期化したいとします。その値を class
コンストラクタのデータ引数として与えることができます。
class Counter(init : Nat) {
var c = init;
public func inc() : Nat { c += 1; c };
};
このパラメータはすべてのメソッドで利用可能です。
例えば、初期値のパラメータに対して Counter
を reset
することができます。
class Counter(init : Nat) {
var c = init;
public func inc() : Nat { c += 1; c };
public func reset() { c := init };
};
型引数
カウントするためのデータを特殊な Buffer
のようにしてカウンタに持たせたいとします。
クラスが任意の型のデータを使用したり含む場合、型関数と同様に、未知の型のための引数(型引数)を持ちます。
この型引数のスコープは、データ引数と同じように class
全体をカバーします。
そのため、クラスのメソッドはこれらの型引数を使用することができます(再び導入 する必要はありません)。
import Buffer "mo:base/Buffer";
class Counter<X>(init : Buffer.Buffer<X>) {
var buffer = init.clone();
public func add(x : X) : Nat {
buffer.add(x);
buffer.size()
};
public func reset() {
buffer := init.clone()
};
};
型注釈
オプションで、クラスのコンストラクタに "戻り値の型" (生成するオブジェクトの型)の型注釈を付けることもできます。 型注釈が付与されると、Motoko はこの型注釈がクラスの本体(オブジェクト定義)と互換性があるかどうかをチェックします。 このチェックにより、コンストラクタが生成する各オブジェクトが提供された仕様に適合することが保証されます。
例えば、先述の Counter
に対して、より一般的な型である Accum<X>
で型注釈します。Accum<X>
は値を増やすことはできますが、リセットすることはできないものとします。
この注釈により、オブジェクトは Accum<X>
型と互換性があることが保証されます。
import Buffer "mo:base/Buffer";
type Accum<X> = { add : X -> Nat };
class Counter<X>(init : Buffer.Buffer<X>) : Accum<X> {
var buffer = init.clone();
public func add(x : X) : Nat { buffer.add(x); buffer.size() };
public func reset() { buffer := init.clone() };
};
全ての構文
クラスは、キーワード class
に続けて以下を与えることで定義します。
-
定義されるコンストラクタと型の名前(たとえば
Counter
) -
オプションの型引数(省略するか、
<X>
、<X, Y>
など) -
引数リスト(
()
または(init : Nat)
など) -
コンストラクタに生成されたオブジェクトに対するオプションの型注釈(省略するか、例えば
Accum<X>
など) -
クラスの "本体" はオブジェクトの定義であり、(もしあれば)型と値の引数によってパラメータ化されます。
public
とマークされたクラス本体の構成要素は、生成されるオブジェクトの型に寄与し、これらの型は(オプションの)型注釈と比較されます。
他の例: Bits
他の例として、Nat
型の自然数のビット移動のタスクを考えてみましょう。例えば、以下のように定義することができます。
class Bits(n : Nat) {
var state = n;
public func next() : ?Bool {
if (state == 0) { return null };
let prev = state;
state /= 2;
?(state * 2 != prev)
}
}
上記のクラス定義は、構造的な型シノニムとファクトリ関数をどちらも Bits
という名前で同時に定義することと同義です。
type Bits = {next : () -> ?Bool}
let Bits : Nat -> Bits =
func Bits(n : Nat) : Bits = object {
// クラス本体
};
構造的サブタイピング
Motoko におけるオブジェクトのサブタイピングは 構造的サブタイピング を使用しており、公称型サブタイピング ではありません。
公称型サブタイピングでは、2 つの型が等価かどうかは(プロジェクトや時間を超えて)一貫した、グローバルにユニークな型名の選択に依存することを思い出してください。
Motoko では、2 つの型の等価性の問題は、名前ではなく 構造 に基づきます。
構造的サブタイピングによって、クラスの型に名前を付けることによって便利な省略形として使うことができます。
しかし、型付けの目的において重要なのは、対応するオブジェクト型の 構造 です。名前が違っても、同等の定義を持つ 2 つのクラスは型互換性のあるオブジェクトを生成します。
クラス宣言の中で型注釈が指定された場合、その適合性がチェックされます。オブジェクトの型は型注釈のサブタイプでなければなりません。ただし、型注釈がオブジェクト型の適切なスーパータイプを記述しているだけであっても、型注釈はそのクラスの型に影響しません。
形式的には、Motoko のサブタイプの関係は、オブジェクト型だけでなく、すべての型に拡張されます。
ほとんどの場合は標準的なものであり、従来のプログラミング言語理論(特に 構造的サブタイピング)に従っています。
新しいプログラマにとって、Motoko の他の注目すべき事柄は配列、オプション、バリアント、数値型の相互関係です。