函数式编程和解耦

Functional programming and decoupling

我是您的经典 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
    }
}

现在,在未来,如果我决定我们需要一个新的商店来提供类别,我可以为该特定服务定义一个新的实现,替换注入的类型,而不是因为这个变化而弄乱依赖。

试图理解函数依赖方法是如何解决的定义

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?我是不是想错了?

我认为对此没有唯一的正确答案,但这里有几点需要考虑。

  • 首先,我认为现实世界的功能代码通常具有“三明治结构”,其中包含一些输入处理,然后是纯功能转换和一些输出处理。 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 作为参数.但是,在您的主要代码中,您会将其作为参数(或在程序启动时在某处对其进行初始化),使用该服务来获取数据并调用您的主要转换逻辑。

在我意识到我以错误的方式看待它之前,我为这个问题苦苦挣扎了很多年。来自面向对象的开发和依赖注入,我一直在寻找依赖注入的功能替代方案。我终于明白了 Dependency Injection makes everything impure, which means that you can't use that approach (not even partial application) if you want to apply a functional architecture.

转移注意力的是依赖关系。相反,专注于编写纯函数。您仍然可以使用 Dependency Inversion Principle, but instead of focusing on actions and interactions, focus on data. If a function requires some data, pass it as an argument. If a function has to make a decision, 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, with impure actions surrounding the pure heart of the application. It's also known as functional core, imperative shell.

如果您只想不使用对象,那么这是相当机械的重写。

单一方法接口只是一个命名函数签名,所以:

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# 面向对象的很棒的幻灯片:

您需要一个异步计算的类别列表。已经有一个类型:Async<seq<Category>>。也许这是由使用 WCRestEndpoint 的函数创建的。也许这是在单元测试中用一些恒定的虚拟值创建的。也许这是为单元测试创​​建的,并且总是会引发错误。消费代码并不关心。它只关心有没有办法获取类别。

此类型比特定于应用程序的 ICategoryService 类型更可重用。例如,也许您有一个接受 Async<'a> 并以标准方式处理错误的函数。也许您有一个接受 Async<seq<'a>> 并验证列表不为空的函数。

老实说,你不需要为获取一种东西的东西起一个特殊的名字。

祝贺您做出了尝试 F# 的绝佳选择!

以另一种方式回答您的问题:

@Asik 已经提到使用函数而不是单一方法接口。这个想法很可能会扩展到 records 将一堆相关的函数组合在一起;例如:

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 所指出的,return 的正确类型是 Async<seq<Category>>。所以你的函数最初应该是 WCRestEndpoint -> Async<seq<Category>>.

类型

但这不是这里的真正问题。真正的问题是这个声明:

Suddenly if I am to change the source of categories I would have to either change the functional signature or provide a new definition to be used which would ripple through the application and thereby not be very robust.

这种说法对我来说毫无意义,因为在 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

并用 getCategoriesFromOther getter.

替换每一次出现的 GetCategoriesFromWC wcRest

显然,当您需要更改获取 Category 的方式时,F# 版本需要较少的重构,因为 F# 不必处理定义新 public class 具有单个只读字段和一个参数构造函数。如果您需要定义一种不同的方式来获取 Category 的序列,您只需这样做而不是跳过不必要的箍。