👌

Verse言語の設計思想を読み解きたい(16) エフェクト指定子

2023/04/23に公開

前回はこちら。
https://zenn.dev/t_tutiya/articles/f9ece14ddb3fda
今回はエフェクト指定子について。「なにが違うのかよくわからない」と言われがちな部分について詳しく見ていきます。

※注意※

以下の記事は公式ドキュメントの記述とコンパイラの挙動から元に土屋が推測した独自研究が多く含まれています。公式に明文化されている訳ではありません。また、個々の指定子の解釈についても独自の物であり、実際の挙動とは異なる場合があるのでご了承ください。なお、この記事は公式ドキュメントでの解説により合致させる為に、後日改めて書き直す可能性があります。

エフェクト指定子(effect specifier)

https://dev.epicgames.com/documentation/ja-jp/uefn/specifiers-and-attributes-in-verse

エフェクト指定子(effect specifier)」は、関数(またはクラス)が許される振る舞いを明示する指定子です。

エフェクト指定子を付与する事で、特定の振る舞いをする関数が、その振る舞いを想定していない場所で実行されないようにコンパイル時に判定を行う事が出来ます。

ただし、場合によってはコンパイラは判定を行わず、コードの実装者に振る舞いの保証を求める場合もあります(これについては後述します)。

エフェクト指定子は「排他エフェクト(Exclusive Effects)[1]」と「追加エフェクト( Additive Effects)[2]」の2種類に分かれます。

  • 排他エフェクト(Exclusive Effects)
    • converges(ネイティブコードでのみ使用)
    • computes
    • varies
    • transacts
    • no_rollback(排他エフェクト指定子を付けなかった場合のデフォルト設定)
  • 追加エフェクト( Additive Effects)
    • decides
    • suspends

関数(クラス)には排他エフェクトをいずれか1個だけ付与出来ます。付与しない場合は、no_rollbackが付与された物とされます。

追加エフェクトは、排他エフェクトとは別にいずれか1個だけを付与出来ます。decidesとsuspendsを同時に付与する事はできません。

この記事では排他エフェクトのみを扱います。追加エフェクトの2個については以前の記事を参照して下さい。

排他エフェクト指定子(Exclusive effects specifier)

排他エフェクト指定子は、関数(クラス)について、以下の制約から外れる振る舞いを許すかによって分類されます。

  • ①確実に終了する
  • ②同じ引数を設定すると同じ値が返る
  • ③副作用が無い

①確実に終了する
処理が終わらない可能性が無いという製薬です。無限ループに陥る可能性がある関数はこの制約を満たしません。

②同じ引数を設定すると同じ値が返る
同じ引数を設定しても別の値が返る可能性が無いという制約です。例えば乱数を返す関数はこの制約を満たしません。

③副作用が無い
引数に対して値を返す以外の処理を行う可能性が無いという制約です。例えばクラスフィールドを更新するクラスメンバ関数はこの制約を満たしません。

以下、個々の排他エフェクト指定子について、これらの制約から外れる振る舞いを許容しているかどうかを軸に見ていきます。わかりやすさの為、指定子の説明は公式ドキュメントとは逆の順に鳴ってます。

converges(収束)指定子(ネイティブコードでのみ使用)

必須 ①確実に終了する
必須 ②同じ引数を設定すると同じ値が返る
必須 ③副作用が無い

注意:convergesはnative指定子が付与された関数(つまりネイティブコードの関数)にのみ付与できます。なので、Verseコード上で記述する事はありません。

converges指定子を付与した関数は、3つの制約が全て必須となります。

コンパイラはコードがこれらの制約を満たしているかを判定しません。なので実装者(ここではネイティブライブラリの開発者)がそれを保証する事になります。

これはネイティブコードを呼びだす以上致し方ない事と言えます。例えばsin()のような三角関数は、ネイティブコード内で(恐らくはC++の)ライブラリを呼びだしているので、コンパイラはその実装を検証できないのです。

computes(演算)指定子

許容 ①確実に終了する
必須 ②同じ引数を設定すると同じ値が返る(ただしコンパイラは判定しない)
必須 ③副作用が無い

computes指定子を付与した関数は、①の制約を満たさなくても構いません。

②と③の制約は満たす必要がありますが、②についてはコンパイラは判定しません。実装者が保証した上で明示する事になります。

また、①についてもコンパイラは判定しておらず[3]、例えば下記のコードは無限ループですがコンパイル可能です[4]

func1()<computes> :void=  
    loop:
        if(false?):
            break

