编程

PHP 中的访问者模式

234 2024-02-24 08:25:00

访问者模式不常使用。这是因为只有在少数情况下它是适用的,甚至是有意义的。然而,当时机成熟时,这是一个很好的模式。让我们看看如何在 PHP 环境中应用此模式。

🛑 问题

与其他一些模式一样,访问者模式试图解决在不更改实体的情况下向实体添加功能的问题(很多…)。除了这个非常普遍的问题外,它还提供了一种将功能添加到多个类似实体的方法,而这些实体无法以相同的方式完全处理。

所以,让我们把这个问题变得更实际一点。假设有两个实体:BookDocument。对于这两个实体,我们都想知道有多少页面。我们的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() 函数,可以处理 BookDocument 实体,并且将会 var_dump 打印两者的页面数。不过,其中还有一些事情比较突出

  • 因为 DocumentBook 之间没有共同的接口或者抽象,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 并将其应用到 BookDocument 上。

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,并可以为 BookDocument 执行某些功能。例如 WordCountVisitor

优缺点

像许多其他模式一样,访问者模式并不是唯一一个可以统治所有模式的模式。不同的问题有多种解决方案。访客模式就是这样;一个特定问题的可能解决方案。让我们来看看您可能使用它的一些原因,以及您可能不使用它的某些原因。

✔️ 优点

  • 你可以通过实现 VisitableInterface 一次来向任何实体添加功能。这使实体更易于扩展。
  • 通过添加访问者功能,你可以强制分离关注点。

实体控制访问者是否被接受。你可以忽略中继并切断双重调度。

  • 个人访客更容易测试。

❌ 缺点

双重分发可能会造成混乱,并使代码更难理解。

accept()visit…() 方法通常不会返回任何内容,因此需要对访问者本身进行记录。

所有访问者都需要 VisitorInterface 上的每一个方法,而它可能没有实现。

真实世界示例

现实地说,你不太可能在野外找到这种模式。然而,这是与树(Trees)和树遍历(Tree Traversal)相结合的常见做法。

当遍历树时,你正在对连续的节点流进行迭代。我们可以对该树中的每个节点执行一个操作。这叫做访问(visiting)…巧合?这些节点通常只是一个持有值的实体。而不是向这些节点添加一堆方法;这实际上是一种很好的方式,可以为这些原本愚蠢的实体添加不同的功能。

我看到的一些树实现实际上有一个 PreOderVisitor 和一个 PostOrderVisistor。然后,这些将按该顺序返回一个节点数组。虽然这是一个完全可以接受的访问者,但我认为访问者不应该规定它应用于树的顺序。对于某些功能,遍历顺序可能并不重要,而在某些情况下可能重要。

在 Trees & Tree Traversal 帖子中,我给出了一个树结构中的文档示例。当在 PreOrder 中遍历该树时,你会得到文档的逻辑流;从封面开始。我们可能想为那棵树建造的一些访客是:

  • RenderPdfVisitor 可以将每个节点渲染为 PDF 文件。
  • TableOfContentsVisitor 可以使用正确的页码创建目录。
  • CombinePdfVisitor 可以将之前渲染的 PDF 合并到单个 PDF。