条款22 尽量用传引用而不用传值
C语言通过传值实现, C++继承传统把它作为默认方式, 除非明确指定, 函数的形参总会通过"实参的拷贝"来初始化, 函数的调用者得到的也是函数返回值的拷贝;
"通过值传递对象"的含义是由对象的拷贝构造函数定义的, 这使得传值操作变得很费事:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
class
Person {
public
:
Person();
// 为简化,省略参数
//
~Person();
...
private
:
string name, address;
};
class
Student:
public
Person {
public
:
Student();
// 为简化,省略参数
//
~Student();
...
private
:
string schoolName, schoolAddress;
};
|
定义一个returnStudent, 参数Student, 返回Student:
1
2
3
4
|
Student returnStudent(Student s) {
return
s; }
//...
Student plato;
// Plato(柏拉图)在Socrates(苏格拉底)门下学习
returnStudent(plato);
// 调用 returnStudent
|
首先, 调用了Student的拷贝构造函数将s初始化为plato; 然后再次调用拷贝构造将函数返回值对象初始化为s; 接着s的析构函数被调用; 最后returnStudent返回值对象的析构函数被调用; 这个什么也没做的函数的成本是两个Student的拷贝加析构;
Student对象中有两个string, 每次构造一个Student就必须构造两个string对象; Student是从Person继承的, 每次构造一个Student对象也必须构造一个Person对象; Person内部还有两个string对象...[ - -!], 传值的开销是: 6个构造和6个析构; 两次传值(参数+返回值)就是12个构造, 12个析构;
有些编译器能优化拷贝构造函数的调用, 但还是要对传值造成的开销有所警惕;
Solution: 避免潜在的开销, 通过引用传递对象;
1
|
const
Student& returnStudent(
const
Student& s){
return
s; }
|
>没有新对象被创建, 没有构造或析构被调用;
另外一个优点: 避免了'切割问题' slicing problem; 当一个派生类对象作为基类的对象被传递时, 派生类对象的新特性会被切割掉, 变成一个简单的基类对象, 和预期的不符;
1
2
3
4
5
6
7
8
9
|
class
Window {
public
:
string name()
const
;
// 返回窗口名
virtual
void
display()
const
;
// 绘制窗口内容
};
class
WindowWithScrollBars:
public
Window {
public
:
virtual
void
display()
const
;
};
|
>每个Window对象可以得到自己的名字-name(); 每个窗口可以被显示-display(); display()是virtual的, 意味着简单的Window基类对象display的方式和WindowWithScrollBar不同;
e.g. 写一个函数打印窗口的名字然后显示;
1
2
3
4
5
6
|
// 一个受“切割问题”困扰的函数
void
printNameAndDisplay(Window w)
{
cout << w.name();
w.display();
}
|
当用WindowWithScrollBars对象来调用这个函数时:
1
2
|
WindowWithScrollBars wwsb;
printNameAndDisplay(wwsb);
|
参数w将会作为一个Window对象被创建(传值), 所有wwsb具有的作为WindowWithScrollBars对象的行为特性都被"切割"掉了; 在printNameAndDisplay内部, w的行为和Window对象一样, 不管当初传导函数的对象类型是什么, 对display的调用总是Window::display而不是WindowWithScrollBars::display;
Solution: 通过引用来传递w;
1
2
3
4
5
6
|
// 一个不受“切割问题”困扰的函数
void
printNameAndDisplay(
const
Window& w)
{
cout << w.name();
w.display();
}
|
>w的行为和传到函数的类型一致, const使得w在函数内部不能修改;
传递引用也会增加复杂性, 最大的一个问题就是别名(条款17); 条款23: 有时不能用引用传递对象;
引用几乎都是通过指针来实现的, 所以通过引用传递对象实际上是传递指针; 如果是一个很小的对象--固定类型e.g. int ---这时传值比传引用更高效;
条款23 必须返回一个对象时不要试图返回一个引用
尽可能让事情简单, 但不要太简单 --- 爱因斯坦(据说是 - -!)
C++: 尽可能让程序高效, 但不要过于高效;
Note 传引用可能犯的严重错误: 传递一个并不存在的对象的引用;
e.g. 有理数类, 包含友元函数, 用两个有理数相乘:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
class
Rational {
public
:
Rational(
int
numerator = 0,
int
denominator = 1);
...
private
:
int
n, d;
// 分子和分母
friend
const
Rational operator*(
const
Rational& lhs,
const
Rational& rhs)
// 参见条款21:为什么返回值是const
};
//...
inline
const
Rational operator*(
const
Rational& lhs,
const
Rational& rhs)
{
return
Rational(lhs.n * rhs.n, lhs.d * rhs.d);
}
|
>这个operator*是通过传值返回对象结果;
引用是一个名字, 一个对已经存在的对象起的名字; 无论何时看到一样引用的声明, 就要问自己: 他的另一个名字是什么? operator*要返回一个引用, 那他返回的必然是某个已经存在的Rational对象的引用, 这个对象包含了两个对象相乘的结果;
在期望调用operator*之前有这样一个对象存在是没道理的:
1
2
3
|
Rational a(1, 2);
// a = 1/2
Rational b(3, 5);
// b = 3/5
Rational c = a * b;
// c 为 3/10
|
>对于这样的代码, 期待已经存在一个值为3/10的有理数是不现实的; 如果operator*要返回这样一个数的引用, 就必须自己创建这个数的对象;
一个函数有两种方法创建新对象: 栈stack或堆heap;
在栈上创建局部对象:
1
2
3
4
5
6
|
// 写此函数的第一个错误方法
inline
const
Rational& operator*(
const
Rational& lhs,
const
Rational& rhs)
{
Rational result(lhs.n * rhs.n, lhs.d * rhs.d);
return
result;
}
|
否决的原因: 1) result对象增加了一次构造; 2) 返回的是局部对象的引用;
在堆上创建对象返回引用:
1
2
3
4
5
6
|
// 写此函数的第二个错误方法
inline
const
Rational& operator*(
const
Rational& lhs,
const
Rational& rhs)
{
Rational *result =
new
Rational(lhs.n * rhs.n, lhs.d * rhs.d);
return
*result;
}
|
1) 构造函数的开销; 2) 创建的对象无法delete; 实际上这是一个内存泄露, 即使要求operator*的调用者去取得函数返回地址delete(条款31), 但有些复杂表达式会产生没有名字的临时值: e.g.
1
|
Rational w, x, y, z; w = x * y * z;
|
>有两个对operator*的调用产生了没名字的临时值, 无法删除;
在函数内部定义静态Rational对象:
1
2
3
4
5
6
7
|
// 写此函数的第三个错误方法
inline
const
Rational& operator*(
const
Rational& lhs,
const
Rational& rhs)
{
static
Rational result;
// 将要作为引用返回的 静态对象 lhs 和rhs 相乘,结果放进result;
return
result;
}
|
>实际实现上面的伪代码时会发现, 不调用一个Rational的构造函数的话, 是不可能给出result的正确值的; [对于现有的接口而言]
即使实现了上面的代码, 这个错误的设计导致的结果:
1
2
3
4
5
6
7
8
|
bool
operator==(
const
Rational& lhs,
const
Rational& rhs);
// Rationals 的operator==...
Rational a, b, c, d;
...
if
((a * b) == (c * d)) {
//处理相等的情况;
}
else
{
//处理不相等的情况;
}
|
>((a*b) == (c*d))会yon永远为true, 不管a b c d是什么值 [最后比较的是static变量自己]
等价函数形式: if (operator==(operator*(a, b), operator*(c, d))); 当operator==被调用时, 有两个operator*被调用, 都返回operator*内部的静态Rational对象的引用; 上面的语句实际上是请求operator==对operator*内部的静态对象的值和自己比较; (停止思考静态数组这样的方式, 数组会增加实例开销, 降低程序性能, 在operator*这样的函数思考返回引用是浪费时间, 本来想优化optimeization, 反而变成差化pessimization)
所以, 写一个必须返回新对象的函数的正确方法就是让函数返回对象;
1
2
3
4
|
inline
const
Rational operator*(
const
Rational& lhs,
const
Rational& rhs)
{
return
Rational(lhs.n * rhs.n, lhs.d * rhs.d);
}
|
>用"operator*返回值构造和析构带来的开销"的代价换来正确的程序运行; [正确性是第一位的]
C++允许编译器采用优化措施来提高代码性能, 所以在某些场合operator*的返回值会被安全地除去; 当编译器(当前大多数支持)优化时, 程序运行速度会比你预计的要快;
Note 当需要在返回引用和返回对象间做决定时, 选择正确的那个, 开销由编译器去优化;
条款24 在函数重载和设定参数缺省值间慎重选择
会对函数重载和设定参数缺省值产生混淆的原因在于, 他们都允许一个函数以多种方式被调用:
1
2
3
4
5
6
7
8
|
void
f();
// f 被重载
void
f(
int
x);
f();
// 调用 f()
f(10);
// 调用f(int)
void
g(
int
x = 0);
// g 有一个
// 缺省参数值
g();
// 调用 g(0)
g(10);
// 调用 g(10)
|
一般来说, 如果可以选择一个合适的缺省值并且只用到一种算法, 就使用缺省参数; 否则使用函数重载;
e.g. 计算5个int最大值的函数, 使用了std::numeric_limits<int>::min(), 作为缺省函数值:
1
2
3
4
5
6
7
8
9
10
11
|
int
max(
int
a,
int
b = std::numeric_limits<
int
>::min(),
int
c = std::numeric_limits<
int
>::min(),
int
d = std::numeric_limits<
int
>::min(),
int
e = std::numeric_limits<
int
>::min())
{
int
temp = a > b ? a : b;
temp = temp > c ? temp : c;
temp = temp > d ? temp : d;
return
temp > e ? temp : e;
}
|
std::numeric_limits<int>::min()是C++标准库中的方法, 表示在C中已经定义的INT_MIN宏(<limits.h>), 处理C++源代码的编译器所产生的int的最小可能值;
假设写一个函数模板, 参数固定为数字类型, 模板产生的函数可以打印用"实例化类型"表示的最小值:
1
2
3
4
5
|
template
<
class
T>
void
printMinimumValue()
{
cout << 表示为T 类型的最小值;
}
|
如果只是使用<limits.h>和<float.h>会比较困难, 因为不知道T是什么, 所以不知道该打印INT_MIN还是DBL_MIN或者其他类型的值;
为了避开这类困难, 标准C++库在<limits>中定义了类模板numeric_limits, 这个类模板本身定义了一些静态成员函数; 每个函数返回的是"实例化这个模板的类型"的信息; numeric_limits<int>中函数返回的信息是关于int类型的, numeric_limits<double>中函数返回的信息是关于double类型的; numeric_limits中有min函数, 返回可表示为"实例化类型"的最小值;
1
2
3
4
5
|
template
<
class
T>
void
printMinimumValue()
{
cout << std::numeric_limits<T>::min();
}
|
>看似numeric_limits的方法表示"类型相关常量"开销大, 其实源代码的冗长语句不会产生带目标代码[库]中;
实际上numeric_limits的调用不产生任何指令, 查看numeric_limits<int>min的简单实现:
1
2
3
4
|
#include <limits.h>
namespace
std {
inline
int
numeric_limits<
int
>::min()
throw
() {
return
INT_MIN; }
}
|
>函数声明为inline, 调用时函数体代替函数(条款33); 它只是个INT_MIN, 本身仅仅是个简单的#define, 是"实现时定义的常量";
因此max函数看起来对每个缺省参数进行了函数调用, 其实只不过是用了简单的方法来表示类型相关常量; C++标准库中有很多这样的高效巧妙的应用(条款49);
max函数的关键是: 不管调用者提供几个参数, max计算采用相同(低效率)的算法; 函数内部不必在意哪些参数是外部输入的, 哪些是缺省的; 而且所选用的缺省值不影响算法的正确性; 所以这里缺省方案可行;
对很多函数来说, 找不到合适的缺省值; e.g. 写一个函数计算可多达5个int的平均值; 这里无法使用缺省函数, 因为函数的结果取决于传入的参数个数: 传入n个值, 总数要处以n; 这种情况下必须重载函数:
1
2
3
4
|
double
avg(
int
a);
double
avg(
int
a,
int
b);
...
double
avg(
int
a,
int
b,
int
c,
int
d,
int
e);
|
另一种必须使用重载函数的情况是: 完成一项特殊的任务, 但算法取决于给定的输入值; 这种情况对于构造函数很常见: "缺省"构造函数是凭空(无输入)构造对象, 拷贝构造函数是根据一个已存在的对象构造一个对象:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
// 一个表示自然数的类
class
Natural {
public
:
Natural(
int
initValue);
Natural(
const
Natural& rhs);
private
:
unsigned
int
value;
void
init(
int
initValue);
void
error(
const
string& msg);
};
inline
void
Natural::init(
int
initValue) { value = initValue; }
Natural::Natural(
int
initValue)
{
if
(initValue > 0) init(initValue);
else
error(
"Illegal initial value"
);
}
inline
Natural::Natural(
const
Natural& x) { init(x.value); }
|
>输入为int的构造必须执行错误检查, 拷贝构造不需要, 因此需要2个不同的函数重载; 两个函数都必须对新对象赋初始值;
写一个包含两个构造函数公共代码的私有成员函数init来解决重复代码的问题; 在重载函数中调用一个"为重载函数完成某些功能"的公共的底层函数的方法很常用(条款12);