Skip to Content
Week 03Day 2 - 继承与组合

Day 2 - 继承与组合

建议用时:180-210 分钟

你将学会什么

  • 继承是什么,组合是什么
  • “是一种”和“拥有一个”怎么判断
  • :basevirtualoverride 分别是什么意思
  • 为什么业务代码通常优先组合
  • 什么时候适合用继承
  • 如何用组合表达订单、客户、订单明细
  • 如何避免为了复用几行代码滥用继承

继承和组合不是语法比赛。先判断关系:A 是一种 B,才考虑继承;A 拥有 B,就用组合。

本页固定顺序

  1. 先学第一部分:弄懂今天最小、最重要的知识,并运行短例子。
  2. 再学第二部分:把刚学的知识组合成一个完整例子。
  3. 然后做第三部分:自己跟着敲,再完成重复训练和每日小测。
  4. 最后做第四部分:先独立完成作业,再用完整答案检查。

学习衔接

上一页学习的是“类与对象”,今天继续学习“继承与组合”。先使用上一页已经会的写法,再只增加今天这个新知识点;如果前置内容还不能独立敲出,先回上一页复习,不要硬跳。

今天的最低通过线

第一次学习不要求背完整页。完成下面 3 项,就可以继续:

  • 能用自己的话说明“继承与组合”解决什么问题。
  • 把第一部分的短例子亲手敲完,并确认每个例子都能运行。
  • 不看完整答案完成第三部分至少前 3 个例子,再主动改一个值观察结果。

第一部分:先学原理和最小知识

这一部分先解决“这是什么、为什么要这样写”。每看到一个短例子就亲手敲一遍并运行,不要先背完整程序。

今天先抓住什么

判断继承和组合,只问两句话:

A 是一种 B 吗?是,就可能用继承。 A 拥有一个 B 吗?是,就用组合。

例子:

关系判断写法
VIP 客户是一种客户是一种继承
订单拥有客户拥有一个组合
订单拥有多条订单明细拥有多个组合
价格计算器是订单吗不是不要继承

名词先讲清楚

名词直接解释例子
继承子类继承父类属性和方法VipCustomer : Customer
父类更通用的类型Customer
子类更具体的类型VipCustomer
组合一个类里包含另一个对象OrderCustomer
base调用父类构造函数或成员: base(name)
virtual父类允许子类重写virtual GetDiscount
override子类改写父类行为override GetDiscount
多态父类变量可以指向子类对象Customer c = new VipCustomer(...)

继承表达“是一种”

Animal animal = new Dog(); animal.Speak(); class Animal { public virtual void Speak() { Console.WriteLine("..."); } } class Dog : Animal { public override void Speak() { Console.WriteLine("汪"); } }

输出:

Dog 是一种 Animal,所以继承关系说得通。

virtualoverride

父类方法如果希望子类能改写,要写 virtual

public virtual decimal GetDiscount() { return 1m; }

子类改写时写 override

public override decimal GetDiscount() { return 0.9m; }

如果父类方法没有 virtual,子类不能直接 override

base 是调用父类

子类构造函数可以先调用父类构造函数:

class VipCustomer : Customer { public VipCustomer(string name) : base(name) { } }

意思是:创建 VipCustomer 时,先让 Customer 部分把 Name 初始化好。

组合表达“拥有一个”

var customer = new Customer("Tom"); var order = new Order(customer, 199m); Console.WriteLine($"{order.Customer.Name}: {order.Amount:F2}"); class Customer { public string Name { get; } public Customer(string name) { Name = name; } } class Order { public Customer Customer { get; } public decimal Amount { get; } public Order(Customer customer, decimal amount) { Customer = customer; Amount = amount; } }

Order 不是一种 Customer,而是拥有一个 Customer,所以用组合。

为什么多数业务代码优先组合

继承会让父类和子类绑得很紧。父类一改,所有子类都可能受影响。

组合更灵活:

Order 使用 PriceCalculator Order 使用 Customer Order 使用 List<OrderLine>

这些对象都可以替换,而不需要改变 Order 的继承层级。

继承和组合常用写法速查

