软件架构风格 仓库风格
第一步。
在上一篇文章中 ,我们从基本原理开始介绍了函数式编程。 这是很多琐事,没有实践。 没有副作用的编程思想很好,但是我们需要知道如何实际进行。 因此,让我们通过看一些代码来探索它。 罗马数字kata是一个很好的练习,我们可以用来说明这些想法。
罗马数字。
简而言之,罗马数字是一种数字系统,使用拉丁字母中的字母表示数字:数字1由I表示,五由V表示 ,十由X表示 ,五十由L表示 ,一百由C表示 ,五百由D表示 ,和一千由M。 规则是:
- 当数字出现在较低或相等值的数字的左侧时,将这些值相加,因此II为2, III为 3, VI为6。
- 当较低的数字出现在较高的数字的左侧时,将从较高的值中减去较低的值。 因此, IV为4, IX为9, XL为40。
这两个规则同时适用于任何给定的数字,因此:
- XIV为14,即10 +(5 – 1)
- LIX是59,即50 +(10 – 1)
- CXL为140,即100 +(50 – 10)。
有了这些知识,我们可以用Groovy编写一个规范,将印度阿拉伯数字转换为罗马数字:
class NumeralsShould extends Specification {
def "convert arabic numbers to roman"(int arabic, String roman) {
def numerals = new Numerals()
expect:
numerals.convert(arabic) == roman
where:
arabic | roman
1 | "I"
2 | "II"
3 | "III"
4 | "IV"
5 | "V"
6 | "VI"
9 | "IX"
27 | "XXVII"
48 | "XLVIII"
59 | "LIX"
93 | "XCIII"
141 | "CXLI"
163 | "CLXIII"
402 | "CDII"
575 | "DLXXV"
911 | "CMXI"
1024 | "MXXIV"
3000 | "MMM"
}
}
解决此问题的关键是通过将减数对本身视为数字来消除特殊情况,例如CM为900, CD为400, XC为90, XL为40, IX为9和IV为4 。 当您执行此操作时,问题将变成纯粹的累加。 您最终得到了一组符号,这些符号可以组合成有效的罗马数字,因此总和等于任何自然数:
class Numerals {
def getNumerals() {
return [
[symbol: 'M', value: 1000],
[symbol: 'CM', value: 900],
[symbol: 'D', value: 500],
[symbol: 'CD', value: 400],
[symbol: 'C', value: 100],
[symbol: 'XC', value: 90],
[symbol: 'L', value: 50],
[symbol: 'XL', value: 40],
[symbol: 'X', value: 10],
[symbol: 'IX', value: 9],
[symbol: 'V', value: 5],
[symbol: 'IV', value: 4],
[symbol: 'I', value: 1],
]
}
String convert(int number) { ... }
}
有多种方法可以实现此kata,但最明显的方法可能是按值的降序排列符号。 当前符号值小于或等于数字时,将符号重复添加到输出并从数字中减去该值; 否则继续下一个符号。 不断重复,直到将数字减为零为止。 您附加的符号字符串就是您的结果:
class Numerals {
def getNumerals() { ... }
String convert(int number) {
String roman = ''
numerals.each { numeral ->
while (number >= numeral.value) {
roman = roman.concat(numeral.symbol)
number -= numeral.value
}
}
roman
}
}
这段代码还可以,但是功能正常吗? 根据我在第一部分中给出的第一个定义,它是:
在函数代码中,函数的输出值仅取决于传递给该函数的参数,因此,对参数x两次调用具有相同值的函数f每次会产生相同的结果f(x)。
这个函数当然是正确的:给定一定的输入,它总是返回相同的值。 它不会更改自己范围之外的任何状态,也不依赖于自己范围之外可能被任何其他线程更改的任何状态。 (这就是为什么我实现了一种按需创建符号映射的方法的原因)。 它满足纯功能的标准。
但是它是以功能样式编写的吗? 一点也不。 可能没有全局状态,但是有内部状态,并且肯定会对其进行修改。 它连接字符串以构成结果,并反复从输入数字中减去。 它甚至修改了它的参数,即使他们对改变状态不满意,许多程序员也会反对它。 总体而言,该算法本质上非常必要:
- 创建一个空字符串。
- 当数字大于或等于符号值时,请执行这些操作。
- 将数字附加到字符串。
- 从数字中减去 。
那么,我们如何才能重写此代码以减少必要性呢? 特别是,如何在不重新分配任何内容的情况下实现迭代? 在我们的循环中,需要跟踪三件事:
- 迭代计数器(好的,for循环为我们隐式处理了这个,因此我们可以假装一个不存在)。
- 输出数字字符串。
- 减法的结果。
递归,神圣。
答案是我们使用递归。 让我们用一个简单的例子来探讨这个话题。 此函数从给定的数字开始倒数:
void countDown(int from) {
for (var i = from; i > 0 ; i--)
System.out.println(i);
System.out.println("We have lift-off!");
}
在此功能中发生突变的状态是循环计数器i
。 我们可以通过使函数调用自身来避免对其进行更改:
void countDown(int from) {
System.out.println(from);
if (from > 1)
countDown(from - 1);
else
System.out.println("We have lift-off!");
}
它的行为完全相同,但是现在没有任何东西可以重新分配。 这里的功能优势有两个代价:首先,可读性略有下降。 我们很快就会得出第二个成本。
要将罗马数字函数重写为递归算法,如果首先将嵌套循环展平为单个循环,则将变得更加容易:
class Numerals {
def getNumerals() { ... }
String convert(int number) {
def roman = ''
def numeralIndex = 0
while (number > 0) {
def numeral = numerals[numeralIndex]
if (number >= numeral.value) {
roman = roman.concat(numeral.symbol)
number -= numeral.value
} else {
numeralIndex += 1
}
}
roman
}
}
是的,我同意这不是原始版本的改进,但是现在我们可以更轻松地重构函数以使用递归:
class Numerals {
def getNumerals() { ... }
String convert(int number) {
convert(number, 0, '')
}
String convert(int remainder, int numeralIndex, String roman) {
if (remainder == 0)
roman
else {
def numeral = numerals[numeralIndex]
if (remainder >= numeral.value)
convert(remainder - numeral.value, numeralIndex, roman.concat(numeral.symbol))
else
convert(remainder, numeralIndex + 1, roman)
}
}
}
不幸的是,我们不得不重载convert
方法,但是现在我们将忽略此问题。 重载采用余数,数字索引和累加结果的字符串。 如果余数为零,则累加的字符串已包含结果,因此我们将其返回。 否则,我们将在给定的数字索引处获得符号和值。 如果该值小于或等于余数,则将符号附加到结果上,从余数中减去该值并递归。 如果该值大于余数,则增加数字索引并递归。
现在,我们有了一种不会重新分配任何值的算法。 本质上,它也没有那么必要。 代码中剩下的唯一语句是赋值
def numeral = numerals[numeralIndex]
如果我们愿意,甚至可以将其排除在外。
但是这样更好吗? 我个人不会这么说:原始版本更短,更清晰,并且无需对convert函数进行重载。 让我们做一个实验,看看它在功能语言中的外观。 使用Brian Marick的测试库Midje,我们可以在Clojure中编写一组事实:
(facts "roman numbers"
(fact "roman numeral for given arabic number"
(convert 1) => "I"
(convert 2) => "II"
(convert 3) => "III"
(convert 4) => "IV"
(convert 5) => "V"
(convert 6) => "VI"
(convert 9) => "IX"
(convert 27) => "XXVII"
(convert 48) => "XLVIII"
(convert 59) => "LIX"
(convert 93) => "XCIII"
(convert 141) => "CXLI"
(convert 163) => "CLXIII"
(convert 402) => "CDII"
(convert 575) => "DLXXV"
(convert 911) => "CMXI"
(convert 1024) => "MXXIV"
到目前为止没有太大的不同。 Clojure中的等效实现如下所示:
(def numerals
[["M" 1000] ["CM" 900] ["D" 500] ["CD" 400]
["C" 100] ["XC" 90] ["L" 50] ["XL" 40]
["X" 10] ["IX" 9] ["V" 5] ["IV" 4]
["I" 1]])
(defn convert
([number]
(convert number 0 ""))
([remainder numeral-index roman]
(if (zero? remainder)
roman
(let [[symbol value] (nth numerals numeral-index)]
(if (>= remainder value)
(convert (- remainder value) numeral-index (str roman symbol))
(convert remainder (inc numeral-index) roman))))))
如果您不熟悉Lisp,那么它可能看起来有些怪异,但是Lisp语法实际上很简单。 在大多数语言中,传递参数x和y的函数f的调用看起来像这样: f(x, y)
。 要将其转换为Lisp,只需删除逗号并在函数名称: (fxy)
左侧移动打开括号。 Lisp已经学到了几乎所有内容。 与Groovy版本类似,Clojure代码重载了convert函数,接受单个参数[number]
或三个参数[remainder numeral-index roman]
。 参数的含义与Groovy中的相同。
Clojure中的if
形式是一个表达式,其操作与C风格语言中的三元运算符相同:
(if cond value-when-true value-when-false)
cond表达式的计算结果为一个值,Clojure中的任何值都可以视为真或假。 nil
和false
都是假的,其他所有东西都是真实的。 这种使用表达式而不是语句的编程方式会导致在行尾形成紧密的括号,许多人对Lisp表示反对。 Haskell通过省略括号来避免出现此问题。 Clojure也有一些宏可以改善该问题,但是我们稍后会讨论。
Clojure代码中唯一具有魔力的部分是以下部分:
let [[symbol value] (nth numerals numeral-index)]
(nth numerals numeral-index)
的含义应该很明显:它为您提供了来自numerals
矢量的第n个元素,其中n等于numeral-index
。 那将是两个元素的向量,例如["M" 1000]
。 我们使用称为解构的Clojure功能将symbol
分配给向量中的第一个元素(“ M”),并将value
分配给第二个元素(1000)。
除了个人品味,这两个版本之间几乎没有选择。 主要区别在于,功能语言不可避免地会导致您根据语言本身所做的设计选择进行递归。 在Groovy中,您可以选择使用循环。 如果必须使用迭代算法,则在命令式语言中,您总是会选择在递归上循环,因为它的效果更好。 这是我前面提到的第二个成本,这是一个很大的问题。
消除尾音。
每当您编写递归程序时,您都必须事先了解递归的深度,以免浪费堆栈。 跳转到子例程的程序将返回地址压入堆栈,该地址在子例程完成时弹出。 如果该子例程调用另一个子例程,则它将其自身的返回地址推入顶部。 堆栈以正确的顺序保存所有返回地址,以便程序始终能够将控制权返回到其来源。 显然,子例程中嵌套的深度越大,就需要越多的堆栈空间。 在非递归代码中,这没有问题:您永远不会接近耗尽内存所需的嵌套深度。 但是递归子例程会自己调用! 在函数式编程中,递归是进行迭代的首选方法,但是这使得删除堆栈的前景非常现实。
可以避免此问题。 关键是放置子例程调用,使其处于尾部位置 。 这意味着在从子例程返回控制与例程的结束之间不再有任何指令。 换句话说,当子例程A从尾部位置调用子例程B时,控制从B返回,仅立即返回到A的调用者。让我们用一些代码来说明这一点。 考虑一下前面的愚蠢倒计时示例:
void countDown(int from) {
System.out.println(from);
if (from > 1)
countDown(from - 1);
else
System.out.println("We have lift-off!");
}
对countDown
的递归调用位于尾部,因为当它返回时,在调用函数中无需执行任何其他操作。 因此,让我们考虑一个不在尾部位置的递归调用:
private long factorial(long n) {
if (n == 1)
return n;
else
return n * (factorial(n - 1));
}
对factorial
的递归调用不在尾部,因为其结果必须乘以n
才能计算外部函数调用的返回值。 但是为什么这很重要? 这很重要,因为正如盖伊·斯蒂尔(Guy L. Steele)在1977年提交给ACM的论文中所观察到的那样,可以将尾部位置的子例程调用替换为goto 。 与子例程调用不同,goto不能预期控件会返回到调用例程,因此不会将返回地址压入调用堆栈。
我们可以通过显示两个嵌套子程序调用的图表来显示斯蒂尔的观察结果:
在这里,例程A调用子例程B,然后B调用子例程C。完成后,C将从调用堆栈中弹出返回地址以返回到B,而B弹出先前的返回地址以返回到A。
但是,正如斯蒂尔注意到的那样,如果对C的调用处于尾部位置,则B也可以直接转到C,将其自己的返回地址保留在调用堆栈的顶部,因为C会弹出该地址并直接返回给A。无论如何,这是我们想要发生的事情。 这称为消除尾部调用 ,其好处是双重的:首先,我们避免了一条不必要的跳转指令,更重要的是,我们避免了增加调用堆栈。
如果您在递归算法中以这种方式消除了tail调用,则会得到一个例程,该例程会反复跳转到其入口点,直到达到终止条件为止。 用机器术语来说,这与常规循环没有什么不同!
啊,但是有一个陷阱。 您需要使用执行这种优化的语言进行编程。 在某些情况下,.NET运行时可以。 JVM没有。 因此,在JVM上运行的功能语言必须提供解决方法。 Scala会在可能的情况下将尾递归函数优化为循环,您可以通过使用@tailrec
注释要接收此处理的函数来验证它是否正在这样做。 (如果@tailrec
注释的函数实际上不是尾递归的,则编译器将产生错误)。 Clojure提供loop..recur
形式,该形式还将您的代码重写为一个循环,同时给您“递归”的感觉:
(defn convert [number]
(loop [remainder number, numeral-index 0, roman ""]
(if (zero? remainder)
roman
(let [[symbol value] (nth numerals numeral-index)]
(if (>= remainder value)
(recur (- remainder value) numeral-index (str roman symbol))
(recur remainder (inc numeral-index) roman))))))
这也使我们摆脱了仅存在于支持递归的重载。 如果我们回头看一下该代码的原始Groovy版本,我认为功能代码并不比原始代码复杂,而且更优雅:
String convert(int number) {
String roman = ''
numerals.each { numeral ->
while (number >= numeral.value) {
roman = roman.concat numeral.symbol
number -= numeral.value
}
}
roman
}
参照透明。
在结束之前,请回想一下Groovy中使用的罗马数字代码版本:
class Numerals {
def getNumerals() { ... }
String convert(int number) {
convert(number, 0, '')
}
String convert(int remainder, int numeralIndex, String roman) {
if (remainder == 0)
roman
else {
def numeral = numerals[numeralIndex]
if (remainder >= numeral.value)
convert(remainder - numeral.value, numeralIndex, roman.concat(numeral.symbol))
else
convert(remainder, numeralIndex + 1, roman)
}
}
}
我们可以通过直接为remainder
, numeralIndex
和roman
参数插入值来计算14的罗马表示形式。 通过将参数索引设置为8,我有点作弊。 我这样做是为了跳过具有较高价值的数字。 否则,这项已经很漫长的练习将变得相当长:
String convert() {
convert(14, 8, '')
}
有一个纯函数的属性,就像convert(int, int, String)
方法一样,我们可以直接在其调用中复制实现,如下所示:
String convert() {
if (14 == 0)
''
else {
if (14 >= 10)
convert(14 - 10, 8, ''.concat('X'))
else
convert(14, 8 + 1, '')
}
}
纯函数的这种特性称为引用透明性 。 我还将文字值复制到了numeral.value
和numeral.symbol
因为它们是由文字索引值从数组中查找的,因此可以简化。 现在,保证通过该代码的某些路径不被采用:14显然不等于0且明显大于10。因此,我们可以手动删除所有保证不采用的分支,以及代码减少到这个:
String convert() {
convert(14 - 10, 8, ''.concat('X'))
}
现在我们可以再次替换convert
方法调用:
String convert() {
if ((14 - 10) == 0)
''.concat('X')
else {
if ((14 - 10) >= 10)
convert((14 - 10) - 10, 8, ''.concat('X').concat('X'))
else
convert((14 - 10), 8 + 1, ''.concat('X'))
}
}
再一次,我们可以删除所有不会被采用的分支,这一次它可以简化为:
String convert() {
convert((14 - 10), 8 + 1, ''.concat('X'))
}
让我们再次进行替换。 这一次,我们更换numeral.value
9和numeral.symbol
与“九”:
String convert() {
if ((14 - 10) == 0)
''.concat('X')
else {
if ((14 - 10) >= 9)
convert((14 - 10) - 9, (8 + 1), ''.concat('X').concat('IX'))
else
convert((14 - 10), (8 + 1) + 1, ''.concat('X'))
}
}
再次,我们可以看到可以保证采用哪个分支,因此可以将代码缩减为:
String convert() {
convert((14 - 10), (8 + 1) + 1, ''.concat('X'))
}
和再次替换方法调用,此时更换numeral.value
用5和numeral.symbol
与“V”:
String convert() {
if ((14 - 10) == 0)
''.concat('X')
else {
if ((14 - 10) >= 5)
convert((14 - 10) - 5, ((8 + 1) + 1), ''.concat('X').concat('V'))
else
convert((14 - 10), ((8 + 1) + 1) + 1, ''.concat('X'))
}
}
再一次,我们消除了所有保证不采取的分支,并获得:
String convert() {
convert((14 - 10), ((8 + 1) + 1) + 1, ''.concat('X'))
}
我们再次替换方法调用,将4替换为numeral.value
并将'IV' numeral.symbol
为numeral.symbol
:
String convert() {
if ((14 - 10) == 0)
''.concat('X')
else {
if ((14 - 10) >= 4)
convert((14 - 10) - 4, (((8 + 1) + 1) + 1), ''.concat('X').concat('IV'))
else
convert((14 - 10), (((8 + 1) + 1) + 1) + 1, ''.concat('X'))
}
}
这次消除未使用的分支将导致:
String convert() {
convert((14 - 10) - 4, (((8 + 1) + 1) + 1), ''.concat('X').concat('IV'))
}
在最后一次,我们复制了方法调用:
String convert() {
if (((14 - 10) - 4) == 0)
''.concat('X').concat('IV')
else {
if (((14 - 10) - 4) >= 4)
convert(((14 - 10) - 4) - 4, (((8 + 1) + 1) + 1), ''.concat('X').concat('IV').concat('IV'))
else
convert(((14 - 10) - 4), (((8 + 1) + 1) + 1) + 1, ''.concat('X').concat('IV'))
}
}
并且因为(((14 - 10) - 4) == 0)
为真,所以我们可以看到最终结果是:
String convert() {
''.concat('X').concat('IV')
}
产生十四 ,十四。
在上一篇文章中,我曾说过,在不重新分配符号值的情况下进行编程可能会导致符号数量激增。 至少看起来应该如此。 但是,用它的实现反复替换函数调用的过程虽然很费力,但并未导致程序中使用的符号数量增加。
您是否建议我们采用这种方式编程?
一点也不。 当我用命令式语言编程时,我仍然使用迭代而不是递归。 如果您在第1部分中回想过,我说过,每种命令式语言都对FP有一定的吸引力。 当然不是。 这篇文章绝不是函数式编程的杀手argument,事实上,它本身根本就不适合FP。 相反,我的目的是解释基础知识,并证明在不改变状态的情况下进行编程确实是可能的。 稍后,我将尝试解释为什么它是可取的。
下次:
如果这是函数编程的全部内容,那么我会原谅您认为这样做不值得大肆宣传。 幸运的是,从长远来看,这还不是全部。 我们在这里看到了一些函数式编程,但是没有我所说的函数式样式。 当您以功能风格进行编程时,几乎不需要编写任何类型的循环。 循环是达到目的的一种手段,而不是目的本身。 功能风格使您可以根据需要的目标进行更多的编程,而不必为实现这些目标而烦恼。 在下一篇文章中,我将开始探讨这个主题。 我们将介绍一流的函数和lambda表达式,以及如何使用它们通常可以完全避免编写显式循环的需要。
翻译自: https://www.javacodegeeks.com/2018/08/functional-style-part-1-2.html
软件架构风格 仓库风格