C++之对象模型

关于对象

  • C++在布局及存取时间上的主要额外负担是由virtual带来的,包括:
    • 虚函数机制,用以支持一个有效率的执行期绑定
    • 虚基类,用以实现多次出现在继承体系中的基类,有一个单一而被共享的实体
    • 发生在子类和父类之间的转换
  • 对象模型:
    • 有两种数据成员:静态和非静态;三种成员函数:静态/非静态/虚函数;非静态成员置于类内,静态数据成员,静态成员函数和非静态成员函数虽然在class的声明之内,都被存放在所有class对象之外
    • 虚函数:有两个步骤支持
      • 每一个class产生出一堆指向虚函数的指针,放在一个一维数组中(虚表(vtbl));派生类会生成一个兼容基类的虚函数表
         1. vs怎样查看虚函数表?打开vs的命令行工具Command prompt;切换到cpp文件目录下,输入cl /d1 reportSingleClassLayoutXXX YYY.cpp,其中XXX是类名,YYY是类所在的cpp文件名
         2. 每一个类对象添加了一个虚指针(vptr),指向相关的虚表;vptr的设定和重置由类的构造,析构和拷贝赋值运算符自动完成;每一个类所关联的type_info对象(用以支持运行时类型识别)放在虚表的第一个位置,其他的虚函数按照声明顺序(先父类,再子类,同类中的声明顺序)依次放入
    • 继承。虚继承的情况下基类不管在继承串链中被派生多少次,永远只会存在一个实体
  • 为了支持POD类型(保证与C兼容的空间布局),可以将pod类型抽出来作为基类再派生需要的class;但这种作法已经不再被推荐(因为某些编译器可能会做改变);组合而非继承,才是把C和C++结合在一起的唯一可行方法
  • 对象的差异:
    • 多态操作要求对象只能是一个指针或引用,指针或引用之所以支持多态,是因为他们并不会引发内存中任何与类型有关的内存委托操作;会受到改变的只是他们所指向的内存的大小和内容解释方式
    • C++的多态只存在于一个个公有类体系中,非公有的派生行为以及类型为void*的指针可以说是多态,但并没有被语言明白地支持,只能通过程序员明白的转型操作来管理。C++以下列 方式支持多态:
      • 经由一组隐含的转换操作,如一个基类指针指向公有继承的子类对象
      • 经由虚函数机制(对象调用虚函数)
      • 经由dynamic_casttypeid运算符
    • 多态的主要用途是经由一个共同的接口来影响类型的封装,这个接口通常被定义在一个抽象的基类中
    • 类对象需要多少内存:
      • 非静态数据成员的总和大小
      • 对齐而填补的字节数
      • 为支持虚函数而产生的额外负担
    • 指针类型:指针类型会教导编译器如何解释某个特定地址中的内存内容及其大小,
      • 所以转型其实是一种编译器指令,大部分情况下并不改变一个指针所含的真正地址,只影响被指出之内存的大小和内容的解释方式
      • 也是为什么一个void*的指针只能够含有一个地址而不能通过它操作所指的object的缘故
    • 加上多态之后:
      • 如果虚函数被定义为inline,则效率上有很大的收获

构造函数语意

默认构造的建构

  • 编译器需要的时候才会合成默认构造,被合成的构造只执行编译器所需的行动,像一些数据成员的初始化操作并不执行,那是程序员的职责,编译器需要默认构造并合成的几种情况:
    • 带有默认构造的对象的数据成员:
      • 这个合成操作只有在构造真正需要被调用时才发生,那么问题来了,在C++的各个编译模块中编译器如何避免合成出多个默认构造?解决方法是把合成的所有方法都以inline方式完成,如果函数太复杂不适合做成inline就会合成出一个explict non-inline static实体
      • 如果有多个成员都要求构造函数初始化,以在class中的声明次序来,在用户代码之前调用成员的构造
    • 带有默认构造的基类:一个没有任何构造的类派生自一个带有默认构造的基类,需要被合成出来调用基类的默认构造
      • 如果子类有除默认构造之外的构造,编译器会扩张现有的每一个构造,将调用所有必要的默认构造的代码加进去
      • 如果存在带默认构造的成员,先基类构造,再成员默认构造
    • 带有一个虚函数的类,两种情况下需要合成默认构造函数:因为要初始化vptr
      • class声明或继承自一个虚函数
      • class派生自一个继承串链,其中有一个或更多的虚基类
    • 带有一个虚基类的类:通过派生类访问成员时需要知道编译器产生指针指向虚基类——?
  • 显式定义默认构造:T() = default;
  • 两个误解:都是不成立的
    • 任何class如果没有定义默认构造,编译器都会合成一个出来
    • 编译器合成出来的默认构造会明确class内的每一个数据成员的默认值
  • 不支持静态构造函数
  • 构造函数不能被 声明为常量成员函数——?;
  • 避免使用默认构造(more effective c++ 4):一个类如果没有缺省构造,在三个方面使用会有问题:
    1. 不能建立对象的数组——可以利用指针数组来代替,但有缺点:增加了内存分配量,可以为数组分配原始内存,这样就避免了浪费内存
    2. 无法在许多基于模板的容器里使用——通过仔细设计模板可以杜绝对缺省构造函数的使用
    3. 不提供缺省构造的虚基类很难与其进行合作

拷贝构造的建构

