如何以纯功能的方式实现观察者设计模式?

Posted

技术标签:

【中文标题】如何以纯功能的方式实现观察者设计模式?【英文标题】:How to implement the Observer Design Pattern in a pure functional way? 【发布时间】:2011-10-20 17:26:18 【问题描述】:

假设我想使用 OO 编程语言实现事件总线。 我可以这样做(伪代码):

class EventBus

    listeners = []

    public register(listener):
        listeners.add(listener)

    public unregister(listener):
        listeners.remove(listener)

    public fireEvent(event):
        for (listener in listeners):
            listener.on(event)

这实际上是观察者模式,但用于应用程序的事件驱动控制流。

您将如何使用函数式编程语言(例如其中一种 lisp 风格)来实现此模式?

我问这个是因为如果不使用对象,仍然需要某种状态来维护所有侦听器的集合。此外,由于 listeners 集合会随着时间而变化,因此不可能创建一个纯函数式的解决方案,对吧?

【问题讨论】:

【参考方案1】:

对此的一些评论:

我不确定它是如何完成的,但是有一个名为“functional reactive programming”的东西可以作为许多函数式语言的库使用。这实际上或多或少是正确的观察者模式。

此外,观察者模式通常用于通知状态的变化,就像在各种 MVC 实现中一样。但是,在函数式语言中,没有直接的方法来进行状态更改,除非您使用诸如 monad 之类的技巧来模拟状态。但是,如果您使用 monad 模拟状态更改,您还将获得可以在 monad 中添加观察者机制的点。

从您发布的代码看来,您实际上是在进行事件驱动编程。因此,观察者模式是在面向对象语言中获得事件驱动编程的典型方式。所以你有一个目标(事件驱动编程)和一个面向对象世界的工具(观察者模式)。如果您想使用函数式编程的全部功能,您应该检查还有哪些其他方法可用于实现此目标,而不是直接从面向对象世界移植工具(它可能不是函数式语言的最佳选择)。只需查看此处提供的其他工具,您可能会发现更符合您目标的工具。

【讨论】:

+1 是一个很好的答案,但请注意,Clojure 确实有一些非常强大的方法来管理可变状态,同时保持在函数范式内(即,您的 cmets 更适用于“纯”函数式语言,例如哈斯克尔) @mikera:Haskell 也有很多方法来管理可变状态,我不知道人们从哪里得到它没有的想法。它只需要效果跟踪和显式排序,这与懒惰有关。 FRP 之所以有趣,主要是因为它在概念上比使用可变状态更好 @CA McCann - 当我最后一次使用 Haskell 时(诚然是几年前),在 Haskell 中涉及可变状态的所有内容都必须使用一元结构来完成,以便将可变性的概念转换为纯功能。那改变了吗?例如如果我有一个函数 Int -> Int 它会产生可变的副作用吗?你可以在 Clojure 中,据我最近的知识,你不能在 Haskell 中,但很高兴学习其他方法。 @mikera:就像我说的,它实际上只是效果跟踪和显式排序; monadic API 是一个实现细节(是的,您可以使用State monad 在纯代码中模拟 状态,但这与真正的可变状态完全不同)。 Clojure uses 的哲学在精神上几乎与 Haskell 相同,只是去掉了静态类型——只需将 IO a 视为抽象标识,其值为 a。函数 Int -> Int 的副作用就像直接在 Clojure 中修改值一样。【参考方案2】:

如果观察者模式本质上是关于发布者和订阅者,那么 Clojure 有几个你可以使用的函数:

add-watch remove-watch

add-watch 函数接受三个参数:一个引用、一个监视函数键和一个在引用改变状态时调用的监视函数。

显然,由于可变状态的变化,这不是纯粹的功能性(正如您明确要求的那样),但add-watcher 将为您提供一种对事件做出反应的方式,如果这是您所寻求的效果,就像这样:

(def number-cats (ref 3))

(defn updated-cat-count [k r o n]
  ;; Takes a function key, reference, old value and new value
  (println (str "Number of cats was " o))
  (println (str "Number of cats is now " n)))

(add-watch number-cats :cat-count-watcher updated-cat-count)

(dosync (alter number-cats inc))

输出:

Number of cats was 3
Number of cats is now 4
4

【讨论】:

【参考方案3】:

我建议创建一个包含一组侦听器的 ref,每个侦听器都是一个作用于事件的函数。

类似:

(def listeners (ref #))

(defn register-listener [listener]
  (dosync
     (alter listeners conj listener)))

(defn unregister-listener [listener]
  (dosync
     (alter listeners disj listener)))

(defn fire-event [event] 
  (doall
    (map #(% event) @listeners)))

请注意,您在这里使用的是可变状态,但这没关系,因为您尝试解决的问题明确需要状态来跟踪一组侦听器。

感谢 C.A.McCann 的评论:我正在使用一个“ref”,它存储了一组活动侦听器,它具有很好的奖励属性,即解决方案对于并发是安全的。所有更新都发生在 (dosync ....) 构造中的 STM 事务的保护下。在这种情况下,它可能是矫枉过正(例如,一个原子也可以解决问题),但这在更复杂的情况下可能会派上用场,例如当您注册/注销一组复杂的侦听器并希望在单个线程安全的事务中进行更新时。

【讨论】:

ref 为您提供基于 STM 的事务性可变引用,不是吗?您可能应该提到这一点,因为这意味着您的示例应该在并发设置中自动安全且正确地工作。 @CA McCann - 没错。我可以看到一个“有趣”的情况,您需要同时删除一个侦听器并添加另一个侦听器,同时保证另一个线程上的传入事件只发送到一个(但不是两个或两个)侦听器。只要您在事务中进行此更新,Clojure 的 STM 就会自动为您解决此问题,这很好! 是的,这正是我想到的那种情况。当假设很少被违反时,应该是原子的非常快速的操作会导致有害的间歇性并发错误,但对于 STM,它可以轻松地工作。我认为容易获得它应该是 Clojure 的一个主要卖点。在 Haskell 中使用 STM 不会增加任何额外的复杂性或语法开销,我的印象是在 Clojure 中使用它同样简单。 因为你只有一个引用,你也可以只使用和原子和swap!。您只需要 STM 来协调多个 refs 的更改。【参考方案4】:

此外,由于 listeners 集合会随着时间而变化,因此不可能创建一个纯函数式解决方案,对吧?

这不是什么大问题 - 通常,每当您在命令式解决方案中修改对象的属性时,您都可以在纯函数解决方案中计算具有新值的新对象。我相信实际的事件传播有点问题 - 它必须由一个接收事件的函数实现,整个潜在观察者集加上EventBus,然后过滤掉实际观察者并返回一组全新的对象,其中观察者的新状态由其事件处理函数计算。非观察者在输入和输出集中当然是一样的。

如果这些观察者生成新事件以响应他们的on 方法(这里:函数)被调用 - 在这种情况下,您需要递归地应用函数(可能允许它接受多个事件)直到它不再产生要处理的事件。

一般来说,该函数会接受一个事件和一组对象,并返回具有新状态的新对象集,这些状态表示事件传播产生的所有修改。

TL;DR:我认为以纯函数方式对事件传播进行建模很复杂。

【讨论】:

以上是关于如何以纯功能的方式实现观察者设计模式?的主要内容,如果未能解决你的问题,请参考以下文章

Refresh design pattern

SOFA 源码分析 — 调用方式

关于观察着设计模式的两种实现方式

模板方法模式 + 观察者模式 + 简单工厂模式 + 单例模式实现一个简单的数据表读写

观察者模式 - 观察者创建

设计模式之观察者模式