任何 C# 应用程序中的第一个设计决策就是选择要创建的类型。 菜单项应是class还是record? 应该让快速计算返回tuple还是命名类型? 每个选项都塑造代码处理相等性、可变性和多态性的方式。 错误的选取会导致模板化、bug 或两者兼有。
在本教程中,你将构建一个小型咖啡店模型,该模型使用菜单项、订单、传感器读数和折扣策略。 分析特征并确定每个概念的最佳 C# 类型。 在此过程中,你将学会识别哪些设计因素倾向于选择一种类型而非另一种。
在本教程中,你将了解:
- 识别何时元组适合用来返回多个值。
- 使用记录类对不可变数据进行建模,并了解基于值的相等性。
- 表示具有记录结构的小型可复制数据。
- 使用类管理可变状态和行为。
- 通过继承扩展类以添加或收紧规则。
- 使用接口跨不相关的类型定义共享功能。
先决条件
- 安装 .NET SDK。
使用元组进行临时分组
咖啡店需要返回当天订单总数和收入的方法。 可以为此定义一个类或结构,但一个方法中的两个值并不总是为新类型辩护。
(int TotalOrders, decimal Revenue) GetDailySummary(int orders, decimal revenue)
=> (orders, revenue);
Console.WriteLine("=== Tuple: daily summary ===");
var summary = GetDailySummary(42, 1234.50m);
Console.WriteLine($"Orders: {summary.TotalOrders}, Revenue: {summary.Revenue:F2}");
var (orders, revenue) = summary;
Console.WriteLine($"Deconstructed: {orders} orders, {revenue:F2}");
GetDailySummary 返回一个(int TotalOrders, decimal Revenue)元组。 调用方可以按名称访问每个元素,也可以将每个元素分解为局部变量。 不需要类或结构定义。
为什么在这个例子中,使用元组效果最好
元组在此处之所以合适,是因为分组是本地的:一个方法生成它,一个调用者使用它。 命名元素使意向清晰,而无需完全类型的仪式。 如果你发现自己在多个方法中传递相同的元组形状,这是将它提升为记录或类的信号。 稍后在本教程中,您将看到那种演变。 有关元组语法和功能的更多详细信息,请参阅 元组类型。
对不可变数据使用记录
每个咖啡店都需要一个菜单。 菜单项具有名称、价格和营养说明。 列出项后,这些值不会更改。 两个系统都引用“拿铁4.50美元”时,应一致认为他们指的是同一个东西,即使他们创建了独立对象。
声明位置记录:
record class MenuItem(string Name, decimal Price, string NutritionalNote);
编译器从该单行生成构造函数、析构函数、Equals、GetHashCode和ToString。 发挥记录的作用
Console.WriteLine("\n=== Record class: MenuItem ===");
var latte = new MenuItem("Latte", 4.50m, "Contains dairy");
var latte2 = new MenuItem("Latte", 4.50m, "Contains dairy");
var seasonal = latte with { Name = "Pumpkin Spice Latte", Price = 5.25m };
Console.WriteLine(latte);
Console.WriteLine(seasonal);
Console.WriteLine($"Same reference (latte vs latte2): {ReferenceEquals(latte, latte2)}");
Console.WriteLine($"Value equal (latte vs latte2): {latte == latte2}");
Console.WriteLine($"Value equal (latte vs seasonal): {latte == seasonal}");
具有相同数据的两 MenuItem 个实例是相等的,即使它们是单独的对象。 该行为说明了基于值的相等性。 该 with 表达式创建一个季节性变体,而不改变原始变量。
当标识来自数据而不是对象引用时, 记录类 是合适的,创建后很少更改实例。 你可以开箱即用地获得可读的 ToString() 输出、结构相等性和 with 支持。 有关更深入的指南,请参阅 记录 和 记录教程。
对小值类型使用记录结构
咖啡机有一个内置的温度计,用于报告温度读数。 每个读数都很小,包含一个单位和一个数字,并会被复制到日志、警报和仪表板中。 你不希望一个副本的更改影响到其他副本。
声明记录结构:
record struct Measurement(double Value, string Unit);
使用记录结构:
Console.WriteLine("\n=== Record struct: Measurement ===");
var temp = new Measurement(72.5, "°F");
var copy = temp;
copy = copy with { Value = 23.0, Unit = "°C" };
Console.WriteLine($"Original: {temp.Value}{temp.Unit}");
Console.WriteLine($"Copy (converted): {copy.Value}{copy.Unit}");
将temp分配给copy并创建一个独立的值。 表达式 with 生成一个新值,而不接触原始值,与记录类相同,但具有复制分配行为,而不是逐个引用复制。
当数据较小(几个基元字段)且复制比堆分配便宜时, 记录结构 适合。 你将获得与记录类相同的值相等性和 with 支持,其底层是真正的值语义。 度量、坐标和类似的轻型数据是自然候选项。 有关更多上下文,请参阅 记录 和 结构类型。
当需要可变的状态和行为时,请使用类
当客户走到柜台时,咖啡师会开始处理订单,并一次添加一个商品。 总量增加,状态从“挂起”变为“就绪”,即使同时下达了两个订单(即使项目相同),它们仍然是不同的订单。
class Order : IOrder
{
public virtual string Status { get; set; } = "Pending";
private readonly List<(string Name, decimal Price)> _items = [];
public void AddItem(string name, decimal price) => _items.Add((name, price));
public decimal Total => _items.Sum(i => i.Price);
public override string ToString() =>
$"Order [{Status}]: {string.Join(", ", _items.Select(i => i.Name))} - Total: {Total:F2}";
}
Console.WriteLine("\n=== Class: Order ===");
var order = new Order();
order.AddItem("Latte", 4.50m);
order.AddItem("Croissant", 3.25m);
order.Status = "Ready";
Console.WriteLine(order);
类 Order 跟踪项目,计算累计总和,并提供一个可设置的 Status。
类在这里是合适的工具,因为对象在其生命周期内持有可变状态,行为(方法)是类型用途的核心,并且标识很重要——即使两个订单包含相同的项目,它们仍然是不同的订单。 有关更多详细信息,请参阅 “类”、“结构”和“记录”。
需要扩展类时使用继承
咖啡店开始餐饮活动。 餐饮订单仍然是一个订单-它有物品和总计-但它也跟踪客人计数,并要求经理批准,然后厨房标记它准备就绪。 派生专用类,而不是复制 Order逻辑。
class CateringOrder : Order
{
public int MinimumGuests { get; }
public string? ApprovedBy { get; private set; }
public CateringOrder(int minimumGuests) => MinimumGuests = minimumGuests;
public void Approve(string manager) => ApprovedBy = manager;
public override string Status
{
get => base.Status;
set
{
if (value == "Ready" && ApprovedBy is null)
throw new InvalidOperationException(
"A catering order requires manager approval before it can be marked ready.");
base.Status = value;
}
}
public override string ToString() =>
$"Catering [{Status}] for {MinimumGuests}+ guests, approved by: {ApprovedBy ?? "(none)"} - Total: {Total:F2}";
}
CateringOrder和AddItem从基类复用Total。 重写 Status 会收紧合同 — 在未经事先批准的情况下调用 Status = "Ready" 将引发异常:
Console.WriteLine("\n=== Inheritance: CateringOrder ===");
var catering = new CateringOrder(minimumGuests: 20);
catering.AddItem("Coffee (serves 20)", 45.00m);
catering.AddItem("Pastry platter", 60.00m);
try
{
catering.Status = "Ready";
}
catch (InvalidOperationException ex)
{
Console.WriteLine($"Blocked: {ex.Message}");
}
catering.Approve("Sam");
catering.Status = "Ready";
Console.WriteLine(catering);
此单个派生类说明了三个继承概念:
-
添加了状态:
MinimumGuests并且ApprovedBy仅存在于派生类上。 -
添加的行为:
Approve是新的——基础Order不知道关于审批的内容。 -
重写行为:
Status设值器实施基类不具备的业务规则。
当新类型是基类型的特化版本,并且在需要复用现有状态和行为的同时添加或收紧规则时,继承是合适的选择。 当类型共享实现时,共享基类比接口更自然,而不仅仅是协定。
使用接口定义共享功能
咖啡店经营着不同的促销-快乐时光,忠诚奖励,季节性特价。 结帐过程需要应用当前处于活动状态的折扣,而无需了解每个策略的具体细节。 你需要一种方法来表达“任何可以应用折扣的对象”,而不必限定在一个单一的类中实现结算逻辑。
interface IDiscountPolicy
{
decimal Apply(decimal total);
}
class HappyHourDiscount : IDiscountPolicy
{
public decimal Apply(decimal total) => total * 0.80m;
}
class LoyaltyDiscount : IDiscountPolicy
{
public decimal Apply(decimal total) => total - 1.00m;
}
该方法 Checkout 接受任何 IDiscountPolicy方法,因此无需更改签出逻辑即可引入新策略:
static decimal Checkout(decimal total, IDiscountPolicy policy) => policy.Apply(total);
Console.WriteLine("\n=== Interface: discount policy ===");
decimal subtotal = 12.00m;
Console.WriteLine($"Happy hour (20% off): {Checkout(subtotal, new HappyHourDiscount()):F2}");
Console.WriteLine($"Loyalty ($1 off): {Checkout(subtotal, new LoyaltyDiscount()):F2}");
接口声明一个契约: 即任何实现类型都必须提供的一组成员。 接口在此处有效,因为折扣类型彼此不相关(它们不共享基类),然而结算需要以统一的方式处理它们。 接口还可以简化测试:在存根策略中交换而无需触摸生产代码。 有关更多详细信息,请参阅 接口。
进化您的类型选择
这些决定都不是永久性的。 事实上,在发布一个库之前,如果中断性变更需要考虑的话,您可以轻松更改它们。 随着需求的增长,将简单类型提升为更丰富的类型。 下面是三种常见的演变。
元组→记录:分组现象不断出现
GetDailySummary元组在一个方法内正常运行,但一旦开始将其传递给报表、仪表板和测试,命名类型的价值会体现出来。 将元组提升到记录并添加计算属性:
record class DailySummary(int TotalOrders, decimal Revenue)
{
public decimal AverageTicket => TotalOrders > 0 ? Revenue / TotalOrders : 0m;
}
以前解构元组的调用方现在可获得 ToString() 免费、值相等以及派生数据的自然位置,例如 AverageTicket:
Console.WriteLine("\n=== Evolve: tuple -> record ===");
var daily = new DailySummary(120, 525.75m);
Console.WriteLine(daily);
Console.WriteLine($"Average ticket: {daily.AverageTicket:F2}");
结构→类:需使用继承
店铺的维护团队要求经过校准的读数:通过偏移量调整的传感器数值。 记录 Measurement 结构非常适合原始数据,但结构不支持继承,因此无法派生校准的变体。 升格到类层次结构:
class SensorReading(double value, string unit)
{
public double Value { get; } = value;
public string Unit { get; } = unit;
public virtual string Display() => $"{Value}{Unit}";
}
class CalibratedReading(double value, string unit, double offset)
: SensorReading(value, unit)
{
public double Offset { get; } = offset;
public override string Display() => $"{Value + Offset}{Unit} (offset {Offset:+0.0;-0.0})";
}
CalibratedReading 继承自 SensorReading 并重写 Display() 以便于包含偏移量。 这种模式无法用结构体或记录结构体实现:
Console.WriteLine("\n=== Evolve: struct -> class ===");
var raw = new SensorReading(72.5, "°F");
var calibrated = new CalibratedReading(72.5, "°F", offset: -0.3);
Console.WriteLine($"Raw: {raw.Display()}");
Console.WriteLine($"Calibrated: {calibrated.Display()}");
类→类 + 接口:需要跨类型多态性
该 Order 类本身效果良好,但一旦 CateringOrder 存在,结帐、报告和打印都需要处理 任何 订单,而无需关心具体类型。 抽取一个包含调用者实际依赖的成员的接口:
interface IOrder
{
string Status { get; set; }
decimal Total { get; }
}
Order 和 CateringOrder 都已满足此协议。 现在,单个方法处理任一类型:
Console.WriteLine("\n=== Evolve: class -> class + interface ===");
static void PrintOrderSummary(IOrder o) =>
Console.WriteLine($" {o.Total:F2} [{o.Status}]");
var walkIn = new Order();
walkIn.AddItem("Mocha", 5.00m);
walkIn.Status = "Ready";
var banquet = new CateringOrder(minimumGuests: 50);
banquet.AddItem("Coffee service", 90.00m);
banquet.Approve("Alex");
banquet.Status = "Ready";
Console.WriteLine("All orders:");
foreach (IOrder o in new IOrder[] { walkIn, banquet })
PrintOrderSummary(o);
提取接口不会更改Order或CateringOrder,它只是明确了它们的共同结构,这也使得测试更容易进行。
快速决策指南
当不确定要选取的类型时,请使用此表作为起点:
| 问题 | 特别适合 |
|---|---|
| 从一个方法返回多个值? | Tuple |
| 不可变数据,其中相等性按值? | 记录类 |
| 具有相等性的小型可复制值数据? | 记录结构 |
| 可变状态、行为或引用标识? | Class |
| 现有类的专用版本? | 派生类 |
| 在不相关的类型中共享能力? | 接口 |
如果这些都不完全合适,请考虑组合类型。 例如,类可以实现接口,记录可以是结构。 有关完整比较,请参阅 “选择哪种类型”。