土屋つかさの技術ブログは今か無しか

土屋つかさが主にプログラミングについて語るブログです。

#UEFN #Verse 3/28の"Ask Epic: Verse"で面白かった回答ピックアップ(延長戦1:型システム編)

 Ask Epic:Verse回答祭りの延長戦です。今回紹介する2つの質問は「この機能は採用予定がありますか?」「ないです。何故なら……」という物で、コーディングにおいて直接役に立つ物ではありません。とはいえ、どちらの質問もVerseの型システムに深く関わる物で、Verseを理解する助けになると思います。

 質問の意図も、回答の意図も、Varseの型システムについての理解が必要ですが、解説を厚めに書いているので、頑張ってください(そして土屋が役を間違ってたり仕様を誤解していたりしたら教えてください!)。

 Verseの型システム、特にサブタイプ(部分型)についてはこちらを参照してください。

zenn.dev

質問1:var変数のオーバーライド

https://forums.unrealengine.com/t/ask-epic-verse-march-28-10-00-am-et/1765393/79?u=t.tutiya Q:サブクラスでミュータブル(var変数)をオーバーライドできるようになりますか?

A1:var変数のデフォルト値を(オーバーライドで)変更したいという事であれば、それは現行のVerseでも可能です。

A2:親クラスで定義されたvar変数型を、その型のサブタイプによってオーバーライドしたいという事であれば、それは型システムが壊れるので出来ません。(というのも)子の型を親クラスとして使用して、かつサブタイプではない値を代入できてしまうからです。以下に具体例を示します。

class1 := class:
  var Data:[]int = ()

class2 := class(class1):
  var Data:tuple(int, int) = (1, 2) # ←①現状ではコンパイルエラーだが、仮に出来たとした場合

Main():void =
  X:class2 = class2{}
  Y:class1 = X
  set Y.Data = (1, 2, 3) # ←②ここがおかしくなる

土屋回答

一つの質問に回答が二個あります。分かりやすさから短い回答の方を先にしてありますが、実際の回答では順序が逆に書かれています。

土屋解説:回答A1編

 まず回答A1について。「var変数のデフォルト値をオーバーライドで変更する」というのは、例えば下記の様なコードです。このコードはコンパイルが通ります。

classE3:= class:
    var Data:int = 0
classE3X := class(classE3):
    var Data<override>:int = 2 # OK

 classE3のインスタンスではData変数には0が、classE3XのインスタンスではData変数には2が格納されます。この値は変数の初期値なので、classE3XインスタンスをclassE3にアップキャストしても値が変わるわけではありません。ちなみに、Dataを定数にしても同様です。

土屋解説:回答A2編

 次に回答A2について。まず、「親クラスで定義されたフィールドを、その型のサブタイプでオーバーライドする」というのは以下のようなコードです。回答A2中のコードと違って、Dataが定数である事に注意してください。

#サンプルA
classB1 := class:
  Data:[]int = ()

classB2 := class(classB1):
  Data:tuple(int, int) = (1, 2) # OK サブタイプの定数はオーバーライド出来る

 子クラスでフィールドをオーバーライドする時、その型は親クラスのフィールド型のサブタイプでなければいけません。サブタイプとは、ある型と下位互換性のある型のことです(なお、すべての型は、その型自身のサブタイプでもあります)。tuple[]intのサブタイプなので、このコードはコンパイルできます。

 下記の様に、サブタイプの関係を逆にした場合はコンパイルエラーになります。

#サンプルB
classD1 := class:
    Data:tuple(int, int) = (1, 2)
classD2 := class(classD1):
    Data<override>:[]int = () # コンパイルエラー This overriding data definition must be a subtype of the definition it tried to override: localhost.Test210320_Constructor.classD1.Data(3602)

 何故ダメなのかと言いますと、子クラスのインスタンスを親クラスにキャストした時に、フィールドに格納していた値を正しく型変換出来ないためです。

 サンプルAの場合、ClassB2インスタンスをClassB1にキャストした時、Dataの型はtuple(int, int)から[]intに変わります。tuple(int, int)[]intのサブタイプなのでこの変換は問題無く行われます。

 サンプルBの場合classD2インスタンスをclassD1にキャストした時、Dataの型は[]intからtupe(int, int)に変わります。この変換は許可されません。サンプルBのコードで言えば、要素数ゼロの[]inttuple(int, int)に変換する必要があり、それは不可能だからです。

 ここまでが定数の場合。次がいよいよ本番のvar変数の場合です。

 var変数の場合、以下のコードはコンパイルエラーになります。

#サンプルC
classZ1 := class:
    var Data:[]int = ()
  
