26、访问者模式:模式动机与定义、角色与结构、C++代码实现、双分派技术

模式动机:为什么需要访问者?

我先问你一个问题:你有一个对象结构,里面装着各种不同类型的元素。比如一个编译器里的抽象语法树,有变量声明、函数调用、赋值语句……现在,你想给这棵树加一个“代码格式化”操作。你会怎么做?

最直接的想法:在每个节点类里加一个 format() 方法。嗯,这确实能干活。但过了一周,产品经理又来了:“我们需要加一个‘变量重命名’功能。”你又得去每个类里加 rename()。再下一周,“加一个‘死代码消除’”。你想想看,每加一个操作,就要改一遍所有节点类。这违反了开闭原则——对扩展开放,对修改关闭。

我在项目中遇到过类似的情况。当时我们维护一个图形编辑器,图元类型有十几种。每次加一个新操作(比如导出SVG、计算面积、碰撞检测),都要改一遍所有图元类。代码变得又臭又长,合并分支时冲突不断。后来我重构了这部分,用访问者模式把操作和数据结构分离了。嗯,世界清净了。

访问者模式的核心思想就是:把操作(算法)从对象结构中抽离出来。你定义一组访问者,每个访问者实现针对不同元素的操作。元素只负责接受访问者,然后把自身“暴露”给访问者。这样,加新操作只需要加一个新的访问者类,不用改已有的元素类。

模式定义与角色结构

说白了,访问者模式让你能在不改变元素类的前提下,定义作用于这些元素的新操作。它的角色结构如下:

角色 职责 我的一点经验
Visitor(访问者) 为每个具体元素类声明一个访问方法,如 visitConcreteElementA() 方法名最好带上元素类型,避免重载混淆
ConcreteVisitor(具体访问者) 实现每个访问方法,定义针对该元素的具体操作 每个访问者就是一个独立的“操作”
Element(元素) 声明一个 accept(Visitor&) 方法,参数是访问者 这个方法通常只有一行:调用访问者的对应 visit 方法
ConcreteElement(具体元素) 实现 accept(),将自己 this 传给访问者 注意传的是 this 指针,类型要匹配
ObjectStructure(对象结构) 管理元素集合,提供遍历方式 可以是数组、列表、树等,负责调用每个元素的 accept()

这里有个关键点:双分派。为什么叫“双分派”?因为操作最终由两个因素共同决定:访问者的类型 + 元素的类型。普通的虚函数是单分派——只根据对象的实际类型调用一个方法。而访问者模式通过两次调用(元素.accept(访问者) → 访问者.visit(元素))实现了双分派。我刚开始学的时候也觉得绕,但写几个例子就明白了。

核心要点:访问者模式把“数据”和“操作”解耦。数据稳定,操作多变时,这个模式特别好用。

SVG 结构图:访问者模式核心逻辑

访问者模式核心结构 Visitor(接口) + visitConcreteA(ElementA) ConcreteVisitor1 实现所有 visit 方法 ConcreteVisitor2 实现所有 visit 方法 Element(接口) + accept(Visitor&) ConcreteElementA accept: visitor.visit(this) ConcreteElementB accept: visitor.visit(this) ObjectStructure 管理元素集合,遍历并调用 accept() 双分派机制 第一次分派: element.accept(visitor) 第二次分派: visitor.visit(element)

C++ 代码实现:一个完整的例子

我们来看一个具体的例子。假设我们有一个文档结构,包含两种元素:段落(Paragraph)和图片(Image)。现在我们要实现两个操作:一个是“HTML导出”,一个是“字数统计”。

先定义元素接口和具体元素:

// 前置声明
class Visitor;

// 元素接口
class Element {
public:
    virtual ~Element() = default;
    virtual void accept(Visitor& v) = 0;
};

// 具体元素:段落
class Paragraph : public Element {
public:
    std::string text;
    explicit Paragraph(const std::string& t) : text(t) {}
    
    void accept(Visitor& v) override {
        v.visit(*this);  // 关键:把自己传给访问者
    }
};

// 具体元素:图片
class Image : public Element {
public:
    std::string src;
    explicit Image(const std::string& s) : src(s) {}
    
    void accept(Visitor& v) override {
        v.visit(*this);
    }
};

然后定义访问者接口和具体访问者:

