首页 / C# 入门教程 / 方法重写

C# 入门教程

方法重写

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

C#C# 入门教程面向对象方法重写

41. 方法重写

本节目标:学会用 virtual/override 让派生类改写基类方法,并分清“重写”与“隐藏”。

继承让我们复用基类的代码。可现实里,派生类往往要把某个行为改掉。比如基类“动物”会叫,狗叫是“汪汪”,猫叫是“喵喵”。这时就要用到方法重写(method overriding)。

重写的本质,是“子类替换父类某段行为”。它和前面讲的方法重载(overload)不是一回事:重载是同一类里同名不同参数;重写是跨继承链、签名完全相同的替换。二者一个发生在编译期,一个发生在运行期。

为什么需要方法重写

先想清楚“为什么”要设计这个机制。假设没有重写,基类把每个行为都写死,派生类要么全盘接受、要么另起炉灶重新写一遍——复用就断了。

真实世界恰恰是“大同小异”:交通工具都能“启动”,但汽车点火、轮船鸣笛、飞机滑跑,启动动作各不相同。把“启动”留在基类声明、把“怎么做”交给每个派生类,调用方就能统一写 vehicle.Start(),不用关心背后是哪种车。这就是重写存在的根本理由:把“共性”留在父类,“个性”下沉到子类。

Note

重写解决的是“同一份调用代码,对不同对象表现出不同行为”。它和重载解决的问题不同:重载是“同名方法靠参数区分”,重写是“同名方法靠对象真实类型区分”。别把两个概念混为一谈。

virtual 与 override 怎么写

基类用 virtual 标记“此方法允许被改写”。派生类用 override 给出自己的实现。两者签名必须完全一致,包括返回类型和参数列表。

namespace CSharpDemo;

class Animal
{
    public virtual string MakeSound() => "动物在叫";
}

class Dog : Animal
{
    public override string MakeSound() => "汪汪";
}

class Cat : Animal
{
    public override string MakeSound() => "喵喵";
}

Animal dog = new Dog();
Animal cat = new Cat();
Console.WriteLine(dog.MakeSound());
Console.WriteLine(cat.MakeSound());

输出是“汪汪”和“喵喵”。注意变量声明类型是 Animal,但运行时真正执行的是 DogCat 的版本。这就是“运行时绑定”,也是多态的雏形。

Tip

写虚方法时,签名一致性由编译器兜底:一旦在派生类写了 override,签名对不上会直接报错。这比用 new 隐藏(见后文)安全得多,因为隐藏不会报签名错误,容易“悄悄写错”。

base 关键字:在重写里呼叫父类

有时你并不想完全替换父类逻辑,而是在其基础上“加料”。用 base 可以调用被重写的基类版本。

namespace CSharpDemo;

class Animal
{
    public virtual void Show() => Console.WriteLine("我是一只动物");
}

class Dog : Animal
{
    public override void Show()
    {
        base.Show();
        Console.WriteLine("具体来说,我是一只狗");
    }
}

new Dog().Show();

输出两行。base.Show() 先跑父类逻辑,再补上派生类的部分。这是模板方法(见第 43 章)常用的手法。

一个常见误区:base 不等于“构造一个父类对象”。它只是从当前对象上调用那段被覆盖的父类代码,对象自始至终还是那个派生类实例。

重写可以多层传递

重写不是只能一层。派生类的派生类还能继续 override,形成链条。

namespace CSharpDemo;

class Animal
{
    public virtual string Sound() => "动物叫";
}
class Dog : Animal
{
    public override string Sound() => "汪汪";
}
class Bulldog : Dog
{
    public override string Sound() => "低沉的汪汪";
}

Animal a = new Bulldog();
Console.WriteLine(a.Sound()); // 低沉的汪汪

最终执行的是“最远一层”的版本。每一代都可以在上一代基础上继续定制。

Note

重写只发生在继承链条里。两个没有继承关系的类,谈不上重写。重写的方法必须和基类签名完全一致,否则编译器会当成“隐藏”而非“重写”。

sealed 重写:锁死某一层

有时候你希望派生类改写了方法,但不允许再往下被重写。这时在 override 前加 sealed

namespace CSharpDemo;

class Animal
{
    public virtual string MakeSound() => "动物在叫";
}

class Dog : Animal
{
    public sealed override string MakeSound() => "汪汪";
}

// 下面这个类如果试图再 override MakeSound,会编译报错
// class Bulldog : Dog { public override string MakeSound() => "嗷"; }

sealed override 把这一层“焊死”。子类的子类只能继承这个实现,不能再改。

Tip

sealed 既能修饰类(禁止被继承),也能修饰被重写的方法。两者目的都是“封死扩展点”。当你确认某层行为已经稳定、不希望再被改动时,用 sealed 既表达了意图,也帮编译器做去虚化优化。

属性也能被重写

不只方法,virtual/override 同样适用于属性(property)。很多新手以为只有方法能重写,其实属性是“带名称的成员”,一样可以虚化。

namespace CSharpDemo;

