十一、动态多态
Covers:多态(抽象类)、多态(接口)
认识多态
多态 就是一个行为可以具有多种形式或效果。在生活中你不可能没见过多态性:同一个行为“着装”,穿着校服你表现得就像是在上学,穿着西装你看起来就像是要出席重大场合,穿着睡衣你看起来就像是刚起床 —— 人就是个衣架子(夸你长得好看),总要接受一件“衣服”,但衣服的种类不是固定的,当获得了一件具体的衣服时,“穿衣服”这一行为就产生不同的效果。
多态分为 静态多态 和 动态多态,静态多态发生在编译时,动态多态发生在运行时,我们这一节只讨论动态多态;C# 的动态多态是对象多态,是依靠引用实现的多态 —— 听不懂就对这几个名词看个眼熟即可,稍后会有例子。
动态多态有两种方式实现:抽象类 和 接口,这是两种特殊的类。
抽象类
“衣服”就和“食品”、“动物”等概念一样,是一大类事物的抽象。你能实例化一个抽象的对象吗?不能,否则就会出现下列欠抽的对话:
“你穿的这是什么衣服啊?”
“我穿的是 衣服~”
“你吃的是什么啊?”
“我在吃 食品~”
“你家养宠物吗?”
“养啊,养了一只 动物~”
你很快发现,上面这些抽象的概念,如果从中继承出具体的事物,如“玩偶服”、“牛肉汉堡”、“哈士奇”,带入上面的加粗部分,那么对话就正常了。由此我们可以总结出一句堪称哲学的真理:抽象的类不能也不应该被实例化,其存在的意义就是被继承 —— 恭喜你掌握了本节最重要的知识点。
像上面这种抽象的类就叫做 抽象类,C# 用 abstract
关键字来定义抽象类,抽象类还可以具有抽象方法,也用这个关键字来定义,抽象方法不能具有执行体(要直接在函数签名后面写个分号);在抽象类之外不能定义抽象方法。
派生自抽象类的子类,要重写(实现)基类中的抽象方法。用 override
关键字来定义一个重写方法,且名称、返回值类型、形参表都必须和基类给出的抽象方法一样。
我们写一下本节开头的“穿衣服”例子来演示:
using System;
namespace Polymorphism
{
abstract class Clothing
{
public abstract void Describe();
}
class SchoolUniform : Clothing
{
public override void Describe()
{
Console.WriteLine("You seem like a nerd.");
}
}
class Pyjamas : Clothing
{
public override void Describe()
{
Console.WriteLine("You feel like going to bed.");
}
}
class PinruArmor : Clothing
{
public override void Describe()
{
Console.WriteLine("How come you're in Pin-ru's outfits?");
}
}
class ClothingWearer
{
public void PutOn(Clothing cloth)
{
cloth.Describe();
}
}
class Program
{
static void Main()
{
var you = new ClothingWearer();
Clothing[] wardrobe =
{
new SchoolUniform(),
new Pyjamas(),
new PinruArmor()
};
foreach (var clothing in wardrobe)
{
you.PutOn(clothing);
}
}
}
}
我们先定义了所有衣服的抽象基类 Clothing,它具有抽象方法 Describe,预期的行为是向控制台打印一句对“穿衣体验”的描述;派生出三种不同的衣服,每种衣服重写 Describe 方法,产生自己独特的描述,这些派生类可以实例化;我们写了一个简单的类 ClothingWearer 用来穿上这些衣服,它有一个方法 PutOn,接受一个 Clothing 类的对象(并未指定是哪种 Clothing,只要是 Clothing 就行),并调用传入对象的 Describe 方法;最后,我们在测试代码中定义了一个 ClothingWearer 对象“you”,定义了一个衣服数组“wardrobe”用来存放衣服,向数组中 new 出三件不同的衣服,并让“you”来 PutOn 所有的衣服。
是不是有点设计游戏内味儿了?
控制台输出:
You seem like a nerd.
You feel like going to bed.
How come you're in Pin-ru's outfits?
需要指出,这里创建数组是为了便于 PutOn 所有衣服,实际上也可以只 new 出某个种类的衣服,直接把衣服对象传递给 PutOn 方法:
var you = new ClothingWearer();
you.PutOn(new Pyjamas());
“You put on new pyjamas”,多么自然的句子……向代码中引入多态后,是否有一瞬间,你觉得程序仿佛拥有了生命?
虚方法
有时抽象和具体的界限并不那么清晰。“狗”既可以作为一种具体的动物被实例化,也可以作为一个抽象的概念被继承。我们常希望某个类本身能够实例化、具有能够执行的方法,同时又保有被继承、方法被重写的潜质。虚方法 是抽象方法的推广,它不仅可以被子类重写,本身也能够执行。
普通类不能具有抽象方法,但可以有虚方法;抽象类既可以有抽象方法,也可以有虚方法。我们 用 virtual
关键字来定义虚方法,下面是一个关于两只狗的例子:
using System;
namespace Learning
{
class Dog
{
public virtual void Bark()
{
Console.WriteLine("Woof!");
}
}
class Husky : Dog
{
public override void Bark()
{
Console.WriteLine("23333");
}
}
class Program
{
static void Main()
{
var lucio = new Dog();
var rex = new Husky();
lucio.Bark();
rex.Bark();
}
}
}
我们定义了 Dog 类,它具有一种“默认”的 Bark 方法,这是一个虚方法,用来在控制台打印狗的叫声;从 Dog 派生出 Husky 类,它重写了 Dog 类虚方法 Bark,使之转而打印另一种独特的叫声;在测试代码中,我们实例化了两只狗,此时可以分别调用二者的 Bark 方法,得到的效果是不一样的,从而实现了动态多态。
有时我们希望在子类中调用已被重写的父类虚方法,使用 base
来指代父类即可:
class Base
{
public virtual void Method()
{
...
}
}
class Deprived : Base
{
public override void Method()
{
base.Method();
...
}
}
接口
接口 是一种特殊的类,它与抽象类非常相似,也不能实例化,最大的区别是:
- 接口名的第一个字母约定为大写字母
I
,这是应用范围很广的规范,不要打破它; - 接口不能包含非静态字段,但 可以包含静态字段、常量和属性;
- 接口不能包含非静态构造函数或析构函数(废话,因为没法实例化),但 可以包含静态构造函数,这种函数用来初始化接口类中的静态字段,我们在之前的“类与对象”一节中介绍过;
- 接口的成员默认是 public 权限,且 C# 语言规范 8.0 之前(非常新,我们不用)不允许定义其权限;
- 任何类只能继承一个类或抽象类,但可以继承任意多的接口,且 只要继承了某一接口,就必须实现其定义的所有方法,且实现的方法必须为公有;
- 抽象类继承接口时,可以将接口实现为抽象的,留给子类去进一步实现,子类只要继承抽象类即可,不需要再直接继承接口就具有接口所带来的一切特性。
说了这么多,其实接口最常见的作用就是规定“继承了它的类必须具有哪些方法”。换言之,它是 程序员之间的一种编程约定:“如果你实现了我的接口,你的类就需要具有哪些功能(方法)”。
我们目前为止都没有讨论“多继承”这个词,它的意思是一个类继承多个(普通)类。多继承是 C++、Python 等一众语言最受人诟病的特性之一,在实际开发中必须用到的场景几乎没有,并且会带来可怕的 菱形继承。在 C# 中不允许多继承,这是为了增强代码的健壮性和可读性。然而 C# 对继承接口的数量没有限制,这是因为接口中不能定义非静态字段,从而不可能为子类实例增加额外的非静态成员(不会更改子类实例的存储结构),而仅仅向子类“注入”了一些方法,因此不会带来多继承的负面影响。
我们可以认为,接口是用于规范一个类必须具有哪些方法的“协定”,一个接口就是一种“概念”,它不带来非静态的数据,所有有关接口的话题都是围绕“方法”展开的,因此 与其说“继承”了一个接口,不如说“实现”了一个接口。
我们用 interface
关键字取代 class 定义接口,接口成员不规定访问权限,也没有 virtual
、abstract
关键字;实现某个接口时,也不需要加 override
关键字。
之前的“服装类”例子就很适合用接口技术改写:
using System;
namespace Game
{
interface IDescribable
{
string Describe();
}
class ConsoleDescriber
{
public static void PrintDescription(IDescribable item)
{
Console.WriteLine(item.Describe());
}
}
abstract class Clothing : IDescribable
{
public abstract string Describe();
}
class Pyjamas : Clothing
{
public override string Describe() => "You feel like going to bed.";
}
}
我们定义接口 IDescribable(可描述的),任何可以描述的东西都应当实现它,它定义了唯一的方法 Describe,返回一个字符串作为一句描述。接下来,我们进行了两方面的“面向接口编程”:
- ConsoleDescriber 类使用了 IDescribable 创造的概念,它无关任何具体(或抽象)的事物,所做的只是提供“字符串与屏幕的交互方法”,它接受一个实现了 IDescribable 的对象,调用其 Describe 方法获取该对象的描述,并调用 System 提供的 API(这里是 Console 类)将描述打印到屏幕上;
- Clothing 类抽象地实现了 IDescribable,把返回字符串的具体内容留给子类去定义;睡衣类 Pyjamas 继承了 Clothing,它的编写极为简单,只要重写抽象方法,返回一个真正的字符串即可(因为是直接返回的方法,还使用了
=>
简写)。通过这两个类,我们不仅定义了“何为衣服”(“衣服是一种可描述的东西”),还定义了一种具体的衣服,并且只要我们喜欢,可以用类似的语法定义无数种衣服 —— 交给策划部门去做吧。
字母 I 加上一个“动词-able”形式的形容词,这是接口名常用的命名方法,可以更好地让别人了解该接口所定义的概念。
现在你的朋友只会简单的 C# 语法,不会用 System.Console 类,更不会继承,他想只用你写的 Clothing 类,和你造的工具 ConsoleDescriber 来做出“穿上衣服、把穿着体验打印到控制台上”的效果。没问题,他写出了 ClothingWearer 类,没有用到一句 Console.WriteLine:
class ClothingWearer
{
public void PutOn(Clothing clothing)
{
ConsoleDescriber.PrintDescription(clothing);
}
}
然后另一个朋友(他不会使用任何静态方法,也不会定义函数)甚至为你俩的成果写了个小场景来测试:
class Program
{
static void Main()
{
var you = new ClothingWearer();
you.PutOn(new Pyjamas());
}
}
控制台输出:
You feel like going to bed.
任务大获成功,并且你们的代码很容易维护:以后要是想加别的服装、想加“裤子类”、“帽子类”、“地图类”……任何可描述的东西,都没问题!—— 一切都是因为你用接口创造了一个概念。
你想说“哪个学过 C# 的不会 Console.WriteLine 啊!”,那么假如 Describer 要与游戏的装备系统 API 协作、负责显示装备的描述呢?
T.B.C.