🚀

Godot 4 await構文

2021/09/11に公開

この前の続き
Godot4を触ってて見つけたもののうち、紹介しきれてなかったGDScript2.0の新機能、
awaitについて書きます

※注釈

この記事が書かれた時点(2021/9/11)はGodot4リリース以前であり、この記事の内容はGodotのnightly buildを使って書かれたものです。

リリース時には仕様が変更されている可能性もありますがご了承ください

基本知識

Godotのイベントシステムの基本はsignalであり、それは3.x系でも4でも変わりなし

Godotの非同期関数は、一旦処理を中断してNodeが特定のsignalをemitするまで待ち、受信したら再開するというCoroutineパターンらしいです。
今までは GDScriptFunctionState というClassがあって、実行中の関数から yield で状態を返し、.resume()メソッドで処理を再開したり、completedシグナルを待ち受けて完了を検出したりしていました。

Godot3.x系以前までの非同期(yield)

英単語の意味
yield 明け渡す、移譲する
一旦、関数の処理を中断して呼び出し元に実行状態(どの行まで実行した?とか)を返すイメージ

yield(node, "signal_string_name")で、指定したnodeが指定したsignalをemitするまで待つ

# 3.x系のyieldを使った非同期
extends Node

signal my_signal(msg)

# yield を使う関数は、戻り値が GDScriptFunctionState オブジェクトになる
func async_func() -> GDScriptFunctionState:
  var timer = self.get_tree().create_timer(5.0)
  yield(timer, "timeout")
  return "5.0sec lapsed"

# GDScriptFunctionState オブジェクトは、コルーチンが完了したら completed(retVal) シグナルを出す
func _ready():
  var finish_msg = yield(async_func(), "completed")
  print(finish_msg) # "5.0sec lapsed"

これでも非同期プログラミング出来ないことはないですが、不満点もあります

  • yieldを使っている関数だと、戻り値の型ラベルによる検査がうまく動いてない
  • signalが文字列指定なので typo する危険がある
  • そもそも、今どきだとcoroutineをyieldするという考え方が直感的じゃない

もっとJavaScriptのPromise的な抽象化でやりたいです!
というわけで、 Godot4 で yield と入れ替えになった await を見てみましょう

Godot4からの非同期(await)

yieldawait というキーワードに変わった
使い方はだいたい同じだが、強化されている部分もある
以下の2種類の使い方ができるようになった

  • signalを待つ
  • coroutineを待つ (new!)
# 4系でのawaitを使った非同期
extends Node

func async_func() -> String:
  var timer = self.get_tree().create_timer(5.0)
  # SceneTreeTimer オブジェクトの timeout シグナルを待つ
  await timer.timeout
  return "5.0sec lapsed"

func _ready():
  # coroutine を待つ
  var finish_msg = await async_func()
  print(finish_msg)  # "5.0sec lapsed"

(zennがまだGDScript2.0に対応してないみたいで、awaitがハイライトされてませんが)

Godot4ではGDScriptFunctionStateというClassは削除されて、awaitキーワードを使う関数はcoroutineとして扱われるようになりました。

yield(func_state, "completed")のように completed シグナルを待つ方式ではなく直接 coroutine の完了を待つことができるようになり、知っているべき前提知識(completed シグナルの存在)やコードのボイラープレート的な記述がこれまでより減ります。

また、await使っている関数(coroutine)を実行するときにawaitで待ち受けないと以下のようなコンパイルエラーが出るようになりました。

Parse Error: Function "async_func()" is a coroutine, so it must be called with "await".

また、前の記事で書いたとおり、Signalはクラス定義されたオブジェクトとなっていてメンバ変数のようにアクセスできるようになったため、yield(node, "signal_string_name")のようにNodeオブジェクト+文字列指定する必要がなくなり、Signal型を渡せば良くなりました。
型によるコンパイルエラーを検出でき、typoによるミスも減ります。

高度な例: HTTPRequest

awaitが使えると何が嬉しいの?的な疑問があると思います。

例えば、ネットワーク関連では非同期的な処理を同期的に扱えると嬉しいですね。
ネットワークの世界では非同期な処理がたくさんありますが、それらの結果を同期的に待ったり、順番が重要な場合というのはよくあります。
Webの申し子たるJavaScript(ECMAScript)に async/await [1] 構文が導入されているように、非同期処理を同期的に扱える抽象化というのは直感的なプログラミングをするために重要だというわけです。

awaitを使わないで、signalだけでやろうとするとコールバック地獄[2]になってしまいますからね!

# Godot4 における HTTPRequest Node の使用例
extends Node

# HTTPRequest signal: 
# request_completed(result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray)


func _ready():
  var req = HTTPRequest.new()
  self.add_child(req)
  var network_err = req.request("https://httpbin.org/get") # GET request
  if network_err != OK:
    return
  var res = await req.request_completed # request_completed シグナルを待つ
  match res:
    [OK, 200, _, var body]:
      print("OK")
      var bodyStr =  (body as PackedByteArray).get_string_from_utf8()
      print(bodyStr)
    [OK, 400, _, _]:
      print("Bad Request")
    [OK, _, _, _]:
      print("Internal Server Error")
    _:
      print("Other error")

実はGDScriptは、エンジン付属の動的スクリプト言語のくせにいっちょ前にパターンマッチ[3]が使えます。signalが多値を含むときはArrayで渡ってくるので、それの分解と場合分けに使ってます(Elixirみたいですね)

上の例は https://httpbin.org (HTTPのテストサイト)にアクセスして、ステータスコードが200だったらbodyを文字列として出力するという単純なプログラムです。

まとめ

  • 新しく await キーワードが追加
    • signalに加えてcoroutineも待てるように
  • yieldキーワードとGDScriptFunctionStateclassは削除
  • 非同期処理を同期っぽく書くのが更にかんたんに

非同期プログラミングを同期的にする抽象化は特にネットワークと相性がいいので、GodotでマルチプレイヤーゲームとかMMOとかランキング機能とか、オンライン要素のあるゲームを作りたい人にはGodot4及びGDScript2.0へのアップデートは必見なのでは無いでしょうか?

ますますリリースが待ち遠しくなってきた。

脚注
  1. ECMAScript2017 で採用された構文。async function は関数を暗黙のPromiseに変換する、await は Promise が返されるのを待つ。https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Operators/await ↩︎

  2. 非同期プログラミングにおいて、コールバック方式を採用したことで関数のネストが深くなり、可読性が著しく阻害される様。例えば、JSのサーバーサイド実行環境であるNodeJS(commonJS)では、非同期にPromiseではなくコールバックが採用されているためよく地獄が見られる(ES6以降からトランスパイルすることで回避可能) ↩︎

  3. OCaml、Haskell、Elixirなどの関数型言語に実装されている、めっちゃ強化されたSwitch文。関数型言語にインスパイアされたRustなど、モダンなプログラミング言語にはよく搭載されている。 ↩︎

Discussion