C++ OOP编程综述

Last Updated on 2020年4月15日

嵌套类和局部类

  • 定义在类X内的类Y称为嵌套类,它就是一个普通类,只不过使用的作用域被限制了.
  • 定义在函数内的类Y称为局部类,这种类限制很多,主要用于在语言层面支持Lambda,实践中基本不用.

struct和class的内存布局

  • C++的struct和class实例大小不会为0,即便它内部没有任何成员函数或成员变量.主要是因为编译器总是需要为实例分配内存,不占内存的实例是不存在的
  • 数据成员全为全public,无构造函数,无类内初始值,不使用继承及virtual的类,称为聚合类.聚合类可以通过{}进行逐成员初始化
  • 如果需要把sturct或class对象通过C风格直接从内存层面管理(如memcpy),那么就必须要求它的内存布局是平凡的(标准的),这写类型称为POD. 标准库中,is_pod::value可以判断其是否是可以直接操作的
  • 如果类内存在多个publicprivate声明,则编译器可能重排该类的内存布局(移动部分对象),这将导致成员排布和程序员的预期产生差异.
  • 对齐方法一般是由编译选项或编译指示决定的.

OOP基础特性:

  • 如果没有特别说明,就永远只使用public继承,privateprotect继承没有动态绑定和多态的特性,这两种继承并非面向对象设计中的组件,而是为了实现其他功能的,在C++中应当避免使用.
  • 继承过程中基类成员的可见性可以通过using T::name手动控制.
    • 例如,基类中有public的function(),我们可以在派生类的private中写using Base::function,那么就不能通过派生类实例访问function了
  • 对于派生类,按min的原则判断基类成员的访问权限,例如,基类的public成员经protect继承后,相当于派生类的min{public,protect}=protect成员.
  • 从实现上说,const成员函数只约束类实例内存区域不可写(bitwise-constness).例如,一个class有一个int *p成员,那么,在const成员函数中,修改p是不可以的,修改*p则是可以的.

friend

  • friend是编译期的,其作用是将当前类的自定义成员向某个作用域开放访问(撤销private限制),继承得到的成员则不受任何影响
    • 这个作用域可以是一个函数,或一个类
    • 只要该作用域能够访问到实例,就能访问该实例的数据成员,或使用成员函数.
    • 即便该作用域不能访问到类实例,也可以直接使用不含this的private成员函数,或者调用private构造函数创建对象.
  • friend语句不是函数声明语句.

this

  • 从逻辑上看,this在形参列表中为(T * const this, ...)
    • 我们可以额外为this添加&,&&const
    • const要求this指向const对象
    • &则要求this指向一个左值
    • &&要求this指向一个右值
  • this的实现方式随编译器ABI而定.

类内基本控制函数

构造函数ClassName(args):{}

  • 对于const类实例,仅当构造函数的代码区执行完后实例才获得const属性.
  • 只有函数调用才能触发隐式类型转换.(重载运算符也是函数调用,也能触发)
  • 添加explicit ClassName(TYPE n)限定符可以阻止编译器进行隐式转换.

拷贝控制

  • 涉及拷贝构造(移动构造),拷贝赋值(移动赋值),析构这三类操作的控制函数被称为copy control,对于旧版本C++,有3个,对于C++11,则有五个
  • 在一些场合,编译器可能优化掉拷贝初始化操作. 例如string str_obj="mystr";,它实际是string str_obj(string("mystr")), 可能会被优化为string str("mystr"),第二步的拷贝初始化就被绕过了.

= delete(C++11)

  • 用于定义”不能被调用”的函数,=delete的函数会参与函数重载,当匹配到=delete的函数时,编译器将报错.例如,对于一个mutex,它显然是不具备拷贝意义的,我们就可以让class Mutex的拷贝构造/拷贝移动为=delete

=default(C++11)

  • 使用=default可以强制编译器按默认规则生成对应的函数
    • 如果编译器无法生成,则生成的版本是=delete的.
  • 在声明处的=default将会使编译器生成inline的版本

编译器的隐式合成行为

  • 编译器会自动插入xxx=default这样的代码,称之为隐式合成.具体而言,只有默认构造函数T()和五个拷贝控制函数是可能被隐式合成的.
    • 有了任意显式构造函数都不会合成T();
    • 有了任意显式拷贝控制都不会合成其余的拷贝控制;(有的编译器不严格执行)
    • 注意:即便是T(xxx) =default这样的代码,仍被认为是”显式”的
  • 如果T存在不能拷贝的成员,则编译器将合成=delete版本的拷贝赋值及拷贝初始化.
  • 只有当所有成员都可以移动时,编译器才会合成移动构造和移动赋值.换言之,只要编译器隐式合成了移动控制函数,就一定是可用的,不会是=delete
    • “没有合成”与”合成delete的版本”是不同的. =delete的版本会参与重载,而未合成的版本则不会。例如a=T(std::move(b))中,如果没有合成移动构造函数T(T&&),那么将使用拷贝构造T(const T&),但是如果合成了T(T&&)=delete版本,则会编译失败。

动态绑定与多态

  • 返回类型放松:对于T内的virtual函数,如果返回类型是T&T*我们可以在override的时将返回类型修改为派生类的T2&T2*
  • 在构造函数和析构函数中调用虚函数仅会静态绑定.这是为了避免安全问题.例如,对于class B: public A{virtual void fun();},在构造A时需要调用fun(),若因动态绑定调用了B的版本,由于此时B仍未构造,此时调用B的版本就可能访问未初始化的区域,这是十分危险的.
  • 虚函数表的典型实现:
    • 每一个多态类都有一个大致形如void* __vptr[N]的虚函数表. 因此,每一个实例内都附带一个指针,指向该实例对应类型的虚函数表.(非多态类则没有这个表)
    • 对于类X,它每多一个多态基类,头部就会多一个虚函数表指针.这将引起内存偏移等复杂的问题,实用中一般禁用多继承.
  • 多态调用与普通调用的一个简单逻辑展示如下:
    • 当编译器发现p->fun(args)是普通调用时,将其翻译为T::fun(p,args),本质仍是一个普通调用.
    • 当编译器发现p->vfun(args)是虚函数调用时,就将其翻译为:p->__vptr[ind_of_vfun](p,args),所以虚函数的调用依赖实例.