需求写法什么时候用
表达“是一种”class VipCustomer : CustomerVIP 客户是一种客户
父类允许重写virtual子类可能改规则
子类重写override改写父类行为
调用父类构造: base(...)子类创建前先初始化父类
表达“拥有一个”public Customer Customer { get; }订单拥有客户
表达“拥有多个”List<OrderLine>订单拥有多条明细
优先选择组合把对象作为属性业务对象之间通常更稳

继承模板:

class Customer { public string Name { get; } public Customer(string name) { Name = name; } public virtual decimal GetDiscount() { return 1m; } } class VipCustomer : Customer { public VipCustomer(string name) : base(name) { } public override decimal GetDiscount() { return 0.8m; } }

组合模板:

class Order { public Customer Customer { get; } public List<OrderLine> Lines { get; } = new(); public Order(Customer customer) { Customer = customer; } }

继承容易误用的场景

不要为了复用几行代码就继承:

OrderService 不是 Logger Order 不是 PriceCalculator Product 不是 Database

如果说不出“A 是一种 B”,通常不要继承。

今天写代码时按这个顺序

  1. 先写出两个类的中文关系。
  2. 如果能说“A 是一种 B”,才考虑继承。
  3. 如果是“A 拥有 B”,用组合。
  4. 父类公共方法需要子类改写时,用 virtual
  5. 子类改写父类方法时,用 override
  6. 子类构造函数需要初始化父类数据时,用 base

第二部分:把知识组合成完整例子

前面已经学过最小知识。现在把它们组合起来,先读懂执行顺序,再完整敲一遍。今天最终要写一个订单模型:订单拥有客户、订单拥有多条明细,VIP 客户是一种客户。

最终你要写出这个程序

var customer = new VipCustomer("Tom", 0.9m); var order = new Order(customer); order.AddLine("Keyboard", 199m, 1); order.AddLine("Mouse", 59m, 2); Console.WriteLine($"客户: {order.Customer.Name}"); Console.WriteLine($"原价: {order.GetOriginalTotal():F2}"); Console.WriteLine($"折后价: {order.GetFinalTotal():F2}"); class Customer { public string Name { get; } public Customer(string name) { Name = name; } public virtual decimal GetDiscount() { return 1m; } } class VipCustomer : Customer { private readonly decimal discount; public VipCustomer(string name, decimal discount) : base(name) { this.discount = discount; } public override decimal GetDiscount() { return discount; } } class Order { private readonly List<OrderLine> lines = new(); public Customer Customer { get; } public Order(Customer customer) { Customer = customer; } public void AddLine(string name, decimal price, int count) { lines.Add(new OrderLine(name, price, count)); } public decimal GetOriginalTotal() { return lines.Sum(line => line.Price * line.Count); } public decimal GetFinalTotal() { return GetOriginalTotal() * Customer.GetDiscount(); } } record OrderLine(string Name, decimal Price, int Count);

输出:

客户: Tom 原价: 317.00 折后价: 285.30

这里有两种关系:

VipCustomer 是一种 Customer:继承 Order 拥有 Customer 和 OrderLine:组合

第三部分:跟着敲代码

从这里开始动手。每个例子都可以直接放进 Program.cs 运行。

例子 1:最小继承

Animal animal = new Dog(); animal.Speak(); class Animal { public virtual void Speak() { Console.WriteLine("..."); } } class Dog : Animal { public override void Speak() { Console.WriteLine("汪"); } }

例子 2:客户和 VIP 客户

var normal = new Customer("Tom"); var vip = new VipCustomer("Alice", 0.8m); Console.WriteLine($"{normal.Name}: {normal.GetDiscount():P0}"); Console.WriteLine($"{vip.Name}: {vip.GetDiscount():P0}"); class Customer { public string Name { get; } public Customer(string name) { Name = name; } public virtual decimal GetDiscount() { return 1m; } } class VipCustomer : Customer { private readonly decimal discount; public VipCustomer(string name, decimal discount) : base(name) { this.discount = discount; } public override decimal GetDiscount() { return discount; } }

例子 3:组合表达订单拥有客户

var customer = new Customer("Tom"); var order = new Order(customer, 199m); Console.WriteLine($"{order.Customer.Name}: {order.Amount:F2}"); class Customer { public string Name { get; } public Customer(string name) { Name = name; } } class Order { public Customer Customer { get; } public decimal Amount { get; } public Order(Customer customer, decimal amount) { Customer = customer; Amount = amount; } }

