GDScript 静态类型编程

在本指南中,你将学会:

  • 如何在 GDScript 中使用静态类型编程;

  • 静态类型编程可以帮助你避免问题;

  • 静态类型编程可以提升编辑器的使用体验。

这项语言功能的使用场合、使用方式完全取决于你:你可以只在部分敏感的 GDScript 文件中使用,也可以在所有地方都使用,甚至可以完全不使用。

静态类型编程可用于变量、常量、函数、参数和返回类型。

静态类型编程简介

使用 GDScript 静态类型编程,Godot 在编写代码时甚至可以帮你检测到更多代码错误,在你工作时为你和你的团队提供更多信息,当你调用方法时,会显示出参数的类型。静态类型编程也能改善编辑器的自动补全体验,其中也包括 documentation

想象你正在编写背包系统,你需要编写一个 Item(道具)节点,然后再编写一个 Inventory(背包)。要将道具添加到背包中,使用你所编写的代码的开发者应始终将 Item 传递给 Inventory.add 方法。有了类型,你就可以强制执行以下操作::

  1. class_name Inventory
  2. func add(reference: Item, amount: int = 1):
  3. var item := find_item(reference)
  4. if not item:
  5. item = _instance_item_from_db(reference)
  6. item.amount += amount

静态类型编程还能为你提供更好的代码补全选项。下面,你可以看到一个名为 PlayerController 类的动态类型和静态类型补全选项之间的区别。

你之前可能已经将节点存储在了变量中,打了一个句点符号,却没有代码自动补全提示:

动态类型的代码补全选项。

由于动态代码是动态的,因此 Godot 无法得知你传递给函数的值的类型。可如果你明确地声明了类型,则将从该节点类型获取所有公共方法和变量:

静态类型的代码补全选项。

小技巧

若偏向静态类型编程,建议开启编辑器选项**文本编辑器 > 补全 > 添加类型提示**,顺便也可以考虑开启默认关闭的 某些选项

同时,在操作数/参数类型在编译时已知时,静态类型编程编写的 GDScript 代码还能通过优化后的操作码提升代码运行性能。未来还计划进行更多 GDScript 方面的优化,如 JIT/AOT 编译。

总体而言,静态类型编程可为你提供更加结构化的体验,有助于避免代码错误,改善脚本的文档生成能力。当你在团队中或长期项目中工作时,静态类型编程将会特别有用。研究表明,开发人员将大部分时间要么都花在阅读别人的代码上,要么都花在阅读他们以前编写过但后来忘掉的脚本上。代码越清晰、越结构化,开发人员理解得也就越快,项目开发的速度也就越快。

如何使用静态类型编程

要定义变量、参数、常量的类型,请在名称后写一个英文冒号,再写上类型。例如 var health: int。这样就能够让变量的类型始终保持一致:

  1. var damage: float = 10.5
  2. const MOVE_SPEED: float = 50.0
  3. func sum(a: float = 0.0, b: float = 0.0) -> float:
  4. return a + b

如果你写了冒号但是省略类型,Godot 就会尝试推导类型:

  1. var damage := 10.5
  2. const MOVE_SPEED := 50.0
  3. func sum(a := 0.0, b := 0.0) -> float:
  4. return a + b

备注

  1. 对于常量而言, =:= 没有区别。

  2. 常量不需要写类型提示,Godot 会自动根据所赋的值设置该常量的类型,你仍然可以写上类型提示来让代码更整洁。同时,这样写对于类型化数组也还是很有用的(比如 const A: Array[int] = [1, 2, 3]),因为默认使用的是无类型的数组。

类型提示可以是什么

下面列出的是所有可以用作类型提示的东西:

  1. Variant,任何类型。大多数情况下与不写类型声明差不多,但能够增加可读性。作为返回类型时,能够强制函数显式返回值。

  2. (仅作返回类型使用) void。表示函数不返回任何值。

  3. 内置类型

  4. 原生类(ObjectNodeArea2DCamera2D 等)。

  5. 全局类

  6. 内部类

  7. 全局具名常量与内部具名常量。注意:枚举是 int 类型的数据,不能保证一个值属于该枚举。

  8. 包含预加载类和枚举的常量(或局部常量)。

