我最近读了一本书,名为《用C#进行函数式编程》,我发现函数式编程的不可变和无状态本质可以实现与依赖注入模式相似的结果,并且可能甚至是更好的方法,尤其是在单元测试方面。
如果对这两种方法都有经验的人可以分享他们的思想和经验以回答主要问题,我将不胜感激:函数式编程是否可以替代依赖项注入模式?
我最近读了一本书,名为《用C#进行函数式编程》,我发现函数式编程的不可变和无状态本质可以实现与依赖注入模式相似的结果,并且可能甚至是更好的方法,尤其是在单元测试方面。
如果对这两种方法都有经验的人可以分享他们的思想和经验以回答主要问题,我将不胜感激:函数式编程是否可以替代依赖项注入模式?
Answers:
依赖管理是OOP中的一个大问题,其原因有两个:
大多数面向对象的程序员都认为,数据和代码的紧密耦合完全有益,但要付出代价。在任何范例中,管理通过层的数据流都是编程中不可避免的一部分。耦合数据和代码会增加一个额外的问题,即如果您想在某个特定位置使用某个函数,则必须找到一种将其对象传递至该位置的方法。
使用副作用会产生类似的困难。如果您对某些功能使用了副作用,但又希望能够交换其实现,则除了注入该依赖关系外,您别无选择。
以垃圾邮件发送程序为例,该程序将网页抓取为电子邮件地址,然后通过电子邮件将其发送出去。如果您有DI的心态,那么现在您正在考虑将要封装在接口后面的服务,以及哪些服务将注入到哪里。我将把该设计留给读者练习。如果您有FP思维定势,那么现在您正在考虑最低功能层的输入和输出,例如:
当考虑输入和输出时,没有函数依赖性,只有数据依赖性。这就是使它们如此容易进行单元测试的原因。您的下一层安排将一个功能的输出馈入下一个功能的输入,并可以根据需要轻松地交换各种实现。
从非常真实的意义上讲,函数式编程自然会促使您始终反转函数的依赖关系,因此,事后您通常不必采取任何特殊措施。当您这样做时,诸如高阶函数,闭包和部分应用程序之类的工具使使用更少的样板变得更容易完成。
请注意,问题本身并不是依赖项本身。依赖关系指出了错误的方式。下一层可能具有以下功能:
processText = spamToSMTP . emailAddressToSpam . removeEmailDups . textToEmailAddresses
对于这一层,将这样的依赖项进行硬编码是完全可以的,因为它的唯一目的是将较低层的功能粘合在一起。交换实现就像创建不同的组合一样简单:
processTextFancy = spamToSMTP . emailAddressToFancySpam . removeEmailDups . textToEmailAddresses
由于没有副作用,使这种简单的重组成为可能。下层功能彼此完全独立。下一层可以processText
根据一些用户配置选择实际使用的层:
actuallyUsedProcessText = if (config == "Fancy") then processTextFancy else processText
同样,这不是问题,因为所有依赖项都指向一种方式。我们不需要反转某些依赖关系就可以使它们全部指向相同的方向,因为纯函数已经迫使我们这样做。
请注意,通过config
向下传递至最低层而不是在顶部进行检查,可以使此过程更加紧密。FP不会阻止您执行此操作,但是如果您尝试这样做,它的确会使您更加烦恼。
System.String
。模块系统可以让您替换System.String
为变量,这样就不会对字符串实现的选择进行硬编码,但仍可以在编译时解决。
功能编程是依赖注入模式的可行替代方案吗?
这使我感到奇怪。函数式编程方法在很大程度上与依赖项注入有关。
当然,具有不变的状态会导致副作用或将类状态用作函数之间的隐式契约,从而使您不致作弊。它使数据传递更加明确,我认为这是依赖项注入的最基本形式。传递函数的函数式编程概念使操作变得更加容易。
但这并不能消除依赖性。您的操作仍然需要状态可变时所需的所有数据/操作。而且您仍然需要以某种方式获得那些依赖。因此,我不会说功能编程方法完全可以取代 DI,因此别无选择。
如果有的话,他们只是向您展示了不良的OO代码如何创建隐式依赖关系,这是程序员很少考虑的。
您问题的快速答案是:不。
但是,正如其他人所断言的那样,这个问题结合了两个彼此无关的概念。
让我们一步一步地做。
函数编程的核心是纯函数-将输入映射到输出的函数,因此对于给定的输入,您始终可以获得相同的输出。
DI 通常意味着您的装置不再是纯净的,因为输出可能会因进样而变化。例如,在以下函数中:
const bookSeats = ( seatCount, getBookedSeatCount ) => { ... }
getBookedSeatCount
(一个函数)可能会有所不同,对于相同的给定输入,会产生不同的结果。这也使bookSeats
不纯。
对此有一些例外-您可以注入实现相同输入-输出映射的两种排序算法之一,尽管使用的算法不同。但是这些都是例外。
正如功能编程源中所断言的那样,系统不能纯净的事实同样被忽略。
系统必须具有副作用,其中显而易见的示例是:
因此,系统的一部分必须包含副作用,并且该部分还可能包含命令式风格或OO风格。
借用Gary Bernhardt关于边界的精彩演讲中的术语,一个好的系统(或模块)体系结构将包括以下两层:
关键要点是将系统“拆分”为纯部分(核心)和不纯部分(外壳)。
尽管提供了稍微有缺陷的解决方案(和结论),但Mark Seemann的文章提出了完全相同的概念。Haskell实现特别具有洞察力,因为它表明可以使用FP来完成所有工作。
即使您的应用程序很纯,使用DI也是完全合理的。关键是将DI限制在不纯的外壳中。
一个示例将是API存根-您需要在生产中使用真正的API,但在测试中使用存根。坚持壳核心模型将在这里大有帮助。
因此,FP和DI并非完全替代。您可能同时在系统中拥有这两个建议,建议是确保将系统的纯净部分和不纯净部分分开,FP和DI分别驻留在该部分中。
从OOP的角度来看,可以将功能视为单方法接口。
接口比功能更强大。
如果您使用功能性方法并进行大量DI,那么与使用OOP方法相比,您将为每个依赖项获得更多候选。
void DoStuff(Func<DateTime> getDateTime) {}; //Anything that satisfies the signature can be injected.
与
void DoStuff(IDateTimeProvider dateTimeProvider) {}; //Only types implementing the interface can be injected.