跳转至

类是用户定义的一种数据类型 类之于对象就像类型之于变量。类描述了一种数据类型的全部属性(包括可使用它执行的操作),对象是根据这些描述创建的实体。

类是用户定义的类型,对象是类的实例。每个对象存储自己的数据,而通过“发送消息”的方式共享类的方法(方法发送消息给对象)。

类声明: class A;,作前置声明,一般用于类相互引用(前置声明详解)。 类定义: class A { ... };,在不需表示严格意义上的类声明时,称此为类声明(广义)。可以在类声明中提供完整的函数定义,而不是函数原型。 类实现:即完成其方法的过程,类的实现有两种方式, 一种是在类定义时完成对成员函数的定义, 另一种是在类定义的外部进行完成。

调用成员函数被称为发送消息。 创造对象 , 类的实例

一、结构体

类与结构体的区别:

  • 结构体默认访问类型是public,通常表示纯粹的数据对象(POD , Plain Old Data)
  • 类默认访问类型是private,通常为数据和方法的组合。

bit控制:整型不跨(限制),联合重映射(应用)

标准与实现

位域(bit-field)在内存中的分配顺序、是否跨越字节边界、如何对齐——C++ 标准留给实现定义(implementation-defined)。同一段位域代码在 GCC、Clang、MSVC 下的实际位排布可能不同,跨编译器/跨平台序列化要用位运算显式控制,不要直接 memcpy 位域结构。

二、类的实现

注意public:;:

class A{
    int a;
public:
    A(int a);
};

2.1 构造函数

  • 默认构造函数(条款5):即没有输入参数的构造函数。
  • 合成默认构造函数:当用户没有定义~~默认~~构造函数时,编译器自动生成,不执行任何操作(对类类型成员调用其默认构造,对内置类型成员则不做初始化)。A() = default;
  • 拷贝构造函数。
  • 合成拷贝构造函数:当用户没有定义拷贝构造函数时,编译器自动生成,逐成员浅拷贝。A(const A& a) = default;
  • 深拷贝构造函数
  • 移动构造函数(C++11)
  • 合成移动构造函数:当用户没有定义移动操作且未声明拷贝操作/析构函数时,编译器才会自动生成
  • 普通构造函数
  • 一个基本类型参数的构造函数:需确认是否允许类型转换+拷贝构造的存在(默认存在)。

实现“多个”构造函数可以有多种方式。一种是形参提供默认值,一种是重载。两种方式同时使用时 ,应避免语义冲突。

2.2 合成函数/特种成员函数

PrimeC++说的合成函数和ModernC++中特种成员函数:

英文术语(English terms):合成函数 = synthesized member functions(C++ Primer / Lippman 用语,指编译器自动生成的版本,如 synthesized default constructor、synthesized copy constructor);特种成员函数 = special member functions(共 6 个:default constructor、destructor、copy constructor、copy assignment operator、move constructor、move assignment operator)。 注意 ISO C++ 标准正文并不使用 "synthesized" 这个词,正式措辞是 implicitly-declared(隐式声明)/ implicitly-defined(隐式定义,即按 = default 的规则定义);显式写 = default 的称为 defaulted,写 = delete 的称为 deleted。"synthesized" 只是教科书通俗说法,看标准/规范文档时要对应到 implicitly-declared/defined。

使用: 特种成员函数:默认构造函数、析构函数、拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符

  • 两种复制操作是彼此独立的(声明其中一个不影响另一个的合成)
  • 两种移动操作并不互相对立(声明其中一个不影响另一个的合成)
  • 一旦声明了移动操作,编译器就会废除复制操作,反之亦然。
  • 三大律: 如果声明了复制构造函数、复制赋值运算符,或析构函数中的任何一个,你就得同时声明所有这三个(即用户管理)。C++11 后扩展为五法则,再加移动构造与移动赋值。
  • 成员函数模板不会阻止特种成员函数的生成。

cpp_4-类与对象_260718_010207.png

若不存在「移动操作」函数,调用者输入的右值会自动走入「拷贝操作」函数。

不使用: delete vs private: 删除函数无法通过任何方法使用,所以即使成员和友元函数中的代码也会因试图复制basic_ios型别对象而无法工作。 习惯上,删除函数会被声明为public,而非private。当客户代码尝试使用某个成员函数时,C++会先检验可访问性,后检验删除状态。 delete 用于避免合成默认构造/拷贝函数的生成,另外删除类型隐式转换而引入的不必要重载。P76

2.3 初始化列表

成员变量在使用初始化列表初始化时,与构造函数中初始化成员列表的顺序无关,只与定义成员变量的顺序有关(按声明序初始化,标准良定义)。 初始化列表中可以进行运算。

  • [ ] 初始化列表一般出现在类外的方法定义,但C++11新增的类内初始化可用于非静态成员(含 const 成员)。

不需要的构造函数推荐写为A() = delete;。(条款6) 别让异常逃离析构函数(条款8) 绝不在构造和析构过程中调用(自身的)virtual函数(条款9)。

构造/析构期间的虚分派:良定义行为而非未定义行为

构造基类子对象期间,对象的动态类型就是该基类——此时对其调用虚函数,会分派到基类版本,不会下钻到派生类。这是标准良定义的行为,不是未定义行为(Undefined Behavior)。Scott Meyers 把它列为"该避免"是因其结果常常违背直觉。 真正的未定义行为是:在构造/析构中调用纯虚函数(pure virtual),构成对未定义函数的调用,标准未定义。GCC/Clang 在能检测到的情形下会给出 runtime 链接错误("_pure virtual method called"),但标准并不保证。

若目标类没有以 initializer_list 为形参的构造函数,则在构造函数被调用时,只要形参中没有任何一个具备 std::initializer_list 型别,那么小括号和大括号的意义就没有区别。 使用移动构造函数时,要使用小括号。 Widget w2{}:空大括号表示没有实参,即走默认构造函数。 Widget w3({}):调用 initializer_list 的构造函数,initializer_list 为空。 你最好把构造函数设计成用户无论使用小括号还是大括号都不会影响调用的重载版本才好。换言之,现在一般是把std::vector的接口设计视为败笔的。 大括号初始化可以应用的语境最为宽泛,可以阻止隐式窄化型别转换。