你可以将任何类(包括自定义类)用作类型。有两种在脚本中使用这些类型的方法:第一种方法是将要用作类型的脚本预加载为常量::

  1. const Rifle = preload("res://player/weapons/rifle.gd")
  2. var my_rifle: Rifle

而第二种方法则是在创建类时使用 class_name 关键字。对于上面的示例, Rifle.gd 看起来就像这样::

  1. class_name Rifle
  2. extends Node2D

使用 class_name 时,Godot 会在编辑器中注册一个全局 Rifle 类型,可以在任何地方使用该类型,而无需将其预加载到常量当中::

  1. var my_rifle: Rifle

使用箭头 -> 来定义函数的返回类型

要定义函数的返回类型,请在声明后写一个短划线和一个右尖括号 -> ,后跟返回类型::

  1. func _process(delta: float) -> void:
  2. pass

类型 void 表示函数不返回任何内容。你可以使用任何类型,如变量::

  1. func hit(damage: float) -> bool:
  2. health_points -= damage
  3. return health_points <= 0

你还可以使用自定义类作为返回类型::

  1. # Adds an item to the inventory and returns it.
  2. func add(reference: Item, amount: int) -> Item:
  3. var item: Item = find_item(reference)
  4. if not item:
  5. item = ItemDatabase.get_instance(reference)
  6. item.amount += amount
  7. return item

协变与逆变

继承基类方法时,应遵循 里氏代换原则

协变: 继承方法时,你可以为子类方法指定一个比该子类方法的父类方法更为具体的返回值类型(如**子类型**)。

逆变: 继承方法时,你可以为子类方法指定一个比该子类方法的父类方法更不具体的返回值类型(如**超类型**)。

示例(以匿名 setter/getter 函数为例):

  1. class_name Parent
  2. func get_property(param: Label) -> Node:
  3. # ...
  1. class_name Child extends Parent
  2. # `Control` is a supertype of `Label`.
  3. # `Node2D` is a subtype of `Node`.
  4. func get_property(param: Control) -> Node2D:
  5. # ...

定义 Array 元素的类型

要定义 Array 的类型,请将类型名称填写在 [] 内。

