Day 2 - 继承与组合
建议用时:180-210 分钟
你将学会什么
- 继承是什么,组合是什么
- “是一种”和“拥有一个”怎么判断
:、base、virtual、override分别是什么意思- 为什么业务代码通常优先组合
- 什么时候适合用继承
- 如何用组合表达订单、客户、订单明细
- 如何避免为了复用几行代码滥用继承
继承和组合不是语法比赛。先判断关系:A 是一种 B,才考虑继承;A 拥有 B,就用组合。
本页固定顺序
- 先学第一部分:弄懂今天最小、最重要的知识,并运行短例子。
- 再学第二部分:把刚学的知识组合成一个完整例子。
- 然后做第三部分:自己跟着敲,再完成重复训练和每日小测。
- 最后做第四部分:先独立完成作业,再用完整答案检查。
学习衔接
上一页学习的是“类与对象”,今天继续学习“继承与组合”。先使用上一页已经会的写法,再只增加今天这个新知识点;如果前置内容还不能独立敲出,先回上一页复习,不要硬跳。
今天的最低通过线
第一次学习不要求背完整页。完成下面 3 项,就可以继续:
- 能用自己的话说明“继承与组合”解决什么问题。
- 把第一部分的短例子亲手敲完,并确认每个例子都能运行。
- 不看完整答案完成第三部分至少前 3 个例子,再主动改一个值观察结果。
第一部分:先学原理和最小知识
这一部分先解决“这是什么、为什么要这样写”。每看到一个短例子就亲手敲一遍并运行,不要先背完整程序。
今天先抓住什么
判断继承和组合,只问两句话:
A 是一种 B 吗?是,就可能用继承。
A 拥有一个 B 吗?是,就用组合。例子:
| 关系 | 判断 | 写法 |
|---|---|---|
| VIP 客户是一种客户 | 是一种 | 继承 |
| 订单拥有客户 | 拥有一个 | 组合 |
| 订单拥有多条订单明细 | 拥有多个 | 组合 |
| 价格计算器是订单吗 | 不是 | 不要继承 |
名词先讲清楚
| 名词 | 直接解释 | 例子 |
|---|---|---|
| 继承 | 子类继承父类属性和方法 | VipCustomer : Customer |
| 父类 | 更通用的类型 | Customer |
| 子类 | 更具体的类型 | VipCustomer |
| 组合 | 一个类里包含另一个对象 | Order 有 Customer |
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,所以继承关系说得通。
virtual 和 override
父类方法如果希望子类能改写,要写 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 : Customer | VIP 客户是一种客户 |
| 父类允许重写 | 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”,通常不要继承。
今天写代码时按这个顺序
- 先写出两个类的中文关系。
- 如果能说“A 是一种 B”,才考虑继承。
- 如果是“A 拥有 B”,用组合。
- 父类公共方法需要子类改写时,用
virtual。 - 子类改写父类方法时,用
override。 - 子类构造函数需要初始化父类数据时,用
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。
常见错误和修法
| 错误 | 为什么错 | 修法 |
|---|---|---|
| 为了复用一点代码就继承 | 继承会建立强关系 | 优先考虑组合,把对象作为属性放进去 |
| 子类改坏父类规则 | 继承层级过深难控制 | 父类保留稳定规则,变化点用虚方法或接口 |
| 组合对象没有初始化 | 使用时会空引用 | 构造函数里传入或创建依赖对象 |
| 把“有一个”写成继承 | 关系表达错了 | Order 有 Customer 是组合,不是继承 |
| 父类职责太多 | 子类被迫继承不需要的内容 | 拆小类或改用接口 |
小白重复敲写训练
继承和组合分开练,不要在第一段代码里同时使用。
训练 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。
第四部分:作业完整答案
这一部分给出当天作业的完整答案。先按第三部分做一遍,再用这里检查。
作业要求
写一个客户订单模型,要求:
Customer是普通客户。VipCustomer是一种Customer。VipCustomer可以重写折扣。Order拥有一个Customer。Order拥有多条OrderLine。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表示子类改写父类方法。- 组合通常比继承更灵活。