GDScript:動態語言簡介

關於

本教學旨在提供一個更有效率使用 GDScript 的參考文件,並著重於 GDScript 中常見情形,同時也包含許多動態型別語言的資訊。

也希望本教學能為僅有少數動態型別語言經驗或完全沒經驗的程式設計師提供幫助。

動態性

動態型別的優缺點

GDScript 是動態型別語言。因此,GDScript 有下列優點:

  • GDScript 簡單易學。

  • 可以方便地撰寫或修改大部分程式碼。

  • 寫更少的程式碼,代表需要修正的錯誤與失誤更少。

  • 更容易閱讀程式碼(混亂的部分更少)。

  • 要測試時不需要編譯。

  • 執行環境很小。

  • 具有鴨子型別與多型。

而主要缺點如下:

  • 與靜態型別相比效能較差。

  • 比較難進行重構(無法追蹤符號)

  • 在靜態型別語言中通常在編譯時偵測的錯誤只有在執行程式碼時才會出現(因為運算式解析比較嚴格)。

  • 程式碼自動補全的靈活讀比較低(某些變數型別只有在執行時才可知)。

也就是說,實際上,這代表 Godot + GDScript 這樣的組合時設計來快速並有效率地製作遊戲。對於需要大量計算且無法通過 Godot 內建工具 (如 Vector 型別、物理引擎、數學函式…等) 來獲益的遊戲,也可以使用 C++。這樣一來便能以 GDScript 製作大部分的遊戲,並在需要改善效能的部分使用一點 C++。

變數與賦值

在動態型別語言中所有的變數都類似「Variant 型別」。這表示變數的型別並非固定,且只會在賦值時更改。如:

靜態語言:

int a; // Value uninitialized.
a = 5; // This is valid.
a = "Hi!"; // This is invalid.

動態語言:

var a # 'null' by default.
a = 5 # Valid, 'a' becomes an integer.
a = "Hi!" # Valid, 'a' changed to a string.

作為函式引數:

函式也具有動態性質,這代表函式可以使用不同的引數呼叫,如:

靜態語言:

void print_value(int value) {

    printf("value is %i\n", value);
}

[..]

print_value(55); // Valid.
print_value("Hello"); // Invalid.

動態語言:

func print_value(value):
    print(value)

[..]

print_value(55) # Valid.
print_value("Hello") # Valid.

指針與參照:

