函数式编程和解耦
Posted
技术标签:
【中文标题】函数式编程和解耦【英文标题】:Functional programming and decoupling 【发布时间】:2021-09-10 14:57:55 【问题描述】:我是您的经典 OOP 开发人员。然而,自从我发现纯函数式编程语言后,我就一直对为什么很感兴趣,因为 OOP 似乎以合理的方式解决了大多数业务案例。 在我的软件开发经验中,我现在已经到了寻求更简洁和富有表现力的语言的地步。我通常用 C# 编写我的软件,但对于我的最新项目,我决定迈出一大步,使用 F# 构建业务服务。在这样做的过程中,我发现很难理解如何使用纯函数方法完成解耦。
情况是这样的。我有一个数据源,即 WooCommerce,但我不想将我的函数定义绑定到那个特定的数据源。 在 C# 中,我显然想要一个看起来像这样的服务
public record Category(string Name);
public interface ICategoryService
Task<IEnumerable<Category>> GetAllAsync();
// With a definition for the service that specifies WooCommerce
public class WcCategoryService : ICategoryService
private readonly WCRestEndpoint wcRest;
// WooCommerce specific dependencies
public WcCategoryService(WCRestEndpoint wcRest)
this.wcRest = wcRest;
public Task<IEnumerable<Category>> GetAllAsync()
// Call woocommerce REST and map the category to our domain category
现在,如果将来我决定需要一个新的商店来提供类别,我可以为该特定服务定义一个新的实现,替换注入的类型,并且不会因为这种变化而弄乱依赖项。
试图了解函数依赖方法是如何解决的,我遇到了这种情况(阅读“Domain Modeling made functional”),其中类型签名直接定义依赖关系,因此上面的 C# 等效项将变成高度耦合的定义
type Category = Name: string
type GetCategories =
WCRestEndpoint
-> Category list
突然间,如果我要更改类别的来源,我将不得不更改功能签名或提供要使用的新定义,这会影响应用程序,因此不是很健壮。
我很好奇的是我是否误解了一些基本的东西。
以我的 OOP 大脑,我所能想到的就是这样
type Category = Name: string
// No longer directly dependent on WCRestEndpoint
type GetCategories = unit -> Category list
// But the definition would require scoped inclusion of the dependency
// Also how does the configuration get passed in without having the core library be dependent on the Environment or a config in the assembly?
let rest = WCRestEndpoint(/* Config... */)
type getCategories: GetCategories =
fun () ->
let wcCategories = rest.GetCategories()
// Convert the result into a Category type
我环顾四周,没有找到任何关于如何使用纯功能方法处理更改的解释,这让我相信我误解了一些基本的东西。
如何在不将函数类型签名绑定到实现特定类型的情况下公开函数式 API?我是不是想错了?
【问题讨论】:
不是答案,但blog.ploeh.dk 上写了很多关于功能架构的文章。 顺便说一句,与函数式程序员交谈时要小心使用 category 一词。 【参考方案1】:首先,正如@Karl Bielefeldt 所指出的,此处返回的正确类型是Async<seq<Category>>
。所以你的函数原本应该是WCRestEndpoint -> Async<seq<Category>>
类型的。
但这不是这里真正的问题。真正的问题是这种说法:
突然间,如果我要更改类别的来源,我将不得不更改功能签名或提供要使用的新定义,这会影响应用程序,因此不是很健壮。
这种说法对我来说毫无意义,因为在 F# 案例中重构实际上更简单。
无论您如何编码,您总是需要编写接受WCRestEndpoint
并输出Category
s 序列的代码。如果您决定实际上要以其他方式获得Category
s 的序列,那么无论如何您都需要编写新代码来执行此操作。
例如,假设我决定需要修改代码以从OtherCatGetter
而不是WCRestEndpoint
获取类别。在你的 C# 代码中,我需要替换
public class WcCategoryService : ICategoryService
private readonly WCRestEndpoint wcRest;
// WooCommerce specific dependencies
public WcCategoryService(WCRestEndpoint wcRest)
this.wcRest = wcRest;
public Task<IEnumerable<Category>> GetAllAsync()
// Call woocommerce REST and map the category to our domain category
与
public class OtherCategoryService : ICategoryService
private readonly OtherCatGetter getter;
// WooCommerce specific dependencies
public WcCategoryService(OtherCatGetter getter)
this.getter = getter;
public Task<IEnumerable<Category>> GetAllAsync()
// Do something with getter to get the categories
我们还必须将每次调用 new WcCategoryService(wcRest)
替换为调用 new OtherCategoryService(getter)
。
在F#
方面,我们必须替换
let getCategoriesFromWC (wcRest: WCRestEndpoint) = ... // get categories from wcRest
与
let getCategoriesFromOther (getter: OtherCatGetter) = ... // get categories from getter
并将每次出现的GetCategoriesFromWC wcRest
替换为出现的getCategoriesFromOther getter
。
显然,当您需要更改获取 Category
s 的方式时,F# 版本需要的重构更少,因为 F# 不必处理通过单次读取定义新公共类的样板文件-only 字段和单参数构造函数。如果您需要定义一种不同的方式来获取Category
s 的序列,您只需这样做,而不是跳过不必要的圈子。
【讨论】:
我明白你的意思,在什么情况下使用 ICategoryService 绝对不明显。在我的情况下,我将服务连接到 Microsoft 的依赖注入库中,因此每个 ASP.NET 控制器只需要引用接口而不是实际的实现,而连接新服务所需要做的就是重构 IServiceCollection 的接线。这会将受影响的区域集中到一个地方(在我的 Startup.fs 或 ServiceCollectionExtension.fs 中),而不是像您的示例中那样每次出现。 @BjarkeSporring 我不是 C# 方面的专家,但听起来你说你只需要在 C# 代码中调用一次new WcCategoryService(wcRest)
,这使得重构变得简单。但如果是这种情况,那么您只需在 F# 代码中调用一次getCategoriesFromWC wcRest
,这样您就可以以完全相同的方式将受影响的区域集中到 F# 代码中的一个位置。
是的,这正是我想要做的,你可能是对的,这就是我现在正在调查的。一旦我掌握了确切的方法,我会用我的更新帖子。谢谢你的观点。【参考方案2】:
恭喜您做出了尝试 F# 的绝佳选择!
以另一种方式回答您的问题:
@Asik 已经提到使用函数而不是单方法接口。这个想法很可能扩展到拥有将一堆相关功能组合在一起的记录;例如:
type MyEntityRepository =
Fetch: Guid -> Async<MyEntity>
Add: MyEntity -> Async<unit>
Delete: Guid -> Async<unit>
也总是可以使用接口,但我更喜欢这种方法,因为它更容易模拟(只需将 Unchecked.defaultof<_>
分配给测试代码不会使用的任何字段)并且语法看起来更好,等等东西。
如果您需要嵌套依赖项(您肯定会这样做),您可以简单地使用闭包:
let createRepository (connection: IDbConnection) =
Add = fun entity -> connection.Execute(...)
Fetch = fun id -> connection.Query(...)
本质上,您将依赖项提供给工厂函数,然后依赖项将在 lambda 的闭包中捕获,从而允许您尽可能深入地嵌套依赖项。这种使用工厂函数的模式也适用于 ASP.Net 的内置 DI 容器。
【讨论】:
【参考方案3】:您需要一个异步计算的类别列表。已经有一个类型:Async<seq<Category>>
。也许这是由使用WCRestEndpoint
的函数创建的。也许这是在单元测试中使用一些恒定的虚拟值创建的。也许这是为单元测试创建的,并且总是引发错误。消费代码不在乎。它只关心是否有办法获取类别。
这种类型比特定于应用程序的ICategoryService
类型更可重用。例如,也许您有一个函数采用Async<'a>
并以标准方式处理错误。也许你有一个函数接受 Async<seq<'a>>
并验证列表不为空。
老实说,你不需要一个特殊的名字来获取一种东西。
【讨论】:
完全正确地区分了这个函数需要什么——一个可用的类别列表——当他们准备好时,从一些将它与“类别服务”联系起来的“接口”——不管是什么是。 (特别是因为无论那是什么,一个“类别服务”,接口ICategoryService
提供(直接或间接通过继承)这个函数不需要,例如,如何启动和停止“服务” - 并且具有 that 与此使用相关是 完全不必要的依赖。)(但这也适用于“普通”OO 编程......尽管您经常看不到它使用正确。 ..)
顺便说一下,这是一个普遍困扰编程的例子,特别是 O-O 编程。一种特定的范式——尤其是像 O-O 这样流行的范式——在将其应用于实际用例时存在问题。因此,开发了一些工具来缓解这些问题。一开始很简单,但它们非常有效,人们构建更多它们并在它们的基础上构建它们,它们变得更复杂、更灵活......然后......工具接管了范式! OP 和其他人可能认为“依赖注入”是 OO 的一部分,因为 tool 能够注入 services ...
...很容易将服务注入视为O-O设计的一部分。但它不是:OO 设计的原则是通过隔离 设计决策来最小化依赖关系……但如果你买了一堆关于如何使用 Spring 或Hibernate 什么的,并且和一群同样沉浸在那种文化中的人一起工作......【参考方案4】:
如果你只想不使用对象,那是相当机械的重写。
单方法接口只是一个命名函数签名,所以:
public interface ICategoryService
Task<IEnumerable<Category>> GetAllAsync();
async Task UseCategoriesToDoSomething(ICategoryService service)
var categories = await service.GetAllAsync();
...
变成:
let useCategoriesToDoSomething(getAllAsync: unit -> Async<seq<Category>>) = async
let! categories = getAllAsync()
...
您的组合根变成了通过这些函数参数的具体实现部分应用函数的问题。
也就是说,使用对象本身并没有错; F# 主要拒绝可变性和继承性,但包含接口、点表示法等。
a talk Don Syme gave 有一张关于 F# 中 OO 的精彩幻灯片:
【讨论】:
【参考方案5】:在我意识到我以错误的方式看待它之前,我多年来一直在为这个问题苦苦挣扎。来自面向对象的开发和依赖注入,我一直在寻找依赖注入的功能替代方案。我终于意识到Dependency Injection makes everything impure,也就是说,如果你想申请a functional architecture,你不能使用那个方法(not even partial application)。
红鲱鱼是专注于依赖关系。相反,专注于编写纯函数。您仍然可以使用Dependency Inversion Principle,但不要关注操作和交互,而是关注数据。如果函数需要一些数据,请将其作为参数传递。如果某个函数必须做出决定,return it as a data structure。
您没有提供任何示例说明您希望在何处使用 Category
值列表,但依赖于此类数据的函数将具有如下类型:
Category list -> 'a
这样的功能与类别的来源完全解耦。它仅取决于 Category
类型本身,它是域模型的一部分。
最终,您需要从某个地方获取类别,但这项工作您会推到系统的边界,例如Main
:
let Main () =
let categories = getCategories ()
let result = myFunction categories
result
因此,如果您改变了如何获取类别的想法,您只需更改一行代码。这种架构是akin to a sandwich,在应用程序的纯心周围有不纯的动作。它也被称为functional core, imperative shell。
【讨论】:
我认为说依赖注入是不纯的(至少在一般情况下)是非常误导的。例如,代数效应是一种注入不同依赖项的稳健方式,同时保留所有代码除了依赖项纯。 (当然,Monad 也对此进行了建模,但依赖注入仅使用 monad 就需要更多的麻烦,例如变压器堆栈。)当说依赖注入使一切“不纯”时,您是什么意思? @gntskn 这是一个流行语,我相信对于大多数非退化依赖注入 (DI) 来说都是如此。在某些极端情况下它不成立,但除了偶尔注入的Strategy 之外,大多数反例都会退化。 DI 的大多数实际用途都涉及不纯的动作,一旦你有一个不纯的动作,所有的调用者也是传递不纯的。我希望我链接到的资源能够充分阐述这些观点,包括对替代品的认识。我不认为该短语具有误导性。 我可能应该说它是模棱两可的,而不是误导。我认为我的分歧取决于我们所说的“不纯洁”。 DI 可能确实意味着正在运行的程序是不纯的,但正在运行的程序几乎总是必须是不纯的。但是,这并不意味着程序本身是不纯的:您可以以 100% 纯的方式编写程序,直到有人提供不纯的效果评估器。我确实认为 DI 有一个“传染性”方面(类似于带有 async/await 的“函数着色”)可能导致蔓延。这可能与您的理解接近。【参考方案6】:我认为没有一个正确的答案,但这里有几点需要考虑。
首先,我认为现实世界的函数式代码通常具有“三明治结构”,其中包含一些输入处理,然后是纯函数转换和一些输出处理。 F# 中的 I/O 部分通常涉及与命令式和 OO .NET 库的接口。因此,关键的教训是将 I/O 保持在外部,并将核心功能处理与其分开。换句话说,在外部使用一些命令式 OO 代码来处理输入是非常有意义的。
其次,我认为解耦的想法在 OO 代码中更有价值,在这种代码中,您希望拥有复杂的接口和相互交织的逻辑。在功能代码中,(我认为)这不是一个问题。换句话说,我认为 I/O 不用担心这个是完全合理的,因为它只是“三明治结构”的外侧。如果您需要更改它,您可以直接更改它,而无需触及核心功能转换逻辑(您可以独立于 I/O 进行测试)。
第三,在实用方面,在 F# 中使用接口是完全合理的。如果你真的想做解耦,你可以定义一个接口:
type Category Name: string
type CategoryService =
abstract GetAllAsync : unit -> Async<seq<Category>>
然后你可以使用对象表达式来实现接口:
let myCategoryService =
new CategoryService with
member x.GetAllAsync() = async ...
然后,我将有一个主函数将seq<Category>
转换为您想要的任何结果,这不需要需要将CategoryService
作为参数。但是,在您的主要代码中,您可以将此作为参数(或在程序启动时将其初始化),使用服务获取数据并调用您的主要转换逻辑。
【讨论】:
我希望我也能接受这个答案。这确实帮助我了解了我在功能范式方面的想法缺乏的地方。以上是关于函数式编程和解耦的主要内容,如果未能解决你的问题,请参考以下文章