在所有的问题中,当我们为自己的系统编程时,状态可能是最麻烦的问题。对系统的状态和它与应用程序的生命周期所做的决定会产生深远的影响,这可能是非常难以解开的。
如果不对应用程序中的状态的生命周期做出深思熟虑的决定,就会保证应用程序将不可避免地难以工作,而且变化会越来越慢。在这一点上,你必须忍受缓慢/难以改变的应用程序,大量重构系统以组织状态,或者扔掉整个东西,编写一个全新的应用程序。
纯函数
首先让我们来谈谈纯函数。这些函数是活在梦中的!是各地单元测试教程的宠儿。
什么是纯函数?
一个纯函数总是从相同的输入产生相同的输出。一个纯函数没有副作用。没有HTTP请求,没有数据库查询,没有从系统时钟查询时间,甚至没有打印文本或记录到文件:
def add(a, b)
a + b
end
const add = (a, b) => a + b;
输入进来,输出出去。当你处理纯函数时,你可以明确地、绝对地知道该函数接收的所有东西,并且相同的参数将总是产生相同的输出。
你可以看到单元测试教程是多么喜欢纯函数。测试纯函数几乎是如此简单,以至于如果你只关心证明正确性的话,它可能看起来几乎没有必要。
当然纯函数可以很复杂,但它们总是接受一些参数,并从这些参数返回相同的输出。纯函数不能从数据库查询或网络请求中偷偷加入额外的数据,也不能引用内存中的一些对象。
计算保龄球的分数是一个比较著名的纯数据问题,结果却出奇的复杂。你可以把分数计算变成一个庞大而复杂的纯函数。为了使它易于理解,你可能想把它分解成更小的函数。只要所有的函数都是纯的,那么顶层的协调函数也可以被认为是纯的(同样的输出,没有副作用)。
但问题是,测试纯函数是一个梦想,因为你只需要确定输入数据的形状,然后描述它与输出数据的关系,并确认满足期望。
当我给 "add" 函数两个数字时,输出是这两个数字的总和。每次都是如此。
当我给 "计算保龄球得分 "函数一个游戏的分数卡时,输出是该游戏的分数。每次都是如此。
添加状态
当我们把状态添加到组合中时,我们突然有了更多的力量。我们可以写到数据库中我们可以记录到一个文件中而且,对这次讨论来说,关键是我们可以在内存中保存数据。
我们可以在内存中保存正在进行的保龄球游戏,并根据最新的帧来修改分数,例如 "bowlingGame.recordFrame(3, 7)"。我们可以将越来越多的数字添加到我们不断增长的整体数字系统中 "numberTotal.plus(2)"。
内存中的这些数据是状态。虽然它确实给我们带来了灵活性和力量,但它的代价比我们预期的要大。
当我们修改一个函数以产生副作用时,例如将数据写入其他地方,我们称它为不纯函数。
状态的问题
不管怎么说,状态并不是错误的唯一来源,但我认为它是最复杂的错误的来源,需要追踪和修复。当你在处理状态的时候,你的函数的输入并不是绝对可知的。而这是一个真正的问题
我们可以调用像bowlingGame.recordFrame(3, 7) ,但不知道已经记录了多少帧。这不是好事,因为保龄球游戏的定义是有固定的帧数的。
如果我们不小心记录了太多的帧,我们的系统会怎么样?额外的帧数会被拒绝吗?分数是否会被添加到保龄球游戏的定义范围之外?或者,阴险的是,系统会不会在一段时间内看起来很正常,直到我们调用bowlingGame.printResult() 函数,由于意外的额外数据,它就会爆发出异常?
这种将输入和其不良影响分开的做法使得对有状态的应用程序的调试在本质上非常困难。我们不仅要掌握我们认为正在处理的函数,还要掌握该函数之前的所有函数以及它们对系统状态的所有影响。
即使我们想通了,知道了有问题的状态,如果我们想测试这个问题,那么我们还得进一步想办法在测试中把坏的状态上演。对于那个 "bowlingGame "的例子,我们需要建立一个测试设置,增加过多的帧,然后触发 "printResult "的错误行为,并确保 printResult 在帧数过多时不会爆炸。
但是后来我们,或者其他一些命运多舛的程序员,在跟进这项工作时,发现printResult 并不是问题所在。真正的问题是recordFrame 允许太多的帧!修正了recordFrame 的代码,工作就完成了。我们甚至可以围绕recordFrame 代码编写测试,以断定正确的行为。
但是,在运行整个测试套件时,突然对 "printResult "的测试失败了?这怎么会有关系呢?我们必须挖掘这些测试(生产代码是好的!),并希望认识到是测试设置故意增加了太多的框架,以测试printResult在这种状态下不会爆炸的异常。
即使在这个简单的构思的例子中也很容易引起那些 "我们的测试套件是有问题的,不可靠的 "症状!我们可以感谢状态带来的这种行为。
如果你曾经不得不追踪一个内存泄漏,那就是没有自我清理的状态。状态在它的请求早已完成之后还在徘徊和积累。越来越多的请求积累了越来越多的徘徊状态,然后......内存利用率图表就会上升并向右移动。
状态给了我们的系统很多的灵活性和力量。但它也不是没有代价的。当你不仔细地、有意地管理状态时,它将成为未来开发工作的噩梦。事实上,我声称过度生长的状态是拖累应用程序的工程工作的主要因素之一,随着时间的推移。
状态是强大的,任何合理的复杂的应用程序都需要至少一些状态。但要小心处理它。尽可能地明确你何时以及如何使用它。
Elixir和状态
最后我要说的是,Elixir有我最喜欢的处理状态的方法。这里没有环境状态。当你需要在Elixir应用程序中拥有状态时,你必须编写一个 "服务器 "来保持状态,并响应更新或返回该状态的调用。该服务器的实现函数将状态作为一个参数。我不能过分强调这一点:这意味着你可以使用状态作为纯函数来测试这些函数。
但是等等,你可能会说,所有的调用者都必须持有并将状态传递给服务器?不!是的。服务器持有状态。调用者使用服务器API与服务器进行交互,然后服务器API用API的参数和当前状态调用实现函数。
甚至更好?实现函数将状态作为其返回数据的一部分而返回。服务器API为自己保留状态(以传递给下一次对服务器的调用),并将明确声明的返回数据传回给最初的调用者。这都是假设函数调用首先是同步的,因为服务器API有明确命名的函数,用于向服务器发出异步("cast")和同步("call")请求。