// 访问者接口
class Visitor {
public:
    virtual ~Visitor() = default;
    virtual void visit(Paragraph& p) = 0;
    virtual void visit(Image& i) = 0;
};

// 具体访问者:HTML导出
class HtmlExporter : public Visitor {
public:
    void visit(Paragraph& p) override {
        result += "<p>" + p.text + "</p>\n";
    }
    void visit(Image& i) override {
        result += "<img src='" + i.src + "' />\n";
    }
    std::string getResult() const { return result; }
private:
    std::string result;
};

// 具体访问者:字数统计
class WordCounter : public Visitor {
public:
    void visit(Paragraph& p) override {
        // 简单按空格分词统计
        int count = 0;
        bool inWord = false;
        for (char c : p.text) {
            if (std::isspace(c)) {
                inWord = false;
            } else if (!inWord) {
                ++count;
                inWord = true;
            }
        }
        total += count;
    }
    void visit(Image& i) override {
        // 图片不算字数,但可以加个占位计数
        total += 1;  // 每张图片算1个字
    }
    int getTotal() const { return total; }
private:
    int total = 0;
};

最后是对象结构和客户端代码:

// 对象结构:文档
class Document {
public:
    void addElement(std::unique_ptr<Element> elem) {
        elements.push_back(std::move(elem));
    }
    
    void accept(Visitor& v) {
        for (auto& elem : elements) {
            elem->accept(v);  // 遍历并接受访问者
        }
    }
    
private:
    std::vector<std::unique_ptr<Element>> elements;
};

// 客户端使用
int main() {
    Document doc;
    doc.addElement(std::make_unique<Paragraph>("Hello, world!"));
    doc.addElement(std::make_unique<Image>("logo.png"));
    doc.addElement(std::make_unique<Paragraph>("This is a visitor pattern demo."));
    
    HtmlExporter exporter;
    doc.accept(exporter);
    std::cout << "HTML输出:\n" << exporter.getResult();
    
    WordCounter counter;
    doc.accept(counter);
    std::cout << "总字数: " << counter.getTotal() << std::endl;
    
    return 0;
}
个人习惯:我通常把访问者的 visit 方法参数写成 visit(ConcreteElement& elem) 而不是 visit(ConcreteElement* elem)。引用更安全,而且语义上“访问者访问一个元素”更自然。

双分派技术:到底是怎么工作的?

你可能会问:为什么不用一个普通的虚函数搞定?比如在 Element 里定义一个纯虚函数 doOperation(),然后每个子类实现?那样的话,操作是写死在元素里的。而访问者模式把操作移到了外部。

双分派的关键在于两次函数调用:

  1. 第一次分派element.accept(visitor)。这里根据 element 的实际类型(Paragraph 还是 Image),调用对应的 accept() 方法。
  2. 第二次分派:在 accept() 内部,调用 visitor.visit(*this)。这里的 this 类型是确定的(比如 Paragraph*),所以编译器会调用 Visitor::visit(Paragraph&) 这个重载版本。

两次分派合起来,就实现了“根据访问者类型 + 元素类型”共同决定行为。这就是双分派。C++ 本身只支持单分派(虚函数),但通过访问者模式,我们模拟出了双分派的效果。

我曾经踩过的坑:accept() 里传 this 时,一定要传 *this 的引用,而且类型要匹配。如果你不小心传了基类引用,就会调用基类的 visit 重载,导致行为错误。我 debug 了整整一个下午才发现是这里的问题。

什么时候用?什么时候别用?

访问者模式不是银弹。我个人觉得它最适合以下场景:

  • 对象结构稳定,但操作频繁变化:比如编译器、图形引擎、文档格式转换器。
  • 需要对一个对象结构做很多不相关的操作:避免每个操作都污染元素类。
  • 元素类不想依赖具体操作:保持元素类的纯净。

但如果你发现元素类型经常增加,那就要小心了。每加一个新元素,你都得修改所有访问者——这正好是访问者模式的痛点。我一般会问自己:未来半年内,是加操作的概率大,还是加元素类型的概率大?想清楚再决定。

嗯,访问者模式就讲到这里。它有点绕,但用熟了之后,你会发现它在某些场景下特别优雅。尤其是当你需要给一个稳定的对象结构不断添加新功能时,它能让你的代码干净很多。


公众号:蓝海资料掘金营,微信deep3321