自在学
分类课程智能体订阅
分类课程AI导师价格
课程进度
5 / 11
上一节引用与类型下一节接口与抽象
自在学

© 2025 - 2026 自在学,保留所有权利。

公网安备湘公网安备43020302000292号 | 湘ICP备2025148919号-1

关于我们隐私政策使用条款

© 2025 自在学,保留所有权利。

公网安备湘公网安备43020302000292号湘ICP备2025148919号-1

编程C#继承

继承

在面向对象程序设计中,“继承”(Inheritance)是一种通过定义基类(父类)与派生类(子类)来抽象和复用共性逻辑的机制。 当多个类具备相同的属性或行为时,可以将这些共性提取到基类中,子类则通过继承的方式获得这些通用成员,并可根据需求进行扩展或重写,从而加强类型系统的表达能力和维护性。

C# 语言通过关键字 : 基类名 实现继承。通过继承,派生类不仅自动获得基类已实现的字段和方法,还可以按照"对外一致,内部多样"的原则,遵循基类对外接口(如方法签名、属性),而实现或重写具体行为。这形成了多态(Polymorphism)和代码复用的基础。

例如,假设我们开发一个画图应用,初始只支持圆形(Circle),后续加入矩形(Rectangle)。很快可以发现,两者都包含位置、颜色等属性,且具备类似的 Draw() 方法,对应的绘制流程相似,仅在实际渲染时的具体实现不同。 若直接复制粘贴实现,会导致冗余和维护困难;采用继承方式,将共性逻辑抽象到 Shape 基类,再由各派生类实现特有逻辑,可显著提升代码的内聚性与可扩展性。这正是继承在实际工程中的核心价值所在——抽象共性,分离变异。

继承


基类与派生类

我们用一个简单的例子来理解继承:

csharp
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(...) 这种方式,把需要的信息顺利地传递给基类,让整个继承链条上的每一环都能各司其职地完成初始化。

csharp
using System;
 
class Animal
{
    public string Name { get; }
    public Animal(string name) => Name = name ?? "(unknown)";
}
 
class Dog : Animal
{
    public bool IsGoodBoy { get; }
    public Dog(string name, bool good) : base(name) // 把 name 传给基类
    {
        IsGoodBoy = good;
    }
}
 
class Program
{
    static void Main()
    {
        var d = new Dog("旺财", true);
        Console.WriteLine($"{d.Name}, good: {d.IsGoodBoy}");
    }
}

如果基类有无参构造器且可访问,子类可以不显式写 base();否则必须显式选择构造器。


方法重写与 sealed

在 C# 里,如果我们希望某个方法在子类中可以被重新实现(也就是“重写”),就需要在基类里用 virtual 关键字把它声明为“可重写的”。 这样,子类就能用 override 关键字来提供自己的实现,覆盖掉基类的默认行为。

不过,有时候我们可能不希望这种“重写”无限制地传下去。比如说,某个子类已经把方法改成了最合适的样子,我们不想让更下一级的子类再去动它, 这时就可以在重写时加上 sealed 关键字。这样一来,这个方法就被“封死”了,后面的子类再想重写就会报错。

假设我们有一个交通工具的基类 Vehicle,它有一个可以被重写的 Drive 方法。然后我们写了一个 Car 子类,觉得它的 Drive 实现已经很完美了,不 想让更具体的车型再去改,于是就在 Car 里用 sealed override 把它钉死。这样,SportsCar 之类的子类就不能再重写 Drive 了。

csharp
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
{
    // public override void Drive() { } // 编译错误:被 sealed 钉住
}

sealed 用于:

  • 阻止进一步继承(sealed class);
  • 阻止进一步重写(sealed override)。

隐藏 vs 重写:new 与 override 的区别

csharp
class A
{
    public virtual void Say() => Console.WriteLine("A");
}
 
class B : A
{
    public new void Say() => Console.WriteLine("B-new");   // 隐藏:按静态类型分派
}
 
