⚠️

【Godot4】[if node] ≠ [if node ! = null]の謎

2024/09/09に公開

0. この記事の内容

GDScriptのif文でのnullチェック時の少し奇妙な挙動について調べたのでその備忘録としてまとめました。これが原因でクラッシュなんてこともあるので、参考にしてください。

Godot 4.3-stableを使用しています。ソースコードは2024年9月8日に参照したものです。個人的にコードブロックの方が見やすいので該当箇所を抜き出したコードブロックとGitHubへのリンクを併記しています。

1. if nodeの奇妙な挙動

突然ですが、次のメソッドを実行したらどのように出力されるでしょうか?

func test() -> void:
    var node: Node = null
    
    print("null , if node != null : ", true if node != null else false)
    print("null , if node         : ", true if node else false)
    
    node = Node.new()
    node.queue_free()
    
    # freeされるのを待つためにprocess_frameをawait
    await get_tree().process_frame
    
    print("freed, if node != null : ", true if node != null else false)
    print("freed, if node         : ", true if node else false)

答えはこちら
null , if node != null : false
null , if node         : false
freed, if node != null : false
freed, if node         : true

破棄されたオブジェクトのif nodeだけがtrueとなりました。

予想は当たっていたでしょうか?

2. なぜif nodetrueになるのか

なぜこのような現象が起きるのでしょう?幸いGodotはオープンソースなゲームエンジンです。中身を覗いてみましょう。

GDScriptを解釈する処理はgdscript_vm.cppにあります。その中のif文の条件式に関する処理を見てみましょう。

bool result = test->booleanize();
gdscript_vm.cpp

どうやらifの条件式はbooleanizeによってboolに変換されているようです。booleanizeの定義されているvariant_op.cppを調べると、その中ではis_zeroが呼ばれています。

bool Variant::booleanize() const {
    return !is_zero();
}
variant_op.cpp

次はis_zeroの定義されているvariant.cppを見てみましょう。
今回用いたnodeObjectを継承しているので953行~が該当します。

case OBJECT: {
    return _get_obj().obj == nullptr;
}
variant.cpp

is_zeroはオブジェクトがnullptrの際はtrueを返すことが分かります。

オブジェクトを破棄した後もif nodetrueになったということは、破棄後そのオブジェクトの参照はnullptrにならないということです。破棄された後も同じアドレスを保持し続けるため、メモリアクセス違反を起こしてしまうのです。

CC++を使っていれば当たり前っちゃ当たり前なのですが、GDScriptのようなリッチな言語を使っているとどうしても意識が薄くなりがちです。

3. なぜif node != nullfalseになるのか

if nodetrueになった謎については解決しましたが、ではなぜif node != nullfalseになったのでしょうか。こちらも調べてみましょう。

演算子の実装はvariant_op.hに書かれています。

static void evaluate(const Variant &p_left, const Variant &p_right, Variant *r_ret, bool &r_valid) {
    const Object *a = p_left.get_validated_object();
    const Object *b = p_right.get_validated_object();
    *r_ret = a == b;
    r_valid = true;
}
variant_op.h

等価演算子の評価の過程で、オブジェクトの参照はget_validated_objectを用いて取得されています。get_validated_objectvariant.cppに定義されています。

Object *Variant::get_validated_object() const {
    if (type == OBJECT) {
        return ObjectDB::get_instance(_get_obj().id);
    } else {
        return nullptr;
    }
}
variant.cpp

get_validated_objectではget_instanceを用いて参照を取得しています。get_instanceobject.hObjectDBクラスに定義されています。

if (unlikely(object_slots[slot].validator != validator)) {
    spin_lock.unlock();
    return nullptr;
}
variant.cpp

get_instanceは破棄されている等、オブジェクトが有効でない場合はnullptrを返すようになっています。そのため破棄されたオブジェクトに対してif node != nullfalseになったのです。

4. バグでした

「この挙動はバグではないか?」と思い調べたところ、やはりバグでした。

https://github.com/godotengine/godot/pull/93809

https://github.com/godotengine/godot/issues/90513

しかしなかなか根深いバグで、簡単には修正できないようです。

少し面倒くさいですが、オブジェクトが有効かどうかをチェックしたい場合はis_instance_validを使うのが良さそうです。

5. おわり

ふと疑問に思った奇妙な挙動について調べました。今回はコードを追い、オブジェクト参照の仕方の違いによってif nodeif node != nullは異なる結果になっていると結論づけました。しかし、バグ修正が難航していることから、もしかしたらそんな単純な問題では無いのかもしれません。間違いがあればご指摘お願いします。

Discussion