2.4 运算符重载符

  • 重载后的运算符必须至少有一个操作数是用户定义的类型。
  • 使用运算符时不能违反运算符原来的语法规则。(包括优先级)
  • 不能创建新的运算符;不可重载的运算符:::,.,.*,?:,sizeof,typeid;千万不要重载&&||,操作符;部分运算符只能通过成员函数进行重载:=(),[],->

隐式类型转换操作符:operator <otherType>() const; 隐式类型转换操作符详见类类型转换

2.4.1 赋值

——自我赋值与返回 *this (自赋值安全性「条款10」) → 处理所有成员 + 异常安全 (「条款11」) const Time& Time::operator=(const Time& t) {...} 浅拷贝时等价于:函数原型中Time& operator=(const Time& t)=default; 深拷贝的实现:

const String& String::operator=(const String& st){
    if(this == &st) return *this;   // 自赋值检查,必须放在释放旧资源之前
    char* newstr = new char[st.len + 1];  // 先分配新内存,再释放旧的——异常安全
    delete[] str;
    len = st.len;
    str = newstr;
    std::strcpy(str, st.str);
    return *this;
}
const String& String::operator=(const char* s){
    if(this == &s) return *this;   // 这里 s 不可能等于 this,但保持对称
    char* newstr = new char[std::strlen(s) + 1];
    delete[] str;
    len = std::strlen(s);
    str = newstr;
    std::strcpy(str, s);
    return *this;
}

总结:两引两常。自我删增与返回。 注:类中添加成员后,拷贝赋值函数也需要相应的添加。派生类拷贝函数要负责调用基类的拷贝函数「条款12」,包括拷贝构造函数和拷贝赋值运算符。但不该令copy assignment操作符调用 copy构造函数,反之亦然,唯一可行的方法是让两者调用一个公共函数来减少代码冗余。

2.4.2 四则运算

四则运算: 加减乘除模负 const Time Time::operator-(const Time& t)const{...} 等效于:const Time operator-(const Time& lhs, const Time& rhs){...}

const Time Time::operator*(const double& mult)const{...} 类型不同时,存在方向性问题,因此非成员函数更优: const Time operator*(const double& mult, const Time& t){...}

const Time Time::operator-()const{...} 等效于:Time operator-(const Time& t){...}

不要返回指向局部变量或临时对象的引用,返回const 主要是避免计算结果成为左值。 重载(包含运算符重载)时需要避免二义性错误 总之,const叠满,引用仅返回不需要。

左自增和右自增:

// Postfix increment operator
// C++ 不允许后置 ++ 返回值继续被 ++ 操作,所以返回 const
const Numeric_Iterator operator++(int);
// Prefix increment operator
// 前缀返回引用,效率更好,同时支持连续运算
Numeric_Iterator& operator++();
// 进一步分析:后置 ++ 优先级高于前置,故 ++i++ 解析为 ++(i++)。
// i++ 返回 const 右值,而前置 ++ 要求左值,故会编译报错。

2.4.3 输入输出

输出: friend std::ostream& operator<<(std::ostream& os, const Time& T); 输入: friend std::istream& operator>>(std::istream& is, Time& T); 引用叠满,

2.4.4 operator delete/new

