享元模式

享元(有时也称为token或cookie)是一种临时组件,可以看作是对某个对象的智能引用。通常,享元适用于拥有大量非常相似的对象情况,并且希望最小化存储这些对象的内存量。

让我们看一下与此模式相关的一些场景。

用户名字

想象一个大型多人在线游戏。我跟你赌20美元,不止一个叫John Smith的用户,因为这是一个流行的名字。因此,如果我们一遍又一遍地存储这个名称(以ASCII格式),我们将为每个这样的用户花费11个字节。相反,我们可以只存储一次名称,然后存储一个指向该名称的每个用户的指针(只有8个字节)。真是省了不少空间。

也许将名字分割成姓和名更有意义:这样,Fitzgerald Smith将由两个指针(16字节)表示,分别指向名和姓。事实上,如果我们使用索引而不是名称,我们可以大大减少字节数。你不会指望有2^64个不同的名字,对吗?

我们可以对它进行类型定义,以便稍后进行调整

  1. typedef uint32_t key;

根据这个定义,我们可以将用户定义为:

  1. struct User
  2. {
  3. User( const string& first_name, const string& last_name ):
  4. first_name{ add(first_name) },
  5. last_name{ add(last_name) }
  6. { }
  7. protected:
  8. key first_name, last_name;
  9. static bimap<key, string> names;
  10. static key seed;
  11. static key add (const string& s) { }
  12. };

如你所见,构造函数利用add()函数的返回值来初始化成员first_namelast_name。该函数根据需要将键-值对(键是从seed中生成的)插入到names中。我在这里使用boost::bimap(一个双向映射),因为它更容易搜索副本。记住,如果姓或名已经在bimap中,我们只返回它的索引。

下面是add()函数的实现:

  1. static key add( const string& s )
  2. {
  3. auto it = names.right.find( s );
  4. if( it == names.right.end() )
  5. {
  6. names.insert( {++seed, s} );
  7. return seed;
  8. }
  9. return it->second;
  10. }

这是get-or-add机制的标准执行。如果你以前没有接触过bimap,你可能想要查阅bimap的文档,了解更多关于它如何工作的信息。

如果我们想把姓和名给暴露出来(该成员对象位于保护段,类型是key,其实不是很有用!),我们可以提供适当的gettersetter

  1. const string& get_first_name() const
  2. {
  3. return name.left.find(first_name)->second;
  4. }
  5. const string& get_last_name const
  6. {
  7. return name.left.find(last_name)->second;
  8. }

例如,要定义用户的operator<<,你可以简单地编写

  1. friend ostream& operator<<(ostream& os, const User& obj)
  2. {
  3. return os << "first_name: " << obj.get_first_name()
  4. << " last_name: " << obj.get_last_name();
  5. }

就是这样的,我不打算提供节约了多少空间的统计数据(这个真的取决于你的样本大小),但是当有大量重复的用户名的时候,这样做的确显著的节约了空间。如果你还打算进一步节约空间,可以通过改变key的类型定义来进一步调整sizeof(key)的大小。

Boost.Flyweight

在前面的示例中,我们动手实现了一个享元,其实我们可以直接使用Boost库中提供的boost::flyweight,这使得User类的实现变得非常简单。

  1. struct User2
  2. {
  3. flyweight<string> first_name, last_name;
  4. User2(const string& first_name, const string& last_name) :
  5. first_name { first_name },
  6. last_name { last_name }
  7. { }
  8. };

你可以通过运行以下代码来验证它实际上是享元:

  1. User2 john_doe{ "John", "Doe" };
  2. User2 jane_doe{ "Jane", "Doe" };
  3. cout << boolalpha <<
  4. (&jane_doe.last_name.get() == &john_doe.last_name.get());
  5. // true

String Ranges

如果你调用std::string::substring(),是否会返回一个全新构造的字符串?最后的结论是:如果你想操纵它,那当然,但是如果你想改变字串来修改原始对象呢?一些编程语言(例如Swift、Rust)显式地将子字符串作为范围返回,这同样是享元模式的实现,它节省了所使用的内存量,此外还允许我们通过指定区间(range)来操作底层对象。

c++中与字符串区间操作等价的是string_view,对于数组来说还有其他的变体——只要避免复制数据就行!让我们试着构建我们自己的,非常简单的字符串区间操作(string range)。

