以下是我去年在LinkedIn上发表的演讲的重新格式化的内容。 该演示试图解释功能编程,而没有使用“单子”或“不变性”或“副作用”之类的概念。 取而代之的是,它着重于思考使用 构图 如何使您成为一个更好的程序员,而不管您使用什么语言。
40年前的1977年10月17日,图灵奖被授予John Backus,以表彰他对高级编程系统(最著名的是Fortran编程语言)的设计做出的贡献。 图灵奖的所有获奖者都有机会在获得奖项的那一年中就自己选择的主题进行演讲。 作为Fortran编程语言的创建者,人们可能期望Backus讲授Fortran的好处以及该语言的未来发展。 取而代之的是,他做了一个演讲,题为“编程可以从冯·诺伊曼风格中解放出来吗? 他在其中批评了包括Fortran在内的当今一些主流语言的缺点。 他还提出了另一种选择:一种编程的功能样式 。
该讲座将传统程序及其“无法有效使用强大的合并形式”与“基于合并形式的使用”的功能性程序进行了对比。 由于高度可扩展和并行计算的兴起,在过去的几年中,函数式编程引起了人们的新兴趣。 但是函数式编程的主要好处是可以实现的好处,而与您的程序是否要进行并行化无关:函数式编程的合成能力更好。
合成是通过汇总简单片段来组合复杂行为的能力。 在计算机科学课程中,很多注意力都放在了抽象上:解决一个大问题并将其分解为易处理的部分。 反之则更少强调:一旦实现了小块,那么如何将它们连接在一起。 似乎某些功能和系统很容易连接在一起,而其他功能和系统则更为混乱。 但是我们需要退后一步,问:这些功能和系统的哪些属性使其易于组成? 哪些属性使它们难以编写? 阅读完足够的代码后,该模式就会开始出现,并且该模式是理解函数式编程的关键。
让我们从一个组成得很好的函数开始:
String addFooter (String message) {
return message.concat( " - Sent from LinkedIn" );
}
我们可以轻松地将其与其他函数组合在一起,而无需对原始代码进行任何更改:
boolean validMessage (String message) {
return characterCount(addFooter(message)) <= 140 ;
}
太好了,我们只用了一小部分功能,并将其组合在一起就可以做更大的事情。 validMessage
函数的用户甚至不需要知道该函数是由较小的函数构建的; 作为实现细节被抽象出来。
现在,让我们看一下一个组成不太好的函数:
String firstWord (String message) {
String[] words = message.split( ' ' );
if (words.length > 0 ) {
return words[ 0 ];
} else {
return null ;
}
}
然后尝试在另一个函数中编写它:
// “Hello world” -> “HelloHello”
duplicate(firstWord(message));
尽管乍看之下很简单,但是如果我们使用空消息运行上述代码,则将遇到可怕的NullPointerException
。 一种选择是修改重复函数,以处理其输入有时可能为null
的事实:
String duplicateBad (String word) {
if (word == null ) {
return null ;
} else {
return word.concat(word);
}
}
现在,我们可以将函数与较早的firstWord
函数一起使用,并且只需传递null
值即可。 但这违反了组成和抽象的观点。 如果每次要制作更大的东西时都经常需要进入并修改零部件,那么它是不可组合的。 理想情况下,您希望函数像黑盒子一样,确切的实现细节无关紧要。
空对象组成不好。
让我们看一下使用Java 8 Optional
类型的替代实现(在其他语言中也称为Option
或Maybe
):
Optional firstWord (String message) {
String[] words = message.split( ' ' );
if (words.length > 0 ) {
return Optional.of(words[ 0 ]);
} else {
return Optional.empty();
}
}
现在,我们尝试使用之前的未修改的duplicate
函数来构成它:
// "Hello World" -> Optional.of("HelloHello")
firstWord(input).map( this ::duplicate)
有用! 可选选项照顾到firstWord
有时不返回值的事实。 如果从firstWord
返回Optional.empty()
,则.map
函数将仅跳过运行duplicate
函数。 我们能够轻松地组合功能,而无需修改duplicate
的内部结构。 将此与null情况进行对比,在null情况下,我们必须创建duplicateBad
函数。 换句话说: null
对象不能很好Optionals
构成,而Optionals
对象则可以。
函数式程序员着迷于以这种方式使事物可组合。 结果,他们创建了一个大型工具箱,其中装有使非组合代码可组合的结构。 这些工具之一是Optional类型,用于处理有时仅返回有效输出的函数。 让我们看一下已经创建的其他一些工具。
众所周知,异步代码很难编写。 异步函数通常接受“回调”,这些“回调”在调用的异步部分完成时运行。 例如,函数getData
可以对Web服务进行HTTP调用,然后对返回的数据运行一个函数。 但是,如果您想在此之后立即进行另一个HTTP调用怎么办? 然后另一个? 快速执行此操作将使您陷入亲切地称为回调地狱的情况。
getData(function(a) {
getMoreData(a, function(b) {
getMoreData(b, function(c) {
getMoreData(c, function(d) {
getMoreData(d, function(e) {
// ...
});
});
});
});
});
例如,在较大的Web应用程序中,这将导致高度嵌套的意大利面条代码。 它也不是很容易组合。 想象一下,尝试将getMoreData
函数之一分离为自己的方法。 或者想象一下尝试向此嵌套函数添加错误处理。 它不可组合的原因是,每个代码块都有很多上下文要求:最里面的块需要访问a
, b
, c
等的结果。
值比函数更容易组合在一起
让我们在函数式程序员的工具箱中查找一个替代方案: Promise
(有时用其他语言称为Future
)。 这是现在的代码:
getData()
.then(getMoreData)
.then(getMoreData)
.then(getMoreData)
. catch (errorHandler)
现在, getData
函数将返回Promise
值,而不是接受回调函数。 值比函数更容易组合在一起,因为它们不具有与回调相同的先决条件。 由于Promise
对象为我们提供了功能,现在向整个块添加错误处理变得微不足道了。
比异步代码少讨论的非组合代码的另一个示例是循环,或更一般地说,返回多个值(例如列表)的函数。 让我们看一个例子:
// ["hello", "world"] -> ["hello!", "world!"]
List addExcitement (List words) {
List output = new LinkedList<>();
for ( int i = 0 ; i < words.size(); i++) {
output.add(words.get(i) + “!”);
}
return output;
}
// ["hello", "world"] -> ["hello!!", "world!!"]
List addMoreExcitement (List words) {
return addExcitement(addExcitement(words));
}
我们已经组成了将一个感叹号添加到一个将两个感叹号添加在一起的函数。 这种方法有效,但是效率不高,因为它在循环中循环两次,而不是一次。 我们可以返回并修改原始函数,但是像以前那样破坏了抽象。
这是一个人为的示例,但是如果您想象代码分散在更大的代码库中,那么它说明了一个重要的观点:在大型系统中,当您尝试将事物分解为模块时,对一条数据的操作不会所有人在一起生活。 您必须在模块化或性能之间做出选择。
使用命令式编程,您只能获得模块化或性能之一。 通过功能编程,您可以同时拥有两者。
功能程序员回答这个(在Java中8至少)是Stream
。 默认情况下, Stream
是惰性的,这意味着仅在绝对必要时才循环访问数据。 换句话说,该函数是“惰性”的:它仅在询问结果时才开始工作(功能编程语言Haskell围绕着惰性概念构建)。
让我们改用Stream
重写上面的示例:
String addExcitement (String word) {
return word + "!" ;
}
list.toStream()
.map( this ::addExcitement)
.map( this ::addExcitement)
.collect(Collectors.toList())
这只会在列表中循环一次,并在每个元素上两次调用addExcitement
函数。 同样,我们需要想象我们的代码在应用程序的多个部分中的同一数据上运行。 如果没有像Stream
这样的惰性结构,试图通过将所有列表遍历合并到一个地方来提高性能,将意味着将现有功能分开。 使用惰性对象,因为遍历被推迟到最后,您可以同时实现模块化和性能。
现在,我们已经看到了一些示例,让我们回到确定哪些属性使某些函数比其他函数更容易组成的任务。 我们已经看到,空对象,回调和循环之类的东西组合不佳。 另一方面,可选,承诺和流的组合很好。 这是为什么?
答案是,可组合的例子有你想要做什么 ,你实际上是怎么做的完全分离。
在所有上述示例中,有一个共同点。 服务的功能方式着眼于您想要的结果是什么。 迭代的做事方式着重于您实际到达那里的方式,实现细节。 事实证明,将关于如何做事情的迭代指令组合在一起并不能构成应该做什么的高级描述。
例如,在承诺的案例:在这种情况下,是什么让一个HTTP调用接着又一个。 方式无关紧要,也被抽象化了:也许它使用线程池,互斥锁等,但这并不重要。
函数式编程是分离 你想要的结果,有待
从 结果 如何 实现。
确实,那是我对函数式编程的实际定义。 我们希望在我们的程序中明确分离关注点。 “您想要的东西”部分很好且可组合,可轻松地从较小的对象构建较大的对象。 有时需要“如何做”部分,但是通过将其分离开来,我们可以将组成不那么复杂的内容排除在组成更复杂的内容之外。
我们可以在实际示例中看到这一点:
即使您不使用功能性编程语言,将程序的内容和方式分开也可以使它们更具可组合性。
感谢Dmitriy Afanasyev。
From: https://hackernoon.com/practical-functional-programming-6d7932abc58b