(条款7,参考

操作符 作用 使用 说明
new 内存分配→初始化→返回指向类型的指针 std::string *pstr = new std::string("hello"); 操作符new一般称为 new operator 或 new expression。是内建操作符,无法被你所控制。
operator new 内存分配 void* memory = operator new (sizeof(std::string));
new (buffer) 初始化(即构造函数去除隐式堆栈控制部分) std::string* pstr = new (memory) std::string("Memory Management"); 称为placement new,只是new 操作符的一种应用特例。是标准库的一部分。需要头文件 #include <new>
delete 析构函数 + 释放内存 delete pstr;
operator delete 释放内存 pstr->~string(); operator delete(memory);;

注:记忆o前生后全。

  • [ ] 数组版本类似:STL源码解析有这部分内容,以后再熟悉下
void** memory = operator new[10] (sizeof(std::string));  

    std::string* ppstring[10];  
    for (int i = 0; i < 10; ++i) {  
        ppstring[i] = static_cast<std::string*>(memory[i]);  
        new (memory[i]) std::string("Memory Management");  
    }

    // 使用 string 对象...  

    // 当不再需要时,手动调用析构函数  
    for (int i = 0; i < 10; ++i) {  
        ppstring[i]->~basic_string();  
    }

    // 释放内存  
    operator delete[] (memory);

2.4.5 其他

区分常量的运算:*,->,[] char& String::operator[](std::size_t i) {return str[i];} const char& String::operator[](std::size_t i) const {return str[i];} 为了避免成员函数代码冗余,可使用const_cast实现常量转移(casting away constness),即非常方法调用常量方法(反向做法不应该)——条款3。

2.5 注意项

2.5.1 命名

类名首字母大写 前缀:数据成员名使用m_,指针p,向量v 类方法名=限定名(qualified name) + 非限定名(unqualified name)。非限定名只能在类作用域中使用。

2.5.2 默认值/初始值

  • 初始化列表中,内部参数(外部参数),内部参数和外部参数可以同名。初始化列表顺序应与类中数据定义顺序一致。
  • 初始化列表中内部变量(外部变量)。括号内是可以调用函数的。
  • 类中数据可直接提供初始值,也会被构造函数覆盖。构造函数先运行成员初始化列表(member initialization list),再运行构造函数体(在函数体内对成员做的赋值不是真正的"初始化")。
  • 初始化列表中内部变量(外部变量或表达式),括号内支持?:new表达式,甚至是要构造的类内的函数。(x条款10)
  • 方法参数的默认值可以写在方法定义中或方法原型中,但就是不能同时出现。一般约定成俗地设置在方法原型中。必须从右向左添加默认值。
  • 类中static成员变量,只能在类内声明,然后类外定义(C++17 起可用 inline 在类内定义)。

跨编译单元的 non-local static 初始化次序——未指定

不同编译单元(translation unit)内的 non-local static 对象,彼此之间的初始化次序是未指定的(unspecified)——这是经典的"static initialization order fiasco"。同一编译单元内则按定义顺序初始化、按逆序析构(标准良定义)。为了解决跨 TU 的次序问题,可使用 Singleton 模式的实现手法(条款4——确定对象在使用前被初始化):用函数返回局部 static 对象的引用,实现"首次遇上该对象之定义式"时才被初始化(C++11 起保证局部 static 初始化线程安全)。在多线程环境,推荐在程序的单线程启动阶段,手动调用所有 reference-returning 函数,这可消除与初始化有关的"竞速形式(race conditions)"。

2.5.3 修饰符

  • 顶层 const 不能区分函数的重载(即 f(int)f(const int) 不能同时存在);引用与非引用形参(如 f(int)f(int&))类型不同、可作为不同重载,但用左值调用时常产生二义性。函数原型和变量定义中&起到修饰符的作用,&在其他地方可起到"与"和"取址"的运算符作用。(即形参表示引用,实参表示取地址)
  • 修饰方法的const在类中的函数原型和类外的函数定义中都应该存在。

  • const对象使用const方法(即const关键字放在参数列表后)。只要类方法不修改对象,就应该将其声明为const,构造和析构时除外。

  • 重载时,区分常量和非常量函数的特征标。普通对象可以使用const方法,但一旦定义了非const方法,则优先使用非const方法。
    • C++11提供了非成员函数版本的std::begin(...)std::end(...)
    • C++14提供了非成员函数版本的std::cbegin(...)std::cend(...)
  • const 表示"只读"语义(被 const int x = 5; 修饰的值编译期就已知,但 const 本身只承诺不可修改);constexpr 是 C++11 引入的、要求在编译期求值的指示符,对变量而言必须能用常量表达式初始化。
  • const 成员和引用成员只能在初始化列表中初始化。
  • friend函数的定义在类作用域外,需使用类内数据时需要使用全名。

  • 只有在类声明中的原型中才能使用friend关键字。除非函数定义也是原型,否则不能在函数定义中使用该关键字。

左值变为右值:std::move 类似const, 右值对象使用右值成员函数:DataType data() && {return std::move(values);}

  • explicit只能在类中使用,在类外的函数定义不能使用。
  • 静态类方法的static只能在类中使用,在类外的函数定义不能使用。

  • 静态数据成员在类声明中声明,在包含类方法的文件中定义。但如果静态成员是 const/constexpr 整数类型或枚举型,则可以在类声明中给出初值。

  • 静态变量在程序结束时调用对象的析构函数(逆序于构造)。
  • virtual只能在类中使用,在类外的函数定义不能使用。
  • inline 方法定义位于类声明中的函数都将自动成为内联函数。 确保内联定义对多个文件程序中的所有文件都可用的,最简单的方法是:将内联定义放在定义类的文件中。 类外的方法内联定义中,关键字inline只需在方法定义中使用,无需再方法原型中使用。即inline是一种"用于实现的关键字,而不是用于声明的关键字"。 方法的形参默认值只需在原型中写明,无需再方法的定义中表明。 inline只是对编译器的一个申请,不是强制命令。是否将Derived构造函数inline化,并非是个可以轻松的决定。inline函数无法随着程序库的升级而升级。大部分调试器面对inline函数是束手无策的。(条款30)
  • mutable 《effective C++》条款03就有讲到:对于C++类的const成员函数,logical constness的观点允许修改它所处理的对象内的某些bits,但是bitwise const的观点是不能改变对象内的任何一个bit。 由于mutable的存在,const成员函数也要保证线程安全。 mutable的应用:(M条款16)
  • 计算代码昂贵,不愿意重复计算,将其结果缓存起来。
  • std::lock_guard<std::mutex>, std::atomic<type>
  • 按值捕获(Capture by Value)的方式不允许程序员在 Lambda 函数的函数体中修改捕获的变量。而以 mutable 修饰 Lambda 函数,则可以打破这种限制。auto f1 = [=]() mutable {x = 42;};
  • 其他: enum, virtual, override,final,volatile,registervolatile/register 参考

2.5.4 编译优化

A getA(A& ta, B& tb)
{
    A a1;          //-----A Constructor
    return a1;     //-----A Copy Constructor temp=a1, A Destructor a1
}

A a2;
a2 = getA(a,b);    //-----A Copy Constructor a2=temp,  A Destructor temp

若使用选项-fno-elide-constructors,则函数返回对象时,会多出两次拷贝构造和析构,即a1→tmp→a2。C++17 起对纯右值(prvalue)情形的拷贝消除是强制的(guaranteed copy elision),而命名返回值(NRVO)仍属于可选优化。

作用域

  • :: 作用域解析运算符
  • 在类中定义的名称(如类数据成员名和类成员函数名)的作用域都为整个类;
  • 在类声明或成员函数定义中,可以使用未修饰的成员名称(未限定的名称);
  • 作用域为类的常量:
  • 在类中声明一个枚举(无名枚举技巧,C++98 即支持):enum {Months=12};
  • 在类中定义一个静态常量:static constexpr int Months =12;(C++11)
  • 类中的多个枚举作用域:解决多个枚举定义中存在相同的枚举量的问题,enum class egg {small ...}; 注:有作用域枚举enum class/enum struct,C++11)不允许隐式转换到基本类型;无作用域枚举(普通 enum)仍然可以隐式转整型。C++11 允许指定枚举量对应的基本类型,如 enum class A : double {...};;相同枚举量间可以进行比较。可以进行前置声明,进而避免了编译依赖性。

2.5.5 动态内存分配

当类中存在动态内存分配时,需要考虑传参、初始化、赋值三种情形。需要严格避免使用自动生成的类函数,即默认构造函数、拷贝构造函数、拷贝运算符、析构函数。其他函数也要注意内存管理。 C++11新增了nullptr,是关键字,而NULL则是宏,有被覆盖的风险。

三、类的使用