在如 C 或 C++ 的靜態語言中 (與某種程度上來說的 Java 與 C#),變數與變數的指針/參照是有區別的。後者可讓物件作為參照傳遞給函式時被函式修改。

在 C# 與 Java 中,所有非內建型別 (int, float, 某些時候的 String) 永遠都使用指針或參照來傳遞。參照也會自動執行記憶體回收機制,這表示參照會在不再使用後被清除。動態型別語言通常也會使用這種記憶體模型。例如:

  • C++:

void use_class(SomeClass *instance) {

    instance->use();
}

void do_something() {

    SomeClass *instance = new SomeClass; // Created as pointer.
    use_class(instance); // Passed as pointer.
    delete instance; // Otherwise it will leak memory.
}
  • Java:

@Override
public final void use_class(SomeClass instance) {

    instance.use();
}

public final void do_something() {

    SomeClass instance = new SomeClass(); // Created as reference.
    use_class(instance); // Passed as reference.
    // Garbage collector will get rid of it when not in
    // use and freeze your game randomly for a second.
}
  • GDScript:

func use_class(instance): # Does not care about class type
    instance.use() # Will work with any class that has a ".use()" method.

func do_something():
    var instance = SomeClass.new() # Created as reference.
    use_class(instance) # Passed as reference.
    # Will be unreferenced and deleted.

在 GDScript 中,只有基礎型別 (int, float, string 與 vector 型別) 會以值來傳遞給函式 (會複製其值)。其他的型別 (實體、陣列、字典…等) 則會以參照傳遞。繼承了 Reference 的類別 (若未指定則預設繼承) 則會在不再使用後被釋放,但也可以手動繼承 Object 來手動管理記憶體。

陣列

動態型別中的陣列可以包含各種混合的不同資料型別,且永遠為動態 (可隨時調整大小)。與靜態型別語言的陣列比較範例:

int *array = new int[4]; // Create array.
array[0] = 10; // Initialize manually.
array[1] = 20; // Can't mix types.
array[2] = 40;
array[3] = 60;
// Can't resize.
use_array(array); // Passed as pointer.
delete[] array; // Must be freed.

// or

std::vector<int> array;
array.resize(4);
array[0] = 10; // Initialize manually.
array[1] = 20; // Can't mix types.
array[2] = 40;
array[3] = 60;
array.resize(3); // Can be resized.
use_array(array); // Passed reference or value.
// Freed when stack ends.

GDScript 中則為:

var array = [10, "hello", 40, 60] # Simple, and can mix types.
array.resize(3) # Can be resized.
use_array(array) # Passed as reference.
# Freed when no longer in use.

在動態型別語言中,陣列也可以作為其他各種資料型別來使用,如列表:

var array = []
array.append(4)
array.append(5)
array.pop_front()

或無序集合:

var a = 20
if a in [10, 20, 30]:
    print("We have a winner!")

字典

字典在動態型別語言中是很強大的功能。大多數靜態語言出生的工程師 (如 C++ 或 C#) 都忘了有這東西,並讓自己的生活變得太複雜。通常靜態型別語言內不會有這種資料型別(或是只有提供一小部分功能)。

字典可以用來映射任何數值到另一個數值,不論索引鍵或值是什麼資料型別。與一般的迷思不同,字典其實是很實用的工具,因為可以用來實作雜湊表。事實上,字典實用到有些語言直接把陣列實作成字典。

字典使用例:

var d = {"name": "John", "age": 22} # Simple syntax.
print("Name: ", d["name"], " Age: ", d["age"])

字典也是動態的,索引鍵可以在任何時候新增或移除,而不需花費太多力氣:

d["mother"] = "Rebecca" # Addition.
d["age"] = 11 # Modification.
d.erase("name") # Removal.

大多數情況下,我們可以通過字典來更輕鬆地實作二維陣列。下面這個例子演示了一個簡單的戰艦遊戲:

# Battleship Game

const SHIP = 0
const SHIP_HIT = 1
const WATER_HIT = 2

var board = {}

func initialize():
    board[Vector2(1, 1)] = SHIP
    board[Vector2(1, 2)] = SHIP
    board[Vector2(1, 3)] = SHIP

func missile(pos):
    if pos in board: # Something at that position.
        if board[pos] == SHIP: # There was a ship! hit it.
            board[pos] = SHIP_HIT
        else:
            print("Already hit here!") # Hey dude you already hit here.
    else: # Nothing, mark as water.
        board[pos] = WATER_HIT

func game():
    initialize()
    missile(Vector2(1, 1))
    missile(Vector2(5, 8))
    missile(Vector2(2, 3))

字典也可以用來作為資料標記或是快速架構。由於 GDScript 的字典很像 Python 的字典,所以也支援 Lua 類型的語法與索引,在寫初始狀態或快速架構時很有用:

# Same example, lua-style support.
# This syntax is a lot more readable and usable.
# Like any GDScript identifier, keys written in this form cannot start
# with a digit.

var d = {
    name = "John",
    age = 22
}

print("Name: ", d.name, " Age: ", d.age) # Used "." based indexing.

# Indexing

d["mother"] = "Rebecca"
d.mother = "Caroline" # This would work too to create a new key.

For 與 While

在某些靜態型別語言中迭代可能很困難:

const char* strings = new const char*[50];

[..]

for (int i = 0; i < 50; i++) {

    printf("Value: %s\n", i, strings[i]);
}

// Even in STL:

for (std::list<std::string>::const_iterator it = strings.begin(); it != strings.end(); it++) {

    std::cout << *it << std::endl;
}

而在動態型別語言中,迭代便非常簡單:

for s in strings:
    print(s)

可迭代容器資料型別 (陣列與字典)。字典則允許迭代索引鍵:

for key in dict:
    print(key, " -> ", dict[key])

也可以迭代索引:

for i in range(strings.size()):
    print(strings[i])

range() 函式有三個引數:

range(n) # Will go from 0 to n-1.
range(b, n) # Will go from b to n-1.
range(b, n, s) # Will go from b to n-1, in steps of s.

這些靜態型別語言的例子:

for (int i = 0; i < 10; i++) {}

for (int i = 5; i < 10; i++) {}

for (int i = 5; i < 10; i += 2) {}

可以轉換為:

for i in range(10):
    pass

for i in range(5, 10):
    pass

for i in range(5, 10, 2):
    pass

而反向循環可以使用負數計數器來完成:

for (int i = 10; i > 0; i--) {}

改成:

for i in range(10, 0, -1):
    pass

While

while() 循環則在所有地方都長得一樣:

var i = 0

while i < strings.size():
    print(strings[i])
    i += 1

自定迭代器

當預設的迭代器不合用時,可以通過複寫 Variant 類別的 _iter_init, _iter_next_iter_get 函式來製作自定迭代器。下列為一個正向迭代器的實作範例:

class ForwardIterator:
    var start
    var current
    var end
    var increment

    func _init(start, stop, increment):
        self.start = start
        self.current = start
        self.end = stop
        self.increment = increment

    func should_continue():
        return (current < end)

    func _iter_init(arg):
        current = start
        return should_continue()

    func _iter_next(arg):
        current += increment
        return should_continue()

    func _iter_get(arg):
        return current

用法與其他迭代器一樣:

var itr = ForwardIterator.new(0, 6, 2)
for i in itr:
    print(i) # Will print 0, 2, and 4.

請確保在 _iter_init 中重設迭代器的狀態,否則使用自定迭代器的巢狀 for 循環可能會不正常。

鴨子型別

從靜態型別語言換到動態型別語言時,其中一個最難掌握的概念就是鴨子型別。使用鴨子型別能讓程式碼設計整體上來說更簡單且直觀,但運作原理就不太直觀。

舉個例子,想像當一個巨石滾到隧道中,沿路砸碎了所有東西。在靜態型別語言中,石頭的程式碼可能會長這樣:

void BigRollingRock::on_object_hit(Smashable *entity) {

    entity->smash();
}

這樣一來,所有能被石頭砸碎的物體都必須繼承 Smashable。若角色、敵人、傢俱、小石頭都要能被砸碎的話,則這些物件都必須繼承 Smashable 類別,而且可能需要用到多重繼承。所不想使用多重繼承的話,則必須都繼承通用類別,如 Entity。但,若只有其中幾個物件可以被砸碎的話,在 Entity 中加上 smash() 虛擬方法便不太優雅。

在動態型別語言中,就不會有這種問題。通過鴨子型別,我們就只要在有需要的地方定義 smash() 方法,而無需考慮繼承、基礎類別…等。

func _on_object_hit(object):
    object.smash()

就這樣。若被大石頭撞到的物件有 smash() 方法,則會呼叫 smash()。不需要使用繼承或多型。動態型別語言只關心實體是否有需要的方法或成員,而不管實體繼承哪個類別。瞭解鴨子型別的定義應該可以更清楚:

「如果看到一隻鳥,像鴨子一樣走路、像鴨子一樣游泳、有像鴨子一樣的叫聲,那我們就叫這隻鳥鴨子」

這時,我們可以把這句話翻成:

「若物件可以被砸碎,不管是什麼物件,把它砸碎就對了。」

當然,我們也可以稱這個方法為「綠巨人浩克型別」。

被撞到的物件也可能沒有 smash() 函式。有些動態型別語言會忽略呼叫不存在的方法 (如 Objective C),但 GDScript 中比較嚴格,所以需要檢查函式是否存在:

func _on_object_hit(object):
    if object.has_method("smash"):
        object.smash()

接著,只需要在被石頭碰到可以砸碎的物件上定義 smash 方法就好了。