受歧视的工会是否与开闭原则冲突

Do Discriminated Unions conflict with the Open Close Principle

我不禁质疑在大型系统中使用歧视联盟是否违反了Open/Close原则。

我理解 Open/Close 原则是面向对象的,而不是函数式的。但是,我有理由相信存在相同的代码味道。

我经常避免使用 switch 语句,因为我通常被迫处理最初没有考虑到的情况。因此,我发现自己必须用新案例和一些相关行为来更新每个参考文献。

因此,我仍然相信 Discriminated Union 与 switch 语句具有相同的代码味道。

我的想法准确吗?

为什么 switch 语句不受欢迎,而 Discriminated Union 却受到欢迎?

随着代码库的发展或偏离,我们是否不会 运行 陷入与我们使用 switch 语句时使用可区分联合的维护问题相同的问题?

对象和受歧视的联合具有彼此双重的限制:

  • 使用接口时,很容易添加新的 class 来实现接口而不影响其他实现,但很难添加新方法(即,如果添加新方法,则需要添加实现每个 class 实现接口的方法。
  • 在设计 DU 类型时,很容易在不影响其他方法的情况下使用该类型添加新方法,但很难添加新案例(即,如果添加新案例,则需要更新每个现有方法来处理它).

因此 DU 绝对不适合对所有问题进行建模;但传统的 OO 设计也不是。通常,您知道 "direction" 您需要在以后进行修改,因此很容易选择(例如,列表肯定是空的或者有头尾,所以通过 DU 对它们建模是有意义的) .

有时您希望能够在两个方向上扩展事物(添加新的 "kinds" 对象并添加新的 "operations")- 这与 expression problem, and there aren't particularly clean solutions in either classic OO programming or classic FP programming (though somewhat baroque solutions are possible, see e.g. Vesa Karvonen's comment here, which I've transliterated to F# here 相关)。

DU 比 switch 语句更受欢迎的一个原因是 F# 编译器对穷举和冗余检查的支持比 C# 编译器对 switch 语句的检查更彻底(例如,如果我有 match x with | A -> 'a' | B -> 'b' 然后我添加一个新的 DU 案例 C 然后我会得到一个 warning/error,但是在 C# 中使用 enum 时我需要有一个 default 案例所以compile-time 检查不能那么强)。

我不确定你的 Open-Close 面向对象原则的方法是什么,但我经常 end-up 通过诉诸 higher order functions 来实现这样的原则代码,我采用的另一种方法是使用接口。我倾向于避免使用 base 类.

您可以对 DU 使用相同的方法,方法是在其他更 hard-coded 更 hard-coded 的有用案例之上有一个具有仿函数作为参数的扩展案例:

type Cases<T> =
| Case1 of string
| Case2 of int
| Case3 of IFoo
| OpenCase of (unit -> T)

当使用 OpenCase 时,您可以传递一个特定于站点的函数,您实例化该可区分联合的值。

Why are switch statements frowned upon but Discriminated Unions are embraced?

您可以将 DU 与模式匹配进行比较,所以我会尝试澄清:

模式匹配是一种代码构造(如 switch),而 DU 是一种类型构造(如 类 或结构或枚举的封闭层次结构)。

在 F# 中使用 match 的模式匹配比在 C# 中使用 switch 更强大。

Do we not run into the same maintenance concerns using Discriminated Unions as we do switch-statements as the codebase evolves or digresses?

与模式匹配一​​起使用的可区分联合比常规 switch 语句具有更多类型 safety/exhaustiveness 属性,编译器更有帮助,因为它会发出不完全匹配的警告,而 switch 语句则不会C#.

您可能对 Open-Close 原则性的 OO 代码存在维护问题,我认为 DU 与此无关。

在我看来,Open/Closed 原则有点模糊 -- "open for extension" 到底是什么意思?

这是否意味着使用新数据进行扩展,或使用新行为进行扩展,或两者兼而有之?

引用 Betrand Meyer 的话(摘自 Wikipedia):

A class is closed, since it may be compiled, stored in a library, baselined, and used by client classes. But it is also open, since any new class may use it as parent, adding new features. When a descendant class is defined, there is no need to change the original or to disturb its clients.

引用罗伯特·马丁 article 的话:

The open-closed principle attacks this in a very straightforward way. It says that you should design modules that never change. When requirements change, you extend the behavior of such modules by adding new code, not by changing old code that already works.

我从这些引述中得出的结论是强调永远不要破坏依赖您的客户。

在 object-oriented 范式 (behavior-based) 中,我会将其解释为使用接口(或抽象基础 类)的建议。然后,如果 需求发生变化,您可以创建现有接口的新实现,或者,如果需要新行为,则创建一个扩展的新接口 原来的。 (顺便说一句,switch 语句不是面向对象的——you should be using polymorphism!)

在函数范式中,从设计的角度来看,接口相当于一个函数。就像您将接口传递给 OO 设计中的对象一样, 在 FP 设计中,您会将一个函数作为参数传递给另一个函数。 更重要的是,在 FP 中,每个函数签名都会自动成为一个 "interface"!功能的实现可以在以后更改,只要 它的函数签名没有改变。

如果您确实需要新的行为,只需定义一个新函数 -- 旧函数的现有客户端不会受到影响,而客户端 需要此新功能的用户需要进行修改以接受新参数。

扩展 DU

现在,在 F# 中更改 DU 需求的特定情况下,您可以通过两种方式扩展它而不影响客户端。

  • 使用组合从旧数据类型构建新数据类型,或者
  • 对客户隐藏案例并使用主动模式。

假设您有一个像这样的简单 DU:

type NumberCategory = 
    | IsBig of int 
    | IsSmall of int 

并且您想添加一个新案例 IsMedium

在组合方法中,您将在不触及旧类型的情况下创建新类型,例如:

type NumberCategoryV2 = 
    | IsBigOrSmall of NumberCategory 
    | IsMedium of int 

对于只需要原始 NumberCategory 组件的客户,您可以像这样将新类型转换为旧类型:

// convert from NumberCategoryV2 to NumberCategory
let toOriginal (catV2:NumberCategoryV2) =
    match catV2 with
    | IsBigOrSmall original -> original 
    | IsMedium i -> IsSmall i

您可以将其视为一种显式向上转换:)

