泛型<编程>:基于策略的basic_string实现
泛型<编程>:基于策略的basic_string实现
泛型<编程>:基于策略的basic_string实现
Andrei Alexandrescu
这个月的返型<编程>部分有两样新的东西。一样是本篇主题——我们会讨论实现标准库部件basic_string(更多作为 string为人所知,为了方便,string是一个basic_string<char>的typedef),一个C++库的重要部件。但 真正有趣的是本篇供下载的代码为了在Visual C++ 6.0下工作做了特别处理,VC6编译器有两个为人所知的矛盾特性——它被广泛使用和它对泛型编程支持极弱。
文章附带的代码不是实现一个,或者 两个,而是十二个basic_string的不同变化,每个都有各自的优缺点。它们不是玩具程序。我们这里谈论的是完整的,与标准兼容的,工业级强度的东 西(呃,当然还是有bug的)。你认为会有大量代码?再想想吧。相信我,本文将会非常有趣。
一种选择不能满足所有人
首先,为什么要有人去很麻烦地实现basic_string呢?你的标准模板库已经实现它了,那么拿出“另外一个”basic_string实现看上去只有教育价值了。
然而,许多在多线程程序里面用过字符串的人知道一个很难解决的问题。标准试图允许“写时拷贝(copy-on-write)”的basic_string 实现(写时拷贝被它的爱好者呢称为COW(母牛),而被反对者称为“疯牛”)。基于COW的字符串内部使用引用记数,在多线程环境下,这样的字符串要么不 能用,要么如果库支持多线程的话,慢的无法接受,即使用在你的单线程部分。二者必居其一。
COW字符串更多的问题出现在动态装载库的程序中——如果你释放一个库,有可能你的程序还保留有原来分配在库内存空间字符串的浅拷贝。
对COW字符串带来问题的广泛讨论使得大多数STL实现不采用COW,而对它们basic_string实现使用其他优化策略。然而,“大多数”不是“全 部”,所以程序里如果同时用到线程和basic_string,你必须使用非COW实现,后果是放弃你代码对STL其他部分的通用性。
此外,COW有它的优点而且对大量的应用程序非常有用。那么。是否你能够只用在某个应用中选择使用某个字符串的优化方案?“这里我需要非COW字符串并对小于16个字符的字符串执行小字符串优化”或者“这里我要用到COW的优点,而且我要在我自己的堆里分配字符串”。
怎样在200行代码里实现basic_string
放弃现有的多个有用的字符串实现而仅仅自己去开发一个是个艰巨的任务。写出可供具体实现去选择的所有的成员函数和类型定义已经不是个简单的工作。我知道这点是因为我做过写一个在需要使用COM字符串分配和多线程环境下的basic_string的接口。
当我小心翼翼地写好标准规定的所有功能性函数时,我注意到一个有趣的现象。大多数函数看上去都是围绕一小部分核心函数和类型——换句话说,你能够把 basic_string接口分解为“核心”和“附加功能”。附加功能部分不管你选择的实现策略如何都是相同的,但是核心部分在不同实现中区别极大。比如 说,replace系列函数包含很多功能函数,这些功能函数使用核心函数resize实现。
现在可以用到橡皮擦了,Basic_string的 附加功能部分也是最大的部分(在我的实现中有超过300行代码)。反之,写一个实现核心,即使是一个复杂的核心,是个相对简单的工作——在我实现里是75 到250行代码。这意味着你只需要简单地在扩展功能部分接口下配以不同的核心实现就可以能够创建一个新的实现。(实际上不完全是这样,因为你可以下载本篇 代码,这些代码也希望被使用)实际上,离你梦想中的basic_string实现还差200行代码!
一个基于策略的字符串
你们读过我的书[3]的人(啊呀,你讨厌广告)了解我们设计刚开始就在声嘶力竭叫喊的词:策略 (policies)!当然罗,如果你想要分离出一个类实现的特定方面并且让你的用户选择使用那个方面的某个实现,你把那个方面移至一个模板参数并为它定 义一个接口。这不是尖端科学,但它也极其有效。
标准的basic_string是这样的:
namespace std
{
template <class E,
class T = char_traits<E>,
class A = allocator<E> >
class basic_string;
}
E是字符串的字符类型(大多数情况下,不是char就是wchar_t),T控制字符串如何比较和拷贝,A是分配器(allocator),它正如 我们都知道的:爱它,但从不用它。我们要增加第四个模板参数,控制字符串的真正实现。因为它真正处理字符串的存储,我们叫它Storage策略
我们叫我们的新字符串flex_string,因为,你们很快就会看到,它是多么的灵活(flexible)
template <class E,
class T = char_traits<E>,
class A = allocator<E>,
class Storage = AllocatorStringStorage<E, A> >
class flex_string;
Storage的默认值是AllocatorStringStorage<E,A<,这是一个直观的存储实现,使用热情拷贝 (eager copy)(某种COW的对立面)。在其实现中,flex_string保存一个Storage对象并使用它的内部类型和成员函数。
你选择的Storage的接口可能会有些微区别。基本上,当我浏览了几遍我的basic_string实现后,我发现了如果没有一些函数我就不可能提供实现,而且这些函数也不会太多,这里是一个Storage策略实现必须满足的半正式的条件规格
template <typename E, 其他参数<
class StorageImpl
{
public:
typedef 某类型 size_type;
typedef 某类型 iterator;;
typedef 某类型 const_iterator;
typedef 某类型 allocator_type;
StorageImpl(const StorageImpl &);
StorageImpl(const allocator_type&);
StorageImpl(const E* s, size_type len, const allocator_type& a);
StorageImpl(size_type len, E, const allocator_type&);
iterator begin();
const_iterator begin() const;
iterator end();
const_iterator end() const;
size_type size() const;
size_type max_size() const;
size_type capacity() const;
void resize(size_type, E);
void reserve(size_type);
void swap(StorageImpl&);
const E* c_str() const;
const E* data() const;
allocator_type get_allocator() const;
};
这些足够了。规范很简单(而且如果没有allocator这个还会更简单)。这里的想法是你能够最终借助Storage中很少的核心类型和函数有效地实现basic_string的整个接口。
Flex_string类保存一个Storage对象的值。我为了一些小小的方便选择私有继承。这样,供下载代码中的flex_string就象这样。
template <class E,
class T = std::char_traits<E>,
class A = std::allocator<E>,
class Storage = AllocatorStringStorage<E, A> >
class flex_string : private Storage
{
public:
typedef typename Storage::iterator iterator;
typedef typename Storage::const_iterator const_iterator;
//21.3.1 构造/拷贝/析构
explicit flex_string(const A& a = A())
: Storage(a)
{}
.. ..
};
实现Storage策略
现在开始干脏活了。我们来完成Storage的实现。一个高效的字符串实现会保存指向缓存的指针。接下来缓存包含 字符串长度和容积,再加字符串本身。为了避免两次分配内存(一次为了记录控制数据一次为了数据)你可能会使用一个叫“the struct hack”的技巧,缓存中包含一个C风格字符数组作为它最后一个成员,并且当需要许多字符时会动态增长。这就是SimpleStringStorage所 做的
template <class E, class A = std::allocator<E> >
class SimpleStringStorage
{
struct Data
{
E* pEnd_;
E* pEndofMem_;
E buffer_[1];
};
Data* pData_;
public:
size_type size() const
{ return pData_->pEnd_ - pData_->biffer_; }
size_type capacity() const
{ return pData_->pEndOfMem_ - pData_->buffer_; }
.. ..
};
pEnd_指向字符串末尾,pEndOfMem_指向已分配缓存的末尾,buffer_大小扩展为能容纳字符串中所有字符——换句话说, buffer_在Data的内存范围外继续存在。为了取得这样的灵活性,pData_不是真的指向一个Data对象,而是指向一个转换为Data的大块内 存。这个“struct hack”技巧理论上不是百分之百通用,但实际上是这样。
SimpleStringStorage有另一个小小的特别优化——所有空字符串被一个静态Data实例所共享。另一个实现可能会对空字符串把pData_初始化为空,但那样会在许多成员函数里需要测试是否为空。
SimpleStringStorage 是“简单的”是因为它没有用传入的分配器。SimpleStringStorage需要内存时只是使用标准的自由存储区(new/delete)。使用传 入的分配器来分配Data对象比想象中要难,这部分是因为分配器的设计问题(不支持任意大小的对象),部分是因为编译器的兼容性问题。你可以在作为 Storage策略的正确实现的AllocatorStringStorage模板类中发现这种做法。
还有种可能的字符串存储实现是简单地使用std::vector作为后端。这种实现方法非常迅速有力,你得到的是一个复用了设计完备的标准库库工具的简单字符串。这也对减少代码量有帮助。你可以在VectorStringStorage中看到这种实现。
所有这三种实现在任何可能的地方都自然而然地用到了EBO(空基类优化Empty Base Optimization)[4]。(我是不是说过“工业级强度”这个时髦词?)使用EBO非常有效率,因为大多数分配器都是空类。
可爱的C++
好吧,我们手中握有三个很棒的basic_string实现,每个实现大概有433行代码,这样我们已经有了1300行代码。还不坏,尤其是当你想到你能很容易地增加新的实现。
如果你觉得这很有趣,本文至此已经达到了目的。但别忘了开头一节告诉你会有大量乐趣,而现在只是开始。
我们看看SSO(小字符串优化)[5]。SSO背后的想法是在字符串对象中直接存放小字符串(而不是在动态分配内存中)。当大小超过string能容纳范 围时,使用一个动态分配的策略。这两种策略共享字符串对象内的内存来存放记录数据。String类能够通过某种标记来区分这两种机制。
template <class E, other parameters>
class sso_string
{
struct DynamicData { .. ..};
static const unsigned int maxSmallStringLen = 12;
union
{
E[maxSmallStringLen] inlineBuffer_;
DynamicData data_;
};
bool isSmall_;
...
};
如果isSmall_为真,字符串直接存储在inlineBuffer_,否则data_有效。问题是DynamicData用何种动态分配机 制?一个std::vector?一个SimpleStringStorage?一个AllocatorStringStorage?回答当然是“请给我 上述所有而且还要多”
非常明显,使用SSO和你选择使用的任何存储机制正交。这样,SmallStringOpt模板类有另一个模板参数作为存储机制。
Template <class E, unsigned int threshold, class Storage, typename Align = E*>
Class SmallStringOpt
{
enum { temp = threshold > sizeof(Storage) ? threshold : sizeof(Storage) };
public:
enum { maxSmallString = temp > sizeof(Align) ? temp : sizeof(Align) ];
private
union
{
E buf[maxSmallString + 1];
Align align_;
};
.. ..执行存储策略.. ..
};
buf_成员变量存放一个Storage对象或者是字符串本身。但Align有什么用呢?当处理类似于“预分配(seated allocation)”的情况时,你必须非常小心地处理对齐问题。因为没有通用的方法来算出Storage对齐的需要,SmallStringOpt接 受一个指定对齐的类型,并存放在虚设的align_变量中。
SmallStringOpt怎样区分大小字符串?当为小字符串时,buf_的最后 一个元素(就是buf_[maxSmallString])存放maxSmallString减去字符串实际长度后的结果,当为长字符串时存放一个常量。 对一个长度为maxSmallString的字符串,buf_[maxSmallString]为零,这样很好地充当了null终止符和标志这两个角色。
你能在SmallStringOpt中看到一些技巧,转换和低层次的东西。(我们这里正在讲优化,不是吗?)但结果是惊人的。我们能把 SmallStringOpt和任何其他Storage实现结合起来,当然包括SimpleStringStorage, VectorStringStorage和AllocatorStringStorage。那么现在我们有六个basic_string实现——我们多一 点努力(顺便提一下,也非常有趣不是吗?)就有成倍的回报。现在代码有1440行长,所以得到每个basic_string实现有240行。如果说C++ 编程是空手道,那么有效地同时使用你所写代码的多个产品就会象一次同时对付多个敌人。
这是一个例子——实例化。
typedef flex_string<
char,
std::char_traits<char>,
std::allocator<char>,
SmallStringOpt<char, 16, VectorStringStorage<char, std::allocator<char> > >
> String;
这指定了一个字符串,使用基于std::vector的存储和对16个字符以下的小字符串采取优化
回到COW
不管你是否喜欢,你不能忽略COW——太多人觉得它有用。为了这些人,我们来实现一个CowString模板类,这个模板类也能把COW特性加到其他Storage中去。CowString是这样的:
template <class E, class Storage>
class CowString
P
Struct Data
{
Storage s_;
Unsigned int refs_;
};
Data* pData_;
Public:
.. ..
};
Data保存任何你选定的Storage加上一个引用计数器。CowString本身只包含一个Data指针,多个CowString可能会指向同一个Data对象。当Data可能发生改变时,CowString制造一个它data的真正复制。
现在来看看这个:
typedef flex_string<
char,
std::char_traits<char>,
std_allocator<char>,
SmallStringOpt<char, 5,
CowString<char, AllocatorStringStorage<char, std::allocator<char> > > >
> String
现在我们得到一个字符串,它对小于五字节字符串不采用动态分配。对长字符串,使用COW策略,而这个COW策略又是建立在基于分配器的实现之上。
CowString 再次使flex_string的可能实例加倍,所以我们现在有十二个实现供我们支配,代码总数增加到1860行,或者说每个实现155行。实际上如果你考 虑到使用时可以互换SmallStringOpt和CowString的次序,那么有二十四个实现。但是,小字符串使用COW不是一个有效率的设计决策, 所以我们总是在SmallStringOpt中使用CowString而不是相反。
总结
basic_string是一个非常复杂的组件。但是,仔细地采用基于策略的设计能够把你的生产率提高到最高。通过使用有限几个策略实现,你能够选择是直接的,还是小字符串优化的,还是引用记数的basic_string。这一切只需对模板类传入几个参数就可完成。
参考书目
[1] Herb Sutter. "Optimizations that Aren't (In a Multithreaded World)," C/C++ Users Journal, June 1999.
[2] Kevlin Henney. "From Mechanism to Method: Distinctly Qualified," C/C++ Users Journal C++ Experts Forum, May 2001, http://www.cuj.com/experts/1905/henney.htm.
[3] Andrei Alexandrescu. Modern C++ Design (Addison-Wesley, 2001).
[4] Andrei Alexandrescu. "Traits on Steroids," C++ Report, June 2000, http://ftp.sj.univali.br/prof/Fernando%20Montenegro/artigos/GenericProgramingCPP02.htm.
[5] Jack Reeves. "String in the Real World - Part 2," C++ Report, January 1999, http://www.bleading-edge.com/Publications/C++Report/v9901/Column14.htm.
Andrei Alexandrescu 是位于西雅图的华盛顿大学的博士生,也是受到好评的《Modern C++ Design》一书的作者。可以通过www.moderncppdesign.com. 来联系他。Andrei同时也是C++研讨会 (<www.gotw.ca/cpp_seminar>).的一名有号召力的讲师。
你可以从CUJ网站和http://merced.go.nease.net/code/alexandr.zip获得本文源代码。