converges指定子とcomputes指定子はコンパイラから見た機能は同じです。convergesと同じ制約を持たせたい関数をVerseで記述した際に、それを明示するためにcomputes指定子を付与するのが良いようです。

varies(変化)指定子

許容 ①確実に終了する
許容 ②同じ引数を設定すると同じ値が返る
必須 ③副作用が無い

varies指定子を付与した関数は、①と②の制約を満たさなくても構いません。ただし、②についてはコンパイラは判定しないため、実装者が保証した上で明示する事になります。

コンパイル時にはcomputes指定子とvaries指定子は実質的に区別されません[5]。公式には、コードをモジュールとして公開する際、将来的に挙動が変わる可能性がある場合には(computes指定子ではなく)varies指定子を使用するのが望ましいとされています。

transacts(トランザクト(取引))指定子

許容 ①確実に終了する
許容 ②同じ引数を設定すると同じ値が返る
許容 ③副作用が無い

transacts指定子を付与した関数は、①②③の全ての制約を満たさなくても構いません。クラスインスタンスの内部状態を更新しうる関数は、transacts指定子を指定する必要があります。付与しない場合はコンパイルエラーになります。

transacts指定子が付与された関数は、「失敗」が発生した時に自動的にロールバックが発生します。

no_rollback(排他エフェクト指定子を付けなかった場合のデフォルト設定)

排他エフェクト指定子が付与されていない関数は、no_rollback指定子が付与されている物として扱われます。なお、no_rollback指定子自体を明示的に指定する事はできません。

no_rollbackは定義上はtransacts指定子の「ロールバックしない版」にあたります。

エフェクト指定子の効果範囲の伝播

エフェクト指定子は下位で付与された物を上位でより狭い範囲に付与し直すことができません。例えばPrint()関数はno_rollbackが付与されているため、no_rollback関数からしか呼び出せません。

補足1:transactsは付けるがdecidesは付けない場合

ドキュメントを読んでいて、decides追加エフェクト指定子は常にtransacts指定子とセットで記述する必要があるように感じました。そこで「decidesが必要な時はtransactsが必ず必要だけど、じゃあ逆にtaransactsのみを付ける場合ってあるのか?」についてちょっと考えてみました。

以下のコードはtransacts指定子のみを付与した例になります。コードの処理自体には特に意味はありません。

class1 := class:
    var arg1 : logic = true
    isTrue(judge:logic)<transacts>:logic=
        if(judge = true):
            set arg1 = judge
            true
        else:
            false

c1 := class1{}
if(c1.isTrue(true)?):
    Print("true")

class1.isTrue()は引数をメンバフィールドに格納した後、trueあるいはfalse(「失敗」ではなくlogic型のfalse)を返す関数です。decides指定子が付与されていないので、失敗許容関数ではありません。

isTrue()関数をtransacts指定子を無しにすると、関数自体は定義できますがif式の関数呼び出しがコンパイルエラーになります。これはクエリ演算子("?")がオペランドにno_rollback属性を受け入れないからだと思われます[6]

逆に、この関数にdecides指定子を追加した場合もコンパイルエラーになります[7]

また、transacts指定子をvaries指定子(あるいはcomputes指定子)に変えた場合は、"set arg1 = judge"がコンパイルエラーになります。これは、これらの指定子ではロールバックする式(ここでは変数の再定義)が許されないためで、この行をコメントアウトすればコンパイル出来ます。

補足2:decides指定子のみの関数(は作れなさそう)

逆に、decides指定子のみの関数を実装出来るのかについては、土屋が確認した限りでは「実装は出来るが呼びだせない」なのかなと思っています。ただし、transacts指定子でなければならないという訳ではありませんでした。

#これだと関数は定義できるが呼び出せない
func1()<decides> :void= false?
#これならOK
func1()<converges><decides> :void= false?

#関数呼び出し
if(func1[]):

#Fortnite #Verse #VerseLang #UEFN

続き

https://zenn.dev/t_tutiya/articles/71010a30e35dcc

脚注
  1. 公式訳では「排他的エフェクト」 ↩︎

  2. 公式訳では「加法的/加算フェクト」 ↩︎

  3. 多分 ↩︎

  4. なお、この無限ループはUEFNが自動的に検知して処理を強制的に終了します。有能! ↩︎

  5. 土屋調べ。どちらかでしかコンパイル出来ないコードに遭遇したら是非教えてください ↩︎

  6. ここはちょっと曖昧 ↩︎

  7. 理由は色々あるが、そもそもクエリ演算子が失敗コンテキストを受け入れない ↩︎

Discussion