C++ Primer: Chapter 13,14,15概要

Chapter 7 类

1) this指针

对于this,是每个成员函数中的一个隐含的参数,它代表的是当前执行该成员函数的地址,在每次调用成员函数时,该对象都会将自己的地址初始化给成员函数中的this指针.我们可以将其理解为这种:

object.dosomething();
//相当于
Object::dosomething(&object);//将自己的地址填充到隐含的参数中,该参数也就是this.

关于这个指针,这个指针this是无法修改的,也就说只能指向这个对象,不能修改它使其指向其他的对象.如果引入const符号,那么就可以表示这是一个const Object*类型,也就是说这个对象是不可修改的.也就相当于这个(伪代码):

std::string Scalas::ibsn(const Scalas* const this) { return this->isbn; }

对于一些情况,我需要返回一个this所指向的对象?对于下面这种情况:

Scalas_data& Scalas_data::combine(const Scalas_data& rhs) {
  	units_sold += rhs.units_sold;
  	return *this;
}

这是对于=,+=,-=等运算符常用的方式.

2) = defualt的使用

关于构造函数中的=default表示的是,对于某人构造函数,我显式地声明我所使用的是编译器生成的版本.再primer书中的一些设计中,对于一些可以采用合成的构造函数,都将其设置为=default了.

除了构造之外,析构、拷贝、赋值等操作都有编译器合成的现象.如果一个类内部涉及到一些动态管理内存的,那么使用合成的函数则不大妥当,如果是vector,string等数据类型的数据成员,由于这些容器内部已经做好了关于内存管理的方面的工作,因此合成的函数对于vector,string等类型的数据成员不会有一些不当的影响.

primer中说,对于struct和class唯一的区别就在于默认的访问权限.其中有一种C++编程风格是:对于全public的类,使用struct!

此外关于C++的封装,封装的有点一是可以确保User Code对与一个类对象的内部状态是可控的,此外一旦一些接口的内部细节需要变化,无需在调用处的用户代码作出改变,只需调整内部细节即可.这将给代码的维护带来极大的便利.

3) 向前声明

关于类的向前声明,向程序中引入了名字Screen并且指明Screen是一种类类型,但之后的,在其定义之前是一个不完全类型,不清楚内部的数据成员以及方法等属性,在此期间只可以定义指针或者引用,也可以作为函数参数或者返回值.如果想要创建某个类的对象,必须要有该类的定义.向前声明时常用来解决互为成员的类,但是又不能头文件相互引用的情况.(用来避免互相include头文件)

4) name lookup和作用域规则

一个类就对应了一个作用域,这一点很好理解,这也是为什么我们需要在类外访问一个成员时需要“Object::function”这种形式.比如说:

void YfsBox::setBox(const boxvec& bv) { // 比如说这样的一个函数,其中函数名及其参数列表都可以说处于YfxBox这个class的作用域内部了,因此可以使用boxvec这个在YfsBox内部定义的类型.
  //......
}
YfsBox::boxvec YfsBox::getVec() { // 但如果这种情况,需要注意的是函数的返回值并没有处于YfsBox的作用域内,因此还需要额外加上“YfsBox::”
  //......
}

关于类中的name lookup和作用域的关系.这个过程比较简单:

  • 再该name所在的块寻找声明,只考虑该name引用前的声明.
  • 没有找到的话,就去外层找.
  • 如果一直没有找到就报错.

这里需要注意的是类的编译的过程,一个类的编译分为了两个阶段,第一阶段遍历类及其内部的所有声明,第二阶段直到类都可见之后才编译类中的函数体.这里举一个例子:

using Money = double;
std::string bal;
class Account {
  public:
  	Money balance() { return bal; }
  	void dump(int hight) {
      return hight * 2; // 这个hight究竟是参数中的hight还是private中的hight呢?是参数中的hight,name loopup会优先找到参数中的hight,这样的弊端在于private中的hight将会被隐藏,只能通过this来访问.
    }
  private:
  	using Money = int; //这个将无法对前面声明的Money有效,因为该类型名的定义出现在前面的声明之后.
  	Money bal;
  	int hight;
}

首先我们看类中的声明,比如说balance,其中的Money作为返回类型,首先在Account内部寻找,没有找到因此在向外层寻找,找到其中的using Money = double的定义.之后对于函数体的定义的编译,在此之前类内部的bal已经被声明,因此返回的是private的bal.

往往将using或者typedef放在一个类的开始处.

