感觉很难理解,因为几乎所有的教程/教材,样例代码或者实际代码里面,提到观察者模式的时候,都是清一色的用返回 void 的方法来 visit 和 accept ,然后依赖副作用和全局变量来返回结果。感觉这样的代码很难懂而且很绕,也可能因为我比起命令式或 OOP 代码更容易理解纯函数式代码吧。就,比如说我试图理解 visitor pattern 的话,我会把它当成一个「在命令式语言里通过动态分派实现模式匹配」的技巧。自然而然的就会想出这样的代码,比如说在 Java 里的话:
public ISomeVisitor<T> {
public T visit(DerivedDataTypeA data);
public T visit(DerivedDataTypeB data);
...
}
public IDataType {
public T accept(ISomeVisitor visitor);
}
然后在 visitor 的具体实现里面,就只需要去重写然后使用 DerivedDataType 里面的访问方法去处理它,然后返回一个转换后的?T 类型。同时,由于每个 visit 分支都返回相同的类型,它们可以被组合起来,看起来就跟比如 Scala 或者 ML 语言里的模式匹配是同样的方式。嗯,这个是理想情况。
但我记得在大学里学习 OOP 然后第一次按照这个路子写 visitor 后,就被其他人纠正说我实现的方法不对,因为我在 visit 函数里实现了具体逻辑。嗯,我一直没能搞懂为什么我实现错了,其实现在我也没搞懂。但是我其实对返回 void 的方法和没有参数化泛型的类感觉更难理解,大概有这么几个原因吧:
首先是这样实现的模式完全依赖于副作用,而不是显式的返回一个值。给人想起了 C 语言里的通过参数返回函数结果的代码风格,至少对我来说,感觉这样的代码更难懂,因为没有类型安全,而且靠副作用来操纵数据变换的时候,基本上算是做啥都行也没有任何机制可以保证返回结果能够被妥善的使用并且只使用一次。感觉像是典型的约定大于类型。
其次是这样的代码里基本上可以不管什么都往 visitor 的基类里面塞,感觉就很自然地倒向了 god class 的反模式。而且这也是经常在使用了 visitor 的工程代码里看到的,而且每次看到这样的几百行甚至上千行的 visitor 都特别头疼。
同样的道理,我可以有一个public Result returnedResult;在第 19 行,然后我的几十个分支里面都去 mutate 这个Result来返回一次访问的结果,就像刚才说的,没有任何机制可以让我不去随意修改这个returnedResult来破坏 visitor pattern 的模式。但同时,一个字段可以在第 19 行,第 199 行和第 1099 行被修改,这也破坏了「就近原则」吧?而且这里也可以看到一个潜在的 NPE 热点。
所以觉得这样的代码既难以理解又难以维护。
当然也许有人会说顺序很重要,但是一般来说 visitor 在业务代码例如 web 应用里面都是用在树状数据结构上,这种使用场景应该没差?所以我感觉困惑的大概就是,我这个 visitor 的实现思路错在了哪里?为什么几乎清一色的所有 visitor 的代码实现都是返回 void 方法并且通过副作用修改全局变量来储存返回计算结果的?这样做是为了什么呢?
然后这个草稿写完之后又读了一本叫 A little Java, A few Patterns 的书,就感觉更困惑了。因为这本书里的 visitor 不但也是不依赖副作用而是返回值的,它的 visit 函数甚至还可以接受多个参数,看起来更不符合 visitor 的一般定义。
所以我该怎么理解这个 visitor pattern 呢?
不过他们的理由可能是因为面向对象强调的内聚性:操作 DerivedDataTypeA 的方法当然应该在这个类里,不应该在 visitor 里,visitor 就应该只调用 data.opeartionXX() 就返回,这样也说得通。
Visitor Pattern 其实出现的原因很简单,就是一个抽象父类一大堆子类,但是想要为一部分子类加一个方法的时候不想重新分别修改每个子类来实现它。按照命令式语言的逻辑,当然不需要关心这个操作有没有副作用——当然把返回值塞到父类的成员里就是严重的设计错误了。
返回值的 Visitor Pattern 我感觉做个解释器之类的比较适合吧,其他情况下也用不太上。知乎上有很多讨论 Expression Problem 的文章,OP 可以看看。
visitor 经常有根据一些条件停止继续遍历或者记录一下是否碰见过某种节点以执行不同逻辑的需求吧
因此,不如在设计上退一步。无论是否需要访问状态,都要求访问者自己维护。