84、STL与享元模式:共享对象、内部状态、外部状态
享元模式,说白了就是「抠门」的艺术。你想想看,如果一个对象被反复创建,内存里堆了一堆一模一样的东西,是不是很浪费?我早年做游戏服务器时,就吃过这个亏——每个玩家登录都new一个技能描述对象,结果几万人同时在线,内存直接爆了。从那以后,我对「对象复用」这件事就特别敏感。
STL里其实到处都有享元模式的影子。今天我们就把它拎出来,好好聊一聊。
什么是享元模式?
享元模式的核心思想就一句话:通过共享来减少对象的创建。它把对象的状态分成两类:
- 内部状态:对象固有的、可共享的部分。比如字符串的内容、字符的字体信息。
- 外部状态:随环境变化的、不可共享的部分。比如字符在屏幕上的位置、颜色。
内部状态存在享元对象里,外部状态由客户端传入。这样,同一个内部状态的对象就能被多处复用。
关键点:享元模式不是为了「省代码」,而是为了「省内存」。如果你的对象很小,或者创建开销不大,别硬套享元模式——过度设计也是坑。
STL中的享元模式实例
STL里最典型的享元模式应用,就是 std::string 的 COW(Copy-On-Write)实现。不过C++11之后,COW被禁用了,因为多线程下容易出问题。但别急,还有别的例子。
1. std::shared_ptr 的控制块
你每次创建 std::shared_ptr,它内部会生成一个控制块,记录引用计数。多个 shared_ptr 如果指向同一个对象,它们共享同一个控制块。这就是享元模式——控制块是内部状态,指针本身是外部状态。
#include <memory>
#include <iostream>
int main() {
auto sp1 = std::make_shared<int>(42);
std::shared_ptr<int> sp2 = sp1; // 共享控制块
// sp1 和 sp2 指向同一块内存,控制块也是同一个
std::cout << "use count: " << sp1.use_count() << std::endl; // 输出 2
return 0;
}
我个人习惯用 make_shared,它把对象和控制块分配在一块内存里,比先new再传指针更高效。嗯,这里要注意:make_shared 虽然快,但如果你要用自定义删除器,那就只能老老实实传裸指针了。
2. std::string 的 SSO(小字符串优化)
SSO 不是享元模式,但它和享元模式有异曲同工之妙——都是为了减少内存分配。SSO 把短字符串直接存在栈上,避免了堆分配。对于长字符串,才走堆内存。这其实也是一种「按需分配」的思想。
小技巧:如果你知道字符串大概率很短,用 std::string 就对了。SSO 在大多数实现里能容纳 15 个字符左右(具体看实现)。超过这个长度,才会触发堆分配。
3. std::locale 与 facet 共享
这个可能比较冷门,但确实是个好例子。每个 std::locale 对象内部维护了一组 facet(比如 std::ctype、std::num_get 等)。多个 locale 如果使用相同的 facet,它们会共享同一个 facet 对象。facet 是内部状态,locale 是外部状态。
#include <locale>
#include <iostream>
int main() {
std::locale loc1("en_US.UTF-8");
std::locale loc2("en_US.UTF-8");
// loc1 和 loc2 可能共享相同的 facet 对象
// 具体是否共享,取决于标准库实现
std::cout << "locale names: " << loc1.name() << ", " << loc2.name() << std::endl;
return 0;
}
我曾经在写国际化模块时,反复创建 locale 对象,结果性能很差。后来发现,把 locale 缓存起来,多个线程共享同一个 locale,性能提升很明显。这就是享元模式在实际项目中的价值。
自己实现一个享元模式
光说不练假把式。我们写一个简单的享元工厂,用来管理「字符样式」对象。
#include <iostream>
#include <unordered_map>
#include <string>
#include <memory>
// 享元对象:字符样式(内部状态)
class CharStyle {
public:
CharStyle(const std::string& font, int size, bool bold)
: font_(font), size_(size), bold_(bold) {}
void apply(int x, int y) const {
std::cout << "绘制字符于 (" << x << ", " << y << ")"
<< " 字体: " << font_
<< " 大小: " << size_
<< " 粗体: " << (bold_ ? "是" : "否")
<< std::endl;
}
private:
std::string font_;
int size_;
bool bold_;
};
// 享元工厂
class CharStyleFactory {
public:
std::shared_ptr<const CharStyle> getStyle(const std::string& font, int size, bool bold) {
std::string key = font + std::to_string(size) + (bold ? "1" : "0");
auto it = styles_.find(key);
if (it != styles_.end()) {
return it->second;
}
auto style = std::make_shared<CharStyle>(font, size, bold);
styles_[key] = style;
return style;
}
size_t size() const { return styles_.size(); }
private:
std::unordered_map<std::string, std::shared_ptr<const CharStyle>> styles_;
};
int main() {
CharStyleFactory factory;
// 外部状态:位置 (x, y)
auto style1 = factory.getStyle("Arial", 12, false);
style1->apply(10, 20);
style1->apply(30, 40); // 复用同一个样式对象
auto style2 = factory.getStyle("Arial", 12, false); // 和 style1 相同
std::cout << "style1 和 style2 是同一个对象?"
<< (style1 == style2 ? "是" : "否") << std::endl;
auto style3 = factory.getStyle("Times New Roman", 14, true);
style3->apply(50, 60);
std::cout << "工厂中样式数量: " << factory.size() << std::endl;
return 0;
}
运行结果:
绘制字符于 (10, 20) 字体: Arial 大小: 12 粗体: 否
绘制字符于 (30, 40) 字体: Arial 大小: 12 粗体: 否
style1 和 style2 是同一个对象?是
绘制字符于 (50, 60) 字体: Times New Roman 大小: 14 粗体: 是
工厂中样式数量: 2
看到了吧?虽然我们调用了三次 getStyle,但工厂里只创建了两个对象。因为前两次请求的样式完全一样,工厂直接返回了已有的享元对象。
避坑指南:享元对象一旦创建,就不应该被修改。所以我把 CharStyle 的成员都设成了 const,并且工厂返回 shared_ptr<const CharStyle>。如果你不小心修改了享元对象,所有引用它的地方都会受影响——我曾经在项目里犯过这个错,调试了一整天。
享元模式与STL的配合
STL容器和享元模式是天然搭档。比如:
- 用
std::unordered_map做享元工厂,查找效率高。 - 用
std::shared_ptr管理享元对象的生命周期,自动释放。 - 用
std::string作为键,方便组合内部状态。
我个人建议,享元工厂里用 std::weak_ptr 来缓存对象,这样当外部不再引用时,对象能被自动回收。不过要注意线程安全——如果多线程访问,得加锁或用 std::atomic。
什么时候该用享元模式?
别一上来就想着用享元模式。先问自己几个问题:
- 对象数量是不是很大?(成千上万甚至更多)
- 这些对象是不是有很多重复的内部状态?
- 外部状态能不能和内部状态分离?
如果三个答案都是「是」,那享元模式就派上用场了。否则,别折腾——简单直接才是王道。
总结一下:享元模式的核心是「共享内部状态,分离外部状态」。STL 里的 shared_ptr 控制块、locale 的 facet 共享,都是活生生的例子。自己实现时,记得用工厂模式管理享元对象,并且保证享元对象不可变。这样,你的程序既省内存又安全。
这张图把享元模式的脉络理清楚了。客户端不直接创建对象,而是通过工厂获取。工厂内部维护一个对象池,相同的内部状态只存一份。外部状态由客户端在使用时传入——这样既省内存,又灵活。
公众号:蓝海资料掘金营,微信deep3321