调用构造函数/定义对象/类的实例化

  • 隐式构造(直接初始化):A a(50);,A a;, A a{50};,A a {};
  • 显式构造(含拷贝初始化):A a = A(50), A a = A();,A a = {50}; 可能会创建临时对象,临时对象不一定会立即删除。
  • 转换+普通构造+拷贝构造:A a = 50;
  • 函数声明(最令人苦恼的解析 / most vexing parse):A a(); —— 这会被解析为一个函数声明,而不是默认构造的对象。默认构造应写 A a;A a{};

初始化对象数组的方案是,首先使用默认构造函数创建数组元素,然后花括号中的构造函数将创建临时对象。

3.1 类类型转换

将其他类型转为当前类型:单参数构造函数又称为转换构造函数。C++ 的隐式转换序列中至多允许一次用户自定义转换——int→B(B 的构造接收 int)再 B→A(A 的构造接收 B)的链式隐式转换是不允许的,必须显式完成其中一步。当且仅当转换不存在二义性时,编译器才会插入这一次用户转换。 将当前类型转为其他类型:类型转换操作符/转换函数。可以由编译器自动执行,但是编译器每次只能执行一种类类型的转换。如果一个类型转换为另外一个类型,需要同时进行多次转换,这种转换将会失败。

3.1.1 单参数构造函数(转入)

为了避免错误(预期外)的类型转换:(x条款5)

优化方式1:改为普通函数 用户自定义的类类型转换,他们的出现可能导致错误(非预期)的函数被调用。解决办法是以功能对等的另一个函数取代类型转换操作符。如asOtherType(),c_str()

优化方式2:代理类 proxy class: 他的每一个对象都是为了其他对象而存在的。 通过代理类和用户定义类型转换只能转换一次的特性,定义class ArraySize,并让转换构造函数使用该类型做作为输入参数。

优化方式3:explicit explicit关键字可以关闭拷贝初始化(= 形式)中的隐式转换。explicit禁止的是A a = 10.5;,而A a(10.5);A a{10.5}; 则无法阻止。 注:C++11 起 explicit 同样适用于多参数构造函数——多参数构造虽不能做单值隐式转换,但仍可参与 A a = {1, 2}; 形式的拷贝列表初始化;加 explicit 后只能 A a{1,2};A a(1,2);,禁止 = 形式的列表初始化。所以"参数 ≥2 时 explicit 不生效"的说法是错的。

3.1.2 转换函数(转出)

转换函数是用户定义的强制类型转换。一般用于类→基类型A类→B类的转换。 转换函数必须是类方法;不能指定返回类型;不能有参数:operator int() const;,而括号运算符int operator() () const; C++11 支持转换函数使用 explicit,这是有益的。应该谨慎使用隐式转换函数。通常选择仅在被显式地调用时才会执行的函数。

自行GPT查看:C++区分括号运算符和类型转换函数的使用。

注意类的类型转换将扩大运算符重载的含义。如果一个类中同时定义了转int和转double的转换函数,则 double a = A(10),将调用double转换函数,因为一步转换优于两步转换。 cout << A(10)则将报错,因为存在二义性。 所以不要提供转换函数,除非你确定需要它们。STL中std::ref基于转换函数,用户可以无感地让「原本形参是非引用的,但实参添加了std::ref后操作结果如同引用形参那样」。

若所有参数皆需类型转换,请为此采用non-member函数(条款24)。如int要和ratio类型进行乘法计算。

3.2 重载

模板匹配与重载匹配的原则是一致的。

完全匹配(即不算一次转换):有无顶层 &, 有无volatile, []与*,函数指针类型(即Type (argument-list)Type (*)(argument-list)), 最特殊的完全匹配:当重载声明中只存在const类型时,非const可完全匹配const类型参数(顶层 const 不参与重载区分)。

返回值不作为重载依据。

一次转换:提升转换,标准转换,用户定义的转换。

一个模板实例化函数和一个非函数模板具备相等的匹配程度,则优先选用常规函数。 尽可能避免把万能引用型别作为重载函数的形参选项。(包含构造函数)万能引用形参的重载比你想要的程度更贪婪,而却又未贪婪到能够独当一面的程度。若有重载需求,详见「模版」篇。

四、warning/error

Q: warning "will be initialized after [-Wreorder] A:构造函数时,初始化成员变量的顺序要与类声明中的变量顺序相对应,若不对应,则出现如题错误。

Q:"Cannot declare member function ...to have static linkage" A: 类成员函数的 static 关键字表示该成员属于类本身("one-per-class",所有实例共享);而在 .cpp 文件作用域使用 static 等价于给该函数/变量加上内部链接(只能被本翻译单元访问)。把这两种 static 都加在同一个类外成员函数定义上语义冲突,编译器会拒绝。类外定义静态成员函数时不要再加文件作用域的 static

Q:error: default argument given for parameter 2 A:将定义或声明中的任一个缺省参数删除即可。

五、定位new运算符

  • 程序员必须负责管理定位new运算符所使用的缓冲区内存单元,并保证两个内存单元不重叠。
  • 需显式调用析构函数,并谨慎使用delete(只能 delete[] 原始 buffer,不能 delete 定位 new 出来的对象)。
char* buffer = new char[512];
JustTest* pc1 = new (buffer) JustTest();
JustTest* pc2 = new (buffer + sizeof(JustTest)) JustTest();
pc2->~JustTest();
pc1->~JustTest();
delete [] buffer;

六、类的继承

继承类包含:

  • 继承基本 public/private 的方法和数据。
  • 重新定义/覆盖基类方法。
  • 派生类独有的数据和方法。

6.1 构造与析构与赋值

构造函数和析构函数亦无法继承。C++11新增了一种让您能够继承构造函数的机制(using Base::Base;),但默认仍然不继承构造函数。 创建派生类对象时,程序首先调用基类构造函数,然后再调用派生类构造函数。派生类构造函数总是需要以显式或隐式(合成构造函数时)的方式调用基类构造函数。 但析构时总是从派生类向基类析构(构造序:基→成员→派生;析构序:派生→成员→基,标准良定义)。

当基类和派生类都采用动态内存分配时,派生类的析构函数、拷贝构造函数、拷贝赋值运算符需使用递增模式,即都必须使用相应的基类方法来处理基类成员。

