accelerated-cpp学习笔记,参考电力出版社的《Accelerated C++》翻译,这里是第12章内容。本章的内容主要在于如何使类对象像一个数值一样使用,以实现一个string类为例,关键在于类对象在使用过程中的类型转换和重载运算符的使用。因此,介绍了构造函数在进行类型转换时的工作,以及自动类型转换需要注意的事项,为了提供类对象之间的运算操作,重载了常见的输入输出运算符、加号运算符,并提及了二元运算符在使用时的注意事项。
第12章 使类对象像一个数值一样使用——类型转换与运算符重载
C++内建类型的对象使用起来像一个数值一样:在对这种对象执行复制操作时,原对象与它的副本内容一样,但它们彼此独立,可以把它们作为参数传递给函数,作为函数的返回值,对它们进行复制,或者把它们赋值给其他对象。对于大多数的内建类型,C++定义了大量的操作,并且为逻辑上相似的类型之间提供了自动转换功能,例如,一个int
和一个double
相加,编译器将自动把int
转换为double
。
标准库中的string
类就是一个很好的例子,它有丰富的函数支持自动转换。因而,可以定义一个string
类的简化版本,并命名为Str
。有了Vec
类的经验,为Str
类设计一个友好的接口,以及它所具备的操作与转换功能将作为重点。
1 一个简单的str类
创建一个简单的Str
类,这个类可以如预期的生成对象:
class Str {
public:
typedef Vec<char>::size_type size_type;
Str();
Str(size_type n, char c): data(n, c) {}
Str(const char* cp) {
std::copy(cp, cp+std::strlen(cp), std::back_inserter(data));
}
// 模板函数,根据传入的参数类型实例化出不同的构造函数
template <class In> Str(In b, In e) {
std::copy(b, e, std::back_inserter(data));
}
private:
Vec<char> data;
}
Str
类中将管理数据的工作交给了Vec
类,所以不需要定义拷贝构造函数和析构函数,编译器会自动生成,并调用Vec
类相关的函数。
2 类型转换
2.1 自动转换
在Str
类中,可以通过调用构造函数创建一个Str
类型对象,Str s("hello");
用一个const char*
类型的变量来构造变量s
,也可以这样进行赋值
Str s("world");
Str t = "hello";
s = "hello"; // 把一个新的值赋给s
第一种形式的=
表示初始化,它始终需要调用一个参数类型为const Str&
的拷贝构造函数,第二个语句是一个表达式,=
代表赋值,由编译器自动生成的赋值运算符函数需要一个const Str&
类型的参数,而表达式中的右操作数是一个const char*
类型的字符串常量,在这个过程中,编译器隐式地调用了非explicit
的带const chat*
类型参数的构造函数,使得无需定义一个带const char*
类型参数的赋值运算符函数。
具体过程是:编译器调用Str(const char*)
构造函数为这个字符串常量构造一个没有名字的、局部的、临时的Str
类型对象,然后再调用编译器自动生成的赋值运算符函数把这一临时变量赋值给s
。
2.2 有些转换是危险的
习惯上,把定义被构造对象的结构的构造函数声明为explicit
,有些构造函数的参数最后会变成对象的一部分,这些构造函数一般就不必要被声明为explicit
。例如Str
类定义了带一个const char*
类型参数的构造函数,这个构造函数用它的参数来初始化对象的值,所以允许在表达式和函数调用中对const char*
进行自动转换的操作是合理的。又比如Vec
类中带一个Vec::size_type
类型参数的构造函数,它使用它的参数来决定为对象分配多少个元素的内存,只决定了对象的结构,而不是对象的内容,所以被声明成explicit
。这是因为,对于构造函数,尤其是带有一个整型参数的构造函数,隐式转换容易将传入的参数当作对象的初始值,实际上这个整型参数可能只是为了声明对象的结构而非内容。
2.3 类型转换操作函数
前面说过,类的对象的自动转换是隐式的,是编译器根据构造函数自动生成的操作,但类的编写者也可以显式地定义类的转换操作,该操作定义了如何把一个对象转换成想要的类型。转换操作必须定义成类的成员函数,转换操作函数的函数名为operator
加上目标类型名称。例如
class StudentInfo {
public:
operator double(); // StudentInfo类的对象转换成duoble类型的值
};
在需要一个double
类型的地方,程序提供了一个StudentInfo
类型对象,这时候编译器会自动调用这个转换函数,例如
vector<StudentInfo> vs;
double d = 0;
for (int i = 0; i != vs.size(); ++i)
d += vs[i]; // vs[i]被自动转换成double类型的值
转换操作函数常用在在两种情况下:C++内置类型和没有代码的类型,都不能向目标对象中加入构造函数,因此只能在有代码的类中把转换函数定义成类的一部分。例如,在每一个隐式地检测istream
的值的循环中,都调用了这类转换函数:
if (cin >> x) { /* ... */ }
它等价于
cin >> x;
if (cin) { /* ... */ }
显然,if
语句判断的是一个bool
值,在使用任何一个数字类型或者指针类型的值时都会先把它转换成bool
类型的值,所以才可以在判断表达式中使用这些类型的值。当然,iostream
既不是指针也不是数字,但是,在标准库中定义了一个从iostream
到void*
的转换函数istream::operator void*
,它通过检验不同的状态标志来判断istream
是否有效,并返回0
或者一个自定义的非零void*
值以表示流的状态。
void
类型有几种用途,最基本的用途时作为一个指针指向的类型,一个指向void
的类型有时候又叫做通用指针,因为这是一个可以指向任何类型的指针。当然,这个指针不能间接引用,因为它指向的对象的类型还是未知的,不过可以把它转换成bool
类型。
那么为什么不直接定义成operator bool
呢,这是因为,对于cin << x
这样的表达式,编译器将调用operator bool
,把cin
转换成bool
,然后把生成的bool
值转换成int
类型的值,并把该值左移x
位,然后舍弃这个结果。而定义成一个向void*
类型的转换,使得istream
类型可以用作一个判断条件表达式,但不能作为一个算术值来使用。
2.4 类型转换与内存管理的冲突
习惯上,有很多语言以空字符为结尾的字符数组来储存字符串数据,因此,可以定义一个从Str
类型到以空字符结尾的字符数组类型的转换,这样Str
类型就可以传递给一个以空字符结尾的字符数组为参数的函数,但是,这样做将会导致内存管理上的缺陷。就像这样
class Str {
public:
operator char*();
operator const char*() const;
private:
Vec<char> data;
}
如果真的这样做,这样的转换几乎不可能正确地完成。首先,这里需要的应该是一个char*
类型的data
,这里提供的是Vec<char>
类型,所以不可能仅仅返回data
。其次,即便类型匹配,返回data
变量将会破坏Str
类的封装性,使用该指针可能会修改Str
类型对象的值,即便将该变量声明为const
,当Str
类型的对象被删除之后,该指针将指向一片已经释放内存的空间,这是个无效的指针。
那么,如果为返回的变量申请一块新的内存空间,然后将data
中的字符复制到这块内存,然后返回指向该内存的指针,这样用户只能对这片内存进行操作了。但是,如果转换是隐式发生的,虽然Str
变量转换成了构造函数要求的const char*
类型,并分配了新的内存空间来储存data
变量的一个副本,但这样的操作是隐式发生的,生成的副本作为一个临时变量,并不会返回指向该副本的显式的指针,这片内存将无法被释放。显然,这种操作导致的内存泄漏问题是应当被避免的。
标准库的string
类使用了另一种方法:该类允许用户获得一个储存在字符数组中的字符串的一个副本,但只能显式地获取。string
类提供了三个这样的成员函数,第一个是c_str()
,它把string
类型对象的内容复制到一个以空字符结尾的字符数组中,这个数组属string
类的对象所有,用户不能删除指向它的指针。这个数组中的数据只是临时的,在下一次调用一个可以改变string
的成员函数的时候它就会失效。c_str()
函数要求用户只能短暂地使用返回的指针或者把数据复制到一块指定的内存中去。第二个函数是data()
,除了返回一个不是以空字符结尾的字符数组以外,其他都与c_str()
函数一样。第三个函数是copy()
,它带有一个char*
类型和一个int
类型参数,用来把int
类型参数指定个数的字符复制到char*
类型参数的内存中,这片内存必须由用户来分配和释放。
注意到c_str
函数和data
函数都具有隐式地向const char*
类型的转换的缺陷。不过在另一 方面,因为它要求用户只能显式地调用这个类型转换,所以用户一定要理解他们正在调用的函数。他们知道在获得一个指针的一个复件的时候会带来些什么隐患。
3 Str操作
标准库中string
类具有一些操作
cin >> s; // 用输入运算符读取一个字符串
cout << s; // 用输出运算符输出一个字符串
s[i]; // 索引运算符访问字符串中的字符
s1 + s2; // 加运算符连接两个字符串
上面几个运算符都是二元运算符,一个参数是对象本身,如果定义成成员函数,那么这个参数可以隐式地被提供。如索引运算符
char& operator[](size_type i) { return data[i]; }
const char& operator[](size_type i) const { return data[i]; }
在定义其他操作之前,还需要决定哪些函数将作为Str
类的成员函数。
3.1 输出运算符
有一种判断方法是,看这个操作会不会改变对象的状态,输入运算符当然会改变对象的状态,但是将输入运算符函数作为成员函数,并不会像想象中的那样工作。首先,对于一个二元运算符,其左操作数必须作为函数的第一个参数,右操作数必须作为函数的第二个参数,如果该运算符函数是成员函数,那么第一个参数总是默认地传递给该成员函数,因此cin>>s;
等价于cin.operator>>(s);
,它调用了cin
的重载运算符,也就是说,这个运算符函数必须是istream
类的一个成员。然而,对istream
的定义也没有权限去修改,也就不能把这个操作添加进去。
如果把operator>>
作为Str
的成员,就必须定义运算符函数s.operator>>(cin);
或者使用它的等价形式s>>cin;
,这不是常见的语法形式,所以,输入——输出函数不能作为类的成员函数,现在,就可以给它们在Str.h
中声明了:
std::istream& operator>>(std::istream&, Str&);
std::ostream& operator<<(std::ostream&, const Str&);
输出函数的定义十分简单:它用一个迭代器遍历Str
类中的每一个元素,每次输出单个字符:
// 实际调用的Vec类型对象的size成员
size_type size() const { return data.size(); }
ostream& operator<<(ostream& os, const Str& s) {
for (Str::size_type i = 0; i != s.size(); ++i)
os << s[i]; // 调用Str::operator[]函数获得字符
return os;
}
3.2 输入运算符——友元函数
输入运算符函数需要从输入流读出字符,并忽略开头的空格字符,然后连续地读出其他字符并储存起来,直到遇到另一个空格字符或者遇到一个文件结束符。
// 简化版本,并不能直接运行
istream& operator>>(istream& is, Str& s) {
s.data.clear(); // 清除已存在的值
char c;
// 每次读取一个字符,直到遇到非空格字符
while (is.get(c) && isspace(c));
is.unget();
if (is) {
do s.data.push_back(c); // data是私有成员,这里会报错
while (is.get(c) && !issapce(c));
}
return is;
}
这段代码不能通过编译的原因,在于运算符函数operator>>
上,它不是Str
类的成员函数,所以不能访问s
的私有成员data
。因为要向data
中写入数据,而且也不能将它声明为公有成员,所以,可以将运算符函数声明为友元函数,使得它拥有和成员函数一样的权限。
friend std::istream& operator>>(std::istream&, Str&);
友元函数的声明可以加在类定义的任何一个地方,包括private
标识后面,因为友元函数具有特殊的访问权力,所以它是类接口的一部分。因此,一般在类声明的前面,public
接口的附近,把所有友元函数的声明放在一起作为一个相对独立的组。
3.3 加号运算符
在编写加号运算符函数之前,需要考虑,它是否作为一个成员函数,它的操作数是什么类型,该函数返回什么类型的结果?
首先,该函数需要连接两个Str
类型对象的值,其次,在连接时不能改变原来两个操作数的值,这样的话,就没什么特殊的理由要求该运算符函数是一个成员函数了,最后,还希望能够在一个简单的表达式中对几个Str
类型的对象进行连接,就像s1+s2+s3
。这样的话,实现一个非成员函数更合适
Str operator+(const Str&, const Str&);
如果提供了operator+
函数,那么最好同时提供operator+=
函数,也就是s+=s1
,这更符合常见的语法形式。事实证明,要想方便地实现operator+
函数,最好先写出operator+=
函数。与简单的连接操作不同,复合的版本改变了运算符的左操作数,所以把它写成一个成员函数。现在,在加入各个运算符函数之后,Str
类如下所示:
class Str {
friend std::istream& operator>>(std::istream&, Str&);
public:
Str& operator+=(const Str& s) {
std::copy(s.data.begin(), s.data.end(), std::back_inserter(data));
return *this;
}
typedef Vec<char>::size_type size_type;
Str() {}
Str(size_type n, char c) : data(n, c) {}
Str(const char* cp) {
std::copy(cp, cp+std::strlen(cp), std::back_inserter(data));
}
template <class In> Str(In i, In j) {
std::copy(i, j, std::back_inserter(data));
}
char& operator[](size_type i) { return data[i]; }
const char& operator[](size_type i) const { return data[i]; }
size_type size() const { return data.size(); }
private:
Vec<char> data;
};
std::ostream& operator<<(std::ostream&, const Str&);
Str operator+(const Str&, const Str&);
现在可以用operator+=
函数来实现operator+
函数:
Str operator+(const Str& s, const Str& t) {
Str r = s;
r += t;
return r; // 隐式地调用拷贝构造函数,将值返回给接收函数返回值的变量
}
3.4 混合类型表达式
已经定义了连接运算符,它的操作数是const Str&
类型对象,如果表达式中含有字符指针,比如
const Str greeting = "hello, " + name + " !";
其中,name
是一个Str
类型的对象,因为+
运算符是左结合的,所以表达式等价于("hello, "+name)+" !"
。在计算第一个表达式时,是一个字符串常量和一个Str
类型对象相加,计算第二个表达式时,也是一个字符串常量和一个Str
类型对象相加,只不过两个参数的顺序不同了。Str
的+
运算符函数的参数类型是const Str&
,但通过定义一个定义const char*
类型为参数的构造函数,也就定义了一个从const char*
到Str
类型的转换操作。在任何一种情况里,编译器都先把const char*
类型的参数转换成Str
类型的参数,然后再调用operator+
函数,因此,上述表达式其实与下面的代码等价:
Str temp1("hello, ");
Str temp2 = temp1 + name;
Str temp3(" !");
Str s = temp2 + temp3;
这样的做法用到了很多临时变量,内存开销很大,所以标准库中string
类并不是依赖自动转换来实现混合类型的操作数相加,而是为每一种可能的操作数类型的连接都定义了一个重载运算符函数。
3.5 设计二元运算符
在二元运算符的设计中参数转换的地位十分重要,如果一个类支持转换,那么把二元运算符定义成非成员函数,可以保证两个操作数的对称性。如果一个运算符函数是类的成员函数,那么这个运算符的左操作数不能是自动转换得到的结果,原因在于,在写一个像x+y
这样的表达式时,编译器不会对整个程序进行检测,以发现把x
转换成一个拥有成员operator+
类型的可能性。因此,编译器不得不检查作为非成员函数的operator+
函数以及x
类的operator+
成员函数。
一个非成员运算符函数的左操作数以及运算符函数的右成员函数,都遵循与一般函数的参数一样的规律:操作数可以是任何一种能被转换成参数指定类型的类型。如果定义该二元运算符函数是一个成员函数,那么也就同时引入了操作数的非对称性:右操作数可能是自动转换之后的结果,但左操作数不能。这种非对称性对于一个像+=
这样的固有的非对称操作函数来说不是问题,可是在对称操作数的环境中,这种要求会使人迷惑并可能导致错误。一般来说 都希望这样的两个操作数是完全对称的,这就要求我们把运算符函数定义成一个非成员函数。
在赋值这个二元运算符函数里,我们规定左操作数的类型必须是该类的类型。如果不这样 又会发生什么事呢?如果我们允许左操作数被转换,那么就可能会把操作数转换成该类的对象,并把它放在一个临时变量中,最后把一个新的值赋给这个临时变量。然这是一个临时变量,在赋值操作完成后,我们没有办法访问到刚才生成的这个对象!因此,就像赋值运算符函数一样,所有的复合赋值操作也必须是类的成员函数。
总的来说,如果在二元运算符函数中,如果希望左操作数必须是该类的类型,而不是自动转换之后的结果,那么它必须是类的成员函数。而在对称操作数的环境下,如果右操作数可能是自动转换之后的结果,一般来说,都希望这两个操作数是完全对称的,那么希望左操作数也是自动转换的结果,所以它需要被定义成一个非成员函数。这对像+=
这样的固有的非对称操作函数来说就不是问题了。