谈了类,就肯定要谈继承。毕竟GDScript脚本在很多时候也会继承一个节点类。
在GDScript中,我们在脚本文件的开头写上extends Node这样的语句来表示此脚本(类)的基类是谁。
在C#中定义一个类时,在类名后面加上一个冒号,再写上另一个类名就表示此类继承了后者:
class Item
{
public int Id {set; get;}
public string Name {set; get;}
}
class Weapon : Item
{
public int Atk { set; get; }
}
一般来说,基类是更抽象的概念,而子类是更具体的概念。
除了之前提到的public和private,还有一个常用的访问级别是protected。
标注为protected的成员除了在定义它的类中访问之外,还可以在由它派生出来(继承它)的类中访问。在同一个继承链条上的类都可以访问,即子类、子类的子类都可以访问它。
如果你是C++程序员,你可能发现C#的继承语法和C++几乎一样,但是C#在继承类时不区分public、protected、private。C#的继承就相当于public继承,父类中的成员在子类中的访问级别不会发生变化。
Item i = new ();
Weapon w = new Weapon();
i = w;
F(w);
void F(Item i){}
这些行为是合情合理的,因为我们在继承时就是为了表达“is-a”关系。Weapon是Item的子类,就意味着所有的Weapon都也应该是Item。所有需要Item的地方放入一个类型实为Weapon的实例也是没有问题的。
不过,在通过类型为基类的变量、参数引用实为更具体的类的实例时,我们只能访问基类中定义的成员。
GDScript中也是类似的。但还是由于GDScript本质上是动态类型的语言,即使尝试在类型为基类的变量上访问子类中定义的成员,只要此成员存在,那么在运行时就不会报错:
class A:
var n = 1
class B extends A:
var m = 2
var a: A = B.new()
print(a.m)
类似的代码在C#中编译时就会报错。当然你会发现,按照目前的Godot编辑器的行为来看,这里实际上无论把a标记成啥类型或者不标类型,编辑器都会识别到a拿到了一个类型为B的对象,然后在下面的代码中会自动提示B的成员。
C#中所有的类都隐式地继承了object类——你可以随便找个对象然后用blahblah is object去试。“一切皆对象”那确实不是说着玩的。
前面提到,所有的类实际上都可以调用ToString方法转换为字符串,这个方法实际上就定义在object上。因此你定义的一个新类的实例也可以调用这个方法,只不过它不一定是你想要的效果,默认情况下它会直接输出你的类名。
在Godot中,也存在一个叫Object的类型,引擎中的所有“类”都继承了它。但实际上按照Godot的设计,并不是所有的东西都是一个类的实例。例如整数、信号这些内置类型都不属于Object类型。Godot中真正可以引用任意类型的值的类型实际上是Variant,如果你不给一个变量标注类型,就相当于它是Variant类型。本质上Godot将值的类型分成了Variant.Type中定义的这些类别。 你的教科书可能会讲,我们通过继承和派生来表达各个类之间、实例和类之间的is-a关系。你可以在C#中使用is关键字来判断某个对象是不是某个类型的:
object o = new Item();
Console.WriteLine(o is Weapon);
if (o is Item i)
{
Console.WriteLine($"It's an item called {i.Name}");
}
这实际上是C#模式匹配语法的一部分,类型放在这里会作为所谓的类型模式,即当给定的对象属于某个类型时就会匹配成功。整个表达式的返回值是bool,所以你可以很自然地把它用到if的条件中。
o is Item i这种写法也很好用。它的意思就是如果o成功和Item类型匹配,那么就把它的值绑定到变量i。这样一来就可以在if后面的代码块中通过类型为Item的变量i来访问定义在Item上的成员了。
GDScript中也有is运算符,可以用类似的语法进行类型判别。
正如前面所说,我们可以通过一个类型为基类类型的变量去引用一个实为子类的对象,但是通过基类类型的变量只能访问基类中定义的成员。
没毛病,因为所有汉堡都是食物,但是并不是所有食物都是汉堡。我们不能假定一个食物可以提供和面包胚相关的属性。
但是有些时候我们可能比编译器知道得更多,我们可以断定某个通过基类类型变量引用的对象实际上属于某个子类:
Item i = new Weapon();
Console.WriteLine((i as Weapon).Atk);
as可以将一个类型的对象“转换”成另一个类型——大家都爱这么说,但是实际上什么转换都没有发生,对象本身并没有变。通过不同的(但兼容的)类型去引用某个对象,实际上只是观察角度的改变。
从基类类型转换为子类类型的过程叫downcast,即向下转换。由于这个过程可能失败,as在转换失败时会返回null:
object o = new Player();
Console.WriteLine(o as Weapon);
另一个方向的转换叫upcast,但是由于语言本身的设计,这种转换一般是不需要的。
GDScript中也存在as运算符,和C#中的as行为基本一致。
Item i = new Weapon();
Console.WriteLine((Weapon)(i).Atk);
和使用as的区别在于,显式转换在失败时会直接引发异常。
当一个方法标记为virtual,就表明此方法可以在其派生类中被重写(override)。“可以”的意思是,你可以重写也可以保持原样。
恰好,ToString就是这样一个方法。其默认的行为在很多时候用起来在调试时并不方便,更别提真正地把一个对象转换成有用的字符串了。如果我们要重写一个方法,我们要这样写:
class Item
{
// 略去其它定义
public override string ToString()
{
return $"{Id} {Name}\nDescription: {Description}\n";
}
}
和定义一般的方法唯一的区别在于多了个override关键字。这个关键字就表明这个方法的定义就是在重写一个基类中对应的方法。
重写方法时,方法的签名必须和基类的定义保持一致。即方法的名称、参数列表、返回值类型、访问级别都必须和基类中的定义保持一致。在实际编写代码的过程中,你也不用老老实实地从头打到尾。一般你写一个override之后,你的IDE和编辑器就会提示可以重写的方法。
我们已经了解到,属性的定义在很大程度上也就是定义了对应存取的两个方法。属性也可以标记为virtual,也可以重写:
class A
{
protected int n;
public virtual int N { protected set => n = value; get => n; }
}
class B : A
{
public override int N
{
protected set
{
Console.WriteLine($"N set to {value}");
n = value;
}
get => n;
}
}
如果你不需要完全覆盖虚方法在基类的实现中所做的工作,你也可以按需调用基类实现的版本:
// 继承Item的Weapon类
public override string ToString()
{
return $"{base.ToString()}Atk: {Atk}";
}
通过base关键字可以调用各种在基类中定义的成员。
GDScript中使用的是super关键字,作用类似。
继承和重写方法的好处在于,我可以在有必要的时候忽略父类和子类的区别,又在有必要的时候让它们的成员自发地有不同的行为。
假设我把一系列道具放在一个数组(背包)里面。由于C#的数组要求保存的元素必须都属于同一个类型,所以我们只能把数组的元素类型定义为基类。但是,由于继承关系的存在,我们实际上可以放入从Item类派生出来的各个类型的实例:
Item i = new Item();
// 假设这些类存在且都继承了Item
Weapon w = new Weapon();
SkillScroll s = new SkillScroll();
Item[] items = [i, w, s];
很好,所有东西都一股脑地装进去了,数组也不管它们到底是啥,只要它们“是道具”就行了。
foreach(var i in items)
{
items.Use();
}
如果Use是定义在Item上的一个虚方法,且各个类都实现了自己的版本。那么在上面这样的代码中,在运行时它会“非常聪明地”调用具体的道具实例对应真正的类型上定义的具体的版本,而不是调用Item上的版本。
这就是所谓多态(polymorphism)的一种体现。
有些时候一个基类是真的很抽象,以至于没必要、甚至不应该直接构造基类的实例。
比如说“动物”。我们有很多种方式去定义动物的分类、去构造它的各种子类。食蚁兽、西伯利亚雪橇犬都是很具体的动物,但是并不存在一种动物就是“动物”。
abstract class Animal
{
public stting Name {set; get;}
}
抽象类不能被实例化,但是我们依然可以定义类型为抽象类的变量。正如我反复强调的,变量不是对象也不存储对象,它只是引用一个对象,并通过它的类型来观察这个对象。
抽象类中可以定义抽象方法。抽象方法不允许有方法主体,用传统一点的术语来说就是它只能有声明,不能有定义。抽象类中定义的抽象方法,就是要求子类在继承此类时必须实现此方法。一般适用于一些基类无法实现或者没什么意义的方法。
abstract class Animal
{
// ...
public abstract void Bark();
}
class Dog : Animal
{
public override void Bark()
{
Console.WriteLine("Woof");
}
}
class Husky : Dog
{
public override void Bark()
{
Console.WriteLine("Woooooooooo");
}
}
在子类中,抽象方法的实现会用到和重写虚方法一样的语法即使用override关键字。
Dog类实现了Animal的抽象方法,Husky本身是不用重新实现的。但是如果有必要也可以进行重写,抽象方法天生就是virtual的。
评论区
共 条评论热门最新