我说的谈对象是说要谈谈面向对象编程。之前”先这么着,后面再解释“的迷雾,到这里要散开一大部分。
依然要强调,面向对象编程在大学里是可以开一门课的,这里只是一篇文章而已。这里我们也只谈Godot采用的基于类的面向对象编程范式。
可喜可贺,这篇文章之后GDScript精要部分很快就要结束了。
对象是类的实例。那啥是类(class)?类就是做蛋糕的模具,一个模子可以倒出来无数个蛋糕,但是我们的蛋糕可以有不同的原料,不同的口味,还可以加上不同的点缀。
用蛋糕模具做蛋糕的过程就叫实例化(动词instantiate,名词instantiation)。实例化的结果就是实例(instance),有时候和对象(特指谈面向对象编程中的对象)互换使用。
摊牌了,GDScript中的所有类型都是类。int是类,String也是类,场景里面出现的所有节点都属于某个类。随便找一个类型,点进文档都能看到class字样:
那为什么要用使用类呢?如果我们只有一些基础类型,我们很难表达一些复杂概念。
道具是游戏中很常见的概念,我们常常需要记录道具相关的信息。比如最基本的道具Id、名称、描述、数量。然这里各个信息的数据类型是明确的,一个道具的信息可以这样表示:
但是我们必然需要定义很多道具,一个道具就定义了四个变量,一百个道具就有四百个变量了。
你可能会想我们用数组来表达,在每个代表道具的数组中依次存入相关信息:
一百个道具也只有一百个数组。这似乎是一种解决方案。我们现在的道具信息只有几个条目,一旦道具信息复杂起来,我们就很难记住数组中的那个索引对应哪个条目。并且一旦位置出错我们的信息就走样了:
你只能期待自己或者其他开发者能够足够细心并且没人使坏。
比数组好一点,至少我们可以通过有意义的名字来获得对应的数据。但是由于字典中的键值对不限制类型,也不能用字典类型来限制里面有没有某个键,因此依然无法保证字典信息的有效性:
此外,我们必然会需要一些函数来操作这些道具信息。为了让这些函数都能操作道具库,我们必须把他们放到一个大家都能拿到的地方,比如“最外面”的作用域。但是这样一来大家都会篡改道具库,我们有时很难搞清楚到底是哪里出错了。
面向对象编程中的类就是一种由我们自己定义的一类数据类型。它能将多种不同的数据作为某种概念的抽象描述。例如“道具”,它在不同的问题中肯定关注点也不一样,但在这里我们关心它是如何在道具库中表示,因此我们需要它有id、名称、描述、数量。因此我们把“道具”抽象、提炼为由它们构成的一种数据类型。
可以看到,类的定义以关键字class(类)开始,随后是类名,冒号意味着下方又是一个代码块。代码块中是我们熟悉的变量定义。在面向对象编程中,类中定义的变量也被称为字段(field)。
这样一个类就定义了一个新的Item类型,每一个Item对象都将拥有这几个字段。这些字段是关于一个个具体的实例的信息,是它们各自特有的而不是大家共有的。
类是模具,为了获得一个具体的实例,我们要从用模具来构造实例。每一个类都有一个特殊的函数叫new,new会构造一个新的实例给我们。类名后面加一个点就可以调用和类对应的new函数。我前面提到过,这个点运算符就相当于说”某某某‘的’什么什么“,稍后我们就会讲到它到底在干什么。
这样我们就得到了一个Item的实例,我们同样可以通过点来访问它的字段:
这些字段使用起来和变量无异,但是它们都属于某个具体的实例并和它们的状态相关。这些字段总是会给我们一个“上下文”。
前面的Item类的定义中,我为每一个字段都没有设置初始值,因此在使用new实例化后,所有字段都是其默认值。数字类型的默认值为0,String的默认值是空字符串:
不过,如果一个类类型的变量没有被初始化,它的默认值是什么呢?
输出面板中显示为<null>。还记得我们上次见到它吗?在GDScript(以及包括C#、Java在内的大量编程语言)中,用关键字null来表示空值——也就是“什么都没有”,我们没有给它初始化。
由于类类型的变量默认即为null,所以在初始化一个变量时我们一般不会用到null。但是我们有时候可能需要主动将某个变量置为null以表示某个东西已经消失或者不再可用了。
另一方面,我们在不确定一个变量是否为null时需要进行null检查。如果在运行时尝试在null上访问它的字段或者进行其他操作是会发生错误的——毕竟你不能凭空变出某个数据出来:
这段代码在运行时会发生错误。Godot会说“无法在Nil上找到索引name”。当然这里有点让人困惑,Nil又是啥呢。Nil实际上和Null是差不多的意思,Nil实际上也是Godot中的一个类型,可以认为null是它唯一的取值。
试图在null上访问字段继而引发错误是如今所有开发者都遇到过的问题。null在很多语言中都有类似概念,只不过名字不一样。比如Python中等效的概念是None。首次引入null的Tony Hoare在2009年将这一发明称为“价值十亿美元的错误”。null实现起来很简单,用起来也很简单,但是大家往往会在不经意间试图在null上访问某个东西继而造成错误。为了避免这类错误,一些语言直接在语法层面消灭了null,可以为空的类型会作为一种单独的类型,否则这个类型的变量绝不可能为空——它至少有一个可用的默认值。
从现在起,点后面出现的函数就应当称为方法了。在类中也可以定义函数,这样的函数称为方法(method)。方法有两大类,分为实例方法和静态方法(也称类方法)。实例方法直接作用于(且只能作用于)具体的实例,它们往往会操作具体实例的各个字段以达成改动实例的目的。
道具自然要使用,我们可以给它定义一个use方法,这里简单输出一行字:
这个点在GDScript的作用就是特性访问(attribute access)。在类中定义的各种字段和方法统称为成员(member),也被称为特性(attribute),通过点运算符来访问某个对象(或者类)的成员。这个点就好比说,“我要以这个对象为上下文来谈这些具体的字段和方法”。
如你所见,在方法中可以直接使用类中定义的字段。方法的代码访问变量时同样会先查找方法中定义的局部变量,如果找不到就到类的定义中去找,一直往上最后到全局作用域。
静态成员有时候也被称为类成员。这类成员和具体的实例无关,但是和类有关。例如可以给道具一个最大数量限制。一方面这个限制是所有实例、所有使用该类的代码需要达成的共识,所以不应该让它绑定到某个具体的实例:
访问时直接提供类名访问,不过也可以通过实例访问。但是无论如何静态变量是实例共享的:
如果修改MAX_COUNT,通过i2访问它也会发现它被修改了。说明静态成员是大家共享的。不过静态字段的值是在游戏从启动到结束之间大家共享的,下次运行游戏它又是初始值。
但是要注意的是,静态方法只能在方法中访问该类中的静态成员、或者其他外部已经定义的变量和函数。而无法访问实例成员——毕竟静态成员不和任何具体实例绑定。
此外在类中也可以定义常量,由于常量本身就是不可变的,所以它默认也就是静态的,但是不需要static关键字:
这里我们就完全可以理解,之前的文章中介绍到的String.num函数。它就是String的一个静态方法,可以从数字构造字符串。因为我们要从数字构造一个新的字符串,所以它和任何现有的字符串是无关的,因此定义为静态方法是合理的。而String类型的length方法就是一个实例方法,它会返回字符串的长度,显然这是针对具体的String实例来说的。
Item每次都要通过new方法构造实例然后再给它的字段赋值有点麻烦。类的new方法在语法上像是静态方法,但是有它的特殊性。我们可以写一个类方法来改变new方法的参数。
这个特殊方法叫_init。顾名思义是用来初始化(initialize)的方法。我们可以提供几个参数来初始化。另一方面我们不提供id作为参数,而是用一个静态变量记录道具的实例数量并作为道具的id在_init中就给它赋值(实际开发中极大可能不会这样做,这里为了演示方便这样写一下):
每次构造道具实例时current_item_id都会加1,然后作为新道具的id。这里出现了一个新关键字self。self关键字顾名思义,它代表当前调用这个方法的实例,实际上大部分时候都不需要用到它。但是需要注意到这里的函数参数name、description和类的字段是同名的。如果不加self指明是哪个name,实例字段name就拿不到name参数的值。
现在new方法也多了这几个参数,如果没有传递参数在编辑时便会报错:
当然这里只是简单给字段赋值,实际上这里可以按需编写其他初始化逻辑。
这样的函数一般称为构造函数(constructor),顾名思义是负责构造(初始化)对象的。
前面定义的Item类是最基本的道具数据。不过游戏中的道具还有更多细分的类目。比如装备。装备需要有相应的攻击或者防御字段,而装备还可以进一步细分为武器和防具。
我们自然可以定义几个单独的类。但是毫无疑问它们之间有太多相同的字段。并且不能体现它们之间的层次关系:
为了减少这种重复且体现它们的关系,这里需要用到面向对象编程的特性之一,继承(inheritance)。
这里的Item是最基本、最抽象的一个概念。这就意味着更具体的道具应当具有它的所有字段(以它为基础)。因此我们将它作为基类(base class),也称为父类(parent class)。装备属于道具的一种,因此它应当继承(inherit)道具类,也可以从另一个方向来说,它由道具类派生而来(derive from)。
在定义类时,类名后可以用extends关键字指明该类的基类:
现在我们用pass留白,看起来它没有任何的成员。不过由于它是Item的子类,在子类中,我们可以直接访问基类中定义的成员:
同时,作为构造函数的_init也被继承了,new方法也需要填入参数才行。
既然是扩展(extends),那么我们就可以在子类中定义更多子类需要的成员:
应用继承之后,我们就可以表达“某某(子类)是某某(基类)”的关系。例如,虽然装备是不同于道具的类,但是装备依然是道具——毕竟它满足道具的定义。因此,在需要一个Item类型的值的地方,填入一个Equipment也是合理的:
我们首先实例化一个装备,然后在下面把它赋值给一个Item变量。这样的操作无论在编辑时还是在运行时都没有问题。这就是因为Equipment是Item的子类。在use_item中也成功调用了use方法,因为use在Item上定义了,这个方法对于Equipment类型的实例来说也是可以调用的。
多态是什么意思,说白了就是同一个成员在不同的类中有不同的定义。例如我们在道具上定义一个“使用”的方法。那么不同类型的道具在使用后应当有不同的效果。
装备的使用一般来说应当是攻击,我们已经定义了attack方法,但是试想一下,一个道具库中保存了一堆道具。我们可以保证它们都是Item(子)类型的对象。在某个菜单的某个选项按下之后,我们要使用它们。我们要怎么知道调用哪个方法呢?
事实是,我们可以不用知道。只要我们为不同的子类定义了同一个成员,Godot会知道如何调用哪个具体类型上定义的方法。比如我们在Euipment上也定义use方法:
它就简单地调用attack方法。然后我们模拟使用道具库中的道具:
可以看到,在装备实例上调用use时实际上调用的是我们在Equipment中定义的use方法——即使我主动为循环中的item变量标注为Item。
这种行为用行话来讲叫多态(polymorphism)。即同一个成员(在一个类的继承链条上的)不同类型中有不同的行为。有了多态的支持,即使我们只通过一个基类来访问某个成员,来自不同子类的对象也可以根据各自不同的方法定义来进行相关工作,而我们只需要用同一个名字就可以了。修改基类方法定义的操作称为重写(override,也称覆盖)。
有时候我们可能不想完全替换掉基类的方法实现,只是想进行一些额外的操作。此时可以借用基类实现的版本,然后再编写自己的代码:
这里修改Equipment的use的定义,先调用Item定义的use。super在这里就是指的当前类的基类。基类、父类有时候也称为超类(super class)。
和之前版本的输出进行对比可以注意到,如果不主动调用基类实现,重写的方法就完全覆盖了基类对应的方法的实现。
直接操作对象的字段存在一些问题,我们无法在外部访问字段时执行额外的操作。比如对于消耗品来说,我们想在增减道具数量时进行一些操作,我们就需要额外调用方法。但是由于是从外部访问的,我们只能期望访问字段的代码能够按照约定调用这些代码:
但是!依然无法控制外部直接访问字段而不调用这个方法。
这里需要引入属性(property)的概念。属性是对字段的包装,用以进一步控制字段的访问。属性还有个好处就是它可以直接通过点语法访问且不需要加括号,这样对外表现得就像一个普通的字段一样。
定义属性时,在变量的类型标注后打上冒号表示代码块开始。下方分别是属性的getter和setter。它们各自就是一个函数定义。由于getter是获得值,所以显然不需要参数(也不能有参数)。而setter必须要一个参数,这个参数的名字是可以随便取的。它就代表设置属性值时出现在等号右边的值。当然C#也有语法层面的属性支持,不过它的setter不用指定参数,value是一个关键字没法修改。
此外也可以直接将已经写好的函数交给属性的get和set关键字,就像这样:
如果getter和setter比较复杂,这样的写法可能会更好。
另外顺带提一个翻译上的问题。attribute在某些语境中和property的含义实际上有重合,在没有歧义的情况下实际上都翻译成“属性”。不过有些时候会将attribute翻译成“特性”,property翻译成“属性”。在C#中,attribute和property都是存在的,但是两者区的联系不密切。而在Python和GDScript中也是同时存在这两个概念,但是两者是相关的。所以我会把attribute说成特性。
属性可以只有getter。有些属性可能是通过类中的其他属性或方法计算出来的,它自己并没有一个单独的变量来支持它(有时候会称这种字段为backing field),也可能不需要这样一个变量。
例如要定义一个圆,半径是必须的。而直径、周长虽是常用属性,但它们可以直接根据半径求得,而不需要单独用一个变量来代表它们:
在其他支持属性的编程语言中,属性还可以用来防止字段被外部修改。但是Python和GDScript并不能控制外部访问字段的权限,所以用只有getter的属性来控制访问权限也是无法实现且没必要的。
如果在某个时候你加入了一种新的东西,它既不是装备也不是消耗品,甚至不是道具,比如它可能是载具,可能是随从——但是你坚持把它放在道具库中,并且可以使用。此时如果你想对它们统一使用use方法来使用,那么就不能把参数约束为Item了——毕竟你不再能保证它是Item(子)类型。
如果这样写,由于use_something的参数已经注明为Item,在游戏运行时在循环中会发现v不是Item,因此会发生运行时错误。
我们可以做的是直接省略类型标注。这是前面我提到的省略类型标注的正确用法之一。
函数内部的代码不用修改。只要传入的参数上有use方法,并且它的参数列表都一样(这里是没有参数),那么对use的调用就不会出现问题:
当然,没有类型标注就意味着在运行游戏之前不会有类型检查,也不会根据类型进行的自动补全提示。并且如果运行时找不到use方法它同样会报错。但是如果确实需要这样的灵活性,这样的付出也是值得的。
如果一个东西像鸭子一样走路、会嘎嘎叫,那它就是鸭子。
这种类型系统的特征称为鸭子类型(duck typing)。编程语言会无视对象具体所属类型,在运行时直接在对象上尝试访问指定的成员——只要它有。
在Godot中,一个文件就是一个类定义。现在新建脚本时选择基类也不再神秘了。脚本模板开头的extends也就是指明这个脚本定义的类是继承自哪个节点。
脚本中定义的所有函数都是方法。这些非静态变量也就是实例变量。
模板中的_ready和_process方法都是在重写Node节点中对应的方法。Node类是所有Godot节点的基类。
你可能会问,按照前面说的方法重写的行为,如果不主动调用基类的实现,那么它们就不会调用,那这样一来,我们自己编写的脚本不就完全覆盖引擎内置节点的初始化代码,进而没了它们默认的功能吗?
实际上这些以下划线开头的可以重写的方法有它们的特殊性。它们会响应Godot中的通知,这些通知是引擎的一些特殊事件。当READY通知发生时,场景中的节点会先让它的子节点的_ready被调用,然后调用自己的_ready。在响应READY通知的代码中,它会调用脚本基类中定义的_ready,因此我们就不需要自己主动调用基类的这些方法了。不过在C#中是需要手动调用基类的方法的。
此外,我们前面用class定义的类实际上在Godot中称为内部类(inner class)。它只在某个脚本文件内部可见。尽管前面提到的“一个脚本就是一个类”,但是如果不额外编写一行代码的话,我们其实还是无法在其他脚本中使用另外一个脚本所定义的节点类。要将一个脚本所定义的类暴露出来,我们需要通过class_name关键字来给它取一个外部可用的名字。例如在之前为logo编写的脚本中写上:
随后,在主场景中即可将某个变量标注为Logo类型:
这样一来,如果这个节点有logo的脚本,我们就可以将场它转换为我们定义Logo类,然后更安全地我们在其中定义的各种方法和属性了!
其实上面说这些的也是骗人的——但是并不妨碍你这样去理解、去使用Godot。
在GDScript中,一个脚本并不是一个真正意义上的基于内置节点类创建的新类——至少不是一个C++意义上的类。
在Godot中,脚本和各种图片、音乐一样属于一种资源。它们在节点上表现为Script和ScriptInstance。Script类直接继承Resource类,这表明了它的身份。Script类的两个直接子类就是GDScript和CSharpScript。Node类继承了Object类,很多和属性、方法访问的代码都在这里。GDScript中定义的各种类型、属性、方法最终都会成为ClassDB中的数据。在节点身上通过脚本调用访问各种成员最终都会从ClassDB中获得。
用一个赋值为数字的变量给另一个变量赋值之后,修改它并不影响原本的变量:
在之前讲到变量的时候我提到过,变量并非”保存了一个对象“,它们在很多时候表现得像是对象的名字。不同的名字可以代表同一个对象。用行话来说,它们是对具体对象的引用(reference)。在某些语言中,也叫指针(pointer)。这种行为能够避免不必要的对象复制,减少消耗。
但是有些时候我们确实需要复制或者构造一个新的对象来避免修改原本的对象。数值类型可以说是最常用的类型,对它们最主要的操作就是进行算术运算。但是我们并不希望一个指向数字的变量在进行一些运算之后就变了。
Godot的大部分内置类型都是在栈上分配的,这就意味着它们会和数字一样在赋值时克隆一个副本给新的变量而不是让多个变量引用同一个对象实例。当然,内置类型中的Object、Array、Dictionary是例外。毕竟数组和字典往往会保存多个元素在其中,随意复制的消耗很大。 目前GDScript中的自定义类型没法很方便地像内置类型一样实现这种行为。不过在C#中可以直接定义struct来实现各种自定义数据结构来获得默认复制的行为。
如前所述子类实例可以通过基类实例来引用,但是有些地方我们可能需要知道对象的具体类型。比如NPC要求你给它一瓶药水,但是你却选择交出了一把刀。
在GDScript中可以使用is运算符来检查某个对象是否是某种类型:
既然我们已经通过检查得到某对象是某种类型,那么我们就可以在它身上访问这种类型上定义的成员。由于这个函数的参数类型是Item,所以在试图调用Consumable上的成员时不会出现自动补全提示。当然由于有类型检查的保护,Godot会很贴心地根据你检查的类型为你自动补全转换后的成员。
不过有些时候我们可能需要进行手动类型转换。as运算符顾名思义,可以将某个类型的对象转换为指定类型:
你可能会问,as转换和String.num这种转换有啥区别呢?实际上这两种转换对应着不同的说法。as进行的叫cast(ing),它是在同一条继承链上的类型相互转换。而String.num这种转换叫convert(ion),往往意味着在两种不直接相关(没有继承关系)的类型之间转换。
在这篇文章中,我们对主流编程范式之一的面向对象编程有了一个初步的了解。这对我们后续充分利用Godot的各种节点和功能来说是非常必要的基础。不过即使还有些不明白也没关系,随着后续结合更具体的例子来学习就会有更深刻的理解!
评论区
共 11 条评论热门最新