C++设计模式11.享元模式
### 享元模式享元(有时也称为token或cookie)是一种临时组件,可以看作是对某个对象的智能引用。通常,享元适用于拥有大量非常相似的对象情况,并且希望最小化存储这些对象的内存量。
让我们看一下与此模式相关的一些场景。
#### 用户名字
想象一个大型多人在线游戏。我跟你赌20美元,不止一个叫`John Smith`的用户,因为这是一个流行的名字。因此,如果我们一遍又一遍地存储这个名称(以ASCII格式),我们将为每个这样的用户花费11个字节。相反,我们可以只存储一次名称,然后存储一个指向该名称的每个用户的指针(只有8个字节)。真是省了不少空间。
也许将名字分割成姓和名更有意义:这样,`Fitzgerald Smith`将由两个指针(16字节)表示,分别指向名和姓。事实上,如果我们使用索引而不是名称,我们可以大大减少字节数。你不会指望有2^64个不同的名字,对吗?
我们可以对它进行类型定义,以便稍后进行调整
```c++
typedef uint32_t key;
```
根据这个定义,我们可以将用户定义为:
```c++
struct User
{
User( const string& first_name, const string& last_name ):
first_name{ add(first_name) },
last_name{ add(last_name) }
{ }
protected:
key first_name, last_name;
static bimap<key, string> names;
static key seed;
static key add (const string& s) { }
};
```
如你所见,构造函数利用`add()`函数的返回值来初始化成员`first_name`和`last_name`。该函数根据需要将键-值对(键是从`seed`中生成的)插入到`names`中。我在这里使用`boost::bimap`(一个双向映射),因为它更容易搜索副本。记住,如果姓或名已经在bimap中,我们只返回它的索引。
下面是`add()`函数的实现:
```c++
static key 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`机制的标准执行。如果你以前没有接触过`bimap`,你可能想要查阅`bimap`的文档,了解更多关于它如何工作的信息。
如果我们想把姓和名给暴露出来(该成员对象位于保护段,类型是`key`,其实不是很有用!),我们可以提供适当的`getter`和`setter`。
```c++
const string& get_first_name() const
{
return name.left.find(first_name)->second;
}
const string& get_last_name const
{
return name.left.find(last_name)->second;
}
```
例如,要定义用户的`operator<<`,你可以简单地编写
```c++
friend ostream& operator<<(ostream& os, const User& obj)
{
return os << "first_name: " << obj.get_first_name()
<< " last_name: " << obj.get_last_name();
}
```
就是这样的,我不打算提供节约了多少空间的统计数据(这个真的取决于你的样本大小),但是当有大量重复的用户名的时候,这样做的确显著的节约了空间。如果你还打算进一步节约空间,可以通过改变`key`的类型定义来进一步调整`sizeof(key)`的大小。
#### Boost.Flyweight
在前面的示例中,我们动手实现了一个享元,其实我们可以直接使用Boost库中提供的`boost::flyweight`,这使得`User`类的实现变得非常简单。
```c++
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 }
{ }
};
```
你可以通过运行以下代码来验证它实际上是享元:
```c++
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`)。
让我们假设我们在一个类中存储了一堆文本,我们想获取该文本的某个区间的字符串并将其大写,有点像文字处理器或IDE可能做的事情。我们可以只大写每个单独的字母来实现,但是现在假设我们想保持底层的纯文本的原始状态,并且只在使用流输出操作符时才大写。
#### 简单方法
一种非常简单明了的方法是定义一个布尔数组,它的大小与纯文本字符串相等,布尔数组中的值指示是否要大写该字符。我们可以这样实现它:
```c++
class FormattedText
{
string plainText;
bool *cap;
public:
explicitFormattedText( const string& plainText ) : plainText { plainText }
{
caps = new bool [ plainText.length() ];
}
~FormattedText( )
{
delete [ ] caps;
}
};
```
我们现在可以用一个实用方法来大写一个特定的范围:
```c++
void capitalize(int start, int end)
{
for( int i = start, i <= end; ++i )
caps[ i ] = true;
}
```
然后定义一个使用布尔掩码的流输出操作符:
```c++
friend std::ostream& operator << (std::ostrem& os, const Formatted& obj)
{
string s;
for( int i = 0; i < obj.plainText.length(); ++i)
{
char c = obj.plainText[ i ];
s += ( obj.cap[ i ] ? toupper( c ) : c );
}
return os << s;
}
```
不要误解我的意思,这种方法是有效的。在这里:
```c++
FormattedText ft("This is a brave new world");
ft.capitalize(10, 15);
cout << ft << endl;
// prints "This is a BRAVE new world"
```
但是,再一次地,将每个角色定义为拥有一个
布尔标记,当只使用开始和结束标记时。
#### 享元实现
让我们利用享元模式来实现一个`BetterFromattedText`类。我们将定义一个外部类和一个嵌套类,在嵌套类中实现享元。
```c++
class BetterFormattedText
{
public:
struct TextRange
{
int start, end;
bool capitalize;
// other options here, e.g. bold, italic, etc.
bool covers(int position) const
{
return position >= start && position <= end;
}
};
private:
string plain_text;
vector<TextRange> formatting;
};
如您所见,`TextRange`只存储它所应用的起始点和结束点,以及我们是否希望将文本大写的实际格式信息,以及任何其他格式选项(粗体、斜体等)。它只有一个成员函数`covers()`,用来确定是否需要将此格式应用于给定位置的字符。
`BetterFormattedText`用一个`vector`来存储·TextRange`的享元,并能够根据需要构建新的享元:
```c++
TextRange& get_range(int start, int end)
{
formatting.emplace_back(TextRange{ start, end });
return *formatting.rbegin();
}
```
上面的`get_range()`函数做了三件事:
1. 构建一个新的`TextRange`对象
2. 把构建的对象移动到`vector`中
3. 返回`vector`中最有一个元素的引用
在前面的实现中,我们没有检查重复的区间范围,如果是基于享元模式节约空间的精神的话也可以进一步加以改进。
现在我们来实现`BetterFormattedText`中的`operator<<`:
```c++
friend std::ostream& operator<<(std::ostream& os,
const BetterFormattedText& obj)
{
string s;
for (size_t i = 0; i < obj.plain_text.length(); i++)
{
auto c = obj.plain_text;
for (const auto& rng : obj.formatting)
{
if (rng.covers(i) && rng.capitalize)
c = toupper(c);
}
s += c;
}
return os << s;
}
```
同样,我们所做的就是遍历每个字符并检查是否有覆盖它的范围。如果有,则应用范围指定的内容,在本例中就是大写。注意,这种设置允许范围自由重叠。
现在,我们可以使用之前构造的所有东西来将这个单词大写,尽管API稍有不同,但更灵活:
```c++
BetterFormattedText bft("This is a brave new world");
bft.get_range(10, 15).capitalize = true;
cout << bft << endl;
// prints "This is a BRAVE new world"
```
#### 总结
享元模式是一种节约空间的技术。它存在许多确切的变体:
有时,享元作为API `token`返回,允许你执行修改;而在其他时候,享元是隐式的,隐藏在场景后面—就像我们的`User`一样,客户端并不打算知道实际使用的享元。
页:
[1]