例子 4:组合表达订单拥有多条明细

var order = new Order(); order.AddLine("Keyboard", 199m, 1); order.AddLine("Mouse", 59m, 2); Console.WriteLine($"总价: {order.GetTotal():F2}"); class Order { private readonly List<OrderLine> lines = new(); public void AddLine(string name, decimal price, int count) { lines.Add(new OrderLine(name, price, count)); } public decimal GetTotal() { return lines.Sum(line => line.Price * line.Count); } } record OrderLine(string Name, decimal Price, int Count);

例子 5:组合服务,而不是继承服务

var service = new OrderService(new PriceCalculator()); Console.WriteLine(service.Total(199m, 2)); class OrderService { private readonly PriceCalculator calculator; public OrderService(PriceCalculator calculator) { this.calculator = calculator; } public decimal Total(decimal price, int count) { return calculator.Calculate(price, count); } } class PriceCalculator { public decimal Calculate(decimal price, int count) { return price * count; } }

OrderService 不是 PriceCalculator,它只是拥有并使用一个 PriceCalculator

常见错误和修法

错误为什么错修法
为了复用一点代码就继承继承会建立强关系优先考虑组合,把对象作为属性放进去
子类改坏父类规则继承层级过深难控制父类保留稳定规则,变化点用虚方法或接口
组合对象没有初始化使用时会空引用构造函数里传入或创建依赖对象
把“有一个”写成继承关系表达错了OrderCustomer 是组合,不是继承
父类职责太多子类被迫继承不需要的内容拆小类或改用接口

小白重复敲写训练

继承和组合分开练,不要在第一段代码里同时使用。

训练 1:最小继承

var dog = new Dog(); dog.Name = "Lucky"; dog.Speak(); class Animal { public string Name { get; set; } = ""; } class Dog : Animal { public void Speak() { Console.WriteLine($"{Name}: 汪"); } }

改动任务:增加 Cat : Animal,让它输出“喵”。

训练 2:用组合保存地址

var customer = new Customer { Name = "Tom", Address = new Address { City = "Shanghai" } }; Console.WriteLine($"{customer.Name} - {customer.Address.City}"); class Customer { public string Name { get; set; } = ""; public Address Address { get; set; } = new(); } class Address { public string City { get; set; } = ""; }

第二遍增加 Street 属性。

训练 3:判断该用继承还是组合

分别写出这两句话,再决定关系:

狗是一种动物 -> 继承 订单拥有收货地址 -> 组合

自己再写两个业务例子,并画出“是一个”或“有一个”。

每日小测

做完本页后,用这 5 题检查是否真的掌握。

1. 判断题

本页的目标不是只把代码运行起来,还要能说清楚“为什么这样写”。

答案:对。能运行只是第一步,能解释原理、常用操作和常见错误,才说明本页内容进入了可复用能力。

2. 填空题

本页主题是:继承与组合。今天至少要掌握的 3 个点是:

1. 继承是什么,组合是什么 2. “是一种”和“拥有一个”怎么判断 3. `:`、`base`、`virtual`、`override` 分别是什么意思

答案:以上 3 点必须能用自己的代码跑通,不能只停留在阅读。

3. 流程题

遇到本页相关功能时,先按什么顺序处理?

答案:先看完整例子,确认最终效果;再读原理和名词;然后跟着第三部分从空项目敲代码;最后对照作业答案检查。

4. 找错误题

如果本页代码运行失败,第一步应该做什么?

答案:先看终端或 IDE 里的第一条错误,找到文件名和行号;不要同时改很多地方。再回到本页的“常见错误和修法”表格,对照错误类型逐项排查。

5. 改需求题

在本页完整例子跑通后,至少改一个小需求。

可选改法:

  • 改一个字段名称。
  • 多加一个校验条件。
  • 多输出一行结果。
  • 把固定数据改成用户输入。
  • 把一次处理改成多条数据处理。

答案标准:修改后能重新运行,并能说明这次修改影响了哪一段逻辑。重点检查:继承是什么,组合是什么。

上位机专项练习

不同设备可以继承公共信息,但通信参数更适合用组合。

