EffectiveC++
术语(Terminology)
1.声明式(declaration):告诉编译器某个东西的名称和类型。忽略其中的细节。
extern int x; // object 声明式
std::size_t numDigits(int number); // 函数(function)声明式
class Widget; //类声明式
template <typename T> //模版(template) 声明式
class GraphNode;
其中size_t只是一个typedef,是c++计算个数时的某种不带正负号(unsigned)的类型。
2.定义式(definition):提供编译器一些声明式所遗漏的细节。对对象而言定义式为编译器为此对象拨发内存的地点。
int x;
std::size_t numDigits(int number)
{
std::size_t digitsSoFar = 1;
while ((number/=10)!=0) ++digitsSoFar;
return digitsSoFar;
}
class Widget
{
public:
Widget();
~Widget();
};
template <typename T>
class GraphNode{
GraphNode();
~GraphNode();
};
3.explicit 声明构造函数
explicit 声明构造函数可以防止被执行隐式类型转换(implicit type conversions),但是仍然可以用来显式类型转换。
class B
{
public:
explicit B(int x = 0,bool b = true);
};
被声明为explicit的构造函数通常比其non-explici更好。因为禁止编译器执行非预期的类型转换,除非你有非常充分的理由,否则建议把构造函数声明为explicit
4.copy 构造函数
copy构造函数用来“以同类型对象初始化自我对象”,通俗的说就是复制一份。具体实现如下:
class Widget
{
public:
int id;
Widget(int id);
public:
Widget();
~Widget();
Widget(const Widget& rhs);
Widget& operator=(const Widget &rhs);
void printWidget();
};
Widget::Widget()
{}
Widget::~Widget()
{}
Widget::Widget(int id) : id(id) {}
Widget::Widget(const Widget &rhs):id(rhs.id)
{}
Widget &Widget::operator=(const Widget &rhs)
{
id = rhs.id;
return *this;
}
copy构造函数是一个重要的函数,它清晰的说明了一个对象如何passed by value
通常来说以by value 传递用户自定义类型通常不是很好的选择,pass by reference to const 是更好的选择。
5.其他
命名习惯方面 lhs 与 rhs 为二元操作符(binary operators)
TR1 (Technical Report 1) 是一份规范,描述加入c++标准程序库的诸多新机能。
Boost开源库。
Effective c++ 第一章 习惯于c++
条款1:c++是一个语言联邦
- C:是的,C语言,包括但不限于:
- 区块
- 语句
- 预处理器
- 内置数据类型
- 数组
- 指针
- Object-Oriented C++,是的,面向对象的C++,包括但不限于
- 构造函数和析构函数
- 封装、继承、多态
- Template C++,泛型编程
- STL:非常重要的一块,包括但不限于
- 容器
- 迭代器
- 算法
谨记
当你从一个次语言切换到另一个时候,会导致高效守则的改变,比如说,当你使用内置类型(C-like)的时候,pass-by-value通常比pass-by-reference更高效,但是当你从*C part of C++移往Object-Oriented C++*的时候,由于用户自定义构造函数和析构函数,pass-by-reference往往更好
条款2:尽量使用const,enum,inline 替换 #define
这个的意思是,尽可能多的让编译器替换预处理器。因为预处理器会根据宏定义的内容,在源代码中直接盲目替换,有可能会导致未知的错误发生。
情况一:预处理器盲目替换
#define ASPECT_RATIO 1.653
当你使用如上的宏的时候,可能会发生:在编译器处理的时候,记号ASPECT_RATIO就被预处理器移走了,所以记号ASPECT_RATIO有可能没进入记号表内,于是当你使用这个变量的时候就会获得一个编译错误信息,而且这个错误信息的内容是1.653,如果ASPECT_RATIO定义在不是你写的代码里,那么这会让你不明所以,解决之道便是使用const:
const double AspectRatio = 1.653
这样写还有一个好处是,防止预处理器盲目地把宏名替换,导致记号出现多份。
情况二:常量指针
为了被不同的源码所包含,所以常量通常被放在头文件内,因此,如果是指针,那么有必要把指针声明为const,例如:
如果要在头文件内定义常量的char*字符串,必须得写const两次
const char *const authorName = "tom";
当然了,咱们是学C++不是C,所以定义成string显然更好
const std::string authorName("tom");
关于const修饰,是指针不变还是指针所指之物不变可见条款3:尽可能多使用const
情况三:class专属常量
为了将常量的作用域限制于类内,则这个常量就必须成为这个类的成员,而为了确保这个成员只有一份,所以,必须得让它成为一个static成员,例如:
class A
{
private:
static const int numTurns = 5; // class 类中专属的常量
};
但是你看到numTurns的是声明,而不是定义,只要不是获取它们的地址,你可以声明并且使用它们而无需提供定义式。类的静态成员不在类内部进行初始化,所以你要单独定义一次,形如:
const int A::numTurns;
但是请把这个式子放在实现的文件里,而不是放在头文件里,值得注意的是,因为声明的时候已经有值了,所以定义的时候可以选择不赋值。
如果,定义了类内静态成员,需要留意一些其他问题,可以参考C++primer第七章第6小节
特别的
万万不可用#define 创建 类专属常量,因为#define并不重视作用域,也就是说,一旦被定义,那么这之后都有效,除非后面#undef,所以,#define也不能提供封装性,也就是说,并没有private #define,但是const可以
情况四:初值的类内常量
假设有这么一段代码
class GamePlayer
{
private:
static const int numTurns = 5; // 声明一个class 类中专属的常量。
int wins[numTurns];
};
在编译期间,需要知道数组大小,所以在编译期间就需要知道常量的值,但是有一些老版本的编译器可能不支持对static整数型class常量赋初值,那么可以采用the enum hack,其理论基础是:”一个属于枚举类型的数值可以权充int使用“,于是GamePlayer可以被定义如下:
class GamePlayer
{
private:
enum {NumTurns = 5};
int wins[numTurns];
};
基于以上代码,我们可以认识到:the enum hack有点类似#define,而不是const,例如:获取一个const地址是合法的,但是取一个enum地址是非法的,当然取#define的地址也是非法的。特别的,enum和#define 都不会导致非必要的内存分配
情况五:宏函数
宏函数看上去像是函数,但是不会招致函数调用带来的额外开销,假如有如下代码
#define MAX(a, b) f((a) > (b) ? (a) : (b))
不论何时,当你使用宏函数的时候,切记给函数的参数加上小括号,理由大家肯定都知道,但是即使你加上了,还会有奇奇怪怪的情况发生,假设有如下代码:
int a = 5, b = 10;
MAX(++a, b); // a被累加二次
MAX(++a, b + 10); // a被累加一次
这段代码可以看出,a的累加次数居然取决于b的数值,所以,当你有想使用宏函数的欲望时,请使用template inline函数
template<typename T>
inline void callWithMax(const T&a, const T&b)
{
f(a > b ? a : b);
}
该函数会比较出大者,然后调用函数f,此外,由于callWithMax是一个真正的函数,所以它遵守作用域和访问规则,例如我们可以实现class内的private inline函数,而宏做不到
条款2小结
- 尽量使用const或enum代替#define
- 对于宏函数,最好改用template inline
条款3:尽可能使用const
情况一:在声明常量时
- const 修饰符用来修饰在不同作用域下的常量。例如class、 global、namespace、文件,函数 等等。
- const 可以用来指出指针/所指的事物是不是const
char greet[] = "hello";
const char *p = greet; // non-const pointer ,const data
char* const p1 = greet; // const pointer, non-const data
const char* const p2 = greet; // const pointer, const data
这一部分内容,大家可以配合C++primer 5th的第二章第四小节
小结
关于const修饰指针,只需要牢记const和星号的位置关系即可
如果关键字const出现在星号左边,表示所指物是常量;如果出现在星号右边,表示指针自身是常量,如果在星号两边都出现了const则表示指针和指物都是常量。
情况2:迭代器与const
STL迭代器是以指针为根据塑造出来,所以迭代器的作用就像T类型的指针。声明迭代器为const就像声明指针为const一样,即声明一个T*const指针,表示这个指针不可以再指向其他的东西,但是指向的东西可以改变
如果你想要迭代器指向的东西不能够改变,则需要使用const_iterator
// T* const 可以改变iter所指的值,但是不能改变iter指针。
const std::vector<int>::iterator iter = vec.begin();
// const *T 不可改变cIter所指的值,但是可以改变iter的指针。
std::vector<int>::const_iterator cIter = vec.begin();
情况3:const 令函数返回一个常量值
为了避免 (a*b)= c; 的情况发生。当然这样的错误应该避免。
使用const 令重载的操作符返回一个const的值就避免了上述不必要的情况发生。
class Rational
{
public:
const Rational operator*(const Rational& lhs,const Rational& rhs);
};
const Rational Rational::operator*(const Rational &lhs, const Rational &rhs) {
return Rational();
}
你可能会觉得这个(a*b) = c的例子很蠢,你想要更要的例子,但是考虑一下你是否写过
if (a * b = c)
{
…;
}
其实本意是用来作为判断条件,但是却因为疏忽而导致如此结果,所以const可以避免这种情况
同理,不仅返回值应该const, 函数的传入参数也应该const,从而避免某些不必要的行为
情况4:const 成员函数
将const实施于成员函数的目的,是为了确认该成员函可以作用于const对象身上。基于两个理由:
- 使const接口比较容易理解。得知哪个函数可以改动对象内容而哪个函数不行。
- 使操作const对象成为可能。因为改善c++ 程序效率的一个根本办法是pass by reference-to-const
并且在c++中,如果两个成员函数
只是常量性
不同,也是能发生重载的,假设有以下的类,被用来表示一大块文字:
class TextBlock
{
private:
std::string text;
public:
const char& operator[](std::size_t position) const;
char& operator[](std::size_t position) const;
};
const char &TextBlock::operator[](std::size_t position) const
{
return text[position];
}
char &TextBlock::operator[](std::size_t position) const
{
return text[position];
}
上面const成员函数的实现,是返回一个const值且text[] 成员在函数中不可改变。非const的版本的返回的值可以改变,并且,成员变量的内容在重载操作符中的可以被改变。
所以,遇到如下代码时:
TextBlock tb("hello");
const TextBlock ctb("world!");
tb[0] = 'x'; // 合法,因为是调用非const版本
ctb[0] = 'x'; // 非法,因为是调用const 版本
如果对调用哪个版本的操作符重载函数感到疑惑,可以看参考C++ primer5th第7章第一小节
如果operator[]返回的不是reference to char 而是char,那么当你写出
tb[0] = ‘x’;
的时候,如果cbt的类型的返回类型是内置类型,那么就无法通过编译,因为这段代码企图更改一个函数的返回值
退一步讲,即使它是合法的,那更改的也只是一个副本,而不是text[0]本身
const成员函数意味着什么?
const前置声明,代表函数返回一个const值,后置声明保证了成员变量的稳定性,即在函数执行的过程中不被改变,这就是 bitwise const原则。
const 成员函数,保证了成员变量在函数实现中不能改变 ,但是有时候需要改变成员变量,此时用一个与const相关的 mutable(可变的) 修饰成员变量来实现。
你可能会对effective在这里的对const讲解感到疑惑,那么还可以参考C++ primer5th第7章第一小节
里面提到:参数列表后面的const是用于修改this指针,默认情况下,this是指向类类型的非常量的常量指针,所以this指针并不能绑定到一个常量对象上,也就是说,我们不能通过一个常量的对象来调用普通的成员函数,所以参数列表后面的const表明this指针是一个指向常量的常量指针
class CTextBlock
{
private:
char *pText;
public:
// 表明该函数不能改变对象
char& operator[](size_t ops) const
{
return ptext[pos];
}
};
这个类不恰当的把operator[]声明为const成员函数,这表明该函数不能改变对象里的值,但是这个函数却返回一个指向对象的引用,所以可能会发生
const CTextBlock cctb("Helo");
char *pc = &cctb[0];
// cctb现在变成了xello
*pc = 'x';
你看,const成员函数就意味着不会更改对象的任何数值,但是却通过const成员函数改变了值,
再比如
class CTextBlock
{
private:
char *pText;
std::size_t textLength;
bool lengthIsValid;
public:
std::size_t length() const;
};
std::size_t CTextBlock::length() const
{
cout<<lengthIsValid<<endl;
if(!lengthIsValid){
// 非法!因为是const成员函数,不能更改非静态的成员变量
textLength = std::strlen(pText);
lengthIsValid = true;
}
return textLength;
}
但是你确实是想摆脱掉const的这点约束就可以使用,mutable,于是乎:
class CTextBlock
{
private:
char *pText;
mutable std::size_t textLength;
mutable bool lengthIsValid;
public:
char* getpText()
{
return pText;
}
CTextBlock();
CTextBlock(char *pText);
std::size_t length() const;
};
std::size_t CTextBlock::length() const
{
cout<<lengthIsValid<<endl;
if(!lengthIsValid){
//cout<<"ok"<<endl;
textLength = std::strlen(pText); //现在可以改变。
lengthIsValid = true;
}
return textLength;
}
情况5:non-const调用const 避免代码重复
对于,”bitwise-constness 非我所欲”的问题,mutable是个解决办法,但是它不能解决所有的const难题,例如
class TextBlock
{
public:
static const int Num = 30;
char text[Num];
public:
char& operator[](std::size_t position) ;
const char& operator[](std::size_t position) const;
};
char& TextBlock::operator[](std::size_t position)
{
text[position]++;
//边界检验的代码(bounds checking)
...
//志记数据访问的代码(log access data)
...
//检验数据完整性的代码(verify data integrity)
...
return text[position];
}
const char &TextBlock::operator[](std::size_t position) const
{
//text[position]++;
//边界检验的代码(bounds checking)
...
//志记数据访问的代码(log access data)
...
//检验数据完整性的代码(verify data integrity)
...
return text[position];
}
在上述代码中,const 和 non-const 的函数实现中都出现了,同样的内容的代码(边界校验,志记数据访问,检验数据完整性),在规模较大的项目中,编译时间过长,维护,代码膨胀都是令人很头疼的问题。
因此,把相同内容的代码移到另一个成员函数并且让operator[]调用它是可行的,但是还是重复了一些代码,如函数调用和两次return
而真正正确的做法是:实现operator[]的功能一次,并使用两次,也就是,一个operatrop[]调用另一个。也就是 常量性转除。类型转换是一个糟糕的想法,但是代码重复也是,本例中const版本完全实现了非const版本的一切,唯一的不同就只是返回类型多了一个const修饰。
本例中将返回值的const转除是安全的,因为不论是谁调用非const版本,都一定要有一个非const对象,否则就不能够调用非const版本,下面是具体实现如下:
class TextBlock
{
public:
static const int Num = 30;
char text[Num];
public:
char& operator[](std::size_t position) ;
const char& operator[](std::size_t position) const;
};
char& TextBlock::operator[](std::size_t position)
{
// 将op[]返回值的const转除
// 先为*this加上const,这样才可以调用const版本的opertor[]
// 然后再调用const_cast再去除const
return const_cast<char&>(
static_cast<const TextBlock&>(*this)[position]
);
}
const char &TextBlock::operator[](std::size_t position) const
{
//text[position]++;
//边界检验(bounds checking)
...
//志记数据访问(log access data
...
//检验数据完整性(verify data integrity)
...
return text[position];
}
上面代码可能有一些难以理解,如果非const只是单纯调用operator[]的话,那么就是递归调用自己,这样将会陷入死循环,所以需要特别指明是调用const版本,所以就有了以上的代码
上述实现过程中有两个转型操作:
- 将this从原始类型TextBlock&转型为const TextBlock&* (转型类似强制类型转换)用来调用 operator[ ] const版本。
- 从const operator[ ]__的返回值中移除const**
注意:反向操作是不允许的:令const版本调用 non-const版本。因为const成员函数承诺绝不改变其对象的逻辑状态(logical state)。但是非const函数却没有承诺,所以只能非const调用const版本
当然,可读性很差是显而易见的,hh,但是相比于代码重复是可以接受的
请记住
- 将某些东西声明为const可以帮助编译器报错。
- 编译器强制实施bitwise constness。但是编写程序是应该使用 “概念上的常量性” (conceptual constness)。
- 当const和 non-const成员函数有着实质等价的实现时,令non-const版本调用const版本,可以避免代码的重复。
条款4::确定对象被使用前已先被初始化
情况1:明白初始化和赋值
为了避免随机初始化的值,导致不可测知的程序行为。我们应当在对象,变量在使用之前保证其已经被初始化。这样初始化的责任就落到了构造函数(constructors)
构造函数的初始化分为 赋值(assignment) 和 初始化(initialization) 通常来说在构造函数中使用 成员初值列(member initialization list) 才是初始化,在构造函数函数体内就是赋值了。
假设有如下实现通讯录的代码:
class PhoneNumber
{
...
};
class ABEntry
{
private:
std::string theName;
std::string theAddress;
std::list<PhoneNumber> thePhones;
int numTimesConsulted;
public:
ABEntry(const std::string &name, const std::string &address,
const std::list<PhoneNumber> &phones);
};
ABEntry::ABEntry(const std::string &name,
const std::string &address,
const std::list<PhoneNumber> &phones)
: theName(name),
theAddress(address),
thePhones(phones),
numTimesConsulted(0)
{}
上述代码是在构造函数中用 成员初值列(member initalization list) 的方法去完成初始化的工作。相比如果用简单的赋值操作进行初始,这样的效率要高。
原因在于:赋值初始化,要经过default 构造函数,然后调用 copy assignment ,而成员初值列是单次调用copy 构造函数效率要高。
在上述代码中 default 构造函数中对numTimesConsulted 成员做了显式的初始化。
当然,如果你想对某个成员变量进行默认构造也可以使用成员初值列,只要括号里没有任何东西即可,例如:
{
ABEntry::ABEntry()
: theName(); // 调用string的default构造函数
theAddress(); // 同理
thePhones(); // 同理
numTimesConsulted(0); // 内置类型得显示初始化
}
但是当你决定这样写时,请遵守:列出所有的成员变量,假如你漏了内置类型,它就没有初始值,那后果就不可估量了
特别的:如果面对的成员变量是内置类型(初始化和赋值的成本相同),也最好使用初值列。如果成员变量是const或者reference,那么它们就一定需要一个初值,而不能被赋值,所以,如果要避免时刻牢记成员变量何时必须使用初值列初始化,何时不需要,最简单的做法就是总是采用成员初值列
但是:如果一个类拥有许多构造函数,每个构造函数都有自己的成员初值列,如果这种类存在很多成员变量或者基类,多个成员初值列会导致不必要的重复工作,这种情况下可以合理的漏掉哪些“赋值表现和初始化一样好”的成员变量,从初始化它们该为给它们赋值,并将这些赋值操作移动到某个private函数里,供所有的构造函数调用
在成员初值列中初始化的顺序最好和 内置型对象的声明 顺序一致,因为C++中,是按照声明顺序初始化的,所以回过头去看,theName永远最先被初始化,然后是theAddress,再来是thePhones,最后是numTimesConsulted,即使再成员初值列中以不同次序出现。
所以为了避免某些错误(两个成员变量的初始化有依赖关系时,如数组的大小),当你使用成员初值列的时候,最好总是以声明次序为次序
如果你已经很小心的把内置类型的成员变量明确的加以初始化,而且也确保你的构造函数运用了成员初值列初始化基类和成员变量,那么你要担心的就只剩下非局部静态变量的初始化次序
情况2:static 对象使用初始化顺序问题
所谓static 对象,其寿命从被构造出来直到程序结束为止,因此 stack 和 heap-based对象都被排除。
- local static :包括global对象,定义于namespace作用域内的对象,在class内,在函数内,以及在file作用域内被声明为static的对象。
- non-local static :其他,程序结束时static对象会被自动销毁,也就是它们的析构函数会在main()函数结束时被自动调用。
编译单元 (translation unit) :是指产出单一目标文件(single object file)的那些源码。基本上它是单一源码文件加上它所含入的头文件。
问题:多个源码文件,每一个至少一个non-local static。当某个源码文件中的non-local static 的动作使用了另一个编译单元中的的某个对象,但是这个对象并 未初始化 因此出现问题。
假设你有一个文件系统,让互联网上的文件看上去像是本机一样,假设某些客户建立了一个目录类,使用到了tfs对象:
class FileSystem
{
public:
std::size_t numDisk() const;
};
std::size_t FileSystem::numDisk() const
{
return 0;
}
// 预留给客户使用的对象,tfs表示the file system
extern FileSystem tfs;
class Directory
{
public:
Directory();
};
Directory::Directory()
{
// 使用tfs对象
std::size_t disks = tfs1().numDisk();
}
那么,除非tfs再Directory的对象之前先被初始化,否则Directory的对象的构造函数会使用到未初始化的tfs,但是tfs和Directory是不同时间不同源码文件建立起来的,如何保证tfs先于Directory?
解决方法便是:Singleton 模式:把每个非局部的静态变量搬到自己的专属函数里(该对象再此函数里被声明为static),这些函数返回一个引用指向它所含的类,然后供用户调用这些函数,而不是直接指涉这些对象,换句话说,非局部静态变量被局部静态变量替换了。
而C++保证,函数内的local static对象会在“该函数被调用期间” “首次遇上该对象之定义式”时被初始化。
所以有:
//这个函数用来替换tfs对象
FileSystem& tfs1()
{
//它在FileSystem class中可能是个static。
static FileSystem fs;
//定义并初始化一个local static对象 并返回一个reference指向上述对象。
return fs;
}
//同上
Directory& tempDir1()
{
static Directory td;
return td;
}
注意:任何一种non-const static对象,不论它是local还是non-local,在多线程的环境下“等待某种事情发生”都会有麻烦,处理这个麻烦的做法是: 在程序单线程启动阶段(single-threaded startup portion )手工调用所有reference-returning函数(也就是上述代码实现),这可以消除与初始化有关的“竞速形势”(race conditions)。
第一章总结
- 为内置对象进行手工初始化。
- 使用构造函数的成员初值列(member initialization list)。
- 为了免除 “跨编译单元之初始化次序” 问题,请以local static 对象替换 non-static对象。具体实现是 reference-returning 函数。
第1章的要点总结,本人会不断更新之后的学习笔记