TL; DR
- インターフェースを使用したポリモーフィズムが可能だが制約がある
- 継承によるメソッドのオーバーライドはできない
- (ポリモーフィズムではないが)タグ付きユニオンを使った抽象化は自由度が高い
はじめに
2023年の暮れに登場し話題となったOnyx言語。コンパイル先がWebAssembly専用であることやデフォルトでWASIXをサポートしていることから話題となりました。
型システムについても、インターフェース、部分型、タグ付きユニオン、ジェネリクス(ポリモーフィック変数)等抽象化するための機能が揃っています。
一方で、込み入ったことをしたい場合1、どれを使えばいいか迷うことがありました。
そこで本記事では、上記の型を抽象化するための機能の使い分けについて書いていきたいと思います。
おことわり
以下の記述は記事執筆時点(2024/2/5)のmasterブランチで実行したものです。今後文法が変更される可能性があります。
構造体
本題に入る前に、まずOnyxの構造体について見ていきます。
Person :: struct {
// メンバ
name: str;
age: i8;
// メソッド(第一引数はレシーバを指定するのを忘れずに!)
say_hello :: (p: Person) {
printf("My name is {}.\n", p.name);
}
can_drink :: (p: Person) -> bool {
return p.age >= 20;
}
// メソッドはただの関数なので、レシーバを指定せずstaticメソッド的なこともできる
num :: () -> i64 {
return 8000000000;
}
}
.
でメンバを参照し、 ->
でメソッドを呼び出します(間違えやすいので注意!)。
// 初期化
person := Person.{name="Taro", age=18}; // メンバ名は省略可能
// メンバ参照
println(person.age); // 18
// メソッド呼び出し
println(person->can_drink()); // false
// 上記は以下のシンタックスシュガー
println(person.can_drink(person)); // false
// staticメソッド的な使い方
println(Person.num()); // 8000000000
1点注意として、メソッドや関数の仮引数にはサイズが0の型(全メンバが void
からなる) は使用できません。(WASMにコンパイルした後でオブジェクトを区別できないからでしょうか?)
ファクトリ等はダミーのメンバーを持たせるかシンプルに関数で作る必要があります。
Function parameters cannot have zero-width types.
また、いわゆる「すべての値はオブジェクト」という言語ではないため、str
や 数値 (例: u64
) 等の組み込み型は構造体ではありません。
抽象化するための機能
続いて、型を抽象化して扱うための機能について見ていきます。
インターフェース
まずはインターフェースです。Onyxのインターフェースは構造的部分型を採用しているため、指定されたメソッドを持つオブジェクトは自動的にインターフェースを実装します。
// このメソッドを持つものは自動的にGreetableを実装する
Greetable :: interface (t: $T) {
{ t->greet() } -> void;
}
書き方が少し特殊なので注意が必要です。
- レシーバ自身をポリモーフィック変数(=型引数)として宣言する
-
{}
の中に、シグネチャを 値(型ではない!) で記述
レシーバ以外の引数については、その型の適当な値を入れることでシグネチャを表します、
Decoratable :: interface (t: $T) {
// decorate :: (T, str) -> str を要求
{t->decorate("")} -> str;
}
SuffixDecorator :: struct {
decoration: str;
decorate :: (d: SuffixDecorator, message: str) -> str {
return tprintf("{}{}", message, d.decoration);
}
}
インターフェース型を引数に取る場合、
- 引数の型をポリモーフィック変数で宣言する
-
where
句でポリモーフィック変数がインターフェースを実装するという制約を加える
という流れで記述します。
print_greeting_card :: (g: $T) where Greetable(T) {
println("~~~~~~~~~~~~~~~");
g->greet();
println("~~~~~~~~~~~~~~~");
println("");
}
main :: () {
print_greeting_card(Duck.{});
print_greeting_card(Person.{"Taro", 18});
}
関数のロジック上ポリモーフィック変数の特定が不要な場合は省略可能です。
// 標準ライブラリより抜粋
count :: (list: &list.List) -> i32 {
c := 0;
elem := list.first;
while elem != null {
c += 1;
elem = elem.next;
}
return c;
}
部分型(埋め込み)
続いて部分型です。「部分型」という名前ですが継承ではなく埋め込みです。
先頭のメンバ変数で use
を付けることで、指定したメンバの部分型として扱うことができます。
// https://docs.onyxlang.io/book/types/structures.html#sub-type-polymorphism より抜粋
Person :: struct {
name: str;
age: u32;
}
Joe :: struct {
use base: Person; // Personの部分型として使う
pet_name: str;
}
部分型も含めて引数として受け入れる場合、型に ^
を付けます。
// https://docs.onyxlang.io/book/types/structures.html#sub-type-polymorphism より抜粋
// Personおよびその任意の部分型を受け入れる
say_name :: (person: ^Person) {
printf("Hi, I am {}.\n", person.name);
}
joe: Joe;
joe.name = "Joe";
// 実引数で渡す際も^を付ける
say_name(^joe);
埋め込みのため、部分型でメソッドをオーバーライドすることはできません。
Person :: struct {
name: str;
age: u32;
hello :: (p: &Person) {
printf("Hi, I am {}.\n", p.name);
}
}
Joe :: struct {
use base: Person;
pet_name: str;
// 同名のメソッドを定義
hello :: (p: &Joe) {
printf("Hello!\n");
}
}
say :: (person: ^Person) {
person->hello();
}
main :: () {
joe: Joe;
joe.name = "Joe";
joe->hello(); // Hello!
// Personのメソッドが呼び出されてしまう!
say(^joe); // Hi, I am Joe.
}
基本的に、利用はメンバ変数を共有したい場合にとどめるのが良さそうです。
タグ付きユニオン
続いてタグ付きユニオンです。様々な型の直和型として扱えます。
(イメージとしてはRustのEnumに近いです)
// https://docs.onyxlang.io/book/types/unions.html より抜粋
Value :: union {
// 以下の型(variant)のいずれか1つを持つ
Int: i32;
String: str;
Unknown: void;
}
// 以下は中身が異なるがすべて `Value` 型として扱える
// NOTE: どのvariantであるかを明示する必要がある
v1 := Value.{ Int = 123 };
v2 := Value.{ String = "string value" };
v3 := Value.{ Unknown = .{} }; // .{} はvoid型のリテラル
利用する側では、パターンマッチで中身を取り出します。
// https://docs.onyxlang.io/book/types/unions.html より抜粋
print_value :: (v: Value) {
switch v {
case n: .Integer {
printf("Its an integer with value {}.\n", n);
}
case s: .String {
printf("Its a string with value {\"}.\n", s);
}
case #default --- // 何もしない
}
}
実装上variantが分かっているときは、直接中身を取得することも可能です。variant名で参照するとvariantのOptionalが返ってきます(variantが間違っている場合はNone)。
v1 := Value.{ Int = 123 };
// NOTE: ?はoptionalを参照するシンタックスシュガー https://docs.onyxlang.io/book/types/optional.html
println(v1.Int? + 1); // 124
組み込み型の Optional
, Result
も実体はタグ付きユニオンで実装されています。
抽象化を表現する際にはどれを使うのが良いか
いくつか選択肢を見てきましたが、個人的には以下のように使い分けるのが良いと思いました。
定義すべきシグネチャが...
- シンプル:インターフェース
- 複雑:タグ付きユニオン
インターフェースの制約
インターフェースにはいくつか制約があり、複雑なシグネチャが必要な場合に表現できないことがあります。
戻り値にポリモーフィック変数を使えない
具象型ごとに戻り値の型が異なる、以下のようなインターフェースを作ると、戻り値を記述できずにコンパイルエラーになってしまいます。
Printable :: interface (t: $T, _: $R) {
{t->print()} -> R;
}
// Polymorphic variable not valid here.
print :: (p: $T) -> $R where Printable(T, R) {
return p->print();
}
もちろん決め打ちで指定することはできますが、これではジェネリクスの意味がありません。
print :: (p: $T) -> str where Printable(T, str) {
return p->print();
}
一応、戻り値と同じ型のダミー引数をもらうことでシグネチャを確定させられますが、少し不格好です。
Printable :: interface (t: $T, r: $R) {
{t->print(r)} -> R;
}
StrPrinter :: struct {
content: str;
print :: (p: StrPrinter, _: str) -> str {
return p.content;
}
}
print :: (p: $T, r: $R) -> R where Printable(T, R) {
return p->print(r);
}
main :: () {
p := StrPrinter.{content="foo"};
// 戻り値 str を引数で指定
printf("{}", print(p, ""));
}
タグ付きユニオンの場合ポリモーフィック変数を使わないため上記の問題は発生しません。
インターフェースのスライスを作れない
インターフェースを使う場合、具象型 T
を確定させる必要があるため、「Greetableの任意の具象型をまとめたスライスを逐次処理」のような書き方はできませんでした。
Greetable :: interface (t: $T) {
{ t->greet() } -> void;
}
main :: {
// Waiting for local variable's type.
greetables: []Greetable = .[Duck.{}, Person.{"Taro", 18}];
}
Rustの dyn
のような機能も今のところは無さそうです2。
タグ付きユニオンの場合、variantを混ぜてスライスを宣言可能です。
values: []Value = Value.[
Value.{Int = 123},
Value.{String = "string value"},
Value.{Unknown = .{}},
];
タグ付きユニオンの制約
variantを増やした時の改修が大変
タグ付きユニオンも万能ではなく、代償としてすべての型のパターンを1関数内に記述する必要があります。
呼び出し元はすべてのvariantを意識して実装する必要があるため、実装コストが上がってしまいます。
// 分岐網羅しないとコンパイルエラーするため、variantを追加したら全利用箇所の修正が必要!
#operator == (v1, v2: Value) -> bool {
switch v1 {
case i1: .Int {
switch v2 {
case i2: .Int {
return i1 == i2;
}
case #default {
return false;
}
}
}
case s1: .String {
switch v2 {
case s2: .String {
return s1 == s2;
}
case #default {
return false;
}
}
}
case .Unknown {
switch v2 {
case .Unknown {
return true;
}
case #default {
return false;
}
}
}
}
}
インターフェースが使えるのであれば、インターフェースを使うのがシンプルに思えます。
終わりに
以上、Onyxの型の抽象化方法についての紹介でした。構文や継承の仕組みが少し独特なので注意が必要ですが、既に選択肢が複数あるのは心強いです。
また、インターフェースの型引数については発展途上なようなので今後の機能追加が楽しみです。
-
私事ですが、現在Onyxで 「Crafting Interpreters」のLox言語を実装しています( https://github.com/Syuparn/onylox )。元のJava実装には継承、インターフェース、ジェネリクスが多用されるので、それをOnyxへどうやって移植するか考えた結果をまとめたのが本記事になります。 ↩
-
そもそもwasmにコンパイルするときはパフォーマンスを重視していると思うので、メモリサイズを無視したポリモーフィズムを実現したい場面はそこまでないかもしれません... ↩