撰写了文章 发布于 2020-03-02 17:23:24
从C#开始的编程入门——多态
即便是双胞胎姐妹,实际上某些特征也是不一样的。
多态(polymorphism)指的是同一个东西在不同情况下有不同的行为。这个单词看起来很哲学,但实际上没那么复杂。
我们依然使用前面的Animal家族做例子。 Animal里面有一个SaySomething方法。Dog继承了这个方法。现在我们可以在Dog上调用这个方法,但是它的行为和Animal上调用完全一样。以我的经验来看,一般来说狗是会汪汪叫的。我们应该尝试把Dog的SaySomething改成汪汪叫。
我们在Dog里面再定义一个SaySomething。
public void SaySomething()
{
Console.WriteLine(“Woof! I’m a dog.”);
}
现在你的IDE或者编辑器可能会提醒你使用new关键字,不过现在暂时不要管它。
如果我们这样调用这个方法。会得到如下结果。请注意这样的行为,因为事情并不是总是这样。
Animal animal = new Animal(“Name”);
Dog dog = new Dog();
animal.SaySomething(); // I’m just an animal.
dog.SaySomething(); // Woof! I’m a dog.
重识引用
对于引用类型来说,变量类型并不总是和它引用的实例真正的类型一致。
再看继承
Animal是一个抽象的、笼统的概念。 Dog则是一种更加具体的Animal。
我们可以说Dog is an Animal。继承实际上就是表达这种从抽象到具体,从笼统到细致,从Animal到Dog,从面包到牛角面包,从显卡到RTX2080这种关系。
静态类型和动态类型
在谈编程语言的时候,提到静态(static)和动态(dynamic)往往是在谈论编译时(compile time)和运行时(runtime)。
尽管C#采用的是和C++一样的静态类型系统,但是都无法避免地,有些信息只能在运行时确定。
结合前面讲的,Dog is an Animal。我们也提到过,Dog实例实际上也有Animal实例的部分(具体在内存中不一定是这样安排的,但是我们在抽象上可以这样理解)。
那么依据这样的逻辑,似乎这样的代码也是成立的。
Animal animal = new Dog();
事实上,这样的代码的确是合法的。Dog必然是一个Animal。
对于C#的类型系统,一个变量的类型必须在编译时确定下来。所谓静态类型指的就是变量在声明时的类型。
但是我们无法保证这个变量在运行时拿到的究竟是一个Animal还是Animal的子类或者孙子类或者经过好几次继承的子类。而运行时变量引用的对象的“真正”的类型,就是动态类型。
可访问成员取决于静态类型
当我们通过某种类型去引用一个在其继承链上的实例,最终能够访问的成员取决于静态类型。无论Animal类型的变量引用的是继承链上的哪个类的实例,它最终都只能访问Animal中暴露的成员。
Animal animal = new Dog();
animal.SaySomething(); // OK
animal.Walk(); // Error
因为当我们声明一个Animal类型的变量,并尝试使用Animal提供的成员的时候,我们只关心Animal的行为,而不关心它是不是其他的、更加具体的类。
我们回到SaySomething。紧接着上面的初始化语句,试看下面的代码,猜测一下究竟会有怎样的输出。
Animal animal = new Dog();
animal.SaySomething();
我们Dog的SaySomething直接隐藏了父类的版本,对于这样的方法,最终SaySomething只会根据静态类型进行调用。
如果仔细想想,这有一点不符合我们前面的逻辑。如果一个对象真的是一个Dog,那么为什么它不汪汪叫呢?即便我们把它当作Animal也无法改变堆中有一个Dog的事实。如果我们召集动物王国所有的动物,即便我们不关心或者根本叫不出来它们的名字,但是我们也不希望,也不可能召集起来的动物全都只会说“I’m just an animal.”
那么究竟怎么让Animal类型的引用根据对象的实际类型调用方法呢?接下来我们看看真正的多态。
虚方法和重写
只有当一个方法被标记为virtual的方法,在运行时才会根据动态类型调用方法。子类改写父类方法行为的行为,我们称为重写(override)。
我们把Animal中的SaySomething标记为virtual。
public virtual void SaySomething()
{
System.Console.WriteLine(“I’m just an animal.”);
}
然后把Dog的SaySomething标记为override,表示我们重写了父类的虚方法。(基类中标记了virtual的方法,在子类中就不用、也不允许使用virtual了)
public override void SaySomething()
{
System.Console.WriteLine(“Woof!I’m a dog.”);
}
我们来测试下面的代码。
Animal animal = new Dog(“Name”);
animal.SaySomething(); // Woof! I’m a dog.
现在无论我们怎么引用,调用时总是会调用它真正的类型中重写的方法版本。 同样的方法,在一个继承链中不同的类型中有不同的实现,而我们调用时使用同样的方法去调用,这就是多态的体现。
overload与override:overload的中文和英文看起来都一点像,不要搞混。overload一般译作重载。指的是同一个作用域中的同一个名称的方法可以有不同签名的不同版本。override一般译作重写或者覆盖、改写。指的是类的继承链中对父类方法实现的修改。
类类型的类型转换与判断
在继承链上,或者说在is a关系中,子类到父类的转换永远是合情合理的。我们可以直接赋值,而不需要强制转换。
Animal animal;
Dog dog = new Dog();
animal = dog;
但是反过来则不一样。因为一个层次较浅的类引用的不一定是某个层次更深的类。就像狗一定是动物,但动物不一定是狗。
这个时候如果你十分肯定他是你需要的子类,你可以使用as进行向下转换。
is运算符
is运算符实际上是用作模式匹配,但这里我们把它当作一个用来判断类型的运算符。下面的代码可以验证我们陈述的is a关系是不是像我们想的那样。
Animal animal = new Animal(“Animal”);
Dog dog = new Dog();
Animal animalDog = new Dog();
System.Console.WriteLine(animal is Animal);
System.Console.WriteLine(dog is Animal);
System.Console.WriteLine(dog is Dog);
System.Console.WriteLine(animal is Dog);
System.Console.WriteLine(animalDog is Animal);
System.Console.WriteLine(animalDog is Dog);
多态让程序进一步抽象。在我们不知道,或者不关心一个对象的具体类型时,我们仍然可以通过父类来统一引用它们,并且调用我们关心的方法,然后让它们各自按照自己的逻辑去处理。
类型转换运算符
括号除了是方法调用运算符(())之外,它有另一个作用就是类型转换。由于C#在设计上需要保证类型安全,所以对于类型之间的转换,要么提供更加明确、严格方法,要么需要用户自行实现类型转换运算符的重载。
用户可以自行定义的类型转换分为两种,显式的和隐式的。简单来说,在表达式中遇到可以转换的类型就自动转换的称为隐式,需要使用括号进行转换的就是显式。
int i = 1;
long l = i; // implicit
int n = (int)l; // explicit
int到long总是安全的,因为long的范围更大。但是long到int并不总会成功,因为有可能long类型变量的值超出了int的范围。
在某些时候,使用类型转换运算符的显式转换又被称为“强制类型转换”。和名字暗示的一样,强制类型转换往往可能出现问题,甚至直接引发异常。因此对于这样的转换隐式转换无法提醒开发者这是可能出现问题的转换。有关类型转换运算符的更多内容,参见这里。
这样的代码在编译时不会发生异常,但是运行时必然异常。
Dog dog = (Dog)new Animal(“”);
as运算符
as运算符只能在类类型上使用。我们提到,狗一定是动物但动物不一定是狗。对于你只拿到一个Animal类型的情况(你调用的方法可能不关心它是怎样的动物,但是你却可能只想要狗),你这个时候却只想要狗,并且使用狗才有的方法或属性,那么你就需要使用as来进行转换。(假设下面的GetAnimal方法返回一个Animal类型的引用)
if (GetAnimal() as Dog != null)
{
System.Console.WriteLine(“I got a dog!”);
}
as会尝试把一个类类型转换成另一个更加具体的类型。和强制转换不一样的是,as是相对安全的。如果可以完成转换,那么返回转换后的引用,如果不能,则表达式的值为null。你可以根据表达式的值判断转换是否成功,或者结合null条件运算符来访问成员。
抽象基类
动物本身就是一个相当抽象和笼统的概念。如果我告诉你前面有一条狗,那么你大体能够在脑海中浮现出一条狗的形象。
而如果我突然冷不丁地说前面有个动物,即使你不骂我深井冰,也会非常迷惑地问我是什么动物。
也就是说,我们可能根本不会去实例化一个Animal对象,因为它太抽象了,根本没有意义。
这个时候我们应该把Animal定义为抽象类。
public abstract class Animal // 其他不变,省略
在类的定义上使用abstract表示这是一个抽象类。抽象类永远不会,也不能被直接实例化(抽象类的子类在实例化时,抽象基类的构造函数也会被调用以构造基类部分)。
Animal animal = new Animal(“Animal”); // Error!
除此之外,因为类本身是抽象的,因此它的成员也可以只声明而不定义。进而要求其子类必须实现和重写这些方法。
public abstract void SaySomething();
如果方法被标注为abstract,那么就不用再写virtual了,因为它必须由子类实现。这个时候要记得尽管不需要实现,但是必须在结尾加上分号。并且一旦被标注为abstract就不能提供方法的实现,如果你需要提供一个默认的实现供子类使用 ,那么请把它定义为虚方法而不是抽象方法。
下一篇文章中,我们介绍更加彻底的抽象:接口。
目录