决定一个拷贝构造能否被编译器合成的标准在于class能否展现出位逐次拷贝,如果能展示出位逐次拷贝就不需要合成

  • 有三种情况会调用拷贝构造(注意后两种不是调用拷贝赋值运算符,实测):
    • 一个对象初始化另外一个
    • 参数值传递
    • 返回值拷贝
  • 一个类什么时候不展示出位逐次拷贝?前两种情况将合成的构造安插到拷贝构造中
    • 当class有一个成员对象且这个成员对象有一个拷贝构造时(不管这个拷贝构造是明确定义的还是编译器合成的)
    • 当class继承自一个base class而该base class有拷贝构造函数(不管是明确定义的还是编译器合成的)
    • 当class声明了一个或多个虚函数的时候:需要拷贝构造初始化vptr
    • 当class派生自一个继承串链,其中有一个或多个虚基类
  • 拷贝构造要还是不要?
    • 如果支持位拷贝,不需要提供拷贝构造,因为编译器自动为你实施了最好的行为,既快速又安全
    • 如果需要拷贝构造,使用memcpy会更有效率,不管使用memcpy或memset,都只在class不含任何编译器产生的内部成员时才安全,如有虚函数或虚基类就不安全
    • 类的拷贝操作在什么情况下可以直接使用memcpy?
  • 拷贝构造函数通常不应该是explicit的,因为如果没有拷贝赋值运算符,=会被转换为拷贝构造的调用
  • 关于拷贝构造和拷贝运算符:基本上声明时会使用拷贝构造函数,赋值语句会使用拷贝运算符
MyClass a{5};   // 拷贝构造函数
MyClass b = a;  // **这里也是调用拷贝构造函数**,因为这条语句是一个声明
a = b;          // 调用 operator=

程序转化语意

  • 明确的初始化操作:a. 重写每一个定义,其中的初始化操作会被剥除;b. class的拷贝构造的调用操作会被安插进去
  • 参数的初始化:
  • 返回值的初始化,以下例子也是在编译层面做优化(NRV(返回值)优化):
X bar(){
    X xx;
    return xx;
}
// 转化过程(可能每个编译器的实现不一样):
  1. 首先加一个额外参数,类型为对象的引用;
  2. 在return之前安插一个拷贝构造操作,return空(这就是编译器省略拷贝构造的唯一一种情况<Modern C++ Desgin P123>)
void bar(X &_result){
    X xx;
    xx.X::X();
    _result.X::X(xx);
    return ;
}
// 所以:
X xx = bar();
// 转换为:
X xx;    // 注意,这里是不需要施行默认构造的
bar(xx);

如果是函数指针也是同理

  • 在程序员层面优化:
X bar(const T& y, const T& z){
    X xx;
    return xx;
}
// 这个会要求xx被membersize地拷贝到编译器所产生的结果中,使用以下替代:
X bar(const T& y, const T& z){
    return X(y, z);
}
// 效率会比较高,避免了一次拷贝构造:
void bar(X& __result, const T& y, const T& z){
    __result.X::X(y, z);
    return;
}

成员函数的初始化

  • 必须使用列表初始化:
    • 初始化一个引用成员
    • 初始化const成员
    • 调用基类的构造,而基类构造有一组参数(严格的说应该是没有默认构造)
    • 调用成员的构造,而成员构造有一组参数(同上)
  • 初始化列表的内部实现是将代码安插在构造函数体中,初始化的顺序是按声明次序而不是列表初始化的顺序,初始化执行代码置于任何显式用户代码之前;析构的时候是按相反的顺序吗?
  • 在构造函数初始化列表中调用成员函数(注意所用到的成员变量是否已经初始化)是可行的,但尽量避免这么做,因为你不知道这个成员函数对类型的依赖性又多强,如果依赖的数据成员还没有完成初始化就有问题
  • this指针在什么时候构建妥当:X::X(/*this pointer, */ int val){ … }
  • 派生构造中,派生类的成员函数返回值被当作base类的构造参数也是有问题的,vptr的初始化是在基类构造完成之后,是静态调用

Data语意

数据成员的布局

  • sizeof一个空类为1,因为编译器会安插进一个1byte的char以表示对象在内存中配置独一无二的地址;继承自空虚基类的空类有多大
// 64位测试
class X{};  // sizeof(X)==1
class Y: public virtual X {};    // sizeof(Y)==8
class Z: public virtual X {};    // sizeof(Z)==8
class A: public Y, public Z {};  // sizeof(A)==16
  • 数据成员:
    • 非静态成员的顺序和在类中被声明的顺序一致,任何中间插入的静态成员都不会被放进对象布局中
    • C++标准要求同一个访问限制符下的成员只需要符合“较晚出现的成员有较高的位置(和栈向下增长的不符?经验证:全局函数中的局部变量和指针向下增长;类中的成员栈向上增长,搞不清了)”即可,各个成员并不一定得连续排列,什么东西可能介于被声明的成员之间呢,如为对齐而补齐的字节,vptr 等
    • C++没有规定不同的访问限制符下的成员的排列顺序,但大部分编译器还是按照顺序排列
    • C++标准允许vptr放在对象的任何位置,一般编译器会放在对象的头部或尾部

数据成员的存取

  • 普通成员的存取成本是多大?每个成员的存取,以及与class的关联,并不会导致任何空间或执行时间上的额外负担
  • 静态数据成员:经由对象的.或者->对静态成员的存取操作只是语法上的一种便宜行事,即便是定义在虚继承基类内,存取路径仍然很直接,所以封装不会带来存取成本
    • 被编译器提出class之外,并被视为一个全局变量,存放在data段中,所以若取一个静态数据成员,会得到指向其数据类型的指针而非一个指向其类成员的指针。每一个静态成员只有一个实体
    • 如果不同的class有同名的static成员,都存放在data段中,怎么做区分?编译器会改写名称,具体不同的编译器有不同的做法
    • 在类内声明,必须在类的外部定义和初始化每个静态成员,只能定义一次;static关键字只出现在类内声明中;
    • 静态数据成员可以是不完全类型,特别的,静态数据成员的类型可以是它所属的类类型,而非静态数据成员则受到限制,只能声明成它所属类的指针或引用——待验证
    • 可以使用静态成员作为默认实参,非静态数据成员则不能
  • 非静态成员:
    • 欲对一个非静态成员进行存取操作,编译器需要把类对象的起始地址加上数据成员的偏移量
    • 每一个非静态成员的偏移量在编译期间就能获取地址,即使是派生自单一或多重继承链,因此存取一个非静态成员的效率和存取一个C结构体的成员的效率是一样的

