65.9K
CodeProject 正在变化。 阅读更多。
Home

无类编码 - 极简 C# 以及 F# 和函数式编程的优势

starIconstarIconstarIconstarIconstarIcon

5.00/5 (16投票s)

2017 年 8 月 7 日

CPOL

34分钟阅读

viewsIcon

36954

downloadIcon

125

C# 中不太“有范儿”,但在 F# 中却很有“范儿”。

目录

太长不看

换句话说,“抽象”。实际上,这本身就相当长了!

我们能否仅使用原生的 .NET 类来开发代码,而不是立即编写一个应用程序特定的类,而这些类通常只不过是一个容器?我们能否使用别名、流畅的风格和扩展方法来实现这一点?如果我们只使用 .NET 类,我们将不得不使用泛型字典、元组和列表,这会很快变得难以处理。我们可以使用 `using` 语句为这些类型创建别名,但这需要将这些 `using` 语句复制到每个想要使用该别名的 .cs 文件中。流畅的(“点风格”)表示法通过以“工作流风格”的表示法来减少代码行数。在 C# 中,如果我们不编写带有成员方法的类,那么我们就必须实现行为作为扩展方法。别名在使用别名定义时会以混淆泛型类型嵌套为代价,从而在一定程度上提高了语义可读性。扩展方法可能会被过度使用,导致两个规则:为语义表达编写低级函数,并避免嵌套括号,这需要程序员在头脑中维护一个工作流“栈”。与 C# 的 `using` 别名不同,F# 类型定义不是别名,而是具体的类型。可以从现有类型创建新的类型定义。类型定义还可以用于指定函数的参数和返回值。前向管道运算符 `|>` 类似于 C# 中的流畅“点”表示法,但 `|>` 运算符左侧的值会“填充”函数参数列表中的最后一个参数。当编写返回某些内容的函数时,最后一个函数必须被管道传输到 `ignore` 函数,这有点尴尬。F# 类型依赖关系基于项目中文件的顺序,因此必须先定义类型才能使用它。在 C# 中,创建更复杂的别名会很快变得混乱——这是一个实验,而不是对编码实践的推荐!在 F# 中,我们不需要 Action 或 Func 类来传递函数,因为 F# 本身支持声明函数参数和返回值的类型定义——换句话说,函数式编程中的一切实际上都是函数。元组在 C# 中是一个类,但在函数式编程中是原生的,尽管 C# 6.0 使使用元组非常类似于 F#。虽然 C# 允许函数参数为 null,但在 F# 中,你必须传递一个实际的函数,即使该函数什么也不做。F# 使用名义(“按名称”)推理引擎而不是结构推理引擎,为类型赋予语义上有意义的名称非常重要,以便类型推断引擎可以推断出正确的类型。在 C# 中,更改类成员不会影响类类型。F# 则不然(至少对于纯粹的记录类型而言)——更改记录的结构会更改记录的类型。更改 C# 类成员可能会导致不正确的初始化和使用等问题。继承,特别是与可变字段结合使用时,可能会导致具有隐含理解的行为,例如“这永远不会发生”突然被打破。扩展方法和重载会产生语义歧义。F# 本身不支持重载——函数必须具有语义上不同的名称,而不仅仅是不同的类型或参数列表。面向对象编程和函数式编程都有其优缺点,这里希望能提供一些具体的讨论。

引言

总结:我们能否仅使用原生的 .NET 类来开发代码,而不是立即编写一个应用程序特定的类,而这些类通常只不过是一个容器?我们能否使用别名、流畅的风格和扩展方法来实现这一点?

我有时会问自己一些旨在挑战我对编程基本概念的问题。我最近在 C# 编码方面问自己的问题是:

  • 我真的需要所有这些类吗?

显然,有很多编程语言不需要一定使用类,例如 JavaScript,其中类是“主要基于 JavaScript 现有原型继承的语法糖”。(参考)我提出这个问题的基础是,无论何时我在 C# 中开始编写代码,我做的第一件事就是编写 `class Foo` 或 `static class Foo`。C# 强制执行这种行为——一切,甚至扩展方法,都需要包装在一个类中。所以这个问题立即演变成了:

  • 在编写 C# 代码时,我真的需要实现专门的类来作为数据和行为的容器吗?

我决定通过重新实现我大多数编码中最常用的三个核心组件来回答这个问题:

  1. 服务管理器
  2. 线程池(一种服务)
  3. 语义发布/订阅(另一种服务)

并且不使用 .NET 框架提供的任何类。(不要问我为什么使用线程池而不是 `Task` 等——答案超出了本文的范围,所以就接受它吧,即使你对实现有意见。)C#(使用 .NET 4.7)具有丰富的语法来利用现有的 .NET 框架类,所以让我们来看看一个不编写任何自定义类(除了扩展方法容器类)的实现。

(这是一个“旁注”)——这个问题的一个次要方面是确定代码是否可以写得更易于理解。我坚信**你阅读的代码越少,就越容易理解代码在做什么**。**我们会看看效果如何。**

那么,我们该如何做到这一点呢?为什么这会让我们开始关注 F#?

一、类型别名

总结:如果我们只使用 .NET 类,我们将不得不使用泛型字典、元组和列表,这会很快变得难以处理。我们可以使用 `using` 语句为这些类型创建别名,但这需要将这些 `using` 语句复制到每个想要使用该别名的 .cs 文件中。

编写类的便利之处之一是它表达了某个事物的强类型别名。例如,服务管理器需要一种方法将服务“名称”映射到服务实现。服务“名称”是一个相当抽象的概念。例如,它可以是字符串文字、类型(如接口)、类型或枚举。字符串文字由于拼写错误和大小写敏感性而是一个坏主意,而枚举则是一个坏主意,因为与枚举名称相关的数值可能会改变。接口是一个坏主意,因为服务管理器需要对定义接口的程序集或程序集有引用。所以,我们将使用类型映射,并将使用 `ConcurrentDictionary`,因为我希望服务管理器是线程安全的,并且我不想自己处理线程安全问题,因为 .NET 提供了不错的线程安全字典。

ConcurrentDictionary<Type, Object>

服务实现是一个 `Object`,我对这一点不太满意,但我们将继续这样做,因为我不想用接口和类型约束来“污染”我的代码。

无论何时我们需要传入服务管理器,都必须编写 `ConcurrentDictionary`,如下所示:

void NeedsAService(ConcurrentDictionary<Type, Object> serviceManager)

这很麻烦,很难看,并且在类型中不传达任何有意义的信息。太糟糕了。但是我们可以创建一个别名来代替:

using Services = System.Collections.Concurrent.ConcurrentDictionary<System.Type, object>;

这里有趣的是——请注意,我没有称之为“服务管理器”——它是一个服务类型的集合,映射到它们的实现,所以我称之为“Services”。

这种方法的缺点是,别名需要在每个使用该类型的 .cs 文件中定义。又一个糟糕的地方,但我们将继续下去,因为我现在可以这样写:

void NeedsAService(Services serviceManager)

在 C# 中,使用 .NET 类的别名会破坏 OOP 的封装原则——别名对象可以直接被调用者操作,调用者可以删除项目,进行错误的键值关联等等。讽刺的是,如果用 F# 以不可变字典、元组和集合的形式实现,这就不成问题了,因为操作集合或字典会导致字典或集合的副本(或者最多是一个共享尾部但可以有不同头部的集合)——但这种方法超出了本文的范围。

二、流畅性

总结:流畅的(“点风格”)表示法通过以“工作流风格”的表示法来减少代码行数。

我想探索的“极简编码”的另一个原则是“流畅”(点表示法)的编程风格。原因?因为它产生了一种非常简洁和线性的语法来完成任务。例如(稍后我会详细阐述):

