发布于 2026-01-06 8 阅读
0

C# 中如何处理空对象——空对象模式

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

订单可以应用多种类型的折扣,因此我们使用策略模式将订单与折扣计算解耦。

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 { /* ... */ }
Enter fullscreen mode Exit fullscreen mode

根据订单总额,折扣对象可以根据自身的一套规则计算折扣后的价格。

订单类提供了添加行项目和折扣的方法,以及一个返回应用折扣后总额的属性。

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

上面的类看起来完全没有问题。能出什么问题呢?我们甚至为所有参数都添加了空值检查。但它仍然很可能抛出NullReferenceException 异常。你能找出问题所在吗?

var order = new Order();

order.AddLineItem(new LineItem(product1, 2));
order.AddLineItem(new LineItem(product2, 1));

Console.WriteLine($"Total: {order.Total}"); // BOOM!!!
Enter fullscreen mode Exit fullscreen mode

哦,我们忘了加上折扣!

等等。创建订单时不使用折扣也是完全合理的需求。我们来修改代码以支持这一点。

public decimal Total =>
    discount == null ? totalBeforeDiscount : discount.Calculate(totalBeforeDiscount);
Enter fullscreen mode Exit fullscreen mode

这段代码虽然能运行,但绝对称不上美观。即使我们暂且不考虑美观问题,每次访问折扣字段时都必须检查是否为空,这本身就是个灾难。哪怕只漏掉一次,就会抛出NullReferenceException异常。此外,我们每次都需要重复返回折扣前总价的默认逻辑。这部分逻辑封装性很差。

幸运的是,我们可以做得更好。让我们来看看在添加行项目时,我们是如何处理同样的问题的。我们在构造函数中将lineItems字段初始化为一个安全的默认值,即空列表。

public Order()
{
    lineItems = new List<LineItem>();
}
Enter fullscreen mode Exit fullscreen mode

这样一来,在AddLineItem方法中就不需要检查lineItems是否为空了

lineItems.Add(lineItem);
Enter fullscreen mode Exit fullscreen mode

我们能否设想一个同样安全的折扣默认值呢?当然可以,那就是创建一个名为NoDiscount 的类,其中的Calculate方法会直接返回orderTotal参数,而不做任何修改。

public class NoDiscount : IDiscount
{
    public decimal Calculate(decimal orderTotal) => orderTotal;
}
Enter fullscreen mode Exit fullscreen mode

这种默认对象称为空对象。现在我们可以将其用作构造函数中折扣的初始值。

public Order()
{
    lineItems = new List<LineItem>();
    discount = new NoDiscount();
}
Enter fullscreen mode Exit fullscreen mode

并消除Total属性中存在问题的空值检查。

public decimal Total => discount.Calculate(totalBeforeDiscount);
Enter fullscreen mode Exit fullscreen mode

最后一点建议是,在使用空对象模式时要谨慎。当默认行为无害且可预测时,它是一种非常优雅的解决方案。但如果空对象的行为对使用者来说并不明显,那么抛出NullReferenceException实际上比让设计糟糕的空对象做出意料之外的行为更有用。

文章来源:https://dev.to/balazsbotond/dealing-with-nothing-in-c---the-null-object-pattern-2e9c