hasDMA::hasDMA(const hasDMA & hs): basDMA(hs){...}
hasDMA& hasDMA::operator=(const hasDMA& hs){
    ...
    baseDMA::operator=(hs);
    ...
    return *this;
}
ostream& operator<<(std::ostream& os, const lacksDMA & ls)
{
    os << (const baseDMA&) ls;
    os << "Color:" << ls.color << std::endl;
    return os;
}

公有继承时,基类指针可以在不进行显式类型转换的情况下指向派生类对象(向上强制转换)。向下强制转换是不允许的,需要使用dynamic_cast。 在私有继承中,未进行显式类型转换的派生类引用或指针,无法赋值给基类的引用或指针。原因:

  • 避免无限递归调用
  • 避免多重继承时无法确定使用哪个基类的方法。

使用基类构造/赋值派生类时,需要定义新的构造函数(毕竟存在向下转移的关系)。当派生类构造/赋值基类时,已有的基类函数已经满足要求,无需新建方法。

友元函数并非类成员,因此不能继承。

6.2 继承/覆盖/隐藏/重载(using 引入)

派生类可以使用非私有的基类方法。继承后的同名方法,可分为用于覆盖的方法和隐藏的方法。

派生类可以有与基类同函数名的方法,也可以有与基类函数名+参数列表相同的方法(即重新定义方法)。当派生类与基类到 函数名相同时,基类同名方法被隐藏

如果基类声明被重载了,则派生类要么不定义同名方法,要么需要重新定义所有的基类版本。如果派生类和基类方法的定义无法一一对应,那么可能出现warning。

  • 重新定义的含义:如果重新定义继承的方法,应确保与原来的原型完全相同,但如果返回类型是基类引用或指针,则可以修改为指向派生类的引用或指针。这种特性被称为返回类型协变(covariance of return type)。
  • 如果派生类方法不需要修改,则新定义可只调用基类版本。 void Hovel::showperks() const {Dwelling::showperks();}
  • 更好的方法是用 using 声明引入基类重载集using Base::f;

七、多态

多态:方法是通过对象调用的。指针或引用使用方法,方法的行为取决于调用该方法的对象。

非虚函数使用静态联编,虚方法使用动态联编。动态联编的实现一般为:P503 对象中存放指向虚函数表的指针(vptr)。虚函数表(vtable)单独存放,一般每一个含 virtual 函数的多态类对应一个虚函数表。虚函数表存放指向 virtual 方法的指针、相关元信息(如 RTTI、偏移修正 thunk)。程序运行时通过查找对象的虚函数表来获取方法。

标准与实现:vptr/vtable/对象布局都是 ABI 决定的

标准只规定:通过基类指针/引用调用虚函数必须按对象的动态类型分派(这是标准良定义的语义)。 至于"虚指针 vptr 放在对象哪里、vtable 里槽位如何排布、有没有 thunk、多重继承时各基类子对象偏移多少"——标准一概不规定,全部由 ABI 决定

  • Linux/macOS x86-64 与 ARM:走 Itanium C++ ABI(GCC/Clang 遵循),vptr 放在对象起始处
  • Windows x86/x64:走 MSVC ABI,vptr 同样在对象起始,但 vtable 槽位排布、多重继承下基类偏移与 thunk 形式与 Itanium 完全不同

所以同一段含虚函数的类定义,GCC/Clang 与 MSVC 编译出的对象二进制不兼容,跨编译器传递多态对象会出错。 另外:"派生类未重写基类虚函数时不需要 vtable"是误解——只要类继承了虚函数、对象就要带 vptr,派生类会有自己的 vtable 副本(其中未重写的槽位指向继承自基类的实现)。

当存在多态时,指向同一对象的基类指针和派生类指针的指向地址不一定相同单继承下基类子对象一般在偏移 0,两者地址相同;多重继承或含虚基类时,非首基类子对象有偏移,基类指针与派生类指针地址不同。故多态和指针算术不能混用(x条款4)。 虚函数是动态绑定的,默认参数是静态绑定的。默认参数的使用需要看指针或者引用本身的类型,而不是对象的类型!

7.1 虚函数判定

派生类可以不显式的用virtual声明虚函数,这时系统就会用以下规则来判断派生类的一个函数成员是不是虚函数: (1)该函数是否与基类虚函数有相同的名称,参数个数以及对应的参数类型; (2)该函数是否与基类虚函数有相同的返回值或者满足类型兼容规则的指针/引用型的返回值; (3)为了避免判断失败导致不符合预期的行为,最好在程序中加 override 关键字让编译器代为校验;final 用于禁止再下一层派生类继续覆盖,与 override 用途不同。

7.2 抽象类

含有纯虚函数的类称为抽象类(abstract class)。

namespace experiment3 {
class AWOV {
 public:
  virtual ~AWOV() = 0;     //当一个类中无其余函数时,将析构函数定义为pure virtual是一个方法
};

AWOV::~AWOV() { } //需要定义,否则会链接错误(undefined reference):派生类析构会隐式调用基类析构

7.3 关键字

  • override关键字作用:(C++11) 如果派生类在虚函数声明时使用了override描述符,那么该函数必须覆盖(override,注意不是"重载")其基类中的同名同参虚函数,否则代码将无法通过编译。
  • final关键字作用:(C++11) 既可用于类(禁止被继承),也可用于虚函数(禁止派生类再覆盖该函数)。继承一个没有virtual析构函数的基类或者不了解具体实现的标准容器是危险的。(条款7)
  • staticvirtual互斥。
  • 通过基类指针或者引用调用的虚函数必定不能被内联。当然,实体对象调用虚函数或者静态调用时可以被内联,虚析构函数的静态调用也一定会被内联展开。

7.4 常用方法的多态

继承时,