继承与数据成员

  • C++标准并未强制指定基类和派生类成员的排列次序,大部分编译器按先基类后派生类的顺序,如果是虚基类就例外
  • 只要继承不要多态:
    • 一般而言具体继承不会增加空间或存取时间上的额外负担
    • 字节对齐导致的膨胀,以及子类赋值给父类,填补了字节对齐而补充的字节,造成子类的值被覆盖
  • 加上多态
    • 会带来哪些负担?
      • 新增一个和增加虚函数类相关的虚表,虚表元素数目是虚函数数目加上一两个元素空间以支持运行时类型识别
      • 在每个类对象中增加一个虚指针,指向虚表
      • 加强构造,使能够设定vptr;加强析构,使能够消除vptr
  • 多重继承:C++标准并未要求多重继承的父类在内存中的排列顺序,大部分按从左到右排序。这种情况下要注意,子类转换为排序靠后的父类时要注意字节偏移
class Point2d{};
class Point3d{};
class Vertex{};
class Vertex3d :public Point3d, public Vertex{};
Vertex3d v3d, Vertex *pv, Point2d *p2d, Point3d *p3d;
pv = &v3d;    // 需要进行这样的转换:pv = (Vertex*)(((char*)&v3d)+sizeof(Point3d));
Vertex3d *pv3d; Vertex *pv; 
pv = pv3d;    不只是简单的上述转换了,对于指针,内部转换操作需要有一个条件测试:
pv = pv3d ? (Vertex*)(((char*)&v3d)+sizeof(Point3d)):0;
  • 虚拟继承:解决下图所示的菱形继承问题
    • 语言层面导入虚拟继承之后,如果class内含一个或多个virtual base class(下图种的istream或ostream),将被分割成两部分,一个不变局部和一个共享局部。不变局部种的数据不管后继如何衍化总是拥有固定的offset,所以这部分数据可直接存取,共享局部所表现的就是虚基类的部分,这部分数据位置会因为每次的派生操作而有变化,所以只能被间接存取。各家编译器实现技术之间的差异就在于间接存取的方法不同
    • 一般的布局策略是先安排好derived class的不变部分,然后再建立其共享部分;微软的解决办法是引入virtual base class table,每个class object如果有一个或多个virtual base classes,就会由编译器安插一个指针指向virtual base class table
    • 一般而言virtual base class最有效的一种运用形式就是:一个抽象的virtual base class,没有任何数据成员
class ios{ ... };
class istream: public virtual ios{ ... };
class ostream: public virtual ios{ ... };
class iostream: public istream, public ostream { ... };

在这里插入图片描述

对象成员的效率

  • 测试结果表明,打开编译器的优化后单一继承(不支持多态)封装不会带来执行期的效率成本,成员被连续存储在对象中,并且偏移量在编译期就是已知的
  • 虚拟继承的效率总是令人失望

指向数据成员的指针

class Point3d{
public:
    float x, y, z;    
}
Point3d origin;
  • 问题1:vs上运行
printf("x: %p\n", &Point3d::x)0float Point3d::*p1=0; printf("x: %p\n", &Point3d::x); // vs测试打印为FFFFFFFF
float Point3d::*p2 = &Point3d::x;    //注意x的类型是float Point3d::

所以 p1 != p2

  • 问题2:&Point3d::x&origin.z之间有什么差异?
    取一个数据成员的地址,将会得到它在class中的偏移量(可以认为它是一个不完整的值),而取一个绑定于类对象上的数据成员得到的是该成员在内存中的真正地址
  • 如果不做优化,以指向数据成员的指针存取数据要消耗直接存取的两倍的时间——?
  • 类成员指针:使用classname::*的形式声明一个指向数据成员或成员函数的指针
    • 当初始化一个成员指针或为成员指针赋值时,该指针并没有指向任何数据。成员指针指定了成员而非该成员所属的对象,只有当解引用成员指针时才提供对象的信息
    • 常规的访问规则对成员指针仍然有效,如public,private
    • 使用类型别名或typedef可以让成员指针更容易理解
    • 使用成员函数指针调用对象时需要用到两个C++操作符:“.“和”->”,这两个操作符优先级都很低,所以通常用括号保证表达式语法正确
class A{
public:
    void Eat() { cout << "Eat" << endl; }
};
typedef void (A::*TpMemFun) ();  // 成员函数指针类型
// 给TpMemFun的对象赋值及调用:
TpMemFun mfp1 = &A::Eat;
A a;
(a.*mfp)();    // 通过普通对象调用A中的Eat成员函数
A *pa = new A;
(pa->*mfp)();    // 通过指针对象调用

函数语意

