【Roblox】Unit for Luauの内部実装の話
はじめに
こんにちは!hoshinoです!
前回の記事では、最近私がリリースしたRoblox向けライブラリ「Unit for Luau」についてご紹介しました。
本記事では、「Unit for Luau」の実装の裏話をできればと思います。
本題: 「Unit for Luau」の内部実装の話
設計
まずは、Unit型に満たしてほしい条件を洗い出します。
- 特別な意味や操作を持たない
- Unit型同士の比較は常に等しく、Unit型以外との比較は常に等しくない
- 文字列表現は
()
1.については、Luauの言語仕様上、tableを使ってもliteral typeを使っても何らかの操作が露出してしまいそうです。実装の隠蔽への挑戦と妥協については後半で取り扱います。
2.と3.については、メタテーブルに__eqと__tostringを実装するのがよさそうです。
今思えばliteral typeで"()"としても良かった気もしますが、メインユーザーのRobloxクリエイターの皆さんの多くはtableを使ったオブジェクト指向スタイルに慣れているだろうということもあり、今回はtableを内部実装の型としました。
ということで、早速実装を見ていきましょう!
本体の実装
ご覧の通り、実装自体は非常にシンプルです。
今回は、上記方針に従いつつ公式ドキュメントを読みながら実装を進めました。
公開APIとしては、関数型スタイルの方に向けてUnit.unit、オブジェクト指向スタイルの方に向けてUnit.newを用意しました。(公開APIの詳細はREADMEをご覧ください。)
-- Unit型同士の比較は常に等しい
print(Unit.new() == Unit.unit) -- true
-- Unit型以外との比較は常に等しくない
-- メタテーブルが異なるので静的にエラーが検知され、実行時もfalseを返す
print(Unit.new() == { __brand = "UnitData" }) -- false
実装の隠蔽にやや不足を感じつつも、これで本体の実装は完了です。
Wally向け対応
前回の記事でも触れた通り、Wallyには、ライブラリ側でエクスポートした型が見えなくなってしまう制限があります。JohnnyMorganz氏によるOSS、Wally Package Types Fixerを使えば解消されますが、全員が必ず使ってくれる保証はありません。
そのため、Unit.Unit(型)が見えないユーザーのことも考える必要があります。
ということで、以下のようなコードでUnit.luauをラップすることにしました。
これにより、実装の(ある程度の)隠蔽も同時に実現できました。次のセクションで詳しく解説します。
型の隠蔽と公開
問題の整理
改めて問題を整理しましょう。Unit.luauには以下の問題があります。
- 内部実装の型が
tableであること露出している- つまり、
tableに対する操作ができる
- つまり、
- Wallyでインストールすると、
Unit.Unit(型)にアクセスできなくなる- つまり、型注釈などを陽に書けなくなる
逆に、理想的な状態は以下です。
-
Unit.Unitという型の内部実装が隠蔽されている -
Unit.Unitという型の存在は外部に公開されている
型の検討
まずは、1.について考えます。
理想的には、export type Unitと書けると嬉しいのですが、残念ながらLuauでそれは無理そうですし、幽霊型のようなテクニックも難しそうです。そこで、妥協として「多少の操作は露出してしまうが比較的安全な型」にエイリアスを振ることにしました。
この判断に至った理由として、上述のWallyの型のエクスポートの制限もあるため、2.の代わりにプリミティブな型で記述できることは必ずしもデメリットでもないだろう、という考えもありました。[1]
unknownに落ち着くまで
-
any
なんでもありすぎるのでNG。 -
table、string、numberなど
typeof(setmetatable({} :: UnitData, UnitMetatable))や"()"なども含め、それぞれの型に固有の操作が露出するので避けました。(例:tableの長さを取る#Unit.Unitなど) -
nil[2]
元々のLuau文化圏との相性はよいです。しかしそうした場合、Unit.Unit(型)が見えないユーザーは、以下のように関数を書くことになりますlocal function doSomething(): nil -- do something... return Unit.Unit endしかし、
nilが混入することはあっても、nilを陽に書くことは許容したくない……
これは作者の私の好みが多分に入っています。 -
never
ふるまいとしてはかなりよいです。unknownでは通ってしまう以下のようなケースも静的にエラーを検出できます。-
Case 1
-- unknownには何でも入る local function doSomething1(): Unit.Unit return "123" end local function doSomething2(): Unit.Unit return "456" end -- Unit同士の比較なのでtrueになってほしいが、中身はstringなので実行時falseになる print(doSomething1() == doSomething2()) -
Case 2
-- 定義時の返し忘れ local function test(): Unit.Unit return end local function test2(a: Unit.Unit): Unit.Unit return a end -- 渡し忘れ print(test2()) -- 複合ケース print(test2(test()))
-
-
unknown
上述の通り、静的な検出の精度はneverの方が高いです。
「ならneverの方がよいのでは?」となると思うので、ここで改めて、上記を静的に検出できることが果たして必要なのか?を考えます。
まずはCase 1です。具体的なユースケースとして、Unit型同士を比較することはそう多くないです。そのため、Case 1は大きな問題とはならないでしょう。
次にCase 2です。Luauにおいてこのコードは実行時エラーを起こしません。であれば、静的に検出する意味は大きくないです。[3]
ということで、最終的にはunknownに落ち着きました!
Wallyを使っていて、かつ、JohnnyMorganz氏によるOSS、Wally Package Types Fixerを使っていないユーザーでも、Unit.Unitの代わりにunknownが型注釈に使えます。もちろん自分でtype Unit = unknownとエイリアスを振っても大丈夫です!
おわりに
本記事では、「Unit for Luau」の実装の裏話について、特に型の隠蔽と公開に注目してお伝えしました。
なかなか教科書通りには行かず苦戦しましたが、結果としてはだいぶいいところに落とし込めたのではないかと思います。
よければ是非使っていただけると嬉しいです!
Discussion