我曾经经常旅行,一个旅行适配器可以让我将欧洲插头插入英国或美国插座 1 中,这是对适配器模式的一个很好的类比:我们有一个接口,但我们想要一个不同的接口,在接口上构建一个适配器可以让我们到达我们想要的地方。
这里有一个简单的例子:假设您正在使用一个非常擅长绘制像素的库。另一方面,你处理的是几何对象——直线、矩形之类的东西。您希望继续处理这些对象,但也需要渲染,因此需要使几何体适应基于像素的表示。
让我们从定义示例中的(相当简单的)域对象开始:
1 struct Point
2 {
3 int x, y;
4 };
5
6 struct Line
7 {
8 Point start, end;
9 };
现在让我们从理论上研究矢量几何。典型的矢量对象很可能是由一组Line
对象定义的。我们可以只定义一对纯虚拟迭代器方法,而不是从vector<Line>
继承:
1 struct VectorObject
2 {
3 virtual std::vector<Line>::iterator begin() = 0;
4 virtual std::vector<Line>::iterator end() = 0;
5 };
这样,如果您想定义一个Rectangle
,您可以在一个vector<Line>
类型的字段中保存一串行,并简单地公开它的端点:
1 struct VectorRectangle : VectorObject
2 {
3 VectorRectangle(int x, int y, int width, int height)
4 {
5 lines.emplace_back(Line{ Point{x,y}, Point{x + width,y} });
6 lines.emplace_back(Line{ Point{x + width,y}, Point{x+width, y+height} });
7 lines.emplace_back(Line{ Point{x,y}, Point{x,y+height} });
8 lines.emplace_back(Line{ Point{ x,y + height },
9 Point{ x + width, y + height } });
10 }
11
12 std::vector<Line>::iterator begin() override {
13 return lines.begin();
14 }
15 std::vector<Line>::iterator end() override {
16 return lines.end();
17 }
18 private:
19 std::vector<Line> lines;
20 };
现在,这里是设置。假设我们想在屏幕上画线。长方形,甚至!不幸的是,我们不能,因为绘图的唯一界面实际上是这样的:
1 void DrawPoints(CPaintDC& dc, std::vector<Point>::iterator\
2 start, std::vector<Point>::iterator end)
3 {
4 for (auto i = start; i != end; ++i)
5 dc.SetPixel(i->x, i->y, 0);
6 }
我这里用的是 MFC(微软基础类)的 CPaintDC 类,但这是题外话。关键是我们需要像素。我们只有台词。我们需要一个适配器。
好吧,假设我们想画几个矩形:
1 vector<shared_ptr<VectorObject>> vectorObjects{
2 make_shared<VectorRectangle>(10,10,100,100),
3 make_shared<VectorRectangle>(30,30,60,60)
4 }
为了绘制这些对象,我们需要将它们中的每一个从一系列线转换成大量的点。为此,我们创建一个单独的类来存储这些点,并将它们作为一对迭代器公开:
1 struct LineToPointAdapter
2 {
3 typedef vector<Point> Points;
4
5 LineToPointAdapter(Line& line)
6 {
7 // TODO
8 }
9
10 virtual Points::iterator begin() { return points.begin(); }
11 virtual Points::iterator end() { return points.end(); }
12 private:
13 Points points;
14 };
从一条线到多个点的转换正好发生在构造器中,所以适配器非常渴望。 2 转换的实际代码也相当简单:
1 LineToPointAdapter(Line& line)
2 {
3 int left = min(line.start.x, line.end.x);
4 int right = max(line.start.x, line.end.x);
5 int top = min(line.start.y, line.end.y);
6 int bottom = max(line.start.y, line.end.y);
7 int dx = right - left;
8 int dy = line.end.y - line.start.y;
9
10 // only vertical or horizontal lines
11 if (dx == 0)
12 {
13 // vertical
14 for (int y = top; y <= bottom; ++y)
15 {
16 points.emplace_back(Point{ left,y });
17 }
18 }
19 else if (dy == 0)
20 {
21 for (int x = left; x <= right; ++x)
22 {
23 points.emplace_back(Point{ x, top });
24 }
25 }
26 }
上面的代码很简单:我们只处理完全垂直或水平的行,而忽略其他的。我们现在可以使用这个适配器来实际呈现一些对象。我们从示例中取出两个矩形,并简单地将它们渲染成这样:
1 for (auto& obj : vectorObjects)
2 {
3 for (auto& line : *obj)
4 {
5 LineToPointAdapter lpo{ line };
6 DrawPoints(dc, lpo.begin(), lpo.end());
7 }
8 }
太美了!我们所做的就是,对于每个 vector 对象,获取它的每条线,为那条线构造一个LineToPointAdapter
,然后迭代由适配器产生的点集,将它们提供给DrawPoints()
。而且很管用!(相信我,确实如此。)
不过,我们的代码有一个主要问题:DrawPoints()
在我们可能需要的每一次屏幕刷新时都会被调用,这意味着相同行对象的相同数据会被适配器重新生成无数次。我们能做些什么呢?
一方面,我们可以在应用程序启动时预定义所有点,例如:
1 vector<Point> points;
2 for (auto& o : vectorObjects)
3 {
4 for (auto& l : *o)
5 {
6 LineToPointAdapter lpo{ l };
7 for (auto& p : lpo)
8 points.push_back(p);
9 }
10 }
然后DrawPoints()
的实现简化为
1 DrawPoints(dc, points.begin(), points.end());
但是让我们假设一下,vectorObjects
的原始集合是可以改变的。缓存这些点没有意义,但是我们仍然希望避免不断地重新生成潜在的重复数据。我们该如何应对?当然是带缓存的!
首先,为了避免再生,我们需要独特的识别线的方法,这就意味着我们需要独特的识别点的方法。ReSharper 的 Generate | Hash 函数拯救了我们:
1 struct Point
2 {
3 int x, y;
4
5 friend std::size_t hash_value(const Point& obj)
6 {
7 std::size_t seed = 0x725C686F;
8 boost::hash_combine(seed, obj.x);
9 boost::hash_combine(seed, obj.y);
10 return seed;
11 }
12 };
13
14 struct Line
15 {
16 Point start, end;
17
18 friend std::size_t hash_value(const Line& obj)
19 {
20 std::size_t seed = 0x719E6B16;
21 boost::hash_combine(seed, obj.start);
22 boost::hash_combine(seed, obj.end);
23 return seed;
24 }
25 };
在前面的例子中,我选择了 Boost 的散列实现。现在,我们可以构建一个新的LineToPointCachingAdapter
来缓存这些点,并在必要时重新生成它们。除了以下细微差别之外,实现几乎是相同的。
首先,适配器现在有了一个缓存:
1 static map<size_t, Points> cache;
这里的类型size_t
正是 Boost 的哈希函数返回的类型。现在,当涉及到迭代生成的点时,我们产生它们如下:
1 virtual Points::iterator begin() { return cache[line_hash].begin(); }
2 virtual Points::iterator end() { return cache[line_hash].end(); }
这是算法有趣的部分:在生成点之前,我们检查它们是否已经生成。如果他们有,我们就退出;如果没有,我们会生成它们并将其添加到缓存中:
1 LineToPointCachingAdapter(Line& line)
2 {
3 static boost::hash<Line> hash;
4 line_hash = hash(line); // note: line_hash is a field!
5 if (cache.find(line_hash) != cache.end())
6 return; // we already have it
7
8 Points points;
9
10 // same code as before
11
12 cache[line_hash] = points;
13 }
耶!多亏了哈希函数和缓存,我们大大减少了转换的次数。剩下的唯一问题是在不再需要旧点后将其删除。这个具有挑战性的问题留给读者做练习。
适配器是一个非常简单的概念:它允许您将您拥有的接口适配到您需要的接口。适配器唯一真正的问题是,在适配过程中,有时您最终会生成临时数据,以满足一些其他的数据表示。当这种情况发生时,转向缓存:确保新数据只在必要时生成。哦,如果您想在缓存的对象发生变化时清理过时的数据,您还需要做更多的工作。
我们还没有真正解决的另一个问题是懒惰:当前的适配器实现在创建转换时就执行转换。如果您只想在实际使用适配器时完成工作,该怎么办?这很容易做到,留给读者作为练习。
Footnotes 1
以防你像我一样是欧洲人,想抱怨每个人都应该使用欧洲插座:不;英国的设计在技术上更好,也更安全,所以如果我们真的只想要一个标准,英国的将会是我们的首选。
我们能让适配器变得懒惰吗?当然,我们可以只在本地保存line
(因为它是一个引用,我们不希望它过时或改变),然后,每当有人调用begin()
时,如果还没有初始化,就执行初始化。然而,如果我们有几个适配器成员,这个初始化检查必须在每个成员中重复。