下面 3 个例子都要亲手敲。先运行原代码,再完成每个例子后面的改动任务。

专项例子 1:继承公共设备信息

class Device { public string Name { get; } public Device(string name) { Name = name; } } class TemperatureSensor : Device { public string Unit { get; } = "℃"; public TemperatureSensor(string name) : base(name) { } } class Program { static void Main() { var sensor = new TemperatureSensor("温度-01"); Console.WriteLine($"{sensor.Name} {sensor.Unit}"); } }

运行结果或界面效果:

温度-01 ℃

改动任务: 增加 PressureSensor,单位使用 MPa。

专项例子 2:组合通信配置

class ConnectionOptions { public string Host { get; init; } = "127.0.0.1"; public int Port { get; init; } = 502; } class Device { public ConnectionOptions Connection { get; } public Device(ConnectionOptions connection) { Connection = connection; } } class Program { static void Main() { var options = new ConnectionOptions { Host = "192.168.1.10", Port = 502 }; var device = new Device(options); Console.WriteLine($"{device.Connection.Host}:{device.Connection.Port}"); } }

运行结果或界面效果:

192.168.1.10:502

改动任务: 把端口改成 503。

专项例子 3:组合报警规则

class AlarmRule { public double HighLimit { get; } public AlarmRule(double highLimit) { HighLimit = highLimit; } public bool Check(double value) => value >= HighLimit; } class Device { public AlarmRule Rule { get; } public Device(AlarmRule rule) { Rule = rule; } } class Program { static void Main() { var device = new Device(new AlarmRule(80)); Console.WriteLine(device.Rule.Check(85)); } }

运行结果或界面效果:

True

改动任务: 把高限改成 100。

第四部分:作业完整答案

这一部分给出当天作业的完整答案。先按第三部分做一遍,再用这里检查。

作业要求

写一个客户订单模型,要求:

  1. Customer 是普通客户。
  2. VipCustomer 是一种 Customer
  3. VipCustomer 可以重写折扣。
  4. Order 拥有一个 Customer
  5. Order 拥有多条 OrderLine
  6. Order 可以计算原价和折后价。

完整代码

var customer = new VipCustomer("Tom", 0.9m); var order = new Order(customer); order.AddLine("Keyboard", 199m, 1); order.AddLine("Mouse", 59m, 2); Console.WriteLine($"客户: {order.Customer.Name}"); Console.WriteLine($"原价: {order.GetOriginalTotal():F2}"); Console.WriteLine($"折后价: {order.GetFinalTotal():F2}"); class Customer { public string Name { get; } public Customer(string name) { if (string.IsNullOrWhiteSpace(name)) { throw new ArgumentException("客户名称不能为空"); } Name = name.Trim(); } public virtual decimal GetDiscount() { return 1m; } } class VipCustomer : Customer { private readonly decimal discount; public VipCustomer(string name, decimal discount) : base(name) { if (discount <= 0 || discount > 1) { throw new ArgumentException("折扣必须大于 0 且小于等于 1"); } this.discount = discount; } public override decimal GetDiscount() { return discount; } } class Order { private readonly List<OrderLine> lines = new(); public Customer Customer { get; } public Order(Customer customer) { Customer = customer; } public void AddLine(string name, decimal price, int count) { if (string.IsNullOrWhiteSpace(name)) { throw new ArgumentException("商品名称不能为空"); } if (price < 0 || count <= 0) { throw new ArgumentException("价格或数量不正确"); } lines.Add(new OrderLine(name.Trim(), price, count)); } public decimal GetOriginalTotal() { return lines.Sum(line => line.Price * line.Count); } public decimal GetFinalTotal() { return GetOriginalTotal() * Customer.GetDiscount(); } } record OrderLine(string Name, decimal Price, int Count);

正确运行结果

客户: Tom 原价: 317.00 折后价: 285.30

你要能说清楚这 6 件事

  • VipCustomer 是一种 Customer,所以可以继承。
  • Order 拥有 Customer,所以用组合。
  • Order 拥有多条 OrderLine,所以用 List<OrderLine> 组合。
  • virtual 表示父类方法允许被改写。
  • override 表示子类改写父类方法。
  • 组合通常比继承更灵活。