65.9K
CodeProject 正在变化。 阅读更多。
Home

动态多态:掌握 OOP 的关键概念

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.95/5 (14投票s)

2023 年 6 月 27 日

CPOL

6分钟阅读

viewsIcon

8657

探讨动态多态是什么以及它如何对掌握 OOP 至关重要

引言

面向对象编程的世界有点令人困惑。它需要掌握很多东西:SOLID 原则、设计模式等等。这引发了很多讨论:设计模式是否仍然有意义,SOLID 是否仅针对面向对象代码?有人说应该优先考虑组合而不是继承,那么什么时候应该选择其中一个呢?

鉴于在这件事上发表了许多意见,我不认为我的意见将是最终的,但尽管如此,在这篇文章中,我将介绍一个在日常 C# 编程中帮助过我的系统。但在我们开始之前,让我们先看看另一个问题。考虑一下代码。

public class A
{
    public virtual void Foo()
    {
        Console.WriteLine("A");
    }
}

public class B : A
{
    public override void Foo()
    {
        Console.WriteLine("B");
    }
}

public class C : A
{
    public void Foo()
    {
        Console.WriteLine("C");
    }
}

var b = new B();
var c = new C();
b.Foo();
c.Foo();

你能说出代码在每种情况下会输出什么吗?如果你正确地回答输出将是“B”和“C”,那么 `override` 关键字为什么很重要?

进入动态多态

多态被认为是面向对象编程的基石之一。但它到底意味着什么?维基百科告诉我们,多态是指为不同类型的实体提供单一接口,或者使用单一符号来表示多种不同类型。

我不指望你第一次就能理解这个定义,所以让我们看一些例子。

string Add(string input1, string input2) => string.Concat(input1, input2);
int Add(int input1, int input2) => input1 + input2;

上面是一个类型多态(ad-hoc polymorphism)的例子,它指的是可以应用于不同类型参数的多态函数,但其行为会根据其应用参数的类型而有所不同。那么,为什么多态对于面向对象代码如此重要呢?这个片段并没有给出明确的答案。让我们看更多的例子。

class List<T> {
    class Node<T> {
        T elem;
        Node<T> next;
    }
    Node<T> head;
    int length() { ... }
}

这是一个参数多态(parametric polymorphism)的例子,说实话,它看起来更像是函数式而不是面向对象的。让我们看最后一个例子。

interface IDiscountCalculator
{
    decimal CalculateDiscount(Item item);
}

class ThanksgivingDayDiscountCalculator : IDiscountCalculator
{
    public decimal CalculateDiscount(Item discount)
    {
        //omitted
    }
}

class RegularCustomerDiscountCalculator : IDiscountCalculator
{
    public decimal CalculateDiscount(Item discount)
    {
        //omitted
    }
}

这是一个动态多态(dynamic polymorphism)的例子,它是指在运行时应用多态(主要通过子类型化)。如果你尝试在面试前记住所有这些设计模式或 SOLID 原则,你可能会注意到一些熟悉的东西。让我们看看动态多态如何在这些概念中体现出来。

动态多态与设计模式

大多数设计模式(策略、命令、装饰器等)都依赖于注入抽象类或接口,并在运行时选择它的具体实现。让我们看看一些类图来确保是这样。

上面是策略模式的图,其中 `Client` 与抽象类交互,其具体实现是在运行时选择的。

这里是装饰器模式。

在这种情况下,包装器接受一个被包装的对象,它是一个抽象类的实例,并且它的实现可能在运行时发生变化。

动态多态与 SOLID

在面试中谈论 SOLID 时,我经常听到的答案是“S 代表单一职责,O 代表嗯……”。相反,我认为这个缩写的后四个字母更重要,因为它们代表了你的动态多态能够顺利运行的一组先决条件。

例如,开闭原则(open-closed principle)代表了一种思考方式,即将每个新问题视为你抽象的子类型。回想一下 `IDiscountCalculator` 的例子。假设你需要添加另一项折扣(例如父亲节折扣)。为了满足开闭原则,你需要添加另一个子类 `FathersDayDiscountCalculator` 来执行计算。

让我们继续讨论里氏替换原则(Liskov substitution principle)。想象一个打破它的情况:我们需要检查用户是否确实是父亲并且日期是否匹配。所以我们添加了一个 `public` 方法来检查用户是否有资格。

class FathersDayDiscountCalculator : IDiscountCalculator
{
    public decimal CalculateDiscount(Item discount)
    {
        //omitted
    }

    public bool IsEligible(User user, DateTime date)
    {
        //omitted
    }
}

现在调用代码将面临一些复杂性

private IReadOnlyCollection<IDiscountCalculator> _discountCalculators;
public decimal CalculateDiscountForItem(Item item, User user)
{
    decimal result = 0;
    foreach (var  discountCalculator in _discountCalculators)
    {
        if (discountCalculator is FathersDayDiscountCalculator)
        {
            var fathersDayDiscountCalculator = 
                discountCalculator as FathersDayDiscountCalculator;
            if (fathersDayDiscountCalculator.IsEligible(user, DateTime.UtcNow))
            {
                result += fathersDayDiscountCalculator.CalculateDiscount(item);
            }
        }
        else
        {
            result += discountCalculator.CalculateDiscount(item);
        }
    }
    return result;
}

