🖥️

【Roblox】Luauにおける静的型システム

2024/08/26に公開

はじめに

今回は、Robloxで使用されている言語「Luau」の型システムについて紹介します。
LuauはLua 5.1.4を元に開発された言語で、大きな特徴としてLuaには無かった静的型システムがサポートされています。
ぜひ活用して、安全かつ効率的な開発を目指しましょう。

バージョン:0.638.1.6380615

1. Luauにおける静的型システムとは

型推論型注釈を用いてエディタが型チェックを行い、型安全性が保証できない場合に警告を出すことで、安全性や開発効率を上げることができる仕組み」です。
いきなり言われても何のこっちゃですね。
ざっくり言うと、エラーを起こしかねないソースコードを事前に警告してくれる仕組みです。
また、型が明確な場合にはエディタの入力補完も利用できます。
利点としては、存在しないプロパティの参照、間違った変数の組み合わせでの代入や計算、誤字脱字といった問題を予防し、実行前に気付きやすくしてくれます。

そして型推論とは、変数などの型情報が静的に追跡できる場合、明示的に型を記述しなくても、エディタが自動的に判断してくれる機能です。
例えば、変数からプロパティを参照しようとした時に、自動的に入力補完が出る時があるかと思いますが、それはその変数がそのプロパティを持っている型であるとエディタが自動的に判断しているので可能になっている機能なのです。
型チェックを利用していなくても、知らずにお世話になっているかと思います。

型注釈については後ほど解説します。

2. 型チェックの利用

とりあえず使ってみようということで、まずは設定を行いましょう。
Roblox Studioの静的型システムには以下の3つのモードがあり、それによってエディタの挙動が変化します。
ソースコードの先頭に、--!から始まるコメントディレクティブを記述することで設定できます。

  • nocheckモード
    型チェックが行われないモードで、先頭に--!nocheckと記述することで設定できます。
    現状ではデフォルトの設定がこれになっており、以下のどちらかに変更することで型チェックを利用できるようになります。
  • nonstrictモード
    基本的な型システムを利用できるモードで、先頭に--!nonstrictと記述することで設定できます。
    型チェックは行われますが、型安全性に違反する可能性があっても、一般的なパターンであれば許容するようになっています。
  • strictモード
    より厳密な型推論、型チェックを行うモードで、先頭に--!strictと記述することで設定できます。
    処理されるすべての値について、型が明確であり、かつその型に則った処理である必要があります。
    元々静的型付け言語に慣れている方にとっては、馴染みのある形式だと思います。

ちなみに、コメントディレクティブは記述されたスクリプトでのみ機能します。
でも、毎回全部のスクリプトに記述するのは面倒ですよね。
ということで、ベータ機能として記述が無い場合のデフォルトをnonstrictモードにする設定が公開されています。
Roblox Studioを起動し、「ファイル」→「ベータ機能」から、「スクリプトはデフォルトで非厳格」にチェックを付け、「保存」を押し、Roblox Studioを再起動することで適用されます。

残念ながら、strictモードをデフォルトにする設定は今の所無いようです。

3. 型注釈(型アノテーション)

変数の型を指定する

変数を立てる際に、変数名の後に:型名と記述することで、その変数の型を明示的に指定できます。
この:型名型注釈型アノテーション)と言います。

--!strict

local num :number -- 変数numはnumber型
local str :string = "string" -- 変数strはstring型 同時に初期化してもよい

型を指定すると、型チェックが有効なモードの場合、型に対して誤った使い方をしようとしたときに警告が出るようになります。
例えば、型が一致しない変数に代入しようとした時に警告が出ます。

--!strict

local num :number = 1
local str :string = "string"

local var :number
var = num -- ok
var = str -- not ok

このコードをコピペすると、最後のvar = strの所で警告が出ているのが確認できるかと思います。
マウスカーソルを合わせると、警告メッセージも確認できます。

こんな感じで、きちんと型を指定しておけば、バグを事前に予防できるというわけです。
(以降、--!strictのコメントディレクティブは省略します)

nilを許容する

strictモードで型指定した場合、nilを代入しようとした時も警告が出るようになります。
それだと不便な場合もあるので、型名の末尾に?を記述することで明示的にnilを許容できるようになっています。
nilを許容した場合、適切にnilチェックを行わずに使用しようとすると警告が出るようになります。

local num :number? = nil -- 型名の末尾に?を付けるとnilを許容する

引数の型を指定する

