最近我读了很多关于函数式编程的东西,我可以理解其中的大部分内容,但我无法理解的一件事是无状态编码。在我看来,通过删除可变状态来简化编程就像通过删除仪表板来“简化”汽车:成品可能更简单,但祝它与最终用户交互好运。
几乎我能想到的每个用户应用程序都将状态作为核心概念。如果您编写文档(或 SO 帖子),则状态会随着每个新输入而改变。或者如果你玩电子游戏,有大量的状态变量,从所有角色的位置开始,他们往往会不断地四处走动。如果不跟踪值的变化,你怎么可能做任何有用的事情呢?
每次我找到讨论这个问题的东西时,它都是用真正的技术功能语言编写的,假设我没有沉重的 FP 背景。有没有人知道一种方法可以向对命令式编码有良好、扎实理解但在功能方面完全n00b的人解释这一点?
编辑:到目前为止,一堆回复似乎试图让我相信不可变值的优势。我明白那部分。这很有意义。我不明白的是如何在没有可变变量的情况下跟踪必须更改并不断更改的值。
或者如果你玩电子游戏,有大量的状态变量,从所有角色的位置开始,他们往往会不断地四处走动。如果不跟踪值的变化,你怎么可能做任何有用的事情呢?
如果您有兴趣,here's 一系列描述 Erlang 游戏编程的文章。
您可能不会喜欢这个答案,但在您使用它之前,您不会获得 功能程序。我可以发布代码示例并说“这里,你不看到”——但是如果你不理解语法和基本原理,那么你的眼睛就会呆滞。从您的角度来看,我似乎在做与命令式语言相同的事情,但只是设置了各种边界以有目的地使编程变得更加困难。我的观点是,您只是在体验 Blub paradox。
起初我持怀疑态度,但几年前我跳上了函数式编程的火车并爱上了它。函数式编程的诀窍在于能够识别模式、特定的变量赋值,并将命令式状态移动到堆栈中。例如,for 循环变成递归:
// Imperative
let printTo x =
for a in 1 .. x do
printfn "%i" a
// Recursive
let printTo x =
let rec loop a = if a <= x then printfn "%i" a; loop (a + 1)
loop 1
它不是很漂亮,但我们得到了相同的效果,没有突变。当然,只要有可能,我们喜欢完全避免循环并将其抽象掉:
// Preferred
let printTo x = seq { 1 .. x } |> Seq.iter (fun a -> printfn "%i" a)
Seq.iter 方法将枚举集合并为每个项目调用匿名函数。非常便利 :)
我知道,打印数字并不令人印象深刻。但是,我们可以对游戏使用相同的方法:将所有状态保存在堆栈中,并使用递归调用中的更改创建一个新对象。这样,每一帧都是游戏的无状态快照,其中每一帧只是创建一个全新的对象,其中包含需要更新的任何无状态对象的所需更改。其伪代码可能是:
// imperative version
pacman = new pacman(0, 0)
while true
if key = UP then pacman.y++
elif key = DOWN then pacman.y--
elif key = LEFT then pacman.x--
elif key = UP then pacman.x++
render(pacman)
// functional version
let rec loop pacman =
render(pacman)
let x, y = switch(key)
case LEFT: pacman.x - 1, pacman.y
case RIGHT: pacman.x + 1, pacman.y
case UP: pacman.x, pacman.y - 1
case DOWN: pacman.x, pacman.y + 1
loop(new pacman(x, y))
命令式和函数式版本是相同的,但函数式版本显然不使用可变状态。功能代码保持所有状态都保存在堆栈上——这种方法的好处是,如果出现问题,调试很容易,你只需要一个堆栈跟踪。
这可以扩展到游戏中任意数量的对象,因为所有对象(或相关对象的集合)都可以在它们自己的线程中渲染。
几乎我能想到的每个用户应用程序都将状态作为核心概念。
在函数式语言中,我们不是改变对象的状态,而是简单地返回一个带有我们想要的更改的新对象。它比听起来更有效率。例如,数据结构很容易表示为不可变的数据结构。例如,堆栈非常容易实现:
using System;
namespace ConsoleApplication1
{
static class Stack
{
public static Stack<T> Cons<T>(T hd, Stack<T> tl) { return new Stack<T>(hd, tl); }
public static Stack<T> Append<T>(Stack<T> x, Stack<T> y)
{
return x == null ? y : Cons(x.Head, Append(x.Tail, y));
}
public static void Iter<T>(Stack<T> x, Action<T> f) { if (x != null) { f(x.Head); Iter(x.Tail, f); } }
}
class Stack<T>
{
public readonly T Head;
public readonly Stack<T> Tail;
public Stack(T hd, Stack<T> tl)
{
this.Head = hd;
this.Tail = tl;
}
}
class Program
{
static void Main(string[] args)
{
Stack<int> x = Stack.Cons(1, Stack.Cons(2, Stack.Cons(3, Stack.Cons(4, null))));
Stack<int> y = Stack.Cons(5, Stack.Cons(6, Stack.Cons(7, Stack.Cons(8, null))));
Stack<int> z = Stack.Append(x, y);
Stack.Iter(z, a => Console.WriteLine(a));
Console.ReadKey(true);
}
}
}
上面的代码构造了两个不可变列表,将它们附加在一起以创建一个新列表,然后附加结果。在应用程序的任何地方都没有使用可变状态。它看起来有点笨重,但这只是因为 C# 是一种冗长的语言。这是 F# 中的等效程序:
type 'a stack =
| Cons of 'a * 'a stack
| Nil
let rec append x y =
match x with
| Cons(hd, tl) -> Cons(hd, append tl y)
| Nil -> y
let rec iter f = function
| Cons(hd, tl) -> f(hd); iter f tl
| Nil -> ()
let x = Cons(1, Cons(2, Cons(3, Cons(4, Nil))))
let y = Cons(5, Cons(6, Cons(7, Cons(8, Nil))))
let z = append x y
iter (fun a -> printfn "%i" a) z
创建和操作列表不需要可变变量。几乎所有的数据结构都可以很容易地转换成它们的等效功能。我写了一个页面here,它提供了堆栈、队列、左派堆、红黑树、惰性列表的不可变实现。没有一个代码片段包含任何可变状态。为了“变异”一棵树,我用我想要的新节点创建了一个全新的树——这非常有效,因为我不需要复制树中的每个节点,我可以在我的新节点中重用旧节点树。
使用一个更重要的示例,我还编写了完全无状态的 this SQL parser(或者至少 my 代码是无状态的,我不知道底层的词法库是否是无状态的)。
无状态编程与有状态编程一样富有表现力和强大,它只需要一点练习来训练自己开始无状态思考。当然,“尽可能无状态编程,必要时有状态编程”似乎是大多数不纯函数式语言的座右铭。当函数式方法不那么干净或高效时,使用可变变量并没有什么坏处。
简短的回答:你不能。
那么关于不变性有什么大惊小怪的呢?
如果您精通命令式语言,那么您就会知道“全局变量很糟糕”。为什么?因为它们在您的代码中引入(或有可能引入)一些非常难以解开的依赖关系。而且依赖不好;你希望你的代码是模块化的。程序的一部分尽可能少地影响其他部分。 FP 将您带到模块化的圣杯:完全没有副作用。你只有你的 f(x) = y。把 x 放进去,把 y 拿出来。对 x 或其他任何内容都没有更改。 FP 让你停止思考状态,而开始思考价值。您的所有函数都只是接收值并产生新值。
这有几个优点。
首先,没有副作用意味着程序更简单,更容易推理。不用担心引入程序的新部分会干扰和崩溃现有的工作部分。
其次,这使得程序可以简单地并行化(有效的并行化是另一回事)。
第三,有一些可能的性能优势。假设您有一个功能:
double x = 2 * x
现在你输入 3 的值,你得到 6 的值。每次。但是你也可以在命令式中做到这一点,对吧?是的。但问题是,在命令式中,你可以做的更多。我可以:
int y = 2;
int double(x){ return x * y; }
但我也可以
int y = 2;
int double(x){ return x * (y++); }
命令式编译器不知道我是否会产生副作用,这使得优化变得更加困难(即双 2 不必每次都是 4)。功能性的知道我不会 - 因此,它可以在每次看到“双 2”时进行优化。
现在,即使每次创建新值对于计算机内存方面的复杂类型的值来说似乎都非常浪费,但不必如此。因为,如果您有 f(x) = y,并且 x 和 y 的值“基本相同”(例如,仅在几片叶子上不同的树),那么 x 和 y 可以共享部分内存 - 因为它们都不会发生变异.
所以如果这个不可变的东西这么棒,我为什么要回答没有可变状态你就不能做任何有用的事情。好吧,如果没有可变性,您的整个程序将是一个巨大的 f(x) = y 函数。程序的所有部分也是如此:只是函数,以及“纯”意义上的函数。正如我所说,这意味着每次 f(x) = y。因此,例如 readFile("myFile.txt") 每次都需要返回相同的字符串值。不太好用。
因此,每个 FP 都提供了一些改变状态的方法。 “纯”函数式语言(例如 Haskell)使用诸如 monad 之类的有些吓人的概念来做到这一点,而“不纯”函数式语言(例如 ML)则直接允许这样做。
当然,函数式语言还有许多其他的优点,可以使编程更高效,例如一流的函数等。
int double(x){ return x * (++y); }
,因为当前的仍然是 4,尽管仍然有未公布的副作用,而 ++y
将返回 6。
请注意,说函数式编程没有“状态”有点误导,可能是造成混乱的原因。它绝对没有“可变状态”,但它仍然可以有被操纵的值;它们只是不能就地更改(例如,您必须从旧值创建新值)。
这是一个严重的过度简化,但想象一下你有一种 OO 语言,其中类的所有属性只在构造函数中设置一次,所有方法都是静态函数。您仍然可以通过让方法获取包含计算所需的所有值的对象,然后返回带有结果的新对象(甚至可能是同一对象的新实例)来执行几乎任何计算。
将现有代码转换为这种范式可能“很难”,但那是因为它确实需要一种完全不同的代码思考方式。作为副作用,尽管在大多数情况下,您可以获得很多免费并行的机会。
附录:(关于您对如何跟踪需要更改的值的编辑)它们当然会存储在不可变的数据结构中......
这不是建议的“解决方案”,但最简单的方法是看到这将始终有效,您可以将这些不可变值存储到类似结构的映射(字典/哈希表)中,以“变量名称”为键。
显然,在实际解决方案中,您会使用更理智的方法,但这确实表明,如果没有其他方法可以工作,那么最坏的情况是您可以使用通过调用树携带的这种映射来“模拟”可变状态。
我觉得有一点点误会。纯函数式程序有状态。不同之处在于该状态的建模方式。在纯函数式编程中,状态由函数操作,这些函数接受一些状态并返回下一个状态。然后通过将状态传递给一系列纯函数来实现对状态的排序。
甚至全局可变状态也可以通过这种方式建模。例如,在 Haskell 中,程序是从 World 到 World 的函数。也就是说,你传入整个宇宙,程序返回一个新的宇宙。但实际上,您只需要传入程序实际感兴趣的宇宙部分。程序实际上会返回一系列操作,这些操作作为程序运行的操作环境的指令。
您希望看到用命令式编程来解释这一点。好的,让我们看一些用函数式语言编写的非常简单的命令式编程。
考虑这段代码:
int x = 1;
int y = x + 1;
x = x + y;
return x;
非常标准的命令式代码。没有做任何有趣的事情,但这可以用于说明。我想你会同意这里涉及到状态。 x 变量的值随时间变化。现在,让我们通过发明一种新语法来稍微改变符号:
let x = 1 in
let y = x + 1 in
let z = x + y in z
加上括号可以更清楚地说明这意味着什么:
let x = 1 in (let y = x + 1 in (let z = x + y in (z)))
所以你看,状态是由一系列纯表达式建模的,这些表达式绑定了以下表达式的自由变量。
你会发现这种模式可以模拟任何一种状态,甚至是 IO。
这只是做同一件事的不同方式。
考虑一个简单的例子,例如将数字 3、5 和 10 相加。想象一下,首先通过将 3 的值加 5 来更改它,然后在该“3”上加 10,然后输出“”的当前值。 3" (18)。这看起来显然很荒谬,但本质上它是基于状态的命令式编程经常完成的方式。实际上,您可以有许多不同的“3”,它们的值为 3,但又是不同的。所有这一切似乎都很奇怪,因为我们已经根深蒂固地认为数字是不可变的,这个非常明智的想法。
现在考虑在将值设为不可变时添加 3、5 和 10。将 3 和 5 相加产生另一个值 8,然后将该值加 10 产生另一个值 18。
这些是做同样事情的等效方法。所有必要的信息都存在于这两种方法中,但形式不同。一方面,信息作为状态存在,并且存在于改变状态的规则中。另一方面,信息存在于不可变数据和功能定义中。
以下是您编写没有可变状态的代码的方法:不是将状态变化放入可变变量中,而是将其放入函数的参数中。而不是编写循环,而是编写递归函数。因此,例如这个命令式代码:
f_imperative(y) {
local x;
x := e;
while p(x, y) do
x := g(x, y)
return h(x, y)
}
变成这个功能代码(类似于方案的语法):
(define (f-functional y)
(letrec (
(f-helper (lambda (x y)
(if (p x y)
(f-helper (g x y) y)
(h x y)))))
(f-helper e y)))
或者这个 Haskellish 代码
f_fun y = h x_final y
where x_initial = e
x_final = loop x_initial
loop x = if p x y then loop (g x y) else x
至于为什么函数式程序员喜欢这样做(你没有问过),你的程序越多是无状态的,就越有方法可以在不中断的情况下将它们组合在一起。无状态范式的力量不在于无状态(或纯度)本身,而在于它赋予您编写强大、可重用的函数并将它们组合起来的能力。
您可以在 John Hughes 的论文 Why Functional Programming Matters 中找到包含大量示例的优秀教程。
我迟到了讨论,但我想为那些在函数式编程中苦苦挣扎的人补充几点。
函数式语言保持与命令式语言完全相同的状态更新,但它们通过将更新后的状态传递给后续函数调用来做到这一点。这是一个非常简单的沿数轴移动的示例。您所在的州是您当前的位置。
首先是命令式(伪代码)
moveTo(dest, cur):
while (cur != dest):
if (cur < dest):
cur += 1
else:
cur -= 1
return cur
现在是功能方式(在伪代码中)。我非常依赖三元运算符,因为我希望具有命令式背景的人能够真正阅读这段代码。因此,如果您不经常使用三元运算符(我在命令式的日子里总是避免使用它),这就是它的工作原理。
predicate ? if-true-expression : if-false-expression
您可以通过将新的三元表达式代替假表达式来链接三元表达式
predicate1 ? if-true1-expression :
predicate2 ? if-true2-expression :
else-expression
因此,考虑到这一点,这是功能版本。
moveTo(dest, cur):
return (
cur == dest ? return cur :
cur < dest ? moveTo(dest, cur + 1) :
moveTo(dest, cur - 1)
)
这是一个简单的例子。如果这是在游戏世界中移动人们,则必须引入副作用,例如在屏幕上绘制对象的当前位置,并根据对象移动的速度在每次调用中引入一点延迟。但是你仍然不需要可变状态。
教训是函数式语言通过调用具有不同参数的函数来“改变”状态。显然,这并没有真正改变任何变量,但这就是你获得类似效果的方式。这意味着如果你想进行函数式编程,你必须习惯于递归思考。学习递归思考并不难,但它确实需要练习和工具包。在那本“学习 Java”一书中,他们使用递归来计算阶乘的那一小部分并没有删减它。您需要一个技能工具包,例如使迭代过程脱离递归(这就是尾递归对于函数式语言必不可少的原因)、延续、不变量等。如果不学习访问修饰符、接口等,就不会进行 OO 编程。同样的事情用于函数式编程。
我的建议是做 Little Schemer(注意我说的是“做”而不是“阅读”),然后在 SICP 中做所有的练习。完成后,您的大脑将与开始时不同。
事实上,即使在没有可变状态的语言中,也很容易拥有看起来像可变状态的东西。
考虑一个类型为 s -> (a, s)
的函数。从 Haskell 语法翻译过来,它意味着一个函数,它接受一个“s
”类型的参数并返回一对“a
”和“s
”类型的值。如果 s
是我们的状态类型,则此函数接受一个状态并返回一个新状态,并且可能返回一个值(您始终可以返回“unit”,也就是 ()
,它有点等价于“void
”在 C/C++ 中,作为“a
”类型)。如果您将多个具有此类类型的函数调用链接起来(从一个函数返回状态并将其传递给下一个函数),您将拥有“可变”状态(实际上,您在每个函数中创建一个新状态并放弃旧状态)。
如果您将可变状态想象为程序正在执行的“空间”,然后考虑时间维度,则可能更容易理解。在时刻 t1,“空间”处于某种条件下(例如,某个内存位置的值为 5)。在稍后的时刻 t2,它处于不同的状态(例如,内存位置现在具有值 10)。这些时间“切片”中的每一个都是一个状态,并且它是不可变的(您无法及时返回来更改它们)。所以,从这个角度来看,你从带有时间箭头的完整时空(你的可变状态)到一组时空切片(几个不可变状态),你的程序只是将每个切片视为一个值并计算每个它们中的一个函数应用于前一个函数。
好的,也许这并不容易理解:-)
将整个程序状态显式表示为一个值似乎是无用的,它只能在下一个瞬间被丢弃(就在创建一个新状态之后)。对于某些算法,它可能是自然的,但如果不是,还有另一个技巧。除了真实的状态,您还可以使用一个假的状态,它只不过是一个标记(我们称这个假状态的类型为 State#
)。从语言的角度来看,这种假状态存在,并且像任何其他值一样被传递,但是编译器在生成机器代码时完全忽略了它。它仅用于标记执行顺序。
例如,假设编译器为我们提供了以下函数:
readRef :: Ref a -> State# -> (a, State#)
writeRef :: Ref a -> a -> State# -> (a, State#)
从这些类似 Haskell 的声明中翻译过来,readRef
接收到类似于指向“a
”类型值的指针或句柄的东西,以及假状态,并返回指向“a
”类型的值第一个参数和一个新的假状态。 writeRef
类似,但改变了指向的值。
如果您调用 readRef
,然后将 writeRef
返回的假状态传递给它(也许中间还有其他对不相关函数的调用;这些状态值创建了一个函数调用“链”),它将返回写入的值。您可以使用相同的指针/句柄再次调用 writeRef
,它将写入相同的内存位置 - 但是,由于从概念上讲它返回一个新的(假)状态,(假)状态仍然是不可变的(一个新的被“创造”)。如果存在必须计算的真实状态变量,编译器将按照调用它们的顺序调用函数,但唯一的状态是真实硬件的完整(可变)状态。
(了解 Haskell 的人会注意到我简化了很多东西,并省略了几个重要的细节。想要了解更多细节的人,请查看 mtl
中的 Control.Monad.State
,以及 ST s
和 IO
(又名 ST RealWorld
)单子。)
您可能想知道为什么要以这种迂回的方式来做(而不是简单地在语言中使用可变状态)。真正的优势是您拥有reified程序的状态。以前是隐含的(你的程序状态是全局的,允许像 action at a distance 这样的东西)现在是显式的。不接收和返回状态的函数不能修改它或受它的影响;他们是“纯粹的”。更好的是,您可以拥有单独的状态线程,并且使用一点类型魔法,它们可以用于在纯线程中嵌入命令式计算,而不会使其不纯(Haskell 中的 ST
monad 是通常用于这个技巧;我上面提到的 State#
实际上是 GHC 的 State# s
,由它的 ST
和 IO
monads 的实现使用)。
函数式编程避免状态并强调功能。从来不存在没有状态之类的东西,尽管状态实际上可能是不可变的或融入到您正在使用的架构中的东西。考虑一下仅从文件系统加载文件的静态 Web 服务器与实现魔方的程序之间的区别。前者将根据旨在将请求转换为文件路径请求的功能来实现,该请求将来自该文件的内容的响应。除了一点点配置之外,几乎不需要任何状态(文件系统“状态”实际上超出了程序的范围。无论文件处于什么状态,程序都以相同的方式工作)。但是,在后者中,您需要对多维数据集和程序实现建模,以了解对该多维数据集的操作如何更改其状态。
除了其他人给出的出色答案之外,想想 Java 中的 Integer
和 String
类。这些类的实例是不可变的,但这并不会因为它们的实例不能更改而使这些类无用。不变性为您提供了一些安全性。您知道,如果您使用 String 或 Integer 实例作为 Map
的键,则无法更改键。将此与 Java 中的 Date
类进行比较:
Date date = new Date();
mymap.put(date, date.toString());
// Some time later:
date.setTime(new Date().getTime());
您已经默默地更改了地图中的键!使用不可变对象(例如在函数式编程中)要干净得多。更容易推断会发生什么副作用 - 没有!这意味着程序员更容易,优化器也更容易。
对于游戏等高度交互的应用程序,函数式响应式编程是您的朋友:如果您可以将游戏世界的属性表述为时变值(和/或事件流) ), 你准备好了!这些公式有时甚至比改变一个状态更自然和更能揭示意图,例如对于一个移动的球,你可以直接使用众所周知的定律x = v * t。更好的是,游戏规则以这种方式编写的组合比面向对象的抽象更好。例如,在这种情况下,球的速度也可以是一个随时间变化的值,这取决于由球的碰撞组成的事件流。有关更具体的设计注意事项,请参阅 Making Games in Elm。
使用一些创意和模式匹配,创建了无状态游戏:
CSSPlay:迷宫游戏
CSSPlay:迷宫游戏 2
CSSPlay:井字游戏
纯 CSS 井字游戏
CSSPlay:乒乓球
CSSPlay:乒乓球
CSSPlay:警察和强盗
CSSPlay:打老鼠
CSS3 Pong:与 CSS 相关的疯狂事情
以及滚动演示:
CSSPlay:随机英雄
动画模拟 SVG 时钟
动画 SVG 摆
动画 SVG 赛车手
CSS3:造雪
和可视化:
XSLT Mandlebrot
这就是 FORTRAN 在没有 COMMON 块的情况下工作的方式:您将编写具有您传入的值和局部变量的方法。而已。
面向对象编程将状态和行为结合在一起,但当我在 1994 年第一次从 C++ 中遇到它时,这是一个新想法。
Geez,当我是一名机械工程师时,我是一名函数式程序员,我不知道!
请记住:函数式语言是图灵完备的。因此,任何用命令式语言执行的有用任务都可以用函数式语言完成。不过,归根结底,我认为混合方法有一些话要说。 F# 和 Clojure(我相信还有其他语言)等语言鼓励无状态设计,但在必要时允许可变性。
您不能拥有有用的纯函数式语言。总会有一定程度的可变性需要处理,IO 就是一个例子。
将函数式语言视为您使用的另一种工具。它对某些事情有好处,但对其他事情则不然。您提供的游戏示例可能不是使用函数式语言的最佳方式,至少屏幕将具有可变状态,您无法使用 FP 做任何事情。您思考问题的方式和使用 FP 解决的问题类型将不同于您习惯使用命令式编程的方式。
JavaScript 提供了非常清晰的示例,说明了在其核心中处理可变或不可变状态\值的不同方法,因为 ECMAScript 规范无法确定一个通用标准,因此必须继续记住或仔细检查哪些函数创建了它们返回的新对象或修改传递给它的原始对象。如果你的整个语言是不可变的,那么你知道你总是得到一个新的(复制的和可能修改的)结果,并且永远不必担心在将变量传递给函数之前意外修改它。
你知道哪个返回一个新对象,哪个改变了以下示例的原始对象吗?
Array.prototype.push()
String.prototype.slice()
Array.prototype.splice()
String.prototype.trim()
让我们回答更普遍的问题:
没有状态,你怎么能做任何有用的事情?
你没有。
在寻找传统语言的替代方案时,我们必须首先认识到系统不可能是历史敏感的(允许执行一个程序来影响后续程序的行为),除非系统具有某种状态(第一个程序可以更改并且第二个可以访问)。因此,计算系统的历史敏感模型必须具有状态转换语义,至少在这种弱意义上。约翰巴克斯。
(我强调。)
重要的是巴库斯随后的观察:
但这并不意味着每次计算都必须严重依赖于复杂的状态 [...]
Haskell 或 Clean 之类的函数式语言使您可以轻松地将这一观察结果付诸实践:大多数定义都是简单的函数,就像您在数学教育中看到的那样。这留下了一个小的“杂牌小组”来处理所有烦人的外部状态,以便例如:
与用户互动,
与远程服务通信,
使用随机抽样处理模拟,
打印出 SVG 文件(例如作为海报),
进行计划备份,
...两种语言都通过使用类型将平原与杂色分开。
有时,如果您尝试实现的算法是使用私有的本地可变状态实现的,那么它的效果最好。在这种情况下,您可以使用 Haskell 扩展来做到这一点而不整个程序“内部杂乱无章” - 有关详细信息,请参阅 John Launchbury 和 Simon Peyton Jones 的State in Haskell。
TLDR:你可以在没有可变状态的情况下进行任何计算,但是当需要实际告诉计算机要做什么时,由于计算机只能在可变状态下工作,所以你需要在某个时候改变一些东西。
有很多答案正确地说,如果没有可变状态,您将无法做任何有用的事情,我想用一些简单的(反)示例以及一般直觉来支持这一点。
如果您看到任何被认为是“纯功能性”的代码,并且它做了这样的事情(不是真正的语言):
printUpToTen = map println [1..10]
这不是纯粹的功能。有一个隐藏状态(stdout
的状态)不仅被变异,而且被隐式传入。代码看起来像这样(同样不是真正的语言):
printUpToTen = map println stdout [1..10]
也不是纯的:即使我们显式传入状态 (stdout
),它仍然会被隐式变异。
现在有一些直觉:可变状态是必要的,因为影响我们计算机的核心构建块是可变状态,该构建块是内存。即使我们的计算模型确实可以在没有“内存”概念的情况下计算任何东西,我们也不能强迫计算机在不以某种方式操纵这个内存的情况下做任何事情。
想想类似旧的 GameBoy Advance:为了在屏幕上显示某些内容,您必须修改内存(某些地址每秒被读取多次,以确定屏幕上显示的内容)。您的计算模型(纯函数式编程)可能不需要状态来操作,您甚至可以使用抽象状态操作的命令式状态操作模型(如程序集)来实现您的模型,但归根结底,在某个地方在您的代码中,您必须修改内存中的这些地址,以便设备实际显示任何内容。
这就是命令式模型具有天然优势的地方:因为它们总是在操纵状态,所以您可以很容易地将其转化为实际修改内存。这是一个示例渲染循环:
while (1) {
render(graphics_state);
}
如果要展开循环,它看起来像这样:
render(graphics_state); // modified the memory
render(graphics_state); // modified the memory
render(graphics_state); // modified the memory
...
但在纯函数式语言中,您可能会得到如下内容:
render_forever state = render_forever newState
where newState = render state
展开(或准确地说是展平)可以像这样可视化:
render(render(render(render(...state) // when is the memory actually changing??
// or if you want to expand it the other direction
...der(render(render(render(render(render(state) // no mutation
正如你所看到的,我们在不断变化的状态上一遍又一遍地调用一个函数,但我们从不改变内存:我们立即将它传递给下一个函数调用。即使我们的实现实际上是在修改一些代表状态的东西(甚至在原地!),它也不在正确的位置。在某些时候,我们需要暂停并修改内存中的正确地址,这涉及到一个突变。
这很简单。您可以在函数式编程中使用任意数量的变量……但前提是它们是局部变量(包含在函数内部)。因此,只需将您的代码包装在函数中,在这些函数之间来回传递值(作为传递的参数和返回值)......这就是它的全部内容!
这是一个例子:
function ReadDataFromKeyboard() {
$input_values = $_POST[];
return $input_values;
}
function ProcessInformation($input_values) {
if ($input_values['a'] > 10)
return ($input_values['a'] + $input_values['b'] + 3);
else if ($input_values['a'] > 5)
return ($input_values['b'] * 3);
else
return ($input_values['b'] - $input_values['a'] - 7);
}
function DisplayToPage($data) {
print "Based your input, the answer is: ";
print $data;
print "\n";
}
/* begin: */
DisplayToPage (
ProcessInformation (
GetDataFromKeyboard()
)
);