class C : A
{
    public override void Say() => Console.WriteLine("C-override"); // 重写:按运行时类型分派
}
 
class Program
{
    static void Main()
    {
        A ab = new B(); ab.Say();   // 输出 A(静态类型 A 决定)
        A ac = new C(); ac.Say();   // 输出 C-override(运行时类型 C 决定)
    }
}

我们要注意,使用 new 关键字只是把父类的方法“藏起来”,并没有实现真正的多态。 也就是说,如果我们用父类类型去调用方法,还是会走父类的实现。只有用 virtual 和 override,才会让方法在运行时根据对象的真实类型自动分派,这才是多态的本质。 所以,当我们希望子类能根据自己的特性来改写父类行为,并且在多态场景下生效,一定要用 virtual/override,而不是 new。


抽象类与抽象成员

在 C# 里,抽象类就像是为一类事物搭建的“蓝图”,它本身不能被直接用来创建对象。我们可以把抽象类想象成一个只画了轮廓但还没上色的画板,具体的细节需要后续的子类来补充完善。 抽象类里可以包含抽象成员,这些成员就像是“必须实现的约定”,也就是说,所有继承这个抽象类的子类都必须给这些成员写出具体的实现代码,否则编译器就会报错。 这样做的好处是,我们可以在抽象类里统一规定好大家都要遵守的接口和行为规范,而具体的实现细节则交给每个子类根据自己的特点去完成。

csharp
using System;
 
abstract class Storage
{
    public abstract void Save(string data);   // 必须实现
    public virtual string Prefix => "";      // 可选重写
 
    public void SaveWithPrefix(string data)
    {
        Save(Prefix + data);                  // 模板方法的一种
    }
}
 
class FileStorage : Storage
{
    public override void Save(string data)
    {
        Console.WriteLine($"[File] {data}"); // 这里简化为打印
    }
}
 
class DbStorage : Storage
{
    public override string Prefix => "DB:";
    public override void Save(string data)
    {
        Console.WriteLine($"[Db] {data}");
    }
}
 
class Program
{
    static void Main()
    {
        Storage s = new FileStorage();
        s.Save("hello");
        s.SaveWithPrefix("world");
        s = new DbStorage();
        s.SaveWithPrefix("x");
    }
}

组合优先于继承

在我们学习 C# 的继承时,会发现继承其实是一种非常紧密的代码复用方式。因为一旦子类继承了父类,它就会直接依赖于父类的结构和行为,这种关系就像“连体婴儿”一样,父类一变,子类也得跟着变。这种强耦合有时候会让我们的代码变得不够灵活。

其实,在很多实际开发场景下,我们更应该优先考虑“组合”这种方式。什么意思呢?就是与其让一个类去继承另一个类,不如让它在内部拥有另一个类的实例。 比如说,我们可以说“一个背包有一个拉链”,而不是“一个背包是一个拉链”。只有当我们真的遇到“X 是一种 Y”这种天然的关系时,继承才是最合适的选择。而大多数情况下,“X 有一个 Y”用组合会让代码更清晰、更容易维护。

csharp
// 反例:为了重用 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 new InvalidOperationException("空栈");
        int last = _list.Count - 1;
        T value = _list[last];
        _list.RemoveAt(last);
        return value;
    }
}

当你说“X 是一种 Y”时再考虑继承;当你说“X 有一个 Y”时,优先考虑组合。


析构次序与资源释放

继承层次里常碰到“资源释放”。C# 有终结器(析构器)语法,但不确定何时运行; 推荐显式实现 IDisposable,并遵循“Dispose 模式”,把释放逻辑封装好并可被子类扩展。

csharp
using System;
 
class BaseRes : IDisposable
{
    private bool _disposed;
    protected virtual void Dispose(bool disposing)
    {
        if (_disposed) return;
        if (disposing)
        {
            // 释放托管资源
        }
        // 释放非托管资源
        _disposed = true;
    }
    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }
}
 
