C# 中如何处理空对象——空对象模式
这是我匈牙利语原文的英文版本。照片由Samuel Zeller拍摄,来自Unsplash。
空引用如此普遍,已成为我们程序员经验中不可或缺的一部分,以至于很难想象没有它的世界会是什么样子。然而,它也是造成诸多挫败感和实际经济损失的根源,因此,认真思考一些替代方案是十分值得的。
但究竟是什么问题呢?
假设我们有一个系统,客户可以在上面下单。订单包含多个明细项,每个明细项都指向一个产品,并存储订购数量。系统还可以根据单价和指定数量计算价格。
public class LineItem
{
public Product Product { get; }
public decimal Quantity { get; }
public decimal Price => Product.UnitPrice * Quantity;
public LineItem(Product product, decimal quantity)
{
if (quantity <= 0)
throw new ArgumentOutOfRangeException(nameof(quantity));
if (product == null)
throw new ArgumentNullException(nameof(product));
Product = product;
Quantity = quantity;
}
}
订单可以应用多种类型的折扣,因此我们使用策略模式将订单与折扣计算解耦。
public interface IDiscount
{
decimal Calculate(decimal orderTotal);
}
public class CouponDiscount : IDiscount
{
public CouponDiscount(string couponCode, decimal rate) { /* ... */ }
public decimal Calculate(decimal orderTotal) => orderTotal * rate;
}
public class FrequentBuyerDiscount : IDiscount { /* ... */ }
根据订单总额,折扣对象可以根据自身的一套规则计算折扣后的价格。
订单类提供了添加行项目和折扣的方法,以及一个返回应用折扣后总额的属性。
public class Order
{
public IEnumerable<LineItem> LineItems => lineItems.AsReadOnly();
public decimal Total => discount.Calculate(totalBeforeDiscount);
private decimal totalBeforeDiscount => lineItems.Sum(i => i.Price);
private IDiscount discount; // initialized to null
private List<LineItem> lineItems;
public Order()
{
lineItems = new List<LineItem>();
}
public void AddLineItem(LineItem lineItem)
{
if (lineItem == null) throw new ArgumentNullException(nameof(lineItem));
lineItems.Add(lineItem);
}
public void AddDiscount(IDiscount discount)
{
if (discount == null) throw new ArgumentNullException(nameof(discount));
this.discount = discount;
}
}
上面的类看起来完全没有问题。能出什么问题呢?我们甚至为所有参数都添加了空值检查。但它仍然很可能抛出NullReferenceException 异常。你能找出问题所在吗?
var order = new Order();
order.AddLineItem(new LineItem(product1, 2));
order.AddLineItem(new LineItem(product2, 1));
Console.WriteLine($"Total: {order.Total}"); // BOOM!!!
哦,我们忘了加上折扣!
等等。创建订单时不使用折扣也是完全合理的需求。我们来修改代码以支持这一点。
public decimal Total =>
discount == null ? totalBeforeDiscount : discount.Calculate(totalBeforeDiscount);
这段代码虽然能运行,但绝对称不上美观。即使我们暂且不考虑美观问题,每次访问折扣字段时都必须检查是否为空,这本身就是个灾难。哪怕只漏掉一次,就会抛出NullReferenceException异常。此外,我们每次都需要重复返回折扣前总价的默认逻辑。这部分逻辑封装性很差。
幸运的是,我们可以做得更好。让我们来看看在添加行项目时,我们是如何处理同样的问题的。我们在构造函数中将lineItems字段初始化为一个安全的默认值,即空列表。
public Order()
{
lineItems = new List<LineItem>();
}
这样一来,在AddLineItem方法中就不需要检查lineItems是否为空了。
lineItems.Add(lineItem);
我们能否设想一个同样安全的折扣默认值呢?当然可以,那就是创建一个名为NoDiscount 的类,其中的Calculate方法会直接返回orderTotal参数,而不做任何修改。
public class NoDiscount : IDiscount
{
public decimal Calculate(decimal orderTotal) => orderTotal;
}
这种默认对象称为空对象。现在我们可以将其用作构造函数中折扣的初始值。
public Order()
{
lineItems = new List<LineItem>();
discount = new NoDiscount();
}
并消除Total属性中存在问题的空值检查。
public decimal Total => discount.Calculate(totalBeforeDiscount);
最后一点建议是,在使用空对象模式时要谨慎。当默认行为无害且可预测时,它是一种非常优雅的解决方案。但如果空对象的行为对使用者来说并不明显,那么抛出NullReferenceException实际上比让设计糟糕的空对象做出意料之外的行为更有用。
文章来源:https://dev.to/balazsbotond/dealing-with-nothing-in-c---the-null-object-pattern-2e9c