TODO:还没看太懂的篇章
可以将C++视为以下4种次语言的结合体:
每个次语言都有自己的规范,因此当从其中一个切换到另一个时,一些习惯或守则是可能会发生变化的。
用const替换#define有以下2个原因:
#define PI 3.14
在报错时可能给出3.14而不是PI。当不想让别人通过一个指针或引用来指向某个int常量时,会用到enum hack做法,即用enum来代替(const)int的使用。例如取一个const地址是合法的,但取一个enum地址是非法的。
最后,对于形似函数的宏,最好改用inline。
只要某值是保持不变的,就应该将它说出来。
const出现在*左边表示被指物是常量,在*右边表示指针是常量。
但特殊的,迭代器并非如此。对于迭代器:
const vector<int>::iterator iter // iter类似于T* const
vector<int>::const_iterator citer // citer类似于const T*
对于函数,令函数返回一个const可以预防某些无意义或不消息的错误,例如if (a * b = c)
,当abc是自定义类型时这句话不会报错,但是显然我们要的是==。
两个成员函数如果只是常量性(constness)(即函数中行为是否是不变的)不同,则它们是可以被重载的。关于常量性,有两个概念:bitwise/physical constness和logical constness。
前者指const函数中不更改任何对象的任何一个bit,这也是编译器的策略。
但bitwise constness会有不在预期的情况。例如在函数中有一个指针,更改其所指物的值,这并不违反bitwise constness,但逻辑上不符合我们的预期。
因此就有了logical constness。它允许在函数中做一些修改,只要逻辑上不能变的不变就行,这是通过关键字mutable
对变量进行修饰实现的。mutable释放掉非静态成员变量的bitwise constness约束,使其可以在const函数中改变。
虽然编译器采用bitwise constness策略,但写代码时应该使用logical constness。
另外,一些const函数和非const函数有着相同的实现,为了代码复用,我们可以令非const函数调用它对应的const函数,例如:
class TextBlock {
public:
const char& operator[](size_t position) const {
// 做一些事情,这部分const和非const函数都要做,即是他们俩的共同实现
return text[position];
}
char& operator[](size_t position) {
return const_cast<char&>(
static_cast<const TextBlock&>(*this)[position]
);
}
};
这里做的,首先是将原始的TextBlock&
类型转化为const TextBlock&
进行[]
操作(即调用const版本),然后使用const_cast
从返回值中移除const。
注意不能相反地,用const版本调用非const版本。因为const函数是一种约束,让它调用没有约束的函数就是违反了不改变的承诺。
在构造函数中使用初始化列表比赋值效率更高,因为后者会先调用无参默认构造函数,然后再给成员变量赋值。总是使用初始化列表是没有问题的。
C++有十分固定的成员初始化顺序:基类早于派生类,成员变量总是以声明的顺序被初始化。
另外,为避免多个编译单元之间的非局部静态对象初始化次序问题,应使用一个函数返回指向静态对象的引用而非直接使用静态对象自身(这其实就是单例模式的一个常见手法)。
所谓静态对象,就是指从被构造一直存在到程序结束的对象。静态对象包括全局对象、定义于命名空间作用域内的对象,以及在类内、函数内核在文件作用域内被声明为static的对象。其中,函数内的static对象被称为局部静态对象,其他则是非局部静态对象。
如果不想编译器自动生成拷贝函数等,可以将其声明为private并且不去实现它,这样的话如果别人试图调用则会得到一个链接错误。
当然也可以将这个链接错误提前到编译期,做法是设计一个阻止自动生成该函数的基类,例如不想要自动生成拷贝构造函数和赋值操作符,可以这样实现:
class Uncopyable {
protected:
Uncopyable() {}
~Uncopyable() {}
private:
// 阻止拷贝构造和赋值操作符
Uncopyable(const Uncopyable&);
Uncopyable& operator=(const Uncopyable&);
};
// 然后将自己的类继承自Uncopyable即可
class MyClass : private Uncopyable {
```
};
如果不这么做,在用基类指针析构派生类对象时可能会造成局部销毁,即只析构了基类的部分。
当希望拥有一个抽象类,但并无可用的纯虚函数时,就可以为它声明一个纯虚析构函数,并且必须为它提供一个实现,哪怕为空。
注意给基类声明虚析构函数这个规则只适用于具有多态性质的基类,目的是为了通过基类接口处理派生类对象。如果不是具备多态性,则不用声明虚析构函数。
析构函数绝对不要吐出,而应该捕获任何异常,然后吞下(不处理)或结束程序。
还有一个策略是把可能导致异常的函数不止在析构函数调用,而且提供一个它的调用接口,这样就给了客户一个处理该异常的机会。
简单地说,在基类构造期间,虚函数还不是虚函数。或者根本地讲,在派生类对象的基类构造期间,对象类型是基类而非派生类。
所有的=、+=等操作符都应该return *this
。
形如x = x
这样的自我赋值当然不易发生,但由于基类的指针或引用可以指向其派生类对象,这就是潜在的自我赋值情况。
自我赋值可能引发一些问题,例如下面这种情况在自我赋值时会由于先被delete掉而出错:
class MyClass {
// ```
private:
SomeClass* sp;
};
MyClass& Myclass::operator=(const MyClass& rhs) { // rhs指right hand side,即'='右边的值
delete sp;
sp = new SomeClass(rhs.sp);
return *this;
}
要阻止这种错误,可以在函数最前面做一个identify test来检验:
MyClass& Myclass::operator=(const MyClass& rhs) {
// identify test
if (this == &rhs)
// 如果是自我赋值则什么都不做
return *this;
delete sp;
sp = new SomeClass(rhs.sp);
return *this;
}
除了自我赋值安全性,还要注意异常安全性,例如上例中new那句可能抛出异常,但此时指针已经被delete。
令人高兴的是,让operator=具备异常安全性的同时往往会自动地获得自我赋值安全性,例如这样写:
MyClass& Myclass::operator=(const MyClass& rhs) {
SomeClass* temp = sp;
sp = new SomeClass(rhs.sp);
delete sp;
return *this;
}
另外,与非const函数调用const函数来复用代码相比,不要令拷贝赋值操作符调用拷贝构造函数,因为这就像构造一个已存在的对象。
如果要复用代码,可以提取共同部分创建一个新的函数供它们调用,这个函数往往是private的而且命名为init()。
首先明确资源指的就是一旦使用,将来必须还给系统的东西,把资源放到一个管理资源的对象内,便可以依赖析构函数自动调用的机制确保其释放。
这个观念也叫作资源获取即初始化(Rescource Acquisition Is Initialization, RAII),可以使用智能指针或共享指针来在资源初始化的时候就获取它来实现该功能,也可以自己实现一个资源管理类。
智能指针在使用拷贝构造函数或赋值操作符时,会将之前的智能指针变为null,而复制所得的指针将获得资源的唯一拥有权,以避免将来对同一资源的多次delete。共享指针则没有该特性,而是一种引用计数型智能指针(reference-counting smart pointer, RCSP),它在引用数为0时删除所指物,如果我们想要在引用为0时不删除而是做些其他事情,可以自定义一个删除器(deleter)函数替代其行为。
另外注意,复制资源管理对象时,应该同时复制其拥有的资源,即进行深拷贝。
对于原始资源的访问,可以采用显式的或隐式的转换方法。
auto_ptr和trl::shared_ptr都提供了一个get()显式地访问原始资源,也可以隐式地转换函数,即使用->或*操作符。
RAII类返回原始资源是否违背了封装呢?是的,但并无大碍,因为它本来就不是为了封装而存在的一个类,它只是为了确保资源的释放而已。
我认为这条建议更准确地说应该是:RAII类在初始化并获取资源时,应该是一句独立语句。
例如在调用funcA(std::auto_ptr
这样的函数前,编译器会做以下三件事:
但C++并不保证这些事情的顺序,如果以这样的顺序执行:
并且在调用funcB()时触发了异常,那么RAII的初始化(new SomeClass)和获取(用auto_ptr获得资源)就被分开了,此时new返回的指针就遗失了。
避免这类问题的方法就是将RAII作为一句独立语句:
std::auto_ptr<SomeClass> p(new SomeClass);
funcA(p, funcB());
当new抛出异常前,会调用一个客户指定的错误处理程序,即所谓的new-handler。
我们重载new时必须用std::set_new_handler(someFunc)
来指定new-handler。一个设计良好的new-handler必须做到让更多的内存可被使用,或设置另一个new-handler。new-handler要一直设置并调用下一个new-handler,直到足够的内存被释放出来,或传给它null指针并抛出异常,当然也可以在中间终止程序。
什么时候需要重载new和delete呢?
如果基类重载的new不想用来new派生类对象(多数情况也不应如此,它们的大小是不同的),可以这样来写:
void* Base::operator new(std::size_t size) {
if (size != sizeof(Base))
// 用标准的new处理
return ::operator new(size);
}
在new数组时,也一定要使用delete[]释放,一般情况当然不会犯这种错误,但下面这种情况就容易出错:
typedef std::string Lines[4]; // Lines的4行中每一行都是一个string
std::string* lp = new Lines; // 返回一个string*,就像new string[4]一样
// 此时应该
delete[] lp;
为了避免这类错误,尽量不要对数组形式做typedef。
另外,考虑SomeClass* p = new SomeClass
,这里调用了两个函数,分配内存的new和构造函数。如果new调用成功而构造函数抛出异常,就可能会造成内存泄漏。为了解决这个问题,运行期系统此时会调用这个new对应的delete,但如果我们重载了new而没有重载对应的delete,就真的会发生内存泄漏。
同样的情景,如果重载了placement new就一定要写placement delete。实际上placement delete只有在“其对应的new触发的构造函数出现异常”时才会被调用,而简单地对一个指针使用delete是永远不会调用placement delete的。因此如果要避免placement new相关的内存泄漏,我们需要提供一个正常的delete用于构造期间无异常和一个对应的placement delete用于异常的构造期间。
保证类的约束和一致性等,例如Month类只能有1~12月,可以这样实现:
class Month {
public:
// 以函数替代对象
static Month Jan() {return Month(1);}
static Month Feb() {return Month(2);}
// ···
private:
explicit Month(int m); // 阻止生成新的月份
};
另外,万一客户忘记使用智能指针怎么办?较好的办法是先发制人地让工厂函数返回智能指针,而不是对象:
SomeClass* createClass(); // 客户可能不使用智能指针
std::auto_ptr<SomeClass> createClass(); // 强迫客户使用智能指针
前者往往效率更高,且可避免切割问题(slicing problem),即派生类对象采用值传递的方式,并被视为了一个基类对象,会调用基类的构造函数,进而造成构造不完全。
注意这条并不适用于内置类型和STL的迭代器和函数对象。
如果是栈对象,就不要用指针或引用返回;如果是堆对象,就不要用引用返回;如果是局部静态对象且有可能同时需要多个这样的对象,就不要用指针或引用返回。
一旦一个成员变量被声明为public或protected且客户开始使用它,就很难改变那个变量所涉及的一切,因为需要太多的重写、测试、编译等环节了。
越多的东西被封装,我们改变它们的权利就越大。
有一个反直觉的现象:非成员且非友元函数的封装性是要优于成员函数的。
因为成员函数还可以访问它用不到的private变量或函数等,但非成员且非友元函数则不行,它只能做自己该做的事情。
比较自然的做法是声明这样一个非成员非友元函数,将其放到和类所在的同一个命名空间内。
一个所有参数都要类型转换的例子就是,比如有理数类Rational重载了*操作符,就可以Rational r2 = r1 * someInt
,但不能Rational r2 = someInt * r1
,因为后者调用的是int的*操作符。
此时就应该将重载*操作符作为一个非成员函数。
那么是否需要将其声明为友元函数呢?答案是不用,因为*操作符并不需要用到Rational类的私有变量。注意如果可以,尽量避免友元函数。
本条款只适合C++的面向对象次语言,而不适合模板次语言。
copy and swap原则:为打算修改的对象做一个副本,在副本上做一切修改,如果有问题则抛出异常(此时不影响之前的对象,因此异常安全)。修改完后,在一个不抛出异常的操作中交换原对象和副本。
还有很多看不懂,学一些模板后再看吧。
不止应该延后变量的声明到使用为止,还应该延后到给它赋初值为止。
如果是循环内的变量,则要综合考虑它的构造、析构、赋值代价,以确定是在循环外还是循环内声明。
C++提供四种新的转型方式:
另外注意C++中单一对象可能拥有一个以上的地址行为,如下:
Derived d;
// 这句隐式地将Derived*转换为Base*
Base* pb = &d;
一是容易引起bitwise constness导致的错误。
二是不论这个handle是个指针、引用或迭代器,还是说它是个常量,或返回它的函数是个常量函数,关键是一个handle被传出去了,这就可能造成handle比起所指对象更长寿的风险。
时刻想想某函数抛出异常会怎样,它后面的语句本该执行而没有执行会出问题吗?
关于异常安全有三个等级的保证:
强烈保证往往能以copy and swap实现,但强烈保证并不总是有效,它需要函数中的所有部分都是强烈保证才行。
inline导致的代码膨胀可能导致额外的换页行为,因而降低cache的命中率,进而影响效率。
尝试接口与实现分离,关键在于以声明的依赖性替换定义的依赖性。常用的方法是handle class和接口类。
记住,如果对象引用或指针可以完成定义,就不要用对象,否则还会引出该类型的定义式。并且在要用到其他类时,尽量以它的声明式替换其定义式。
例如把一个Person类分割为两个类,分别是接口类和实现类。接口类只有一个指针成员指向其实现类,这种设计被称为pimpl(pointer to implementation) idiom,而这种类就被称为handle class。
另一种制作handle class的方法是,令Person称为一个抽象基类,即接口类,这种类的目的是详细地一一描述其派生类的接口。它的派生类则需要有办法为它创建对象,这就需要一个特殊的函数扮演派生类的构造函数,这就是工厂函数。
public继承是一种is-a关系,is-a指的是适用于基类的每一件事情都适用于派生类。但有时,常识中的is-a关系在面向对象中并不成立,例如鸟会飞,企鹅继承鸟,但企鹅不会飞。因此类设计需要更多的考虑这些情况(但不需要超出软件需求的考虑,如果需求中用不到企鹅飞的部分,就不需要考虑)。
程序中的对象可以分为应用域和实现域,前者如人、汽车等,后者如缓冲区、锁、查找树等。
当复合在应用域时,表示has-a关系,在实现域时则表示is-implemented-in-terms-of(根据某物实现出)关系。
private继承是一种实现技术,意为派生类是根据基类实现的(这也是为什么继承而来的所有东西都变成private的原因,因为它们都是实现的细枝末节),因此private继承在设计上没有意义,只在软件实现层面有意义。
复合也可以表示“根据某物实现出”的关系,要尽可能使用复合,除非当派生类需要访问基类的protected成员,或需要重新定义继承而来的虚函数时才用private继承,原因有二:
另外,有一种情况是需要使用private继承的,即空基类最优化(empty base optimization, EBO),此时使用private继承可以节约空间。
所谓的空类(empty class)指不使用空间的类,但由于C++的规定,空类至少要被安插一个char的大小到空对象内,因此,对于下面这种情况,sizeof(SomeClass) > sizeof(int)。
class Empty{};
class SomeClass {
private:
int x;
Empty e;
};
此时使用private继承,就可以节约空间,使得sizeof(SomeClass) == sizeof(int)。
class SomeClass : private Empty {
private:
int x;
};
现实中的空类并不真正的空,它虽然没有非静态成员,但往往有typedef, enum, static或非虚函数。
另外补充,如果继承类型是private,编译器就不会自动将派生类对象转换成基类对象。
如果真的有重名,可以使用using来得到被遮盖的变量。
文中还提到可以利用纯虚函数“也可以先实现,且必须在派生类中重新声明”的特点,避免派生类忘记实现虚函数而非预期地使用其缺省版本。具体做法是将原本的虚函数改为纯虚函数并实现它,然后在派生类要用到缺省版本时手动调用之前实现的纯虚函数,这样,在本应实现却忘记实现该虚函数时就会引发报错。
但是和这样麻烦的做法相比,我觉得“记得实现该函数”不应该才是更应该做的吗?
一个例子:
class Shape {
public:
enum Color {Red, Green, Blue};
// 每个形状都要有draw函数,默认为红色
virtual void draw(Color color = Red) const = 0;
};
class Rectangle : public Shape {
public:
virtual void draw(Color color = Blue) const;
/*
* 大错误!虚函数是动态绑定,而默认参数是静态绑定
* 如果有Shape* pr = new Rectangle;
* 因为pr动态类型是Rectangle*,因此调用的是Rectangle的draw()
* 而其静态类型却是Shape*,因此默认参数是来自Shape的Red
*/
};
class Circle : public Shape {
public:
virtual void draw(Color color) const;
/*
* 注意,虽然基类有默认参数
* 但用对象调用该函数时,还是一定要指定参数
* 因为静态绑定下该函数并不从基类继承默认参数
* 但若用指针或引用调用该函数,则可以获得默认参数
* 因为动态绑定下会得到继承
*/
};
当想要类似上面的虚函数表现出期望的行为时,应该考虑替换设计。例如让非虚函数指定默认参数,并调用一个private虚函数来完成真正的工作:
class Shape {
public:
enum Color {Red, Green, Blue};
// 只用来指定默认参数
void draw(Color color = Red) const {
doDraw(color);
}
private:
// 真正完成工作的函数
virtual void doDraw(Color color) const = 0;
};
class Rectangle : public Shape {
private:
virtual void doDraw(Color color) const;
}
注意继承而来的private的虚函数是可以重写的。
多重继承可能引发钻石型继承,这可能会造成派生类从多个路径继承重复的成员,例如:
File中有一个filename,IOFile则会继承到2个filename。如果不想要,就必须令File成为一个虚基类。但虚基类带来的虚继承需要付出代价,除了空间,还有虚基类的初始化要由最低端的类负责。如果必须使用虚基类,尽可能避免在其中放置数据。
模板元编程(Template metaprogramming,TMP)是编写基于模板的c++程序并执行于编译期间。已经证明TMP是一个图灵完全机器,它可以做到任何事情,声明变量、执行循环等。TMP擅长的事情有:
对模板而言,接口是隐式的,多态则是通过模板具现化和函数重载解析发生于编译期。
使用模板可能会导致代码膨胀,模板可能生成多个类和函数。因非类型的模板参数造成的代码膨胀(即模板参数不是类型,而用于其他用途),只需用函数参数或类成员变量来替代模板参数即可。而因类型的模板参数造成的代码膨胀(如针对参数是int和long生成两种代码,但有时它们是相同的),降低膨胀的做法是让它们共享代码。
template<typename T>
void func(const T& container) {
T::const_iterator* it;
···
}
T::const_iterator
上面的T::const_iterator
就是嵌套从属名称,我们想要的是任意一个STL容器T的迭代器const_iterator,但如果T::const_iterator
不是类型呢?如果T刚好有个静态成员变量叫做const_iterator,且it恰好是个全局变量,那么上式就不再是声明一个指针,而是让T::const_iterator
乘以it。
不巧的是,C++的解析器在模板中遇到一个嵌套从属名称时,会假设其不是一个类型。除非我们告诉他,方法就是在其前面加上typename:typename T::const_iterator* it
。
不过还有例外,在继承的基类列表和初始化列表中都不能使用typename。
继续使用有理数类为例:
template<typename T>
class Rational {
public:
Rational(const T& numerator = 0, const T& denominator = 1);
···
};
template<typename T>
const Rational<T> operator*(const Rational<T> lhs, const Rational<T> rhs) {}
此时,如果有
Rational<int> oneHalf(1, 2);
Rational<int> result = oneHalf * 2;
就会编译错误。
编译器要找到某个operator*接受两个Rational
,就需要推导出T。而为了推导出T就需要查看两个参数。第一个Rational
参数oneHalf是Rational
,因此T就一定是int,但第二个Rational
参数2是一个int,就推导不出T的类型。要知道编译器在模板实参推导过程中不会考虑隐式类型转换函数,因此也就不会将int隐式转换为Rational
。
解决方法则是将operator*的合并到其类内:
template<typename T>
class Rational {
public:
···
friend const Rational<T> operator*(const Rational<T> lhs, const Rational<T> rhs) {}
};
有趣的点在于,虽然使用了friend,但与其传统用途(访问类的非公有成员)毫不相干。为了让类型转换发生到所有实参身上,我们需要一个非成员函数。而为了让这个函数被自动具现化,我们需要将其声明在类内部。最终,在类内声明一个非成员函数的唯一方法就是,使用friend。