成员函数的各种调用方式

  • 非静态成员:C++设计准则之一是非静态成语函数至少必须和普通函数有相同的效率,因为编译器已经在内部将成员函数转换为对等的非成员函数,步骤:
    • 修改函数签名,在函数参数第一个位置处插入this指针,this类型是指向类类型非常量版本的常量指针,T *const this,如果是const函数,const T *const this
    • 将每一个非静态成员的操作改成由this指针来存取
    • 将成员函数重新写成一个外部函数,对函数名称进行处理,使之独一无二
      • 成员函数名称会加上命令空间名称,类名,参数链表等,至于具体怎么加,目前的编译器没有统一的编码方法(c++filt工具可解析出来),并伴随NRV优化
      • extern "C"会压抑特殊处理效果
  • 虚成员函数:通过vptr调用。vptr的名称也有可能被改写,因为在一个复杂的类派生体系中可能存在多个vptrs
    ptr->normalize(); normalize()是一个虚函数,在内部会被转换为:(*ptr->vptr[n])(ptr)
  • 静态成员函数:主要特性是没有this指针
    • 静态成员的名称也会被修改
    • 不能够直接存取非静态成员
    • 不能够被声明为const,volatile或virtual,因为静态成员没有this指针,const是用来修饰this的
    • 不需要经过class的对象来调用,大部分这样的调用只是为了符号上的便利
//静态成员函数:
static unsigned int &Point3d::object_count();
&Point3d::object_count()   // 的类型是:
unsigned int (*)()而不是unsigned int (Point3d::*)();
// 静态成员可以通过以下形式调用:
((Point3d*)0)->object_count()

虚函数

C++中的多态表示以一个公有继承的父类指针寻址出一个子类对象

  • 对多态的支持:
    • 编译阶段:
      • 编译器建构好虚函数表的大小和内容(虚函数在编译期可以获取到,并且是固定不变的,将虚函数地址放在表中)
      • 给每个类对象添加虚指针并指向虚表,给每个虚函数指派一个索引值
    • 执行期要做的就是激活虚函数(不就是调用一个函数指针造成的性能吗,为什么会很低?);
  • 一个类只能有一个虚函数表,每一个表内对应虚函数的地址,包括:
    • 这个类所定义的虚函数实体,他会改写一个可能存在的从基类继承的虚函数
    • 继承自基类的函数实体,这是不改写虚函数时才出现的情况
    • 纯虚函数既可以占位,也可以当作执行期异常处理函数
  • 多重继承下的虚函数
// 问题1:怎样确定调用的是哪一个虚函数?
Base* pBase = new Derived1;  
// 编译器会转换如下:
Derived1 *temp = new Derived1;
Base* pBase = temp ? temp + sizeof(Base): 0;——这个转换不理解
  • 虚拟继承下的虚函数:
    • 虚函数不能是内联的,放弃了内联,这也是虚函数的一个代价
    • 对一个虚成员函数取地址,所能获得的只能是一个索引值——?
  • 纯虚函数:至少有一个纯虚函数的类称为抽象类,抽象类不能被实例化。

函数的效能

  • inline函数不止能够节省一般函数调用所带来的负担,也提供了程序优化的额外机会——具体做了哪些优化

指向成员函数的指针

  • 取一个非静态数据成员的地址,如果该函数是非虚,则得到的结果是它在内存中真正的地址,然而这个值也是不完全的,需要绑定到某个类对象的地址上,才能通过它调用该函数,所有的非静态成员函数都需要对象的地址
  • 以下两个问题:
    • printf("Point::~Point: %p\n", &Point::~Point); // 并不能打印出析构的地址,编译器报错:构造函数或析构函数不能提取其自身;原因:C++标准规定,不能获取构造函数和析构函数的地址,指向成员函数的指针可以,指向构造和析构的不行,因为构造和析构都是没有返回值的
    • printf("Point::z: %p\n", &Point::z); 并没有打印出z(虚函数)的序号,打印出的是地址,这个地址是什么?

Inline函数

  • 一般而言处理一个inline函数有两个阶段:
    • 分析函数的复杂度或建构等问题判断能否称为inline函数,如果被判断不可成为inline函数,它会被转为一个static函数,并在被编译模块内产生对应的函数定义
    • 必须要到汇编器里面才能看到inline的真正实现了
  • 形参:如果inline的实际参数是一个表达式,就要先求值,所以inline函数可能会产生大量的临时变量,所以用起来还是要谨慎
inline int min(int i, int j) { return i < j ? i: j; }
int mainval = min(foo(), bar() + 1);   // 扩展成:
int mainval = (t1 = foo()), (t2 = bar() + 1), t1 < t2 ? t1 : t2;
  • 局部变量:inline函数中的局部变量在展开后也会产生临时变量,如果是单一表达式扩展多次,每次扩展都需要一组表达式;如果是以分离的多个式子被扩展多次,只需一组变量;结合下面的例子,看看产生了多少的临时变量:
inline int min(int i, int j) { int val = i < j ? i: j; return val; }
int val = min(val1, val2) + min(foo(), foo()+2)

构造,析构,拷贝语意

概述

  • 默认的构造,拷贝构造,拷贝赋值运算符和析构都是public且inline的,编译器生成的析构是non-virtual;拷贝构造和拷贝赋值也只是单纯的将对象的每一个non-static成员变量拷贝到目标对象
  • 编译器拒绝生成默认拷贝运算符的两种情况:
    • 类中含有一个reference成员或const成员,因为这两类成员初始化之后不允许改变;
    • 如果基类的拷贝运算符是private,子类也拒绝生成一个拷贝运算符(拷贝构造呢?),所以需要自己实现。
  • 主动阻止编译器自动生成拷贝构造和拷贝赋值运算符(不只是拷贝相关操作)
    • 可将相应的成员函数声明成private并且不予实现,但如果在成员函数或友元函数中调用会在链接期报错
    • 定义一个专门为了阻止copying动作的基类,定义private成员,并以private的方式继承之,这些函数的编译器生成版会尝试调用其base class的对应兄弟(?)可将链接期错误转移至编译期
    • 在c++11中,将拷贝构造和拷贝赋值运算符定义为删除的函数来阻止拷贝:在参数列表后加=delete;
  • 执行拷贝操作时要确保:
    • 复制所有的local成员变量
    • 调用所有基类内的适当的拷贝函数
  • 通过调用带一个实参的构造函数定义一条从构造函数的参数类型向类类型隐式转换的规则;可以通过将构造函数声明为explicit加以阻止:explicit只对一个实参的构造函数有效;只在类内声明时使用,在类外定义时不应重复;需要多个实参的构造函数不能用于执行隐式转换,所以无须声明为explicit;explicit构造函数只能用于直接初始化,虽然explicit不能用于隐式转换,但可以显式的强制进行转化;explicit能否被继承?不能,因为子类对父类的构造函数是调用而非继承关系
  • 继承构造函数:使用using A::A
  • 委托构造函数:使用其他构造函数执行自己的初始化过程;委派构造函数是在构造函数的初始化列表位置进行构造,委派的;但是构造函数不能同时委派和使用初始化列表,所以初始化代码必须放在函数体中
  • 访问控制:
    • public:对象和子类成员都可以访问
    • protected: 在本类和派生类的成员,友元中能够使用,但对象不能使用
    • private:只能本类中使用,对于派生类来说是不可访问的

