在上一节中,看到了访问者模式如何向现有层次结构添加新操作。其中一个示例中,访问了一个包含指向其他对象指针的复杂对象,访问者以有限的方式遍历了这些指针。现在,将访问由其他对象组成或包含其他对象的通用问题,并最终演示一个可行的序列化/反序列化解决方案。
访问复杂对象的总体思路相当直接 —— 访问对象本身时,通常不知道如何处理每个组件或包含对象的所有细节。但还有其他东西知道 —— 该对象类型的访问者专门编写用于处理该类,且仅此而已。这一观察表明,处理组件对象的正确方法是简单地访问每一个,并将问题委托给其他人(这在编程及其他领域都是一种通常强大的技术)。
首先,通过一个简单的容器类示例来演示这一思想,比如 Shelter 类,可以包含任意数量的宠物对象,代表等待领养的宠物:
// Example 06
class Shelter {
public:
void add(Pet* p) {
pets_.emplace_back(p);
}
void accept(PetVisitor& v) {
for (auto& p : pets_) {
p->accept(v);
}
}
private:
std::vector<std::unique_ptr<Pet>> pets_;
};
这个类本质上是一个适配器,用于使宠物对象的向量变得可访问(我们在同名章节中已经详细讨论了适配器模式)。这个类的对象确实拥有它们包含的宠物对象 —— 当 Shelter 对象销毁时,vector中的所有 Pet 对象也会销毁。包含 unique 指针的容器都是拥有其包含对象的容器;这就是多态对象应该如何存储在诸如 std::vector 之类的容器中(对于非多态对象,可以直接存储对象本身,但在我们的例子中这行不通,因为从 Pet 派生的对象具有不同的类型)。
当然,与当前问题相关的代码是 Shelter::accept(),它决定了如何访问 Shelter 对象。我们并没有在 Shelter 对象本身上调用访问者,相反,将访问委托给每个包含的对象。由于访问者已经编写好以处理 Pet 对象,因此不需要再做特殊处理。当 FeedingVisitor 访问Shelter 时,收容所中的每只宠物都会喂食,而不需要编写特殊代码来实现。
复合对象的访问以类似的方式完成 —— 如果一个对象由几个较小的对象组成,必须访问这些对象中的每一个。考虑一个表示家庭的对象,该家庭有两只宠物,一只狗和一只猫(照顾宠物的人类未包含在以下代码中,但我们假设他们也存在):
// Example 07
class Family {
public:
Family(const char* cat_color, const char* dog_color) :
cat_(cat_color), dog_(dog_color) {}
void accept(PetVisitor& v) {
cat_.accept(v);
dog_.accept(v);
}
private: // 为简洁起见,未显示其他家庭成员
Cat cat_;
Dog dog_;
};
同样,使用 PetVisitor 层次结构中的访问者来访问家庭,会将访问委托给每只宠物对象,而访问者已经具备了处理这些对象所需的一切(当然,Family 对象也可以接受其他类型的访问者,为此需要为其编写单独的 accept() 方法)。
终于,拥有了所有必要的组件,来解决任意对象的序列化和反序列化问题。下一小节将展示如何使用访问者模式来实现。
问题本身在上一节中已详细描述过 —— 对于序列化,每个对象都需要转换为一串比特,这些比特需要存储、复制或发送。操作的第一部分取决于对象(每个对象的转换方式不同),但第二部分取决于序列化的具体应用(保存到磁盘与通过网络发送是不同的)。实现取决于两个因素,需要双分派,而这正是访问者模式所提供的。此外,如果有一种方法来序列化某个对象,然后反序列化它(从比特序列中重建对象),当该对象被包含在另一个对象中时,应该使用相同的方法。
为了演示使用访问者模式,对类型层次结构进行序列化/反序列化,需要一个比迄今为止使用的玩具示例更复杂的层次结构。来看这个二维几何对象的层次结构:
// Example 08
class Geometry {
public:
virtual ~Geometry() {}
};
class Point : public Geometry {
public:
Point() = default;
Point(double x, double y) : x_(x), y_(y) {}
private:
double x_ {};
double y_ {};
};
class Circle : public Geometry {
public:
Circle() = default;
Circle(Point c, double r) : c_(c), r_(r) {}
private:
Point c_;
double r_ {};
};
class Line : public Geometry {
public:
Line() = default;
Line(Point p1, Point p2) : p1_(p1), p2_(p2) {}
private:
Point p1_;
Point p2_;
};
所有对象都派生自抽象的 Geometry 基类,但更复杂的对象包含一个或多个较简单的对象;例如,Line 由两个 Point 对象定义。最终,所有的对象都由 double 数字构成,因此将序列化为一串数字。关键是知道哪个 double 代表哪个对象的哪个字段;我们需要这个信息来正确地恢复原始对象。
要使用访问者模式序列化这些对象,我们遵循上一节中使用过的相同过程。首先,需要声明基访问者类:
// Example 08
class Visitor {
public:
virtual void visit(double& x) = 0;
virtual void visit(Point& p) = 0;
virtual void visit(Circle& c) = 0;
virtual void visit(Line& l) = 0;
};
也可以访问 double 值;每个访问者都需要适当地处理它们(写入它们、读取它们等)。访问几何对象最终都会导致访问其数字。
我们的基类 Geometry 和所有从中派生的类,都需要接受这个访问者:
// Example 08
class Geometry {
public:
virtual ~Geometry() {}
virtual void accept(Visitor& v) = 0;
};
当然,没有办法向 double 添加 accept() 成员函数,但我们也不需要这样做。由一个或多个数字及其他类组成的派生类的 accept() 成员函数,会按顺序访问每个数据成员:
// Example 08
void Point::accept(Visitor& v) {
v.visit(x_); // double
v.visit(y_); // double
}
void Circle::accept(Visitor& v) {
v.visit(c_); // Point
v.visit(r_); // double
}
void Point::accept(Visitor& v) {
v.visit(p1_); // Point
v.visit(p2_); // Point
}
所有派生自基访问者类的具体访问者类,负责序列化和反序列化的具体机制。对象分解为其组成部分(直到数字)的顺序由每个对象自身控制,但访问者决定对这些数字进行何种操作。例如,可以使用格式化 I/O 将所有对象序列化为一个字符串(类似于将数字打印到 cout 中得到的结果):
// Example 08
class StringSerializeVisitor : public Visitor {
public:
void visit(double& x) override { S << x << " "; }
void visit(Point& p) override { p.accept(*this); }
void visit(Circle& c) override { c.accept(*this); }
void visit(Line& l) override { l.accept(*this); }
std::string str() const { return S.str(); }
private:
std::stringstream S;
};
字符串在 stringstream 中累积,直到所有必要的对象都序列化:
// Example 08
Line l(...);
Circle c(...);
StringSerializeVisitor serializer;
serializer.visit(l);
serializer.visit(c);
std::string s(serializer.str());
现在可以将对象打印到字符串 s 中,就可以从这个字符串中进行恢复,也许是在另一台机器上(如果安排将字符串发送到那里)。首先,需要反序列化的访问者:
// Example 08
class StringDeserializeVisitor : public Visitor {
public:
StringDeserializeVisitor(const std::string& s) {
S.str(s);
}
void visit(double& x) override { S >> x; }
void visit(Point& p) override { p.accept(*this); }
void visit(Circle& c) override { c.accept(*this); }
void visit(Line& l) override { l.accept(*this); }
private:
std::stringstream S;
};
这个访问者从字符串中读取数字,并将其保存到访问对象提供的变量中。成功反序列化的关键是按与保存时相同的顺序读取数字 —— 如果开始时写入了一个点的 X 和 Y 坐标,应该从读取的前两个数字构造一个点,并将它们用作 X 和 Y 坐标。如果写入的第一个点是线段的端点,应该将构造的点用作新线段的端点。
访问者模式的美妙之处在于,执行实际读写操作的函数,不需要做特殊的事情来保持这种顺序 —— 顺序由每个对象决定,并且保证对所有访问者都相同(对象不区分具体的访问者,甚至不知道它是哪种访问者)。我们所需要做的就是,以与序列化时相同的顺序访问对象:
// Example 08
Line l1;
Circle c1;
// s 是来自序列化器的字符串
StringDeserializeVisitor deserializer(s);
deserializer.visit(l1); // 恢复了线段 l
deserializer.visit(c1); // 恢复了圆形 c
现在,我们知道了哪些对象序列化以及其顺序。因此,可以按相同的顺序反序列化相同的对象。更普遍的情况是,在反序列化期间我们不知道期望哪些对象 —— 这些对象存储在一个可访问的容器中,类似于前面示例中的 Shelter,该容器必须确保对象以相同的顺序进行序列化和反序列化。例如这个类,将几何体存储为另外两个几何体的交集:
// Example 09
class Intersection : public Geometry {
public:
Intersection() = default;
Intersection(Geometry* g1, Geometry* g2) :
g1_(g1), g2_(g2) {}
void accept(Visitor& v) override {
g1_->accept(v);
g2_->accept(v);
}
private:
std::unique_ptr<Geometry> g1_;
std::unique_ptr<Geometry> g2_;
};
这个对象的序列化很简单 —— 按顺序通过将细节委托给这些对象来序列化两个几何体。不能直接调用 v.visit(),因为不知道 *g1_ 和 *g2_ 几何体的类型,但可以让这些对象适当地分派调用。但如所写的反序列化将会失败 —— 几何体指针为空,还没有分配对象,并且不知道应该分配什么类型的对象。需要以某种方式首先在序列化流中编码对象的类型,然后根据这些编码类型来构造。有另一种模式为此问题提供了标准解决方案,就是工厂模式(在构建复杂系统时,通常需要使用多个设计模式)。这可以通过几种方式完成,但都归结为将类型转换为数字,并序列化这些数字。在我们的例子中,必须在声明基访问者类时知道完整的几何体类型列表,以便可以同时为所有这些类型定义一个枚举:
// Example 09
class Geometry {
public:
enum type_tag {POINT = 100, CIRCLE, LINE, INTERSECTION};
virtual type_tag tag() const = 0;
};
class Visitor {
public:
static Geometry* make_geometry(Geometry::type_tag tag);
virtual void visit(Geometry::type_tag& tag) = 0;
...
};
enum type_tag 并不一定需要定义在 Geometry 类内部,make_geometry 工厂构造函数也不一定是 Visitor 类的静态成员函数。它们也可以在类之外声明,但为每个派生的几何类型返回正确标签的虚拟 tag() 方法必须按所示方式精确声明。每个派生的 Geometry 类都必须定义 tag() 的重写,例如 Point 类:
// Example 09
class Point : public Geometry {
public:
...
type_tag tag() const override { return POINT; }
};
其他派生类也需要进行类似的修改。
然后,需要定义工厂构造函数:
// Example 09
Geometry* Visitor::make_geometry(Geometry::type_tag tag) {
switch (tag) {
case Geometry::POINT: return new Point;
case Geometry::CIRCLE: return new Circle;
case Geometry::LINE: return new Line;
case Geometry::INTERSECTION: return new Intersection;
}
}
这个工厂函数根据指定的类型标签构造正确的派生对象。剩下的就是让 Intersection 对象序列化和反序列化构成交集的两个几何体标签:
// Example 09
class Intersection : public Geometry {
public:
void accept(Visitor& v) override {
Geometry::type_tag tag;
if (g1_) tag = g1_->tag();
v.visit(tag);
if (!g1_) g1_.reset(Visitor::make_geometry(tag));
g1_->accept(v);
if (g2_) tag = g2_->tag();
v.visit(tag);
if (!g2_) g2_.reset(Visitor::make_geometry(tag));
g2_->accept(v);
}
...
};
首先,标签发送给访问者。序列化访问者应将标签与其余数据一起写入:
// Example 09
class StringSerializeVisitor : public Visitor {
public:
void visit(Geometry::type_tag& tag) override {
S << size_t(tag) << " ";
}
...
};
反序列化访问者必须读取标签(实际上,读取一个 size_t 数字并将其转换为标签):
// Example 09
class StringDeserializeVisitor : public Visitor {
public:
void visit(Geometry::type_tag& tag) override {
size_t t;
S >> t;
tag = Geometry::type_tag(t);
}
...
};
当反序列化访问者恢复了标签,Intersection 对象就可以调用工厂构造函数来构造正确的几何对象。现在可以从流中反序列化此对象, Intersection 将恢复为与序列化的对象完全相同的副本。
打包访问标签和调用工厂构造函数的方式还有其他方法;最佳解决方案取决于系统中不同对象的角色 —— 反序列化访问者可能会根据标签构造对象,而不是由拥有这些几何体的复合对象来构造,但需要发生的事件序列保持不变。
现在,我们一直在介绍经典的面向对象的访问者模式。在我们了解经典模式在 C++ 中的特定变体之前,我们应该了解另一种类型的访问者,它解决了访问者模式中的一些难言之隐。