個人開発をしています。無料の計算ツール集 ShibaHub、補助金を検索できる 補助金ナビ を運営していて、いまは3Dのゲームをストアに出す準備をしています。
そのゲームで、通信の処理が終わったかどうかを待つテストを書いたときの話です。
実装は正しく動いていたのに、テストだけが「失敗」と言い続けました。 しかも失敗の見え方が「タイムアウト」だったので、まる一本、実装のほうを疑って調べることになりました。
原因は言語の仕様で、知っていれば1秒で分かるものでした。同じ形で踏む人がいそうなので書いておきます。
書いたテスト
やりたかったのは「非同期の処理が終わるのを待つ」ことです。
処理が終わると became_host という合図(シグナル)が飛んでくるので、それを受け取るまで待ちます。
var got := false
net.became_host.connect(func() -> void: got = true) # 合図が来たら true にする
var waited := 0.0
while not got and waited < 10.0:
await process_frame
waited += 0.016
print("結果: ", got) # → 常に false
素直に読めば、合図が来たら got が true になってループを抜けるはずです。
でも永遠に抜けません。 10秒たってタイムアウトします。
実装を疑って、時間を使う
失敗の表示が「10秒待っても来なかった」だったので、当然こう考えました。
合図が飛んでいないんだな。処理のほうが壊れている。
そこから、通信のコードを読み直す時間が始まります。
- 合図を出す側のコードを確認する → ちゃんと
emit()している - 順番の問題かと思い、合図を繋ぐ位置を前にずらす → 変わらない
行き詰まったので、テストではなく、その場で状態を出すだけの小さなスクリプトを書き直しました。
net.became_host.connect(func(): print(">>> became_host が来た"))
print("開始 -> ", net.host_game())
# 8秒待つ
結果はこうでした。
開始 -> true
>>> lobby_created_ok 109775242537706555
>>> became_host ← ★来ている
合図はちゃんと来ていました。 実装は最初から正しかったわけです。
つまり壊れていたのはテストのほうでした。
原因:ラムダはローカル変数を「コピー」して持つ
GDScriptのラムダ(無名関数)は、外側のローカル変数を値でコピーして捕まえます。
var got := false
var f := func(): got = true
f.call()
print(got) # → false(true にならない)
ラムダの中の got は、外の got とは別物です。ラムダを作った瞬間の値がコピーされていて、中で書き換えても外には反映されません。
これは「クロージャが変数を参照で掴む」言語(JavaScriptなど)に慣れていると、まず引っかかります。自分もそうでした。
公式ドキュメントにも書かれています。
Lambda functions capture the local environment by value.
— GDScript reference / Lambda functions
直し方
外に反映したいものは、ローカル変数以外で受けます。
方法1:メンバ変数にする(今回はこれ)
var _got_host := false # クラスのメンバにする
func _on_became_host() -> void:
_got_host = true
func test() -> void:
_got_host = false
net.became_host.connect(_on_became_host) # ラムダをやめて関数を渡す
...
while not _got_host and waited < 10.0:
await process_frame
ラムダ自体をやめて、普通の関数を渡すのがいちばん素直でした。
方法2:配列や辞書で包む
どうしてもその場に書きたいときは、中身を書き換えられる入れ物にします。
var got := [false] # 配列はコピーされても中身は共有される
net.became_host.connect(func(): got[0] = true)
while not got[0]:
await process_frame
配列や辞書は参照で扱われるので、コピーされても同じ中身を指しています。
いちばんの学び:テストの失敗を、そのまま信じない
技術的には「ラムダは値でコピーする」で終わりなのですが、時間を溶かした理由はそこではありません。
理由は、テストが出した「10秒待っても来なかった」という言葉を、そのまま信じたことです。
この表示は、実際には2つの可能性を含んでいました。
- 合図が来ていない(実装の問題)
- 合図は来ているが、受け取り側が壊れている(テストの問題)
自分は1しか考えませんでした。テストは「正しさを測る道具」なので、無意識に道具のほうは正しい前提にしていたわけです。
いま気をつけているのは、この2つです。
- テストが落ちたら、まず「テストが正しいか」を疑う。 特に新しく書いたテストは、実装と同じくらい壊れている
- タイムアウトは情報が少ない失敗。 「来なかった」ではなく「何が来て、何が来なかったか」を出すようにする
いまはテストの中に、こうコメントを残してあります。
ラムダの中からローカル変数は書き換えられない。実装は正しく動いているのに
「10秒待っても来ない」と出て、実装側を疑ってしまう。
コメントは未来の自分あてのメモです。同じ罠は必ずもう一度踏みます。
まとめ
- GDScriptのラムダは、ローカル変数を値でコピーして捕まえる
- 外に結果を返したいなら、メンバ変数か、配列・辞書のような中身を共有する入れ物を使う
- 新しく書いたテストが落ちたら、テスト自体をまず疑う
- タイムアウトで落とすときは、「何が来たか」も一緒に出す