3708 字
19 分钟
More Effective C++ 笔记
2023-02-27

基础议题#

条款4:非必要不提供默认构造#

这一点可能和我们之前的认知有差异。但是也是正确的。原因是在语义上或者是设计上,针对某一些类型,如果我们不能提供一个初值来初始化一个对象,那么通过默认构造实例化出来的这个对象会是无意义的。

当然了,还有很多类型是允许有默认对象的,比如空的容器之类的。

  • 所以,如果类型展示出:从无到有生成对象是合理的 的语义,则应该有默认构造。
  • 但是如果类型展示出:必须有外来信息才能生成对象 的语义,则不应该有默认构造。

但是为了表现出这种清晰的语义,会有诸多限制。

  • 当然,需要注意有些函数或容器强调参数或元素必须是可默认构造的。
  • 同时,在继承环境下,如果基类不是可默认构造的,那么就需要显式调用基类的构造。非常头疼。

操作符#

条款8#

查看memory3

效率#

条款17 考虑使用 缓式评估#

缓式评估就行copy on write 写时复制一样。当我们调用拷贝构造的时候并不一定拷贝的副本立刻被使用,有可能从不使用或者是从不更改。所以此时可以单独的做一个标记,当真的对副本进行修改的时候再进行构造动作。

条款18 分期偿还预期的计算成本(超急评估)#

其实就是和17反过来。如果某些大概率或一定会使用的数据,尤其当这些数据使用频繁的时候,尝试设计一种数据结构进行预加载,当做一种缓存。

条款21 考虑利用重载来避免隐式类型转换造成的临时对象。#

我们在这一条中讨论的不是是否禁止隐式类型转换,主要是讨论如何降低开销。

假设我们有这样的简朴的代码:

struct myclass{
int val;
myclass(int x):val(x){
cout <<"const" << endl;
};
};
const myclass operator+(const myclass& lhs, const myclass& rhs){
return myclass(lhs.val + rhs.val);
}
int main(){
myclass a(20);
a+20;
20+a;
}

这段代码会一共构造五次。第一次是a,第二次是20的隐式类型转换。第三次是a+20的返回值。第四次是20的隐式类型转换,第五次是20+a的返回值。

我们此时可以多写出两个重载的版本。

const myclass operator+(const myclass& lhs, int rhs){
return myclass(lhs.val + rhs);
}
const myclass operator+(int lhs, const myclass& rhs){
return myclass(lhs + rhs.val);
}

之后,执行代码就只会构造三次。分别是a,a+20和20+a。没有隐式转换造成的临时对象的开销。

条款22 针对操作符,考虑同时提供复合形式(+=或-=)和单独形式(+,-)#

主要原因是如+=和-=形式的符合操作符是直接作用于自身,所以返回的是T&形式。但是单独形式的操作符一般都是const T形式。所以显然前者效率较高。

同时,应该以复合形式为基础,实现单独形式 。也就是在operator+内部使用operator+=。因为这样只需要更改operator+=就可以改变其行为。

条款26 限制某个class所能产生的对象数量#

主要讲了单例模式 和 对象数量计数器。需要计算数量的对象可以继承自对象数量计数器基类。行为有一点像侵入式智能指针。

计数器基类大概长这样**(利用了CRTP)**:

template<typename T>
struct counts{
static int count;
void increase(){
count++;
}
void decrease(){
count--;
}
static void get(){
cout << count << endl;
}
};
template<typename T>
int counts<T>::count = 0;
class myobj : counts<myobj>{ //这是一种CRTP。
//具体内容
};
class myobjanother : counts<myobjanother>{
//具体内容
};

既可以手动分离处理加减,也可以直接在计数器基类的构造或析构函数中计算加减。一个是手动一个是自动而已。

条款27 要求或禁止对象产生在堆中#

要求对象产生在堆中#

  • 将对象的析构函数声明为protected。如果有继承,则必须要同时声明为virtual。
    • 因为外部无法访问对象的析构函数,则编译器禁止在栈上创建对象
    • 声明为protected而不是private的目的是让子类可以访问析构函数。
    • 注意子类也应该将析构函数声明为protected,否则子类对象会被允许创建在栈上。
    • 注意杂记4中提到的自动储存期限。所以子类如果在栈上,则子类的父类部分也在栈上。子类在堆上,则子类的父类部分也在堆上。
    • 格外注意。如果将析构函数声明为protected或private,仅意味着不可在外部调用析构函数。但是在该类的成员函数中依旧可以调用析构函数,这意味着在该类的成员函数中,我们依旧可以将对象创建在栈上。
  • 声明一个destroy函数,用于调用析构函数。
    • destroy函数是否为virtual不重要,因为delete会调用对应的virtual析构函数。