classZ2 := class(classZ1):
    var Data<override>:tuple(int, int) = (1, 2) # コンパイルエラー This overriding data definition must be a subtype of the definition it tried to override: localhost.Test210320_Constructor.classZ1.Data(3602)

 このコードはA2回答中に出て来たのと同じ物です。サンプルAとの違いはDataが定数からvar変数に変わっただけなのに、こちらはコンパイルエラーになってしまいます。

 A2回答では、なぜvar変数でのオーバーライドが出来ないのかについて以下のように説明しています。

 もしサンプルCがコンパイル出来た場合、以下のようなコードが書ける事になります。

Main():void =
  X:classZ2 = classZ2{}
  Y:classZ1 = X
  set Y.Data = (1, 2, 3)

 classZ2のインスタンスであるXを、classZ1型のYに割当て(ここでキャストが発生)、Y.Dataに配列(1,2,3)を割り当てています。YclassZ1型なのでY.Dataは配列型となるので、問題無く(1,2,3)が割り当てられる筈です。

 しかし、実際にYに格納されているのはclassZ2型なので、Y.Datatuple(int, int)型です。ここに要素数3の配列を割り当てられるの不味いです。もしこれが可能だったとして、次にYclassZ2にダウンキャストした時、Dataには何が格納される事になるのでしょうか? これが「型システムが壊れている(だから許可されない)」という状況です。

 とまあこんな次第で、「親クラスで定義されたフィールドを、その型のサブタイプでオーバーライドする」は、定数フィールドでは可能ですが、var変数フィールドでは型システム上許されないという話でした。

 ちなみに、なんでこんな質問が出たのかを推測しますと、サンプルCで出るコンパイルエラーのエラーメッセージが、サンプルBでのエラーメッセージと同じ物*1になっているため「サブタイプを指定しているのに「サブタイプでなければダメ」ってどういうこと?」となったんじゃないかと思います。これは恐らくコンパイラのパーサー処理のミスで、サンプルCで表示される方のエラーメッセージが間違っています。

質問2:パラメータ型へのキャスト

https://forums.unrealengine.com/t/ask-epic-verse-march-28-10-00-am-et/1765393/120?u=t.tutiya

Q:パラメータ型への(明示的な)キャストは可能になりますか?

A:はい、導入される予定ですが、制限がつくでしょう。特にパラメータ型のサブタイプ化(によるキャスト)の場合、先に通常の継承グラフによるマッチングによって(キャスト可能有無が)判断されます。それが一致した場合、構造的マッチングが行われます。例えば、

class1(t:type) := class {}
X:class1(int) = class1(float){}

 これは(現時点でも)コンパイルできます。というのも、class1は通常の継承グラフによりclass1のサブタイプにマッチングし、かつ一致させる構造が無いからです。結果、サブタイプ化は成功します。これと同じ振る舞いが、ランタイム時のキャストにおいても必要になります。しかしランタイム時では、様々な値が静的な(解決が可能な)型でない場合が多く、このような構造的マッチングを扱うのがとても困難です。

土屋解説

 質問自体はシンプルなのですが、実は型システムの中でも難しい課題に触れていて、回答も難解です。土屋が回答者の意図を正しく理解できているのか正直自信が無いのですが、土屋の理解の範囲で解説を書いてみます。

※Verseコンパイラの挙動について、ドキュメントに無い点を想像で補って書いている箇所が非常に多いです。予めご了承ください。

サブクラスの暗黙的なキャスト

まず、「パラメータクラスのキャスト」がどういう物なのかから見て行きましょう。

classX(t:type) = class{ Data:t }

classA1 := class:
classA2 := class(classA1):

 classA2classA1のサブクラス、classXはパラメータクラスです。

 上記のようなクラスがあった場合、下記のコードは実行出来ます。

#サンプルD
X := classX(classA2){ Data:= classA2{}}
Y:classX(classA1) = X # OK

 XはclassX(classA2)型のインスタンスが格納されます。これは「classA2でパラメータ化されたclassB」という型を表します。一方、YはclassX(classA1)型として定義されます。

 classA1classA2は別の型ですが、継承関係(classA2classA1のサブクラス)にあるので、YにXを割り当てる事が可能です。

 このように、型が自動的に変換(キャスト)されることを「暗黙的なキャスト」と言います。

サブクラスの明示的なキャスト

 ここまでは良いとして次の例。下記のコードはコンパイルエラーになります

#サンプルE
X := classX(classA2){ Data:= classA2{}}
if:
    Y := classX(classA1)[X] #コンパイルエラー

 クラス名[オブジェクト]という記法は「型変換」と言います。変換する型を指定するので、型変換によるキャストを「明示的なキャスト」と言います。

 ここではアップキャスト(子クラスから親クラスへのキャスト)の為に型変換を使っていますが、本来はダウンキャスト(親クラスから子クラスへのキャスト)の為に利用します*2

 型変換は失敗許容式なので、if:の失敗コンテキスト内に記述しています。ここではXclassX(classA2)からclassX(classA1)に型変換してYに割り当ようとしています。

 お分かりと思いますが、サンプルDとサンプルEは行われる処理は同じです。にもかかわらずサンプルEはコンパイルエラーになります。エラーメッセージは以下の通り。

