在面向对象程序设计中,“继承”(Inheritance)是一种通过定义基类(父类)与派生类(子类)来抽象和复用共性逻辑的机制。 当多个类具备相同的属性或行为时,可以将这些共性提取到基类中,子类则通过继承的方式获得这些通用成员,并可根据需求进行扩展或重写,从而加强类型系统的表达能力和维护性。
C# 语言通过关键字 : 基类名 实现继承。通过继承,派生类不仅自动获得基类已实现的字段和方法,还可以按照"对外一致,内部多样"的原则,遵循基类对外接口(如方法签名、属性),而实现或重写具体行为。这形成了多态(Polymorphism)和代码复用的基础。
例如,假设我们开发一个画图应用,初始只支持圆形(Circle),后续加入矩形(Rectangle)。很快可以发现,两者都包含位置、颜色等属性,且具备类似的 Draw() 方法,对应的绘制流程相似,仅在实际渲染时的具体实现不同。
若直接复制粘贴实现,会导致冗余和维护困难;采用继承方式,将共性逻辑抽象到 Shape 基类,再由各派生类实现特有逻辑,可显著提升代码的内聚性与可扩展性。这正是继承在实际工程中的核心价值所在——抽象共性,分离变异。

我们用一个简单的例子来理解继承:
|using System; // 基类:形状 class Shape { public string Color { get; private set; } = "Black"; // 共享的状态 public void SetColor(string color) { Color = string.IsNullOrWhiteSpace(color) ? "Black" : color.Trim(); } public virtual void Draw() // 可被重写的行为 { Console.WriteLine($"Draw a {Color} shape"); } } // 派生类:圆形 class Circle : Shape { public int Radius { get; } public Circle(int radius) => Radius = radius; public override void Draw() // 重写:换个具体画法 { Console.WriteLine($"Draw a {Color} circle with r={Radius}"); } } // 派生类:矩形 class Rectangle : Shape { public int Width { get; } public int Height { get; } public Rectangle(int w, int h) { Width = w; Height = h; } public override void Draw() { Console.WriteLine($"Draw a {Color} rectangle {Width}x{Height}"); } } class Program { static void Main() { Shape s = new Shape(); s.Draw(); var c = new Circle(10); c.SetColor("Red"); c.Draw(); var r = new Rectangle(3, 5); r.SetColor("Blue"); r.Draw(); } }
: 指定派生关系,如 class Circle : Shape;virtual 标注允许在子类用 override 改写行为;virtual 的方法在子类里默认不可改写(可用 new 隐藏,但非多态)。在我们用 C# 创建一个子类对象的时候,其实背后会先帮我们把基类的构造器调用一遍,然后才轮到子类自己的构造器执行。这样做的好处是,基类里定义的那些属性和初始化逻辑能先被妥善处理好,子类再在这个基础上“锦上添花”。
有时候,基类的构造器需要一些参数,比如名字、初始值之类的,这时我们可以在子类构造器的参数列表后面用 : base(参数) 这种写法,把参数直接传递给基类的构造器。这样,基类就能顺利拿到它需要的信息,整个对象的初始化过程也会变得很自然。
举个例子:假如我们有一个“动物”基类,它的构造器需要一个名字参数。我们再写一个“狗”子类,狗除了名字,还想记录它是不是“乖宝宝”。这时我们就可以在狗的构造器里用 : base(name),把名字传给基类的构造器,让基类先把名字处理好,然后狗再处理自己的“乖宝宝”属性。
总之,子类对象的创建过程其实是“先基后子”,而且我们可以用 base(...) 这种方式,把需要的信息顺利地传递给基类,让整个继承链条上的每一环都能各司其职地完成初始化。
|using System; class Animal { public string Name { get; } public Animal(string name) => Name = name ?? "(unknown)"; } class Dog : Animal { public bool IsGoodBoy { get; } public Dog
如果基类有无参构造器且可访问,子类可以不显式写 base();否则必须显式选择构造器。
sealed在 C# 里,如果我们希望某个方法在子类中可以被重新实现(也就是“重写”),就需要在基类里用 virtual 关键字把它声明为“可重写的”。
这样,子类就能用 override 关键字来提供自己的实现,覆盖掉基类的默认行为。
不过,有时候我们可能不希望这种“重写”无限制地传下去。比如说,某个子类已经把方法改成了最合适的样子,我们不想让更下一级的子类再去动它,
这时就可以在重写时加上 sealed 关键字。这样一来,这个方法就被“封死”了,后面的子类再想重写就会报错。
假设我们有一个交通工具的基类 Vehicle,它有一个可以被重写的 Drive 方法。然后我们写了一个 Car 子类,觉得它的 Drive 实现已经很完美了,不
想让更具体的车型再去改,于是就在 Car 里用 sealed override 把它钉死。这样,SportsCar 之类的子类就不能再重写 Drive 了。
|class Vehicle { public virtual void Drive() => Console.WriteLine("Vehicle driving"); } class Car : Vehicle { public sealed override void Drive() => Console.WriteLine("Car driving"); } class SportsCar : Car {
sealed 用于:
sealed class);sealed override)。new 与 override 的区别|class A { public virtual void Say() => Console.WriteLine("A"); } class B : A { public new void Say() => Console.WriteLine("B-new"); // 隐藏:按静态类型分派 } class C : A { public
我们要注意,使用 new 关键字只是把父类的方法“藏起来”,并没有实现真正的多态。
也就是说,如果我们用父类类型去调用方法,还是会走父类的实现。只有用 virtual 和 override,才会让方法在运行时根据对象的真实类型自动分派,这才是多态的本质。
所以,当我们希望子类能根据自己的特性来改写父类行为,并且在多态场景下生效,一定要用 virtual/override,而不是 new。
在 C# 里,抽象类就像是为一类事物搭建的“蓝图”,它本身不能被直接用来创建对象。我们可以把抽象类想象成一个只画了轮廓但还没上色的画板,具体的细节需要后续的子类来补充完善。 抽象类里可以包含抽象成员,这些成员就像是“必须实现的约定”,也就是说,所有继承这个抽象类的子类都必须给这些成员写出具体的实现代码,否则编译器就会报错。 这样做的好处是,我们可以在抽象类里统一规定好大家都要遵守的接口和行为规范,而具体的实现细节则交给每个子类根据自己的特点去完成。
|using System; abstract class Storage { public abstract void Save(string data); // 必须实现 public virtual string Prefix => ""; // 可选重写 public void SaveWithPrefix(string data) { Save(Prefix + data); // 模板方法的一种 }
在我们学习 C# 的继承时,会发现继承其实是一种非常紧密的代码复用方式。因为一旦子类继承了父类,它就会直接依赖于父类的结构和行为,这种关系就像“连体婴儿”一样,父类一变,子类也得跟着变。这种强耦合有时候会让我们的代码变得不够灵活。
其实,在很多实际开发场景下,我们更应该优先考虑“组合”这种方式。什么意思呢?就是与其让一个类去继承另一个类,不如让它在内部拥有另一个类的实例。 比如说,我们可以说“一个背包有一个拉链”,而不是“一个背包是一个拉链”。只有当我们真的遇到“X 是一种 Y”这种天然的关系时,继承才是最合适的选择。而大多数情况下,“X 有一个 Y”用组合会让代码更清晰、更容易维护。
|// 反例:为了重用 List 行为,让 Stack : List ?不合适 // 更好的:Stack 内部“有一个 List”,通过组合来复用 class MyStack<T> { private readonly List<T> _list = new(); public void Push(T item) => _list.Add(item); public T Pop() { if (_list.Count == 0) throw
当你说“X 是一种 Y”时再考虑继承;当你说“X 有一个 Y”时,优先考虑组合。
继承层次里常碰到“资源释放”。C# 有终结器(析构器)语法,但不确定何时运行;
推荐显式实现 IDisposable,并遵循“Dispose 模式”,把释放逻辑封装好并可被子类扩展。
|using System; class BaseRes : IDisposable { private bool _disposed; protected virtual void Dispose(bool disposing) { if (_disposed) return; if (disposing) { // 释放托管资源 } // 释放非托管资源 _disposed = true; }
在学习继承时,我们常常会遇到一个非常重要的原则,叫做“里氏替换原则”(Liskov Substitution Principle,简称 LSP)。
这个原则其实很朴素:如果我们有一个基类 A,然后写了一个子类 B 继承自 A,那么在程序中所有需要 A 的地方,我们都应该可以放心地用 B 来替换,而不会让程序的行为变得奇怪或者出错。
换句话说,子类对象应该能够完全“扮演”基类的角色,外部代码不需要知道它其实是个子类。
如果子类不能做到这一点,比如说它改变了基类的某些重要行为或者破坏了基类的约定,那么这样的继承关系就很危险,容易导致 bug。
我们来看一个经常被提及的反例:假设我们有一个 Rectangle(矩形)类,表示宽和高都可以自由设置的矩形。现在我们想表达“正方形是一种特殊的矩形”,于是写了一个 Square : Rectangle 的继承关系。
表面上看没问题,但实际上正方形的宽和高必须始终相等,而矩形的宽和高可以独立变化。
如果我们用 Square 替换 Rectangle,就会出现很多违反预期的情况,比如只想改宽度却导致高度也变了,这就破坏了原本矩形的行为契约。
所以,继承不仅仅是代码复用,更重要的是要保证子类和基类之间的行为一致性,不能随意破坏基类的规则,否则就会违背里氏替换原则,埋下隐患。
|class Rectangle { public virtual int Width { get; set; } public virtual int Height { get; set; } } class Square : Rectangle { public override int Width { get => base.Width; set { base
这段代码看似工作正常,但许多依赖“宽高可独立设置”的调用方逻辑会被破坏。
4. 虚方法与重写练习
分析以下代码,理解虚方法和重写的多态行为:
Base 类定义了 virtual 方法 Who(),返回 "Base"Child 类继承 Base,使用 override 重写 Who() 方法,返回 "Child"Main 方法中,Base b = new Child() 创建了一个 Child 对象,但用 Base 类型引用b.Who() 时,由于使用了 virtual 和 override,会根据实际对象类型(Child)调用方法请分析并预测输出结果。
|using System; class Base { public virtual string Who() => "Base"; } class Child : Base { public override string Who() => "Child"; } class Program { static void Main() {
5. 隐藏与重写的差异练习
分析以下代码,理解 new 和 override 的区别:
A 类定义了 virtual 方法 F()B 类使用 new 隐藏基类方法(不是重写)C 类使用 override 重写基类方法new 不会实现多态,override 会实现多态请分析并预测输出结果,并编写测试代码验证。
|using System; class A { public virtual void F() => Console.WriteLine("A"); } class B : A { public new void F() => Console.WriteLine("B"); // new:隐藏,不是重写 } class C :
6. base 的使用练习
分析以下代码,理解 base 关键字的作用:
Animal 类定义了 virtual 方法 Sound(),返回 "..."Cat 类继承 Animal,使用 override 重写 Sound() 方法Cat 的 Sound() 方法中,使用 base.Sound() 调用基类方法,然后追加 " meow"base 关键字用于在子类中访问基类的成员请分析并预测输出结果,并编写测试代码验证。
|using System; class Animal { public virtual string Sound() => "..."; } class Cat : Animal { public override string Sound() => base.Sound() + " meow"; // 调用基类方法并扩展 } class Program {
7. 抽象成员练习
分析以下代码,理解抽象类和抽象成员的概念:
Parser 是抽象类,定义了抽象方法 Parse(string s)IntParser 继承 Parser,实现了 Parse 方法请分析并预测输出结果,并编写测试代码验证。
|using System; abstract class Parser { public abstract int Parse(string s); // 抽象方法,必须在子类实现 } class IntParser : Parser { public override int Parse(string s) => int.Parse(s); // 实现抽象方法 } class
8. sealed 的应用练习
分析以下代码,理解 sealed 关键字的作用:
X 类定义了 virtual 方法 Run()Y 类继承 X,使用 sealed override 重写 Run() 方法sealed override 表示这个方法不能再被进一步重写Z 类继承 Y,尝试重写 Run() 方法会编译错误请分析并预测输出结果,并说明 Z 类能否重写 Run() 方法。
|using System; class X { public virtual void Run() => Console.WriteLine("X.Run"); } class Y : X { public sealed override void Run() => Console.WriteLine("Y.Run"); // sealed:不能再被重写 } class
9. 组合优先练习
设计一个 Playlist 类,实现以下功能:
List<string> 存储歌曲列表Add(string song) 方法:添加歌曲到播放列表Remove(string song) 方法:从播放列表中移除歌曲Items 属性:返回只读的歌曲列表(使用 IReadOnlyList<string> 或 IEnumerable<string>)List<string>,而是通过封装提供受控的访问Main 方法中测试:添加几首歌曲,移除一首,然后遍历播放列表|using System; using System.Collections.Generic; using System.Linq; class Playlist { private readonly List<string> _songs = new List<string>(); public void Add(string song) {
|Child
说明:
virtual 关键字允许方法在子类中被重写override 关键字重写基类的虚方法|A C B C
说明:
new 关键字只是隐藏基类方法,不会实现多态new 方法按静态类型(基类)调用override 关键字实现真正的多态override 方法按实际对象类型(子类)调用override 而不是 new|... ... meow ... meow
说明:
base 关键字用于在子类中访问基类的成员base.Sound() 调用基类的 Sound() 方法|123 456
说明:
abstract 关键字用于定义抽象类和抽象成员override 实现|X.Run Y.Run Z.Run Y.Run
说明:
sealed override 表示方法不能再被进一步重写Z 类不能使用 override 重写 Run() 方法,会编译错误Z 类可以使用 new 隐藏方法,但不会实现多态sealed 用于防止继承链中的进一步重写,保持方法的稳定性new 方法按静态类型调用,不会实现多态|添加后的播放列表: - 歌曲1 - 歌曲2 - 歌曲3 移除'歌曲2'后的播放列表: - 歌曲1 - 歌曲3
说明:
Playlist 内部有一个 List<string>,而不是继承 List<string>Add、Remove 方法控制列表的修改Items 返回 IReadOnlyList<string>,防止外部直接修改列表AsReadOnly() 方法创建只读包装,保护内部数据