也就是说自内而外地过程.关于这一部分容易出现的问题在于,内部的name可以将外部的name隐藏,因此一些情况下原本想要访问外部的,单却访问到了内部的name的情况.

Chapt 8 标准库

1. 文件io

其中ifstream、ofstream、fstream(继承自iostream)分别对应了读文件、写文件和读写文件.其中fstream有一些特定的方法,比如说open,close,is_open等等.

在使用时,需要将其与某个文件绑定起来,还需要提供操作位.

关于文件io的这些操作位,会根据文件io流的不同有所限制,比如只有ofstream和fstream可以设置为out模式,trunc和app模式不可兼得.此外还有默认的打开模式,比如说ifstream的默认模式是in,ofstream的默认是out,fstream则是两者结合.

  • 使用out的方式打开会丢失原有的数据.
  • 每次调用open也可以起到重新设置打开模式的作用.

Chapt 13 拷贝控制

1. 拷贝控制

这一部分有两个核心的概念:右值引用,移动操作.主要说copy constructor,move constructor,copy-assignment,move-assignment,destructor等函数.

关于这部分的坑点,主要是当不对某个函数进行定义(比如拷贝构造),编译器就会自动生成,然而自动生成的未必可用.

为什么拷贝构造函数不应该是explicit的呢?因为有几种情况下,需要进行隐式地转化.

关于编译器自动合成的拷贝构造函数,对于其中类成员,则调用其构造函数进行构造,对于内部类则会进行直接拷贝,总的来说,是将类内部的每个元素(非static)逐个拷贝.这种行为对于类成员来说还可以往下进行递归,即使某个数据成员是数组,也会被拷贝.

此外还提出了两个不同的概念copy initializationdirect initialization.其区别在于一个使用构造函数,另一个使用的是拷贝构造函数.

image-20220831232358680

其中拷贝初始化发生的时机有:通过=定义,传参与返回,花括号列表初始化数组或者某个class中的成员.关于函数的传参与返回,很大关系上和参数或者返回值是否是引用类型有关.

正是因为函数传参的过程中会触发拷贝构造,所以拷贝构造的定义应该是引用,否则就会陷入“鸡生蛋,蛋生鸡”的死循环问题.

关于explicit对于拷贝初始化的限制问题,这个关键字通常作用于:告诉编译器是否可以隐式初始化对象.对于加上explicit的拷贝构造函数后,对于像=,函数传参数和返回等会触发隐式拷贝构造的情况会禁止,只有直接地调用拷贝构造函数才可以.直接构造函数也是同理.

image-20220901110642492

其中第二行代码代表赋值操作会触发隐式地出发构造函数的一种场景,因此会被explicit拦截.

对与合成拷贝赋值函数,会对内部的每个非静态成员进行拷贝赋值,如果是数组则是逐个元素地进行.

关于析构函数,由于没有参数的存在,因此也不会被重载,每个类都是唯一的.对于销毁的动作,如果是一个内置类型,什么也不需要做,对于类成员则会调用它自己的析构函数.

关于一个类被析构的时机,其中最重要的是:(1)离开作用域(2)当一个对象被析构其成员也会被销毁(3)对于动态分配的对象,当它的指针被delete时因此也会触发该对象的析构函数.(5)对于临时对象来说,创建其的表达式结束时(6)如果是容器类型,当离开一个vector的作用域,其内部的元素会逐个析构.首先执行析构函数函数体中的代码,然后将成员逐个销毁,其销毁顺序与初始化的顺序是逆序的(此时对于其成员的销毁将会调用各自的析构函数).

=delete和private拷贝构造的好处在于private不能禁止友元函数和类内的调用.

下面是一些建议:

  • 需要析构函数的类往往也需要拷贝和赋值操作,如果内部有一个指针,析构函数如果会释放该指针的内存,如果这个时候用的合成的拷贝和赋值函数,则会在其他对象对该指针有浅拷贝,一旦被析构函数delete,那么其他地方的浅拷贝就会持有死亡的指针.
  • 如果定义了拷贝,最好也定义赋值.
  • default对于一些支持合成的函数有用,=delete用于禁用某个函数,比如拷贝构造和赋值运算符.对于private类型的拷贝和赋值函数来说,可以只声明不定义.

2.拷贝控制与资源管理

书中将类分为两类,一个是行为像指针,另一个像值.

其中“行为像值的类”对于内部核心数据的操作使用的是深拷贝,比如说拷贝构函数进行深拷贝,拷贝赋值函数则是释放当前的数据,然后进行深拷贝.不过要考虑赋值拷贝中的自赋现象.

