Effective C++ 第二版 17)operator=检查自己 18)接口完整 19)成员和友元函数
条款17 在operator=中检查给自己赋值的情况
1
2
3
|
class
X { ... };
X a;
a = a;
// a 赋值给自己
|
>赋值给自己make no sense, 但却是合法的;
重要的是, 赋值给自己的情况可以以隐蔽的形式出现: a = b; 如果b是a的另一个名字(初始化为a的引用), 那也是对自己赋值; 这是一个别名的例子: 同一个对象有两个以上的名字; 别名可以以任意形式的伪装出现, 在写函数时一定要考虑到;
Note 赋值运算符中要特别注意可能出现别名的情况;
1) 效率: 如果可以在赋值运算符函数体的开始检测到是给自己赋值, 可以立即返回, 节省大量工作;
e.g. 派生类的赋值运算符必须调用它的每个基类的赋值运算符, 所以在派生类中省略赋值运算符函数体的操作可以避免大量的函数调用;
2) 正确性: 一个赋值运算符必须先释放掉一个对象的资源(去掉旧值), 然后根据新值分配新的资源;
在自己给自己赋值的情况下, 释放旧资源是灾难性的, 因为在分配新资源的时候需要的正是旧的资源;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
class
String {
public
:
String(
const
char
*value);
// 函数定义参见条款11
~String();
// 函数定义参见条款11
//...
String& operator=(
const
String& rhs);
private
:
char
*data;
};
// 忽略了给自己赋值的情况的赋值运算符
String& String::operator=(
const
String& rhs)
{
delete
[] data;
// delete old memory
// 分配新内存,将rhs 的值拷贝给它
data =
new
char
[
strlen
(rhs.data) + 1];
strcpy
(data, rhs.data);
return
*
this
;
// see Item 15
}
|
>下面这种情况下会出现不可预知的错误:
1
2
|
String a =
"Hello"
;
a = a;
// same as a.operator=(a)
|
*this和rhs名称不同, 却都是同一个对象的不同名字; *this data ------------> "Hello\0" <-----rhs data
赋值运算符做的第一件事是delete [] data; *this data ------------> ??? <-----rhs data
当赋值运算符对rhs.data调用strcpy时结果将无法确定, 因为data被delete时rhs.data也被删除了; data, this->data和rhs.data其实都是同一个指针;
Solution 对可能发生的自己给自己赋值的情况先进行检查, 如果有这种情况就立即返回;
难题是必须定义两个对象怎么样才算是"相同的"; 这个问题学术上称为object identity;
两个解决问题的基本方法:
1) 如果两个对象有相同的值, 即相同(具有相同的身份);
e.g. String a = "Hello"; String b = "World"; String c = "Hello"; a和c有相同的值, 被认为是相同的, b和他们都不同;
1
2
3
4
5
|
String& String::operator=(
const
String& rhs)
{
if
(
strcmp
(data, rhs.data) == 0)
return
*
this
;
...
}
|
>值相等通常由operator==来检测, 对于一个用值相等来检测对象身份的类C来说, 他的赋值运算符的一般形式是:
1
2
3
4
5
6
7
|
C& C::operator=(
const
C& rhs)
{
// 检查对自己赋值的情况
if
(*
this
== rhs)
// 假设operator==存在
return
*
this
;
...
}
|
Note 这个函数比较的是对象(通过operator==), 不是指针;
用值相等来确定对象身份, 与两个对象是否占用相同的内存无关, 有关系的只是他们所表示的值;
2) 两个对象当且仅当他们具有相同的地址时才是相同的;
这个定义在C++程序中运用得更广泛, 可能是因为容易实现而且计算较快;
1
2
3
4
5
6
|
C& C::operator=(
const
C& rhs)
{
// 检查对自己赋值的情况
if
(
this
== &rhs)
return
*
this
;
...
}
|
如果需要一个复杂的机制来确定两个对象是否相同, 需要靠程序员自己实现; 最普通的方法是实现一个返回某种对象标识符的成员函数:
1
2
3
4
5
|
class
C {
public
:
ObjectID identity()
const
;
// 参见条款36
//...
};
|
对于两个对象指针a和b, 当且仅当a->identity() == b->identity()的时候, 它们所指的对象是完全相同的; 必须自己来实现ObjectIDs的operator==;
别名和object identity的问题不仅仅局限在operator==里, 任何一个用到的函数都有可能遇到;
在用到引用和指针的场合, 任何两个兼容类型的对象名称都可能指向了同一个对象:
1
2
3
4
5
6
7
8
9
10
11
|
class
Base {
void
mf1(Base& rb);
// rb 和*this 可能相同
//...
};
void
f1(Base& rb1,Base& rb2);
// rb1 和rb2 可能相同
//
class
Derived:
public
Base {
void
mf2(Base& rb);
// rb 和*this 可能相同
//...
};
int
f2(Derived& rd, Base& rb);
// rd 和rb 可能相同
|
>例子中使用的是引用, 指针有相似情况;
别名可以以各种形式出现, 处理它会达到事半功倍的效果; 写任何一个函数, 只要有别名可能出现, 就必须在写代码时进行处理;
---内存管理 End---
类和函数: 设计与声明
在程序中声明一个新类将产生一种新的类型: 类的设计就是类型设计; 好的类型具有自然的语法, 直观的语义和高效的实现;
设计每个类都会遇到的问题:
- 对象如何被创建和摧毁? 这将影响构造函数和析构函数的设计, 以及自定义的operator new, operator new[] 以及 operator delete, operator delete[];
- 对象初始化和对象赋值有何不同? 这决定了构造函数和赋值运算符的行为以及区别;
- 通过值来传递新类型的对象? 拷贝函数对此负责;
- 新类型的合法值有什么限制? 这决定了成员函数(特别是构造函数和赋值运算符)内部的错误检查的种类; 还可能影响到函数抛出的Exception的种类以及函数的异常规范;
- 新类型符合继承关系么? 如果是从已有类继承, 新类的设计会受限于这些类, 还要考虑被继承的类是否是虚类; 如果新类允许被继承, 要考虑函数是否是virtual的;
- 允许哪种类型转换? 如果允许类型A的对象隐式转换为类型B的对象, 就要在A中写一个类型转换函数, 或者在B中写一个单参数调用的非explicit构造函数; 如果只允许显式转换, 就要写函数来执行转换, 但不会写成类型转换运算符或单参数的非explicit构造;
- 什么运算符和函数对新类型有意义? 这决定了在类中声明什么样的函数;
- 哪些运算符和函数要被明确禁止? 把他们声明为private; [无需实现]
- 谁有权访问新类型的成员? 决定哪些成员是public, protected, private的, 哪些类/函数是友元, 将一个类嵌套到另一个类中是否有意义;
- 通用性如何? 也许你正在定义一整套的类型, 这样需要定义一个新的类模板;
C++中定义一个高效的类不那么简单, 如果自定义的类型和固定类型使用起来没什么区别, 那这个类就完成的不错;
条款18 争取使类的接口完整并且最小
用户接口是指这个类的程序员所能访问到的接口; 典型的接口里只有函数存在, 因为在用户接口里放数据成员会有很多缺点; [安全性]
哪些函数应该作为类的接口? 一方面, 类要简单易读, 意味着函数要少, 每个函数都完成各自独立的任务; 另一方面, 类要功能强大, 意味着不时地增加函数, 提供对各种功能的支持;
Note 类接口的目标是完整且最小;
完整的接口支持用户完成任何合理的任务; 一个最小的接口是指函数尽可能少, 函数间没有重叠功能的接口;
充斥大量函数的类的接口有很多缺点:
1) 接口中函数越多, 以后就越难理解; 拥有太多的函数的类, 对使用者来说会有学习困难; 函数太多, 容易有相似的出现, 用户使用时的选择将不再简单直接;
2) 难以维护; 含有大量函数的类难以维护和升级, 难以避免重复代码(重复bug), 难以保持接口的一致性, 难以建立文档;
3) 长的类会导致长的头文件; 每次编译时会浪费时间读取头文件, 使编译时间变长;
在接口里增加函数时, 要考虑到它带来的方便是否值得: 同时带来的复杂性, 可读性, 可维护性和编译时间;
e.g. 一个类模板, 实现了用户自定义下标上下限的数组功能, 提供上下限检查选项:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
template
<
class
T>
class
Array {
public
:
enum
BoundsCheckingStatus {NO_CHECK_BOUNDS = 0, CHECK_BOUNDS = 1};
Array(
int
lowBound,
int
highBound, BoundsCheckingStatus check = NO_CHECK_BOUNDS);
Array(
const
Array& rhs);
~Array();
Array& operator=(
const
Array& rhs);
private
:
int
lBound, hBound;
// 下限, 上限
vector<T> data;
// 数组内容; 关于vector,请参见条款 49
BoundsCheckingStatus checkingBounds;
};
|
>析构不是virtual的, 表示这个类不作为基类使用;
Note C++中固定类型的数组是不允许赋值的;
>数组型的模板vector(STL)允许vector对象间赋值, 所以Array对象可以使用赋值运算符;
1
|
Array(
int
size, BoundsCheckingStatus check = NO_CHECK_BOUNDS);
|
>支持固定大小的数组声明;
带上下限参数的构造也能完成同样的功能, 所以这样就不是最小接口; 为了迎合基本语言(C语言), 可能是值得的;
1
2
|
T& operator[](
int
index);
// 返回可以读/写的元素
const
T& operator[](
int
index)
const
;
// 返回只读元素
|
>对数组的索引; 提供了对const和非const Array对象的支持, 返回值也不相同;
1
2
3
|
Array<
int
> a(10, 20);
// 下标上下限为:10 到20
for
(
int
i = a 的下标下限; i <= a 的下标上限; ++i)
cout <<
"a["
<< i <<
"] = "
<< a[i] <<
'\n'
;
|
>要获得数组的下标上下限;
1
|
int
lowBound()
const
;
int
highBound()
const
;
|
>const成员函数, 不会对成员进行修改, 遵循"能用const就尽量用const"的原则;
1
2
|
for
(
int
i = a.lowBound(); i <= a.highBound(); ++i)
cout <<
"a["
<< i <<
"] = "
<< a[i] <<
'\n'
;
|
还需要一个类型T的operator<<, T可以隐式转换成其他类型的operator<<;
size可以通过highBound - lowBound +1获得; 各种关系运算符 <, >, ==等可以通过operator[]实现;
Note operator<<, operator>>这样的函数和关系运算符, 经常用非成员的友元函数来实现; 友元函数在实际应用中是类接口的一部分, 影响类接口的完整性和最小性;
条款19 分清成员函数, 非成员函数和友元函数
成员函数和非成员函数最大的区别: 成员函数是可以虚拟的; 如果函数必须进行动态绑定, 就要采用虚拟函数;
1
2
3
4
5
6
7
8
|
class
Rational {
public
:
Rational(
int
numerator = 0,
int
denominator = 1);
int
numerator()
const
;
int
denominator()
const
;
private
:
...
};
|
>有理数, 需要增加+, -, *, /等算术操作支持;
有理数的乘法和Ratinal类相联系: const Rational operator*(const Rational& rhs) const;
1
2
3
|
Rational oneEighth(1, 8); Rational oneHalf(1, 2);
Rational result = oneHalf * oneEighth;
// 运行良好
result = result * oneEighth;
// 运行良好
|
>Rational对象间乘法操作运行良好, 接下去试验混合类型操作: e.g. Rational和int相乘;
1
2
|
result = oneHalf * 2;
// 运行良好
result = 2 * oneHalf;
// 出错!
|
乘法应该满足交换律;
以上的代码可以用等价函数形式重写:
1
2
|
result = oneHalf.operator*(2);
// 运行良好
result = 2.operator*(oneHalf);
// 出错!
|
>oneHalf是包含operator*函数的类的实例, 而整数2没有相应的函数;
编译器还会去搜索一个非成员的operator*函数 (在可见的名字空间里的operator*函数或全局的operator*函数): result = operator*(2, oneHalf); // 错误! 找不到函数;
对于运行良好的operator*, 编译器进行了隐式转换: 传递的值是int, 函数需要的是Rational, 但Rational的构造可以将int转换成Rational对象;
1
2
|
const
Rational temp(2);
// 从2 产生一个临时Rational 对象
result = oneHalf * temp;
// 同oneHalf.operator*(temp);
|
Note 只有当所涉及的构造函数没有声明为explicit的情况下才能隐式转换;
如果Rational定义了explicit的构造:
1
|
explicit
Rational(
int
numerator = 0,
int
denominator = 1);
// 此构造函数为explicit
|
那么, 下面的语句都无法通过编译:
1
2
|
result = oneHalf * 2;
// 错误!
result = 2 * oneHalf;
// 错误!
|
Note 编译器只对函数参数表中的参数进行隐式转换, 不会对成员函数所在的对象(*this指针对应的对象)进行转换;
要支持混合型的算术操作, 应该使operator*成为一个非成员函数, 允许编译器对所有的参数执行隐式类型转换:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
class
Rational {
...
// contains no operator*
};
// 在全局或某一名字空间声明, 参见条款M20 了解为什么要这么做
const
Rational operator*(
const
Rational& lhs,
const
Rational& rhs)
{
return
Rational(lhs.numerator() * rhs.numerator(), lhs.denominator() * rhs.denominator());
}
//...
Rational oneFourth(1, 4);
Rational result;
result = oneFourth * 2;
// 工作良好
result = 2 * oneFourth;
// 万岁, 它也工作了!
|
Note 当operator*可以完全通过类的公有public接口来实现, 就不需要成为友元; 尽量避免友元函数, 有时候他带来麻烦比帮助多;
某些情况下, 不是成员的函数从概念上说也算是类接口的一部分, 需要访问类的非公有成员的情况也不少;
对于String类, 如果想重载operator>>和operator<<来读写String对象, 就不能写成成员函数:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
// 一个不正确地将operator>>和operator<<作为成员函数的类
class
String {
public
:
String(
const
char
*value);
//...
istream& operator>>(istream& input);
ostream& operator<<(ostream& output);
private
:
char
*data;
};
//...
String s;
s >> cin;
// 合法, 但有违常规
s << cout;
// 同上
|
>这样容易把概念混淆, 注意这里的目标是自然的调用语法, 和前面的说到的隐式类型转换情况不同;
设计非成员操作符函数:
1
2
3
4
5
6
7
8
9
10
|
istream& operator>>(istream& input, String& string)
{
delete
[] string.data;
//read from input into some memory, and make string.data point to it
return
input;
}
ostream& operator<<(ostream& output,
const
String& string)
{
return
output << string.data;
}
|
>这两个函数都要访问private的data成员, 这样就只能成为友元函数; e.g. cin >> s; cin << s;
结论:
1) 虚函数必须是成员函数;
2) operator>>和operator<<决不能是成员函数; 只有非成员函数可以对最左边的参数进行类型转换;
[C++要求赋值=, 下标[], 调用()和访问箭头->操作符必须被指定为类成员操作符; 对于::, :, *, ?不能重载]