虚函数

  • 如果基类的析构是纯虚函数,则必须要有定义,否则会导致链接的失败,因为子类调用的时候会调用到;较好的方案就是不要把虚析构声明成纯虚。不过有个好处是一举两得(既是抽象类,又是虚析构函数)
class Base
{
public:
       virtual ~Base() = 0
       {
              cout << "Base::~Base" << endl;
       }
};
  • 虚函数最好不要声明成inline函数——内联时函数会展开,但虚函数只有在调用时才能确定具体调用哪个
  • 虚函数中const存在:最好的方法是不要把虚函数声明成const函数,因为有可能在派生类中会改变成员

移动语义

对象的移动语义需要实现移动构造函数或移动赋值运算符。如果源对象是操作结束后会被销毁的临时对象,或显式使用std::move时,就会使用移动构造/赋值运算符。移动将内存或其他资源从一个对象移动到另一个对象,这两个方法基本上只对成员变量进行浅拷贝,然后转换已分配内存和其他资源的所有权,从而防止资源泄露。
移动操作将成员从源对象移动到新对象,然后使源对象处于有效但不确定的状态。通常,源对象的数据成员被重置为空,但这不是必需的。标准库规定std::shared_ptrstd::unique_ptr在移动时必将将其内部指针重置为nullptr,这使得智能指针在移动后可以安全地重用。
如果一个类没有移动操作,通过正常的函数匹配,类会使用相对应的拷贝操作来代替移动操作

  • 移动构造接受一个右值引用的参数
  • 定义了一个移动构造函数或移动赋值运算符的类也必须定义一个常量左值为参数的拷贝操作,否则这些成员默认的被定义为删除的;所以定义了移动构造一般会定义拷贝构造,移动不成再拷贝
  • 如果一个类没有移动构造函数,函数匹配规则保证该类型的对象会被拷贝,即使试图通过std::move来移动也是如此(用拷贝构造函数代替移动构造函数几乎肯定是安全的)
  • 移动构造函数必须确保移后源对象处于这样一种状态:销毁它是无害的,一旦资源完成移动,源对象必须不再指向被移动的资源,这些资源的归属权已经归属新创建的对象,必须确保移后源对象进入一个可析构的状态(也就是移后源对象可以随时析构掉)
  • 由于移动操作是窃取资源,通常不分配任何资源,所以不会抛出任何异常;不抛出异常的移动构造函数和移动赋值运算符必须标记为noexcept,在声明和定义中都要指定
  • 类似移动构造函数,移动赋值运算符必须正确处理自赋值——?
  • 移动构造(移动赋值运算符)的生成条件:
    • 如果定义了拷贝操作(拷贝构造和拷贝赋值运算符)和析构函数中的一个或多个,则编译器不会为类定义默认移动构造
    • 每个非static数据成员都可以移动
    • 所有拷贝构造/拷贝赋值和移动构造/移动赋值必须同时提供或同时不提供,才能保证类同时有拷贝和移动语义,只声明其中一个的话类都仅能实现一种语义
    • #include<type_traits>中,可以使用辅助的模板类来判断一个类型是否是可以移动的,如std::is_move_constructible, std::is_trivially_move_constructible, std::is_nothrow_move_constructible,使用方法仍然是使用其成员value, 具体参考这里
  • 对于移动构造函数来说抛出异常是危险的事情,所以通过声明为noexcept,在抛出异常时直接调用terminate来结束程序;可用std::move_if_noexcept的模板函数替代std::move函数,该函数在类的移动构造函数没有noexcept关键字修饰时返回一个左值引用从而使变量可以使用拷贝语义;而在类的移动构造中有noexcept时返回一个右值引用,从而使变量可以使用移动语义
  • 移动构造的实现上,将本对象的指针成员指向右值引用成员所指向的内存,这个“偷”是必须要做的,因为在移动构造完成后,临时对象会立即被析构掉
class HasPtrMem{
    int *d;
public:
    HasPtrMem(HasPtrMem&& h): d(h.d){
        h.d = nullptr;    // 这一步是必须的,因为移动构造完成之后,临时对象会立即析构掉,如果不改变h.d的值,则临时对象会析构掉偷来的堆内存
    }
    ....
};
  • 实现
    • 基本类型可以使用C++14中的std::exchange
    • 其他对象的数据成员,应该使用std::move
    • 使用swap。上述两种方式都是针对单个元素进行赋值,使用swap可以交换整个对象的值。这种方式可以避免成员变量有遗漏。
// 前两种方式:
MyClass::MyClass(MyClass && c) {
  int_val = std::exchange(c.int_val, 0);
  str_val = std::move(c.str_val); }