“行为像指针的类”,比如说shard_ptr.下面是关于引用计数的实现,主要增加一个数据成员size_t * used.当调用析构函数时,这个值-1,当减到0时,将其所维护的指针和used自己都释放.赋值拷贝构造函数除了浅拷贝数据的指针,还需要对于右值的对象的引用计数+1,自己的原本的引用计数-1,如果减到0,如同析构函数.

3.交换操作

一般来说,对于swap的实现,采用交换指针的方式比较好,这样能够减少因为拷贝而带来的开销.拷贝并交换的技术可以用来做拷贝赋值,这对于应对自赋值的作用非常显著.

image-20220901160415799

4. 对象移动

右值引用与move

移动用于不可以被共享的资源,只能有一个所有者.

在内存的操作上,move和copy(深拷贝)的区别在于,是否分配新的内存空间.

其中string,stl容器,shared_ptr既支持移动也支持拷贝,IO类和unique_ptr可以移动但是不能拷贝.

右值引用也就是&&,重要的特点就是只能绑定到一个将要销毁的对象中.对于左值来说可以通过std::move来进行.

int &&rr3 = std::move(rr1);
int &&rr4 = 40; // 将rr4绑定到40上.

关于左值和右值的区别,左值是一个持久存在的变量,而右值则是短暂的,一般是字面常量或者临时对象.因此对于右值引用的作用,我们可以理解为,从一个将要消亡的对象中接管.

移动构造函数

为了可以实现这种操作,比如Object obj(std::move(robj));.这种操作并不能通过一般的构造函数来完成,因此需要移动构造函数.除了初始化左值还要将原来的右值设置为空.

image-20220901162559946

由图中的操作可见,相比于浅拷贝,多了一步,也就是将原来的指针设置成nullptr.当移动操作结束后,原本的源对象会被调用析构函数因此而被销毁,由于这个时候内部的指针已经成空的了,所以即使被delete也不会影响到被move到的对象.

移动赋值操作相比移动拷贝来说有一点不同的地方在于,应该将原本左值中的资源释放掉,然后在“赋值”,不过需要防止的情况就是右值和左值中有相同指针的情况.

至于编译器合成的移动构造函数,其合成发生的条件:没有定义任何自己的拷贝构造函数,并且每个非static都是可以移动的(对于类成员来说,如果它有move操作那么就可以).

此外对于源对象虽然要将其中的指针设置为nullptr,但也要考虑是否出现析构安全问题,或者说有效状态,访问其中的一些方法不会出现一些异常,能够被再次赋新值.

定义了一个移动构造函数和移动赋值运算符也必须定义自己的拷贝操作,否则会将这些成员默认地定义为删除的,一般情况下如果我们不定义移动拷贝/赋值函数就不会有编译器生成,这一点不同于一般的拷贝/赋值等.关于移动被定义为delete的条件:

image-20221205004142974

如果没有移动构造函数只有拷贝构造的情况下,会发生什么?

这种情况下,Object &&会被转换成const Object &,进而调用拷贝构造函数.这时和一般的拷贝构造行为没有区别.

结合swap的移动赋值操作

如果结合其之前的swap操作,那么移动赋值运算符可以有怎样的实现呢?

简单地说,就是将其参数设置成非引用的模式.函数内部采用之前实现的swap.这样实现,如果传入的是一个右值,就会借助移动构造函数进行初始化,否则就是拷贝构造函数进行初始化.当离开作用域后,该参数所对应的对象都将会被销毁.最终这个函数可以呈现一种“通用”的效果.

image-20221205005727197

不过像上面这段代码的前提是能够正确地实现拷贝构造和移动构造.

支持右值引用的成员函数

一个类的成员函数如何实现既支持const Obj &,也支持Obj &&.就比如说STL vector中:

void push_back(const X &);
void push_back(X &&);

至于当我们调用push_back的时候,我们传入的实参的类型将决定具体会执行哪一个.只要借助C++的重载机制即可实现这种机制.

Chapt 14 运算符重载与类型转换

其中的核心问题,在于根据内置匀速运算的机制,去理解参数与返回值类型设计的原因.

1.基本概念

运算符重载本质上还是一种函数,并且还是可以直接调用的.如下:

data1 + data2;
operator + (data1,data2);
//或者
data1 += data2;
data1.operator+=(data2);
//如果是定义的成员函数,这样会出错!如果改成非成员的则不会出错
string s = "world";
string t = "hi" + s; //应该是s + "hi"