変数以外に対しても型を指定できます。
関数の引数の型を指定する場合は、変数と同様に引数の後に型注釈を記述します。

local function Func(num :number) -- 引数numはnumber型
	-- なんらかの処理
end

戻り値の型を指定する

関数の戻り値の型を指定する場合は、関数宣言の行の最後に型注釈を記述します。
戻り値の型を指定した場合、一致しない型の値を戻り値にしようとした時に警告が出るほか、関数を使用して戻り値を受け取る際にも間違った使い方をしていると警告が出るようになります。

local function Func(num :number) :number -- 関数Funcの戻り値はnumber型
	local result :number = num
	-- なんらかの処理
	return result -- number型の値を返している
end

複数の戻り値の型を指定したい場合は、()で括り、,で区切ることで指定できます。

local function Func(num :number) :(boolean, number) -- 関数Funcの戻り値はboolean型、number型
	local result :number = num
	-- なんらかの処理
	return true, result -- boolean型、number型の値を返している
end

テーブル型を指定する

テーブル型を指定したい場合は、型名の代わりに{}を使用します。
格納するデータの型を指定したい場合、{}の中に型名を記述します。

local tbl :{} -- 変数tblはテーブル型
tbl = {}

local strTbl :{string} -- 変数strTblはstringのテーブル型
strTbl = {"hogehoge", "hagehage"}

テーブルのキーの型を指定したい場合は、:{[キーの型] :データの型}と記述します。

local array :{[number] :string} -- numberをキーとしたstringの配列型に指定
array = {"first", "second", "third"}

local dictionary :{[string] :any} -- stringをキーとしたanyの辞書型に指定
dictionary = {
	hogehoge = 1,
	hagehage = "hagehage"
}

local tbl :{[any] :any} -- ただ単に{}と書いた場合と同じ意味になる
tbl = {}

ちなみに、型システムはnumberやstringのようなプリミティブ型だけでなく、PartやIntValue、ModelといったRobloxのクラス型も利用することができます。

4. 型の定義

型を定義する

自分でテーブル型を定義し、型システムで利用することもできます。
自分で型を定義する際は、typeキーワードを使います。
typeキーワードの後に使用したい型名を記述し、=で型の内容を代入のように繋ぐことで型を定義できます。
さらに、型のメンバ名の後に型注釈をつける事で、メンバの型も指定できます。
試しに、ユーザーの名前と年齢のプロパティを持つUserData型を定義してみましょう。

type UserData = { -- 型名をUserDataとして定義
	name :string, -- メンバ名と型を定義
	age :number
}

local user :UserData = { -- 初期化、使用する際は、定義と型が一致するようにする
    name = "taro",
    age = 12
}

関数型メンバの型を指定する

関数型のメンバの場合は、:(引数の型) -> (戻り値の型)のように記述します。
これも型注釈の一種です。

type UserData = {
	name :string,
	age :number,

	AddAge :(UserData, number) -> (number) -- 関数型の型注釈を記述
}

local function AddAgeFunc(self :UserData, val :number) :number -- 引数、戻り値の型が一致する関数を定義
	self.age += val
	return self.age
end 

local user :UserData = {
	name = "taro",
	age = 12,
	AddAge = AddAgeFunc -- 定義しておいた関数をメンバにする ここで直接関数を定義してもいい
}

これでUserData型が定義できました。
この型のオブジェクトを初期化や使用する際に、定義にないメンバ名を使用しようとしたり、代入しようとした変数や関数の型が一致しないと警告を出してくれます。

他のスクリプトから利用可能にする

しかし、このままでは他のスクリプトからこの型を利用できません。
自分で定義した型情報を参照できるModuleSctiptを作ってみましょう。
typeキーワードの前にexportキーワードを付けることで、そのモジュールのメンバのような形で型情報を参照できるようになり、requireしたスクリプトでも利用できるようになります。

ModuleScript
export type UserData = {
	name :string,
	age :number,

	AddAge :(UserData, number) -> (number)
}

return nil

さらに、オブジェクト指向っぽくモジュールのメンバにnewを追加し、初期化したUserDataオブジェクトを返してくれるようにしてみました。

ModuleScript
export type UserData = {
	name :string,
	age :number,

	AddAge :(UserData, number) -> (number)
}


local module = {}

module.new = function() :UserData
	local data :UserData = {
		name = "",
		age = -1,
		
		AddAge = function(self :UserData, val :number) :number
			self.age += val
			return self.age
		end
	}
	
	return data