class Person
{
    public virtual string Title => "普通人";
}

class Student : Person
{
    public override string Title => "学生";
}

Person p = new Student();
Console.WriteLine(p.Title); // 学生

重写的属性,其类型、读写能力(只读/只写/读写)必须与基类完全一致。想把一个只读属性在子类变成可写是不允许的。

一个更贴近业务的例子

假设有支付渠道,基类定义“付款”流程,各渠道自己实现具体动作。

namespace CSharpDemo;

class Payment
{
    public virtual void Pay(decimal amount) =>
        Console.WriteLine($"付款 {amount:C}");
}

class WeChatPay : Payment
{
    public override void Pay(decimal amount) =>
        Console.WriteLine($"微信支付 {amount:C}");
}

Payment p = new WeChatPay();
p.Pay(9.99m);

调用方只认 Payment,换渠道不用改主流程。这正是“面向基类编程”的好处。

Tip

金额一律用 decimalm 后缀,避免 double 的浮点误差。这是 C# 里处理钱的唯一稳妥选择。

重写 vs 隐藏(new 关键字)

这是新手最容易混的地方。如果派生类写 new 而不是 override,那叫“成员隐藏(hiding)”,不是重写。

namespace CSharpDemo;

class Base
{
    public virtual string Say() => "基类";
}

class Derived : Base
{
    public new string Say() => "派生类隐藏版";
}

Base b = new Derived();
Derived d = new Derived();
Console.WriteLine(b.Say()); // 输出:基类
Console.WriteLine(d.Say()); // 输出:派生类隐藏版

关键的差别在变量类型。声明为 Base 时调用的仍是基类版本,因为隐藏并没有真正“替换”虚方法分派。而 override 无论变量是什么类型,都走派生类版本。

一张表看清两者差异

维度override 重写new 隐藏
基类是否需要 virtual否(也能隐藏非虚成员)
多态是否生效是,按真实类型分派否,按变量声明类型
编译器约束签名必须完全一致仅遮蔽,可不同
适用目的替换行为、支持多态同名但语义无关的新成员
Warning

隐藏用不好很容易埋坑:你把变量声明成基类类型时,隐藏的方法“悄悄失效”,调用的是父类版本。除非你确实只想遮蔽一个无关同名成员,否则应优先用 override

什么时候该标 virtual

不要一上来就给所有方法标 virtualvirtual 有极小的分派开销,更重要的是它会把方法签名“锁定”为可被重写。只有当你预期派生类要定制某个行为时,才把它设为虚方法。

如果一个方法你确定永远不该被替换,保持普通方法反而更清晰、更安全。

Tip

一个实用的判断:这个方法在“未来可能出现的派生类”里,有没有理由表现不同?有,就虚;没有,就普通。过度虚化会让基类契约变得模糊。

与其他语言的客观对比

Java 里方法默认就是“虚”的,除非标 final;C# 则反过来,默认非虚,要虚必须显式写 virtual。两种设计各有取舍:Java 偏向“默认可扩展”,C# 偏向“默认锁定、显式开放”。Kotlin、Swift 也都采用“默认非虚、需标注”的思路,和 C# 一致。

理解了这点,你从别的语言切到 C# 时,就不会疑惑“为什么我的方法没被重写”——八成是忘了写 virtual

新手常见坑

第一,基类和派生类方法签名不一致,编译器会当隐藏处理,而不是报错。加上 override 后签名不对会直接报错,更容易发现错误。

第二,静态方法不能 virtual/override。虚分派依赖对象实例,静态成员没有实例可谈。

第三,override 不能改访问修饰符,可见性必须与基类一致。想收窄或放宽访问级别都做不到。

第四,重写属性时也要用 override,且属性类型、读写能力必须一致。属性同样可以被虚化。

第五,构造器里调用虚方法要小心。若基类构造器调用了虚方法,而派生类字段还没初始化完,可能读到默认值。尽量别在构造器里调虚方法。

最佳实践小结

  • 只在“确实需要被定制”的成员上用 virtual,其余保持普通。
  • 派生类重写时优先用 override,少用 new 隐藏。
  • 想在父类逻辑上增量修改,用 base 调用。
  • 稳定的中间层用 sealed override 锁死,既明确意图又利于优化。
  • 构造器内避免调用虚方法,防止对象未初始化就被使用。

动手练一练

  1. 写一个基类 Shape 带虚方法 Describe() 返回“图形”,让 CircleSquare 各自重写返回自己的描述,并用基类变量循环打印。
  2. Dog 重写 Show() 时用 base.Show() 先打印父类信息,再补一句“我是狗”。
  3. 故意把派生类方法写成 new 而非 override,分别用基类变量和派生类变量调用,观察输出差异,体会隐藏与重写的区别。

本节要点速记

方法重写让“同一份调用代码”表现出不同行为,是面向对象灵活性的根。记住三件套:virtual(开放)、override(替换)、base(复用父类)。下一章我们把它和“基类引用”结合起来,看多态的全貌。