返回 登录
2

从.NET到Scala:一段函数式编程之旅

原文:From .NET to Scala and beyond: a journey to Functional Programming
作者:Igal Tabachnik
翻译:贺雨言

原标题是“Monad解决了一个你也许没有碰到的问题,但这是一个好问题”,以此向Krzysztof Koźmic关于IoC容器的这篇好文致敬。

这是一个从面向对象/命令式语言向函数式语言的转变,以至于,这个Monad和其他M开头的单词在一些环境下完全被禁止

本文不是为了解释monad,至少不是有意去解释。Max Kreminski在他的精彩文章中表示大部分“monad教程”(或者通常所说的教育博客文章)对于问题解决方案都需要排序,这一点他做得比我好,请先花点时间看一下他的这篇文章再回来继续往下看。

大部分monad教程的问题在于它们将monad作为一个问题的解决方案,而问题本身却没有定义清楚。

对于monad,我最近突然醍醐灌顶,终于有了全面的理解。我反复研读关于monad的各种文章,如Douglas Crockford所预测,我却丧失了向别人解释monad的能力,但是对于monad的定义,我却有了新的理解,并且领略到了定义背后的原因和逻辑关系。

有了这种强大的力量之后,我决定以后不再写monad相关的教程,而是充分利用这种力量,为那些将monad作为解决方案的问题重新创造出智力需求(如Max的文章所说)。

Unlearning to walk

对于学习一个与已有知识截然不同的东西来说,最困难的就是将这个新东西应用到已有的知识体系中去。

大概一年前(写.NET时),我决定停下来去探索一些.NET之外的东西。我在Wix.com找了一份工作,我知道这个公司基本上都是做后台Scala。首先,我需要学习Scala,我的周围有大量的优质的学习资源,但是很快分化出两个Scala阵营:一边将Scala当做“高级Java”来用(Java结构简短、语法简单,是很多函数概念的强大支撑),另一边是将Scala用作JVM上的Haskell。

对于大多数使用命令式/面向对象的语言的人来说,通常一提起Haskell就会导致他们按下浏览器中的后退按钮。Haskell通常与学者和大学应届毕业生有关,这些人参加过函数式编程(FP)课程。直到最近,我为自己生活在一个将Haskell视为与我无关的集体感到自豪,因为我无法想象它是怎样被用作解决现实生活中的问题,比如构建一个Visual Studio插件,甚至是典型的CRUD应用程序。

我从来没想过,Haskell的一个简单的Hello World应用程序改变了我的观点。

函数式的开端

我看过Rúnar Bjarnason的一篇文章,Rúnar Bjarnason是书籍《Scala的函数式编程》(又名“红皮书”)的作者之一,这篇文章的名字叫做纯函数式I/O,我强烈推荐(如果你没在用Scala的话至少推荐前半部分)。它概述了函数式编程的原理,解释了为什么我们在大多数编程语言中不能真正使用“纯”函数式编程,问题在于任何函数都可以执行I/O,或者“有副作用”,这使我们失去了函数式编程的大部分好处。

John A De Goes在这篇文章中提到的好处,只是函数必须展示的三个属性:

  1. 整体性:函数必须为每一个可能的输入返回一个输出值;
  2. 确定性:同样的输入,函数必须得到同样的输出;
  3. 纯洁性:函数唯一的作用必须是计算其返回值,而不是其他。

“副作用”违背了这些属性,这里说的“副作用”包括读取文件、与Web服务器对话、启动线程、抛出异常等等。确切地说,有副作用的函数违背了引用透明性(RT),这是函数式语言的基本属性,即程序的表达式模块可以平稳地被评估表达式的结果代替,而不改变程序的行为。如果一个函数对所有引用透明性(RT)参数来说都是引用透明的,那么这个函数就是“纯”函数,意味着传递给这个函数的参数本身也是透明的。因此,“副作用”就是指任何违背引用透明性(RT)的现象。

在这个纯函数必须输出一个返回值的世界里,void是不存在的。所有函数必须返回一个值,而且同样的输入必须返回同一个值。“限制”这么多,是不是感觉函数式语言没什么用呢(例如,与数据库对话)?

Hello,(函数式)World

颠覆我世界观的时候到了!实际上,现有的任何一种编程语言都能写出一个“hello”程序,在C#中代码如下:

Console.WriteLine("What is your name?");
Console.WriteLine("Hello " + Console.ReadLine());

在Scala中代码如下:

printLn("What is your name?")
printLn("Hello " + readLine)

编译和运行之后,屏幕上打印出第一行,等待输入并按下enter键后,将输入与字符串“Hello”连接起来,然后屏幕上打印出组合后的字符串。这个几乎在所有编程语言中都一样。

如果在Haskell中写同样的代码呢?

putStrLn "What is your name?"
putStrLn ("Hello, " ++ getLine)

还没到编译就会给出如下错误提示:

Couldn’t match expected type String with actual type IO String
In the second argument of (++), namely getLine

提示说,getLine函数的返回值不是字符串,而是IO字符串。IO是什么不重要,重要的是,它们不兼容。IO作为一个封装,是一个包含字符串的容器。