对于函数来说,函数有参数,有返回值.参数的话,如果是一元运算符的话,就是一个参数二元运算符则是两个参数.当作为成员函数的时候,第一个参数默认是this指针上的对象.因此看上去,参数总是少一个.

对于某一个运算符在进行重载时应该作为成员函数还是非成员函数呢?

有以下准则可以供参考:

  • =,[],(),->等运算符应该是成员函数,++,--,&等应该是成员.
  • 具有对称性的运算符,可以转换到任意一端,比如算术,相等性,关系等.应该是非成员.

此外,即使是有运算符重载的作用,内置运算符的优先级和结合律仍然一致.

2.输入和输出运算符

对于"<<"的第一个形参是一个非常量ostream对象引用,之所以是引用是因为这个对象我们需要进行改写,并且还不能直接复制过来.第二个const Object&,既避免了复制,也限制了改写.其返回值也是它的形参.

通常情况下,"<<"的运算符的重载允许开发者对其输出格式进行一定的控制,比如说日志库的使用.

此外,输入输出函数不能是类的成员函数.因为这样的话,其第一个参数会是this所指的对象.由于很多时候需要读取类中的私有成员,因此也常被生命为friend.

对于">>"来说,第一个参数是一个要读取的流引用,第二个是要读入到的对象的引用.

比如说:

istream &operator >>(istream &is,Sales_data &item) {
  //......
}

一般来说,输入运算符最好考虑处理失败的情况,当读取操作发生错误的时候,应该负责从错误中进行恢复.输入运算符也最好设置好的流的状态并标识处失败的信息.

3.算术和关系运算符

算术和关系运算符一般定义为非成员函数.形参都是const Object&,返回值就是一个Object.

有个问题,一般在+,-等函数体中,返回的是一个临时对象的拷贝,如果该临时对象内部有一个指针,并且指针所指向的东西在调用析构函数时被释放怎么办?

很多情况下,是根据+=,-=运算符重载实现+,-等运算符的.

==匀速符对于形参也是类似的方式,返回值是一个bool,有一些设计准则:

  • ==,!=都进行定义比较好.但是可以这样: != 通过调用==实现,或者==通过调用!=实现.
  • 应当具有传递性.

有关于<,>,<=等关系与运算符的定义,对于多个数据成员的类需要考虑优先级,在实现时也可以通过基于<去实现>=这种类似的方式.

4.赋值运算符

除了拷贝赋值,移动赋值之外,还有接收{}内参数列表进行赋值的类型.采用以下类似的方式实现:

image-20220901203049121

有关于+=,-=等类型,一般倾向于定义为成员内部函数.通常返回类型该类对象的引用,返回值为*this,

5.下标运算符

下标运算符必须是成员函数.一般会将其定义为两个版本:普通引用和常量引用.

image-20220901203735066

当对象时非常量时,将会调用第一个,如果是常量则会调用第二个.

6.递减和递增运算符

返回值应该是引用,这个原因不必多说,因为++,--的意义就需要将其所跟的对象进行改变.

虽然不一定要求定义为类成员函数,但是还是定义为成员函数比较好.此外有前后置之分,应该分为不同版本.对于前后的区分,则是括号内的int,看似是多了一个形参,其实没有参与到运算过程,只是起到一个标识的作用.

但是后置的返回值通常不是引用类型.因为我们在使用++object时,其返回值是++之前的旧值.

7.成员访问运算符

其中包括*和->.其返回值分别对应引用和指针.

Chapter 15 面向对象程序设计

继承的定义不在多说.

虚函数,对于某些函数,不同的派生类希望继承基类某些方法,但是又希望有不同的实现.

在派生类中,将继承来的虚函数后面加一个override是比较好的编程习惯.

动态绑定是多态实现的基础,需要借助基类的指针或者引用实现,借助指针或者引用调用某个虚函数方法时,会根据该指针或引用所指对象的类型确定具体调用的是虚函数的哪个版本.

动态绑定机制和虚函数相互依存.

1.基类和虚函数的定义

关于基类的定义,需要注意的是将析构函数定义为virtual.

在定义派生类的虚函数时,通常在后面加上override比较好.

关于类型转换方面值得一提,在此先不讨论多继承的情况.静态类型是在编译期间已知的类型,由变量声明或者表达式生成时的类型,而动态类型是在运行时才确定的类型,是变量或者表达式中对应的内存中的类型.对于非引用或者指针的数据类型,其动态类型和静态类型是一致的.