new Thread(new ThreadStart(() => aThread.Forever(t => t.WaitForWork().GetWork().DoWork()))).IsBackground().StartThread();

典型的实现看起来像这样:

private void ThreadHandler()
{
  while (true) 
  {
    WaitForWork();
    var work = GetWork();
    DoWork(work);
  }
}

var thread = new Thread(new ThreadStart(ThreadHandler));
thread.IsBackground = true;
thread.Start();

记住,我不是在争论这两种方法的优缺点,我是在探索极简编程风格会是什么样子,而流畅的表示法是这种极简方法的一部分,至少在高层次的“我,作为我的库代码的用户”的抽象层面是如此。

三、扩展方法

总结:在 C# 中,如果我们不编写带有成员方法的类,那么我们就必须实现行为作为扩展方法。

如果我们不将行为包装在类中(请记住,这些类只不过是更通用的容器的别名),那么我们如何实现语义行为呢?通过扩展方法!至少在 C# 方面,这颠倒了整个编程模型。而不是:

class ASemanticAliasOf
{
  SomeCollection collection;

  void SomeOperationA() {...}
  void SomeOperationB() {...}
}

我们改为:

static void SomeOperationA(this SomeCollection collection) {...}
static void SomeOperationB(this SomeCollection collection) {...}

当然,扩展方法需要包装在 `static class` 中。这个是逃不掉的!

一个极简的 C# 服务管理器

总结:别名在使用别名定义时会以混淆泛型类型嵌套为代价,从而在一定程度上提高了语义可读性。扩展方法可能会被过度使用,导致两个规则:为语义表达编写低级函数,并避免嵌套括号,这需要程序员在头脑中维护一个工作流“栈”。

字典就是一个字典,它不知道自己是服务管理器,虽然我们可以直接在代码中使用它,但当我们进行键值对赋值时,会失去很多语义含义:

serviceManager[typeof(MyService)] = myService;

其他有用的行为也会丢失,例如:

  • myService 实际上是 MyService 类型吗?
  • MyService 是否已分配给某个服务?
  • myService 是否为 null?

为了缓解这个问题,服务管理器有一些扩展方法来提高使用字典的语义。但首先,我们将定义类型别名:

using Service = System.Object;
using Services = System.Collections.Concurrent.ConcurrentDictionary<System.Type, System.Object>;

请注意,我映射到的是一个对象,而不是一个声明实例“是一种服务类型”的接口。这更多是为了避免弄乱示例,尤其是 F# 示例,使其包含不必要的类型。对于 C# 实现来说,这也是一个无关紧要的点,因为从其类型获取服务会将对象类型转换回该类型,所以我们都很好(这是名人常说的话。)

别名的局限性

请注意,在 C# 中,我们不能在其他别名中使用别名。换句话说,我们不能说:

using Services = System.Collections.Concurrent.ConcurrentDictionary<System.Type, Service>;

也请注意显式的“命名空间-点-类”表示法。我们也不能这样做:

using Services = ConcurrentDictionary<Type, Service>;

这是使用 C# 中的别名的一个缺点,因为别名表示法很快就会变得很难看——稍后我们将看到这一点。

语义扩展方法

我们将实现极简的扩展方法来为服务管理器操作提供更好的语义:

public class ServiceManagerException : Exception
{
  public ServiceManagerException(string msg) : base(msg) { }
}

public static class ServiceManagerExtensionMethods
{
  public static Services Register<T>(this Services services, T service)
  {
    services.ShouldNotContain<T>()[typeof(T)] = service;

    return services;
  }

  public static T Get<T>(this Services services)
  {
    return (T)services.ShouldContain<T>()[typeof(T)];
  }

  private static Services ShouldNotContain<T>(this Services services)
  {
    if (services.ContainsKey(typeof(T)))
    {
      throw new ServiceManagerException(String.Format("Service {0} already registered.", typeof(T).Name));
    }

    return services;
  }

  private static Services ShouldContain<T>(this Services services)
  {
    if (!services.ContainsKey(typeof(T)))
    {
      throw new ServiceManagerException(String.Format("Service {0} is not registered.", typeof(T).Name));
    }

    return services;
  }
}

请注意,我在公共 RegisterGet 方法中也使用了流畅的风格。

示例用法

假设我们有一个线程池和发布/订阅对象,我们现在可以将它们注册为服务:

Services services = new Services().Register(aThreadPool).Register(pubsub);

除非“程序员用户”检查类型(当然,不得不将那些“using...”别名放在代码文件顶部),否则这看起来就像实例化一个类并以流畅的表示法调用该类的成员。

过度使用流畅性和扩展方法

人们可以将这种扩展方法的概念进一步发展,例如:

private static Services ShouldNotContain<T>(this Services services)
{
  return services.Assert(!services.ContainsKey(typeof(T)), String.Format("Service {0} already registered.", typeof(T).Name)));
}

甚至更糟:

services.Assert(!services.ContainsKey(typeof(T)), String.Format("Service {0} already registered.", typeof(T).Name)))[typeof(T)] = service;

但我发现这方向错了——程序员必须阅读大量的参数和嵌套的括号才能弄清楚发生了什么。而且,当他们解开参数和括号嵌套时,他们已经忘记了外部上下文试图完成什么,即类型到实现的映射。

两个有用的规则

这实际上带来了一些关于扩展方法应如何使用的良好指导:

1. 编写封装行为语义的低级方法。

这两个方法很好:

private static Services ShouldNotContain<T>(this Services services)
private static Services ShouldContain<T>(this Services services)

因为它们可以以语义可读的方式使用:

services.ShouldNotContain<T>()[typeof(T)] = service;
return (T)services.ShouldContain<T>()[typeof(T)];

但也许你不同意!

2. 避免复杂的括号嵌套。

这个是可读的:

Services services = new Services().Register(aThreadPool).Register(pubsub);

这个变得不那么可读了:

Services services = new Services().Register(new ThreadPool()).Register(new SemanticPubSub());

所以, wherever you end up with stringing (hah!) closing parens together, extract the inner operations until you get something clean.

一个极简的 F# 服务管理器

总结:与 C# 的 `using` 别名相比,类型定义不是别名,而是具体的类型。可以从现有类型创建新的类型定义。类型定义还可以用于指定函数的参数和返回值。前向管道运算符 `|>` 类似于 C# 中的流畅“点”表示法,但 `|>` 运算符左侧的值会“填充”函数参数列表中的最后一个参数。当编写返回某些内容的函数时,最后一个函数必须被管道传输到 `ignore` 函数,这有点尴尬。F# 类型依赖关系基于项目中文件的顺序,因此必须先定义类型才能使用它。

我不是纯粹的函数式编程者。世界是动态的,我并不介意在 F# 代码中使用可变的 C# 类——处理尾递归和 Monad 来创建纯粹的不可变 FP 代码可能很有趣,但至少对于初学者 FP 程序员来说,代码会变得非常难以阅读。当然,每个 FP 程序员至少应该了解尾递归,以及 Monad 和计算表达式。然而,就本文而言,我想在 C# 代码和 F# 代码之间进行尽可能一对一的比较,因此 F# 代码利用了可变的 C# 类。另一个声明是,我绝不是 FP 专家,所以如果你是,并且有更好的方法来做某事,请在本文的评论区分享你的知识。

我实际上是从线程池服务开始的,稍后会讨论,但无论如何,此时我开始意识到我的编码看起来很像函数式编程:

  • C# 点表示法 vs. F# 前向管道运算符 `|>`
  • 扩展方法 vs. 可以柯里化或部分应用的函数定义
  • 没有类,只有类型(当然,是别名)
  • 线程池和发布/订阅中的匿名方法(函数)用例