class ChildRes : BaseRes
{
    protected override void Dispose(bool disposing)
    {
        // 子类先释放自己的,再调用基类
        base.Dispose(disposing);
    }
}

里氏替换原则(LSP)

在学习继承时,我们常常会遇到一个非常重要的原则,叫做“里氏替换原则”(Liskov Substitution Principle,简称 LSP)。 这个原则其实很朴素:如果我们有一个基类 A,然后写了一个子类 B 继承自 A,那么在程序中所有需要 A 的地方,我们都应该可以放心地用 B 来替换,而不会让程序的行为变得奇怪或者出错。 换句话说,子类对象应该能够完全“扮演”基类的角色,外部代码不需要知道它其实是个子类。

如果子类不能做到这一点,比如说它改变了基类的某些重要行为或者破坏了基类的约定,那么这样的继承关系就很危险,容易导致 bug。

我们来看一个经常被提及的反例:假设我们有一个 Rectangle(矩形)类,表示宽和高都可以自由设置的矩形。现在我们想表达“正方形是一种特殊的矩形”,于是写了一个 Square : Rectangle 的继承关系。 表面上看没问题,但实际上正方形的宽和高必须始终相等,而矩形的宽和高可以独立变化。 如果我们用 Square 替换 Rectangle,就会出现很多违反预期的情况,比如只想改宽度却导致高度也变了,这就破坏了原本矩形的行为契约。

所以,继承不仅仅是代码复用,更重要的是要保证子类和基类之间的行为一致性,不能随意破坏基类的规则,否则就会违背里氏替换原则,埋下隐患。

csharp
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.Width = value; base.Height = value; }
    }
    public override int Height
    {
        get => base.Height;
        set { base.Width = value; base.Height = value; }
    }
}

这段代码看似工作正常,但许多依赖“宽高可独立设置”的调用方逻辑会被破坏。


小练习

  1. C#中用于继承的关键字包括?
  1. 要重写基类的virtual方法,应该使用?
  1. sealed关键字的用途包括?

4. 虚方法与重写练习

分析以下代码,理解虚方法和重写的多态行为:

  • Base 类定义了 virtual 方法 Who(),返回 "Base"
  • Child 类继承 Base,使用 override 重写 Who() 方法,返回 "Child"
  • 在 Main 方法中,Base b = new Child() 创建了一个 Child 对象,但用 Base 类型引用
  • 当调用 b.Who() 时,由于使用了 virtual 和 override,会根据实际对象类型(Child)调用方法

请分析并预测输出结果。

csharp
using System;
 
class Base
{
    public virtual string Who() => "Base";
}
 
class Child : Base
{
    public override string Who() => "Child";
}
 
class Program
{
    static void Main()
    {
        Base b = new Child();  // 用基类类型引用子类对象
        Console.WriteLine(b.Who()); // 输出: Child(多态:根据实际对象类型调用)
    }
}
console
Child

说明:

  • virtual 关键字允许方法在子类中被重写
  • override 关键字重写基类的虚方法
  • 多态:即使使用基类类型引用,也会根据实际对象类型调用方法
  • 这是面向对象编程中多态性的核心体现

5. 隐藏与重写的差异练习

分析以下代码,理解 new 和 override 的区别:

  • A 类定义了 virtual 方法 F()
  • B 类使用 new 隐藏基类方法(不是重写)
  • C 类使用 override 重写基类方法
  • 当用基类类型引用子类对象时,new 不会实现多态,override 会实现多态

请分析并预测输出结果,并编写测试代码验证。

csharp
using System;
 
class A
{
    public virtual void F() => Console.WriteLine("A");
}
 
class B : A
{
    public new void F() => Console.WriteLine("B"); // new:隐藏,不是重写
}
 
class C : A
{
    public override void F() => Console.WriteLine("C"); // override:重写,实现多态
}
 