#include <string>
#include <map>
#include <iostream>
using namespace std;
struct myclass{
myclass(){
cout <<"myclass const" << endl;
}
void destroy(){
delete this;
}
void myclassmemberfunction(){ //成员函数
myclass obj; //可以创建在栈上。因为成员函数可以访问到本类的所有成员
}
protected: //protected
virtual ~myclass(){ //虚函数
cout <<"myclass dest" << endl;
}
};
struct derive: public myclass{
derive(){
cout << "derive const" << endl;
}
void destroy(){
delete this;
}
void derivememberfunction(){ //成员函数
derive obj; //可以创建在栈上。因为成员函数可以访问到本类的所有成员
}
protected: //protected
~derive(){
cout <<"derive dest" << endl;
}
};
int main(){
myclass* p = new myclass();
p->myclassmemberfunction(); //成员函数
p->destroy();
derive* pp = new derive();
pp->derivememberfunction(); //成员函数
pp->destroy();
myclass* ppp = new derive();
ppp->destroy();
myclass pppp; //禁止
derive ppppp; //禁止
return 0;
}

禁止对象产生在堆中#

  • 非常简单。只需要把operator new 和 operator delete声明为private即可。
    • 也可以同时把operator new[] 和 operator delete[]声明为private
struct myclass{
myclass(){
cout <<"myclass const" << endl;
}
~myclass(){
cout <<"myclass dest" << endl;
}
private:
static void* operator new(size_t size); //私有
static void operator delete(void* ptr); //私有
};
struct derive: public myclass{
derive(){
cout << "derive const" << endl;
}
~derive(){
cout <<"derive dest" << endl;
}
};
int main(){
derive obj;
derive* p = new derive(); //错误
return 0;
}

条款28 智能指针#

都在智能指针章节。

条款29 引用计数#

大概和智能指针的引用计数理论一致。额外的就是COW相关的实现。还有就是operator[]的语义,这部分写在effSTL的map的[]部分了。

条款 30 代理类#

STL的例子是vector<bool>参考effective STL 19条

书里的例子是尝试用别的方法实现重载operator[][]的语义。因为压根没有operator[][]。

struct arr2{
struct arr1{ //代理类
arr1() = default;
arr1(int x, int y):pivot(x), pivot2(y){}; //假设每个arr1拥有两个元素
int pivot = 20;
int pivot2 = 30;
int& operator[](size_t index){ //返回实际元素的引用,满足赋值要求。
cout <<"arr1 []" << endl;
if(index == 0){
return pivot;
}
else{
return pivot2;
}
}
};
vector<arr1> arrs{arr1(1,2),arr1(3,4), arr1(5,6)};//我们假设arr2拥有三个arr1
arr2() = default;
arr1& operator[](size_t index){ //代理operator[],直接返回对应下标的arr1对象的引用。满足赋值要求
cout <<"arr2 []" << endl;
return arrs[index];
}
};
int main(){
arr2 obj;
cout << obj[2][1] << endl;
cout << (obj.operator[](2)).operator[](1) << endl; //等同于上面
obj[2][1] = 300;
cout << obj[2][1] << endl; //赋值也没问题
arr2::arr1 temp = obj[2]; //单独提取出第一维对象也没问题。
//第一个operator[]返回的是arr1对象。所以如果再次链式调用operator[]就会自然匹配到arr1的那个而不是arr2的
return 0;
}

我们想模拟二维数组的语义,但是这里模拟的还是有问题。图一乐就行

obj[2][1]的语义是(obj.operator[](2)).operator[](1);

我们核心想法是让第一层arr2储存一堆的arr1。然后arr2的operator[]会返回对应的arr1对象。此时,如果链式调用,第二个operator[]自然会是arr1的。因为当前的operator[]是作用在arr2的operator[]返回的arr1上面的那个

让operator[]可以区分左值和右值(不是真正区分,而是区分行为)#

一般来说,左值是写入行为,右值是读取行为。这个例子只能用书里的。

class String {
public:
//代理类用于区分operator[]的读写操作
class CharProxy { // proxies for string chars
public:
CharProxy(String& str, int index); // creation
CharProxy& operator=(const CharProxy& rhs); // 拷贝赋值。左值运用的场景,这种适用于 s1[3] = s2[8]的场景
CharProxy& operator=(char c); // 拷贝赋值。左值运用的场景,这种适用于 s2[5] = 'x'的场景
operator char() const; //右值运用的场景。
private:
String& theString; //用于操作String,并在适当时机开辟新内存并复制
int charIndex;
};
const CharProxy operator[](int index) const; // string类的 operator[]重载
CharProxy operator[](int index); // string类的 operator[]重载
...
friend class CharProxy;
private:
RCPtr<StringValue> value;//见条款29
};
  • 就像书里的例子。s2[5] = 'x';由于下标访问运算符重载为返回一个代理对象,所以此时的赋值会调用代理类对象的赋值。也就是CharProxy& operator=(char c);这个函数。这时候s2[5]的语义是左值。

  • 针对s1[3] = s2[8]这个场景,虽然我们知道s2[8]没有写入,应该是个右值。但是注意,因为书中String实现了写时复制,所以operator[]本身是无成本的。因为代理对象只保有数据的引用。随后直到调用了左侧的代理对象的CharProxy& operator=(const CharProxy& rhs); 这个函数,这时候才开始相应的动作。所以这里我们本身无法区分左值和右值,但是我们可以从行为上区分左值和右值。