  • 构造函数:无多态属性。
  • 析构函数应是虚函数,除非类不用做基类。基类的析构函数总是要有virtual属性。假设基类中采用的是非虚析构函数,当删除基类指针指向的派生类对象时就不会触发动态绑定,因而只会调用基类的析构函数,而不会调用派生类的析构函数(结果为未定义行为)。派生类析构函数中无需调用基类析构函数,程序会自动调用。但只有当类内至少有一个virtual函数,才为它声明virtual析构函数,否则会增加一个vptr(virtual table pointer)的存储空间。
  • 普通方法:使纯虚函数实现接口定义:virtual void ViewAcct() const =0; 自动回归特性(静态链接性):若B继承A,且存在方法B::x(){A::x()},virtual A::x(){y()},virtual A::y(),B::y();时,当指针指向派生类,并调用x(),则最终将运行B::y()。函数参数默认值也是静态链接的。

7.5 接口设计

具有至少一个纯虚函数(pure virtual function)的类为抽象类(注意"虚基类"另有所指——指通过 virtual 继承的基类,二者不是一回事)。纯虚函数通过用声明为public属性用于作为接口,而与纯虚函数对应的通用实现方法可写成如protected defaultFly()的方法作为常用实作,这样一方面可以避免代码的冗余,又保证接口的纯粹性。若一个函数不存在被override的可能,则写为non-virtual函数即可。(条款34)

non-virtual interface(NVI)手法,即Template Method设计模式(与C++templates并无关联):把接口都写为non-virtual方法,其实现包括:前置工作、private virtual 私有函数、后置工作。因此这个non-virtual方法被称为外覆器(wrapper)。private virtual方法一方面让派生类必须重写该方法才能使用,起到了类似纯虚函数的效果,但对外又不可见,而是被wrapper调用。

一个思考),容许private方法是virtual是不是一个漏洞?在C++11以前是的,但通过private成员继承目标类来实现;在C++11及以后已通过final关键字补偿了这个漏洞。(条款39 P190)

通过函数指针、std::function可以作为virtual函数的一种替代(条款35)。虽然个人感觉不完全等效,前者只是一种面向对象的自由接口,即回调函数;后者为面向类的接口,而古典Strategy模式介于这两者之间。

class内声明一个non-virtual 函数会为该 class 建立起一个不变性(invariant),凌驾于其他特性(specialization)。为了避免有些重载的函数是动态绑定,有些又是静态绑定的「精神分裂行径」,绝不重新定义继承而来的non-virtual函数(条款36)。因此基类non-virtual函数应该写为final。 由于缺省参数值是静态绑定的,所以绝不重新定义继承而来的缺省参数值。(条款37)

7.6 虚基类 vs 抽象类(别搞混)

这两个都带"虚"字、都跟继承沾边,但解决的是完全不同层面的问题,互不相关。一个类可以既是抽象类、又用到虚基类,但二者没有任何蕴含关系。

7.6.1 一句话核心区分

  • 抽象类(abstract class):控制接口、约束子类必须实现某些函数,解决「功能没实现、不能实例化」的问题——是接口 / 设计层面的概念。
  • 虚基类(virtual base class):解决多继承菱形继承的成员重复冗余与二义性,解决「同一个父类被继承出多份」的内存布局问题——是多继承内存布局层面的语法。

7.6.2 对照表

抽象类 虚基类
触发条件 类含纯虚函数 virtual void f() = 0; 继承时加 virtual 关键字:class D : virtual public B
解决什么 接口未实现 → 禁止实例化 菱形继承里基类子对象重复多份 + 访问二义
所处层面 接口 / 设计 多继承内存布局
能否实例化 不能(含纯虚函数) 能(与是否可实例化无关)
是否涉及 ABI 否(纯语言语义) (虚基类子对象的布局、偏移、thunk 由 ABI 决定,见后文多重继承 ABI 一节)

7.6.3 各自长什么样

抽象类——接口约束:

class Shape {                          // 抽象类
public:
    virtual double area() const = 0;   // 纯虚函数 → Shape 不能实例化
};
class Circle : public Shape {          // 子类必须实现 area() 才能实例化
public:
    double area() const override { /* ... */ return 0; }
};
// Shape s;   // ❌ 编译错:抽象类不能实例化

虚基类——解菱形继承的重复:

class A { public: int x; };
class B : virtual public A {};         // ← virtual 让 A 成为(B、C 的)虚基类
class C : virtual public A {};         // ← 共享同一份 A
class D : public B, public C {};       // 菱形:D 里只有【一个】A 子对象
// d.x;       // ✓ 无二义;若 B、C 不写 virtual,则各带一份 A,d.x 因二义报错

注意上面两个例子是互相独立的:虚基类例子里 A 不是抽象类(它没有纯虚函数);抽象类例子里 Shape 也没用 virtual 继承。一个类当然可以是抽象类、是某条继承链上的虚基类——那是两套机制叠加在同一类型上,不是一回事。

标准与实现

「抽象类」是标准良定义的语言语义(含纯虚函数即抽象类、不可实例化),与 ABI 无关。而「虚基类」的子对象在对象里怎么摆、偏移怎么算、this 怎么用 thunk 调整——这些由 ABI 决定(Linux/macOS 走 Itanium C++ ABI,Windows 走 MSVC ABI,二者不兼容),详见后文「多重继承的对象布局由 ABI 决定」。

八、继承属性

8.1 所有权属性(访问规则)

公有继承(public)下为is-a属性,私有继承或保护继承时为has-a属性(实现层面关系)。 注:公有继承,即对 base class 为真的事情一定也对其 derived classes 为真。在语言中,一般会对被概括的对象施加一般特定对象都具有的属性,如鸟会飞。而并不适用所有对象,如企鹅不会飞,因此可由「编译期强制实施,运行期检测」加以处理。(条款32) 注:由于public约定俗成是is-a属性,因此派生类不应该隐藏基类方法。相对地,若私有继承若要暴露基类的某些方法的特定参数版本,则可通过转交函数(forwarding function),否则使用using声明。(条款33)

访问说明符继承后的结果(基类成员在派生类中的访问属性):

继承方式 基类 public 基类 protected 基类 private
public 继承 public protected 不可访问
protected 继承 protected protected 不可访问
private 继承(class 的默认继承方式) private private 不可访问

注:class D : B 默认是私有继承struct D : B 默认是公有继承——两者默认值不同,不要混淆。protected 继承必须显式写 protected没有哪种关键字默认就是 protected 继承

