Up to date

This page is up to date for Godot 4.2. If you still find outdated information, please open an issue.

GDScript:动态语言入门

关于

本教程旨在快速介绍如何更有效地使用GDScript,且只关注特定于该语言的常见情况,同时也会涉及许多关于动态类型语言的信息。

对于完全没有或几乎没有动态类型语言编程经验的程序员而言,本教程将会十分有用。

动态性

动态类型的优缺点

GDScript 是一种动态类型语言,其主要优点为:

  • 语言简单易学。

  • 大部分代码均可进行快速编写与更改,无任何麻烦。

  • 更少的代码编写量,需要修复的错误也更少。

  • 代码简单易读(但可能会有些许杂乱)。

  • 无需编译,即用即测。

  • 运行时(Runtime)小。

  • 从骨子上就注定了会有鸭子类型和多态这两个特性。

主要缺点有:

  • 性能要低于静态类型语言。

  • 重构会更加困难(无法追踪符号)。

  • 由于表达式解析更为严格,使得一些通常会在静态类型语言编译时可以检测到的错误只会在运行代码时出现。

  • 由于某些变量的类型只能在运行时确定,导致代码补全的灵活性较低。

其实,这也说明 Godot 旨在通过 GDScript 来快速高效地创建游戏,而对于计算量巨大并且无法从引擎内置工具(如向量类型、物理引擎、数学库等)中获得性能提升的游戏,Godot 也提供了使用 C++ 的可能性, 且依旧允许你使用 GDScript 创建游戏的绝大部分代码,只是在需要性能的地方添加少量 C++ 代码而已。

变量与赋值

动态类型语言中的所有变量都可以是“变体”类型的变量,即这些变量的类型不是固定的,只能通过赋值修改。例如:

静态类型编写示例:

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 和向量类型)会按值传递给函数(通过复制值来传递),而其他所有类型(对象实例、数组、字典等)都会按引用进行传递。继承自 RefCounted 的类(未指定父类时会默认继承该类)的实例在不被使用时释放,而对于继承自 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!")

字典

字典是动态类型语言中一个十分强大的工具。大多数用静态类型语言(例如 C++ 或 C#)编写代码的程序员都忽略了字典的存在,而不必要地增加了他们的工作难度。字典这种数据类型通常不存在于此类语言中(或仅以受限的形式出现)。

字典可以完全忽略键或值的数据类型,从而将任意一个值映射到其他值上。由于这些映射可以通过哈希表实现,因此字典十分高效,这一点与目前流行的观点相反。事实上,由于字典的高效性,在一些编程语言里甚至可以用数组的方式来实现字典。

字典示例:

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 循环

在一些静态类型的语言中,循环迭代可能会写得非常复杂:

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() 函数可包含 3 个参数:

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 类,(在 C++ 里)甚至可能还需要多重继承。如果不希望进行多重继承,那么这些类就必须继承像 Entity 这样的公共类。可如果只是其中几个能被大石块粉碎掉的话,那就要在 Entity 中添加一个虚方法 smash() ,这样写并不十分优雅。

用动态类型的语言来写这段代码,问题也就迎刃而解了。 在鸭子类型的编程环境下,只需确保在需要的地方定义一个 smash() 函数就行了,无需考虑继承、基类等问题。

func _on_object_hit(object):
    object.smash()

就是这样,如果被大石块集中的对象有 smash() 方法,那么就会调用该方法,就不需要考虑继承、多态。动态类型语言只在意实例是否具有所需方法或成员,而不在意该实例继承自什么类及该实例的对象类型。鸭子类型的定义会使这一点更加清晰明了:

“当我看到一只鸟像鸭子一样走路、像鸭子一样游泳、像鸭子一样呱呱叫时,我就管这只鸟叫鸭子”

在这种情况下,这句话就可以翻译成:

“如果物体可以被砸碎,那就不要在意这个物体是啥了,只管把这个物体砸碎就完事儿了。”

没错,称它为浩克(Hulk,即绿巨人)类型适乎更合适。

不过,被击中的对象可能并没有 smash() 函数。虽然一些动态类型语言在被调用方法不存在时会直接忽略该方法的调用,但GDScript在这方面更为严格,因此有必要检查目标函数是否存在:

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

然后,稍微定义下这个方法之后,大石块所触碰的任何东西都可以被大石块砸碎了。