String::CharProxy& String::CharProxy::operator=(const CharProxy& rhs)
{
if (theString.value->isShared()) {
theString.value = new StringValue(theString.value->data);
}
theString.value->data[charIndex] = rhs.theString.value->data[rhs.charIndex];
return *this;
}
  • 针对如cout << s1[2];这种场景,非常明确s1[2]的行为是右值行为,也就是读取行为。所以说压根没有必要做出任何的额外成本动作
String::CharProxy::operator char() const
{
return theString.value->data[charIndex]; //单纯的返回一个字符。这个data的类型是一个char*数组。所以这个下标访问是内置的。
}
  • 潜在的问题是代理类非常复杂,常常会造成语义的改变。因为目标对象和代理对象的行为常常有细微差异。比如在上文的例子中char* p = &s[2]就无法通过。

    • 因为取出来的地址的类型是代理类类型。无法赋值给char类。由于我们目标类的operator[]返回的是代理类对象,所以这时候我们必须重载代理类的取地址运算符operator&
  • 但是这不能解决所有问题。假设有一个类A引用了上面的这个蕴含代理类的目标对象,那么直接针对这个A使用目标对象的operator[]依旧返回的是代理类对象。此时如果我们想进行函数调用,那么就会出现问题。因为取回来的并不是类A对象,代理类对象并没有这个特定的成员函数。所以需要在所有的函数上都进行重载让他们也适用于代理类对象。

  • 同时,我不知道为啥上面的目标类的operator[]一定要返回代理类的对象而不是代理类的引用。则此时在赋值方面会出现问题。

  • 最后一个问题是隐式类型转换。隐式类型转换中,每一个步骤都只能执行一次。也就是每个步骤都只能进行一个层次的转换。比如a可以由int构造,b可以由a构造。那么如果一个函数接受一个b类对象,可以传入a,但是传入int就不可以。

条款31 让函数根据一个以上的参数类型来决定如何虚化#

主要讲述的是multi-dispatch。

我们知道,一般来说多态是单个参数的。也就是依靠调用方的多态来决定调用哪个类的函数。那么如何实现多重派发呢(也就是同时根据调用方和入参来进行多态调用)?

第一个方法是虚函数+RTTI。

这个办法非常简陋。也就是先设计一层虚函数,然后再对应的虚函数内使用typeid判断,随后执行对应的函数。这种做法效率非常的低,并且伴随着一大堆的cast

第二个方法就是只使用虚函数

struct banana; //前向声明
struct apple;
struct kiwi;
struct fruit{ //抽象基类
virtual void blend(fruit* obj) = 0; //通过该函数决定每一个类的二次派发。
virtual void blend(banana* obj) = 0;
virtual void blend(apple* obj) = 0;
virtual void blend(kiwi* obj) = 0;
};
struct banana:fruit{
virtual void blend(fruit* obj){ //通过该函数决定每一个类的二次派发。
obj->blend(this); //注意切记不可写反写成this->blend(obj)。会造成递归调用。
}
virtual void blend(banana* obj){
cout <<"banana blend banana" << endl;
}
virtual void blend(apple* obj){
cout <<"banana blend apple" << endl;
}
virtual void blend(kiwi* obj){
cout <<"banana blend kiwi" << endl;
}
};
struct apple:fruit{
virtual void blend(fruit* obj){
obj->blend(this);
}
virtual void blend(banana* obj){
cout <<"apple blend banana" << endl;
}
virtual void blend(apple* obj){
cout <<"apple blend apple" << endl;
}
virtual void blend(kiwi* obj){
cout <<"apple blend kiwi" << endl;
}
};
struct kiwi:fruit{
virtual void blend(fruit* obj){
obj->blend(this);
}
virtual void blend(banana* obj){
cout <<"kiwi blend banana" << endl;
}
virtual void blend(apple* obj){
cout <<"kiwi blend apple" << endl;
}
virtual void blend(kiwi* obj){
cout <<"kiwi blend kiwi" << endl;
}
};
int main(){
fruit* o = new banana();
fruit* k = new kiwi();
o->blend(k); //kiwi blend banana
return 0;
}

我们看到我们通过两层虚函数来实现两层派发。具体原理是o->blend(k)的o是第一层派发。通过o的动态类型决定进入对应的类。由于此时我们传入的指针类型是fruit。由于只能子转父,不能父转子,所以必须要有void blend(fruit* obj)这个东西帮助我们进行第二层派发。o的动态类型此时是banana,所以此时进入了banana的blend函数。在这个函数中,我们的obj->(this)的obj就是第二层派发。根据obj的动态类型决定进入对应的类的blend函数。此时obj的动态类型是kiwi。所以此时是调用了kiwi类的blend函数。同时我们知道,每个类的this指针的“类型”都是本类类型。因为它永远指向自己。所以此时this的类型是banana。所以最后我们调用了kiwi类的banana为参数的函数。

问题在于这种方案非常的不符合设计理念。因为只要我们增加了一个对应的水果,就要修改每个类型。

More Effective C++ 笔记
https://sterben-01.github.io/posts/MoreEffectiveC++/
作者
Sterben_01
发布于
2023-02-27
许可协议
CC BY-NC-SA 4.0