条款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/2Rational
b(3, 5); //
b = 3/5Rational
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);};inlinevoid Natural::init(int initValue)
{ value = initValue; }Natural::Natural(int initValue){ if (initValue
> 0) init(initValue); else error("Illegal
initial value");}inlineNatural::Natural(const Natural&
x) { init(x.value); } |
>输入为int的构造必须执行错误检查, 拷贝构造不需要, 因此需要2个不同的函数重载; 两个函数都必须对新对象赋初始值;
写一个包含两个构造函数公共代码的私有成员函数init来解决重复代码的问题; 在重载函数中调用一个"为重载函数完成某些功能"的公共的底层函数的方法很常用(条款12);

本文探讨了C++设计中的关键原则,包括优先使用引用而非值传递、避免切割问题、正确处理函数返回值,以及如何在函数重载和设置参数默认值之间做出明智选择。
627

被折叠的 条评论
为什么被折叠?



