如何像函数式程序员一样思考






4.96/5 (64投票s)
从面向对象程序员的角度学习函数式编程的经验
引言
当我第一次接触 F#(以及函数式编程,或 FP)时,我的第一反应是:这有什么用?为什么? 我决定通过用 F# 编写一些非琐碎的组件来尝试回答这些问题。 首先让我印象深刻的是,我能看出我的代码仍然看起来像命令式的——大量的可变变量,“for” 循环而不是递归等等。 我想,这很有趣,我必须真正学会用 FP 不同的思考方式,这导致了更深层次的问题——我为什么必须以不同的方式思考,我如何以不同的方式思考,以及最后,以 FP 的方式思考是否能改进我构建应用程序的方式?
这些问题比 F# 和 C# 之间的语法差异对我来说更有趣。 如何用 FP 的方式思考也是我发现缺乏的——大多数 FP 资源直接讲到 FP 在处理列表方面有多么强大,但这并没有教会我为什么它更好以及如何像 FP 程序员那样思考。 此外,我感到好奇,因为我很少遇到(至少在技术圈子里)需要我在根本上重新评估我的编程概念的东西。 我不认同那种(即使我自己也在宣扬自己的想法!)“FP 更棒”的教条式鼓吹,但我认为 FP 可能有一些有价值的东西可以提高我的软件架构能力,如果它能与我认为对于非琐碎小应用来说已经是基本技术的实践**达到平衡**的话。
因此,在本文中,我将探讨一个人需要改变的思维方式是什么,以及拥有另一种应用程序开发思维方式的**平衡**的好处,这种思维方式可以添加到命令式/面向对象工具箱中,更重要的是,为什么 FP 能够促进一种不同的思考方式。 对一些人来说,这可能很明显,但对我来说,这远非显而易见,因为我在过去的 30 年里一直沉迷于 OO 架构和命令式语言。 我也不会鼓吹 FP 本质上更好——它是**不同的**,当一个问题需要不同的方法时,FP 是另一个可以考虑的工具。 虽然我会提供由 F# 支持的 OO 范例示例,但我会采取“这是‘糟糕的’FP”的立场,而是关注如何通过不同的思考方式来避免 OO 结构,更深入地活在纯 FP 思考的世界里。 这里的所有示例都在 VS2010 中实现。 我发现与 VS2008 和 F# 插件,或 VS2011 相比,没有代码不兼容问题。
对于那些不想读完整篇文章的人,可以随时跳到摘要部分,那里有说明 OOP/命令式思维与 FP 思维之间区别的“主要观点”。
此外,读者需要已经对 F# 有一定的熟悉度,特别是对类类型的定义方式和“match”语句。
入门
我决定要弄清楚的第一件事是如何开始接触,这意味着学习如何从 C# 调用 F# 函数,而 C# 我已经非常熟悉了。 此外,我读了很多关于 FP 和可变性的内容,还有一些我以前从未遇到过的词,如柯里化、闭包、延续和单子。 弄清楚如何在天然不可变的语言中进行编码,让我第一次在变量、状态和列表管理方面踏入了“不同思考”的兔子洞。 此外,我发现 FP 中处理列表的任何事情通常都通过递归而不是循环来完成。 在命令式语言中,递归总是会引发堆栈溢出(递归调用太多)和性能问题(我需要从多少层函数调用中返回?),所以这对我来说是一个立即需要仔细研究的危险信号,看看这是否真的有实际意义,即处理有时数百万项的集合。 最后,当我终于足够理解 FP 并能做一些实际可行的事情时,我遇到了 FP 的另一项功能——类型推断——的一些重大陷阱。
从 C# 调用 F#
到目前为止,我可能 95% 的代码库都是 C#,所以弄清楚如何从 C# 调用 F# 函数似乎很自然。 这至少可以提供一个我非常熟悉的框架,作为 springboard 进入 F#。
从一个空白解决方案开始,创建一个 C# 控制台项目和一个 F# 项目,并在 C# 项目的引用中引用 F# 项目:
在 VS2010 创建的默认“Module1.fs”文件中,替换模块名称并创建一个简单的函数,该函数接收两个参数:
module FSModule let Add x y = x + y
在 C# 程序中,调用该函数并将结果写入控制台
using System; using System.Collections.Generic; using System.Linq; using System.Text; namespace WhyFP { class Program { static void Main(string[] args) { int ret = FSModule.Add(1, 2); Console.WriteLine(ret); } } }
太棒了,成功了
工具问题:IntelliSense
如果您输入此示例而不是从文章中复制粘贴,您会发现 FSModule 没有被高亮显示,并且由于 IntelliSense 不知道 FSModule 模块,因此 Add 方法也不为人所知,所以 IntelliSense 再次让您失望了。 因此,这是您遇到的第一个问题——为了让 IntelliSense 工作,F# 库必须先编译! 这种行为与其他 .NET OO 语言的工作方式不同。
模块和命名空间
定义模块不需要任何代码。如果一个代码文件没有前导的命名空间或模块声明,F# 代码将隐式地将代码放入一个模块中,模块的名称与文件名相同,首字母大写。 要访问另一个模块中的代码,只需使用 . 符号:moduleName.member。 注意,此符号类似于访问静态成员的语法——这并非巧合。 F# 模块被编译为只包含静态成员、值和类型定义的类。1
我习惯了命名空间,并且知道 F# 支持命名空间,我想了解命名空间和模块之间的区别。 此代码会导致错误“命名空间不能包含值。”
namespace FSModuleNS let Add x y = x + y
命名空间仅用于模块、类和其他命名空间的层级分类1。 因此,F# 代码必须如下所示:
namespace FSModuleNS module FSModule = let Add x y = x + y
并且 C# 代码将被修改为这样调用 Add 函数:
int ret = FSModuleNS.FSModule.Add(1, 2);
这可能看起来微不足道,但它与我们在 C# 中定义事物的方式略有不同,值得注意。
前向引用
我很快发现了 F# 代码中的另外两个“问题”,都涉及到前向引用。 这些可能看起来微不足道,但它们说明了学习新语言和开发环境时可能遇到的许多“WTF?”经历。
模块前向引用
添加另一个模块,我们称之为“Xyz.fs”以及函数:
module Xyz let MagicNumber = 5
然后在此函数中引用此函数:
let Add x y = x + y + Xyz.MagicNumber
这将导致编译器错误“模块或命名空间‘Xyz’未定义。 这是因为 F# 不允许在项目的文件列表中进行前向引用。 项目文件:
必须重新排列如下:
模块声明:顶层 vs. 局部
既然我提到了这一点,请注意模块定义 Xyz.fs 后面没有等号。 这是一个顶层模块声明。 这个:
module FSModule =
是一个局部模块声明。 基本上,顶层模块声明适用于文件的所有内容,而局部模块声明允许您将文件划分为不同的模块。 但关键点在于实现:模块“实现为公共语言运行时(CLR)类,该类仅包含静态成员。”2
使用“open”关键字(类似于“using”)可以避免写出完整的模块名称。 例如:
namespace FSModuleNS open Xyz module FSModule = let Add x y = x + y + MagicNumber
此关键字适用于 F# 模块以及 .NET 命名空间。
类型前向引用
这是一个有点牵强的例子:
type Customer = { FirstName : Name; LastName : Name; } type Name = string
但它说明了 F# 不支持前向引用的事实。 上面的代码会导致错误“类型‘Name’未定义。” 要解决此问题,我们需要使用“and”关键字:
type Customer = { FirstName : Name; LastName : Name; } and Name = string
可变性
OO/命令式语言的一切都是可变的。 我当然通常不会在 C# 中将字段设为 `readonly`——最多一个属性可能有受保护的或私有的 setter(或者根本没有 setter),但这仍然允许实现类修改底层字段值。 相反,FP 的一切都与不可变性有关——你必须显式使用 `mutable` 关键字才能使某物可修改。 这是思考函数式编程术语的第一个真正的思维障碍,因为它提出了一个问题:你实际上如何在 FP 中**做事**?
FP 思维,#1:在函数式编程中,我们必须接受这样一个想法及其含义:一旦某个东西被初始化,就不能更改它。
让我们仔细看看。 让我们考虑一个简单的交通信号灯类(请忽略枚举使用周围的强制转换,这是糟糕的设计,但我想要一个简短简单的例子):
public class TrafficLight { public enum State { Red, Green, Yellow }; public State LightState { get; protected set; } protected int[] stateDurations = { 10, 5, 1 }; protected DateTime transitionTime; public TrafficLight() { LightState = State.Red; InitializeDuration(); } public bool CheckState() { bool lightChanged = false; if (DateTime.Now >= transitionTime) { LightState = (State)(((int)LightState + 1) % 3); InitializeDuration(); lightChanged = true; } return lightChanged; } protected void InitializeDuration() { int durationAtState = stateDurations[(int)LightState]; transitionTime = DateTime.Now.AddSeconds(durationAtState); } }
通过编写一个简单的循环测试用例,您可以验证此代码是否有效(10 秒红灯,5 秒绿灯,1 秒黄灯等):
TrafficLight tl = new TrafficLight(); for (int i = 0; i < 20; i++) { tl.CheckState(); Console.WriteLine(tl.LightState); System.Threading.Thread.Sleep(1000); }
OOP 的“问题”在于,类不仅仅是方法的包装(从而解决了模块化等问题),它们还**管理状态**。
在 OOP 中,一个类一旦实例化,就是一个状态和方法的(即函数)的小包,你可以调用这些方法来“计算”基于当前状态的东西,以及会改变当前状态的方法。 通常,您会同时执行这两种操作——执行一个也改变状态的计算,称为“副作用”,无论是对自身还是对另一个对象。 在 C、Pascal 和 BASIC 等命令式语言的全盛时期,状态实际上是全局可访问的。 OOP 通过引入类“修复”了作用域、可见性和全局状态管理问题,然而,状态仍然与类实例相关联。 几年前,我写了一篇题为《对象有什么问题?》的文章,我不得不说,现在答案更简单:对象是以下内容的纠缠:
- 状态表示
- 受对象状态(以及通常在调用时传入的其他参数值)影响的计算(函数)
- 状态管理
- 副作用
这意味着一个对象是一个可变的、复杂实体,难以测试和重用。 因此,出现了各种额外的技术来支持这些复杂的小生物——单元测试引擎、设计模式、代码生成器、ORM 等。 不可变性(以及缺乏副作用)是 FP 程序员喜欢指出的 FP 的一个优点。 不可变性是 FP 的一个基本“不同思考”原则。
FP 思维,#2:使用 FP,没有副作用,因为状态更改由新实例表示。 所以,停止思考如何改变现有实例的状态,而是开始思考“这种状态变化意味着我需要一个新实例。”
当然,您**可以**在 F# 中编写引入副作用的程序,这会带来一个推论:
FP 思维,#3:即使语言支持可变性,也应不惜一切代价避免(除非与 C/C++/C# 这样的语言交互,因为这些语言需要可变性才能做任何有用的事),因为可变性破坏了 FP 的许多优点。
例如,整个类都可能在 F# 中写得很糟糕:
module FSTrafficLight type LightStateEnum = | Red | Green | Yellow type TrafficLight() as this = let mutable myLightState = Red let mutable transitionTime = System.DateTime.Now do this.InitializeDuration member this.LightState with get() = myLightState and set(value) = myLightState <- value member this.GetDuration = match this.LightState with | Red -> 10.0 | Green -> 5.0 | Yellow -> 1.0 member this.NextState = match this.LightState with | Red -> Green | Green -> Yellow | Yellow -> Red member this.CheckState() = let now = System.DateTime.Now match now with | now when now >= transitionTime -> myLightState <- this.NextState this.InitializeDuration true | _ -> false member this.InitializeDuration = let durationAtState = this.GetDuration transitionTime <- System.DateTime.Now.AddSeconds(durationAtState)
但这在 F# 中是一个写得很糟糕的类,因为有可变字段。 这是 C# 中的测试代码:
static void FSharpTrafficLight() { FSTrafficLight.TrafficLight tl = new FSTrafficLight.TrafficLight(); for (int i = 0; i < 20; i++) { tl.CheckState(); Console.WriteLine((TrafficLight.State)tl.LightState.Tag); System.Threading.Thread.Sleep(1000); } }
(顺便说一句,请注意 F# 的区分联合 `LightStateEnum` 如何有一个 `Tag` 属性来获取枚举值,该值被映射到 C# 枚举。)
每当您看到这样的结构时:
myLightState <- this.NextState
您正在修改现有实例的状态。 停下来,意识到这破坏了 FP。
不可变性
那么,上面这个类如何以更合适的 FP 风格编写呢? 首先,这是代码:
module FSBetterTrafficLight type LightStateEnum = | Red | Green | Yellow type TrafficLight(state, nextEventTime : System.DateTime) = let myLightState = state let transitionTime = nextEventTime new() = let initialState = LightStateEnum.Red let nextEventTime = TrafficLight.GetEventTime initialState TrafficLight(initialState, nextEventTime) member this.LightState with get() = myLightState static member GetDuration state = match state with | Red -> 10.0 | Green -> 5.0 | Yellow -> 1.0 static member NextState state = match state with | Red -> Green | Green -> Yellow | Yellow -> Red static member GetEventTime state = let now = System.DateTime.Now let duration = TrafficLight.GetDuration state now.AddSeconds(duration) member this.CheckState() = let now = System.DateTime.Now match now with | now when now >= transitionTime -> let nextState = TrafficLight.NextState myLightState let nextEventTime = TrafficLight.GetEventTime nextState TrafficLight(nextState, nextEventTime) | _ -> this
这是我们如何使用它:
static void FSharpBetterTrafficLight() { FSBetterTrafficLight.TrafficLight tl = new FSBetterTrafficLight.TrafficLight(); for (int i = 0; i < 20; i++) { tl = tl.CheckState(); Console.WriteLine((TrafficLight.State)tl.LightState.Tag); System.Threading.Thread.Sleep(1000); } }
注意 C# 代码中调用我们 F# 代码的这行:
tl = tl.CheckState();
每次调用 `CheckState` 时,我们都要么分配一个新实例(当交通信号灯的状态改变时),要么继续使用同一个实例(如果状态没有改变)。 敏锐的读者会说:“等等,你所做的只是将可变性推给了调用者!” 这是正确的,但请记住,我们是从一个本身就可变的语言(C#)调用 F# 函数的。 稍后我们将看到如何在 F#(本质上是不可变的)内部调用 `CheckState` 函数,而无需使用任何可变字段。
在 F# 中使用类:构造函数初始化状态
为了记录在案,我不是 F# 中类使用的倡导者。 它们支持与 .NET 框架和命令式语言协同工作,到目前为止,我还没有遇到过在 F# 中无法不使用类,而使用函数和部分函数(稍后将详细介绍)等功能来完成的事情。 如果您从典型的 OOD 的角度来看待一个类(即使是不可变的类),您将立即想到继承和多态性。
FP 思维 #4:停止思考对象。 停止思考继承和多态性。 将类封装的字段分离成 FP 表示。 将类封装的方法分离成独立的静态函数。 学习如何使用部分函数来利用继承的概念。 通过更好地命名您的函数来停止使用多态性——多态性实际上只是弱思维的创可贴。
为了消除副作用,F# 中不可变类的构造函数必须完全初始化其字段。 实际上:
FP 思维 #5:学会如何看待结构(OO 类、FP 记录)作为完全初始化且不可变的实体。 弄清楚如何以完全初始化的实现来思考。 学会纯粹地思考初始化和计算。 用“新实例”替换“赋值”。
如果字段是可变的,它就是“可赋值的”,这违反了我们的“无可变字段”规则,因为我们正在改变当前实例的状态。 在上面的代码中,我提供了一个默认构造函数,它将通过调用参数化构造函数来完全初始化类的状态。 参数化构造函数只是将字段初始化为通过参数传递的值。
在 F# 中使用类:静态成员
FP 思维 #6:类是命令式代码中的强大概念,因为它们封装了可变性——封装主要是为了管理实例状态的变化——使可变性更易于管理。 因为 FP 消除了可变性,我们可以回到非封装的静态方法模型,因为 FP 函数只是执行“计算”,并且没有理由封装计算。
请注意,以下方法是静态的:
- GetDuration
- NextState
- GetEventTime
请注意,这些都接受状态参数。 在这里,我们有独立的成员方法,它们不关心类的状态,它们,嗯,**独立的**! 这些变得异常简单但功能强大(因为说真的,会出什么错?),它们完全与类实例及其状态解耦。 有人可能会争辩说,类的强大之处在于您不必将状态参数传递给类中的方法。 这是真的,但是,它也将方法与类状态耦合起来,这让我们回到了副作用的问题(几个问题之一)。 通过将所有内容传递给执行计算所需的功能,您将获得一个独立、易于测试的功能。 函数的参数完整地描述了该函数执行计算所需的所有内容——无需猜测该函数还依赖于哪些“内部的”、有状态的、可变的字段。
FP 思维 #7:独立函数之所以独立,是因为其参数描述了执行计算所需的所有内容。 停止将函数与参数和有状态字段混合。 相反,创建不依赖于除传入参数以外的任何东西的函数。
这需要一段时间来适应,但想法很简单:如果您有计算要执行,请传入计算所需的所有信息,而不是依赖类本身字段的状态。 其中,这意味着您的计算是真正可重用的:类仅仅是计算的包装器——您甚至不需要实例化类,因为计算是静态方法。 是的,对此可以提出一些论点,但请记住几点:
- 在 F# 中实现类是一种权宜之计,微软支持它是因为 F# 会编译成 IL 并与其他 .NET 语言和 .NET 框架本身兼容。
- 类本质上只是有状态的容器。 在不可变的环境中您真的不需要它们,事实上,它们使使用、测试和并发变得更加困难。
在 F# 中使用类:改变状态
FP 思维 #8:消除通过更改当前实例的字段值来改变状态的方法。 如果需要更改对象的状态,它将变成一个代表新状态的新实例。
在上面的代码中,成员方法 `CheckState` 不会改变类的状态(它不能,因为没有任何东西是可变的),而是创建一个具有新状态的新实例。 该方法要么返回自身(如果还没有到转换到另一种状态的时候),要么返回一个新实例。 您可以看到这如何影响调用类的代码——它在循环 20 次迭代时不断重新分配实例。
那么,类又如何呢?
通过在 F# 中使用更好的 FP 实践,我们已经实现了一些解决之前提到的类问题的功能:
- 我们消除了副作用——字段是不可变的,类的初始状态在构造函数中完全描述。
- 计算甚至不需要在类中——当我们将字段替换为参数时,类就变成了一个方便的容器。
- 我们不再有改变类状态的方法。 相反,我们有了返回代表新状态的新实例的方法。
但是,我们仍然可以做得更好,通过完全消除类,而类毕竟,此时只是一种方便的容器。 这将在下面演示。
那么,谁来管理状态呢?
FP 思维 #9:使用参数和返回值(基于栈的状态管理)而不是基于堆的状态管理来管理您的状态。
重新思考状态管理的方式,特别是将状态视为基于栈而不是基于堆的东西,是一种根本不同的编程方式。 这与命令式编程的许多基础形成了对抗——在早期语言(BASIC、Pascal、Fortran、汇编等)中,字段被分配给物理内存位置,无论是显式分配(如早期微处理器编程)还是隐式分配(由编译器)。 在支持内存池(C、C++ 等)的语言中,出现了“堆”的概念来支持结构的动态创建和销毁。 作为面向对象的程序员,我们非常习惯于思考基于堆的对象,它们漂浮在内存的某个地方,在 C/C++ 时代很容易被覆盖并需要显式生命周期管理。 虽然 .NET 创建了一个更安全、更安全、更隐式的生命周期管理环境(以及自动内存管理和垃圾回收的潜在陷阱),但它实际上只是隐藏了问题。 使用 FP,当您开始考虑基于栈的状态时,问题就完全消失了。 让我们看看交通信号灯的无类(哈哈)实现:
module ClasslessTrafficLight type LightStateEnum = | Red | Green | Yellow let GetDuration state = match state with | Red -> 10.0 | Green -> 5.0 | Yellow -> 1.0 let NextState state = match state with | Red -> Green | Green -> Yellow | Yellow -> Red let GetEventTime state (now : System.DateTime) = let duration = GetDuration state now.AddSeconds(duration) let CheckState(currentState, transitionTime) = let now = System.DateTime.Now match now with | now when now >= transitionTime -> let nextState = NextState currentState let nextEventTime = GetEventTime nextState now (nextState, nextEventTime) | _ -> (currentState, transitionTime) let InitialState = LightStateEnum.Red let InitialEvent = (InitialState, GetEventTime InitialState System.DateTime.Now)
及其用法:
static void FSClasslessTrafficLight() { var state = ClasslessTrafficLight.InitialEvent; for (int i = 0; i < 20; i++) { state = ClasslessTrafficLight.CheckState(state.Item1, state.Item2); Console.WriteLine((TrafficLight.State)state.Item1.Tag); System.Threading.Thread.Sleep(1000); } }
好的,如果您仔细观察,您会注意到我作弊了——我消除了显式类,但实际上我仍然在一个类中管理状态——在这种情况下,是一个 `Tuple<>`。 如果您检查类型“state”,我方便地将其隐藏为“var”,您会注意到它属于类型:
Tuple<ClasslessTrafficLight.LightStateEnum, DateTime>
这里重要的不是我仅仅用一个通用容器替换了一个特定的类,而是我将管理状态的容器(在此例中是 Tuple)与改变状态的计算完全解耦了。 此外,状态更改由新的 Tuple 管理,而不是更改 Tuple 中的字段值。 然而,与之前的示例一样,调用者(C#)仍在可变字段中管理状态。 我们仍然必须消除这一点,而且很快就会!
继承作为部分应用 / 柯里化
在交通信号灯示例中,我们可以构造一个简单的类继承模型来构建两种交通信号灯——一种三色版本和一种两色版本:
public abstract class TrafficLightBase { public enum State { Red, Green, Yellow }; public State LightState { get; protected set; } protected DateTime transitionTime; public TrafficLightBase() { LightState = State.Red; InitializeDuration(); } public abstract bool CheckState(); protected abstract void InitializeDuration(); } public class ThreeColorTrafficLight : TrafficLightBase { public override bool CheckState() { bool lightChanged = false; if (DateTime.Now >= transitionTime) { switch (LightState) { case State.Red: LightState = State.Green; break; case State.Green: LightState = State.Yellow; break; case State.Yellow: LightState = State.Red; break; } InitializeDuration(); lightChanged = true; } return lightChanged; } protected override void InitializeDuration() { int durationAtState = 0; switch (LightState) { case State.Red: durationAtState = 10; break; case State.Green: durationAtState = 5; break; case State.Yellow: durationAtState = 1; break; } transitionTime = DateTime.Now.AddSeconds(durationAtState); } } public class TwoColorTrafficLight : TrafficLightBase { public override bool CheckState() { bool lightChanged = false; if (DateTime.Now >= transitionTime) { switch (LightState) { case State.Red: LightState = State.Green; break; case State.Green: LightState = State.Red; break; } InitializeDuration(); lightChanged = true; } return lightChanged; } protected override void InitializeDuration() { int durationAtState = 0; switch (LightState) { case State.Red: durationAtState = 3; break; case State.Green: durationAtState = 3; break; } transitionTime = DateTime.Now.AddSeconds(durationAtState); } }
在 C# 代码中,我们有两个虚拟方法用于获取下一个状态和状态持续时间,具体取决于交通信号灯是三色(红、绿、黄)还是只有两种颜色(红和绿)。
在 F# 代码中,我们首先创建一些类似于派生类重写方法的函数:
类似于 TwoColorTrafficLight.InitializeDuration
let GetDuration2Color state = match state with | Red -> 3.0 | Green -> 3.0
类似于 ThreeColorTrafficLight.InitializeDuration
let GetDuration3Color state = match state with | Red -> 10.0 | Green -> 5.0 | Yellow -> 1.0
类似于 TwoColorTrafficLight.CheckState
let NextState2Color state = match state with | Red -> Green | Green -> Red
类似于 ThreeColorTrafficLight.CheckState
let NextState3Color state = match state with | Red -> Green | Green -> Yellow | Yellow -> Red
接下来,我们修改 CheckState 函数以接受两个附加参数,这两个参数本身也是函数。 想法是,我们将传入适当的函数来确定下一个状态和状态持续时间,具体取决于我们要模拟的交通信号灯类型:
let CheckState fncNextState fncEventTime (currentState, transitionTime) = let now = System.DateTime.Now match now with | now when now >= transitionTime -> let nextState = fncNextState currentState let nextEventTime = GetEventTime fncEventTime nextState now (nextState, nextEventTime) | _ -> (currentState, transitionTime)
现在,有趣的部分来了(双关语)。 我们创建了几个部分函数,提供了这两个参数(请注意它们是 `CheckState` 函数的前两个参数):
let CheckState2Color = CheckState NextState2Color GetDuration2Color let CheckState3Color = CheckState NextState3Color GetDuration3Color
我们利用 FP 的一个特性,称为部分应用,而不是通过继承和虚拟方法。 这个概念(及其近亲柯里化和闭包)将在下面讨论,特别是关于我们如何从不同角度看待 OO 继承在 FP 中。
部分应用和柯里化
事实证明,“部分应用”和“柯里化”这两个术语之间存在很多混淆。 这些概念是 FP 思维的基础。 例如,继承是 OOP 中的一个基本概念。 继承的一个特性是它向程序员隐藏了实现行为的具体类型。 这种具体类型可以被视为对象图的“状态实例”。 根据实例化哪个子类,我们可以影响应用程序的行为。 在 FP 中,我们可以做到这一点,但通过函数的部分应用和将函数作为参数传递。 这需要一种不同的继承思维方式。
FP 思维 #10:通过继承,您利用了编译器向您隐藏的虚拟函数指针系统。 在 FP 中,您可以轻松地将函数作为参数传递,并构建部分应用其他函数的函数。 这显式地定义了具体函数的用法,并且类似于实例化所需的子类。
基本上,您在 FP 中所做的是通过使用 OO 世界中将被视为函数指针的东西来显式实现继承的概念。 这类似于 .NET 4.5 库中的 `Action
部分应用
“**部分应用**……指的是固定函数的一些参数,生成另一个arity较小的函数的过程。"4 Arity 只是一个花哨的词,意思是“函数接受的参数或操作数的数量。”6 在 F# 代码中,根据我们想要的“状态实例”(2 色或 3 色交通信号灯),我们创建了部分函数:
let CheckState2Color = CheckState NextState2Color GetDuration2Color let CheckState3Color = CheckState NextState3Color GetDuration3Color
这意味着 CheckState2Color 和 CheckState3Color 提供了前两个参数,调用者只需要提供最后一个参数,即元组 `(currentState, transitionTime)`。
然后,根据我们想要“实例化”的交通信号灯类型,我们返回所需的 `CheckState` 部分函数,这类似于我们在 OOP 中习惯的工厂模式:
let CheckTrafficLightState trafficLightType = match trafficLightType with | Color2 -> CheckState2Color | Color3 -> CheckState3Color
然后我们在 `ProcessTrafficLight` 函数中提供其余参数:
let ProcessTrafficLight(trafficLightType, currentState, transitionTime) = CheckTrafficLightState trafficLightType currentState transitionTime
在我所有的其他示例中,我们都是从 C# 调用 F# 代码。 调用上述 F# 函数的 C# 示例将如下所示:
for (int i = 0; i < 20; i++) { state = ClasslessTrafficLight.ProcessTrafficLight(trafficLightType, state.Item1, state.Item2); Console.WriteLine((TrafficLight.State)state.Item1.Tag); System.Threading.Thread.Sleep(1000); }
这并不理想,因为 `CheckTrafficLightState` 函数被频繁调用。 相反,让我们将 C# 代码中的循环移到 F# 代码中,我们可以看到一个更清晰的实现:
let StartTrafficLight trafficLightType iterations = let checkLightState = CheckTrafficLightState trafficLightType let startingState = InitialEvent trafficLightType RunTrafficLight checkLightState startingState iterations
在这里,我们有一个简单的函数,它首先将部分函数 `CheckState2Color` 或 `CheckState3Color` 分配给 `checkLightState`。 然后我们调用 `RunTrafficLight` 函数来运行交通信号灯指定的迭代次数。 我们将在稍后看 `RunTrafficLight`。 但首先,关于柯里化:
柯里化
“**柯里化**是将一个接受多个参数(或 n 元组参数)的函数转换为可以作为一系列每个只有一个参数的函数(部分应用)调用的技术。”5
这是柯里化的正确定义,您可以看到柯里化是部分应用的一种特殊形式,其中柯里化函数只有一个参数。 在上面的部分应用示例中,这些符合柯里化函数的条件,因为柯里化函数只剩下元组作为剩余参数。 关于柯里化有很多混淆,正如这句话所证明的:“当一个函数被柯里化时,它会返回另一个其签名包含剩余参数的函数。"3 这不完全准确。 只有当返回的函数只有一个参数时,才是柯里化。 如果返回的函数有多个参数,则认为是“部分应用”。
循环和递归
在上面的代码中,我没有展示运行指定次数迭代的交通信号灯的循环。 这是 F# 代码:
let rec RunTrafficLight checkLightState currentState n = match n with | 0 -> () | _ -> let nextState = checkLightState currentState printfn "%s" (ParseLightState (fst(nextState))) System.Threading.Thread.Sleep(1000) RunTrafficLight checkLightState nextState (n-1)
在这里,我们终于看到了如何在 F# 中运行交通信号灯,而无需使用可变字段来处理 `checkLightState` 函数的返回。 我们正在利用 FP 的两种方式:
- “let”语句是一个初始化,而不是赋值。 这很重要,因为在 FP 中,我们希望初始化实体而不是进行赋值(这需要可变类型)。
- 通过使用递归函数调用,我们正在使用栈而不是本地定义的可变字段来管理状态更改。
FP 思维 #11:递归是我们处理状态更改和状态更改创建的新实体实例的迭代(令人困惑,不是吗?)方式。 思考递归的关键在于识别状态将改变的实体(或实体),并将这些实体作为参数传递给函数,进行递归调用。 这样,状态更改就可以表示为传递给函数的新的实例,而不是使用同一实例的可变字段。
虽然 F# 支持“for”循环,但如果我们用 F# 以命令式方式编写代码,它会是这样的:
let ForLoopTrafficLights trafficLightType iterations = let checkLightState = CheckTrafficLightState trafficLightType let mutable currentState = InitialEvent trafficLightType for i in 1..iterations do currentState <- checkLightState currentState printfn "%s" (ParseLightState (fst(currentState))) System.Threading.Thread.Sleep(1000)
请注意,这需要一个可变字段来维护交通信号灯的状态。 我们希望消除可变字段,所以我们改用递归调用。 通过使用递归调用,`nextState` 被参数化,我们使用栈(理论上)作为管理状态变化的手段。 现在,重要的是要认识到编译器会将您的递归调用转换为迭代循环(有很多关于“尾递归”的讨论,您可以自行搜索)。 观察反编译的 F# 递归代码(使用 DotPeek),在 C# 中:
public static void RunTrafficLight<a>(FSharpFunc<Tuple<ClasslessTrafficLight.LightStateEnum, a>, Tuple<ClasslessTrafficLight.LightStateEnum, a>> checkLightState, ClasslessTrafficLight.LightStateEnum currentState_0, a currentState_1, int n) { while (true) { Tuple<ClasslessTrafficLight.LightStateEnum, a> func = new Tuple<ClasslessTrafficLight.LightStateEnum, a>(currentState_0, currentState_1); switch (n) { case 0: goto label_1; default: Tuple<ClasslessTrafficLight.LightStateEnum, a> tuple1 = checkLightState.Invoke(func); ExtraTopLevelOperators.PrintFormatLine<FSharpFunc<string, Unit>>((PrintfFormat<FSharpFunc<string, Unit>, TextWriter, Unit, Unit>) new PrintfFormat<FSharpFunc<string, Unit>, TextWriter, Unit, Unit, string>("%s")) .Invoke(ClasslessTrafficLight.ParseLightState(Operators.Fst<ClasslessTrafficLight.LightStateEnum, a>(tuple1))); Thread.Sleep(1000); FSharpFunc<Tuple<ClasslessTrafficLight.LightStateEnum, a>, Tuple<ClasslessTrafficLight.LightStateEnum, a>> fsharpFunc = checkLightState; Tuple<ClasslessTrafficLight.LightStateEnum, a> tuple2 = tuple1; ClasslessTrafficLight.LightStateEnum lightStateEnum = tuple2.Item1; a a = tuple2.Item2; --n; currentState_1 = a; currentState_0 = lightStateEnum; checkLightState = fsharpFunc; continue; } } label_1:; }
请注意,递归调用被实现为无限循环,其中 case 0 跳出。 另请注意,参数,通常会为每次递归调用推送到栈上,是如何由参数 `currentState_0` 和 `currentState_1` 处理的。 有趣的是,IL 代码创建了可变变量,但话又说回来,考虑到微处理器具有非常基于命令的指令集、可变寄存器,并且当然使用可变内存,这是可以预期的。 我们当然不希望将递归调用实现为 IL 或汇编语言中的真正递归——如果我们要递归处理一千万项的列表,请想象所需的内存量和将值推入栈的性能下降!
列表
列表说明了另一种使用递归进行迭代的思考方式。 当学习 F#(或任何 FP 语言)时,几乎立即会遇到的常见示例之一就是列表。 当然,我留下的疑问是,我为什么要用递归而不是迭代来处理列表? 答案相当直接:
FP 思维 #12:处理列表时,考虑三点:工作项本身(通常是列表的头部),列表的其余部分(通常是尾部),以及您想对工作项(头部)进行的工作。 将列表处理分解成这三个独立的概念,可以使迭代工作变得清晰。
考虑我们如何在 C# 中初始化一个交通信号灯列表:
static List<TrafficLightBase> InitializeList() { List<TrafficLightBase> lights = new List<TrafficLightBase>(); for (int i = 0; i < 20; i++) { lights.Add(new ThreeColorTrafficLight()); } return lights; }
以及我们如何迭代列表:
static void IterateLights(List<TrafficLightBase> lights) { foreach (TrafficLightBase tl in lights) { tl.CheckState(); } }
从命令式角度来看,这看起来很干净,但从 FP 角度来看,思考方式需要不同:
- 每次调用 `Add` 方法时,列表本身都会被修改。
- 工作项(交通信号灯实例)与列表的其余部分(其他交通信号灯的集合)之间没有清晰的分离。
- 由于没有分离,当所有工作完成后会有一个隐含的“无所事事”。 FP 中不存在这种隐含行为——我们明确说明完成工作项时应该做什么(即使是什么也不做)。
诚然,除了第一个问题(可变列表)之外,这读起来就像我在发明问题。 即使是可变列表,只要我们不在多个线程中操作列表,也不是真正的问题。 然而,我在这里说明的不是一种方式比另一种更好,而是当你处理 FP 中的列表时,你必须真正地用不同的方式思考。
让我们来看看上面用 F# 实现的代码。 我们首先改变的是,我们不再实例化一个类——而是创建状态列表,这只是类为我们提供的一项功能:
let rec InitializeTrafficLights trafficLightType iterations lights = match iterations with | 0 -> lights | _ -> InitializeTrafficLights trafficLightType (iterations-1) (InitialEvent trafficLightType :: lights)
这会创建一个使用递归函数调用的“迭代”项列表,这是列表操作的典型做法,match 语句描述了在所有迭代处理完毕时(“0”情况)与还有一些迭代剩余时(“_”或“任意”情况)所做的操作。 要在 C# 中调用此函数,我们将编写:
var list = ClasslessTrafficLight.InitializeTrafficLights( ClasslessTrafficLight.TrafficLightType.Color2, 20, FSharpList<Tuple<ClasslessTrafficLight.LightStateEnum, DateTime>>.Empty);
请注意,我们需要传递一个空列表。 另请注意,上面的 F# 代码只是初始化列表的一种选择。 另一种选择是(类似于 C# 代码):
let InitializeTrafficLights2 trafficLightType iterations = [for i in 1 .. iterations -> InitialEvent trafficLightType]
这两个示例都说明了初始化一个不可变列表。 在第一个示例中,我们通过将“工作项”添加到原始列表前面来递归创建一个新列表。 在第二个 F# 示例中,我们使用“for”循环初始化列表。 由于对列表更常见的操作是处理列表的头部和尾部,让我们看看递归函数生成的代码:
public static FSharpList<Tuple<ClasslessTrafficLight.LightStateEnum, DateTime>> InitializeTrafficLights( ClasslessTrafficLight.TrafficLightType trafficLightType, int iterations, FSharpList<Tuple<ClasslessTrafficLight.LightStateEnum, DateTime>> lights) { while (true) { switch (iterations) { case 0: goto label_1; default: ClasslessTrafficLight.TrafficLightType trafficLightType1 = trafficLightType; int num = iterations - 1; lights = FSharpList<Tuple<ClasslessTrafficLight.LightStateEnum, DateTime>>.Cons(ClasslessTrafficLight.InitialEvent(trafficLightType), lights); iterations = num; trafficLightType = trafficLightType1; continue; } } label_1: return lights; }
我们注意到关于此方法有几点:
- 递归已转换为迭代。 这是一件好事,因为它避免了真正的栈递归。
- F# 的“::”运算符实际上是对 `FSharpList` 的 `Cons` 方法的调用。
Cons 方法
F# 列表是不可变的链表。 “重要的是要理解,cons 操作以恒定时间 O(1) 执行。 要将一个元素连接到一个不可变链表,您只需要将该值放入一个列表节点,并将其‘下一个列表节点’设置为现有列表的第一个元素。 ‘之后’的新节点的所有内容都一无所知。”7 这个“技巧”确保原始列表没有改变,我们只是创建了一个新列表,它由新项节点(其“下一个节点”指向现有列表的第一个元素)构成。 如果我们要将项**追加**到现有列表中,我们需要复制原始列表并将最后一个节点链接到新项,这需要 O(n) 操作。
FP 思维 #13:处理列表时,您希望尽可能保留(不修改)原始列表。 将列表视为一个实体本身,而不是一项集合。 如果您更改了列表实体,FP 会复制列表,听起来可能有点奇怪,但更改列表的“下一个节点”条目,甚至是列表的最后一个节点,都是对列表“实体”的更改。 当然,这确保了原始列表没有被修改,这意味着原始列表对于其他并发进程来说是安全的,可以继续使用,而与其他进程对列表的操作无关。
尾递归
微软提供了这个示例8作为真正的递归调用:
let rec sum list = match list with | [] -> 0 | head :: tail -> head + sum tail
确实,当我们使用 DotPeek 检查这段代码时,我们会发现它是真正递归的:
public static int sum(FSharpList<int> list) { FSharpList<int> fsharpList1 = list; if (fsharpList1.get_TailOrNull() == null) return 0; FSharpList<int> fsharpList2 = fsharpList1; FSharpList<int> tailOrNull = fsharpList2.get_TailOrNull(); return fsharpList2.get_HeadOrDefault() + ClasslessTrafficLight.sum(tailOrNull); }
为了避免这种情况,我们使用一种称为“尾递归”的技术,通过使用累加器:
let rec sum2 list acc = match list with | [] -> acc | head :: tail -> sum2 tail (head + acc)
我们看到,在 IL 中,递归确实已经被转换为迭代:
public static int sum2(FSharpList<int> list, int acc) { while (true) { FSharpList<int> fsharpList1 = list; if (fsharpList1.get_TailOrNull() != null) { FSharpList<int> fsharpList2 = fsharpList1; FSharpList<int> tailOrNull = fsharpList2.get_TailOrNull(); int headOrDefault = fsharpList2.get_HeadOrDefault(); FSharpList<int> fsharpList3 = tailOrNull; acc = headOrDefault + acc; list = fsharpList3; } else break; } return acc; }
但是请注意 F# 代码中的括号,在这一行:
| head :: tail -> sum2 tail head + acc
`(head + tail)` 周围的括号告诉编译器我们正在定义一个接受两个参数、将它们相加并返回和的函数,并且这个计算出的值被作为第二个参数传递给 `sum2`。 如果我们省略括号,我们将回到真正的递归函数!
public static int sum3(FSharpList<int> list, int acc) { FSharpList<int> fsharpList1 = list; if (fsharpList1.get_TailOrNull() == null) return acc; FSharpList<int> fsharpList2 = fsharpList1; return ClasslessTrafficLight.sum3(fsharpList2.get_TailOrNull(), fsharpList2.get_HeadOrDefault()) + acc; }
为什么会这样? essentially,从编译器的角度来看,没有括号,对 `sum3` 的调用是使用**列表的头部项**进行的,当 `sum3` **返回**时,累加器值被**加到** `sum3` 的返回值上。 注意调用中的括号如何对齐:
return ClasslessTrafficLight.sum3(fsharpList2.get_TailOrNull(), fsharpList2.get_HeadOrDefault()) + acc; ^ ^
并且“acc”被加到 `sum3` 的返回值上,而不是将和传递给 `sum3`。 因此,仅仅说“我们正在使用一个累加器”是不够的,您必须正确使用累加器!
FP 思维 #14:要确保尾递归,您需要显式创建一个“函数”作为参数,该参数执行累加操作。 如果您忘记了这一点,编译器会将您的代码非常字面地视为“调用头部值,然后对结果执行累加器操作。” 这与传统的“运算符优先级”思维会导致您的想法背道而驰。 换句话说,除非您通过使用括号明确表示进行求值,否则函数调用将非常严格地从左到右进行操作。
另一种看待此问题的方式是通过显式创建一个 Add 函数:
let Add a b = a + b let rec sum4 list acc = match list with | [] -> acc | head :: tail -> sum4 tail Add head acc
此代码无法编译。 它会生成错误“类型不匹配。 期望‘a -> 'b -> 'c’但获得‘c’。 统一‘a’和‘b -> 'c -> 'a’时,结果类型将是无限的。” 这是一个非常令人困惑的错误消息,但我们基本上可以将其简化为“sum4 tail Add”的签名与定义不符。 如果我们这样写代码:
let rec sum4 list acc = match list with | [] -> acc | head :: tail -> sum4 tail (Add head acc)
显式说明计算“Add head acc”的结果是第二个参数,那么一切都会正常工作。
相反,考虑这段代码:
let rec Accumulate list f acc = match list with | [] -> acc | head :: tail -> Accumulate tail f (f head acc) let Adder = Accumulate [1;2;3] Add 0
在这里,我们显式地传入累加器函数(此处为 Add),我们可以看到累加器函数是如何作为参数传递给 `Accumulate` 函数的,并且它本身以尾递归的形式用于执行累加操作。
列表迭代
基本的列表迭代,不使用上述所有复杂的递归函数,可以通过 F# List 类的“iter”方法相对简单地实现,但很容易出错。 首先,让我们看看这段不编译的代码:
let AddMe = let acc = 0 List.iter(fun x -> acc <- acc + x) [1;2;3] acc
敏锐的读者会想到,它不编译的原因是“acc”不是可变的。 那么我们来修复一下。 然而,这段代码也不编译:
let AddMe = let mutable acc = 0 List.iter(fun x -> acc <- acc + x) [1;2;3] acc
编译器给出了一个非常有趣的错误:
“可变变量‘acc’的使用方式无效。 可变变量不能被闭包捕获。 考虑消除这种突变的使用,或者使用通过‘ref’和‘!’实现的堆分配可变引用单元。”
嗯,是的,我们已经知道如何通过使用递归来避免突变。 但这个“闭包”是什么? 根据微软9:
“闭包是由某些 F# 表达式生成的局部函数,例如 lambda 表达式、序列表达式、计算表达式或使用部分应用的参数的柯里化函数。 这些表达式生成的闭包会存储以供后续评估。 这个过程与可变变量不兼容。 因此,如果您在这样的表达式中需要可变状态,则必须使用引用单元。”
这意味着我们必须这样写代码:
let AddMe = let acc = ref 0 List.iter(fun x -> acc := !acc + x) [1;2;3] acc
如果我们用 F# Interactive 运行它,我们会得到预期的结果:
val AddMe : int ref = {contents = 6;}
FP 思维 #15:List.iter 方法不应用于累加操作。 这与我们在 C# 中的思考方式不同,特别是关于列表的扩展方法,这些方法提供了遍历列表并执行某种累加操作的便捷方式。 如果您还不熟悉 LINQ,现在应该了解一下,因为 LINQ 中的许多操作在 F#(以及 FP 语言一般)中都可用,比简单的列表迭代更合适。
我上面这句话的意思是,仔细看看,您会发现更好的解决方案,您会发现 `List.fold` 方法(在 LINQ 中,它是 `Aggregate` 方法)。 例如,在 C# 中,您可以毫无问题地这样写:
static int ListTest() { int acc=0; new List<int>() { 1, 2, 3 }.ForEach(t => acc = acc + t); return acc; }
但在 FP 中,与其进行这种讨厌的引用迭代,不如写一个优雅的:
let AddMe2 = List.fold(fun acc i -> acc + i) 0 [1;2;3]
在 C# 中,它可以写成:
static int ListAggregate() { return new List<int>() { 1, 2, 3 }.Aggregate((n, acc) => acc += n); }
当然,通常人们只会使用 `Sum` 扩展方法。
请注意 F# Iter.fold 如何为我们提供累加器! 在这里,我们有一个不错的函数,它接受初始累加器值(在此例中为 0)和列表,遍历列表,将函数应用于每个项,并将函数的结果馈入下一次计算。
类型推断
FP 的一个优点也是它的一个“绊脚石”:类型推断。 考虑这段代码,其中我们定义了两个记录类型:
type Rec1 = { Count : int; } type Rec2 = { Count : int; Sum : int; } let Foo = {Count = 1}
此代码生成错误*“在类型‘ClasslessTrafficLight.Rec2’的字段‘Sum’上未赋值。”* 等等,你是说编译器笨到无法弄清楚,基于我只初始化了 Count 的事实,我想要 Rec1 的实例吗? 是的,我正是这个意思。 这可能导致一些相当令人困惑且难以理解的函数式编程错误。 事实上,您可以创建一段代码,该代码可以编译并正常运行,但会给您错误的类型! 考虑这段代码:
type Rec1 = { Count : int; } type Rec2 = { Count : int; } let Foo = {Count = 1}
如果我们将其放入 FSI(F# Interactive),我们会得到:
val Foo : Rec2 = {Count = 1;}
Foo 被评估为 Rec2 类型! 这可能不是我们想要的,而且我们肯定不会收到编译器错误,表明 Rec1 和 Rec2 是完全相同的类型! 要解决此问题,您必须显式定义 Foo 的类型:
let Foo : Rec1 = {Count = 1}
或者,为记录字段赋予唯一的名称:
type Rec1 = { CountA : int; } type Rec2 = { CountB : int; } let Rec1 = {CountA = 1}
FP 思维 #16:定义记录类型时,类型推断引擎将仅使用记录的第一个字段来确定类型。 如果您为字段赋予唯一的名称以帮助类型推断引擎,您的生活将会轻松很多。 这也使代码对人来说更具可读性,因为即使应该很明显,他们也能轻松弄清楚正在初始化或使用的记录类型。
函数式编程的炒作到底是怎么回事?
更好的粘合剂?
John Hughes 写了一篇关于《为什么函数式编程很重要》的优秀论文。 在介绍中,他做了一个非常重要的陈述:
“函数式编程的特殊特性和优点通常被总结如下。函数式程序不包含赋值语句,因此变量一旦获得值,就永远不会改变。更广泛地说,函数式程序根本不包含副作用。函数调用除了计算其结果外,没有任何其他影响。这消除了一个主要的错误来源,也使得执行顺序无关紧要——由于没有任何副作用可以改变表达式的值,因此可以随时对其进行求值。这使程序员摆脱了规定控制流的负担。由于表达式可以随时求值,因此可以自由地用
值替换变量,反之亦然——即,程序是“引用透明的”。 这种自由有助于使函数式程序比传统程序更易于数学处理。
这样的“优点”清单固然很好,但如果外部人士不认真对待,也不必感到惊讶。 它描述了函数式编程**不是**什么(它没有赋值、没有副作用、没有控制流),但对它**是什么**却说得不多。 函数式程序员听起来很像一个中世纪的僧侣,拒绝生活的乐趣,希望以此获得美德。 对于那些更关心物质利益的人来说,这些“优点”是完全没有说服力的。”
显然,因为 F# 支持可变变量,所以它包含赋值语句,因此使用 F# 实现副作用与命令式/OO 语言一样容易。 因此,我强烈建议您在 F# 中编程时应避免使用可变实体。 此外,控制流**确实**很重要,尤其是在处理用户交互时。 当存在我们想要以特定顺序呈现的 I/O 流时,它在 FP 中实际上会成为一个问题。 现实地说,“控制流的负担”是我们必须处理的,无论何时程序与外部世界接口。 然而,我们也应该“放松”我们的控制流思维。 例如,在命令式语言中,我们通常会将所有数据放入某种容器中,然后将该容器交给一个处理它的方法。 在 FP 思维中,我们可能会将加载数据的函数传递给处理函数。 然而,这可能会导致应用程序自身的一些非预期副作用:如果数据需要由两个不同的算法处理怎么办? 我们当然不想加载两次数据——这可能会非常低效! 因此,而且非常现实地说,作者指出“外部人士并不太认真对待[这些优点]。”
该论文的立场是,与其强调这些通常宣扬的好处,不如说函数式编程的重要性在于它提供了改进的模块化和两种完全新的方式来粘合程序,从而增强了模块化,而这种增强的模块化是函数式编程的主要好处。 这两种粘合剂可以描述为:
- 简单的函数可以粘合在一起,创建更复杂的函数。
- 整个函数式程序本身也可以粘合在一起。
我不太认同这个论点,因为它让我想起了 OO 编程更好的原因:可重用性。 除了通用操作外,我发现 OO 范例中的类并不是特别可重用,在我(尽管有限)的 F# 经验中,我编写的大多数函数都解决了非常领域特定的问题,并且也不是特别可重用。 尽管如此,John Hughes 的论文仍然值得一读。
消除了模式?
Slava Akhmechet 在他出色的博客文章《面向普通人的函数式编程》中写道:
“我遇到的大多数人都读过 GoF 的《设计模式》一书。 任何有自尊的程序员都会告诉你,这本书是语言无关的,模式适用于软件工程,无论你使用哪种语言。 这是一个崇高的声明。 不幸的是,这与事实相去甚远。
函数式语言的表达能力极强。 在函数式语言中,您不需要设计模式,因为语言很可能是高级的,您最终会以消除设计模式的概念来编程。”
Eugene Wallingford 在他同样出色的论文《函数式编程模式及其在教学中的作用》中写道:
“函数式编程是一种强大的编程风格,但许多学生从未完全欣赏它。 在以状态和状态变为中心的命令式风格编程之后,函数式风格的惯用法可能会让人感到不适。 此外,许多函数式编程思想涉及超出其他风格允许的抽象,而且学生们通常不完全理解使用它们的原因。 ...
软件模式最初是一种行业现象,旨在记录开发者在学术研究之外获得的知识。 它们已成为行业和大学面向对象(OO)设计和编程教学的标准教学工具。 鉴于它们在行业和 OO 教学中的效用,模式为帮助学生和教师学习编写函数式程序提供了一种有前途的方法。 这些模式将记录函数式程序员使用的常见技术和程序结构,模式语言将记录在构造更大的程序——理想情况下,是解决实际兴趣问题的完整程序——中使用函数式编程模式的过程。
我同意这一点,并希望在本文中也说明了 FP 模式确实存在,并且它们与 OO 模式有很大的不同。 有一些重叠(例如,在工厂方法中使用部分函数而不是继承),并且在 FP 中有新的模式有待发现。
关于 FP 模式的另一篇好文章在 Brian 的《深入 F#》中。
更容易进行单元测试?
Tomas Petricek 在这里写道:
“...函数式编程的某些方面使得函数式程序的测试更加容易。
- 函数式程序由函数组成,并保证函数在所有上下文中行为一致。 这意味着当您在单元测试中测试一个函数时,您知道它总是会这样工作。 您不必测试它是否在将它插入到某个其他环境中时能正常工作。
- 函数接受参数并返回结果,仅此而已。 这意味着您通常可以避免模拟和其他类似的技巧,因为您不需要验证函数是否对某个对象执行了调用。 您只需要验证它是否为给定参数返回了预期的结果。
- 最后,有一些不错的工具可以自动测试函数式程序。 对于 F#,我们有 FsCheck(基于 Haskell 的 QuickCheck)。 这些工具受益于函数式程序的各种属性。
我必须补充的是,不可变性大大降低了单元测试的复杂性。 此外,通过将典型的 OO 类分解为其组成部分——状态、计算和状态更改方法——单元测试 FP 代码也大大简化了。 如果单元测试简化了,那么出现 bug 的可能性也就降低了,在我看来。
个人观点
根据我的经验,用 F# 编程:
- 产生更小、更简单的函数。 它们更容易测试,也更容易理解它们的作用。
- 不可变代码消除了副作用,再次提高了可测试性并促进了多线程工作,但管理状态更改的额外代码复杂度是有代价的。
- 函数的部分应用是一个强大的特性,减少/消除了对象继承的需要。
- 状态、状态管理和计算的分离提高了代码质量。
- 将函数作为值传递很简单——比处理泛型 `Action<>` 和 `Func<>` 容易得多。
- 代码更简洁,这既是好事(更易读),也是坏事(通常可读性较差,除非您是经验丰富的 FP 程序员)。
- 类型推断在工作时很棒,在不工作时很烦人。
其中大多数“优点”也可以在 OO/命令式代码中相当轻松地实现。 事实上,我从 FP 中获得的经验使我成为一名更好的 OO 程序员,而我学习“FP 思维”也提高了我的软件架构能力。 总的来说,在我看来,FP 是一个非常有用的工具,它比 OO 范例更好地解决了某些架构/编程问题,而对于其他问题,它的表现则不那么好。
摘要
函数式编程需要一种不同的思考方式。 在这里,我试图探讨一些关于 FP 核心概念的基本概念:不可变性、递归/迭代和列表操作,以及它们如何需要一种根本不同的思考方式才能成为成功的 FP 程序员。 关于这个话题还有很多可以说的——我觉得在很多方面我只是触及了表面。 我也可能犯了一些错误,我希望经验丰富的 FP 程序员能够指出,以造福大家!
函数式编程中的主要观点
FP 思维,#1:在函数式编程中,我们必须接受这样一个想法及其含义:一旦某个东西被初始化,就不能更改它。
FP 思维,#2:使用 FP,没有副作用,因为状态更改由新实例表示。 所以,停止思考如何改变现有实例的状态,而是开始思考“这种状态变化意味着我需要一个新实例。”
FP 思维,#3:即使语言支持可变性,也应不惜一切代价避免(除非与 C/C++/C# 这样的语言交互,因为这些语言需要可变性才能做任何有用的事),因为可变性破坏了 FP 的许多优点。
FP 思维 #4:停止思考对象。 停止思考继承和多态性。 将类封装的字段分离成 FP 表示。 将类封装的方法分离成独立的静态函数。 学习如何使用部分函数来利用继承的概念。 通过更好地命名您的函数来停止使用多态性——多态性实际上只是弱思维的创可贴。
FP 思维 #5:学会如何看待结构(OO 类、FP 记录)作为完全初始化且不可变的实体。 弄清楚如何以完全初始化的实现来思考。 学会纯粹地思考初始化和计算。 用“新实例”替换“赋值”。
FP 思维 #6:类是命令式代码中的强大概念,因为它们封装了可变性——封装主要是为了管理实例状态的变化——使可变性更易于管理。 因为 FP 消除了可变性,我们可以回到非封装的静态方法模型,因为 FP 函数只是执行“计算”,并且没有理由封装计算。
FP 思维 #7:独立函数之所以独立,是因为其参数描述了执行计算所需的所有内容。 停止将函数与参数和有状态字段混合。 相反,创建不依赖于除传入参数以外的任何东西的函数。
FP 思维 #8:消除通过更改当前实例的字段值来改变状态的方法。 如果需要更改对象的状态,它将变成一个代表新状态的新实例。
FP 思维 #9:使用参数和返回值(基于栈的状态管理)而不是基于堆的状态管理来管理您的状态。
FP 思维 #10:通过继承,您利用了编译器向您隐藏的虚拟函数指针系统。 在 FP 中,您可以轻松地将函数作为参数传递,并构建部分应用其他函数的函数。 这显式地定义了具体函数的用法,并且类似于实例化所需的子类。
FP 思维 #11:递归是我们处理状态更改和状态更改创建的新实体实例的迭代(令人困惑,不是吗?)方式。 思考递归的关键在于识别状态将改变的实体(或实体),并将这些实体作为参数传递给函数,进行递归调用。 这样,状态更改就可以表示为传递给函数的新的实例,而不是使用同一实例的可变字段。
FP 思维 #12:处理列表时,考虑三点:工作项本身(通常是列表的头部),列表的其余部分(通常是尾部),以及您想对工作项(头部)进行的工作。 将列表处理分解成这三个独立的概念,可以使迭代工作变得清晰。
FP 思维 #13:处理列表时,您希望尽可能保留(不修改)原始列表。 将列表视为一个实体本身,而不是一项集合。 如果您更改了列表实体,FP 会复制列表,听起来可能有点奇怪,但更改列表的“下一个节点”条目,甚至是列表的最后一个节点,都是对列表“实体”的更改。 当然,这确保了原始列表没有被修改,这意味着原始列表对于其他并发进程来说是安全的,可以继续使用,而与其他进程对列表的操作无关。
FP 思维 #14:要确保尾递归,您需要显式创建一个“函数”作为参数,该参数执行累加操作。 如果您忘记了这一点,编译器会将您的代码非常字面地视为“调用头部值,然后对结果执行累加器操作。” 这与传统的“运算符优先级”思维会导致您的想法背道而驰。 换句话说,除非您通过使用括号明确表示进行求值,否则函数调用将非常严格地从左到右进行操作。
FP 思维 #15:List.iter 方法不应用于累加操作。 这与我们在 C# 中的思考方式不同,特别是关于扩展方法,这些方法提供了遍历列表并执行某种累加操作的便捷方式。 如果您还不熟悉 LINQ,现在应该了解一下,因为 LINQ 中的许多操作在 F#(以及 FP 语言一般)中都可用,比简单的列表迭代更合适。
FP 思维 #16:定义记录类型时,类型推断引擎将仅使用记录的第一个字段来确定类型。 如果您为字段赋予唯一的名称以帮助类型推断引擎,您的生活将会轻松很多。 这也使代码对人来说更具可读性,因为即使应该很明显,他们也能轻松弄清楚正在初始化或使用的记录类型。
参考文献
1 - https://wikibooks.cn/wiki/F_Sharp_Programming/Modules_and_Namespaces
2 - http://msdn.microsoft.com/en-us/library/dd233221.aspx
3 - http://diditwith.net/2007/09/20/BuildingFunctionsFromFunctionsPart1PartialApplication.aspx
4 - http://en.wikipedia.org/wiki/Partial_application
5- http://en.wikipedia.org/wiki/Currying
6 - http://en.wikipedia.org/wiki/Arity
7 - http://blogs.msdn.com/b/chrsmith/archive/2008/07/10/mastering-f-lists.aspx
8 - http://msdn.microsoft.com/en-us/library/dd233224.aspx
9 - http://msdn.microsoft.com/en-us/library/dd233186.aspx
DotPeek,免费的 .NET 反编译器: http://www.jetbrains.com/decompiler/