PHP 中的访问者模式
访问者模式不常使用。这是因为只有在少数情况下它是适用的,甚至是有意义的。然而,当时机成熟时,这是一个很好的模式。让我们看看如何在 PHP 环境中应用此模式。
🛑 问题
与其他一些模式一样,访问者模式试图解决在不更改实体的情况下向实体添加功能的问题(很多…)。除了这个非常普遍的问题外,它还提供了一种将功能添加到多个类似实体的方法,而这些实体无法以相同的方式完全处理。
所以,让我们把这个问题变得更实际一点。假设有两个实体:Book
和 Document
。对于这两个实体,我们都想知道有多少页面。我们的Document
有一个公共函数 public function getPageCount(): int
,它返回页数,而 Book
由一个 Chapter
实体数组组成,这些实体也有这个函数。
class Document
{
public function __construct(private int $page_count) {}
public function getPageCount(): int
{
return $this->page_count;
}
}
class Chapter extends Document
{
// Chapter specific code
}
class Book
{
public function getChapters(): array
{
return [
new Chapter(5),
new Chapter(7),
new Chapter(2),
];
}
}
为了简化返回这两种实体类型的页面计数的过程,我们创建了一个 PageCountDumper
。一个(有点天真的)实现可能看起来是这样的:
class PageCountDumper
{
public function handle($entity)
{
if ($entity instanceof Document) {
var_dump($entity->getPageCount());
} elseif ($entity instanceof Book) {
$count = 0;
foreach ($entity->getChapters() as $chapter) {
$count += $chapter->getPageCount();
}
var_dump($count);
} else {
throw new \InvalidArgumentException('PaperCalculator can not handle the provided type.');
}
}
}
然后我们这样调用:
$document = new Document(20);
$book = new Book();
$dumper = new PageCountDumper();
$dumper->handle($document); // int(20)
$dumper->handle($book); // int(14)
这个 PageCountDumper
有一个 handle()
函数,可以处理 Book
和 Document
实体,并且将会 var_dump
打印两者的页面数。不过,其中还有一些事情比较突出
- 因为
Document
和Book
之间没有共同的接口或者抽象,handle()
函数接受mixed $entity
参数并且包含了任何一种情况的逻辑。当添加更多实体时,这种类型检查会堆积起来,并且可能变得非常麻烦和不可读。 - 当实体类型未知时,抛出异常以避免不正确的使用。
我们可以做得更好!
👋 访问者模式方案
访问者模式为这个特定的问题提供了一个解决方案。它将消除对 instanceOf
类型检查的需要,同时保持对实体类型的引用不变。并且它将消除显式抛出异常的需要。让我们看看访问者模式是如何解决这些问题的。
实体特定函数
首先,要删除 instanceOf
检查,就需要为每个可能的实体类型提供一个方法。出于规范的考虑,我们将调用这些方法:visitBook(Book $book)
和 visitDocument(Document $document)
。因为我们正在创建一个 Visitor
,所以让我们将计算器重命名为:PageCountVisitor
。
class PageCountVisitor
{
public function visitBook(Book $book)
{
$count = 0;
foreach ($book->getChapters() as $chapter) {
$count += $chapter->getPageCount();
}
var_dump($count);
}
public function visitDocument(Document $document)
{
var_dump($document->getPageCount());
}
}
通过使用类型提示的参数实现单独的方法,我们消除了对 instanceOf
检查的需要。因为我们只能用适当的类型调用这些方法,所以没有必要抛出异常。当我们提供一个无效的参数时,PHP 已经这样做了。
如果将来有另一个实体需要对其页面进行计数,比如 Report
,我们可以添加一个 pubilc function visitReport(Report $report)
并单独实现该逻辑。
但是,你可能会想:这并没有更好。我仍然需要知道我的实体是什么类型才能调用正确的方法!你是对的。但请稍等,这个重构只是访问者模式的一半。
接收访问者
还记得我说过访问者作用的实体不应该有太大的变化吗?是的,每个实体都需要作一个修改才能使访问者模式正常工作。不过只有一个修改,这将使它接受任何访问者,从而增加任何(未来)功能。
为了避免 instanceOf
检查,只有一个上下文可以确保实体是特定类型的:在实体本身内。只有当我们在类的(非静态)方法内部时,我们才能确定 $this
是该类型的实例。这就是为什么访问者模式使用一种名为 Double Dispatch 的技术,在该技术中,实体在访问者上调用正确的函数,同时将自己作为参数。
为了实现这种双重调度,我们需要一个通用方法来接收访问者,并将调用中继到访问者上的正确方法。按照惯例,此方法被称为:accept()
。此方法将接收访问者作为其参数。为了在将来接受其他访问者,我们首先提取一个 VisitorInterface
。
interface VisitorInterface
{
public function visitBook(Book $book);
public function visitDocument(Document $document);
}
class PageCountVisitor implements VisitorInterface
{
// Make sure the visitor implements the interface
}
然后我们创建一个 VisitableInterface
并将其应用到 Book
和 Document
上。
interface VisitableInterface
{
public function accept(VisitorInterface $visitor);
}
class Book implements VisitableInterface
{
// ...
public function accept(VisitorInterface $visitor)
{
$visitor->visitBook($this);
}
}
class Document implements VisitableInterface
{
// ...
public function accept(VisitorInterface $visitor)
{
$visitor->visitDocument($this);
}
}
在这里你可以看到双重调度(double dispatch)的作用。Book
类在访问者上调用 visitBook()
方法,Document
调用 visitDocument()
。两者都将自己作为参数提供。由于对实体的这一微小更改,我们现在可以应用各种不同的访问者,为每个实体提供特定的功能。
要在实体上使用访问者,我们需要调整调用代码,如下所示:
$document = new Document(20);
$book = new Book();
$visitor = new PageCountVisitor();
$document->accept($visitor); // int(20)
$book->accept($visitor); // int(14)
现在所有的部分都准备好了,我们可以自由地创建更多的访问者来实现 VisitorInterface
,并可以为 Book
和 Document
执行某些功能。例如 WordCountVisitor
。
优缺点
像许多其他模式一样,访问者模式并不是唯一一个可以统治所有模式的模式。不同的问题有多种解决方案。访客模式就是这样;一个特定问题的可能解决方案。让我们来看看您可能使用它的一些原因,以及您可能不使用它的某些原因。
✔️ 优点
- 你可以通过实现
VisitableInterface
一次来向任何实体添加功能。这使实体更易于扩展。 - 通过添加访问者功能,你可以强制分离关注点。
实体控制访问者是否被接受。你可以忽略中继并切断双重调度。
- 个人访客更容易测试。
❌ 缺点
双重分发可能会造成混乱,并使代码更难理解。
accept()
和 visit…()
方法通常不会返回任何内容,因此需要对访问者本身进行记录。
所有访问者都需要 VisitorInterface
上的每一个方法,而它可能没有实现。
真实世界示例
现实地说,你不太可能在野外找到这种模式。然而,这是与树(Trees)和树遍历(Tree Traversal)相结合的常见做法。
当遍历树时,你正在对连续的节点流进行迭代。我们可以对该树中的每个节点执行一个操作。这叫做访问(visiting)…巧合?这些节点通常只是一个持有值的实体。而不是向这些节点添加一堆方法;这实际上是一种很好的方式,可以为这些原本愚蠢的实体添加不同的功能。
我看到的一些树实现实际上有一个 PreOderVisitor
和一个 PostOrderVisistor
。然后,这些将按该顺序返回一个节点数组。虽然这是一个完全可以接受的访问者,但我认为访问者不应该规定它应用于树的顺序。对于某些功能,遍历顺序可能并不重要,而在某些情况下可能重要。
在 Trees & Tree Traversal 帖子中,我给出了一个树结构中的文档示例。当在 PreOrder
中遍历该树时,你会得到文档的逻辑流;从封面开始。我们可能想为那棵树建造的一些访客是:
RenderPdfVisitor
可以将每个节点渲染为 PDF 文件。TableOfContentsVisitor
可以使用正确的页码创建目录。CombinePdfVisitor
可以将之前渲染的 PDF 合并到单个 PDF。