或者,您可以隐藏案例,只公开活动模式:

type NumberCategory = 
    private  // now private!
    | IsBig of int 
    | IsSmall of int 

let createNumberCategory i = 
    if i > 100 then IsBig i
    else IsSmall i

// active pattern used to extract data since type is private
let (|IsBig|IsSmall|) numberCat = 
    match numberCat with
    | IsBig i -> IsBig i 
    | IsSmall i -> IsSmall i 

稍后,当类型发生变化时,您可以更改活动模式以保持兼容:

type NumberCategory = 
    private
    | IsBig of int 
    | IsSmall of int 
    | IsMedium of int // new case added

let createNumberCategory i = 
    if i > 100 then IsBig i
    elif i > 10 then IsMedium i
    else IsSmall i

// active pattern used to extract data since type is private
let (|IsBig|IsSmall|) numberCat = 
    match numberCat with
    | IsBig i -> IsBig i 
    | IsSmall i -> IsSmall i 
    | IsMedium i -> IsSmall i // compatible with old definition

哪种方法最好?

好吧,对于我完全控制的代码,我不会使用任何一个——我只会对 DU 进行更改并修复编译器错误!

对于作为 API 向我无法控制的客户端公开的代码,我会使用活动模式方法。

Switch 语句与 Open/Closed 原则并不对立。这完全取决于你把它们放在哪里。

OCP 告诉您添加依赖项的新实现不应该强迫您修改使用它们的代码。

但是,当您添加一个新的实现时,决定选择该实现 而不是另一个实现的 逻辑必须位于代码中的某个位置。新的 class 不会被魔法考虑在内。这样的决定可以发生在 IoC 容器配置代码中,或者在程序执行期间的某处条件中。 这个条件完全可以是一个 switch 语句

模式匹配也是如此。您可以使用它来决定将哪个函数传递给高阶函数 F(这相当于在 OO 中注入依赖项)。这并不意味着 F 本身做出选择或知道传递给它的具体函数。抽象被保留。