当业务里出现“圆形、矩形都能计算面积”“文件报表、屏幕报表都能渲染”时,最诱人的做法是把相同代码复制到每个类里。继承提供了另一种选择:把稳定的共性放进基类,让派生类只负责差异。但继承不是代码复用捷径,它首先是一条类型承诺——任何需要基类的地方,派生类都应该能安全顶上。
本章用“物流报价系统”贯穿全文:Shipment 表示运单,StandardShipment 与 FragileShipment 表示不同报价规则。学完后,你应该能解释对象构造顺序、访问边界和虚方法分派,分清 override 与 new,并知道何时应当放弃继承、改用组合。

下面的声明表示 FragileShipment 是一种 Shipment:
public class Shipment
{
public string TrackingId { get; }
public Shipment(string trackingId)
{
TrackingId = trackingId;
}
}
public class FragileShipment : Shipment
{
public FragileShipment(string trackingId) : base(trackingId) { }
}派生类会获得基类中可访问的实例成员,但构造函数不会被继承。C# 的类只能直接继承一个类;这让基类的选择成为长期结构决策,而不是临时的复用技巧。
判断关系时可以做一次替换测试:如果某个方法接收 Shipment,传入 FragileShipment 后,调用者不需要知道它的具体类型,也不会破坏原有约定,那么继承关系才站得住。相反,Shipment 有一个报价策略、日志器或时钟,这些是 has-a 关系,通常更适合组合。
static void PrintLabel(Shipment shipment) =>
Console.WriteLine($"Label: {shipment.TrackingId}");
PrintLabel(new FragileShipment("SF-2026-005"));Label: SF-2026-005设计时不要问“能不能继承”,先问三件事:派生对象是不是基类概念的一种;基类契约是否足够稳定;调用者能否只依赖基类就正确工作。
继承并不意味着派生类能直接触碰基类的全部实现。private 成员只在声明它的类型内部可见;public 面向所有调用者;protected 面向当前类型和派生类型。
public abstract class Shipment
{
private readonly List<string> _audit = [];
protected Shipment(string trackingId, decimal weightKg)
{
TrackingId = string.IsNullOrWhiteSpace(trackingId)
? throw new ArgumentException("Tracking ID is required.", nameof(trackingId))
:
把字段直接设为 protected 往往会扩大耦合:所有派生类都能随意修改状态,基类很难继续保证不变量。更稳妥的做法是保留私有字段,通过受控的 protected 方法或只读属性开放必要能力。上例的派生类能调用 Record,却不能替换 _audit 或绕过记录规则。
访问修饰符是变化边界:越少的代码能接触内部状态,越容易独立修改实现。
创建派生对象时,运行顺序不是从派生类“向上补齐”,而是先完成基类构造,再执行派生类构造体。若基类没有可访问的无参构造函数,派生构造函数必须用 : base(...) 明确选择构造函数。
public abstract class Shipment
{
protected Shipment(string trackingId, decimal weightKg)
{
Console.WriteLine("1. Shipment validates common state");
TrackingId = trackingId;
WeightKg = weightKg;
}
public string TrackingId { get; }
public decimal WeightKg { get; }
1. Shipment validates common state
2. FragileShipment validates risk构造函数里不要调用可重写成员。此时派生字段可能还未初始化,虚分派却已经能进入派生实现,容易读取到默认值并制造半初始化对象。把构造期逻辑限制为参数校验、字段赋值和不可重写的私有方法,会更可靠。

基类成员默认是非虚的。只有基类把成员声明为 virtual 或 abstract,派生类才能用 override 加入同一条虚调用链。
public abstract class Shipment
{
protected Shipment(string trackingId, decimal weightKg) =>
(TrackingId, WeightKg) = (trackingId, weightKg);
public string TrackingId { get; }
public decimal WeightKg { get; }
public virtual decimal Quote() => 12m + WeightKg * 2.4m
base.Quote() 不是“退化为基类对象”,而是在当前对象上明确调用基类实现。它适合在保留基础算法的前提下追加差异。
Shipment[] shipments =
[
new StandardShipment("ST-001", 2.5m),
new FragileShipment("FR-002", 2.5m, 4)
];
foreach (Shipment shipment in shipments)
Console.WriteLine($"{shipment.Describe()} => {shipment.QuoteST-001: 2.5 kg => ¤18.00
FR-002: 2.5 kg, risk 4 => ¤30.00变量的静态类型决定编译时“允许调用哪些成员”,对象的运行时类型决定虚成员“最终执行哪个 override”。这两个时间点分清后,多态就不再神秘。

new 表示“我知道基类已有同名成员,但我要为这个静态类型提供另一个成员”。它不会加入基类的虚调用链。
public class ShipmentPrinter
{
public string Format() => "base format";
public virtual string Render() => "base render";
}
public sealed class FancyPrinter : ShipmentPrinter
{
public new string Format() => "fancy format";
public override string Render() =>
fancy format
base format
fancy render隐藏成员按表达式的静态类型选择,重写成员按对象的运行时类型选择。除非在兼容旧 API 等少数场景里有意隐藏,否则同名 new 很容易让调用结果依赖变量声明方式,应优先重新命名或修正基类契约。

抽象类不能直接实例化,却可以同时包含状态、已实现成员、虚成员和没有实现体的抽象成员。它适合表达“所有派生类型共享一段流程,但必须提供某个关键步骤”。
public abstract class Shipment
{
protected Shipment(string id) => TrackingId = id;
public string TrackingId { get; }
protected abstract decimal CalculateCore();
public decimal Quote()
{
decimal core = CalculateCore();
return decimal.Round
这里的公共 Quote() 固定舍入流程,抽象的 CalculateCore() 留给派生类。这类“稳定骨架 + 受控步骤”称为模板方法。抽象成员隐含虚语义,具体非抽象派生类必须实现它。
sealed class 阻止继续派生;sealed override 只终止某个成员的重写链:
public abstract class AuditedShipment : Shipment
{
public sealed override string ToString() => $"Shipment {TrackingId}";
// 派生类仍可存在,但不能继续 override ToString()
}封闭扩展点不是“反面向对象”,而是把不允许变化的约束交给编译器执行。
向上转换把派生引用看作基类引用,不丢失对象本身,只收窄可见成员。向下转换则可能失败,因为一个普通 Shipment 不一定是 FragileShipment。
现代 C# 通常用模式匹配把“检查”和“获得已缩小类型的变量”合并:
static string HandlingNote(Shipment shipment) => shipment switch
{
FragileShipment { RiskLevel: >= 4 } fragile =>
$"{fragile.TrackingId}: two-person handling",
FragileShipment fragile =>
$"{fragile.TrackingId}: add protective wrap",
_ => $"{shipment
如果只做一次判断,也可以使用 is:
if (shipment is FragileShipment { RiskLevel: >= 4 } fragile)
Console.WriteLine($"Escalate {fragile.TrackingId}");只有在类型关系本身就是业务分支时才这样写。若系统到处用 is 判断具体派生类,通常说明基类缺少合适的虚行为,或者继承层次正在泄漏实现细节。
协变返回允许 override 返回比基类声明更具体的引用类型。例如基类 Clone() 返回 Shipment,派生实现可返回 FragileShipment;它能减少调用端转换,但仍应保持基类契约语义。
如果报价规则需要在运行时切换,或者不同维度会自由组合,继续增加派生类很快会产生 FragileExpressInternationalShipment 之类的类型爆炸。此时把“报价能力”组合进去更合适:
public abstract class PricingPolicy
{
public abstract decimal Calculate(decimal weightKg);
}
public sealed class StandardPricing : PricingPolicy
{
public override decimal Calculate(decimal weightKg) => 12m + weightKg * 2.4m;
}
public sealed class
这里为避免把重点扩展到下一章,策略仍用抽象类表达;核心在于 Shipment 拥有一个策略,而不是通过继承把报价算法焊死在自身类型上。策略可以独立测试、替换和演进。
继承适合稳定的分类轴和可替换契约;组合适合可插拔能力、运行时选择、多个正交变化维度。实践中也可以混用:小而稳定的继承层次,内部组合多个易变能力。

提交一个继承设计前,可以逐项检查:
最常见的陷阱包括:为了省几行代码建立错误的 is-a;把所有字段设成 protected;忘记 base(...) 造成编译错误;误把 new 当成重写;在基类构造期间调用 virtual;在调用端大量判断具体派生类。
继承真正的价值不是“少写代码”,而是让调用者针对稳定的基类契约编程,并由运行时对象提供差异行为。把静态类型、运行时类型、构造链和扩展边界同时想清楚,继承才会从语法技巧变成可靠设计工具。