end

return module
Script
local typeModule = require(ModuleScript) -- ホントはModuleSctiptのパスを参照して下さいね

local user :typeModule.UserData -- 型情報を参照
user = typeModule.new()
user.name = "taro"
user.age = 12
user:AddAge(1)

print(user)
実行結果
▼  {
    ["AddAge"] = "function",
    ["age"] = 13,
    ["name"] = "taro"
}

型の参照が長くなってしまうのが嫌な場合、typeキーワードで型名を再定義してやるとよいです。

Script
local typeModule = require(ModuleScript)
type UserData = typeModule.UserData -- 型を再定義

local user :UserData = typeModule.new()

これで、他のスクリプトで利用可能なUserData型が定義できました。

5.キャスト

変数名などの後に::型名と書くことで、キャストして型変換を行うことができます。
その変換自体も型チェックされ、安全な変換が可能なもの以外は警告が出されます。
ただし、特定の型→any型→別の特定の型 という変換は意図的なものと判断され、警告が出ないので注意が必要です。

local str :string = "string"

local var :any = str::any -- string → anyの変換はok
local num1 :number = str::number -- string → numberの変換はnot ok
local num2 :number = str::any -- string → any → numberの変換となるため、okだが危険な変換

キャストは基本的に、any型を特定の型に変換するために用います。
使用例として、先ほどのUserData型のデータを保存しているDataStoreがあったとします。
しかし、DataStoreからGetAsyncでデータをロードした時の戻り値は成否によって型が変わるため、any型となっています。
そこで、成功した場合にデータをキャストしてやることで、保存してあるデータ型として扱うことができるようになります。

local typeModule = require(script:WaitForChild("ModuleScript"))
type UserData = typeModule.UserData

local userDataStore :DataStore = game:GetService("DataStoreService"):GetDataStore("UserData")
local success :boolean, result = pcall(function() -- pcallで包んでやり、成否を判定する
	return userDataStore:GetAsync("Key")
end)

if not success then -- 失敗した場合、resultはstring型でエラーメッセージが入っている
	warn(result)
	return false
end

local loadedData = result::UserData -- 成功した場合、resultは保存されていたUserData型であることがわかっている
-- UserData型に対しての処理

他にも、動的に複製されたModuleScriptをrequireした場合などは型推論が利用できないため、メンバ名をタイプミスしてしまったり、いちいち元のモジュールに関数の情報を確認しにいったりといった問題がありましたが、型情報を持ったモジュールをReplicatedStorage下のなどの静的に参照できる場所に置いておき、requireしたモジュールを適切な型でキャストしてやれば、その型情報を元に入力補完が可能になります。
個人的には、複数の型情報を一纏めに参照できるようにしたモジュールを用意しておくのがよいかなと思っています。

6. 補足

ちなみに、Luauにおける静的型システムは実行時には何も影響しません。
型注釈による指定もキャストも「その型としてエディタ上では扱う」以上の機能は無いので、どのように利用してもパフォーマンスには影響しませんが、仮に危険性のあるソースコードになっていたとしても、実行できてしまうケースの場合は実行時エラーにはならないという点は覚えておきましょう。

その他、関連する機能が沢山あるのですが、紹介しきれないので、より詳しく学びたい方は記事の最後の参考リンクから公式のリファレンスを確認してみてください。

7. まとめ

  • Luauの静的型システムを利用すると、危険な使用に対してエディタが警告を出し、バグの発生を予防できる
  • コメントディレクティブ--!strictなどでモードを設定
  • 型注釈:型名で変数などの型を指定できる
  • 自分で定義した型でも利用することができる
  • ::型名でキャストもできる
  • 実行時には影響しない

この記事では、Luauにおける静的型システムについてご紹介しました。
今回は機能面に絞った内容でしたが、型を明記し、型を意識したコーディングをすることは、そのコードの意図をより明確にし、誰が読んでも(全てを忘れた3ヵ月後の自分が読んでも!)わかりやすくなるという効果もあります。
複数人での開発や、長期間の運営開発など、規模が大きくなるほどその恩恵も大きくなるでしょう。
普段から型を意識して書く癖を付けていくことをオススメします。

ここまで読んで頂きありがとうございました!

8. 参考

https://luau.org/typecheck
https://devforum.roblox.com/t/luau-type-checking-release/878947
https://create.roblox.com/docs/luau/type-checking

ランド・ホー Roblox開発チーム

Discussion