问题描述
我是您的经典 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
}
}
现在,如果我决定需要一个新的商店来提供类别,我可以为该特定服务定义一个新的实现,替换注入的类型,并且不会因为这种变化而搞乱依赖项。
试图了解函数依赖方法是如何解决的,我遇到了这种情况(阅读“领域建模使函数化”),其中类型签名直接定义依赖关系,因此上面的 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?我想错了吗?
解决方法
我在这个问题上挣扎了很多年,然后才意识到我的看法是错误的。来自面向对象开发和依赖注入,我一直在寻找依赖注入的功能替代方案。我终于意识到Dependency Injection makes everything impure,这意味着如果你想应用not even partial application,你不能使用那个方法(a functional architecture)。
红鲱鱼是关注依赖关系。相反,专注于编写纯函数。您仍然可以使用 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。
,我认为这个问题没有唯一的正确答案,但这里有几点需要考虑。
-
首先,我认为现实世界的函数代码通常具有“三明治结构”,带有一些输入处理,然后是纯函数转换和一些输出处理。 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
作为参数。但是,在您的主要代码中,您可以将其作为参数(或在程序启动时将其初始化),使用该服务获取数据并调用您的主要转换逻辑。
如果您只想不使用对象,这是一个相当机械的重写。
单方法接口只是一个命名的函数签名,所以:
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 的精彩幻灯片:
,您需要一个异步计算的类别列表。已经有一个类型:Async<seq<Category>>
。也许这是由使用 WCRestEndpoint
的函数创建的。也许这是在单元测试中使用一些常量虚拟值创建的。也许这是为单元测试而创建的,并且总是会引发错误。消费代码并不关心。它只关心有没有办法获取类别。
这种类型比特定于应用程序的 ICategoryService
类型更容易重用。例如,也许您有一个函数接受 Async<'a>
并以标准方式处理错误。也许您有一个函数接受 Async<seq<'a>>
并验证列表不为空。
老实说,您不需要为获取某种事物的事物取一个特殊名称。
,恭喜您做出了尝试 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 容器。
,首先,正如@Karl Bielefeldt 所指出的,此处返回的正确类型是 Async<seq<Category>>
。所以你的函数最初应该是 WCRestEndpoint -> Async<seq<Category>>
类型。
但这不是真正的问题。真正的问题是这个声明:
突然间,如果我要更改类别的来源,我将不得不更改功能签名或提供要使用的新定义,这会在应用程序中产生涟漪效果,因此不是很健壮。
这种说法对我来说根本没有任何意义,因为在 F# 案例中,重构实际上更简单。
无论您如何编码,您总是需要编写采用 WCRestEndpoint
并输出一系列 Category
的代码。如果您决定实际上要以其他方式获取 Category
的序列,那么无论如何您都需要编写新代码来执行此操作。
例如,假设我决定需要修改代码以从 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
的方式时,F# 版本需要较少的重构,因为 F# 不必处理定义新公共类的样板单个只读字段和一个单参数构造函数。如果您需要定义一种不同的方式来获取 Category
的序列,您只需这样做,而不是跳过不必要的圈套。