Dynamic casting to a parametric type is not yet supported.
訳:パラメータ型の動的なキャストはまだサポートしていません。

 「同じ処理なのに明示的なキャストが出来ないのは何故か。"not yet supported"となっているけど、対応されるのか?」というのが質問の趣旨。それに対する回答は「その予定だけど、制限がある」という物でした。

 この制限がどういう物かと言うと「パラメータがサブタイプの関係にある場合、型変換でのキャストは(恐らく)出来ない」という物です。以下、これについて考えてみます。

サブタイプの暗黙的なキャスト

 では次に、サブタイプの暗黙的なキャストを見てみます(明示的なキャストについては、現時点ではパラメータクラスの明示的なキャストは全てコンパイルエラーになるので省略します)。

#サンプルF
classX(t:type) := class{ Data:t }
X := classX(tuple(int, int)){ Data:= (1, 0)}
Y:classX([]int) = X

 パラメータに指定しているtuple(int, int)型と[]int型とは継承関係にはありませんが、前者は後者のサブタイプなので、この暗黙的なキャストは問題無くコンパイル出来ます。

 このように、サブタイプ関係にある型でパラメータ化したインスタンスをキャストする事も可能です。上記では、classX.Datatuple(int, int)型から[]int型に変換されました。これを「サブタイプ化(subtyping)」と呼ぶようです。

 そろそろこんがらがって来てませんでしょうか? 土屋は既にこんがらがっていますが、この先は更に大変です。

 次のコードはコンパイルエラーになります。

# サンプルG
classX(t:type) = class{ Data:t }
X := classX(float){Data := 3.0} 
Y:classX(int) = X #コンパイルエラー

 パラメータに指定しているfloat型とint型は継承関係にもサブタイプの関係にもありません。なので、当然ながらこの暗黙的なキャストは許可されず、コンパイルエラーになります。

 ところが、次のコードはコンパイルが通ります。このコードは、回答中に出てくる物と同じ処理になります。

# サンプルH
classX(t:type) := class{}
X:= classX(float){}
Y:classX(int) = X

 サンプルFとの違いはclassX.Dataフィールドが無いことです。なぜこの場合は暗黙的なキャストが成功するのでしょうか? これは、パラメータクラスのサブタイプ化の可否を決定するロジックが関係しています。

 パラメータクラスのサブタイプ化は「①継承グラフの一致」「②構造的な一致(structural match)」の順に判定されます。「構造的な一致」というのは、型のメモリマップに上位互換性がある(結果としてスーパータイプだと判断出来る)という意味だと思われます。

 サンプルHでは、①についてはどちらもclassXなので問題なく成功します(すべてのクラスはそのクラス自身のサブクラスでもあります)。

 そして②ですが、classXはフィールドを持っていません。その為、classX(float)型とclassX(int)型はメモリマップ上では(フィールドを持たないので)同一という事になります。そのため②も成功してしまい、コンパイルが通るのです。

 というわけで、サンプルHの場合は、パラメータに指定した型同士が継承関係にもサブタイプ関係にも無いのに暗黙的なキャストが成功します。奇妙な話ですが、これは仕様上正しい挙動です。

 そして、そうであるなら、明示的なキャストでも同じ挙動になる必要があります。しかし、ランタイム時にこの判定を行うのは困難*3なので、明示的なキャストには制限がかかるだろうというのが回答の趣旨でした。恐らく、明示的なキャストでは継承関係にあるパラメータ型のみを許容する(つまり、サブタイプ化はNG)ということになるんじゃないでしょうか(知りませんが)。

おわりに

 今度こそこれで"Ask Epic:Verse"回答祭りは終わりです。今回の記事はVerseの型システムを勉強し直したり、延々実機で検証したりとかなり大変でした。あとは翻訳や回答者の意図を間違ってないことを祈るばかりです……!

 一応この後Ask Epic関連でもう一個記事を書く予定ですが、以前の質問の補足なので新しい情報は無い筈です。

 Verse言語関連でやりたい事は色々あるんですが、Unityの作業も控えてるし、仕事も忙しくなってきたんで、ちょっと予定は立たないのですが、ひとまず5月の技術書典には、Verse本の紙版を頒布予定です。内容はPDF版と同じです(コラムを追加する予定ですが、PDF版もアップデートで対応します)。

宣伝

Verse言語の言語仕様解説同人誌をPDFで頒布しています。よろしければどうぞ。

s-games.booth.pm


*1:"This overriding data definition must be a subtype of the definition it tried to override:(オーバーライドするデータの定義は、オーバーライドする対象のデータの定義のサブタイプでなければなりません)"

*2:アップキャストは暗黙的なキャストが可能だが、ダウンキャストは型変換を使わないと出来ないため。

*3:なにがどう困難なのかは土屋の理解を超えているため省略。