// 使用swap
MyClass::MyClass(MyClass && c) {
  swap(*this, src); }
  • std::move的使用
    std::move的实现类似为static_cast<T&&>(t),本身不调用移动构造函数或移动赋值运算符,它只是将参数转换成为右值引用,使得可以在其上调用移动构造函数或移动赋值运算符。到底调用移动构造函数还是移动赋值运算符,取决于怎样使用std::move。如果用来初始化一个新对象,则会调用移动构造函数,如果赋值给一个已经存在的对象则会调用移动赋值运算符。
MyClass a;
MyClass b(std::move(a));  // 调用移动构造函数
MyClass c;
c = std::move(a);    // 调用移动赋值运算符

无继承情况下的对象构造

  • POD类型的全局变量不会执行默认构造,不过与C不同的是,未定义的全局变量在C中会被视为一个临时性定义,存在bss段中,bss段在C++中并不重要,因为C++构造行为的隐含应用之故。C++中所有全局对象都被当作初始化过的数据对待
  • POD类型的C++类编译器会辨别出来,所以不会产生默认的构造等或产生了但不调用;赋值操作也会像C一样进行纯粹的位拷贝操作,删除时析构也不会被产生或调用,所有的操作如同C结构体一样进行位搬移
  • 虚函数的引入带来class的膨胀:
    • 构造函数附加了代码:初始化vptr,在所有基类构造之后,程序员代码之前调用(在构造函数体中,不过具体要看编译器的实现)
    • 合成拷贝构造和拷贝赋值运算符,且是non-trivial,不再支持位拷贝,合成的拷贝操作会自动拷贝虚指针的
    • 编译器在优化状态下可能会把对象的连续内容拷贝到另一个对象身上,而不会精确的以成员为基础的赋值操作,C++标准要求编译器尽量延迟non-trivial成员的实际合成操作,直到使用时

继承体系下的对象构造

  • 一个对象的定义可能会让编译器按顺序做如下扩充操作:
    • 虚基类的构造被调用,从左到右,从深到浅
      • 如果class被列到成员初始化列表中,如果有任何明确的参数都应传递过去;如果没有列到初始化列表中,调用默认构造
      • class中每一个虚基类子对象的偏移量必须在执行期可被存取——??
      • 如果类对象是最底层的,构造可能被调用,某些用于支持这个行为的机制必须被放进来
    • 所有上一层基类的构造函数必须被调用,以基类的声明顺序为顺序
      • 如果基类被列到成员初始化列表中,那么任何明确指定的参数都要给传过去
      • 如果基类没有列到成员初始化列表中,有默认构造就调用之
      • 如果基类是多重继承下的第二或后继的基类,那么this指针必须有所调整——??
    • 如果类有vptr,必须被设定初值,以指向虚表;如果有一个成员并未出现在初始化列表中,该默认构造必须被调用(基础类型为默认值)
    • 以数据成员的声明顺序在初始化列表中进行初始化
  • 一个类的成员如果有显式的构造,拷贝构造,它自己如果没有定义也会合成;如果一个类的成员的拷贝构造是delete的,这个类没有显式定义拷贝构造,编译器会怎么做?应该是不会合成
  • 虚拟继承:在有虚基类的构造初始化中,最上面公共继承的父类的构造只调用一次,最底层继承的类负责构造最顶层共享的父类,编译器的大概实现是加一个bool字段,在每一层派生的子类构造中添加代码判断是否是最下面的子类,如果是去调用虚基类构造;如果不是就不调用
  • vptr初始化语意:
    • vptr的初始化是在基类构造调用之后,在成员列表初始化之前
    • 构造/析构中调用一个虚函数是以静态方式调用,不会用到虚拟机制;所以尽量不在构造和析构中调用虚函数
      • 在调用父类构造时子类还没有开始构造,所以此时的对象还是父类的,不会触发多态;所以基类构造期间绝不在构造和析构中调用virtual函数
      • 析构同样,子类先进行析构,虚函数被调用时子类内容已经被析构了,所以会执行父类的virtual函数
    • 在类成员中调用虚函数来初始化成员是安全的,但语意上是不安全的,因为有可能依赖未设置初值的成员。所以不推荐

对象拷贝语意

  • 如果类是一个POD类型,自己定义的拷贝赋值运算代替了按位拷贝反倒会降低执行速度,在什么情况下需要定义拷贝语义?只有在默认行为所导致的语意不安全或不正确时才需要设计拷贝赋值运算符
  • 一个类对于默认的拷贝赋值运算符,在以下情况不会表现出按位拷贝行为(重复):
    • 成员变量有拷贝赋值运算符
    • 基类有一个拷贝赋值运算符
    • 类声明了虚函数
    • 类继承自虚函数
  • 默认拷贝构造虽然也可以完成位拷贝,考虑到NVR优化,可能需要提供显式拷贝构造;定义了拷贝构造并不意味着必须要提供拷贝赋值运算符
  • 尽可能不要允许一个虚基类的拷贝操作(甚至不要在任何虚基类中声明数据),多重虚基类可能会造成重复拷贝赋值

对象的功能

  • 按位拷贝:
    • 初始化列表初始化指令只有一个,先存储再赋值有两条指令;
    • 额外的单一继承或多重继承不影响成员对象的初始化或拷贝操作的成本
  • 导入虚拟继承之后,class不再有按位操作,导致效率降低(书中的例子看默认合成的降低2倍,自定义降低接近1倍)——需要自测

