Clojure

值与变化:Clojure 对标识和状态的处理方式

许多人从命令式语言转向 Clojure,在面对 Clojure 的处理方式时会感到不适应,而另一些人则来自函数式编程背景,并假设一旦离开 Clojure 的函数式子集,就会面临与 Java 中相同的状态问题。本文旨在阐明 Clojure 如何解决命令式和函数式程序在建模世界时遇到的问题。

命令式编程

命令式程序直接操作其世界(例如内存)。它建立在一个现已不可持续的单线程前提上——即在你查看或更改世界时,世界是停止的。你说“做这件事”,它就发生了,“改变那个”,它就改变了。命令式编程语言侧重于说“做这个/做那个”,并改变内存位置。

即使在多线程出现之前,这也不是一个好主意。添加并发性,你就会遇到一个真正的问题,因为“世界停止”的前提不再成立,而恢复这种错觉极其困难且容易出错。多个参与者,每个参与者都认为自己是万能的,必须以某种方式避免破坏其他参与者的假设和影响。这需要互斥量和锁,以划分每个参与者操作的区域,以及大量开销来传播对共享内存的更改,以便其他核心能够看到它们。它运行得不太好。

函数式编程

函数式编程对世界采取更数学化的观点,并将程序视为接受某些值并产生其他值的函数。函数式程序避开命令式程序的外部“副作用”,因此更容易理解、推理和测试,因为函数的活动是完全局部的。在程序的某个部分完全是函数式的情况下,并发性不是问题,因为根本没有需要协调的变化。

工作模型和标识

虽然有些程序仅仅是大型函数,例如编译器或定理证明器,但许多其他程序并非如此——它们更像是工作模型,因此需要支持我将在本次讨论中称为**标识**的内容。标识指的是**与一系列随时间变化的不同值相关联的稳定逻辑实体**。模型需要标识的原因与人类需要标识的原因相同——用来表示世界。如果像“今天”或“美国”这样的标识必须始终代表一个不变的值,那世界将如何运作?请注意,标识并不指代名称(我称我母亲为妈妈,但你不会)。

因此,对于本次讨论,标识是一个具有状态的实体,状态是其在某个时间点的值。而**值是不变的东西**。42 不变。2008 年 6 月 29 日不变。点不会移动,日期不会改变,无论一些糟糕的类库可能让你相信什么。即使是聚合也是值。我最喜欢的食物集合不会改变,即如果我将来喜欢不同的食物,那将是不同的集合。

标识是我们用来将连续性强加于不断以函数方式创建自身新值的世界的思维工具。

面向对象编程 (OO)

OO 除其他外,还试图提供用于在程序中建模标识和状态的工具(以及将行为与状态相关联以及分层分类,这里忽略了这两点)。OO 通常将标识和状态统一起来,即对象(标识)是指向包含其状态值的内存的指针。除了复制之外,无法获得独立于标识的状态。在不阻止其他人更改状态的情况下,无法观察稳定状态(即使是复制)。除了就地内存修改之外,无法将标识的状态与不同的值相关联。换句话说,**典型的 OO 将命令式编程融入其中!** OO 不必这样,但通常是这样(Java/C++/Python/Ruby 等)。

习惯于 OO 的人将他们的程序视为正在改变对象的值。他们理解值的真正概念,比如 42,即永远不会改变的东西,但通常不会将这种值的概念扩展到他们对象的状态。这是他们编程语言的失败。这些语言使用相同的结构来建模值,就像它们用于标识、对象一样,并且默认可变,导致除最自律的程序员之外的所有程序员创建比他们应该更多的标识,从应该为值的事物中创建标识等等。

Clojure 编程

还有另一种方法,那就是分离标识和状态(再次强调,间接寻址在编程中拯救了局面)。我们需要从将状态视为“此内存块的内容”的概念转向“当前与此标识相关联的****”的概念。因此,标识可以在不同时间处于不同的状态,但状态本身不会改变。也就是说,标识不是状态,标识**具有**状态。在任何时间点都只有一个状态。并且该状态是真正的值,即它永远不会改变。如果标识似乎发生了变化,那是因为它随着时间的推移与不同的状态值相关联。这是 Clojure 模型。

在 Clojure 的模型中,值计算是纯函数式的。值永远不会改变。新值是旧值的函数,而不是变异。但逻辑标识得到了很好的支持,通过对值的原子引用(RefsAgents)。对引用的更改由系统控制/协调——即合作不是可选的,也不是手动的。世界由于参与者的合作努力而向前发展,编程语言/系统 Clojure 负责管理世界的一致性。引用的值(标识的状态)始终可以在不进行协调的情况下观察到,并且可以在线程之间自由共享。

即使只有一个参与者(线程),也值得以这种方式构建程序。当函数值计算独立于标识/值关联时,程序更容易理解/测试。并且当(不可避免地)需要其他参与者时,很容易添加它们。

