多态
本教程共 100 篇 · 第 42 篇 · 更新于 2026-07-31 · 约 11 分钟阅读
42. 多态
本节目标:理解“基类引用指向派生类对象”,用
is/as安全转换,看清运行时绑定。
什么是多态
多态(polymorphism)字面是“多种形态”。同一段代码,作用于不同类型的对象,表现出不同行为。它是重写的自然延伸。
核心做法:把变量声明为基类(或接口)类型,却让它引用派生类的实例。变量“看着像”基类,实际“内在是”派生类。这种手法让代码对新增类型“敞开怀抱”。
为什么需要多态
先说动机。假设没有多态,每加一种动物,你都得在循环里写 if (是狗) ... else if (是猫) ...。动物种类一多,这段代码会膨胀得难以维护,而且每次加类型都要回头改这段“主流程”。
多态把这种“变化点”封装进各自类型内部。调用方代码稳定不变,新类型即插即用。这正是著名的“对扩展开放、对修改关闭(开闭原则)”的落地方式——你不用动老代码,就能接进新功能。
Note多态成立的三个前提:①有继承或接口实现关系;②基类/接口把成员标成
virtual(方法重写时);③调用方用基类或接口类型引用对象。缺一个,多态都不成立。
基类引用指向派生类
继续用动物例子。把各种动物放进一个列表,统一当作 Animal 处理。
namespace CSharpDemo;
class Animal
{
public virtual string MakeSound() => "动物在叫";
}
class Dog : Animal
{
public override string MakeSound() => "汪汪";
}
class Cat : Animal
{
public override string MakeSound() => "喵喵";
}
List<Animal> animals = [new Dog(), new Cat(), new Dog()];
foreach (var a in animals)
{
Console.WriteLine(a.MakeSound());
}
循环里完全不知道 a 到底是狗还是猫,却能得到正确叫声。新增一种动物也不用改循环,只管往列表里加。这就是多态带来的“可扩展性(extensibility)”。
方法也能“面向基类”
不止变量,方法参数也可以声明为基类。这样同一个方法能处理任何派生类。
namespace CSharpDemo;
void Introduce(Animal a) => Console.WriteLine(a.MakeSound());
Introduce(new Dog());
Introduce(new Cat());
Introduce 只认识 Animal,却能对狗对猫各有正确表现。这是多态最常见的落地形态:把差异锁在类型内部,对外只暴露统一接口。
接口也能带来多态
多态不只来自类继承。接口(interface)同样支持多态:用接口类型作变量,指向任意实现类,效果完全一致。
namespace CSharpDemo;
interface IAnimal
{
string MakeSound();
}
class Dog : IAnimal
{
public string MakeSound() => "汪汪";
}
class Cat : IAnimal
{
public string MakeSound() => "喵喵";
}
List<IAnimal> zoo = [new Dog(), new Cat()];
foreach (var a in zoo)
{
Console.WriteLine(a.MakeSound());
}
IAnimal a = new Dog(); 也是“编译期接口、运行期具体类”。当你需要跨不相关类型共享能力时,接口比抽象类更合适(详见第 44、45 章)。
多态为什么好用
有了多态,差异被“封装”进各自重写的方法里。调用方代码稳定,新类型即插即用。
Tip多态的价值不在“炫技”,而在让主流程代码稳定。把变化点下沉到派生类,是大型项目可维护的关键。
编译期类型与运行期类型
一个变量其实有两个类型。编译期类型(声明类型)决定“能调用哪些成员”;运行期类型(真实实例)决定“虚方法执行哪段代码”。
namespace CSharpDemo;
Animal a = new Dog(); // 编译期 Animal,运行期 Dog
a.MakeSound(); // 执行 Dog 的版本
正因为二者可以不同,多态才成立。接口的多态同理。
is 与 as 类型检查
有时需要针对特定派生类做特殊处理。先用 is 判断类型,再用 as 转换。
namespace CSharpDemo;
foreach (var a in animals)
{
if (a is Dog d)
{
Console.WriteLine("这是狗,还会摇尾巴");
}
else if (a is Cat c)
{
Console.WriteLine("这是猫,会抓老鼠");
}
}
is 模式(pattern)判断的同时把对象赋给变量 d、c,一步到位。
as 用于转换,失败返回 null 而不是抛异常:
namespace CSharpDemo;
Dog? dog = a as Dog;
if (dog is not null)
{
Console.WriteLine("成功转成狗");
}
Note优先用
is模式判断并取值;需要“转换可能失败且不抛异常”时才用as。两者都避免抛异常,比直接强制转型(Dog)a更安全。
运行时绑定是怎么回事
变量声明类型是 Animal(编译期类型),实际指向 Dog(运行期类型)。调用虚方法时,CLR 看的是运行期类型,所以执行 Dog 的版本。这个过程叫“虚方法分派(virtual dispatch)”。
编译期类型决定“能调用哪些成员”;运行期类型决定“虚方法到底执行哪段代码”。二者可以不同,这是多态成立的底层原因。
Warning不要把多态当成“万能解药”。如果每遇到一个派生类都要
if (is Dog)分支处理,说明多态用得不够彻底——应该把差异直接放进各自重写的方法里,而不是在调用方做类型分流。这种“写满分支”往往意味着行为没下沉干净。
与其他语言客观对比
Java 的多态机制和 C# 几乎一致(都是单继承 + 接口 + 运行时虚分派),只是默认虚化策略不同(Java 默认虚,C# 要写 virtual)。C++ 同样支持虚函数,但语法更接近“显式 virtual + 指针”,且多继承会带来歧义,而 C# 用“单基类 + 多接口”避开了这道坑。Python、JavaScript 这类动态语言则不需要 virtual 关键字,分派天然按运行时类型——但代价是编译期没有类型检查。
新手常见坑
第一,忘了在基类方法写 virtual、派生类写 override,多态就失效,调用的仍是基类版本。
第二,滥用类型判断。如果每遇到一个派生类都要 if (is Dog) 分支处理,说明多态用得不够彻底——应该把差异直接放进各自重写的方法里。
第三,as 之后不判空就直接用,会触发空引用异常。配合 is not null 或模式判断更稳妥。
第四,误以为多态只属于类。接口(interface)同样支持多态:用接口类型作变量,指向任意实现类,效果完全一致。
第五,把“能用基类引用”误解为“能当基类用全部功能”。基类引用只能访问基类声明的成员,派生类特有的成员需要转型才能用。
多态的适度原则
多态很强,但也不是处处都要套。如果一个类型体系只有一两个具体类、且未来几乎不会新增,硬上“基类 + 重写”反而增加间接层,让代码更难读。判断标准很简单:这段代码会不会因为“新增类型”而被反复修改?会,就用多态;不会,直接写清楚反而更好。
动手练一练
- 定义一个接口
IShape带Draw(),让Circle、Square各自实现,放进List<IShape>循环调用,体会接口多态。 - 用
is模式遍历一个List<Animal>,对Dog额外打印“会摇尾巴”,对Cat打印“会抓老鼠”。 - 故意忘写基类的
virtual,观察输出变化,理解“没有虚分派就没有多态”。
要点速记
- 多态 = 基类/接口引用 + 派生类实例 + 虚分派。
- 接口同样能实现多态,适合跨不相关类型。
is模式判断并取值,as失败返null。- 别在调用方写满分支来代替真正的多态。
- 多态的价值是“主流程稳定、新类型即插即用”。
多态让同一份代码适配多种类型,是面向对象最强大的特性之一。接下来我们把它推向更抽象的层面:抽象类。