class Program
{
    static void Main()
    {
        A ab = new B();  // 用基类类型引用B对象
        ab.F();          // 输出: A(new不会实现多态,按静态类型调用)
 
        A ac = new C();  // 用基类类型引用C对象
        ac.F();          // 输出: C(override实现多态,按实际类型调用)
 
        // 直接使用子类类型
        B b = new B();
        b.F();           // 输出: B(直接调用子类方法)
 
        C c = new C();
        c.F();           // 输出: C
    }
}
console
A
C
B
C

说明:

  • new 关键字只是隐藏基类方法,不会实现多态
  • 当用基类类型引用时,new 方法按静态类型(基类)调用
  • override 关键字实现真正的多态
  • 当用基类类型引用时,override 方法按实际对象类型(子类)调用
  • 多态是面向对象编程的重要特性,应该使用 override 而不是 new

6. base 的使用练习

分析以下代码,理解 base 关键字的作用:

  • Animal 类定义了 virtual 方法 Sound(),返回 "..."
  • Cat 类继承 Animal,使用 override 重写 Sound() 方法
  • 在 Cat 的 Sound() 方法中,使用 base.Sound() 调用基类方法,然后追加 " meow"
  • base 关键字用于在子类中访问基类的成员

请分析并预测输出结果,并编写测试代码验证。

csharp
using System;
 
class Animal
{
    public virtual string Sound() => "...";
}
 
class Cat : Animal
{
    public override string Sound() => base.Sound() + " meow"; // 调用基类方法并扩展
}
 
class Program
{
    static void Main()
    {
        Animal animal = new Animal();
        Console.WriteLine(animal.Sound()); // 输出: ...
 
        Cat cat = new Cat();
        Console.WriteLine(cat.Sound());   // 输出: ... meow
 
        Animal a = new Cat();              // 多态
        Console.WriteLine(a.Sound());      // 输出: ... meow
    }
}
console
...
... meow
... meow

说明:

  • base 关键字用于在子类中访问基类的成员
  • base.Sound() 调用基类的 Sound() 方法
  • 子类可以在重写方法中调用基类方法,然后扩展功能
  • 这种方式可以复用基类的实现,同时添加子类特有的行为
  • 多态仍然有效,即使使用基类类型引用,也会调用子类的重写方法

7. 抽象成员练习

分析以下代码,理解抽象类和抽象成员的概念:

  • Parser 是抽象类,定义了抽象方法 Parse(string s)
  • 抽象类不能直接实例化,只能作为基类
  • 抽象方法没有实现,必须在子类中实现
  • IntParser 继承 Parser,实现了 Parse 方法

请分析并预测输出结果,并编写测试代码验证。

csharp
using System;
 
abstract class Parser
{
    public abstract int Parse(string s); // 抽象方法,必须在子类实现
}
 
class IntParser : Parser
{
    public override int Parse(string s) => int.Parse(s); // 实现抽象方法
}
 
class Program
{
    static void Main()
    {
        // Parser p = new Parser(); // 编译错误:抽象类不能实例化
 
        IntParser parser = new IntParser();
        int result = parser.Parse("123");
        Console.WriteLine(result); // 输出: 123
 
        // 多态:用基类类型引用
        Parser p = new IntParser();
        int value = p.Parse("456");
        Console.WriteLine(value); // 输出: 456
    }
}
console
123
456

说明:

  • abstract 关键字用于定义抽象类和抽象成员
  • 抽象类不能直接实例化,只能作为基类
  • 抽象方法没有实现,必须在子类中用 override 实现
  • 抽象类可以包含非抽象成员(字段、属性、方法等)
  • 抽象类用于定义接口契约,强制子类实现特定方法

8. sealed 的应用练习

分析以下代码,理解 sealed 关键字的作用:

  • X 类定义了 virtual 方法 Run()
  • Y 类继承 X,使用 sealed override 重写 Run() 方法
  • sealed override 表示这个方法不能再被进一步重写
  • Z 类继承 Y,尝试重写 Run() 方法会编译错误

请分析并预测输出结果,并说明 Z 类能否重写 Run() 方法。