并发

处理并发意味着放弃无所不能的错觉。程序必须认识到将会有其他参与者,并且世界将继续变化。因此,程序必须理解,如果它观察到某些标识的状态值,它所能获得的最好结果只是一个快照,因为它们随后可以获得新的状态。但通常对于决策或报告目的来说,这已经足够了。我们人类凭借我们的感觉系统提供的快照也能很好地工作。好处是,任何此类状态值在处理过程中都不会发生变化,因为它是不变的。

另一方面,将状态更改为新值需要访问“当前”值和标识。Clojure 的 Refs 和 Agents 自动处理此问题。对于 Refs,您执行的任何交互都必须在事务中发生(否则 Clojure 将抛出异常),所有此类交互都将看到某个时间点世界的一致视图,并且除非要更改的状态在此期间尚未被其他参与者更改,否则不会进行任何更改。事务支持对多个 Refs 的同步更改。另一方面,Agents 提供对单个引用的异步更改。您传递一个函数和值,并且在将来的某个时刻,该函数将被传递 Agent 的当前状态,并且该函数的返回值将成为 Agent 的新状态。

在所有情况下,程序都将看到世界中值的稳定视图,因为这些值不会改变,并且在核心之间共享它们是可以的。诀窍是,“值永远不会改变”意味着从旧值创建新值必须有效,并且在 Clojure 中它确实有效,因为它具有持久数据结构。它们允许你最终遵循经常提出的建议,即偏爱不变性。因此,您通过读取其当前值、对该值调用纯函数以创建新值以及将该值设置为新状态来将标识的状态设置为新状态。这些复合操作通过 altercommutesend 函数变得容易且原子化。

消息传递和 Actor 模型

还有其他方法可以建模标识和状态,其中比较流行的一种是消息传递Actor 模型。在 Actor 模型中,状态封装在 Actor(标识)中,并且只能通过传递消息(值)来影响/查看。在异步系统中,读取 Actor 状态的某些方面需要发送请求消息、等待响应以及 Actor 发送响应。重要的是要理解,Actor 模型旨在解决分布式程序的问题。分布式程序的问题要困难得多——存在多个世界(地址空间),无法直接观察,交互可能通过不可靠的通道进行等等。Actor 模型支持透明分布。如果您以这种方式编写所有代码,则不受其他 Actor 的实际位置的约束,从而允许系统分布在多个进程/机器上而无需更改代码。

出于几个原因,我没有在 Clojure 中使用 Actor 模型进行同一进程状态管理

  • 这是一种更加复杂的编程模型,即使是最简单的读取数据操作也需要进行两次消息通信,并且强制使用阻塞消息接收,这会引入死锁的可能性。针对分布式系统故障模式进行编程意味着要利用超时等机制。它导致程序协议出现分支,其中一些由函数表示,另一些由消息的值表示。

  • 它不允许您充分利用在同一进程中的效率。在同一线程之间高效地直接共享大型不可变数据结构是完全可能的,但Actor模型强制进行中间通信,并可能导致数据复制。读取和写入操作会序列化并相互阻塞等。

  • 它降低了您在建模方面的灵活性——这就像一个每个人都坐在没有窗户的房间里,只能通过邮件进行交流的世界。程序被分解成一堆阻塞的switch语句。您只能处理预期接收到的消息。协调涉及多个Actor的活动非常困难。您无法在没有其配合/协调的情况下观察任何事物——这使得临时报告或分析变得不可能,而是强迫每个Actor参与每个协议。

  • 通常情况下,将本地运行良好的东西透明地分布到多个节点上并不能奏效——对话粒度过于频繁或消息有效负载过大,或者故障模式改变了最佳工作划分,即透明分布并非透明,代码仍然需要更改。

Clojure 最终可能会支持用于分布式编程的Actor模型,只有在需要分布时才会付出代价,但我认为对于同一进程内的编程来说,这非常繁琐。当然,您的情况可能有所不同(YMMV)。

总结

Clojure 是一种函数式语言,它明确地支持程序作为模型,并提供了强大且易于使用的设施,用于在面对并发的情况下管理单一进程中的标识和状态。

从面向对象语言转向Clojure时,您可以使用其持久化集合之一,例如map,而不是对象。尽可能多地使用值。对于那些您的对象真正建模标识的情况(直到您开始以这种方式思考之前,您可能意识到的情况要少得多),您可以使用Ref或Agent,例如使用map作为其状态,以便使用变化状态的模型来表示标识。如果您想封装或抽象化您的值细节,如果它们是非平凡的,则这是一个好主意,编写一组用于查看和操作它们的函数。如果您想要多态性,请使用Clojure的多方法。

在本地情况下,由于Clojure没有可变的局部变量,因此您可以使用函数式方法(如recurreduce)来构建值,而不是在变异循环中构建值。