第19章 特殊运算符(Special Operators)
目录
19.2.4 递增和递减(Increment and Decrement)
19.2.5 分配和释放内存 (Allocation and Deallocation)
19.2.6 用户定义文字量 (User-defined Literals)
19.3.1 基本操作(Essential Operations)
19.3.2 访问字符(Access to Characters)
19.3.3.1 协助函数(Ancillary Functions)
19.3.6 使用String(Using Our String)
19.4.2 友函数与成员函数(Friends and Members)
19.1 引言
重载不仅仅适用于算术和逻辑运算。事实上,运算符在容器(例如,vector和map;第 4.4 节)、“智能指针”(例如,unique_ptr 和share_ptr;第 5.2.1 节)、迭代器(第 4.5 节)和其他关注资源管理的类的设计中是至关重要的。
19.2 特殊运算符(Special Operators)
运算符
[] () −> ++ −− new delete
的特殊之处仅在于,从它们在代码中的使用到程序员的定义的映射与传统的一元和二元运算符(例如 +、< 和 〜)所使用的映射略有不同(第 18.2.3 节)。 [](下标)和 ()(调用)运算符是最有用的用户定义运算符。
19.2.1 下标运算符(Subscripting)
Operator[] 函数可用于为类对象赋予下标含义。 Operator[] 函数的第二个参数(下标)可以是任何类型。这使得定义vector、关联数组等成为可能。
例如,我们可以定义一个简单的关联数组类型,如下所示:
struct Assoc {
vector<pair<string,int>> vec; // {name,value} vector 对
const int& operator[] (const string&) const;
int& operator[](const string&);
};
Assoc 保留一个 std::pairs 向量。该实现使用与第 7.7 节中相同的简单但低效的搜索方法:
int& Assoc::operator[](const string& s)
// 检索 s; 若找到则返回一个指向其值的引用;
// 否则, 构建一个新的 {s,0} 对并返回其值的引用
{
for (auto x : vec)
if (s == x.first) return x.second;
vec.push_back({s,0}); // 初始化: 0
return vec.back().second; // 返回最后一个元素 (§31.2.2)
}
我们可以这样使用Assoc:
int main() // 统计输入流中每一个词出现的次数
{
Assoc values;
string buf;
while (cin>>buf) ++values[buf];
for (auto x : values.vec)
cout << '{' << x.first << ',' << x.second << "}\n";
}
Operator[]() 必须是非静态成员函数。
19.2.2 函数调用运算符(Function Call)
函数调用,即表达式(expression)(表达式列表)(expression-list),可以解释为以表达式为左操作数、以表达式列表为右操作数的二元运算。调用运算符 () 可以像其他运算符一样重载。例如:
struct Action {
int operator()(int);
pair<int,int> operator()(int,int);
double operator()(double);
// ...
};
void f(Action act)
{
int x = act(2);
auto y = act(3,4);
double z = act(2.3);
// ...
};
根据通常的参数传递规则评估和检查operator()()(译注:第一个()是函数调用运算符,第二个()是参数列表)的参数列表。重载函数调用运算符似乎主要用于定义仅具有单一操作的类型以及以一种操作为主的类型。调用运算符也称为应用程序运算符(application operator)。
() 运算符最明显也是最重要的用途是——为在某种程度上表现得像函数的对象提供常用的函数调用语法。行为类似于函数的对象通常称为似函数对象(function-like object)或简称为函数对象(function object) (第 3.4.3 节)。这样的函数对象允许我们编写以非平凡操作作为参数的代码。在许多情况下,函数对象必须能够保存执行其操作所需的数据。例如,我们可以定义一个带有operator()()的类,它将存储的值添加到其参数中:
class Add {
complex val;
public:
Add(complex c) :val{c} { } // 保存值
Add(double r, double i) :val{r,i} { }
void operator()(complex& c) const { c += val; } // 添加一个值到参数
};
Add 类的对象使用复数进行初始化,当使用 () 调用时,它将把该数添加到其参数中。例如:
void h(vector<complex>& vec, list<complex>& lst, complex z)
{
for_each(vec.begin(),vec.end(),Add{2,3});
for_each(lst.begin(),lst.end(),Add{z});
}
这会将complex{2,3}添加到vector的每个元素,并将z添加到list的每个元素。请注意,Add{z} 构造了一个由 for_each() 重复使用的对象:为序列的每个元素调用 Add{z} 的operator()()。
这一切都有效,因为 for_each 是一个模板,它将 () 应用于其第三个参数,而不关心第三个参数到底是什么:
template<typename Iter, typename Fct>
Fct for_each(Iter b, Iter e, Fct f)
{
while (b != e) f(∗b++);
return f;
}
乍一看,这种技术可能看起来深奥,但它简单、高效并且非常实用(§3.4.3,§33.4)。
请注意,lambda 表达式(第 3.4.3 节,第 11.4 节)基本上是定义函数对象的语法。例如,我们可以这样写:
void h2(vector<complex>& vec, list<complex>& lst, complex z)
{
for_each(vec.begin(),vec.end(),[](complex& a){ a+={2,3}; });
for_each(lst.begin(),lst.end(),[](complex& a){ a+=z; });
}
在这种情况下,每个 lambda 表达式都会生成函数对象 Add 的等效项。
operator()() 的其他流行用途是用作子字符串运算符和多维数组的下标运算符(第 29.2.2 节,第 40.5.2 节)。
operator()()必须是非static成员函数。
函数调用运算符通常是模板(第 29.2.2 节,第 33.5.3 节)。
19.2.3 解引用(Dereferencing)
解引用运算符 ->(也称为箭头运算符)可以定义为一元后缀运算符。例如:
class Ptr {
// ...
X∗ operator−>();
};
类 Ptr 的对象可用于访问类 X 的成员,其方式与使用指针的方式非常相似。例如:
void f(Ptr p)
{
p−>m = 7; // (p.operator->())->m = 7
}
对象 p 到指针 p.operator−>() 的转换不依赖于 m 所指向的成员。这就是operator−>() 是一元后缀运算符的意义。然而,没有引入新的语法,所以 -> 之后仍然需要成员名称。例如:
void g(Ptr p)
{
X∗ q1 = p−>; // 语法错误
X∗ q2 = p.operator−>(); // OK
}
重载 -> 主要用于创建“智能指针”,即行为类似于指针的对象,并且每当通过它们访问对象时都会执行一些操作。标准库“智能指针”unique_ptr和shared_ptr(第5.2.1节)提供运算符 ->。
例如,我们可以定义一个类 Disk_ptr 来访问存储在磁盘上的对象。 Disk_ptr 的构造函数采用一个可用于在磁盘上查找对象的名称,Disk_ptr::operator−>() 在通过 Disk_ptr 访问时将对象带入主内存,Disk_ptr 的析构函数最终将更新后的对象写回磁盘:
template<typename T>
class Disk_ptr {
string identifier;
T∗ in_core_address;
// ...
public:
Disk_ptr(const string& s) : identifier{s}, in_core_address{nullptr} { }
˜Disk_ptr() { write_to_disk(in_core_address,identifier); }
T∗ operator−>()
{
if (in_core_address == nullptr)
in_core_address = read_from_disk(identifier);
return in_core_address;
}
};
Disk_ptr 可能这样使用:
struct Rec {
string name;
// ...
};
void update(const string& s)
{
Disk_ptr<Rec> p {s}; // 获得s 的Disk_ptr
p−>name = "Roscoe"; // 更新s; 若有必要, 首先从磁盘取回
// ...
} // p的析构回写到磁盘
当然,现实的程序将包含错误处理代码并使用不太幼稚的方式与磁盘交互。
对于普通指针,−> 的使用与一元 * 和 [] 的某些使用同义。给定一个类 Y,其中 ->、* 和 [] 具有默认含义,并且p为 Y* 类型,则:
p−>m == (∗p).m //成立
(∗p).m == p[0].m //成立
p−>m == p[0].m //成立
与往常一样,不为用户定义的运算符提供此类保证。可以在需要时提供等效项:
template<typename T>
class Ptr {
Y∗ p;
public:
Y∗ operator−>() { return p; } // 解引用访问成员
Y& operator∗() { return ∗p; } // 解引用访问成员
Y& operator[](int i) { return p[i]; } // 解引用访问对象
// ...
};
如果您提供多个这些运算符,则最好提供等价性,就像明智的做法是确保 ++x 和 x+=1 与对于某个类X的简单变量 x 的 x=x+1 具有相同的效果(如果提供了 ++、+=、= 和 +)。
-> 的重载对于一类有趣的程序来说很重要,而不仅仅是一个小小的好奇心。原因是间接是一个关键概念,并且重载 -> 提供了一种干净、直接且有效的方式来表示程序中的间接。迭代器(第 33 章)就是这样一个重要的例子。
运算符 -> 必须是非static成员函数。如果使用,其返回类型必须是指针或可以应用的类的对象 ->。模板类成员函数的主体仅在使用该函数时才进行检查(第 26.2.1 节),因此我们可以定义operator−>() 而不必担心类型,例如 Ptr<int>,其中 −> 不考虑类型有意义。
尽管 -> 和 • (点)之间有相似之处,但没有重载运算符 •(点) 的方式。
19.2.4 递增和递减(Increment and Decrement)
一旦人们发明了“智能指针”,他们通常会决定提供递增运算符 ++ 和递减运算符 -- 以反映这些运算符对内置类型的使用。当目标是用使用具有相同语义的“智能指针”类型替换普通指针类型时,这个特别明显且必要,只是它添加了一些运行时错误检查。例如,考虑一个麻烦的传统程序:
void f1(X a) //传统用法
{
X v[200];
X∗ p = &v[0];
p−−;
∗p = a; // oops: p 越界, 未捕捉
++p;
∗p = a; // OK
}
在这里,我们可能希望将 X* 替换为 Ptr<X> 类的对象,只有当它实际上指向数组中的对象并且递增和递减操作生成该数组中的对象时,才可以解引用该对象。也就是说,我们想要这样的东西:
void f2(Ptr<X> a) // checked
{
X v[200];
Ptr<X> p(&v[0],v);
p−−;
∗p = a; // 运时错误: p 越界
++p;
∗p = a; // OK
}
递增和递减运算符在 C++ 运算符中是唯一的,因为它们可以作为前缀和后缀运算符使用。因此,我们必须为Ptr<T>定义前缀和后缀递增和递减。例如:
template<typename T>
class Ptr {
T∗ ptr;
T∗ array;
int sz;
public:
template<int N>
Ptr(T∗ p, T(&a)[N]); // 绑定到数组 a, sz==N, 初始化 p
Ptr(T∗ p, T∗ a, int s); // 绑定到一个大小为s的数组 a, 初始值p
Ptr(T∗p); //绑定到单个对象, sz==0, 初始值p
Ptr& operator++(); // prefix
Ptr operator++(int); // postfix
Ptr& operator−−(); // prefix
Ptr operator−−(int); // postfix
T& operator∗(); //prefix
};
int 参数用于指示要为 ++ 的后缀应用调用该函数。这个 int 从未使用过;该参数只是一个用于区分应用前缀和应用后缀的伪值(dummy)。记住哪个版本的operator ++ 是前缀的方法是注意没有伪参数(dummy argument)的版本是前缀,就像所有其他一元算术和逻辑运算符一样。伪参数仅用于“奇数”后缀 ++ 和 −−。
考虑在设计中省略后缀 ++ 和 −−。它们不仅在语法上很奇怪,而且往往比后缀版本更难实现,效率较低,而且使用频率较低。例如:
template<typename T>
Ptr& Ptr<T>::operator++() // 在递增后返回当前对象
{
// ... 验证 ptr+1 可用于指向...
return ∗++ptr;
}
template<typename T>
Ptr Ptr<T>::operator++(int) // 递增并返回一个具有旧值的Ptr
{
// ... 验证 ptr+1 可用于指向...
Ptr<T> old {ptr,array,sz};
++ptr;
return old;
}
前自增运算符可以返回对其对象的引用。后自增运算符必须返回一个新对象。
使用 Ptr,该示例相当于:
void f3(T a) // checked
{
T v[200];
Ptr<T> p(&v[0],v,200);
p.operator−−(0); //后缀: p--
p.operator∗() = a; // 运行时错误: p 越界
p.operator++(); // 前缀: ++p
p.operator∗() = a; // OK
}
完成 Ptr 课程作为练习。第 27.2.2 节中介绍了在继承方面表现正确的指针模板。
19.2.5 分配和释放内存 (Allocation and Deallocation)
运算符 new (§11.2.3) 通过调用operator new() 获取其内存。同样,运算符 delete 通过调用operator delete() 释放其内存。用户可以重新定义全局operator new() 和operator delete(),也可以为特定类定义operator new() 和operator delete()。
使用标准库类型别名 size_t (§6.2.8) 作为大小,全局版本的声明如下所示:
void∗ operator new(size_t); // 用于单一对象
void∗ operator new[](size_t); // 用于数组
void operator delete(void∗, size_t); // 用于单一对象
void operator delete[](void∗, siz e_t); // 用于数组
// 更多版本, 见§11.2.4
也就是说,当 new 需要自由存储中的内存来存储类型 X 的对象时,它会调用运算符 new(siz eof(X))。同样,当 new 需要自由存储中的内存来存储类型 X 的 N 个对象的数组时,它会调用运算符 new[](N∗sizeof(X))。new 表达式可能会请求比 N∗sizeof(X) 指示的更多的内存,但它总是会以字符数(即字节数)来请求。替换全局operator new() 和operator delete () 并不适合胆小的人,也不建议这样做。毕竟,其他人可能会依赖默认行为的某些方面,甚至可能已经提供了这些函数的其他版本。
更有选择性且通常更好的方法是为特定类提供这些操作。此类可能是许多派生类的基础。例如,我们可能希望 Employee 类为其自身及其所有派生类提供专门的分配器和释放器:
class Employee {
public:
// ...
void∗ operator new(siz e_t);
void operator delete(void∗, siz e_t);
void∗ operator new[](siz e_t);
void operator delete[](void∗, siz e_t);
};
成员运算符 operator new() 和运算符 operator delete() 是隐式静态成员。因此,它们没有 this 指针,也不会修改对象。它们提供构造函数可以初始化、析构函数可以清理的存储空间。
void∗ Employee::operator new(size_t s)
{
//分配s字节内存并返回指向内存地址的指针
}
void Employee::operator delete(void∗ p, size_t s)
{
if (p) { // 仅当 p!=0 时删除; 见§11.2, §11.2.3
// 假设p指向由Employee::operator new()分配的s字节内存地址
// 释放内存以重用
}
}
编译器如何知道为 operator delete()提供正确的大小?delete操作中指定的类型与要delete的对象的类型相匹配。如果我们通过指向基类的指针delete对象,则该基类必须具有virtual析构函数(§17.2.5)才能给出正确的大小:
Employee∗ p = new Manager; // 潜在麻烦 (缺失准确类型)
// ...
delete p; // 期望 Employee 有一个virtual 析构函数
原则上,释放是由析构函数(它知道其类的大小)完成的。
19.2.6 用户定义文字量 (User-defined Literals)
C++ 为多种内置类型提供了文字量(§6.2.6)(译注:即具体类型的字符呈现形式,在编程的时候的输入格式):
123 // int
1.2 // double
1.2F // float
'a' // char
1ULL // unsigned long long
0xD0 // hexadecimal unsigned
"as" // C-style string (const char[3])
此外,我们可以为用户定义类型定义文字量,为内置类型定义新形式的文字量。例如:
"Hi!"s //string, 不是“不是以0结尾的char数组”
1.2i //虚数
101010111000101b //二进制
123s //秒
123.56km //不是英里! (单位)
1234567890123456789012345678901234567890x //扩展精度
此类用户定义文字量通过文字量运算符的概念得到支持,该运算符将具有给定后缀的文字量映射到所需类型。文字量运算符的名称是operator “”,后接后缀。例如:
constexpr complex<double> operator"" i(long double d) // 虚数文字量
{
return {0,d}; // complex 是一个文字量类型
}
std::string operator"" s(const char∗ p, size_t n) // std::string 文字量
{
return string{p,n}; // 要求分配自由存储
}
这两个运算符分别定义后缀 i 和 s。我使用 constexpr 来启用编译时求值。有了这些,我们可以写出:
template<typename T> void f(const T&);
void g()
{
f("Hello"); // 传指针给 char*
f("Hello"s); // 传(5个字符)的string对象
f("Hello\n"s); // 传(6个字符的) string对象
auto z = 2+1i; // complex{2,1}
}
基本(实现)思想是,在解析完可能是文字量的内容后,编译器始终会检查后缀。用户定义文字量机制仅允许用户指定新后缀并定义如何处理它之前的文字量。无法重新定义内置文字量后缀的含义或扩充文字量的语法。
有四种类型的文字量可以添加后缀来构成用户定义文字量(§iso.2.14.8):
• 整型文字量 (§6.2.4.1):由采用 unsigned long long 或 const char∗ 参数的文字量运算符或模板文字量运算符接受,例如 123m 或 12345678901234567890X
• 浮点文字量 (§6.2.5.1):由采用 long double 或 const char∗ 参数的文字量运算符或模板文字量运算符接受,例如 12345678901234567890.976543210x 或 3.99s
• 字符串文字量 (§7.3.2):由采用 (const char∗, size_t) 参数对的文字量运算符接受,例如 "string"s 和 R"(Foo\bar)"_path
• 字符文字量 (§6.2.3.2):由采用 char、wchar_t、char16_t 或 char32_t 类型的字符参数的字符量运算符接受,例如 'f'_runic 或 u'BEEF'_w。
例如,我们可以定义一个文字量运算符来收集无法用任何内置整数类型表示的整数值的数字:
Bignum operator"" x(const char∗ p)
{
return Bignum(p);
}
void f(Bignum);
f(123456789012345678901234567890123456789012345x);
这里,将 C 风格字符串“123456789012345678901234567890123456789012345”传递给运算符“”x()。请注意,我没有将这些数字放在双引号中。我为运算符请求了一个 C 风格字符串,编译器根据提供的数字传递了它。
要将程序源文本中的 C 风格字符串放入文字量运算符,我们需要同时请求字符串及其字符数。例如:
string operator"" s(const char∗ p, size_t n);
string s12 = "one two"s; // calls operator ""("one two",7)
string s22 = "two\ntwo"s; // calls operator ""("two\ntwo",7)
string sxx = R"(two\ntwo)"s; // calls operator ""("two\\ntwo",8)
在原字符串(§7.3.2.1)中,“\n”代表两个字符 ’\’ 和 ’n’。
要求知道字符数的理由是,如果我们想要“一种不同类型的字符串”,我们几乎总是想知道字符数。
仅接受 const char∗ 参数(不接受大小)的文字量运算符可应用于整数和浮点文字量。例如:
string operator"" SS(const char∗ p); //警告: 这样不能如预期那样生效
string s12 = "one two"SS; // 错: 没有适用的文字量运算符
string s13 = 13SS; // OK, 但为什么有人会这么做呢?
将数值转换为字符串的文字量运算符可能会相当令人困惑。
模板文字量运算符是将其参数作为模板参数包而不是作为函数参数的文字量运算符。例如:
template<char...>
constexpr int operator"" _b3(); // 基为 3, 即, 三进制
由此我们得到:
201_b3 //指operator"" b3<’2’,’0’,’1’>(); 因此 9*2+0*3+1 == 19
241_b3 //指operator"" b3<’2’,’4’,’1’>(); 因此 错误: 4 不是三进制数
可变参数模板技术(§28.6)可能令人不安,但它是在编译时为数字分配非标准含义的唯一方法。
为了定义operator “”_b3(),我们需要一些辅助函数:
constexpr int ipow(int x, int n) // 对于 n>=0 的x的n次幂
{
return (n>0) ? x∗ipow(n−1) : 1;
}
template<char c> // 处理单个三进制数的情况
constexpr int b3_helper()
{
static_assert(c<'3',"not a ternary digit");
return c;
}
template<char c, char... tail> //剥离一个三进制数字
constexpr int b3_helper()
{
static_assert(c<'3',"not a ternar y digit");
return ipow(3,siz eof...(tail))∗(c−'0')+b3_helper(tail...);
}
鉴于此,我们可以定义我们的基数 3 文字量运算符:
template<char... chars>
constexpr int operator"" _b3() // 基底3, 即, 三进制
{
return b3_helper(chars...);
}
许多后缀都很短(例如,s 表示 std::string,i 表示虚数,m 表示 meter(§28.7.3),x 表示扩展),因此不同的用途很容易发生冲突。使用命名空间来防止冲突:
namespace Numerics {
// ...
class Bignum { /* ... */ };
namespace literals {
Bignum operator"" x(char const∗);
}
}
using namespace Numerics::literals;
标准库保留了所有不以下划线开头的后缀,因此请以下划线开头定义后缀,否则将来可能会导致代码崩溃:
123km //标准库保留
123_km //可供您使用
19.3 一个字符串类(A String Class)
本节通过介绍一个相对简单的字符串类来说明几种使用传统定义运算符设计和实现类的实用技术。此String是标准库string(§4.2,第 36 章)的简化版本。String提供值语义、对字符的已检查和未检查访问、流 I/O、对范围 for 循环的支持、相等运算和连接运算符。我还添加了String文字量,而 std::string 尚不具备该文字量。
为了实现与 C 风格字符串(包括字符串文字量(§7.3.2))的简单互操作性,我将字符串表示为以零结尾的字符数组。为了真实起见,我实现了短字符串优化。即,只有几个字符的String将这些字符存储在类对象本身中,而不是存储在自由存储中。这优化了小字符串的字符串使用。经验表明,对于大量应用程序来说,大多数字符串都很短。这种优化在多线程系统中尤为重要,因为通过指针(或引用)共享是不可行的,而自由存储分配和释放相对昂贵。
为了允许String通过在末尾添加字符来有效地“增长”,我实施了一种方案来为这种增长保留额外的空间,类似于用于vector的方案(§13.6.1)。这使得String成为各种输入形式的合适目标。
编写更好的字符串类和/或提供更多功能的类是一个很好的练习。完成后,我们可以放弃练习并使用 std::string(第 36 章)。
19.3.1 基本操作(Essential Operations)
String 类提供了常用的构造函数、析构函数和赋值操作(§17.1):
class String {
public:
String(); //default constructor : x{""}
explicit String(const char∗p); //基于C风格字符串的构造函数: x{"Euler"}
String(const String&); // 复制构造函数
String& operator=(const String&); // 复制赋值运算我们符
String(String&& x); // 移动构造函数
String& operator=(String&& x); // 移动赋值
˜String() { if (short_max<sz) delete[] ptr; } // 析构函数
// ...
};
此String具有值语义。也就是说,在赋值 s1=s2 之后,两个字符串 s1 和 s2 完全不同,对其中一个的后续更改不会对另一个产生影响。另一种方法是赋予字符串指针语义。即让 s1=s2 之后对 s2 的更改也影响 s1 的值。在有意义的情况下,我更喜欢值语义;例如complex、vector、Matrix和string。然而,为了使值语义可承受,我们需要在不需要副本时通过引用传递字符串,并实现移动语义(§3.3.2,§17.5.2)以优化return。
§19.3.3 中介绍了String的稍微复杂的表示。请注意,它需要用户定义版本的复制和移动操作。
19.3.2 访问字符(Access to Characters)
字符串访问运算符的设计是一个难题,因为在理想情况下,访问是通过常规符号(即使用 [])进行的,效率最高,并且经过范围检查。不幸的是,您无法同时拥有所有这些属性。在这里,我遵循标准库,使用常规 [] 下标符号加上经过范围检查的 at() 操作来提供高效的未验证操作:
class String {
public:
// ...
char& operator[](int n) { return ptr[n]; } // 未验证元素访问
char operator[](int n) const { return ptr[n]; }
char& at(int n) { check(n); return ptr[n]; } // 范围检查访问
char at(int n) const { check(n); return ptr[n]; }
String& operator+=(char c); // 在尾端添加c
const char∗ c_str() { return ptr; } // C风格字符串访问
const char∗ c_str() const { return ptr; }
int size() const { return sz; } // 元素数目
int capacity() const // 元素加上可用空间
{ return (sz<=short_max) ? short_max : sz+space; }
// ...
};
这个思想是将 [] 用于普通用途。例如:
int hash(const String& s)
{
int h {s[0]};
for (int i {1}; i!=s.size(); i++) h ˆ= s[i]>>1; // 对s的未验证访问
return h;
}
这里,使用已验证的 at() 是多余的,因为我们只能正确访问从 0 到 s.size()-1 的 s。
我们可以在发现可能出现错误的地方使用 at()。例如:
void print_in_order(const String& s,const vector<int>& index)
{
for (x : index) cout << s.at(x) << ‘\n';
}
不幸的是,假设人们会在可能犯错的地方持续使用 at() 是过于乐观了,因此 std::string 的一些实现(从中借用了[]/at()约定)也检查了[]。我个人更喜欢至少在开发过程中检查[]。然而,对于严肃的字符串操作任务,对每个字符访问进行范围检查可能会带来相当明显的开销。
我提供了访问函数的 const 和非 const 版本,以允许它们用于 const 以及其他对象。
19.3.3 表示(Representation)
选择 String 的表示形式是为了满足三个目标:
• 使将 C 风格字符串(例如字符串文字量)转换为String并允许像访问C 风格字符串那样访问String的字符变得容易。
• 尽量减少自由存储的使用。
• 使向字符串末尾添加字符更加高效。
结果显然比简单的 {指针,大小} 表示更混乱,但更加现实:
class String {
/*
一个实现短字符串优化的简单字符串
size()==sz 元数的数目,若size()<= short_max, 则字符存于String 对象自身中;
否则使用自由存储。
ptr 指向字符序列的头
字符序列保持以0结尾: ptr[size()]==0;
这允许我们使用C库字符串函数且容易返回C风格字符串: c_str()
为了允许在字符串末尾有效追加字符, String按其分配内存的双倍增长;
capacity() 字符可获得的空间的容量(排除末尾的0): sz + space
*/
public:
// ...
private:
static const int short_max = 15;
int sz; // 字符数
char∗ ptr;
union {
int space; // 未使用的已分配空间
char ch[shor t_max+1]; // 留给末尾0的空间
};
void check(int n) const //范围检查
{
if (n<0 || sz<=n)
throw std::out_of_rang e("String::at()");
}
// 辅助成员函数:
void copy_from(const String& x);
void move_from(String& x);
};
通过使用两个字符串表示来支持所谓的短字符串优化:
• 如果 sz<=short_max,则字符存储在 String 对象本身中,即名为 ch 的数组中。
• 如果 !(sz<=short_max),则字符存储在空闲存储空间中,我们可以分配额外的空间用于扩展。名为 space 的成员是此类字符的数量。
在这两种情况下,元素的数量都保存在 sz 中,我们查看 sz 来确定对于给定的字符串使用哪种实现方案。
在这两种情况下,ptr 都指向元素。这对于性能至关重要:访问函数不需要测试使用了哪种表示形式;它们只需使用 ptr。只有构造函数、赋值、移动和析构函数(§19.3.4)必须关心这两种选择。
仅当 sz<=short_max 时,我们才使用数组 ch,仅当 !(sz<=short_max) 时,我们才使用整数 space。因此,在 String 对象中同时为 ch 和 space 分配空间是一种浪费。为了避免这种浪费,我使用union (§8.3)。具体来说,我使用了一种称为匿名union (§8.3.2) 的union形式,它专门用于允许类管理对象的替代表示。匿名union的所有成员都分配在同一内存中,从同一地址开始。一次只能使用一个成员,但除此之外,它们的访问和使用方式与匿名union周围范围的独立成员完全一样。程序员的工作是确保它们永远不会被误用。例如,所有使用 space 的 String 成员函数都必须确保设置的确实是 space 而不是 ch。这是通过查看 sz<=short_max 来完成的。换句话说,Shape(除其他外)是一个以 sz<=short_max 为判别式的判别union。
19.3.3.1 协助函数(Ancillary Functions)
除了用于一般用途的函数外,我发现当我提供三个协助函数作为“构建块”来协助我处理有些棘手的表示并尽量减少代码重复时,我的代码变得更干净了。其中有两个需要访问字符串的表示,所以我把它们作为成员。但是,我把它们作为私有成员,因为它们不代表通常有用且安全的操作。对于许多有趣的类,实现不仅仅是表示加上公共函数。协助函数可以减少代码重复,改善设计,并提高可维护性。
第一个这样的函数将字符移动到新分配的内存中:
char∗ expand(const char∗ ptr, int n) // 扩展到自由存储
{
char∗ p = new char[n];
strcpy(p,ptr); // §43.4
return p;
}
此函数不访问String表示形式,因此我没有将其作为成员。
第二个实现函数用于复制操作,为一个字符串提供另一个字符串成员的副本:
void String::copy_from(const String& x)
// 使 *this 成为x 的一个副本
{
if (x.sz<=short_max) { // 复制 *this
memcpy(this,&x,siz eof(x)); // §43.5
ptr = ch;
}
else { // 复制元素
ptr = expand(x.ptr,x.sz+1);
sz = x.sz;
space = 0;
}
}
目标String的任何必要清理都是 copy_from() 调用者的任务;copy_from() 无条件地覆盖其目标。我使用标准库 memcpy() (§43.5) 将源字节复制到目标中。这是一个低层的且有时非常讨厌的函数。它应该只在复制的内存中没有带有构造函数或析构函数的对象时使用,因为 memcpy() 对类型一无所知。两个字符串复制操作都使用 copy_from()。
移动操作对应的函数是:
void String::move_from(String& x)
{
if (x.sz<=short_max) { // 复制 *this
memcpy(this,&x,siz eof(x)); // §43.5
ptr = ch;
}
else { // 抓取元素
ptr = x.ptr;
sz = x.sz;
space = x.space;
x.ptr = x.ch; // x = ""
x.sz = 0;
x.ch[0]=0;
}
}
它也无条件地将其目标作为其参数的副本。但是,它不会让其参数拥有任何空闲存储空间。我也可以在长字符串的情况下使用 memcpy(),但由于长字符串表示仅使用 String 表示的一部分,因此我决定单独复制使用的成员。
19.3.4 成员函数(Member Functions)
默认构造函数将String定义为空:
String::String() // 默认构造函数 : x{""}
: sz{0}, ptr{ch} // 指向元素的ptr, ch 是初始位置 (§19.3.3)
{
ch[0] = 0; // 0终止
}
有了 copy_from() 和 move_from(),构造函数、移动和赋值就相当容易实现了。接受 C 风格字符串参数的构造函数必须确定字符数并恰当地存储它们:
String::String(const char∗ p)
:sz{strlen(p)},
ptr{(sz<=short_max) ? ch : new char[sz+1]},
space{0}
{
strcpy(ptr,p); // 从p复制字符到ptr
}
如果参数是短字符串,则将 ptr 设置为指向 ch;否则,将在自由存储空间中分配空间。无论哪种情况,字符都会从参数字符串复制到 String 管理的内存中。
复制构造函数只是复制其参数的表示:
String::String(const String& x) // 复制构造函数
{
copy_from(x); // 从x复制表示
}
我没有费心尝试优化源大小等于目标大小的情况(如针对vector所做的那样;§13.6.3)。我不知道这是否值得。
类似地,移动构造函数将表示从其源移动(并且可能将其参数设置为空字符串):
String::String(String&& x) // 移动构造函数
{
move_from(x);
}
与复制构造函数一样,复制赋值使用 copy_from() 来克隆其参数的表示。此外,它必须删除目标拥有的任何自由存储,并确保它不会因自赋值而陷入麻烦(例如,s=s):
String& String::operator=(const String& x)
{
if (this==&x) return ∗this; //处理自赋值
char∗ p = (shor t_max<sz) ? ptr : 0;
copy_from(x);
delete[] p;
return ∗this;
}
String移动赋值删除其目标的自由存储(如果有的话)然后移动:
String& String::operator=(String&& x)
{
if (this==&x) return ∗this; //处理自赋值 (x = move(x)是蠢事)
if (short_max<sz) delete[] ptr; // delete 目标
move_from(x); //不能throw
return ∗this;
}
从逻辑上讲,将源移动到其自身中是可能的(例如,s=std::move(s)),因此我们必须再次防止自我赋值(无论多么不可能)。
逻辑上最复杂的String操作是 +=,它在字符串末尾添加一个字符,使其大小增加1:
String& String::operator+=(char c)
{
if (sz==short_max) { // 扩展至长字符串
int n = sz+sz+2; // 双倍分配(+2 是因为有终止0)
ptr = expand(ptr,n);
space = n−sz−2;
}
else if (short_max<sz) {
if (space==0) { // 按自由存储扩展
int n = sz+sz+2; // 双倍分配(+2 是因为有终止0)
char∗ p = expand(ptr,n);
delete[] ptr;
ptr = p;
space = n−sz−2;
}
else
−−space;
}
ptr[sz] = c; // 末端加c
ptr[++sz] = 0; // 递增并设置终止符
return ∗this;
}
这里有很多事情要做:operator+=() 必须跟踪使用了哪种表示形式(短表示还是长表示)以及是否有额外的空间可供扩展。如果需要更多空间,则调用 expand() 来分配该空间并将旧字符移到新空间中。如果有需要删除的旧分配,则返回该分配,以便 += 可以删除它。一旦有足够的空间,就可以轻松地将新字符 c 放入其中并添加终止符 0。
注意可用内存空间的计算。在所有 String 实现中,这是花费时间最长的:这是一个混乱的小计算,容易出现大小差一错误。重复常数 2 感觉非常像一个“魔法常数”。
所有 String 成员在确定可以建立新表示之前,都小心不要修改新表示。特别是,它们不会delete任何可能的new操作。事实上,String 成员提供了强异常保证(§13.2)。
如果您不喜欢 String 实现中呈现的那种繁琐的代码,只需使用 std::string 即可。在很大程度上,标准库工具的存在是为了让我们大多数时候都免于在这种底层水平上进行编程。更强大的是:编写字符串类、向量类或映射是一项很好的练习。然而,一旦完成练习,结果应该是欣赏标准提供的内容,并且不想维护自己的版本。
19.3.5 辅助函数(Helper Functions)
为了完善 String 类,我提供了一组有用的函数、流 I/O、对范围 for 循环的支持、比较和连接。这些都反映了 std::string 的设计选择。特别是,<< 只打印字符而不添加格式,>> 在读取之前跳过初始空格,直到找到终止空格(或流的末尾):
ostream& operator<<(ostream& os, const String& s)
{
return os << s.c_str(); // §36.3.3
}
istream& operator>>(istream& is, String& s)
{
s = ""; // 清理目标字符串
is>>ws; // 跳过空白 (§38.4.5.1)
char ch = ' ';
while(is.get(ch) && !isspace(ch))
s += ch;
return is;
}
我提供 == 和 != 以便进行比较:
bool operator==(const String& a, const String& b)
{
if (a.size()!=b.siz e())
return false;
for (int i = 0; i!=a.size(); ++i)
if (a[i]!=b[i])
return false;
return true;
}
bool operator!=(const String& a, const String& b)
{
return !(a==b);
}
添加 < 等很简单。
为了支持范围for 循环,我们需要 begin() 和 end() (§9.5.1)。同样,我们可以将它们作为独立(非成员)函数提供,而无需直接访问 String 实现:
char∗ begin(String& x) // C字符串风格访问
{
return x.c_str();
}
char∗ end(String& x)
{
return x.c_str()+x.size();
}
const char∗ begin(const String& x)
{
return x.c_str();
}
const char∗ end(const String& x)
{
return x.c_str()+x.size();
}
鉴于成员函数 += 在末尾添加一个字符,连接运算符很容易作为非成员函数提供:
String& operator+=(String& a, const String& b) // 连接
{
for (auto x : b)
a+=x;
return a;
}
String operator+(const String& a, const String& b) // 连接
{
String res {a};
res += b;
return res;
}
我觉得我在这里可能有点“作弊”。我是否应该提供一个成员 += 来在末尾添加一个 C 风格的字符串?标准库字符串确实有这个功能,但如果没有它,与 C 风格字符串的连接仍然有效。例如:
String s = "Njal ";
s += "Gunnar"; // 连接: 追加到s尾
这种 += 的使用被解释为 operator+=(s,String("Gunnar"))。我猜我可以提供更高效的 String::operator+=(const char∗),但我不知道在实际代码中增加的性能是否值得。在这种情况下,我尽量保守并提供最小的设计。能够做某事本身并不是做这件事的好理由。
类似地,我不会尝试通过考虑源字符串的大小来优化 +=。
添加 _s 作为表示后缀String的字符串文字量很简单:
String operator"" _s(const char∗ p, size_t)
{
return String{p};
}
现在我们可以写出:
void f(const char∗); // C风格字符串
void f(const String&); // 我们的字符串
void g()
{
f("Madden's"); // f(const char*)
f("Christopher's"_s); // f(const String&);
}
19.3.6 使用String(Using Our String)
main程序只是稍微练习了一下String运算符:
int main()
{
String s ("abcdefghij");
cout << s << '\n';
s += 'k';
s += 'l';
s += 'm';
s += 'n';
cout << s << '\n';
String s2 = "Hell";
s2 += " and high water";
cout << s2 << '\n';
String s3 = "qwerty";
s3 = s3;
String s4 ="the quick bro wn fox jumped over the lazy dog";
s4 = s4;
cout << s3 << " " << s4 << "\n";
cout << s + ". " + s3 + String(". ") + "Horsefeathers\n";
String buf;
while (cin>>buf && buf!="quit")
cout << buf << " " << buf.siz e() << " " << buf.capacity() << '\n';
}
此字符串缺少许多您可能认为重要甚至必不可少的功能。但是,就其功能而言,它与 std::string(第 36 章)非常相似,并说明了用于实现标准库string的技术。
19.4 友关系(Friends)(友类,友函数)
普通的成员函数声明指定三个逻辑上不同的内容:
[1] 函数可以访问类声明的私有部分。
[2] 函数在类的作用域内。
[3] 必须在对象上调用函数(具有 this 指针)。
通过将成员函数声明为static(§16.2.12),我们可以只赋予它前两个属性。通过将非成员函数声明为friend,我们可以只赋予它第一个属性。也就是说,声明为friend的函数被授予对类实现的访问权限,就像成员函数一样,但除此之外与该类无关。
例如,我们可以定义一个运算符,将Matrix与vector相乘。当然,vector和Matrix隐藏了各自的表示,并提供了一套完整的操作来操作它们类型的对象。但是,我们的乘法例程不能同时是两者的成员。此外,我们实际上并不想提供底级访问函数来允许每个用户同时读取和写入Matrix和vector的完整表示。为了避免这种情况,我们将运算符∗声明为两者的friend 运算符函数:
constexpr rc_max {4}; // 行和列大小
class Matrix;
class Vector {
float v[rc_max];
// ...
friend Vector operator∗(const Matrix&, const Vector&);
};
class Matrix {
Vector v[rc_max];
// ...
friend Vector operator∗(const Matrix&, const Vector&);
};
现在,operator∗() 可以深入 Vector 和 Matrix 的实现。这将允许复杂的实现技术,但简单的实现将是:
Vector operator∗(const Matrix& m, const Vector& v)
{
Vector r;
for (int i = 0; i!=rc_max; i++) { // r[i] = m[i] * v;
r.v[i] = 0;
for (int j = 0; j!=rc_max; j++)
r.v[i] += m.v[i].v[j] ∗ v.v[j];
}
return r;
}
friend声明可以放在类声明的私有或公共部分;放在哪儿都无所谓。与成员函数一样,友函数在其所属类的声明中明确声明。因此,它与成员函数一样,都是该接口的一部分。
一个类的成员函数可以是另一个类的友函数。例如:
class List_iterator {
// ...
int∗ next();
};
class List {
friend int∗ List_iterator::next();
// ...
};
有一种简写方法可以使一个类的所有函数成为另一个类的友函数。例如:
class List {
friend class List_iterator;
// ...
};
这个friend声明使得 List_iterator 的所有成员函数都成为 List 的友函数。
将一个类声明为friend会授予对该类的每个函数的访问权限。这意味着我们无法仅通过查看类本身来了解可以访问授予类表示的函数集。在这方面,friend类声明不同于成员函数和friend函数的声明。显然,friend类应谨慎使用,并且仅用于表达紧密相关的概念。
可以将模板参数设为friend:
template<typename T>
class X {
friend T;
friend class T; // 冗余“class”
// ...
};
通常,可以选择将一个类设为成员(嵌套类)或非成员友关系(§18.3.1)。
19.4.1 查找友对象(Finding Friends)
必须先在封闭作用域声明友关系,或者在紧邻将其声明为friend的类的非类作用域内定义友关系。对于首先声明为friend的名称,最内层封闭命名空间作用域之外的范围不予考虑(§iso.7.3.1.2)。考虑一个技术示例:
class C1 { }; // 将成为N::C的友类
void f1(); // 将成为N::C的友函数
namespace N {
class C2 { }; // 将成为C的友类
void f2() { } // 将成为C的友函数
class C {
int x;
public:
friend class C1; // OK (前面已定义)
friend void f1();
friend class C3; // OK (已在命名空间定义)
friend void f3();
friend class C4; // 首先在N中声明并假设在N中
friend void f4();
};
class C3 {}; // C的友类
void f3() { C x; x.x = 1; } // OK: C的友函数
} // namespace N
class C4 { }; // 不是N::C的友类
void f4() { N::C x; x.x = 1; } // 错: x是私有的且f4()不是N::C的友函数
即使友函数未在紧邻的函数作用域内声明,也可以通过其参数(§14.2.4)找到它。例如:
void f(Matrix& m)
{
invert(m); // Matrix的友函数invert()
}
因此,友元函数应在封闭作用域内显式声明,或取自其类或从其派生的类的参数。如果不是,则无法调用友函数。 例如:
// 在作用域内没有f()
class X {
friend void f(); // 无用
friend void h(const X&); // 可通过其参数发现
};
void g(const X& x)
{
f(); // 作用域内无f()
h(x); // X友函数h()
}
19.4.2 友函数与成员函数(Friends and Members)
对于具体的操作,应该使用友函数还是使用成员函数才是更好的选择?首先,我们尝试最小化访问类表示的函数数量,并尝试使访问函数集尽可能合适。因此,第一个问题不是“它应该是成员、静态成员还是朋友?”而是“它真的需要访问吗?”通常,需要访问的函数集比我们最初愿意相信的要小。某些操作必须是成员——例如,构造函数、析构函数和虚函数(§3.2.3,§17.2.5)——但通常有一个选择。因为成员名称是类的局部名称,所以需要直接访问表示的函数应该是成员,除非有特定原因使其成为非成员。
考虑一个类 X,它提供了呈现操作的替代方法:
class X {
// ...
X(int);
int m1(); // 成员
int m2() const;
friend int f1(X&); // 友函数, 不是成员
friend int f2(const X&);
friend int f3(X);
};
成员函数只能被其类的对象调用;不会对 . 或 −> 的最左边的操作数应用任何用户定义的转换(但请参阅 §19.2.3)。例如:
void g()
{
99.m1(); // 错: X(99).m1() 未尝试
99.m2(); // 错: X(99).m2() 未尝试
}
全局函数 f1() 具有类似的属性,因为隐式转换不用于非 const 引用参数(§7.7)。但是,转换可以应用于 f2() 和 f3() 的参数:
void h()
{
f1(99); // 错 : f1(X(99)) 未尝试: 非const X& 参数
f2(99); // OK: f2(X(99)); const X& 参数
f3(99); // OK: f3(X(99)); X 参数
}
修改操作数的运算符(例如 =、∗= 和 ++)最自然地定义为用户定义类型的成员。相反,如果希望对操作的所有操作数进行隐式类型转换,则实现该操作的函数必须是采用 const 引用参数或非引用参数的非成员函数。对于实现在应用于基本类型(例如 +、− 和 ||)时不需要左值操作数的运算符的函数,通常就是这种情况。但是,此类运算符通常需要访问其操作数类的表示形式。因此,二元运算符是友函数最常见的来源。
除非定义了类型转换,否则似乎没有令人信服的理由选择成员而不是采用引用参数的友函数,反之亦然。在某些情况下,程序员可能偏爱一种调用语法而不是另一种。例如,大多数人似乎更喜欢使用 m2=inv(m) 表示法来从 m 生成一个逆矩阵,而不是代之以 m2=m.inv()。在另一方面,如果 inv() 反转 m 本身,而不是生成一个与 m 逆的新矩阵,则它应该是一个成员。
在所有其他条件相同的情况下,将需要直接访问表示的操作实现为成员函数:
• 不可能知道是否有人将来会定义转换运算符。
• 成员函数调用语法向用户清楚地表明对象可能会被修改;引用参数则不那么明显。
• 成员函数主体中的表达式可能明显短于全局函数中的等效表达式;非成员函数必须使用显式参数,而成员函数则可以隐式使用。
• 成员名称是类的局部名称,因此它们往往比非成员函数的名称短。
• 如果我们已经定义了成员函数 f(),后来又觉得需要非成员函数 f(x),则我们可以简单地将其定义为 x.f()。
反之,不需要直接访问表示的操作通常最好表示为非成员函数,可能位于命名空间中,以使其与类的关系明确(§18.3.6)。
19.5 建议
[1] 使用 operator[]() 进行下标和基于单个值的选择;§19.2.1。
[2] 使用 operator ()() 进行调用语义、下标和基于多个值的选择;§19.2.2。
[3] 使用 operator −>() 解引用“智能指针”;§19.2.3。
[4] 前缀++ 优于后缀++;§19.2.4。
[5] 仅在确实需要时定义全局 operator new() 和 operator delete();§19.2.5。
[6] 定义成员运算符 new() 和成员运算符 delete() 来控制特定类或类层次结构的对象的分配和释放;§19.2.5。
[7] 使用用户定义的文字量来模仿传统符号;§19.2.6。
[8] 将文字量运算符放在单独的命名空间中以允许选择性使用; §19.2.6.
[9] 对于非专业用途,优先使用标准字符串(第 36 章)而不是您自己的练习结果;§19.3.
[10] 如果您需要非成员函数来访问类的表示(例如,改进符号或访问两个类的表示),请使用友函数;§19.4.
[11] 在授予对类实现的访问权限时,优先使用成员函数而不是友函数;§19.4.2.
内容来源:
<<The C++ Programming Language >> 第4版,作者 Bjarne Stroustrup