当前位置: 首页 > news >正文

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)} {}
};

这里的 bimapboost::mapboost 库中的双向映射容器. 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 机制的标准执行.

如果想把姓和名暴露给外部, 我们需要提供适当的 gettersetter, 如果为了方便地输出信息可以重载运算符 <<, 如下是完整代码:

#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();
	}
};

这样就完成了, 相比较于为每一个人的用户名都给出一个存储, 这样可以节省内存, 而且我们这里的 keyuint32_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


总结

何时需要使用享元模式?

享元模式的核心应用场景是 优化大量相似对象的内存占用和性能开销,具体需求场景包括:

  1. 系统中存在大量重复对象:例如游戏中的粒子效果、文本编辑器中的字符、图形应用中的图标等,这些对象具有高度相似性。
  2. 对象状态可分离为内部与外部
    • 内部状态:对象固有的、可共享的部分(如字符的字体、颜色)。
    • 外部状态:依赖上下文且不可共享的部分(如字符在屏幕上的位置)。
  3. 需要缓存或复用对象:如数据库连接池、线程池等资源复用场景。

享元模式解决的核心问题
  1. 内存消耗过大:通过共享内部状态,减少重复对象的存储空间。
  2. 对象创建与销毁开销高:复用已有对象,避免频繁的创建和垃圾回收。
  3. 性能优化:降低内存占用和对象管理成本,提升系统响应速度。

与其他设计模式的协同使用

享元模式常与其他模式结合,以增强系统设计的灵活性和效率:

模式协同场景示例
工厂模式通过工厂类管理享元对象的创建与复用,确保对象共享逻辑集中化。字符享元工厂根据字体和颜色生成唯一字符对象,避免重复创建。
组合模式将多个享元对象组合成树形结构,统一管理复杂对象(如文档中的段落和句子)。文本编辑器中使用组合模式管理字符享元,形成段落和页面。
单例模式若享元工厂需要全局唯一实例,通过单例模式确保工厂的唯一性。数据库连接池的工厂类设计为单例,统一管理所有连接实例。
代理模式代理控制对享元对象的访问,例如实现延迟加载或权限校验。图形渲染时,通过代理延迟加载高分辨率纹理享元,仅在需要时从磁盘读取。

与其他模式的对比
  • 原型模式
    • 原型:通过克隆快速生成新对象,避免重复初始化。
    • 享元:通过共享减少对象数量,侧重内存优化。
  • 对象池模式
    • 对象池:复用对象生命周期(如线程池),侧重资源管理。
    • 享元:复用对象状态,侧重内存优化。

经典应用场景
  1. 游戏开发
    • 大量粒子效果(如子弹、火焰)共享相同的纹理和动画属性,仅位置和速度作为外部状态。
  2. 文本处理
    • 文档中重复字符(如字母、数字)共享字体和样式信息,仅位置独立存储。
  3. 图形界面
    • 图标库中的图标享元,通过不同坐标和大小渲染到界面不同位置。
  4. 网络通信
    • 复用协议解析器的内部逻辑(如HTTP头部处理),仅会话ID作为外部状态。

实现步骤与关键点
  1. 分离状态
    • 将对象状态拆分为内部状态(共享)和外部状态(上下文相关)。
  2. 创建享元工厂
    • 使用工厂类维护享元池(如哈希表),按需创建或返回已有对象。
  3. 客户端管理外部状态
    • 客户端需自行维护和传递外部状态(如坐标、尺寸)。

注意事项
  • 状态分离的合理性:需确保内部状态真正独立于上下文,否则难以共享。
  • 线程安全:多线程环境下,享元工厂需加锁保证并发安全。
  • 权衡性能与复杂度:若对象数量较少或状态不可分,引入享元模式可能得不偿失。

总结

享元模式是 以空间换时间 的经典实践,其核心价值在于:

  • 减少内存占用:通过共享重复对象的内部状态。
  • 提升性能:降低对象创建和销毁的开销。
  • 增强扩展性:结合工厂、组合等模式,构建高效灵活的系统。

适用于高并发、资源受限或需要处理海量相似对象的场景,是现代高性能系统设计中的重要工具。

. 客户端管理外部状态
- 客户端需自行维护和传递外部状态(如坐标、尺寸)。


注意事项
  • 状态分离的合理性:需确保内部状态真正独立于上下文,否则难以共享。
  • 线程安全:多线程环境下,享元工厂需加锁保证并发安全。
  • 权衡性能与复杂度:若对象数量较少或状态不可分,引入享元模式可能得不偿失。

总结

享元模式是 以空间换时间 的经典实践,其核心价值在于:

  • 减少内存占用:通过共享重复对象的内部状态。
  • 提升性能:降低对象创建和销毁的开销。
  • 增强扩展性:结合工厂、组合等模式,构建高效灵活的系统。

适用于高并发、资源受限或需要处理海量相似对象的场景,是现代高性能系统设计中的重要工具。

相关文章:

  • 网页制作10-html,css,javascript初认识の适用XHTML
  • 【Elasticsearch】(Java 版)
  • springai系列(二)从0开始搭建和接入azure-openai实现智能问答
  • 基于LangChain的智能体开发实战
  • MySQL之解决表中存储类型为[1,2,3]这样的字符串中去除括号[]和逗号‘,‘的问题(FIND_IN_SET+replace)
  • Python--模块(下)
  • 【北京迅为】itop-3568 开发板openharmony鸿蒙烧写及测试-第1章 体验OpenHarmony—烧写镜像
  • Rust 图形界面开发——使用 GTK 创建跨平台 GUI
  • Python 的历史进程
  • Redis的Spring配置
  • 【论文详解】Transformer 论文《Attention Is All You Need》能够并行计算的原因
  • python-leetcode 45.二叉树转换为链表
  • 华为MindIE兼容OpenAI接口与兼容vLLM OpenAI接口的区别(华为VLLM)
  • 企业级AI办公落地实践:基于钉钉/飞书的标准产品解决方案
  • 在阿波罗自动驾驶框架中, 全局路径规划用什么算法
  • drupal是否有翻译的功能,只需要提供文本对应的翻译,自动添加一种语言的所有页面,将对应的文本进行替换
  • windows 下 使用Python OpenCV针对 压缩的tiff 图像进行解压缩 并转换成多张jpeg 图像
  • Asp.Net Web API| React.js| EF框架 | SQLite|
  • Excel的两个小问题解决
  • 如何将图片档案信息读取出来?并把档案信息相关性进行关联
  • 全国治安管理工作视频会召开
  • 韩国第二大轮胎制造商因火灾停产,或影响700万条轮胎销售
  • 竞彩湃|水晶宫夺冠后乘胜追击,四大皆空曼城人间清醒?
  • 鸿蒙电脑正式发布,余承东:国产软件起步晚,基础弱,探索面向未来的电脑体验
  • 推动粒子治疗更加可及可享!龚正调研上海市质子重离子医院
  • 前4个月全国新建商品房销售面积降幅收窄,房地产库存和新开工有所改善