最明显和最直接的问题是:好吧,我有一个IO字符串,那么我怎样得到一个字符串?答案是:得不到。使用普通的方法是不能从IO中得到字符串的,唯一的办法是将这个IO绑定到另一个函数上(使用一个长相滑稽的Haskell操作符>>=声明绑定关系),“绑定”程序如下:

main :: IO ()
main = putStrLn "What is your name?" >> 
   getLine >>= \n -> putStrLn ("Hello, " ++ n)

该程序将getLine函数的输出与 Lambda表达式(Haskell中的Lambdas是用\定义的,看上去像符号λ)的输入参数n绑定在一起,并将其传递给putStrLn函数。

另一种方法就是用do语句,看上去更像命令式语言:

main :: IO () 
main = do 
    putStrLn "What is your name?" 
    name <- getLine 
    putStrLn ("Hello, " ++ name)

表达式name <- getLine就相当于LINQ查询中的表达式 from name in getLine。

进入Monad

此时此刻,应该不难发现IO就是一个monad,Haskell中的一切都是monad。我还没有尝试怎样去解释什么是monad。我要做的是,解释monad的用途,以及我们为什么要用它。

Haskell是一种纯函数式的、延迟执行的语言。根据定义,它有一个局限性——它的所有函数必须是纯函数,这听起来限制比较大,因为我们不可能在Haskell函数中只做一个File.Open,这样不可行。然而,这也给我们带来一个比较有意思的好处:由于Haskell中的所有函数都是纯函数,因此一定是引用透明的,也就是说每一个Haskell程序都是一个单一的、引用透明的表达式。Haskell中的一个程序是对将要做的事情的准确描述——对开发者(和编译器)而言,这些是纯数据。一个Haskell程序准确描述了程序现状和程序执行时间,并且不存在意外情况和副作用。

这让我想到了IO类型。在Haskell中,IO类型用于表示依赖于某个I/O操作的值,我们不知道(也无法知道)这个值是什么,但是没关系。IO类型的作用是产生一个依赖于与外界交互的值,但是对于Haskell编译器而言,这个值就是一个IO字符串值。于是,这个潜在值有了自己的语义,它是处在一个依赖于I/O操作的环境中。

在Haskell、Scala和F#等语言中,我们有一个Option类型(在Haskell中叫Maybe),这个Option类型用来代表一个可能存在也可能不存在的值,不论这个值是否存在,我们都可以在Option类型上执行操作:我们可以传递这个值、转换这个值,或者将这个值绑定/映射到其它值。Option类型的作用是用来表示一个可选值。

IO类型和Option类型都起到了在特定环境中表示一个潜在值的作用。对IO来说,环境是指与外界交互;对Option来说,环境是指可选性。这两种类型都是monad,它们的目的是明确编码类型定义本身所执行的类型的影响。说到底,monad是用来明确表示一个环境,我们在这个环境下计算潜在值,而不用直接与那些值交互。

重新学习

最后,我把这篇文章写在了“Haskell的简介”上,结果就是,即使考虑到类似从某些环境中读取值(读取monad,功能等同于“依赖注入”),或写入日志文件(写入monad),或修改状态(状态monad)等情况,将隐性的副作用转化成显性的monad环境也是非常有用的。表面上看起来不同的问题,可以用一个非常相似的模式来解决。不幸的是,如Max Kreminski在他的文章中所说,在函数式编程语言中,要想实现这种模式需要编写大量代码。

在Haskell中,monad有效地控制了这个副作用,因为没有其他的方法,所以它们是建立在标准库中的一流概念,在其他的(严格的)函数式编程语言中,例如F#或Scala,它们是可选的,也可以同时和“纯”代码一起使用。

最重要的是,monad只是工具箱中的另一个工具而已,它们有用,但对于函数式编程(FP)来说,它们并不是最关键的因素。

结语

亲爱的C#程序员,我很抱歉,没有多少教程真的可以帮助你们理解monad,Eric Lippert曾经很努力尝试过,这是一个长达13集的在C#中解释monad的系列。不幸的是,Eric遭遇了同样的“monads教程”谬论,在第2集时就没什么人看了。即便如此,人们对这一系列文章仍然给予高度评价,作为LINQ的一个伟大的历史,它是目前最接近monad计算C#的文章了。

为什么呢?因为在C#(或Java、Ruby等其他命令式语言)中,大部分问题的解决方法都不尽相同,如果一个简单的if语句或一个null检查就可以搞定了,那么使用monad解决方案难免有点小题大做了。最后,你开始纠结于类型、多余的注释和晦涩的语法,忙活这么多,只为了尝试一下monad解决方案(更有甚者,还包括创建不必要的闭包的时间开销)。

不幸的是,唯一的出路就是认识到我们所知道的面向对象编程(OOP)已经发挥了它的最大潜力。面向对象编程(OOP)本身并没有问题,除非你自己觉得你已经受够了它变得日益复杂、长期纠结于测试和IoC容器,或者你单纯地因为对于日复一日年复一年地解决同样的问题而感到厌倦,那么,你是时候换换口味了!

以上就是函数式编程和(FP)和面向对象编程(OOP)之间的——不同之处,不管怎样,它们的最终目标是一致的——都是工作软件,只是使用方法不一样而已。

评论