很多人第一次学 C# 面向对象时,最容易混淆的不是语法本身,而是“什么时候该继承、什么时候算多态、`new` 和 `override` 到底差在哪”。这篇文章沿着一个敌人系统的例子,把继承关系、访问控制、构造函数调用顺序和多态分派放到同一条线上讲清楚,读完后你能判断哪些写法是在复用代码,哪些写法才是真正让对象在运行时“表现不同”。
继承到底解决了什么问题
继承的核心含义
继承(Inheritance)允许一个子类或派生类,从另一个父类或基类中获得已有的属性和方法。它本质上描述的是一种 is-a 关系,也就是“某个对象本来就是某一类对象的一种”。

这样做最直接的价值有三个:
- 代码重用:公共字段、属性和方法只写一次。
- 结构清晰:类之间的职责层级更明确。
- 维护成本更低:公共逻辑放在基类后,多个子类可以同步受益。
C# 中如何声明继承
在 C# 中,继承通过冒号 : 表示。下面先定义一个基类 Enemy,再让 Goblin 和 Skeleton 继承它:

// 基类 / 父类
public class Enemy
{
public int health;
public int attackPower;
public void Move()
{
Console.WriteLine("敌人正在移动。");
}
public void Attack()
{
Console.WriteLine("敌人发起了攻击!");
}
}
// 派生类 / 子类
public class Goblin : Enemy // Goblin 继承自 Enemy
{
// 它自动拥有了 health, attackPower, Move(), Attack()
public void StealGold()
{
Console.WriteLine("哥布林偷走了你的金币!");
}
}
// 派生类 / 子类
public class Skeleton : Enemy // Skeleton 继承自 Enemy
{
// 它也自动拥有了 health, attackPower, Move(), Attack()
public void Reassemble()
{
Console.WriteLine("骷髅兵正在重组身体!");
}
}
有了这层关系后,子类既能直接使用基类成员,也能补充自己的专属行为:
Goblin goblin = new Goblin();
goblin.health = 100; // 继承自 Enemy
goblin.Move(); // 继承自 Enemy
goblin.StealGold(); // 自己的特有方法
Skeleton skeleton = new Skeleton();
skeleton.attackPower = 20; // 继承自 Enemy
skeleton.Attack(); // 继承自 Enemy
skeleton.Reassemble(); // 自己的特有方法
这个例子里,Goblin 和 Skeleton 都是 Enemy,因此共享移动、攻击、生命值等基础能力;而偷金币、重组身体则是各自差异化的扩展。
访问修饰符和 base,决定子类能拿到什么
public、private、protected 的继承差别
写继承时,很多问题不是出在“能不能继承”,而是出在“继承之后能不能访问”。这时就要看访问修饰符:
public:所有地方都能访问,包括子类和外部代码。private:只能在当前类内部访问,子类也不行。protected:基类自己和所有子类都能访问,但外部代码不能直接访问。
下面这段代码能直观看到差异:
public class Enemy
{
protected int health; // 改为 protected,子类可以直接访问,但外部不行
private string internalId = "ENEMY_001"; // private,子类无法访问
public int AttackPower { get; set; } // public 属性
// ...
}
public class Goblin : Enemy
{
public void TakeDamage(int damage)
{
this.health -= damage; // 正确!可以访问 protected 成员
// this.internalId = "GOBLIN_ID"; // 错误!无法访问 private 成员
}
}
实际开发里,如果一个成员只想给继承体系内部使用,protected 通常比直接写成 public 更稳妥;如果完全不希望子类碰到内部实现细节,就应该保持 private。
子类不会继承父类构造函数
继承关系建立后,还有一个经常被忽略的规则:子类不会继承父类的构造函数。但当你创建子类对象时,父类构造函数会先执行,再执行子类构造函数。
如果父类定义了带参数的构造函数,子类就必须通过 : base() 显式调用它:
public class Enemy
{
public int health;
// 父类的构造函数
public Enemy(int initialHealth)
{
this.health = initialHealth;
Console.WriteLine("Enemy 构造函数被调用。");
}
}
public class Goblin : Enemy
{
public string weapon;
// 子类的构造函数,通过 base 调用父类构造函数
public Goblin(int initialHealth, string weapon) : base(initialHealth)
{
// 先调用 base(initialHealth),即父类的构造函数
// 然后再执行这里的代码
this.weapon = weapon;
Console.WriteLine("Goblin 构造函数被调用。");
}
}
// 使用
Goblin goblin = new Goblin(100, "生锈的匕首");
// 输出:
// Enemy 构造函数被调用。
// Goblin 构造函数被调用。
这段顺序非常关键,因为它说明了为什么基类通常负责初始化“所有子类都必须具备”的那部分状态,而子类再补齐自己的个性化字段。
多态为什么是面向对象里最关键的一步
多态不是“长得像”,而是“调用同一方法时行为不同”
多态的核心可以概括成一句话:不同类的对象,对同一个方法调用作出不同响应。
在 C# 里,最常见的实现方式依赖三件事:
- 基类方法使用
virtual标记,允许被重写。 - 子类使用
override提供自己的实现。 - 通过“基类引用指向子类对象”来触发运行时分派,例如
Enemy enemy = new Goblin();
用 virtual 和 override 建立真正的动态分派
先把基类中的 Attack 声明为可重写方法:
public class Enemy
{
// ... 其他成员 ...
// virtual 关键字表示这个方法可以被子类重写
public virtual void Attack()
{
Console.WriteLine("敌人发起了基础攻击!");
}
}
然后在子类里分别重写:
public class Goblin : Enemy
{
// ...
// override 关键字表示我们正在重写基类的同名方法
public override void Attack()
{
// base.Attack(); // 可以选择性地调用基类的方法
Console.WriteLine("哥布林用匕首进行了一次快速突刺!");
}
}
public class Skeleton : Enemy
{
// ...
public override void Attack()
{
Console.WriteLine("骷髅兵挥舞着骨头棒砸向你!");
}
}
这时,多态就不仅仅是“类之间有继承关系”,而是运行时会根据对象真实类型选择方法实现。
基类列表装子类对象,多态才真正显出价值
多态最典型的好处,是你可以统一处理一批“同属一个体系、但行为不同”的对象:
// 创建一个敌人列表
List<Enemy> enemies = new List<Enemy>();
// 添加不同类型的敌人,注意,都是用子类实例化的
enemies.Add(new Goblin());
enemies.Add(new Skeleton());
enemies.Add(new Enemy()); // 也可以添加一个普通的 Enemy
// 遍历敌人列表,让他们一起攻击
Console.WriteLine("====== 战斗开始 ======");
foreach (Enemy currentEnemy in enemies)
{
// 最关键的一行代码!
//实现多态,不同的对象对同一个方法做出不一样的响应。
currentEnemy.Attack();
}
输出结果如下:
====== 战斗开始 ======
哥布林用匕首进行了一次快速突刺!
骷髅兵挥舞着骨头棒砸向你!
敌人发起了基础攻击!
这里最重要的不是列表,而是 currentEnemy.Attack() 这一行。调用点写的是基类方法名,但实际执行哪个版本,要等程序运行到具体对象时才能确定。这才是 C# 里常说的“真正的多态”。
进阶用法:abstract、sealed 和 new 该怎么区分
abstract:不给默认实现,强制子类自己写
abstract 适合“基类只定义规范,不提供完整行为”的场景。

