首页 / C# 入门教程 / 多态

C# 入门教程

多态

本教程共 100 篇 · 第 42 篇 · 更新于 2026-07-31 · 约 11 分钟阅读

C#C# 入门教程面向对象多态

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)判断的同时把对象赋给变量 dc,一步到位。

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)同样支持多态:用接口类型作变量,指向任意实现类,效果完全一致。

第五,把“能用基类引用”误解为“能当基类用全部功能”。基类引用只能访问基类声明的成员,派生类特有的成员需要转型才能用。

多态的适度原则

多态很强,但也不是处处都要套。如果一个类型体系只有一两个具体类、且未来几乎不会新增,硬上“基类 + 重写”反而增加间接层,让代码更难读。判断标准很简单:这段代码会不会因为“新增类型”而被反复修改?会,就用多态;不会,直接写清楚反而更好。

动手练一练

  1. 定义一个接口 IShapeDraw(),让 CircleSquare 各自实现,放进 List<IShape> 循环调用,体会接口多态。
  2. is 模式遍历一个 List<Animal>,对 Dog 额外打印“会摇尾巴”,对 Cat 打印“会抓老鼠”。
  3. 故意忘写基类的 virtual,观察输出变化,理解“没有虚分派就没有多态”。

要点速记

  • 多态 = 基类/接口引用 + 派生类实例 + 虚分派。
  • 接口同样能实现多态,适合跨不相关类型。
  • is 模式判断并取值,as 失败返 null
  • 别在调用方写满分支来代替真正的多态。
  • 多态的价值是“主流程稳定、新类型即插即用”。

多态让同一份代码适配多种类型,是面向对象最强大的特性之一。接下来我们把它推向更抽象的层面:抽象类。