友元函数的目的是为了让一个函数或者类能够访问另一个类中的私有成员,使用友元的关键字就是friend,总共有三种实现方式分别是将全局函数作为友元,将类作为友元,将成员函数作为友元。 私有继承的一个上古应用:在没有 delete 合成(构造)函数时,(条款6)可以私有继承一个拥有私有合成函数的 Uncopyable 类,则派生类的友元函数就不能调用合成函数(因为派生类继承基类合成函数,但基类合成函数是 private)。但会引入多重继承的问题。

has-a:

  • 优先使用包含。使用简单直观,可以包含多个同类的成员。
  • 使用私有继承/保护继承。可访问不同程度的基类成员,可重新定义虚函数。私有继承的虚函数只能在类中使用,而不是公有的。但可重新定义访问权限:
  • 方法内调用;double Student::sum(void) const {return std::valarray<double>::sum();}
  • using 方法全名(不含参数列表和返回);public: using std::valarray<double>::min;
  • 重新在类中声明(弃用)。

私有继承的内涵: 如果你让class D 以private形式继承class B,你的用意是为了采用class B内已经备妥的某些特性,而不是因为B对象和D对象存在任何观念上的关系。Private继承在软件「设计」层面上没有意义,其意义只及于软件实现层面。 尽可能地使用复合(composition),必要时才使用private继承。何时才是必要?主要是当protected成员和/或virtual函数牵扯进来的时候。(条款39) 一种避免私有继承的策略:

class Widget {
private:
    class WidgetTimer: public Timer {
    public:
        virtual void onTick() const;
        ...
    };
    WidgetTimer timer;
};

私有继承的主要应用:通过private继承和EBO技术,可以实现「用户自定义之函数对象」。如classes unary_function, binary_function。

8.2 多重继承属性(继承规则)

子对象(subobject)来表示通过继承或包含添加的对象。

多重继承(multiple inheritance , MI)时,应对每个基类的属性进行限定。 很多C++用户强烈反对使用MI,一些人甚至希望删除MI;而喜欢MI的人则认为,对于特殊的工程来说是必不可少的。Worker→ Singer/Waiter → SingingWaiter

  • 共享祖先的独立基类问题:使用virtual关键字来表明虚基类。带有pure virtual函数导致 abstract(抽象)类。 对于非虚基类,唯一可以出现在初始化列表中的构造函数是直接基类(即当前派生类的直接父类)的构造函数。C++在基类是虚的时,禁止信息通过中间类自动传递给基类,而需要显式地调用所需的基类构造函数。即如果基类是虚基类,菱形继承下派生类将共享基类的一个子对象;如果基类不是虚基类,菱形继承下派生类将包含多个同名基类子对象。允许派生类同时继承虚基类和非虚基类。

标准与实现:多重继承的对象布局由 ABI 决定

多重继承下,"几个基类子对象各放在哪个偏移、虚基类子对象怎么摆、基类指针/派生类指针之间如何用 thunk 转换、type_info 怎么找"——这些标准一概不规定,由 ABI 决定:

  • Itanium C++ ABI(Linux/macOS x86-64,GCC/Clang;ARM 配合 AAPCS)用一组 vtable 子结构 + offset-to-top + 各种 thunk 处理多重继承与虚继承。
  • MSVC ABI(Windows)用别的一套 vtable 结构与 thunk 方案。

因此带多重继承/虚继承的类,不能在 GCC/Clang 与 MSVC 间跨编译器传递——同一个 Derived* 在两种 ABI 下偏移不同。

class Singer: virtual public Worker{};
class Waiter: public virtual Worker{};
class SingingWaiter: public Singer, public Waiter{};
  • 同名方法的独立基类问题:通过判断非限定方法是否有二义性,分为两种情况: (注:虚二义性规则与访问规则无关。两个同名方法不在同一继承链上时,无论继承远近是否一致,都将出现二义性)
  • 如果继承方法时不存在二义性,则使用最近祖先中的定义。
  • 如果存在二义性,则可以选择使用作用域解析运算符来澄清编程者的意图newhire.Singer::show();,还可以通过函数重写来覆盖各基类同名方法,此时应使用模块化方式,而非递增方式实现方法。

九、RAII/资源管理