析构语意

  • 如果class没有析构,只有class的成员变量有析构的情况下编译器才会自动合成析构,否则不会合成,即使是有虚函数。不要因为不确定就提供析构,提供了反而降低效率;
  • 析构函数没有参数,且只能有一个析构函数,可以是内联的
  • 析构顺序(和构造相反):
    • 执行析构函数本身
    • 如果成员变量有析构,按相反的声明顺序调用成员变量的析构,vs中人写代码在前面,默认成员的析构代码在后面
    • 如果有vptr,先重新设定,指向适当的基类虚函数表
    • 调用父类析构
  • 析构函数永远不应该抛出异常,当在析构函数中抛出异常时:
    • 调用abort()直接退出程序
    • 使用try catch捕获异常,但只是吞掉了异常,并没有处理;所以
    • 如果需要对某个函数运行期间抛出异常做出反应,class应该提供一个普通函数来执行该操作,让使用者去处理这个异常

执行期语意

  • 对象的构造和析构:
    • 全局对象:C++中所有的全局变量保存在data段中,如果没有给初值就赋值为0,这个和C的不自动设定初值不同——C++中全局基础类型的变量不在bss段?
    • 局部静态对象:C++标准规定静态局部自定义类型变量在函数第一次调用时初始化
    • 对象数组
  • new和delete运算符
    • 一个new运算符的两个步骤:申请内存;调用构造,如果是内建类型的话给赋初值
      int *p = new int[0]; 这样写是合法的,语言要求每一次对new的调用都必须传回一个独一无二的指针,解决该问题的方法是内部判断为0时传回一个指针,指向一个默认为1byte的内存区块
    • 针对数组的new语意
    • placement operator new
  • 临时性对象
    • 临时对象的生命周期:应该是在完整表达式求值过程中的最后一个步骤结束之后析构或释放(分号之后就释放了)
      • 例外:如果一个临时性对象被绑定于一个引用,对象将残留,知道被初始化之引用的生命结束,或临时对象的生命范畴结束
      • 反聚合的优化操作能够减少临时对象的产生,对于效率的提升有很大的帮助
T c = a + b;      // 编译器基本上不会产生一个临时变量,为什么不会产生临时;这两个的不同有疑问
c = a + b;        // 赋值语句,(和上面一句是完全不一样的,上面的效率更高)不能忽略a+b所产生的临时变量,会导致以下结果:
T temp;
temp.operator(a+b);   // 
c.operator=(temp);
temp.T::~T();

继承

  • 虚拟继承:为节省内存空间,可以将B和C对A的继承定义为虚继承,A就成了虚拟基类
  • 为什么虚函数效率低?因为虚函数要进行一次间接寻址,而一般函数在编译时就确定了函数的地址
  • 避免遮掩继承而来的名称(effective c++ 32):如果子类中有和父类函数名称一样但参数类型或个数不一致的函数,不管父类是virtual函数还是non-virtual函数,不管带参还是无参,子类都会把父类中同名的所有函数覆盖掉;解决办法:可以使用using声明父类的函数避免被覆盖,或使用转交函数
  • 区分接口继承和实现继承
    • 纯虚函数是为了让派生类继承接口,纯虚函数可以提供定义但唯一调用途径是被静态调用(通过类名调用),不能经由虚拟机制调用
    • 纯虚函数可以设计成私有的,不过这样不允许在本类之外的非友元函数中直接调用它,子类中只有覆盖这种纯虚函数的义务,却没有调用它的权利
  • 普通函数继承默认实现,声明non-vritual的目的就是令derived classes继承函数的接口及一份强制性实现。注意:
    • 基类的虚函数定义了缺省行为,而派生类在未明白说出我要的情况下就继承了该缺省行为,有可能有风险,要切断虚函数接口和其缺省实现之间的联系:(1)将虚函数声明为纯虚,在基类定义默认非虚实现作为缺省实现;(2)定义为纯虚函数,但也可以拥有一份自己的实现
    • 绝不重新定义继承而来的非虚函数,因为非虚函数是静态绑定的
  • 绝对不要重新定义一个继承而来的默认参数值——(定义了会出现什么问题?)(这个主要针对虚函数,因为重新定义非虚函数永远是错误的),因为默认参数值都是静态绑定的,而虚函数是动态绑定的,这里有个奇怪的现象:如果子类重新定义了继承而来的虚函数,用指针调用参数可以不用带默参,但非指针就必须要定义默参(effective c++ 37)(实际验证并没有)
class Parent{
public:
    virtual void foo(int val = 1) { cout << "Parent:" << val << endl; }
};
class Son: public Parent{
public:
    virtual void foo(int val = 2) {cout << "Son:" << val << endl; }
};
Son son;
Parent* p = &son;
Son* s = &son;
p->foo();    // Son:1
s->foo();    // Son:2
son.foo();   // Son:2
  • 继承控制:继承控制的作用要和访问控制结合起来使用
    • public: is-a的关系
      • 期望一个父亲类型的,都可以传递子类型,这个论点只对public继承才成立
    • private:
      1. 编译器不会将一个派生类自动转换为基类;——?
      2. 继承而来的所有成员在子类中都会变成private,即使在父类中是public或protected;
      3. 使用private继承只是为了采用父类的一些特性,并不是两个对象之间有任何关系;也不能由子类转成父类
      4. private继承意味着is-implemented-in-terms-of,和复合的作用相同,两者如何取舍?尽可能的使用复合(effective c++ 38),必要时才使用private继承,何时需要?当protected成员或虚函数牵扯进来的时候(effective c++ 39)
      5. 如何在private继承的对象使用base的成员?using——?
    • protected:
      • 继承而来的public和protected变成protected了
  • 虚继承:
  • 默认继承:private
  • 多重继承:多重继被认为是一项复杂且不必要的部分。
    • 名称歧义:如果Base1和Base2都有一个相同的函数func1,在派生类中这两个函数都存在,编译不会有问题,但调用时编译器会报错,指出对函数func1的调用有歧义。为消除歧义,可采用如下方法:
      • 使用dynamic_cast向上转换类型,本质是向编译器隐藏多余的方法
      • 调用时使用作用域运算符,如 Base1::func1
      • using语句显式指定,在派生类中用哪个版本