所以我决定看看 F# 中的实现是什么样子。

首先,模块(类似于命名空间,我在这里不详细介绍)和 .NET 引用:

module ServiceManagerModule

open System
open System.Collections.Concurrent

其次,类型定义:

type ServiceManagerException = Exception
type Service = Object
type Services = ConcurrentDictionary<Type, Service>

哇,这干净多了,因为**我们没有创建别名**,而是创建了实际的类型!请注意,类型不需要完全限定的“命名空间-点-类”表示法,并且类型可以引用先前声明的类型。

我们还可以创建“类型化”了几个函数的类型:

type RegisterService = Service -> Services -> Services
type GetService<'T> = Services -> 'T

第一个类型表示“一个接受 `Service` 和 `Services` 类型并返回 `Services` 类型的函数”。

第二个类型表示“一个带有泛型参数 `'T` 的函数,它接受一个 `Services` 类型并返回一个 `T` 类型的类型。”

在函数本身中,语义上声明一个函数类型而不是将一堆 `(var : type)` 表达式串联起来可能非常方便。

接下来,实现:

let ShouldContain<'T> (services : Services) =
  if not (services.ContainsKey(typeof<'T>)) then
    raise (ServiceManagerException("Service is not registered."))
  services

let ShouldNotContain<'T> (services : Services) =
  if (services.ContainsKey(typeof<'T>)) then
    raise (ServiceManagerException("Service already registered."))
  services

let RegisterService:RegisterService = fun service services ->
  (services |> ShouldNotContain).[service.GetType()] <- service
  services

let GetService<'T> : GetService<'T> = fun services ->
  (services |> ShouldNotContain).[typeof<'T>] :?> 'T

请注意,当我们指定函数类型而不是从函数参数和返回值推断它时,语法上存在细微差别。另请注意奇怪的 `:?>` 运算符,它是“向下转换”运算符,相当于我们在 C# 中使用的 `(T)` 转换。

return (T)services.ShouldContain<T>()[typeof(T)];

F# 的另一个优势是,我声明的类型不必在每个 .fs 文件中重新声明,因为 F# 代码是**按顺序**编译的,即按照项目 .fs 文件的顺序——只要 .fs 文件在项目文件列表的下面使用之前声明了类型,就可以了。这是“依赖排序”,如果您有递归类型定义,处理起来可能会很麻烦。这里有一个关于解决这个问题的讨论 在这里,当在单个文件中处理前向类型引用时,还有一个 `and` 关键字,请参阅 这里的示例

最后,请注意,与 C# 一样,每个函数(GetService 函数除外)都返回 Services 类型,允许前向管道运算符继续评估其他表达式。这引出了一个重要的问题——前向管道运算符总是提供函数的最后一个参数(或参数)。因此,要实现我们在 C# 中使用的“流畅”风格,我们想要“向前传递给下一个函数”的类型需要是最后一个参数。这里有一个简单的示例,说明了 F# 交互式中的这一点:

> let f a b = printfn "%d %d" a b;;
f 1 2;;
1 |> f 2;;
val f : a:int -> b:int -> unit

> 1 2
val it : unit = ()

> 2 1
val it : unit = ()

在第二个形式 `1 |> f 2;;` 中,请注意“1”是第二个打印的。当编写具有前向管道意图(一般和作为流畅表示法)的函数时,这是一个重要的设计考虑因素。

使用这种表示法,我们可以在 F# 中编写出在 C# 中看起来非常相似的代码(除了没有括号):

let services = new Services() |> RegisterService threadPool |> RegisterService pubsub

但有一个警告。假设我们想这样写:

let services = new Services()
services |> RegisterService threadPool |> RegisterService pubsub |> ignore

请注意末尾的 `|> ignore`。因为 `RegisterService` 函数返回一个类型,F#(我相信任何 FP 语言都是如此)期望你**对该类型做一些事情**。因为我们没有对该类型做任何事情,所以我们必须将其传递给 `ignore` 函数,该函数返回一个“unit”——表示无。你可以在上面的函数“f”的定义中看到这一点:

val f : a:int -> b:int -> unit

在这里,函数返回一个“unit”,因为 `printfn` 函数被定义为返回一个“unit”。

因此,“流畅”的 F# 表示法可能会导致你的 F# 代码中充斥着 `|> ignore` 表达式。这可以通过让你的 F# 函数返回“unit”来避免,但那样你就会失去语法的“流畅性”。

一个极简的线程池

总结:在 C# 中,创建更复杂的别名会很快变得混乱——这是一个实验,而不是对编码实践的推荐!在 F# 中,我们不需要 Action 或 Func 类来传递函数,因为 F# 本身支持声明函数参数和返回值的类型定义——换句话说,函数式编程中的一切实际上都是函数。元组在 C# 中是一个类,但在函数式编程中是原生的,尽管 C# 6.0 使使用元组非常类似于 F#。虽然 C# 允许函数参数为 null,但在 F# 中,你必须传递一个实际的函数,即使该函数什么也不做。

上一节涵盖了所有内容,但我发现继续在 C# 和 F# 中采用这种方法来探索任何进一步的细微差别,特别是元组的使用,非常有趣。我将从现在开始更并排地展示 C# 和 F# 代码。如果你想知道为什么我实现了自己的线程池而不是使用 .NET 的 `ThreadPool`,答案与性能有关,正如在这篇 14 年前的文章中所讨论的——在我最新的测试中,`ThreadPool` 仍然是这样工作的。

C# 版本使用了这些别名:

using Work = System.Action;
using ThreadExceptionHandler = System.Action<System.Exception>;
using ThreadGate = System.Threading.Semaphore;
using ThreadQueue = System.Collections.Concurrent.ConcurrentQueue<System.Action>;
using AThread = System.Tuple<System.Threading.Semaphore, System.Collections.Concurrent.ConcurrentQueue<System.Action>, System.Action<System.Exception>>;
using ThreadPool = System.Collections.Concurrent.ConcurrentBag<System.Tuple<System.Threading.Semaphore, System.Collections.Concurrent.ConcurrentQueue<System.Action>, System.Action<System.Exception>>>;
using ThreadAction = System.Tuple<System.Tuple<System.Threading.Semaphore, System.Collections.Concurrent.ConcurrentQueue<System.Action>, System.Action<System.Exception>>, System.Action>;

注意这有多混乱!实际的线程池对象被别名为 `ThreadPool`,其余的都是为了语义方便。为了向你分解:

using ThreadPool = 
  System.Collections.Concurrent.ConcurrentBag<
    System.Tuple<
      System.Threading.Semaphore, 
      System.Collections.Concurrent.ConcurrentQueue<
        System.Action>, 
      System.Action<System.Exception>>>;

线程池是信号量 - 队列 - 异常处理程序三元组的集合,其中每个队列都是一个操作。想法是单个信号量管理每个队列,一个单独的线程正在从中拉取数据,你可以为管理每个队列的线程提供自定义异常处理程序(我为什么这样做并不重要,而且实际上有点傻。)

相反,请注意 F# 中的类型声明:

type Work = unit -> unit
type ThreadExceptionHandler = Exception -> unit

type ThreadGate = Semaphore
type ThreadQueue = ConcurrentQueue<Work>
type AThread = ThreadGate * ThreadQueue * ThreadExceptionHandler
type ThreadPool = ConcurrentBag<AThread>
type ThreadWork = AThread * Work
type GetWork = AThread -> ThreadWork

type AddWorkToQueue = Work -> AThread -> AThread
type AddWorkToPool = Work -> ThreadPool -> ThreadPool

这里有几个额外的,包括函数类型定义,它们提高了 F# 代码的可读性。请注意,我们没有使用 C# 的 `Action` 类,而是将工作项定义为一个不接受参数也不返回任何内容的函数:

type Work = unit -> unit

毕竟这是函数式编程!

C# 实现,作为扩展方法,看起来像这样:

public static class ThreadPoolExtensions
{
  public static ThreadPool AddWork(this ThreadPool pool, Work work)
  {
    pool.MinBy(q => q.Item2.Count).AddWork(work).Item1.Release();

    return pool;
  }

  public static ThreadPool Start(this ThreadPool pool)
  {
    pool.ForEach(aThread => aThread.Start());

    return pool;
  }

  public static AThread Start(this AThread aThread)
  {
    new Thread(new ThreadStart(() => aThread.Forever(t => t.WaitForWork().GetWork().DoWork()))).IsBackground().StartThread();

    return aThread;
  }

  private static Thread IsBackground(this Thread thread)
  {
    thread.IsBackground = true;

    return thread;
  }

  private static Thread StartThread(this Thread thread)
  {
    thread.Start();

    return thread;
  }

  private static AThread AddWork(this AThread aThread, Work work)
  {
    (var _, var queue, var _) = aThread;
    queue.Enqueue(work);

    return aThread;
  }

  private static AThread WaitForWork(this AThread aThread)
  {
    (var gate, var _, var _) = aThread;
    gate.WaitOne();

    return aThread;
  }

  private static ThreadAction GetWork(this AThread aThread)
  {
    Work action = null;
    (var _, var queue, var _) = aThread;
    queue.TryDequeue(out action);

    return new ThreadAction(aThread, action);
  }

  private static ThreadAction DoWork(this ThreadAction threadAction)
  {
    ((var _, var _, var exceptionHandler), var action) = threadAction;
    exceptionHandler.Try(() => action());

    return threadAction;
  }

  private static ThreadExceptionHandler Try(this ThreadExceptionHandler handler, Work action)
  {
    try
    {
      action?.Invoke();
    }
    catch (Exception ex)
    {
      handler?.Invoke(ex);
    }

    return handler;
  }
}

这里有几点需要注意。

Forever 扩展方法

public static void Forever<T>(this T src, Action<T> action)
{
  while (true) action(src);
}

在 F# 中,这是这样实现的:

let rec Forever fnc = 
  fnc()
  Forever fnc

注意 `rec` 关键字——它告诉编译器该函数将被递归调用(因此是“rec”),但由于该函数是递归声明的,所以它是**迭代**实现的,否则程序最终会耗尽堆栈空间。没有 `rec` 关键字,你实际上无法编写此函数——你会收到一个编译器错误,说 `Forever` 未定义!呼。

元组

C# 6 中的元组更容易处理。注意这种语法的示例:

(var gate, var _, var _) = aThread;
gate.WaitOne();

这里我们只关心信号量,队列和异常处理程序可以忽略。

Work action = null;
(var _, var queue, var _) = aThread;
queue.TryDequeue(out action);

这里我们只关心队列,信号量和异常处理程序被忽略。

return new ThreadAction(aThread, action);

这里我们创建了一个元组。很酷,嗯?

MinBy

这里

pool.MinBy(q => q.Item2.Count).AddWork(work).Item1.Release();

我们正在找到一个工作量最少的队列,并将工作添加到该队列中。顺便说一句,这充其量是可疑的,因为确定哪个线程将排队工作是基于队列大小,**而不是线程是否已经在忙于工作**!忽略这一点,这不是本文的重点。

F# 实现类似——注意处理元组时的语法相似性,元组在函数式编程中更“原生”:

let AddWorkToThread:AddWorkToQueue = fun work athread ->
  let gate, queue, _ = athread
  queue.Enqueue(work)
  gate.Release() |> ignore
  athread

let AddWorkToPool:AddWorkToPool = fun work pool ->
  pool.MinBy(fun (_, q, _) -> q.Count) |> AddWorkToThread work |> ignore
  pool

let rec Forever fnc = 
  fnc()
  Forever fnc

let WaitForWork(athread : AThread) =
  let gate, _, _ = athread
  gate.WaitOne() |> ignore
  athread

let GetWork:GetWork= fun athread ->
  let _, queue, _ = athread
  let _, work = queue.TryDequeue()
  (athread, work)

let DoWork threadWork =
  try 
    (snd threadWork)()
  with
    | ex -> 
      let (_, _, handler) = fst threadWork
      handler(ex)
  threadWork

let StartThread athread : AThread =
  let thread = new Thread(new ThreadStart(fun() -> Forever <| fun() -> athread |> WaitForWork |> GetWork |> DoWork |> ignore))
  thread.IsBackground <- true
  thread.Start()
  athread

let StartThreadPool pool : ThreadPool =
  for athread in pool do athread |> StartThread |> ignore
  pool

我还很懒,没有在 F# 中实现 `IsBackground()` 和 `StartThread()` 扩展方法的等价物。

同样请注意 F# 中的“反向管道”运算符 `<|`:

fun() -> Forever <| fun() -> athread |> WaitForWork |> GetWork |> DoWork |> ignore

F# 的表达能力有其优势。我们也可以不使用反向管道运算符这样写:

let thread = new Thread(new ThreadStart(fun () -> Forever (fun() -> athread |> WaitForWork |> GetWork |> DoWork |> ignore)))

但这将需要围绕我们想永远执行的函数加上括号。

Null 与 Unit

显而易见,C# 有 `null` 的概念,所以在用户传入 `null` 作为异常处理程序的情况下,我们使用 null 条件运算符 `?.` 来测试它:

action?.Invoke();

相反,虽然 F# 有 `null` 的概念,以与 C# 在参数传递和返回值匹配方面兼容,但原生上,最接近 C# null 的等价物是 `None` 选项值。你不能在函数期望参数时将 `None` 作为参数传递。在上面的代码中,如果你不想提供异常处理程序,你仍然必须提供一个“不做任何事”的函数:

fun (_) -> ()

之所以需要 `_`,是因为异常处理程序需要一个参数。但这正是 `ignore` 的定义,所以我们可以写:

let threadPool = new ThreadPool(Seq.map(fun _ -> (new ThreadGate(0, Int32.MaxValue), new ThreadQueue(), ignore)) {1..20})

Lincoln Atkinson 对 F# 中的 null 处理主题有一个很好的介绍 在这里

C# 中的示例用法

以下是如何在 C# 中以极简的方式(可以说)使用它,而无需 for 循环等:

ThreadPool aThreadPool = new ThreadPool(Enumerable.Range(0, 20).Select((_) => 
  new AThread(
    new ThreadGate(0, int.MaxValue), 
    new ThreadQueue(), 
    ConsoleThreadExceptionHandler)));
DateTime startTime = DateTime.Now;

Enumerable.Range(0, 10).ForEach(n => aThreadPool.AddWork(() =>
  {
    Thread.Sleep(n * 1000);
    Console.WriteLine("{0} thread ID:{1} when:{2}ms", 
      n, 
      Thread.CurrentThread.ManagedThreadId, 
     (int)(DateTime.Now - startTime).TotalMilliseconds);
    int q = 10 / n; // forces an exception when n==0
  }));

aThreadPool.Start();

这创建了 20 个线程和 10 个工作项,第一个会抛出异常,然后启动线程。线程被推迟的原因是强制每个工作项在自己的线程上运行。这是输出:

F# 中的示例用法

let exceptionHandler (ex : Exception) = printfn "%s" ex.Message
let threadPool = new ThreadPool(Seq.map(fun _ -> (new ThreadGate(0, Int32.MaxValue), new ThreadQueue(), exceptionHandler)) {1..20})
let startTime = DateTime.Now

for i in {0..9} do
  threadPool |> AddWorkToPool (fun() -> 
    let q = 10 / i
    Thread.Sleep(i * 1000)
    printfn "%d Thread ID:%d when:%ims" i Thread.CurrentThread.ManagedThreadId (int ((DateTime.Now - startTime).TotalMilliseconds)))
  |> StartThreadPool |> ignore

注意这里我使用了一个 `for` 循环——我本可以使用 `Seq.map`,但 F# 的 `for - in` 语法比 C# 的要简洁得多,我认为这只会使代码模糊。在实例化 `ThreadPool` 时使用 `Seq.map` 的优点是它创建了一个集合(尽管我们忽略了序列**编号**)。底层类型 `ConcurrentBag` 接受一个 `IEnumerable` 作为参数。(我必须说 C# 和 F# 之间有很好的互操作性。)如果我们在 F# 中迭代一个简单的 `Seq`,这会进一步混淆代码—— `{0..9}` **就是**序列!

输出:

由于我编写代码的方式不同,存在细微的差异。我不能写:

Thread.Sleep(i * 1000)
printfn "%d Thread ID:%d when:%ims" i Thread.CurrentThread.ManagedThreadId (int ((DateTime.Now - startTime).TotalMilliseconds))
let q = 10 / i

因为“let”不能是代码块的最终元素——一个函数必须求值出一个结果,至少是一个“unit”,而 `let` 语句作为函数中的最后一个代码元素是一个赋值函数,而不是一个求值。为了解决这个问题,我必须将其编写为返回一个“unit”,如下所示:

Thread.Sleep(i * 1000)
printfn "%d Thread ID:%d when:%ims" i Thread.CurrentThread.ManagedThreadId (int ((DateTime.Now - startTime).TotalMilliseconds))
let q = 10 / i
()

然后我得到与 C# 示例相同的输出:

啊,函数式编程的细微之处!另外,我不知道为什么 C# 和 F# 之间的线程 ID 如此不同。

语义发布/订阅

总结:F# 使用名义(“按名称”)推理引擎而非结构推理引擎,因此在 F# 中,为类型赋予语义上有意义的名称非常重要,以便类型推断引擎可以推断出正确的类型。

最后一块!这里的想法是注册订阅者,当发布特定类型时将被触发。调用者可以确定订阅者是否应立即处理该类型,还是可以使用前面创建的线程池异步执行处理。

C# 别名

这些是 pub-sub 定义的别名:

using PubSubExceptionHandler = System.Action<System.Exception>;
using PubSubTypeReceiverMap = System.Collections.Concurrent.ConcurrentDictionary<System.Type, System.Collections.Concurrent.ConcurrentBag<object>>;
using PubSubReceivers = System.Collections.Concurrent.ConcurrentBag<object>;
using SemanticPubSub = System.Tuple<System.Collections.Concurrent.ConcurrentDictionary<System.Type, System.Collections.Concurrent.ConcurrentBag<object>>, System.Action<System.Exception>>;

using ThreadPool = System.Collections.Concurrent.ConcurrentBag<System.Tuple<System.Threading.Semaphore, System.Collections.Concurrent.ConcurrentQueue<System.Action>, System.Action<System.Exception>>>;

请注意,必须包含 `ThreadPool` 别名,因为我们在异步发布过程中使用了线程池。

同样,这变得很糟糕,所以这里是 `SemanticPubSub` 类型的分解:

SemanticPubSub = System.Tuple<
  System.Collections.Concurrent.ConcurrentDictionary<
    System.Type, 
    System.Collections.Concurrent.ConcurrentBag<object>>, 
  System.Action<System.Exception>>;

SemanticPubSub 是一个映射 - 异常处理程序对,其中映射将一个类型与一组接收器关联起来,这些接收器作用于该类型的一个实例,尽管最后一部分并不明显,因为它是一个对象集合。与服务管理器一样,我们理想情况下会使用一个接口类型,这样我们就可以将 `object` 替换为 `Action` 或类似的东西,但当我们调用订阅者时,转换会处理这个问题。我们也可能无法将 `ISubscriberData` 添加到传递给订阅者的类型中,特别是如果我们没有该数据代码。

F# 类型

在 F# 中,这更具可读性:

open ThreadPoolModule

type PubSubExceptionHandler = Exception -> unit
type Subscribers = ConcurrentBag<Object>
type Subscription<'T> = 'T -> unit
type Subscriptions = ConcurrentDictionary<Type, Subscribers>
type SemanticPubSub = Subscriptions * PubSubExceptionHandler

type Subscribe<'T> = Subscription<'T> -> SemanticPubSub -> SemanticPubSub

type Publish<'T> = 'T -> SemanticPubSub -> SemanticPubSub
type AsyncPublish<'T> = ThreadPool -> 'T -> SemanticPubSub -> SemanticPubSub
type CreateMissingBag = SemanticPubSub -> SemanticPubSub

请注意 `open ThreadPoolModule`,它会引入该模块中定义的类型。这里我们再次为订阅者列表使用 `Object`。我一直在探索在 F# 中使用 `^T` 表示法静态解析类型参数的概念,以及Tomas Petricek 关于动态查找的文章,但我的 F# 功力还不够。

这些类型是函数类型声明:

type PubSubExceptionHandler = Exception -> unit
type Subscription<'T> = 'T -> unit
type Subscribe<'T> = Subscription<'T> -> SemanticPubSub -> SemanticPubSub
type Publish<'T> = 'T -> SemanticPubSub -> SemanticPubSub
type AsyncPublish<'T> = ThreadPool -> 'T -> SemanticPubSub -> SemanticPubSub

这接受一个异常并返回无。

type PubSubExceptionHandler = Exception -> unit

这接受一个泛型参数 T 并返回无。

type Subscription<'T> = 'T -> unit

这接受一个 `Subscription` 和 `pubsub` 并返回 `pubsub`。

type Subscribe<'T> = Subscription<'T> -> SemanticPubSub -> SemanticPubSub

这接受一个泛型参数和一个 `pubsub` 并返回一个 `pubsub`。

type Publish<'T> = 'T -> SemanticPubSub -> SemanticPubSub

这接受一个泛型类型、一个线程池和一个 `pubsub` 并返回一个 `pubsub`。

type AsyncPublish<'T> = ThreadPool -> 'T -> SemanticPubSub -> SemanticPubSub

所以,从下往上阅读:

type PubSubExceptionHandler = Exception -> unit
type Subscribers = ConcurrentBag<Object>
type Subscriptions = ConcurrentDictionary<Type, Subscribers>
type SemanticPubSub = Subscriptions * PubSubExceptionHandler

一个 `SemanticPubSub` 是一个元组,由订阅和异常处理程序组成,其中每个订阅是类型与订阅者集合之间的映射,订阅者以 `Object` 的形式实现。为什么是对象?因为订阅

type Subscription<'T> = 'T -> unit

将一个泛型类型定义为一个参数,而这个类型是变化的,pub-sub 并不知道。是的,就像 C# 一样,这可以通过强制任何订阅实现一个接口来完成,但特别是在函数式编程中,当类型可以是任何东西,包括另一个函数时,我们不希望强制要求泛型参数是“类状”的。

C# 实现

再次,扩展方法用于实现。公开的三个方法是:

  • Subscribe (订阅)
  • Publish (发布)
  • AsyncPublish (异步发布)

请注意,`AsyncPublish` 不是可等待的(它不应该是可等待的),否则按照命名约定的一致性,我应该将其命名为 `PublishAsync`。

public static class SemanticPubSubExtensionMethods
{
  public static SemanticPubSub Subscribe<T>(this SemanticPubSub pubsub, Action<T> receiver)
  {
    pubsub.CreateMissingBag<T>().Item1[typeof(T)].Add(receiver);

    return pubsub;
  }

  public static SemanticPubSub Publish<T>(this SemanticPubSub pubsub, T data)
  {
    // No listeners does not throw an exception.
    if (pubsub.Item1.ContainsKey(typeof(T)))
    {
      pubsub.Item1[typeof(T)].ForEach(r => pubsub.Item2.Try((Action<T>)r, data));
    }

    return pubsub;
  }

  public static SemanticPubSub AsyncPublish<T>(this SemanticPubSub pubsub, ThreadPool threadPool, T data)
  {
    // No listeners does not throw an exception.

    if (pubsub.Item1.ContainsKey(typeof(T)))
    {
      // No Try here because threadpool handles the exception.
      pubsub.Item1[typeof(T)].ForEach(r => threadPool.AddWork(() => ((Action<T>)r)(data)));
    }

    return pubsub;
  }

  private static SemanticPubSub CreateMissingBag<T>(this SemanticPubSub pubsub)
  {
    Type t = typeof(T);

    if (!pubsub.Item1.ContainsKey(t))
    {
      pubsub.Item1[t] = new PubSubReceivers();
    }

    return pubsub;
  }

  private static PubSubExceptionHandler Try<T>(this PubSubExceptionHandler handler, Action<T> action, T data)
  {
    try
    {
      action?.Invoke(data);
    }
    catch (Exception ex)
    {
      handler?.Invoke(ex);
    }

    return handler;
  }
}

请注意向下转换 `((Action)r)(data))`,这是完全安全的,因为我们只调用与类型 `T` 订阅的方法。

F# 实现

F# 实现类似,再次注意向下转换运算符 `:?>`:

let CreateMissingBag<'T> : CreateMissingBag = fun pubsub ->
  let t = typeof<'T>
  let (dict, _) = pubsub

  if not (dict.ContainsKey(t)) then
    dict.[t] <- new Subscribers();

  pubsub

let Subscribe<'T> : Subscribe<'T> = fun fnc pubsub ->
  let t = typeof<'T>
  let (dict, _) = pubsub |> CreateMissingBag<'T>
  dict.[t].Add(fnc)
  pubsub

let TryPublish fnc pubsub =
  let (_, handler) = pubsub
  try
    fnc()
  with
    | ex -> 
    handler(ex)

let Publish<'T> : Publish<'T> = fun data pubsub ->
  let t = typeof<'T>
  let (dict, handler) = pubsub

  if (dict.ContainsKey(t)) then
    for subscriber in dict.[t] do 
      pubsub |> TryPublish (fun () -> data |> (subscriber :?> Subscription<'T>))

  pubsub

let AsyncPublish<'T> : AsyncPublish<'T> = fun threadPool data pubsub ->
  let t = typeof<'T>
  let (dict, _) = pubsub

  if (dict.ContainsKey(t)) then
    for subscriber in dict.[t] do 
      threadPool |> AddWorkToPool (fun () -> data |> (subscriber :?> Subscription<'T>)) |> ignore

pubsub

C# 用法示例

这是一个 C# 用法示例——注意流畅的表示法和匿名方法:

public class Counter
{
  public int N { get; protected set; }

  public Counter(int n) { N = n; }
}

public class SquareMe
{
  public int N { get; protected set; }

  public SquareMe(int n) { N = n; }
}

pubsub.Subscribe<Counter>(r =>
{
  int n = r.N;
  Thread.Sleep(n * 1000);
  Console.WriteLine("{0} thread ID:{1} when:{2}ms", 
    n, 
    Thread.CurrentThread.ManagedThreadId, 
    (int)(DateTime.Now - startTime).TotalMilliseconds);
  int q = 10 / n; // forces an exception when n==0
}).Subscribe<SquareMe>(r =>
{
  Console.WriteLine("Second subscriber : {0}^2 = {1}", r.N, r.N * r.N);
});

上面创建了几个订阅者,这是发布类型的方式——`Counter` 实例异步发布,`SquareMe` 实例同步发布:

Enumerable.Range(0, 10).ForEach(n => pubsub.AsyncPublish(aThreadPool, new Counter(n)).Publish(new SquareMe(n)));
 

结果是:

F# 用法示例

相同的示例,但在 F# 中:

type Counter = {N : int}
type SquareMe = {N : int}

pubsub |> Subscribe<Counter> (fun r ->
  let n = r.N
  let q = 10 / n
  Thread.Sleep(n * 1000)
  printfn "%d Thread ID:%d when:%ims" 
    n 
    Thread.CurrentThread.ManagedThreadId 
    (int ((DateTime.Now - startTime).TotalMilliseconds)))
|> Subscribe<SquareMe> (fun r -> printfn "%d^2 = %d" r.N (r.N * r.N)) |> ignore

请注意创建“记录”类型的简洁性,否则其他一切看起来都差不多。发布这两个实例也很相似:

for i in {0..9} do
  pubsub |> AsyncPublish tp {Counter.N = i} |> Publish {SquareMe.N = i} |> ignore

同样,比 C# 代码更简洁(或许更精炼)。但请注意,我们必须明确说明要创建哪个记录:

{Counter.N = i}
{SquareMe.N = i}

因为两个记录在名义上(即按“名称”——术语)是相同的,都有一个成员 `N`。如果我们这样命名它们:

type Counter = {Ctr : int}
type SquareMe = {Sqr : int}

那么发布这些记录实例将更加简洁(也修复订阅方法):

for i in {0..9} do
  ps |> AsyncPublish tp {Ctr = i} |> Publish {Sqr = i} |> ignore

这指出了为类型赋予语义上有意义的名称的重要性,以便类型推断引擎可以推断类型,同时也指出了具有相同名称的记录的危险——F# 的推断引擎将使用最后一个记录,所以如果我们这样做:

type Counter = {N : int}
type SquareMe = {N : int}

而不指定记录类型:

ps |> AsyncPublish tp {N = i} |> Publish {N = i} |> ignore

我们会得到这个:

请注意由于一部分计算在单独的线程上运行而产生的奇怪输出。在上面的代码中,很明显出了问题,但在更复杂的代码中则不那么明显。F# 使用名义类型推断而不是结构类型推断,所以如果你认为你使用的是 A 类型而实际上使用的是 B 类型,这可能会导致非常奇怪的编译器错误消息。

整合

总结:在这里我们看到 C# 中的所有 `using` 别名都必须引入此文件。使用 F#,我们不必重新声明类型。

最后,我们希望线程池和 pub-sub 都是服务,所以我们将服务管理器也关联进来。

完整的 C# 示例

这是 C# 中最终示例的外观,演示了我们如何从服务管理器获取 pubsub 和线程池服务。请注意,我们有所有那些难看的 `using` 别名:

using System;
using System.Linq;
using System.Threading;

using Services = System.Collections.Concurrent.ConcurrentDictionary<System.Type, object>;

using PubSubTypeReceiverMap = System.Collections.Concurrent.ConcurrentDictionary<System.Type, System.Collections.Concurrent.ConcurrentBag<object>>;
using SemanticPubSub = System.Tuple<System.Collections.Concurrent.ConcurrentDictionary<System.Type, System.Collections.Concurrent.ConcurrentBag<object>>, System.Action<System.Exception>>;

using ThreadGate = System.Threading.Semaphore;
using ThreadQueue = System.Collections.Concurrent.ConcurrentQueue<System.Action>;
using AThread = System.Tuple<System.Threading.Semaphore, System.Collections.Concurrent.ConcurrentQueue<System.Action>, System.Action<System.Exception>>;
using ThreadPool = System.Collections.Concurrent.ConcurrentBag<System.Tuple<System.Threading.Semaphore, System.Collections.Concurrent.ConcurrentQueue<System.Action>, System.Action<System.Exception>>>;

namespace MinApp
{
  public class Counter
  {
    public int N { get; protected set; }

    public Counter(int n) { N = n; }
  }

  public class SquareMe
  {
    public int N { get; protected set; }

    public SquareMe(int n) { N = n; }
  }

  class Program
  {
    static void Main(string[] args)
    {
      ThreadPool aThreadPool = new ThreadPool(Enumerable.Range(0, 20).Select((_) => 
      new AThread(
        new ThreadGate(0, int.MaxValue), 
        new ThreadQueue(), 
        ConsoleThreadExceptionHandler)));

      SemanticPubSub pubsub = new SemanticPubSub(new PubSubTypeReceiverMap(), ConsolePubSubExceptionHandler);
      Services services = new Services().Register(aThreadPool).Register(pubsub);
      var pool = services.Get<ThreadPool>(); // example of getting a service.
      DateTime startTime = DateTime.Now;

      pubsub.Subscribe<Counter>(r =>
      {
        int n = r.N;
        Thread.Sleep(n * 1000);
        Console.WriteLine("{0} thread ID:{1} when:{2}ms", n, Thread.CurrentThread.ManagedThreadId, (int)(DateTime.Now - startTime).TotalMilliseconds);
        int q = 10 / n; // forces an exception when n==0
      }).Subscribe<SquareMe>(r =>
      {
        Console.WriteLine("Second subscriber : {0}^2 = {1}", r.N, r.N * r.N);
      });

      Enumerable.Range(0, 10).ForEach(n => pubsub.AsyncPublish(pool, new Counter(n)).Publish(new SquareMe(n)));

      pool.Start();
      Console.ReadLine();
    }

    static void ConsoleThreadExceptionHandler(Exception ex)
    {
      Console.WriteLine(ex.Message);
    }

    static void ConsolePubSubExceptionHandler(Exception ex)
    {
      Console.WriteLine(ex.Message);
    }
  }
}

完整的 F# 示例

相比之下,这是 F# 示例。请注意我们不必重新定义类型:

open System
open System.Collections.Concurrent
open System.Threading
open ThreadPoolModule
open SemanticPubSubModule
open ServiceManagerModule

type Counter = {N : int}
type SquareMe = {N : int}

[<EntryPoint>]
let main argv = 
  let exceptionHandler (ex : Exception) = printfn "%s" ex.Message
  let threadPool = new ThreadPool(Seq.map(fun _ -> (new ThreadGate(0, Int32.MaxValue), new ThreadQueue(), exceptionHandler)) {1..20})
  let pubsub = (new Subscriptions(), exceptionHandler)

  let services = new Services()
  services |> RegisterService threadPool |> RegisterService pubsub |> ignore

  let startTime = DateTime.Now
  let tp = services |> GetService<ThreadPool>
  let ps = services |> GetService<SemanticPubSub>
  ps |> Subscribe<Counter> (fun r ->
    let n = r.N
    let q = 10 / n
    Thread.Sleep(n * 1000)
    printfn "%d Thread ID:%d when:%ims" n Thread.CurrentThread.ManagedThreadId (int ((DateTime.Now - startTime).TotalMilliseconds)))
  |> Subscribe<SquareMe> (fun r -> printfn "%d^2 = %d" r.N (r.N * r.N)) |> ignore

  for i in {0..9} do
    ps |> AsyncPublish tp {Counter.N = i} |> Publish {SquareMe.N = i} |> ignore

  tp |> StartThreadPool |> ignore
  Console.ReadLine() |> ignore
  0

扩展行为——面向对象编程中的危险以及函数式编程的论据

总结:在 C# 中,更改类成员不会影响类类型。F# 则不然(至少对于纯粹的记录类型而言)——更改记录的结构会更改记录的类型。更改 C# 类成员可能会导致不正确的初始化和使用等问题。继承,特别是与可变字段结合使用时,可能会导致具有隐含理解的行为,例如“这永远不会发生”突然被打破。扩展方法和重载会产生语义歧义。F# 本身不支持重载——函数必须具有语义上不同的名称,而不仅仅是不同的类型或参数列表。

作为一项最小化 C# 代码中类创建的练习,尽管使用 `using` 别名和扩展方法是可行的,但它是一种非常非标准的方法。`using` 别名难看、笨拙,并且完全打破了 OOP 的规则,特别是封装和继承,使得任何以这种形式编写的代码都变得脆弱,因为如果你更改了元组或字典的组织方式,你的代码将到处都会中断。此外,别名允许直接操作字典、集合或元组,这是另一个问题,因为它们是可变的——一个我带到 F# 示例中的问题。

通常,可维护性(我指的是你在某个时候会想要扩展现有代码的行为)的 OOP 代码依赖于以下一个或多个选项:

  1. 更改类——如果你有幸拥有源代码,你可以更改类(添加/删除方法/字段/属性/委托),最坏的情况下,你必须重新编译所有依赖于具体实现的类。
  2. 继承——如果你不能更改代码但类不是 `sealed`,你可能至少可以创建一个新类型,该类型继承旧类型的行为,并允许你在继承的类型中扩展行为。
  3. 封装——如果你不能继承类型(例如,它是 `sealed` 的),你可以为类实现一个包装器,并通过你想要保持不变的行为,更改/添加/删除其他行为。
  4. 扩展方法——另一种添加行为的方法,但不是成员变量,添加到类中,无论它是密封的还是未密封的。
  5. 包装方法——我们在扩展方法出现之前就做的事情(此选项不再讨论)。

所以,让我们进行一个相当大的绕道,用 C# 来看看这些 OOP 技术,以及当我们尝试将它们用于 F# 时会发生什么。在此过程中,我将提出一些 OOP 可能危险的原因,函数式编程如何避免这些危险,以及即使在函数式编程中,如何进入危险区域。

改变容器

就 C# 而言,更改容器不会更改容器的类型。例如:

public class Foo
{
  public int A { get; set; }
}

public class UsesFoo
{
  public void CreateFoo()
  {
    var foo = new Foo() { A = 5 };
  }
}

现在我们更改 Foo:

public class Foo
{
  public int A { get; set; }
  public int B { get; set; }
}

方法 `CreateFoo` 仍然有效,尽管其他东西可能会中断,因为 `B` 未初始化。

现在考虑 F# 中的这个,使用记录(记录是命名值的聚合,**默认情况下是引用类型**,与类类似,而不是结构体):

type Foo = {A : int}
let bar = {A=5}

现在让我们更改 `Foo`,添加 `B`:

糟糕!我们改变了类型!当然,我们可以在 F# 中使用类,但这个(也许是学术性的)整个文章的要点是避免使用类!

这有点像在 C# 中这样做:

using Foo = System.Int32;
...
public void FooType()
{
  var foo = new Foo();
  foo = 5;
}

然后,更改 `Foo`:

using Foo = System.Tuple<System.Int32, System.Int32>;

啊!所以在这里我们遇到了我认为是面向对象编程中的一个关键缺陷。当我们修改 OOP 中的类时,该类的类型定义不会改变!这是一个很大的便利,但也非常危险,因为它会导致类型的使用结果可能出乎意料,当类的成员被不正确初始化时。

这是一个人为的 F# 等效示例,更像是这样:

type Foo2 = int
let mutable bar2 = new Foo2()
bar2 <- 5

然后将 Foo2 更改为:

type Foo2 = int * int

重点是,F#(只要你不使用类)甚至不允许我们修改原始类型“别名”的行为,如果你做了一些愚蠢的事情,就像上面的虚构例子一样。

继承与封装

考虑这个例子:

public class Foo
{
  public int A { get; set; }
}

public class Foo2 : Foo
{
  public int B { get; set; }
}

public class UsesFoo
{
  public void CreateFoo()
  {
    var foo = new Foo() { A = 5 };
  }
}

在这里,我们保留了所有使用 `Foo` 的地方的 `Foo` 的“良好”行为,并且我们**引入了一个新类型** `Foo2`,当我们想要扩展行为时使用。

这是一个很好的 OOP 实践,特别是在你的代码进入生产环境后——任何你想利用 `Foo` 的新东西都应该从 `Foo` 派生,这样你就不会无意中破坏现有的用法!

使用记录的 F# 等效项如下所示:

type Foo = {A : int}
type Foo2 = {foo : Foo; B : int}
let bar = {A=5}
let bar2 = {foo=bar; B=5}

但这不是继承,这是封装!你不能继承记录类型,因为记录类型被编译为密封类!

所以实际的 C# 等效项是:

public class Foo2
{
  public Foo Foo { get; set; }
  public int B { get; set; }
}

C# 中继承的优点是 `Foo2` 获得了 `Foo` 的所有公共/受保护成员。但是,如果你将封装的成员 `Foo` 设置为 `protected` 或 `private`,你必须显式公开你仍然想传递给 `Foo` 的成员。这样做可能有充分的理由——例如,在不当使用 `Foo2` 时抛出异常。

你不能在 F# 中这样做:

所有记录字段都必须标记为私有。

type Foo2 = private {foo : Foo; B : int}
let bar = {A=5}
let bar2 = {foo=bar; B=5}
let a = bar2.foo.A
let b = bar2.B

正如上面的代码所示,情况变得更奇怪,如果你想阅读这个

在 F# 中,继承与使用记录类型不同。

type Foo(a) =
  member this.A = a

type Foo2(a, b) = 
  inherit Foo() 
  member this.B = b

这实际上是与 C# 示例的正确等价,正如我在下面“继承与可变和不可变”一节中讨论的。

扩展方法与重载 - 更多糟糕的 OOP 实践

如果你无法更改类成员,也无法继承类,并且不想封装类,你仍然可以使用扩展方法来扩展类的行为。考虑这个人为的例子:

public static class Extensions
{
  public static int Add(this Foo foo, int val)
  {
    return foo.A + val;
  }
}

public class Foo
{
  public int A { get; set; }
  public int Add()
  {
    return A + 1;
  }
}

public class UsesFoo
{
  public void CreateFoo()
  {
    var foo = new Foo() { A = 5 };
    int six = foo.Add();
    int seven = foo.Add(2);
  }
}

请注意,我们有效地重载了命名不佳的 `Add` 方法,并为其提供了一个同名的扩展方法。诚然,尽管这是一个人为的例子,扩展方法(在 C# 2.0 中引入)非常方便,因为以前我们不得不编写静态辅助类来扩展密封类的行为。并且,虽然面向对象编程从一开始就支持方法重载(即多态性的一种特性),但如果使用不当,它很容易导致语义混乱。

相反,在 F# 中,不允许重载(除非你做一些技巧,见下文):

虽然你可以(感谢TheBurningMonk提供了一个示例)强制 F# 使用扩展方法,就像这样:

open System.Runtime.CompilerServices

type Foo = {A : int}

let Add (foo : Foo) = foo.A + 1

[<Extension>]
type FooExt =
[<Extension>] 
static member Add(foo : Foo, n : int) = foo.A + n

let foo = {A = 5}
let six = foo.Addlet seven = foo.Add(2)

不要这样做!这会破坏函数式编程的纯粹性,而函数式编程的优点之一就是强制执行良好的语义命名实践。

type  Foo = {A : int}

let IncrementFoo (foo : Foo) = foo.A + 1
let AddToFoo (foo : Foo) (n : int) = foo.A + n

let foo = {A = 5}
let six = IncrementFoo foo
let seven = 2 |> AddToFoo foo
let eight = AddToFoo foo 3

继承与可变性 vs. 不可变性

最后,我们来到了每个命令式语言都支持的——可变性。

public class Foo
{
  public int A { get; set; }

  public void Increment()
  {
    A = A + 1;
  }
}

在 F# 中,`=` 运算符是比较,而不是赋值。

如果你想进行赋值,你必须使用 `<-` 运算符:

哎呀。你必须将类型声明为 `mutable`:

type Foo = {mutable A : int}

let IncrementFoo (foo : Foo) =
foo.A <- foo.A + 1

let foo = {A = 5}
let b = IncrementFoo foo

这展示了 OOP 的另一个问题。假设你有这个(当然是人为的):

public class Foo
{
  public int A { get; set; }
}

public class Foo2 : Foo
{
  public void Increment()
  {
    A = A + 1;
  }
}

class Program
{
  static void Main(string[] args)
  {
    var myFoo = new Foo2() { A = 5 };
    myFoo.Increment();
    ShowYourFoo(myFoo);
  }

  static void ShowYourFoo(Foo foo)
  {
    Console.WriteLine("Hey, who change my A! " + foo.A);
  }
}

可变性与继承结合,让我能够以原始实现未曾预料到的方式更改实例的继承概念!但是,继承和可变性的全部意义就在于做 exactly 这一点,是的,这种情况有很多优点。也有很多危险:

class Program
{
  static void Main(string[] args)
  {
    var myFoo = new Foo2() { A = -1 };
    myFoo.Increment();
    DivideByA(myFoo);
  }

  // Having written Foo, I know for a fact that Foo.A is never 0.
  static void DivideByA(Foo foo)
  {
    int d10 = 10 / foo.A;
  }
}

来吧,你发现了多少次(甚至是在生产环境中!)一个错误,其中基类的现有方法(基于某些事情不可能发生的知识实现)突然失败,因为基类的该方法现在因为派生类更改了某些东西而失败?

是的,你可以强迫 F# 陷入同样的问题,而且很多时候需要一个可变类型——我总是认为纯函数式编程是不现实的,因为我们生活在一个可变的世界里。但实际上,函数式编程可以在你不需要变异时帮助你避免变异。它通过要求你使用 `mutable` 关键字(再次,只要你避免继承)来构建具有语义名称的新类型来操作这些类型。

关于 F# 中的继承,这是合理的,因为类型是不可变的:

type Foo(a) =
  member this.A = a
  member this.Increment() = 
  this.A + 1


type Foo2(a, b) = 
  inherit Foo(a) 
  member this.B = b

let foo = new Foo2(1, 2)
let newA = foo.Increment()

这个例子:

type Foo(a) =
  let mutable A = a
  member this.Increment() = 
  A <- A + 1


type Foo2(a, b) = 
  inherit Foo(a) 
  member this.B = b

let foo = new Foo2(1, 2)
foo.Increment()

会导致上面描述的相同问题。这里的结论是(你可以争论直到牛回家),具有可变类型的继承是危险的!

结论

该说什么?虽然你永远不会这样用 C# 编码,但在 F# 中这要自然得多,而且我相信函数式编程总体上也是如此。在编写本文的过程中,我认为我发现了 OOP 的一些具体缺点,而函数式编程可以克服这些缺点,尽管 FP(至少用 F#)肯定可以被引导出表现出相同的行为。我还相信我提出了一些关于面向对象编程与函数式编程的具体差异——不是说哪一个更好,而是至少提供了关于两者的优点和缺点的具体讨论点。希望你能同意!

© . All rights reserved.