RAII (Resource Acquisition Is Initialization),也称为"资源获取就是初始化",是C++语言的一种管理资源、避免泄漏的惯用法。C++标准保证任何情况下,已构造的对象最终会销毁,即它的析构函数最终会被调用。简单的说,RAII 的做法是使用一个对象,在其构造时获取资源,在对象生命期控制对资源的访问使之始终保持有效,最后在对象析构的时候释放资源。(RAII 参考

9.1 编译器选项

  • 要在 cl 中启用异常处理,可以使用 /GX 编译器选项或者 /EHsc;要禁用 异常处理,您需要从源代码中删除所有的 trycatch 块,并使用 /GX- 选项编译代码。
  • 在缺省情况下,g++ 编译器启用了异常处理。将 -fno-exceptions 选项传递给 g++,会产生所需的效果。

9.2 智能指针(C++11)

共性:自动释放内存,指向的对象必须建立在堆上。 智能指针通常都重载了提领运算符(operator->和operator*),不过std::weak_ptr是个例外。

  • auto_ptr: 左右值时都可以进行所有权转换,不支持new[]。使用风险较高。(C++11弃用,C++17移除)
  • unique_ptr:只支持临时右值或右值引用下的所有权转换,支持new[]。可在STL容器中使用,但不能调用存在赋值、复制的方法。在 unique_ptr 为右值时,允许赋值给 shared_ptr 。(C++14)
  • shared_ptr:引用计数。(C++11)
#include <memory>
std::unique_ptr<std::string> demo(const char * s){
    //unique_ptr<string> temp(new string(s));
    //1、避免内存碎片;2、避免新构建的智能指针的两个动作被另一个并存的函数参数触发的异常而打断,从而保证预期的求值序。
    auto temp = std::make_unique<std::string>(s);
    return temp;
}

std::unique_ptr<std::string> ps1, ps2;
ps1 = demo("Uniquely special");
//ps2 = ps1; // build error
ps2 = std::move(ps1);
ps1 = std::unique_ptr<std::string>(new std::string("Yo!"));

std::unique_ptr<double[]> pda(new double[5]);   // 注意是 new double[5](数组),不是 new double(5)(单个值为 5)
std::shared_ptr<std::string> sptr1 = demo("get unique");
// 使用 std::unique_ptr 管理一个动态分配的整数数组
std::unique_ptr<int[]> arr(new int[5]);

// 初始化数组
for (int i = 0; i < 5; ++i) {
    arr[i] = i;
}

std::unique_ptr数组形式的存在,作为一种知识上的了解就够了。因为,std::array,std::vector和std::string这些数据结构几乎总是比裸数组更好。 注:C++17 起 std::shared_ptr 已支持数组形式 std::shared_ptr<T[]>(带 operator[]),可正确调用 delete[];早期需自定 deleter。但仍优先用 std::vector

在 C++11 及更高版本中,std::unique_ptr 提供了 std::move 语义来转移所有权。一旦所有权被转移,原 std::unique_ptr 不再拥有内存,它的内部指针会被设置为 nullptr。它的 get() 方法返回的指针值此时确定地是 nullptr(不是"不确定")。

进行一项要求引用计数的操作通常会映射到单个机器指令。尽管与非原子化指令相比可能成本更高一些,但仍然是单指令。

尽可能避免将裸指针传递给一个std::shared_ptr的构造函数。如果非要这样,原则上不能将同一个裸指针提供给两个不同的智能指针初始化,这会导致多重控制块问题。 若类要为自己创建一个智能指针,并将这个智能指针交给其他模块控制,则使用std::shared_from_this()(前提:对象本身已经std::shared_ptr 管理——否则 shared_from_this() 触发未定义行为,通常会抛 std::bad_weak_ptr)。假设程序使用std::shared_ptr来托管Widget对象,并且有个数据结构用来追踪被处理的Widget:

#include <iostream>  
#include <memory>  

class MyClass : public std::enable_shared_from_this<MyClass> {  
public:  
    void DoSomething() {  
        // 获取指向当前对象的 std::shared_ptr  
        std::shared_ptr<MyClass> sptr = shared_from_this();  

        // 现在你可以使用这个 shared_ptr 做一些事情,例如传递给其他函数  
        SomeFunction(sptr);  
    }  

    // 其他成员函数和数据成员  

private:  
    void SomeFunction(const std::shared_ptr<MyClass>& ptr) {  
        // 使用传递进来的共享指针  
        std::cout << "Function called with shared_ptr to MyClass object" << std::endl;  
    }  
};  

int main() {  
    // 创建一个 MyClass 的共享指针  
    std::shared_ptr<MyClass> obj = std::make_shared<MyClass>();  

    // 调用成员函数,该函数内部将获取一个共享指针并传递它  
    obj->DoSomething();  

    return 0;  
}

智能指针手动释放:sptr.reset();sptr = nullptr;

多重控制块的workaround:std::weak_ptr,其不具备提领和检查空的能力。不影响所指对象的引用计数,但能判断弱指针是否失效(裸指针做不到)。简单说,弱指针是一个见证者,而不是一个管理者。 弱智能指针可以临时提升为强指针,这样避免了两个类中的指针是对等关系,从而避免多重控制块的死锁问题(参考)。

shared_ptr和weak_ptr共享一个控制块,这个控制块中有强引用计数和弱引用计数。当强引用计数归零时,指向的对象内存释放,而要当弱引用计数归零时,控制块的内存才释放。

auto spw = std::make_shared<Widget>();
std::weak_ptr<Widget> wpw(spw);
wpw.expired(); //过期判断
auto spw1 = wpw.lock(); //若wpw失效,则spw1为空
std::shared_ptr<Widget> spw2(wpw); //若wpw失效,则抛出std::bad_weak_ptr型别的异常

自定义析构器时不能使用make系列函数。make系列函数对形参进行完美转发的代码是使用小括号的。若想传入初始化列表,可先创建初始化列表对象,再传入。 make系列函数的优点:避免代码冗余、避免内存碎片,避免实参并行时的内存泄露。

万能引用是一种抽象,底层的真相是引用折叠(reference collapse)。 对auto或template的被依赖项直接施加&&时,&&为万能引用,否则&&为右值引用。

针对右值引用实施std::move,针对万能引用实施std::forward

//缺点:代码冗余;没有可扩展性,即n个参数的左右值可能性有2^n种重载可能。
class Widget {
public:
    void setName(const std::string& newName)
    { mName = newName; }
    void setName(std::string&& newName)
    { mName = std::move(newName); }
};

//如果函数内部是emplace,则传入的函数的字符串常量则可以不加转换的使用。
class Widget {
public:
    template<typename T>
    void setName(T&& newName)
    { mName = std::forward<T>(newName); }
};

十、类的内存使用

面对「大小为零之独立对象」时,C++官方勒令默认安插一个char到空对象内——sizeof(EmptyClass) == 1标准良定义:保证同一类的不同对象地址互不相同)。

标准与实现:对象布局/对齐/padding 都由 ABI 决定

标准只规定"空类至少占 1 字节""成员按声明序排列""成员对齐导致 padding"等少数几条;具体每个成员放哪个偏移、要对齐到几字节、alignas/位域怎么实现——全部由 ABI 决定

  • Linux/macOS x86-64 / ARM:Itanium C++ ABI + Itanium psABI(对齐规则、trailing padding、是否在派生类尾部插入 padding 等)。
  • Windows:MSVC ABI + Microsoft 结构对齐规则。

同一个含多个成员的 struct,两种 ABI 下 sizeof 可能不同(典型差异:尾部 padding、位域排布、含虚函数时 vptr 大小位置)。

EBO(empty base optimization;空白基类最优化):约束不适用于derived class对象内的base class成分——派生类里的空基类子对象可被优化为 0 字节。 EBO 由标准允许,但优化到什么程度是编译器实现定义的(GCC/Clang/MSVC 在多数情况下都支持 EBO,但边界——如空基类带非静态数据成员、多重继承第一个基类等——各家实现略有差异)。 通过private继承和EBO技术,可以实现「用户自定义之函数对象」。如classes unary_function, binary_function。