C++ 设计模式 十:享元模式 (读书 现代c++设计模式)
享元模式
文章目录
- 享元模式
- 享元模式
- 用户名
- Boost.Flyweight
- String Ranges
- 简单方法
- 享元实现
- 总结
- **何时需要使用享元模式?**
- **享元模式解决的核心问题**
- **与其他设计模式的协同使用**
- **与其他模式的对比**
- **经典应用场景**
- **实现步骤与关键点**
- **注意事项**
- **总结**
- **注意事项**
- **总结**
享元模式
今天读第11种设计模式享元模式.
享元(有时也称为token或cookie)是一种临时组件,可以看作是对某个对象的智能引用。
通常,享元模式的核心应用场景是 优化大量相似对象的内存占用和性能开销.
下面是一些相关场景:
用户名
现在我们有一个大型在线游戏, 里面有很多同名角色, 比如一个叫 爱猫TV之圆头耄耋来给你踩背
的用户. 如果我们为每个这样的名字都存储一份, 那么会带来很大的空间浪费(可能是14个字节?), 因此, 我们可以只存储一次名称, 然后再存一个指向该名称的每个用户的指针(占用8字节), 这样一来就能省下不少空间了.
不过我们也可以使用索引来表示一个用户名, 我们这样定义:
#include <cstdint>
typedef uint32_t key;
基于这个型别, 我们可以这样定义用户:
#include <cstdint>
#include <string>
#include <boost/bimap.hpp>
typedef uint32_t key;
using namespace std;
struct User {
protected:
key first_name_;
key last_name_;
static bimap<key, string> names_;
static key seed_;
static key add(const string& s);
public:
User(const string& first_name, const string& last_name) :
first_name_{add(first_name)}, last_name_{add(last_name)} {}
};
这里的 bimap
即 boost::map
是 boost
库中的双向映射容器. bimap
能够更容易地搜索到副本, 如果姓或者名已经在 bimap
中了, 那么我们只是返回它的索引.
然后是构造函数, 构造函数利用 add()
函数的返回值初始化成员 first_name_
和 成员 last_name_
.
然后是 add()
函数的实现:
key User::add(const string& s) {
auto it = names_.right.find(s);
if (it == names_.right.end()) {
names_.insert( {++seed_, s} );
return seed_;
}
return it->second;
}
这是 get-or-add
机制的标准执行.
如果想把姓和名暴露给外部, 我们需要提供适当的 getter
和 setter
, 如果为了方便地输出信息可以重载运算符 <<
, 如下是完整代码:
#include <cstdint>
#include <string>
#include <iostream>
#include <boost/bimap.hpp>
using key = uint32_t;
using namespace std;
struct User {
protected:
key first_name_;
key last_name_;
static bimap<key, string> names_;
static key seed_;
static key User::add(const string& s) {
auto it = names_.right.find(s);
if (it == names_.right.end()) {
names_.insert( {++seed_, s} );
return seed_;
}
return it->second;
}
public:
User(const string& first_name, const string& last_name) :
first_name_{add(first_name)}, last_name_{add(last_name)} {}
[[nodiscard]] const string& get_first_name() const {
return names_.left.find(first_name_)->second;
}
[[nodiscard]] const string& get_last_name() const {
return names_.left.find(last_name_)->second;
}
friend ostream& operator<<(ostream& os, const User& obj)
{
return os << "first_name: " << obj.get_first_name()
<< " last_name: " << obj.get_last_name();
}
};
这样就完成了, 相比较于为每一个人的用户名都给出一个存储, 这样可以节省内存, 而且我们这里的 key
是 uint32_t
类型, 如果实际情况更小一些或者更大一些自然就可以额外调整了.
Boost.Flyweight
在前面的示例中,我们动手实现了一个享元,其实我们可以直接使用Boost库中提供的boost::flyweight
,这使得User
类的实现变得非常简单。
struct User2 {
flyweight<string> first_name, last_name;
User2(const string& first_name, const string& last_name) :
first_name{first_name},
last_name{last_name} {}
};
可以通过运行以下代码来验证它实际上是享元:
void test() {
User2 john_doe{"John", "Doe"};
User2 jane_doe{"Jane", "Doe"};
cout << boolalpha <<
(&jane_doe.last_name.get() == &john_doe.last_name.get());
// true
}
String Ranges
如果你调用std::string::substring()
,是否会返回一个全新构造的字符串?最后的结论是:如果你想操纵它,那当然,但是如果你想改变字串来修改原始对象呢?一些编程语言(例如Swift、Rust
)显式地将子字符串作为范围返回,这同样是享元模式的实现,它节省了所使用的内存量,此外还允许我们通过指定区间(range)来操作底层对象。
c++中与字符串区间操作等价的是string_view
,对于数组来说还有其他的变体——只要避免复制数据即可, 我们可以试着构建我们自己的,非常简单的字符串区间操作(string range
)。
简单方法
一种非常简单明了的方法是定义一个布尔数组,它的大小与纯文本字符串相等,布尔数组中的值指示是否要大写该字符。我们可以这样实现它:
#include <cstring>
#include <string>
#include <iostream>
using namespace std;
class FormattedText {
private:
// 待处理字符串
string plainText_;
// bool 标记数组
bool *caps_;
public:
explicit FormattedText(const string& plainText) : plainText_{plainText} {
const size_t length = plainText.length();
caps_ = new bool [length];
memset(caps_, false, length);
}
~FormattedText() {
delete[] caps_;
}
// 大写函数, 将 start 到 end 之间的字母大写处理
void capitalize(const int start, const int end) const {
for (int i = start; i <= end; ++i) {
caps_[i] = true;
}
}
// 依据需求, 我们有一个使用布尔掩码的流输出操作符
friend std::ostream& operator<<(std::ostream& os, const FormattedText& obj) {
string s;
for (int i = 0; i < obj.plainText_.length(); ++i) {
char c = obj.plainText_[i];
s += obj.caps_[i] ? static_cast<char>(toupper(c)) : c;
}
return os << s;
}
};
基本按照注释写的, 我们实现了我们想要的功能, caps_
是一个标记数组, 而 plainText_
则是我们的待处理字符串, 我们通过 capitalize()
函数做出一个范围的大写处理(仅仅是给出标记将 caps_
对应项设置为1), <<
流输出运算符在重载之后为我们提供打印服务, 按照标记输出转换后的字符串.
然后是我们的一个测试用例:
void test() {
FormattedText ft("This is a brave new world");
ft.capitalize(10, 15);
cout << ft << endl;
}
将会打印:
This is a BRAVE new world
享元实现
好的, 我们已经完成了所需要的大写转换工具类, 但是我们现在还能进一步改进一下, 我们可以用享元模式实现一个 BetterFromattedtext
类. 我们可以定义一个外部类和一个嵌套类, 我们在嵌套类中使用享元.
#include <vector>
#include <string>
#include <iostream>
using namespace std;
class BetterFormattedText {
public:
struct TextRange {
int start_;
int end_;
bool capitalize_;
// other options here, e.g. bold, italic, etc.
[[nodiscard]] bool covers(const int position) const {
return position >= start_ && position <= end_;
}
};
private:
string plain_text;
vector<TextRange> formatting;
public:
explicit BetterFormattedText(string text) : plain_text(std::move(text)) {
}
TextRange& get_range(const int start, const int end) {
formatting.emplace_back(TextRange{start, end});
return *formatting.rbegin();
}
};
TextRange只存储它所应用的起始点和结束点,以及我们是否希望将文本大写的实际格式信息,以及任何其他格式选项(粗体、斜体等)。它只有一个成员函数covers()
,用来确定是否需要将此格式应用于给定位置的字符.
BetterFormattedText
用一个 vector
来存储 TextRange
的享元,并能够根据需要构建新的享元
然后是我们的 get_range()
函数, 他做了这三件事:
- 构建一个新的
TextRange
对象 - 把构建的对象移动到
vector
中 - 返回
vector
中最有一个元素的引用
在前面的实现中,我们没有检查重复的区间范围,如果是基于享元模式节约空间的精神的话也可以进一步加以改进。
现在实现BetterFormattedText
中的operator<<
:
friend std::ostream& operator<<(std::ostream& os,
const BetterFormattedText& obj) {
string s;
for (int i = 0; i < obj.plain_text.length(); i++) {
auto c = obj.plain_text[i];
for (const auto& rng : obj.formatting) {
if (rng.covers(i) && rng.capitalize_)
c = static_cast<char>(toupper(c));
}
s += c;
}
return os << s;
}
同样,我们所做的就是遍历每个字符并检查是否有覆盖它的范围。如果有,则应用范围指定的内容,在本例中就是大写。注意,这种设置允许范围自由重叠。
同样,我们所做的就是遍历每个字符并检查是否有覆盖它的范围。如果有,则应用范围指定的内容,在本例中就是大写。
现在,可以将这个单词大写,尽管API稍有不同,但更灵活, 这是测试用例:
void test() {
BetterFormattedText bft("This is a brave new world");
bft.get_range(10, 15).capitalize_ = true;
cout << bft << endl;
}
将会打印:
This is a BRAVE new world
总结
何时需要使用享元模式?
享元模式的核心应用场景是 优化大量相似对象的内存占用和性能开销,具体需求场景包括:
- 系统中存在大量重复对象:例如游戏中的粒子效果、文本编辑器中的字符、图形应用中的图标等,这些对象具有高度相似性。
- 对象状态可分离为内部与外部:
- 内部状态:对象固有的、可共享的部分(如字符的字体、颜色)。
- 外部状态:依赖上下文且不可共享的部分(如字符在屏幕上的位置)。
- 需要缓存或复用对象:如数据库连接池、线程池等资源复用场景。
享元模式解决的核心问题
- 内存消耗过大:通过共享内部状态,减少重复对象的存储空间。
- 对象创建与销毁开销高:复用已有对象,避免频繁的创建和垃圾回收。
- 性能优化:降低内存占用和对象管理成本,提升系统响应速度。
与其他设计模式的协同使用
享元模式常与其他模式结合,以增强系统设计的灵活性和效率:
模式 | 协同场景 | 示例 |
---|---|---|
工厂模式 | 通过工厂类管理享元对象的创建与复用,确保对象共享逻辑集中化。 | 字符享元工厂根据字体和颜色生成唯一字符对象,避免重复创建。 |
组合模式 | 将多个享元对象组合成树形结构,统一管理复杂对象(如文档中的段落和句子)。 | 文本编辑器中使用组合模式管理字符享元,形成段落和页面。 |
单例模式 | 若享元工厂需要全局唯一实例,通过单例模式确保工厂的唯一性。 | 数据库连接池的工厂类设计为单例,统一管理所有连接实例。 |
代理模式 | 代理控制对享元对象的访问,例如实现延迟加载或权限校验。 | 图形渲染时,通过代理延迟加载高分辨率纹理享元,仅在需要时从磁盘读取。 |
与其他模式的对比
- 原型模式:
- 原型:通过克隆快速生成新对象,避免重复初始化。
- 享元:通过共享减少对象数量,侧重内存优化。
- 对象池模式:
- 对象池:复用对象生命周期(如线程池),侧重资源管理。
- 享元:复用对象状态,侧重内存优化。
经典应用场景
- 游戏开发:
- 大量粒子效果(如子弹、火焰)共享相同的纹理和动画属性,仅位置和速度作为外部状态。
- 文本处理:
- 文档中重复字符(如字母、数字)共享字体和样式信息,仅位置独立存储。
- 图形界面:
- 图标库中的图标享元,通过不同坐标和大小渲染到界面不同位置。
- 网络通信:
- 复用协议解析器的内部逻辑(如HTTP头部处理),仅会话ID作为外部状态。
实现步骤与关键点
- 分离状态:
- 将对象状态拆分为内部状态(共享)和外部状态(上下文相关)。
- 创建享元工厂:
- 使用工厂类维护享元池(如哈希表),按需创建或返回已有对象。
- 客户端管理外部状态:
- 客户端需自行维护和传递外部状态(如坐标、尺寸)。
注意事项
- 状态分离的合理性:需确保内部状态真正独立于上下文,否则难以共享。
- 线程安全:多线程环境下,享元工厂需加锁保证并发安全。
- 权衡性能与复杂度:若对象数量较少或状态不可分,引入享元模式可能得不偿失。
总结
享元模式是 以空间换时间 的经典实践,其核心价值在于:
- 减少内存占用:通过共享重复对象的内部状态。
- 提升性能:降低对象创建和销毁的开销。
- 增强扩展性:结合工厂、组合等模式,构建高效灵活的系统。
适用于高并发、资源受限或需要处理海量相似对象的场景,是现代高性能系统设计中的重要工具。
. 客户端管理外部状态:
- 客户端需自行维护和传递外部状态(如坐标、尺寸)。
注意事项
- 状态分离的合理性:需确保内部状态真正独立于上下文,否则难以共享。
- 线程安全:多线程环境下,享元工厂需加锁保证并发安全。
- 权衡性能与复杂度:若对象数量较少或状态不可分,引入享元模式可能得不偿失。
总结
享元模式是 以空间换时间 的经典实践,其核心价值在于:
- 减少内存占用:通过共享重复对象的内部状态。
- 提升性能:降低对象创建和销毁的开销。
- 增强扩展性:结合工厂、组合等模式,构建高效灵活的系统。
适用于高并发、资源受限或需要处理海量相似对象的场景,是现代高性能系统设计中的重要工具。