基类不可以向派生类发生隐式转换,如果需要转换则调用dynamic_cast,对于派生类向基类的转换,通常是基于指针或者引用的(会发生隐式地转换),不使用指针和引用则会slice,导致转换后派生类对象转换的结果不再是原来的完整的对象.

此外,对于派生的构造函数,其中关于基类的部分借助基类的构造函数.一般情况下首先初始化基类的部分,然后按照声明的顺序依次初始化派生类的成员.

在访问权限方面,private的在派生类中不可以访问.protected和public则可以.

如何防止继承的发生呢?在基类后面加final.

有关于基类和派生类的类型转换非常重要,关于变量的类型有静态和动态之分.前者在编译时可知,在表达式或者声明时确定,后者则是在内存中的对象的类型,运行时确定.只有引用和指针的情况,才会发生静态数据类型和动态类型不一致的情况,这也是动态绑定的基础.

此外还要注意几个不存在的类型转换的情况:(1)基类向派生类的转换(2)对象之间的转换.对于前者而言,因为一个派生类对象中总是包含有一个基类对象的部分,而反之一个基类对象不一定处于一个派生类对象中,所以不可以.第二个问题的话,容易发生slice的问题(不仅仅是赋值,以及拷贝,移动等同理).

2.虚函数

结合上面所说的动态绑定可知,虚函数的执行只有在运行时才会被解析.如果不是指针或者引用调用虚函数,那么所调用的版本将会是静态确定的.

关于多态

多态存在的根本依赖于引用和指针的静态类型和动态类型不同.当使用基类的引用或者指针调用基类中的一个函数,并不知道该函数的类型,该对象有可能是一个基类对象也有可能是一个派生类对象.

对于虚函数实现时,有一点需要注意:当虚函数返回值某个类的指针或者引用时,返回类型可以不与基类中的匹配.

有关于override的情况,这个标识符可以让编译器对有没有将对应的虚函数进行有效覆盖作出提示.如果是final的函数,则不允许后面的类中对此进行覆盖.

如果想要强制执行某个虚函数版本,而不是动态绑定的结果,那么应该借助作用域运算符.比如说这样:

double undiscouted = baseP->Quote::net_price(42);

3.抽象基类

抽象类不能直接定义该类的对象.抽象类的实现借助纯虚函数的声明来实现.需要注意的是,纯虚函数的函数体其实是可以定义的,但是需要在函数体之外进行定义.在派生类中,只会初始化它的直接基类,其初始化过程采用的是一种递归初始化的方式.

关于继承体系中的友元,基类中的友元不可以被派生类所继承,并且每个类中的友元都不可以跨越其所处的继承层次,而去访问其他层次的数据成员,比如说某个派生类的友元不能访问其基类中的成员.

关于struct和class的区别,还有默认继承权限的区别,struct的默认继承权限是public,而class的默认继承权限是private.

4.继承中的类作用域

继承体系中作用域分布,通常是基类包住派生类的情况,因此派生类中可以自如地访问基类中的成员(在访问权限允许的情况下).

根据这种作用域的分布方式,我们可以因此推算某个成员名解析的过程:其基本过程是在继承体系中的本层逐渐向外层寻找.比如说bulk.isbn()会先在本层Bulk_quote查找,然后向Disc_quote查找,直到最外的基类.

如果是一个基类类型成员的指针或者派生类成员的指针,前者的解析将会从基类这一层开始,后者将会层该派生类开始.

当名字解析时遇到冲突会怎么样呢?这会使得高层的隐藏掉底层的,因为解析方式就是从继承体系高层向底层解析,遇到结果就返回,这个时候肯定优先就在高层返回了.如果想要使用被隐藏的成员,可以通过作用域去返回.如果是成员函数方法也是这样的.

派生类的析构函数,是从本层开始,派生类自己的析构函数首先执行,一层层地到基类函数,最后析构完毕.

如果某个构造函数或者析构函数调用了某个虚函数,应该执行与构造函数或析构函数所属类型相对应的虚函数版本.如果在调用某个基类的构造函数中,会调用某个虚函数的派生版本,这个虚函数可能会需要访问派生类中的成员,然而由于派生类的构造函数还没有执行,所以派生类成员还没有初始化,因此容易造成程序崩溃.

关于name lookup和动态绑定的顺序,是先进行name lookup,然后在进行动态绑定的.