GDScript:動態語言入門
關於
本教學旨在成為更有效率使用 GDScript 的快速參考指南,聚焦於語言特有常見情境,同時涵蓋許多關於動態型別語言的資訊。
特別適合少有或沒有動態型別語言經驗的程式設計師參考。
動態特性
動態型別的優缺點
GDScript 是動態型別的語言,因此具有以下幾個主要優點:
語言上手容易。
可以快速且輕鬆地撰寫與修改大多數程式碼。
程式碼易於閱讀(雜訊較少)。
不需編譯即可測試。
執行環境小巧。
天生支援鴨子型別與多型。
但主要缺點包括:
效能通常不及靜態型別語言。
重構較困難(符號無法追蹤)。
某些在靜態型別語言中可於編譯時發現的錯誤,只有在執行時才會出現(因為運算式解析較嚴格)。
程式碼補全的彈性較低(有些變數型別僅於執行時才能得知)。
實際上,這代表 Godot 搭配 GDScript 是為了讓你能快速且有效率地開發遊戲。如果你的遊戲需要大量計算、且無法享受引擎內建工具(如 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 型別)會以值傳遞(複製值)給函式。其他(如實體、陣列、字典等)則以參照傳遞。若類別繼承 class_RefCounted`(預設為此),在未被使用時會自動釋放,如果你手動繼承 :ref:`class_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] # You 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!")
字典
字典是動態型別語言中的強大工具。在 GDScript 中,沒有明確型別的字典可以應用於許多靜態語言會使用其他資料結構的場合。
字典可以將任何值對應到任何其他值,無論鍵或值的型別為何。與一般想像不同,字典其實很高效,因為它們是以雜湊表實作。甚至有些語言直接以字典實作陣列。
字典範例:
var d = {"name": "John", "age": 22}
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 迴圈
在 C 系語言中,使用傳統 for 迴圈進行迭代可能會較為複雜:
const char** strings = new const char*[50];
[..]
for (int i = 0; i < 50; i++) {
printf("Value: %c Index: %d\n", strings[i], i);
}
// Even in STL:
std::list<std::string> strings;
[..]
for (std::string::const_iterator it = strings.begin(); it != strings.end(); it++) {
std::cout << *it << std::endl;
}
因此,GDScript 採用統一的 for-in 迴圈搭配可迭代物件:
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 count from 0 to n in steps of 1. The parameter n is exclusive.
range(b, n) # Will count from b to n in steps of 1. The parameters b is inclusive. The parameter n is exclusive.
range(b, n, s) # Will count from b to n, in steps of s. The parameters b is inclusive. The parameter n is exclusive.
以下是一些 C 風格 for 迴圈的例子:
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() 方法。有些動態型別語言(如 Objective C)會自動忽略不存在的方法呼叫,但 GDScript 管得較嚴,建議先檢查該方法是否存在:
func _on_object_hit(object):
if object.has_method("smash"):
object.smash()
然後,只要在你希望能被巨石砸碎的物件上定義這個方法即可。