从本章开始,进入全书第二部分———结构型设计模式。结构型设计模式主要关注如何设置应用程序的结构,以使代码满足SOLID设计原则,提高代码的通用性和可重构性。当谈到对象的结构时,我们可以使用下面几种常用的方式:
- 继承:
对象可以直接获得基类的非私有成员和方法。 - 组合:
组合是一种部分与整体的关系,部分不可以离开整体而单独存在。
例如某个对象有一个类型为owner的成员,当该对象被销毁时,其成员也随之被销毁。 - 聚类
聚类也是一种部分与整体的关系,部分和整体可以单独存在。
例如一个对象可以含有类型为T*或shared_ptr的成员。
现在,我们可以把组合和聚类看作同一种类型的方法。因为我们真正想表达“聚合”的意思但却使用“组合”这个词的现象很普遍,以至于我们可以互换地使用它们。
适配器模式(Adapter)
国际旅行时,旅行适配器让我可以将欧洲插头插入英国或美国的插座,这与适配器模式非常相似:有时候,我们想根据已有的接口得到另一个不同的接口,在接口上构建一个适配器就可以达到此目的。
0.预想方案
本节采用的例子是:将向量表示的几何对象与绘制像素的库进行适配处理。假设我们正在使用一个专用于绘制像素的库,另外我们要处理一些几何对象(直线、矩形、圆等)。
首先定义两个简单对象:Point
类表示笛卡儿空间中的二维坐标(对应于屏幕中的网格),Line
类表示由起止坐标定义的线段:
struct Point
{
int x,y;
};
struct Line
{
Point start_, end_;
};
然后从理论角度来说明向量几何。典型的向量对象可能由一组线段对象定义,我们定义一对纯虚迭代器接口,而不是继承vector<Line>
:
struct VectorObject
{
virtual std::vector<Line>::iterator begin() = 0;
virtual std::vector<Line>::iterator end() = 0;
};
现在假如要定义Rectangle,只需要将描述矩形的4条边的线段存入vector<Line>
类型的成员中即可:
struct VectorRectangle : VectorObject
{
private:
std::vector<Line> lines_;
public:
VectorRectangle(int x, int y, int w, int h)
{
lines_.emplace_back(Line{Point{x,y}, Point{x+w, y}});
lines_.emplace_back(Line{Point{x,y}, Point{x, y+h}});
lines_.emplace_back(Line{Point{x,y+h}, Point{x+w, y+h}});
lines_.emplace_back(Line{Point{x+w,y}, Point{x+w, y+h}});
}
std::vector<Line>::iterator begin() override
{
return lines_.begin();
}
std::vector<Line>::iterator end() override
{
return lines_.end();
}
};
现在假设我们想在屏幕上画线段以及画矩形,但是我们还做不到,因为用于图形绘制的唯一接口实际上是:
void DrawPoints(std::vector<Point>::iterator start, std::vector<Point>::iterator end)
{
for(auto i=start; i != end; ++i)
{
//实际绘制像素点的操作
}
}
简而言之,这个示例中遇到的问题是,我们需要提供像素坐标以渲染图像,但是我们只有一些向量对象。
1. 适配器
为了绘制矩形对象,我们需要将每个矩形从一组线段转换为数量庞大的像素点。为此,我们可以定义一个单独的适配器类,用于存储这些像素点,并且定义一组迭代器来访问这些点。
struct LineToPointAdapter
{
typedef std::vector<Point> Points;
LineToPointAdapter(const Line& line)
{
int left = std::min(line.start_.x, line.end_.x);
int right = std::max(line.start_.x, line.end_.x);
int top = std::max(line.start_.y, line.end_.y);
int bottom = std::min(line.start_.y, line.end_.y);
int dx = right - left;
int dy = top - bottom;
//we only support vertical or horizontal lines
if(dx == 0) // vertical
{
for(int y=bottom; y<=top; ++y)
{
points_.emplace_back(Point{left, y});
}
}
else if(dy == 0) //horizontal
{
for(int x=left; x<=right; ++x)
{
points_.emplace_back(Point{x,bottom});
}
}
}
virtual Points::iterator begin() { return points_.begin();}
virtual Points::iterator end() { return points_.end();}
private:
Points points_;
};
在上面的代码中,将Line
对象转换为像素点集的过程由构造函数完成,所以LineToPointAdapter
是饿汉式的适配器,在适配器对象构建过程中,转换工作随之完成。并且为了简化问题,我们只处理垂直或水平的线段。
现在我们可以使用适配器来渲染矩形对象了!
std::vector<std::shared_ptr<VectorObject>> vectorObjects
{
std::make_shared<VectorRectangle>(10,10,4,5),
std::make_shared<VectorRectangle>(30,30,9,6)
};
for(const auto& obj : vectorObjects)
{
for(const auto& line : *obj)
{
LineToPointAdapter lpa{line};
DrawPoints(lpa.begin(), lpa.end());
}
}
2.临时适配器对象
上面代码中的适配器存在的一个主要问题是:每次刷新屏幕时,函数DrawPoints()
都会被调用,这意味着适配器对象会不断地为同样的线段对象生成相同的像素点数据,甚至是无数次!
一种改善这个问题的方法是在程序的开始处定义一个像素点容器,然后修改DrawPoints()
函数的调用范围:
std::vector<Point> points;
for(const auto& obj : vectorObjects)
{
for(const auto& line : *obj)
{
LineToPointAdapter lpa{line};
for(auto& p : lpa)
{
points.push_back(p);
}
}
}
DrawPoints(points.begin(), points.end());
假如某个时候,原始的几何对象vectorObjects
发生了变化,但我们并不知道它们具体有哪些变化;我们希望缓存未改动的数据,而只为变化了的对象重新生成像素点数据。
为了避免重新生成数据,我们需要独特的识别线段的方法,这意味着我们需要独特的识别点的方法。书中采用了Reshaper的Generate|Hash函数,以及Boost的hash实现。
struct Point
{
int x_, y_;
friend size_t hash_value(const Point& obj)
{
size_t seed = 0x725C686F;
boost::hash_combine(seed, obj.x_);
boost::hash_combine(seed, obj.y_);
return seed;
}
};
struct Line
{
Point start_, end_;
friend size_t hash_value(const Line& obj)
{
size_t seed = 0x719E6B16;
boost::hash_combine(seed, obj.start_);
boost::hash_combine(seed, obj.end_);
return seed;
}
};
现在我们可以构建一个新的LineToPointCachingAdapter
,它可以缓存Point对象并在必要的时候重新生成它们:
struct LineToPointCachingAdapter
{
typedef std::vector<Point2> Points2;
LineToPointCachingAdapter(const Line2& line);
virtual Points2::iterator begin() { return cache[line_hash_].begin();}
virtual Points2::iterator end() { return cache[line_hash_].end();}
private:
size_t line_hash_;
static std::map<size_t, Points2> cache;
};
LineToPointCachingAdapter::LineToPointCachingAdapter(const Line2& line)
{
static boost::hash<Line2> hash;
line_hash_ = hash(line); //line_hash is a field
if(cache.find(line_hash_) != cache.end())
{
std::cout << "find repeated lines! skipped..." << std::endl;
return;
}
Points2 points;
// as before
cache[line_hash_] = points;
}
现在的适配器有一个static的缓存cache,它是一种从哈希值到点集的映射,可存储哈希值和对应的点集合,该缓存会保存所有经由该适配器生成的点集。成员line_hash_
保存当前的直线对应的hash值。
这个算法的有趣之处在于:在生成像素点集之前,先检查这些像素点是否已经生成。如果已经生成,那么函数直接退出;如果没有生成,则算法生成像素点集,并将其保存到缓存cache中。有了hash函数和缓存cache的帮助,我们可以显著减少转换次数了。
3.双向转换器
书中的例子是用户通过UI输入的字符串与底层的数字变量之间进行双向绑定和双向转换。由于例子简单且书中的代码比较完整,不做过多赘述了。
4.总结
“适配器”是一个非常简单的概念:它允许我们将已有的接口调整(适配)为我们需要的另一个接口。适配器模式存在的真正问题是,在适配过程中,有时会生成临时数据以满足其他接口的要求。当发生这种情况时,我们可以采用缓存策略,确保只在必要时生成新的数据。当缓存的数据发生变化时,需要清理缓存过程中过时的数据。