class Derived : public Base1, public Base2 {
public:  
  using Base1::func1;
};

歧义基类
在这里插入图片描述
以上两种情况中,当Derived2或SubDerived的实例调用Base中的成员时都会导致调用函数的二义性而编译不过。

类型转换

两种函数允许编译器进行转换

  • 单参数构造函数:只有一个参数即可调用构造函数,或是虽定义了多个参数,但第一个参数以后的所有参数都有默认;通过声明explicit关键字,编译器会拒绝为隐式类型转换调用单参数构造函数
  • 隐式类型转换运算符:operator 关键字,不需要定义函数的返回类型,因为返回类型就是这个函数的名字——谨慎定义类型转换函数,解决的方法是不使用关键字的等同的函数来替代转换运算符;隐式类型转换可能会出现的问题:effective c++ 15
    • 越有经验的C++程序员越喜欢避开类型转换运算符
    • 注意,在哪些情况下会调用隐式类型转换??编译器没有发现有合适的类型运算符函数,会试图寻找一个隐式类型转换函数

重载

重载某个操作符时应该返回的是引用

  • C++的规则是每一个重载的operator必须带有一个用户定义类型
  • 令operator=返回一个*this的引用:
    • 使其为一个左值,可以实现连锁赋值,这个协议不仅适用于=,而且适用于所有赋值相关运算;
    • 处理自我赋值的方式是证同测试:if(this==&rhs) return *this;(检测是不是自己);
    • 让operator=具备异常安全性就可以自动获得自我赋值安全——?
  • 拷贝赋值操作符调用copy构造是不合理的;同样copy构造调用copy运算符也是无意义的,如果有相同的部分,可以提取出来作为一个单独的函数,这样的函数是private而且常被命名为init
  • 拷贝赋值运算符应该确保复制对象内的所有成员变量及所有的基类成员:通过调用基类拷贝复制运算符;不能用拷贝构造和拷贝运算符互相调用,应该将共同机能放进第三个函数由两个coping函数共同调用
  • 不要重载"&&", “||”, “,”:
    • “&&”/“||”: C++使用短路求值法,一旦确定布尔表达式的真假,即使部分表达式没有被测试,仍然停止运算
      • 所以说,如果重载了这两个就会改变游戏规则:用函数调用发代替了短路求值法
      • 如果重载(operator&&(expr1, expr2)),不确定expr1和expr2哪个先调用,有可能与短路求值法完全相反
    • 逗号表达式:一个包含逗号的表达式先计算逗号左边的表达式,然后计算右边的,整个表达式的结果是逗号右边表达式的结果
      • 如果重载,就不能保证左边的表达式先于右边的表达式计算
    • 不能重载的运算符还有:.; .*; ::; ?:; new; delete; sizeof; typeid
  • 自增,自减前缀返回一个引用,后缀返回一个const对象(不是引用);为了区别,前缀无参,后缀有一个int参数但不会使用;无const则i++++是成立的,所以返回const有两个原因:与内置类型行为不一致,两次使用后缀所产生的结果与调用者期望不一致:第二次调用改变的是第一次调用返回对象的值,而不是原始对象的值
  • operator*为什么返回const by value?

友元函数

  • 需要用类的实例访问类的数据成员?
  • 友元函数是类外的函数,所以放在公有段或私有的都不影响,一般放在声明的开始或结束部分
  • 友元函数可以定义在类的内部,这样的函数默认是内联的
  • 友元函数没有this指针

设计原则:

  • 继承与面向对象的设计
    • classes之间的关系有is-a,has-a,is-implemented-in-terms-of(根据某物实现出),应该要确实了解这些 classes相互关系 之间的差异
    • pulic继承意味着 is-a,适用于base class身上的每一件事情都适用于derived class——effective c++ 32有例子——这一点非常重要,你要知道公有继承就是is-a
  • 设计一个类:
    • 新对象应该如何被创建和销毁?——影响到class的构造函数和析构函数,内存分配和释放函数(operator new/opeartor new[]/operator delete/operator delete[])
    • 对象初始化和对象赋值有什么差别?——这个答案决定构造函数和赋值运算操作符的行为,以及期间的差异
    • 新类型的对象如果按值传递,意味着什么?——拷贝构造函数用来定义一个类型的值传递该如何实现
    • 需要什么样的类型转换?——和其他类型之间的转换
    • 新的type是否需要配合某个继承图系?
    • 。。。

其他汇总

  • 如何阻止一个类被实例化?构造声明称private或定义为抽象基类
  • vs c++ 命令行 /dl reportSingleClassLayoutXXX 注:XXX为类名,如果要打印所有class则为reportAllClass
  • 让某种类型的对象能够自我销毁:delete this的注意事项:
    • this对象必须是用new操作符分配的,而不是new[], placement new, 也不是局部对象,全局对象
    • delete this之后,不能访问this指针,不能访问该对象任何成员,虚函数等
    • 为保证以上,可以采取:
      • 将析构函数私有化以保证对象必须使用new在堆上分配对象
      • 提供destroy函数,里面只有一句delete this
  • 代替虚函数的方案(effective c++ 35)
  • 问题归纳
    1. 以下问题:
class NoDefault {
public:
    NoDefault(int i) :i_(i)
    {}
public:
    int i_;
};
class C {
public:
    NoDefault nf;
};

NoDefault没有默认构造可以编译通过,是因为并没有生成对象实例,没有分配内存,没有调用默认构造函数,当分配内存的时候自然会调用默认构造,这个时候编译不通过

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值