OOP 学习笔记(9)——行为型模式
Contents
行为型模式
设计模式
在日常的开发任务中,采用精心设计的程序架构可以极大方便日常改动以降低维护代价。
设计模式(Design Pattern)就是在长时间的实践之中,开发人员总结出的优秀架构与解决方案。经典的设计模式,都是经过相当长的一段时间的试验和错误总结而成的。
其有助于新开发人员在实际开发中,灵活应用面向对象特性,并快速构建不同场景下的程序架构,写出优质代码。
概念
- 遵循面向对象设计原则。
- 对接口编程而不是对实现编程(提高代码复用,抽象通用接口)。
- 优先使用对象组合而不是继承(即降低模型复杂程度,对功能尽可能划分)。
分类
- 行为性模式(Behavioral Patterns)。
- 关注对象行为功能上的抽象,从而提升对象在行为功能上的可拓展性,能以最少的代码变动完成功能的增减。
- 结构型模式(Structural Patterns)。
- 关注对象之间结构关系上的抽象,从而提升对象结构的可维护性、代码的健壮性,能在结构层面上尽可能的解耦合。
- 创建型模式(Creational Patterns)。
- 将对象的创建与使用进行划分,从而规避复杂对象创建带来的资源消耗,能以简短的代码完成对象的高效创建。
模板方法(Template Method)模式
一个例子:负载监视器
监视计算节点的负载状态(如 CPU 占用率)。
以 CPU 占用率的监视为例,不同条件下(例如不同种类不同版本的 OS)获得 CPU 占用率的方法不同,怎样在一个程序中实现对这些不同条件的适应?
简单枚举
class Monitor
{
public:
void getLoad();
void getTotalMemory();
void getUsedMemory();
void getNetworkLatency();
Monitor(Display *display);
virtual ~Monitor();
void show();
private:
float load, latency;
long totalMemory, usedMemory;
Display *m_display;
};
// 组合一个 Display 接口来进行结果展示
void Monitor::show()
{
m->display -> show(load, totalMemory, usedMemory, latency);
}
//规定所有的系统类型
enum MonitorType {Win32, Win64, Ganglia};
MonitorType type = Ganglia;
…
//获取负载信息的实现
void Monitor::getLoad()
{
switch (type)
{
//Win32 版本的信息获取
case Win32:
load = …;
//Win64 版本的信息获取
case Win64:
load = …;
//Ganglia 版本的信息获取
case Ganglia:
load = …;
}
}
// 其他实现
//主程序
int main(int argc, char *argv[])
{
WindowsDisplay display;
Monitor monitor(&display);
while (running())
{
//获取负载信息
monitor.getLoad();
//获取内存大小信息
monitor.getTotalMemory();
//获取内存使用信息
monitor.getUsedMemory();
//获取网络延迟信息
monitor.getNetworkLatency();
//信息输出
monitor.show();
sleep(1000);
}
return 0;
}
可以发现,每新增一个系统,都需要对每个方法进行相应修改,修改的工作量极大。
模板方法
在接口的一个方法中定义算法的骨架,将一些步骤的实现延迟到子类中,使得子类可以在不改变算法结构的情况下,重新定义算法中的某些步骤。
一定程度上,这很类似于写作中的套作。(但千万别写作中这么干啊)
这种方法,要求抽象类定义算法骨架,子类实现具体类的算法实现细节。
当需要拓展一个新的实现类时,重新继承与实现即可,无需对已有的实现类进行修改。
实现 Monitor
基类代码实现:
class Monitor
{
public:
virtual void getLoad() = 0;
virtual void getTotalMemory() = 0;
virtual void getUsedMemory() = 0;
virtual void getNetworkLatency() = 0;
Monitor(Display *display);
virtual ~Monitor();
void show();
protected:
//用以存储信息的成员变量
float load, latency;
long totalMemory, usedMemory;
Display* m_display;
};
//组合一个Display接口来进行结果展示
void Monitor::show()
{
m_display -> show(load, totalMemory, usedMemory, latency);
}
MonotorWin32
实现:
//通过具体实现抽象的模板来完成Win32系统下的监控器实现
class MonitorWin32::public Monitor
{
public:
void getLoad();
void getTotalMemory();
void getUsedMemory();
void getNetworkLatency();
};
//Win32的getLoad()的具体实现
void MonitorWin32::getLoad()
{
//…
load = …;
//…
}
//Win32的getTotalMemory()的具体实现
void MonitorWin32::getTotalMemory() {
//…
totalMemory = …;
//…
}
//…
主程序实现:
int main(int argc, char *argv[])
{
WindowsDisplay display;
//创建MonitorWin32模式的监控器,并用基类指针来调用方法
Monitor* monitor = new MonitorWin32(&display);
while (running())
{
monitor->getLoad();
//获取负载信息
monitor->getTotalMemory();
//获取内存大小信息
monitor->getUsedMemory();
//获取内存使用信息
monitor->getNetworkLatency();
//获取网络延迟信息
monitor->show();
//信息输出
sleep(1000);
}
//释放
delete monitor;
return 0;
}
针对接口编程
模板方法其实就是一种针对接口编程的设计。
通过抽象出抽象概念,设计出描述这个抽象概念的抽象类,或称为接口类(Monitor
),这个类有一系列的(纯)虚函数,描述了这个类的接口。
对这个类的接口类进行继承并实现这些(纯)虚函数,从而形成这个抽象概念的实现类(MonotorWin32
)——实现可以有多种。
在使用此概念时,使用接口类(Monitor*
)来引用这个概念,而不直接使用实现类,从而避免类的改变造成整个程序的大规模变化。
开放封闭原则
模板方法很好地体现了开放封闭原则:
- 对扩展开放,有新需求或变化时,可以方便地对现有代码进行拓展,而无需整体变动。
- 对修改封闭,新的拓展类一旦设计完成,可以独立完成其工作,同样不需要整体变动。
开放封闭原则的核心就是在结构层面上解耦合,对抽象进行编程,而不对具体编程。
- 抽象结构是简单与稳定的。
- 具体实现是复杂与多变的。
需求变化
如果 getLoad()
等几种函数的接口实现方法互为独立,假设各自有 $n, m , k$ 种实现方法,则需要实现 $n \times m \times k$ 个子类!
这种大量冗余实现看着就很不可取。
策略(Strategy)模式
策略模式
即定义一系列算法并加以封装,使得这些算法可以互相替换。
实现 Monitor
回到原来的问题:
比如对于 LoadStrategy
的实现:
class LoadStrategy // 负载策略基类
{
public:
virtual float getLoad() = 0;
};
// 负载算法一具体实现
class LoadStrategyImpl1 : public LoadStrategy
{
public:
float getLoad() // 获取负载数值
{
//…
return load;
}
};
//负载算法二具体实现
class LoadStrategyImpl2 : public LoadStrategy
{
public:
float getLoad() // 获取负载数值
{
//…
return load;
}
};
同理也可以类似实现 MemoryStrategy
和 Latencystrategy
。
而 Monitor
的实现:
class Monitor // 监控器类
{
public:
// 监控器就是各个策略类的组合
Monitor(LoadStrategy *loadStrategy,
MemoryStrategy *memStrategy,
LatencyStrategy *latencyStrategy
Display *display);
void getLoad();
void getTotalMemory();
void getUsedMemory();
void getNetworkLatency();
void show();
private:
// 获取各类不同信息的策略类
LoadStrategy *m_loadStrategy;
MemoryStrategy *m_memStrategy;
LatencyStrategy *m_latencyStrategy;
// 用以存储信息的成员变量
float load, latency;
long totalMemory, usedMemory;
Display *m_display;
};
// 构造函数初始化所有的策略和参数
Monitor::Monitor(LoadStrategy *loadStrategy,
MemoryStrategy *memStrategy,
LatencyStrategy *latencyStrategy
Display *display) :
m_loadStrategy(loadStrategy),
m_memStrategy(memStrategy),
m_latencyStrategy(latencyStrategy),
m_display(display),
load(0.0), latency(0.0),
totalMemory(0), usedMemory(0) {}
// 统一的输出接口输出不同策略类获得的系统信息
void Monitor::show()
{
m_display -> show(load, totalMemory, usedMemory, latency);
}
// 统一的接口来获取负载
void Monitor::getLoad()
{
load = m_loadStrategy -> getLoad();
}
// 统一的接口来获取总内存
void Monitor::getTotalMemory()
{
totalMemory = m_memStrategy -> getTotal();
}
// 统一的接口来获取已用内存
void Monitor::getUsedMemory()
{
usedMemory = m_memStrategy -> getUsed();
}
// 统一的接口来获取网络延迟信息
void Monitor::getNetworkLatency()
{
latency = m_latencyStrategy -> getLatency();
}
主程序实现:
int main(int argc, char *argv[])
{
// 为每个策略的选择具体的实现算法,并创建监控器类
GangliaLoadStrategy loadStrategy;
WinMemoryStrategy memoryStrategy;
PingLatencyStrategy latencyStrategy;
WindowsDisplay display;
//具体构建过程是将每个策略的具体算法类传入构造函数
Monitor monitor(&loadStrategy, &memoryStrategy, &latencyStrategy, &display);
while (running()) {
// 统一的接口获取系统信息
monitor.getLoad();
monitor.getTotalMemory();
monitor.getUsedMemory();
monitor.getNetworkLatency();
// 统一的接口输出系统信息
monitor.show();
sleep(1000);
}
return 0;
}
在这样的情况下,如果同样还各有 $n + m + k$ 种实现,我们只需要实现 $n + m + k + 3$ 个新类,至少在数量级上有了一个明显的优化。(要是你的 $n, m, k$ 就只有 $1$ 那就是另一个故事)
单一责任原则
策略模式很好的体现了单一责任原则:
- 一个类(接口)只负责一项职责。
- 不要存在多于一个导致类变更的原因。
如果一个类承担的职责过多,职责之间的耦合度会很大。
- 职责的变化可能会削弱或抑制这个类完成其他职责的能力。
- 多变的场景会使得整体程序遭受破坏,维护难度增大。
单一责任原则的核心就是在功能层面上解耦合。(对比:之前是结构层面)
模板方法模式与策略模式的对比
当我们需要实现一个新的 getTotalMemory
和 getUsedMemory()
,模板方法理论上需要实现新子类 $n \times m$ 个,而策略模式则只需 $1$ 个。
可以看出两者的一大区别就是,前者注重继承(功能的抽象与归纳),后者注重组合(功能的划分与组合)。
前者优点:
- 基类高度抽象统一,逻辑简洁明了。
- 子类之间关联不紧密时易于简单快速实现。
- 封装性好,实现类内部不会对外暴露。
缺点:
- 接口同时负责所有功能。
- 任何算法修改会导致整个实现类的变化(重实现)。
后者优点:
- 每个策略只负责一个功能,易于拓展。
- 算法的修改被限制在单个策略类的变化中,任何算法的修改对整体不造成影响。
缺点:
- 在功能较多的情况下结构复杂。
- 策略组合时对外暴露,封装性相对较差。
这两种模式实际上都是解决算法多样性对代码结构冲击的问题。
当业务相对简单时,两者几乎等效。
模板方法更侧重于逻辑复杂但结构稳定的场景,尤其是某些步骤(功能)变化剧烈且无相互关联。
策略模式则适用于算法(功能)本身灵活多变的场景,且多种算法之间需要协同工作。
迭代器(Iterator)模式
一个例子:对考试结果进行统计分析(及格率)
int main(int argc, char *argv[])
{
float scores[STUDENT_COUNT];
int passed = 0;
for (int i = 0; i != STUDENT_COUNT; i++)
{
if (scores[i] >= 60)
passed ++;
}
cout << "passing rate = "
<< (float)passed / STUDENT_COUNT
<< endl;
return EXIT_SUCCESS;
}
把分析单独作为一个功能:
void analyze(float *scores, int student_count)
{
int passed = 0;
for (int i = 0; i != student_count; i++)
{
if (scores[i] >= 60)
passed ++;
}
cout << "passing rate = "
<< (float)passed / student_count
<< endl;
}
但如果成绩不是用数组存储而是单向非循环链表呢?
struct Student
{
float score;
Student *next;
};
//...
Student *head;
此时分析就需要修改为:
void analyze(Student *head)
{
int passed = 0, count = 0;
for (Student *p = head; p != NULL; p = p -> next) {
if (p -> score >= 60)
passed ++;
count ++;
}
cout << "passing rate = "
<< (float)passed / count
<< endl;
}
“遍历”
如何实现与底层数据结构无关的统一算法接口?
变与不变:
- 需要遍历所有学生的成绩,即算法不变。
- 不希望绑定在某种存储方式,即底层数据结构是变化的。
所以可以考虑分离存储的变与访问的不变:
- 把数据访问设计为一个接口。
- 针对不同的存储完成这个接口的不同实现。
迭代器模式
提供一种方法顺序访问一个聚合对象中各个元素。
又不需暴露该对象的内部表示——与对象的内部数据结构形式无关。
具体实现相当于模板方法构建迭代器和数据存储基类,为每种单独数据结构实现独有的迭代器和存储类。
但对于上层算法,算法的执行只依赖于抽象的迭代器接口,而无需关注最底层的具体数据结构。
实现 Iterator
基类
迭代器模式中,存在迭代器 Iterator
和数据存储器 Collection
两部分。
针对每种数据存储结构,通过继承实现相应存储类 CollectionImplementation
及迭代器类 IteratorImplementation
。
迭代器类是存储类的友元,从而迭代器类可以访问存储器类内部的数据。
而对于上层算法,我们可以通过迭代器基类来进行数据访问。
对于迭代器基类,需要把数据访问设计为一个统一接口,形成迭代器。
这个迭代器可以套接在任意的数据结构上。
//迭代器基类
class Iterator
{
public:
virtual ~Iterator() {}
virtual Iterator& operator++() = 0;
virtual float& operator++(int) = 0;
virtual float& operator*() = 0;
virtual float* operator->() = 0;
virtual bool operator!=(const Iterator &other) const = 0;
bool operator==(const Iterator &other) const
{
return !(*this != other);
}
};
使用迭代器
用迭代器作为参数传递参与上层算法构建:
void analyze(Iterator* begin, Iterator* end)
{
int passed = 0, count = 0;
for (Iterator* p = begin; *p != *end; (*p)++)
{
if (**p >= 60)
passed ++;
count ++;
}
cout << "passing rate ="
<< (float)passed / count
<< endl;
}
实现 Collection
基类
定义数据存储结构基类 Collection
,需要给存储对象一个约束:
- 能够返回代表头和尾的迭代器。
- 使用左闭右开区间,即 $[begin, end)$。
class Collection
{
public:
virtual ~Collection() {}
virtual Iterator *begin() const = 0;
virtual Iterator *end() const = 0;
virtual int size() = 0;
};
实现基于数组的 Collection
class ArrayCollection : public Collection // 底层为数组的存储结构类
{
friend class ArrayIterator; // friend 可以使得配套的迭代器类可以访问数据
float* _data;
int _size;
public:
ArrayCollection() : _size(10)
{
_data = new float[_size];
}
ArrayCollection(int size, float* data) : _size(size)
{
_data = new float[_size]; // 开辟数组空间用以存储数据
for (int i = 0; i < size; i++)
*(_data+i) = *(data+i);
}
~ArrayCollection()
{
delete[] _data;
}
int size()
{
return _size;
}
Iterator* begin() const;
Iterator* end() const;
};
Iterator* ArrayCollection::begin() const // 头迭代器,并放入相应数据
{
return new ArrayIterator(_data, 0);
}
Iterator* ArrayCollection::end() const // 尾迭代器,并放入相应数据
{
return new ArrayIterator(_data, _size);
}
实现基于数组的 Iterator
class ArrayIterator : public Iterator // 继承自迭代器基类并配套 ArrayCollection 使用的迭代器
{
float *_data; // ArrayCollection 的数据
int _index; // 数据访问到的下标
public:
ArrayIterator(float* data, int index) :
_data(data), _index(index) {}
ArrayIterator(const ArrayIterator& other) :
_data(other._data), _index(other._index) {}
~ArrayIterator() {}
Iterator& operator++();
float& operator++(int);
float& operator*();
float* operator->();
bool operator!=(const Iterator &other) const;
};
// 迭代器各种内容的实现
Iterator& ArrayIterator::operator++()
{
_index++;
return *this;
}
// 因为是数组,所以直接将空间指针位置 +1 即可,可以思考下这里为什么返回 float&,而不是 Iterator
float& ArrayIterator::operator++(int)
{
_index++;
return _data[_index - 1];
}
// 对 data 的内存位置取值
float& ArrayIterator::operator*()
{
return *(_data + _index);
}
float* ArrayIterator::operator->()
{
return (_data + _index);
}
// 判断是不是指向内存的同一位置
bool ArrayIterator::operator!=(const Iterator &other) const
{
return (_data != ((ArrayIterator*)(&other))->_data || _index != ((ArrayIterator*)(&other))->_index);
}
注:在标准 STL 实现中 operator++(int)
的返回类型应该是一个 iterator
对象,如 ArrayIterator ArrayIterator::operator++(int)
的形式,但在这里迭代器模式的基类 Iterator
是抽象类,无法作为返回类型。
主程序可以是:
int main(int argc, char *argv[])
{
float scores[]={90, 20, 40, 40, 30, 60, 70, 30, 90, 100};
Collection *collection = new ArrayCollection(10, scores);
analyze(collection -> begin(), collection -> end());
return 0;
}
另一种常见的迭代器模式
其使用方法:
//…
Iterator* it = collection.iterator();
//…
while (it->hasNext())
{
it->next();
Object object = it->getValue();
//do something with object;
}
//…
总结
迭代器模式实现了算法和数据结构的隔离,规避了为每一个算法和数据结构的组合均进行代码实现的巨大工作量。
STL
C++ 的 STL 中提供了大量的数据容器,其背后支持的数据结构各不相同,但是拥有许多类似的访问操作,比如遍历,二分查找等等。
这么多数据结构均采用了类似的设计架构来抽象访问接口,可以阅读 STL 的具体实现代码来体会迭代器模式的特点。
本节总结
行为型设计模式关系对象之间的行为功能抽象,核心在于抽象行为功能中不变的成分,具体实现行为功能中变的成分,保证以尽可能少的代码改动完成功能的增减。
- 模板方法归纳了一系列类的通用功能,在基类中将功能的接口固定,在子类中具体实现流程细节,使得新类的增加不影响原有类。
- 策略模式抽象了功能的选择与组合,隔离不同功能使其相互之间不受影响,可灵活支持算法、策略的修改。
- 迭代器模式抽象了数据访问方法,可以访问对象的元素但却不暴露底层实现,隔离具体算法与数据结构,在 STL 的算法、容器与迭代器中有非常多的应用。
No Comments