数组的类型适用于“for”循环变量以及一些运算符,如“[]”、“[]=”和“+”。部分数组方法(如`push_back``)和运算符(如``==``)依旧对数组类型不敏感。内置类型、引擎原生类型、自定义类型及枚举均可以用作元素类型。不支持嵌套数组类型。

  1. var scores: Array[int] = [10, 20, 30]
  2. var vehicles: Array[Node] = [$Car, $Plane]
  3. var items: Array[Item] = [Item.new()]
  4. # var arrays: Array[Array] -- disallowed
  5. for score in scores:
  6. # score has type `int`
  7. # The following would be errors:
  8. scores += vehicles
  9. var s: String = scores[0]
  10. scores[0] = "lots"

从 Godot 4.2 开始,你可以显式指定 for 循环的循环变量类型。比如,你可以这样写::

  1. var names = ["John", "Marta", "Samantha", "Jimmy"]
  2. for name: String in names:
  3. pass

数组仍旧不会限定类型,但 for 循环的 name 循环变量则始终为 String 类型。

类型转换

类型转换是类型语言的关键概念,转型是指将值从一种类型转换为另一种类型的操作或过程。

想象你的游戏中的一个敌人, extends Area2D 。你希望它与游戏角色,即一个附带有一个名为 PlayerController 的脚本的 CharacterBody2D, 碰撞,那么你可以使用 body_entered 信号来检测碰撞。使用类型化代码,其检测到的物体(body)将是通用的 PhysicsBody2D 而非 _on_body_entered 回调上使用的 PlayerController

你可以使用 as``转型关键字来检查这个 ``PhysicsBody2D 是否是你的游戏角色,并再次使用冒号 : 来强制变量使用这种类型。这样会强制变量使用 PlayerController 类型:

  1. func _on_body_entered(body: PhysicsBody2D) -> void:
  2. var player := body as PlayerController
  3. if not player:
  4. return
  5. player.damage()

在处理自定义类型时,如果 body 没有继承 PlayerController 类,则 player 变量将被赋值为 null 。我们可以用这种操作来检查物体是否为游戏玩家角色。多亏了类型转换,我们还能获得 player 变量的代码自动补全功能。

备注

在变量类型在运行时中不匹配的情况下, as 关键字会将变量的值静默转型为 null ,不会触发任何警告与报错,虽然在某些情况下会比较好用,但也会带来一些问题。尽在需要强制转型的时候使用 as ,否则使用 is 来代替转型语法::

  1. if not (body is PlayerController):
  2. push_error("Bug: body is not PlayerController.")
  3. var player: PlayerController = body
  4. if not player:
  5. return
  6. player.damage()

或者用 assert() 断言语法::

  1. assert(body is PlayerController, "Bug: body is not PlayerController.")
  2. var player: PlayerController = body
  3. if not player:
  4. return
  5. player.damage()

备注

如果你尝试使用内置类型进行转型且转型失败,则将触发 Godot 脚本编辑器底部报错。

安全行

你也可以使用转型语法来确保存在安全行,安全行是 Godot 3.1 中加入的新工具,可以告诉你一行歧义代码在什么情况下类型安全。由于你有时会混合使用静态类型代码和动态类型代码,有时如果指令在运行时触发错误,Godot 可能没有足够的信息进行判断。

当你需要获得子节点时就会发生这种情况。以计时器为例:使用动态代码,你可以使用 $Timer 获取节点。GDScript 支持鸭子类型,即使你的计时器是 Timer 类型,计时器也继承了 NodeObject 这两个类。使用动态类型的 GDScript,只要节点具有你需要调用的方法,你也不必关心节点的类型。

当你得到一个节点时,可以使用强制转型来告诉 Godot 你所期望的类型: ($Timer as Timer)($Player as KinematicBody2D) 等,Godot 将确认该类型是否有效,如果有效,在脚本编辑器的左侧的行号将会变为绿色。

不安全行 vs 安全行

不安全代码行(第 7 行)vs 安全代码行(第 6 行和第 8 行)

备注

安全行也并不总表示该行代码更加优秀可靠,参见前文所述的 as 关键字。比如::

  1. @onready var node_1 := $Node1 as Type1 # Safe line.
  2. @onready var node_2: Type2 = $Node2 # Unsafe line.

虽然 node_2 的声明被标记为了不安全行,但比起 node_1 的声明来看却更加可靠,这是因为如果你不小心在场景中更改了节点的类型,又忘记把它改回去的话,就会在场景加载时触发报错。 node_1 的情况则是会被静默转型成 null ,触发报错。

备注

可以在编辑器设置中关闭安全行或更改其颜色。

静态编程还是动态编程:坚持一种风格

静态类型的 GDScript 和动态类型的 GDScript 可共存于同一项目,但还是建议二选其一,以确保每个人代码风格的一致性。如果你们遵循相同的规范,那么每个人就都可以更轻松地协作,阅读、理解他人的代码也就会更加迅速。

虽然静态类型的代码需要敲更多的字,但你将获得前文所述的种种好处。下面是内容一样的空脚本示例,首先是使用动态类型的脚本:

  1. extends Node
  2. func _ready():
  3. pass
  4. func _process(delta):
  5. pass

其次是使用静态类型的脚本示例:

  1. extends Node
  2. func _ready() -> void:
  3. pass
  4. func _process(delta: float) -> void:
  5. pass

如你所见,你也可以对引擎虚函数的参数进行类型声明。和方法一样,信号的回调参数也可以指定类型,下面是一个动态类型的 body_entered 信号示例:

  1. func _on_area_2d_body_entered(body):
  2. pass

以及具有类型提示的相同回调:

  1. func _on_area_entered(area: CollisionObject2D) -> void:
  2. pass

警告系统

备注

关于 GDScript 警告系统的文档已移至 GDScript 警告系统

静态类型的 GDScript 的另一个显著优点便是 警告系统 。从Godot 3.1 开始,Godot 会在你编写代码时向你发出有关代码的警告,引擎会识别代码中可能导致运行时出现问题的部分,你可以决定是否要保留该部分代码。

对于静态类型 GDScript 编程的用户,我们提供了大量警告,这些警告默认关闭,但你可以将这些警告在项目设置(调试 > GDScript)中开启,在此之前,请确保你已启用了项目设置的**高级设置**模式。

若始终进行静态类型编程,你可以启用 UNTYPED_DECLARATION 警告。此外,你还可以启用 INFERRED_DECLARATION 警告来让你的代码可读性更强、更有可靠性,但同时也会让你的代码更加冗长。

UNSAFE_* 警告会让不安全操作比不安全行更容易引人注意。目前, UNSAFE_* 警告并不能涵盖不安行所涵盖的所有情况。

常见的不安全操作及其安全操作

UNSAFE_PROPERTY_ACCESSUNSAFE_METHOD_ACCESS 警告

下例中,我们将给一个属性设值,调用该对象中的方法(该对象的脚本类为 class_name MyScript ,继承自 extends Node2D)。假设我们将该对象引用为 Node2D 类型(此处只是举个例子,还以实际情况为准),我们可以先检测该属性及方法是否存在,如果存在,那么就设置该属性,调用该方法。:

  1. if "some_property" in node_2d:
  2. node_2d.some_property = 20 # Produces UNSAFE_PROPERTY_ACCESS warning.
  3. if node_2d.has_method("some_function"):
  4. node_2d.some_function() # Produces UNSAFE_METHOD_ACCESS warning.

不过这样写会触发 UNSAFE_PROPERTY_ACCESSUNSAFE_METHOD_ACCESS 警告,因为该属性和方法在被引用类型中无法找到(本例中为 Node2D ,即在 Node2D 中找不到该属性及方法)。要让该操作安全执行,可以先用 is 关键字来检测该对象是否为 MyScript 类型的对象,然后再声明一个类型为 MyScript 的对象,再用后者去设置属性值,调用其方法。:

  1. if node_2d is MyScript:
  2. var my_script: MyScript = node_2d
  3. my_script.some_property = 20
  4. my_script.some_function()

当然,也可以尝试将该变量用 as 运算符进行转型,之后检测该变量是否非空,来确定该变量是否成功转型。:

  1. var my_script := node_2d as MyScript
  2. if my_script != null:
  3. my_script.some_property = 20
  4. my_script.some_function()

UNSAFE_CAST 警告

下例中,我们将尝试让标签节点在对象进入碰撞区域时显示该碰撞区域名称。对象一进入碰撞区域,物理系统就会发送一个含 Node2D 类型对象的信号,这样就可以实现我们想要的最简单(非静态类型)的解决方案::

  1. func _on_body_entered(body: Node2D) -> void:
  2. body.label.text = name # Produces UNSAFE_PROPERTY_ACCESS warning.

上述代码段会抛出 UNSAFE_PROPERTY_ACCESS 警告,因为 label 并未在 Node2D 当中进行定义。要解决这个问题,我们可以先检查 label 这个属性是否存在,然后再将该属性强制转换为 Label 类型,之后再去设置其 text 属性,如下所示::

  1. func _on_body_entered(body: Node2D) -> void:
  2. if "label" in body:
  3. (body.label as Label).text = name # Produces UNSAFE_CAST warning.

然而上述示例还会触发 UNSAFE_CAST 警告,因为 body.labelVariant 类型的属性。要想安全获取该属性,且希望该属性为想要的类型,可以使用 Object.get() 方法,如果对应属性存在,则该方法会返回 Variant 类型的值,否则会返回 null ,然后用 is 关键字来判断该属性是否包含关键字右边的类型,最后再用静态类型编写代码的方法声明变量,将该对象赋值给该变量即可::

  1. func _on_body_entered(body: Node2D) -> void:
  2. var label_variant: Variant = body.get("label")
  3. if label_variant is Label:
  4. var label: Label = label_variant
  5. label.text = name

不能指定类型的情况

最后,我们将介绍一些不能使用类型提示的情况。以下所有示例 都会触发报错

  1. 无法指定数组中单个成员的类型,这样会触发报错::

    1. var enemies: Array = [$Goblin: Enemy, $Zombie: Enemy]
    2. var character: Dictionary = {
    3. name: String = "Richard",
    4. money: int = 1000,
    5. inventory: Inventory = $Inventory,
    6. }
  2. 目前不支持数组类型嵌套:

    1. var teams: Array[Array[Character]] = []
  3. 目前不支持为字典元素设置类型:

    1. var map: Dictionary[Vector2i, Item] = {}

总结

静态类型的 GDScript 是一个十分强大的工具,可以帮助编写更多结构化的代码,避免常见错误,创建灵活的代码系统。将来,由于即将进行的编译器优化,静态类型也将会带来不错的性能提升。