- 抽象类(
abstract class):不能被直接实例化,像new Enemy()这样的写法会报错。 - 抽象方法(
abstract method):只有声明,没有方法体,要求非抽象子类必须实现。
// 抽象基类
public abstract class Enemy
{
public int health;
// 抽象方法,没有实现,用分号结尾
// 它强制所有子类必须提供自己的攻击方式
public abstract void Attack();
// 抽象类中也可以有普通方法
public void Move()
{
Console.WriteLine("敌人正在移动。");
}
}
public class Goblin : Enemy
{
// 必须重写父类的抽象方法 Attack
public override void Attack()
{
Console.WriteLine("哥布林用匕首进行了一次快速突刺!");
}
}
abstract 和 virtual 的区别可以这样记:
virtual方法有实现体,子类可以重写,也可以直接继承默认实现。abstract方法没有实现体,子类通常必须重写。abstract方法只能出现在abstract类中。
如果你知道“所有子类都必须定义自己的攻击方式”,那么用 abstract 会比提供一个含糊的默认实现更明确。
sealed:把继承链或重写链停在这里
如果说 abstract 是鼓励扩展,那么 sealed 就是明确告诉后续代码:到此为止。
- 密封类(
sealed class):不能再被继承。 - 密封重写(
sealed override):允许当前类重写父类方法,但禁止它的子类继续重写。
// 密封类,不能被继承
public sealed class SuperBoss : Enemy
{
public override void Attack()
{
Console.WriteLine("超级Boss发动了灭世攻击!");
}
}
// public class MiniBoss : SuperBoss { } // 错误!无法从密封类型“SuperBoss”派生
public class SkeletonWarrior : Skeleton
{
// 将 Attack 方法密封,SkeletonWarrior 的子类将无法再重写它
public sealed override void Attack()
{
Console.WriteLine("骷髅战士发动了精准的剑击!");
}
}
这类写法适合行为已经定型、且不希望被下游扩展破坏约束的场景。例如某些核心业务对象、框架内部类型或已经固定规则的特定实现。
new:方法隐藏,不是多态
很多初学者看到子类里也能写同名方法,就以为这和 override 没差别。实际上,new 做的是隐藏父类成员,不是运行时多态。
public class Parent
{
public void Greet()
{
Console.WriteLine("Hello from Parent!");
}
}
public class Child : Parent
{
// 使用 new 关键字隐藏了父类的 Greet 方法
public new void Greet()
{
Console.WriteLine("Hello from Child!");
}
}
// 观察区别
Parent p1 = new Parent();
p1.Greet(); // 输出: Hello from Parent!
Child c1 = new Child();
c1.Greet(); // 输出: Hello from Child!
// 关键区别在这里!
Parent p2 = new Child(); // 父类引用指向子类对象
p2.Greet(); // 输出: Hello from Parent!
最后这一行尤其关键:虽然对象实际是 Child,但因为 Greet() 只是被 new 隐藏,编译器会根据引用类型 Parent 来决定调用哪个方法,因此输出的仍然是父类版本。
也可以把它简化成一组判断规则:
override:真正的多态,运行时按对象真实类型决定调用版本。new:方法隐藏,编译时按引用类型决定调用版本。- 大多数需要“同一调用入口,不同对象表现不一样”的场景,都应该优先考虑
override。
写 C# 继承和多态时,最该记住的判断标准
把全文压缩成实践中的判断标准,可以归纳为下面几条:
- 当多个类共享同一批基础状态和行为,并且确实满足
is-a关系时,使用继承。 - 当你只想让子类复用默认实现时,基类方法可以用
virtual;当你要求子类必须自己实现时,用abstract更清晰。 - 当子类需要继续使用基类初始化逻辑时,通过
: base(...)显式传参,确保构造顺序正确。 - 当你希望外部不能直接访问、但子类可以使用某些成员时,优先考虑
protected。 - 当某个类型或某次重写不应该再被继续扩展时,用
sealed限定边界。 - 当你看到
new时,要先意识到这不是多态;如果需求是动态分派,通常应该改成virtual+override。
继承解决的是“共享与扩展”的问题,多态解决的是“统一调用入口下的差异化行为”问题。把这两个层次分开理解,再去看 abstract、sealed 和 new,很多概念就不会混在一起了。