非常冗长,不是吗?因此,为了满足里氏替换原则,我们必须强制所有实现表现出由抽象提供的相同的公共契约。否则,它将使动态多态的应用复杂化。

另一个使动态多态的应用复杂化的因素是抽象过于宽泛。想象一下,我们将 `IsEligible` 作为我们接口的一部分,现在所有具体类都实现了它。调用代码大大简化了。

private IReadOnlyCollection<IDiscountCalculator> _discountCalculators;
public decimal CalculateDiscountForItem(Item item, User user)
{
    decimal result = 0;
    foreach (var  discountCalculator in _discountCalculators)
    {
        result += discountCalculator.CalculateDiscount(item);
    }
    return result;
}

但是现在想象一下(我知道这个例子有点牵强,但只是为了论证!)其中一个实现抛出了 `NotImplementedException`,因为对于这种特定的折扣类型没有意义。这时,你可能会预见到 `CalculateDiscountForItem` 失败并出现运行时异常。

这就是接口隔离原则(interface-segregation principle)的意义所在:不要让抽象过于宽泛,以免具体类型在实现它们时遇到麻烦,从而用不必要的 `NotImplementedException` 使你的动态多态复杂化。

到目前为止,你可能已经注意到了依赖倒置原则(dependency inversion principle)在起作用。在上面的例子中,我们处理的是抽象集合,对它们的运行时类型一无所知。

优先组合而非继承

我不会深入探讨为什么组合更受青睐。有很多例子可以说明继承如何使事情复杂化。但是现在,当你有一个关于继承的合法用例的问题时,这里有一个答案:当它有利于动态多态时。

Virtual 和 Override

此时,那些一开始没有正确回答文章开头问题的读者可能怀疑这是一个陷阱题。确实,虽然当我们使用 `var` 关键字时行为相似,但当我们应用动态多态时,差异就开始出现。为此,让我们将两个实例都转换为父类型。

A b = new B();
A c = new C();

现在输出将分别是“B”和“A”。记住这些?`override` 关键字的目标是促进动态多态。所以这样想:当我们注入抽象时,我们期望与具体类型的实现一起工作,并且由于 `override` 促进了这一目标,因此将调用 `B` 的实现。

为什么这很重要?

现在,你知道如何记住所有那些棘手的面试问题了。但是最好奇的你可能会问:这种编程风格有什么好处?为什么我们要努力在面向对象代码库中应用动态多态?

想象一下,我们在代码库的某个地方有两个方法。

public string GetCurrencySign(string currencyCode)
{
    return currencyCode switch
    {
        "US" => "$",
        "JP" => "¥",
        _ => throw new ArgumentOutOfRangeException(nameof(currencyCode)),
    };
}

public decimal GetRoundUpAmount(decimal amount, string currencyCode)
{
    return currencyCode switch
    {
        "US" => Math.Floor(amount + 1),
        "JP" => Math.Floor(amount / 100 + 1) * 100,
        _ => throw new ArgumentOutOfRangeException(nameof(currencyCode))
    };
}

现在想象一下,我们必须添加另一个国家的支持。看起来不是什么大问题,但是想象一下这两个方法隐藏在那些“现实世界”的代码库中,有成千上万的类和数十万行代码。很可能,你会忘记所有应该添加国家支持的地方。这正是霰弹枪手术(Shotgun surgery)代码坏味。

我们如何解决这个问题?让我们把所有与国家代码相关的信息提取到一个地方。

public interface IPaymentStrategy
{
    string CurrencySign { get; }
    decimal GetRoundUpAmount(decimal amount);
}

现在当我们必须添加一个新的国家代码时,我们被迫实现上面的接口,所以我们肯定不会忘记任何事情。我们使用工厂来返回 `IPaymentStrategy` 的实例。

public string GetCurrencySign(string currencyCode)
{
    var strategy = _strategyFactory.CreateStrategy(currencyCode);
    return strategy.CurrencySign;
}

在上面的例子中,我们通过应用动态多态来修复了一个代码坏味。有时,我们设法满足了一些 SOLID 原则(即通过构建新的功能来扩展而不是修改来满足开闭原则),并应用了设计模式。通过应用一个简单的 OOD 原则,就可以获得一堆很酷的企业级的东西用于你的简历!

结论

软件工程师,就像我们大多数人一样,倾向于遵循许多原则而不质疑它们的理由。当这样做时,原则往往会失真并偏离其原始目标。因此,通过质疑原始目标是什么,我们就可以按照预期的方式应用这些原则。

在这篇文章中,我争辩说,OOD 的核心原则之一是动态多态的应用,而许多原则(SOLID、设计模式)只是围绕它构建的助记符。

历史

  • 2023 年 6 月 27 日:初始版本
© . All rights reserved.