csharp
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 Z : Y
{
    // public override void Run() { } // 编译错误:不能重写被sealed的方法
    // 只能使用new隐藏,但不能实现多态
    public new void Run() => Console.WriteLine("Z.Run");
}
 
class Program
{
    static void Main()
    {
        X x = new X();
        x.Run(); // 输出: X.Run
 
        Y y = new Y();
        y.Run(); // 输出: Y.Run
 
        Z z = new Z();
        z.Run(); // 输出: Z.Run
 
        // 多态测试
        Y yz = new Z();
        yz.Run(); // 输出: Y.Run(因为Z的Run是new隐藏,不是override)
    }
}
console
X.Run
Y.Run
Z.Run
Y.Run

说明:

  • sealed override 表示方法不能再被进一步重写
  • Z 类不能使用 override 重写 Run() 方法,会编译错误
  • Z 类可以使用 new 隐藏方法,但不会实现多态
  • sealed 用于防止继承链中的进一步重写,保持方法的稳定性
  • 当用基类类型引用时,new 方法按静态类型调用,不会实现多态

9. 组合优先练习

设计一个 Playlist 类,实现以下功能:

  • 内部使用 List<string> 存储歌曲列表
  • 对外只提供三个公共成员:
    • Add(string song) 方法:添加歌曲到播放列表
    • Remove(string song) 方法:从播放列表中移除歌曲
    • Items 属性:返回只读的歌曲列表(使用 IReadOnlyList<string> 或 IEnumerable<string>)
  • 注意:不要直接暴露内部的 List<string>,而是通过封装提供受控的访问
  • 在 Main 方法中测试:添加几首歌曲,移除一首,然后遍历播放列表
csharp
using System;
using System.Collections.Generic;
using System.Linq;
 
class Playlist
{
    private readonly List<string> _songs = new List<string>();
 
    public void Add(string song)
    {
        if (string.IsNullOrWhiteSpace(song))
            throw new ArgumentException("歌曲名不能为空");
        _songs.Add(song);
    }
 
    public bool Remove(string song)
    {
        return _songs.Remove(song);
    }
 
    // 返回只读视图,防止外部直接修改列表
    public IReadOnlyList<string> Items => _songs.AsReadOnly();
}
 
class Program
{
    static void Main()
    {
        var playlist = new Playlist();
        playlist.Add("歌曲1");
        playlist.Add("歌曲2");
        playlist.Add("歌曲3");
 
        Console.WriteLine("添加后的播放列表:");
        foreach (var song in playlist.Items)
        {
            Console.WriteLine($"  - {song}");
        }
 
        playlist.Remove("歌曲2");
        Console.WriteLine("\n移除'歌曲2'后的播放列表:");
        foreach (var song in playlist.Items)
        {
            Console.WriteLine($"  - {song}");
        }
    }
}
console
添加后的播放列表:
  - 歌曲1
  - 歌曲2
  - 歌曲3
 
移除'歌曲2'后的播放列表:
  - 歌曲1
  - 歌曲3

说明:

  • 使用组合而不是继承:Playlist 内部有一个 List<string>,而不是继承 List<string>
  • 封装:内部列表是私有的,外部不能直接访问
  • 受控访问:通过 Add、Remove 方法控制列表的修改
  • 只读属性:Items 返回 IReadOnlyList<string>,防止外部直接修改列表
  • AsReadOnly() 方法创建只读包装,保护内部数据
  • 这是"组合优先于继承"原则的典型应用
  • 基类与派生类
  • 构造链
  • 方法重写与 `sealed`
  • 隐藏 vs 重写:`new` 与 `override` 的区别
  • 抽象类与抽象成员
  • 组合优先于继承
  • 析构次序与资源释放
  • 里氏替换原则(LSP)
  • 小练习

目录

  • 基类与派生类
  • 构造链
  • 方法重写与 `sealed`
  • 隐藏 vs 重写:`new` 与 `override` 的区别
  • 抽象类与抽象成员
  • 组合优先于继承
  • 析构次序与资源释放
  • 里氏替换原则(LSP)
  • 小练习