让我们假设我们在一个类中存储了一堆文本,我们想获取该文本的某个区间的字符串并将其大写,有点像文字处理器或IDE可能做的事情。我们可以只大写每个单独的字母来实现,但是现在假设我们想保持底层的纯文本的原始状态,并且只在使用流输出操作符时才大写。

简单方法

一种非常简单明了的方法是定义一个布尔数组,它的大小与纯文本字符串相等,布尔数组中的值指示是否要大写该字符。我们可以这样实现它:

  1. class FormattedText
  2. {
  3. string plainText;
  4. bool *cap;
  5. public:
  6. explicit FormattedText( const string& plainText ) : plainText { plainText }
  7. {
  8. caps = new bool [ plainText.length() ];
  9. }
  10. ~FormattedText( )
  11. {
  12. delete [ ] caps;
  13. }
  14. };

我们现在可以用一个实用方法来大写一个特定的范围:

  1. void capitalize(int start, int end)
  2. {
  3. for( int i = start, i <= end; ++i )
  4. caps[ i ] = true;
  5. }

然后定义一个使用布尔掩码的流输出操作符:

  1. friend std::ostream& operator << (std::ostrem& os, const Formatted& obj)
  2. {
  3. string s;
  4. for( int i = 0; i < obj.plainText.length(); ++i)
  5. {
  6. char c = obj.plainText[ i ];
  7. s += ( obj.cap[ i ] ? toupper( c ) : c );
  8. }
  9. return os << s;
  10. }

不要误解我的意思,这种方法是有效的。在这里:

  1. FormattedText ft("This is a brave new world");
  2. ft.capitalize(10, 15);
  3. cout << ft << endl;
  4. // prints "This is a BRAVE new world"

但是,再一次地,将每个角色定义为拥有一个 布尔标记,当只使用开始和结束标记时。

享元实现

让我们利用享元模式来实现一个BetterFromattedText类。我们将定义一个外部类和一个嵌套类,在嵌套类中实现享元。

  1. class BetterFormattedText
  2. {
  3. public:
  4. struct TextRange
  5. {
  6. int start, end;
  7. bool capitalize;
  8. // other options here, e.g. bold, italic, etc.
  9. bool covers(int position) const
  10. {
  11. return position >= start && position <= end;
  12. }
  13. };
  14. private:
  15. string plain_text;
  16. vector<TextRange> formatting;
  17. };
  18. 如您所见,`TextRange`只存储它所应用的起始点和结束点,以及我们是否希望将文本大写的实际格式信息,以及任何其他格式选项(粗体、斜体等)。它只有一个成员函数`covers()`,用来确定是否需要将此格式应用于给定位置的字符。
  19. `BetterFormattedText`用一个`vector`来存储·TextRange`的享元,并能够根据需要构建新的享元:
  20. ```c++
  21. TextRange& get_range(int start, int end)
  22. {
  23. formatting.emplace_back(TextRange{ start, end });
  24. return *formatting.rbegin();
  25. }

上面的get_range()函数做了三件事:

  1. 构建一个新的TextRange对象
  2. 把构建的对象移动到vector
  3. 返回vector中最有一个元素的引用

在前面的实现中,我们没有检查重复的区间范围,如果是基于享元模式节约空间的精神的话也可以进一步加以改进。

现在我们来实现BetterFormattedText中的operator<<

  1. friend std::ostream& operator<<(std::ostream& os,
  2. const BetterFormattedText& obj)
  3. {
  4. string s;
  5. for (size_t i = 0; i < obj.plain_text.length(); i++)
  6. {
  7. auto c = obj.plain_text[i];
  8. for (const auto& rng : obj.formatting)
  9. {
  10. if (rng.covers(i) && rng.capitalize)
  11. c = toupper(c);
  12. }
  13. s += c;
  14. }
  15. return os << s;
  16. }

同样,我们所做的就是遍历每个字符并检查是否有覆盖它的范围。如果有,则应用范围指定的内容,在本例中就是大写。注意,这种设置允许范围自由重叠。

现在,我们可以使用之前构造的所有东西来将这个单词大写,尽管API稍有不同,但更灵活:

  1. BetterFormattedText bft("This is a brave new world");
  2. bft.get_range(10, 15).capitalize = true;
  3. cout << bft << endl;
  4. // prints "This is a BRAVE new world"

总结

享元模式是一种节约空间的技术。它存在许多确切的变体: 有时,享元作为API token返回,允许你执行修改;而在其他时候,享元是隐式的,隐藏在场景后面—就像我们的User一样,客户端并不打算知道实际使用的享元。