类、面向对象与内存管理
掌握三种类的定义方式继承与多态属性访问器,并理解引用计数与手动释放的内存规则
GDScript 是一门面向对象语言,而它的第一条”奇怪”规则是:每个 .gd 脚本文件本身就是一个类,你写的所有代码都活在类里。本篇系统讲清 GDScript 的面向对象体系:三种类的定义方式、对象实例化与构造析构、继承与多态、抽象类、属性访问器、静态成员;最后是内存管理——RefCounted 的引用计数与 Node 的手动释放,这是游戏开发中最容易出事故的知识点。
学习目标
- 会用三种方式定义类:无名类、class_name 全局类、内部类;
- 掌握 _init 构造、_static_init 静态构造与 NOTIFICATION_PREDELETE 清理;
- 理解 extends 的四种形式与继承限制,会用 super 与 is;
- 理解”所有方法默认可重写”的多态规则与 @abstract 抽象类;
- 会写内联与具名两种属性访问器,避开无限递归陷阱;
- 理解 RefCounted 与 Node 的内存规则,会用 weakref 与 is_instance_valid。
三种类形式
无名类:每个脚本文件就是一个类
每个 .gd 文件本身就是一个类。没有 class_name 时它没有全局名字,只能按文件路径引用:
# 方式一:按路径继承另一个脚本
extends "res://path/to/character.gd"
# 方式二:load 加载后实例化
var Character = load("res://path/to/character.gd")
var character = Character.new()
具名全局类:class_name
class_name Character
extends Node2D
- 类名用 PascalCase;注册进全局类表后,任何脚本都能直接用类名访问,不需要 load 或 preload;
- @icon(“res://icon.png”) 可以给类自定义图标,写在 class_name 之前;
- class_name 与 extends 可以写在同一行:class_name MyNode extends Node;
- 注意:以 Editor 开头的类名不会出现在编辑器的”新建节点”对话框中,起名时避开这个前缀。
内部类
用 class 关键字在脚本文件内部声明类:
class MyInnerClass:
func hello() -> void:
print("hello")
func _ready() -> void:
var obj = MyInnerClass.new() # 文件内直接 new
obj.hello()
从外部访问要经过外部类名:MyClass.MyInnerClass.new()。内部类里还可以再嵌套内部类。
对象与实例化
用 类名.new() 创建实例,它会调用构造函数 _init。self 在方法内指向当前实例,有两个典型用途:一是区分同名局部变量与成员变量;二是链式调用——方法返回 self,调用方就能一路点下去:
func set_hp(value: int) -> Character:
hp = value
return self # 返回自身,支持 player.set_hp(10).heal()
性能提示:成员属性的访问沿继承链向上查找,比数组、字典的键访问慢。热点代码里频繁读写的值,优先放进局部变量。
构造与析构
_init 是构造函数,可以带参数:
func _init(name: String, level: int = 1) -> void:
self.name = name
self.level = level
注意一个限制:如果 _init 定义了没有默认值的必填参数,这个类就只能通过代码传参实例化——PackedScene.instantiate()、duplicate() 这类由引擎完成的实例化都会失败,因为引擎提供不了这些参数。
_static_init 是静态构造函数,在类被加载时自动执行一次,适合初始化静态成员。
GDScript 没有析构函数。需要在对象销毁前做清理,用 _notification 监听 NOTIFICATION_PREDELETE:
func _notification(what: int) -> void:
if what == NOTIFICATION_PREDELETE:
# 对象即将销毁,在这里做手动清理
pass
继承
extends 有四种形式:
extends CharacterBody2D # 全局类名
extends "res://path/to/character.gd" # 文件路径
extends "res://path/to/other.gd".MyInnerClass # 其他文件里的内部类
class_name Player extends CharacterBody2D # 与 class_name 同行
继承的核心规则:
- 不允许多重继承;不写 extends 时默认继承 RefCounted;
- 子类不能声明与父类同名但参数不同的方法(GDScript 不支持方法重载),构造函数 _init 例外;也不能声明与父类同名的属性;
- super(x) 调用父类的同名方法(最常用于在 _init 里向父类转发参数);super.method() 显式调用父类的指定方法:
func _init(x: int) -> void:
super(x) # 调用父类 _init
func attack() -> void:
super.attack() # 先执行父类逻辑,再做子类扩展
- 父类 _init 有必填参数时,子类必须定义自己的 _init,并向 super 传参;
- is 检查继承关系:if player is Character:。
多态与抽象类
GDScript 没有 virtual、override 关键字——所有方法默认都可以被子类重写,子类声明同名同参方法即完成重写。约定是:只应该重写下划线开头的引擎内置方法(_ready、_process 等);自己写的、希望被子类重写的方法,也建议加 _ 前缀,表明这是留给子类覆写的。
Godot 4.5 起支持 @abstract 抽象类。先定义抽象基类:
# animal.gd
@abstract class_name Animal
@abstract func eat() -> void
再写具体子类实现它:
# cat.gd
class_name Cat
extends Animal
func eat() -> void:
print("cat eating")
规则:
- @abstract 写在 class_name/extends 之前;
- 抽象类不能实例化,Animal.new() 会报错;
- 具体子类必须实现全部抽象方法,否则报错;
- 抽象类可以没有任何抽象方法——只用它表达”这是一个不许直接实例化的基类”;
- 内部类也可以声明为抽象类。
属性访问器
属性可以定义 get/set 访问器,把读写过程接管下来。第一个例子:内部用毫秒存储,对外以秒读写:
var milliseconds: int = 0
var seconds: int:
get:
return milliseconds / 1000
set(value):
milliseconds = value * 1000
第二个例子是游戏开发中的惯用法——限制取值范围并发射信号:
signal hp_changed(old_value: int, new_value: int)
var max_health := 100
var health: int = 100:
get:
return health
set(value):
if value == health:
return
if value > max_health:
value = max_health
elif value < 0:
value = 0
var old_val = health
health = value
hp_changed.emit(old_val, health)
访问器也可以写成具名函数的形式:
var my_prop: get = get_my_prop, set = set_my_prop
四条要点:
- 内联语法与具名函数语法不能混用;
- set/get 总是被调用,包括类内部自己访问这个属性时;
- 声明时的初始值直接写入,不经过 setter——所以上例 health = 100 不会触发 hp_changed;
- 访问器内部直接用变量名读写不会无限递归(语言层面做了处理);但如果访问器调用了别的函数,而那个函数里又访问这个属性,就会再次触发访问器,形成无限递归——这是最常见的翻车点。
静态成员
static var 属于类而非实例,所有实例共享同一份。经典用法是实例计数:
class_name Student
static var class_size: int = 0
var name: String
var id: int
func _init(name: String) -> void:
self.name = name # self 区分成员与同名参数
class_size += 1 # 所有实例共享同一个计数
id = class_size # 自动编号:第一个学生 1,第二个 2
注意:static var 不能使用 @export 或 @onready;局部变量也不能声明为 static。
内存管理
引擎对象的内存分两套规则:
- RefCounted(引用计数类)及其子类(包括 Resource):引用计数管理,最后一个引用消失时自动释放,不需要手动 free;
- Node:不会自动释放,必须手动调用 free()(立即删除)或 queue_free()(推迟到帧末删除,并递归删除所有子节点,更安全)。
引用计数有一个著名的漏洞:循环引用。两个 RefCounted 对象互相持有引用时,计数永远无法归零,内存就泄漏了。官方提供的破环工具是 weakref() 弱引用——弱引用不增加引用计数:
var my_file_ref = weakref(my_file) # 弱引用:不影响引用计数
var file_ref = my_file_ref.get_ref() # 对象还活着则取回引用;已释放则得 null
if file_ref:
file_ref.close()
判断一个对象是否已被释放(比如手里还留着已被 queue_free 的节点引用),用 is_instance_valid():
if is_instance_valid(enemy):
enemy.queue_free()
实践建议:纯数据对象、逻辑对象继承 RefCounted 或 Resource,把生命周期交给引擎托管;Node 交给场景树管理,删除时优先用 queue_free。
选基类建议
- 需要进入场景树、生命周期回调(_ready、_process)、信号机制,选 Node 系(Node2D、Node3D、Control 等);
- 纯数据与纯逻辑、不进场景树,选 RefCounted;
- 需要序列化存盘、在检查器中编辑,选 Resource;
- 要完全自控内存(不走引用计数、手动 free),选 Object——高级用法且危险,慎用。
小结
- 三种类形式:无名类按路径 extends 或 load 后 .new();class_name 全局注册(PascalCase,@icon 自定义图标,可与 extends 同行,Editor 开头的名字不进新建节点对话框);内部类 class 声明,外部用 MyClass.MyInnerClass.new(),可嵌套。
- new() 调用 _init 构造;self 用于区分同名变量与链式调用;成员访问沿继承链向上,比数组字典慢。
- _init 有必填参数的类只能代码实例化(PackedScene.instantiate、duplicate 会失败);_static_init 类加载时执行一次;无析构函数,用 _notification 监听 NOTIFICATION_PREDELETE 清理。
- extends 四形式:全局类、文件路径、其他文件的内部类、与 class_name 同行;不允许多重继承,默认继承 RefCounted;子类不能声明与父类同名不同参的方法(_init 除外)与同名属性;super(x) 与 super.method() 调父类;is 检查继承关系。
- 所有方法默认可重写(无 virtual/override);只应重写下划线开头的内置方法,自定义可重写方法也建议 _ 前缀;@abstract(4.5+)抽象类不能实例化、具体子类必须实现全部抽象方法、可以没有抽象方法、内部类也可抽象。
- 属性访问器分内联与具名函数两种,不能混用;set/get 总是被调用;初始值不经过 setter;访问器内直接用变量名读写不会无限递归,但经其他函数再访问会。
- static var 属于类,所有实例共享;不能用 @export/@onready。
- RefCounted/Resource 引用计数自动释放;Node 必须手动 free()/queue_free();循环引用会泄漏,用 weakref() 弱引用破环;is_instance_valid 检查对象是否已释放。
- 选基类:场景树与信号选 Node 系;纯数据逻辑选 RefCounted;需存盘选 Resource;完全自控内存选 Object(危险)。