======类的细节====== //第 12 章笔记// ---- ====运算符重载==== 运算符重载是指在 C++ 中基于 ''operator'' 关键字引入对运算符的重载。经过重载的运算符可以被视作**函数**,典型的结构(以减法为例): return Type + operator - + (parameter list) ===运算符重载的特点=== * 不能重新发明运算,只能基于 C++ 的运算符进行重载 * 不能改变对应运算符的优先级和结合性 * 可以,但是不应该去改变运算符重载的意义(加法就是加法,不能用加法代表乘法) * 重载的运算符与 bulit-in 运算符的 operand 应该在数量上一致 * 重载运算符中,其参数需要至少有一个是类类型(不能全是 built-in 类型) * 除了 ''operator()'',其他重载的运算符都不能设置默认参数 ==成员 / 非成员函数== 运算符重载可以以成员函数或非成员函数的方式来实现: * 以成员函数进行重载时,通常以 ''*this'' 作为第一个 operand * 注意 C++ 17 以后,''=='' 与 ''<=>'' 并不遵循此例 // 非成员函数重载 Str operator+ (Str x, Str y) { Str z; z.x = x.x + y.x; return z; } // 成员函数重载 Str operator+(Str rhs) { Str z; //x imply this->x z.x = x + rhs.x; return z; } ===运算符重载的分类=== * 可重载,且必须重载为成员函数的运算符:''='',''()'', ''->'',转型运算符 * 可重载,且可以实现非成员函数的运算符:绝大部分 * 不建议重载的运算符:''&&'',''||'','',''。这类运算符往往需要维护特定的求值顺序,而 * C++17 之前,重载运算符对执行顺序没有规定 * C++17 之后,顺序规定,但重载上述运算符会破坏短路逻辑 * 不可重载的运算符 ===对称性运算符=== 指可以左右算子互换位置的运算符(比如 ''+'',''=='' 等)。对称性运算符需要被设计为非成员重载,是因为: * 对称性运算符通常牵涉到两个算子之间的运算。这个过程中会为了匹配左右算子类型进行隐式转换。如果声明为成员函数,算子的位置被固定,这将导致某些隐式转换无法正常进行,从而导致计算失败。比如: class Str { public: Str(int x) :val(x) {} Str operator+ (const Str& rhs) {...} private: int val; }; int main(int argc, char const *argv[]) { Str myStr1(2); // 可以进行正常运算,4 对应 rhs, 可以通过 Str 的构造函数转化为 Str 类型 Str myRetStr =myStr1 + 4; // 无法正常进行运算,4 不是 Str (this指向的) 类型 Str myRetStr = 4 + myStr1; return 0; } * 设计为非成员函数以后,由于不再受 ''this'' 的匹配影响,上述例子中的 ''4'' 则可以在函数匹配的过程中进行隐式转换。 ==对称性运算符的额外要求== * 需要声明为 ''friend'',否则无法访问私有数据 ===移位运算符=== 移位运算符**必须重载为非成员函数**。这是因为 C++ 中移位运算符有一个更重要的应用:输入输出流运算符。输入输出流运算符一般的格式为: StreamObjReference << Object; 这种情况下,成员函数版本无法支持该功能。 ==移位运算符的实现== * 返回对象为**流的引用**:为了支持连续的流运算 * 参数为流的引用,以及需要进出流的对象 * 具体的 const 根据输入输出决定 // 简单的输出流重载实现 // 需要声明为友元函数 std::ostream& operator << (std::ostream& ostr, const Str &output) { ostr << output.val; return ostr; } ===赋值运算符=== * 必须以成员函数的形式重载 * 返回为引用:因为赋值等号的左边是目标,右边是值 * 典型例子:copy assignment / move assignment;除此之外参数也可以是其他形式 // 简单的实现 Str& Str::operator= (const Str& rhs) { if(this != &rhs) { val = rhs.val; } return *this; } ===下标运算符=== 该运算符重载用于模拟下标操作。典型的应用是 ''std::vector''。需要考虑的问题有: * 下标要支持读取 * 下标运算的结果要支持写入 主要的问题在第二个子问题上: * 首先,要支持写入,那么需要对 ''ObjA[0] = ObjB'' 这种形式的运算进行处理。因此,下标运算的返回结果一定是左值,也就是对象的引用。 * 其次,当下标运算的对象为 ''const'' 时,这种写入的操作应该被侦测并禁止。因此,我们需要一个专门的 const 实现版本来处理该情况。 // 简单的实现,基于 String 的下标运算重载 class StrVec { public: std::string& operator[](std::size_t n) { return elements[n]; } // const 版本 const std::string& operator[](std::size_t n) const { return elements[n]; } private: std::string* elements; // pointer to the first element in the array }; ===自增自减运算符=== * 单目运算符,必须重载为成员函数 * 分 Prefix 和 postfix 两种版本 ==前缀版本与后缀版本== 以加法为例, 前缀版本: * 写法:不带参数 ''operator++()'' * 返回:引用,为了支持连续的使用 * 过程: * 自增 * 返回自增后的结果 后缀版本: * 写法:带一个没有任何用处的 ''int'' 类型参数:''operator++(int)'' * 返回:自增对象**被修改之前**的副本 * 过程: * 保存副本 * 对原有的数据进行自增 * 返回保存的副本 Str& Str::operator++() { ++val; return *this; } Str Str::operator++(int) { Str ret = *this; ++*this; return ret; } ===解引用 / 箭头运算符=== 两者都是模拟指针的行为。 ==解引用运算符== * 返回:解引用后内容类型的**引用**(与下标运算符类似,目的是为了写操作) * 基于第一条的原因,因此也需要引入 ''const'' 版本 int& Str::operator*() { return *ptr; } ==箭头运算符== 箭头运算符的重载是一个通过递归的方式访问最终数据成员的过程: * Base case:返回类型是类类型的**指针**(//bulit-in// 指针) * Normal case:返回类型是普通类类型 当使用箭头运算符的重载时,只要返回值不是指针类型,则编译器会尝试在当前对象中继续寻找 ''operator ->()'' 的定义,并执行该定义继续进行调用;直到某个调用返回指针时,该过程才会停止。这么做是因为箭头操作符右边的 operand(对象中数据成员的名称) 并不是严格意义上的对象;只有在其与对应的对象组合使用时,才能代表正式的对象(比如 ''obj.x'')。因此整个该过程实际上是通过一个层层 “开盒” 的行为来找到最终数据成员的过程。 实现上,箭头运算符的重载有返回类型,但没有参数 // 返回指针(Base case) Str* Str::operator->() { return this; } // 返回对象(Normal case) // 编译器会到 Str2 中继续寻找 -> 的定义 Str2 Str::operator->() { return Str2; } ===函数调用运算符=== 函数调用运算符允许将对象变为**可调用对象**,即 ''obj()'' 这类型的函数。其最大的特点是接收参数数量不定: struct Str { Str(int* p):ptr(p){} int operator() (int x, int y, int z) { return *ptr + x + y + z; } int* ptr; }; int main(int argc, char const *argv[]) { int x = 100; Str StrPtr(&x); // 打印 100 + 1 + 2 + 3 的结果 std::cout << StrPtr(1,2,3); return 0; } ===类型转换运算符=== * 用于自定义类类型与其他类型之间的转换 * 无显式的返回类型 * 需要被定义为 ''const'' * 类型转换不涉及内容的修改,因此应该被定义为 ''const'' * 涉及常量的类型转换需要重载为 ''const'' * 写法: operator type() const {} ==避免歧义== 需要注意的是,除了自定义的类型转换运算符,程序中很可能还存在其他的重载,这些重载也可能具有(隐式)类型转换的功能,比如: // 同时定义了 class 到 int 的转换,以及 class 与 int 的加法 // int() 可以转换 operator int() const { return val; } friend Str operator + (const Str& lhs, const Str& rhs); // 存在两个候选者 // 4 可以通过 int() 转换为 Str, 也可以通过 + 转换为 Str,因此导致歧义 Str obj(100); std::cout << (obj + 4); 对此,我们可以使用 ''explicit'' 关键字禁止其中一个重载进行隐式转换: explicit operator int() const { return val; } ==避免隐式转换带来的其他问题== 隐式转换还可能带来一些未知的结果。比如下面的例子: // 使用 cin 输出,逻辑上是不合理的 // 此处,cin 的隐式转换为 istream -> bool -> int // 根据 cin 是否有效,最终的表达式为 0 << 3 或是 1 << 3 // 也就是非法的输出操作通过隐式转换转变为了合法的移位操作 std::cin << 3; 这种情况依然可以使用 ''explict'' 处理 ''explict'' 存在例外。当 ''explict'' 修饰的表达式作为条件语句的判断条件时,编译器会将其自动转换为 ''bool'' 类型。该行为不受 ''explicit'' 关键字约束。 ===关系运算符的重载=== C++ 20 中: * 只需定义 ''=='' 的重载,编译器会自动推导 ''!='' 的重载(3-way comparsion 同理) * ''=='' 会尝试对 operand 进行自动换位,来匹配用户换位的结果,比如 bool operator ==(Str obj, int val) { return obj.x == val; } // 可以调用 obj == 3; // C++ 20 之前错误,无法找到匹配的函数 // C++ 20 编译通过 // 编译器会自动尝试 bool operator ==(int val, Str obj) 3 == obj; ====类的继承==== ===基础概念=== * 基类(//Base//): 被继承的类 * 派生类(//Derive//): 继承的类 * 继承(//Inheritance//)://is-a// 关系,指派生类是基类的一种。 ==继承的三种方式== * public:父类的内容对所有人可见 * private:父类的内容只对自己可见 * protect:父类的内容对自己以及其子类可见 * ''class'' 默认的继承方式是 ''private'',''struct'' 默认的继承方式是 ''public''。 * 继承方式的部分不算做声明的一部分('':public Base'')这一部分 ==继承的使用== 可以通过基类的指针和引用指向派生类的对象: Derive d; Base& ref = d; Base* ptr = &d; ==被继承类的类型== * 静态类型:父类指针/引用在编译时的类型,通常为父类的类型 * 动态类型:父类指针/引用在运行期时的类型,通常为其指向的派生类的类型 ==派生类的 scope== * 派生类的 scope 属于基类的一部分 * 派生类的 scope 处于基类 scope 的内部,遵循内部 name 隐藏外部 name 的原则 * name 查找原则也是自内向外 ==派生类中调用基类的构造函数== * 默认情况下派生类会委托基类中的**默认构造函数**对继承自基类的部分**先行**进行构造 * 如果基类中缺乏默认构造函数,那么需要在派生类中显式的调用基类的构造函数 * 对基类的构造必须在初始化阶段:派生类委托基类构造应该在初始化列表中,而不是在函数体中(函数体中的处理处于赋值阶段) ===虚函数=== 当基类(或派生类)中某个函数被定义为虚函数时,意味着该函数可以通过基类的**指针或者引用**对其进行调用。其作用是为了实现**运行期的动态多态**:也就是说,根据调用者的动态类型不同,对应的虚函数可能存在不同的实现方式。这些不同的实现方式通过**重写**(//override//)实现。一些注意事项: * 使用 ''vritual'' 关键字定义 * 非静态、非构造函数可以声明为虚函数 ==虚函数与虚表== 虚函数之所以能在运行期与特定类型的对象进行绑定,原因是其维护了一个被称为虚表(//vitrual table//)的数组。该数组会在派生类对象实例化时生成,包含两部分重要的信息: * 派生类的类型信息 * 派生类中所有的虚函数的信息 虚函数的动态绑定会根据这个表中的信息完成。在派生类中,只要定义了虚函数的重写,那么虚表中对应的同名函数的绑定,更新就会到当前的派生类上。 虚函数通过虚表达实现 C++ 中动态多态的功能:在不改变函数签名的情况下,根据实例化的对象来选择对应的函数。 ===纯虚函数=== 纯虚函数是作为类的接口而存在的。与虚函数不同,纯虚函数没有实现。几个特点: * 其所在的类被称为抽象类,无法进行实例化。 * 父类中有纯虚函数时,所有继承者重写出实现版本 纯虚函数通过以下方式定义: virtual returnType Func(parameter_list) = 0; ===继承与特殊成员函数=== * 派生类中合成的特殊成员(构造,拷贝构造,赋值)会**隐式**调用基类中对应成员 * 派生类中的析构函数会调用基类的析构函数 * 派生类中的构造函数会调用基类中的**默认构造函数** * 显式定义的特殊成员可能会需要显式的调用基类对应成员 ==构造与析构的顺序== * 构造:先构造基类成员 * 析构:先析构派生类成员 ====补充知识==== ===继承方式的影响=== 影响的实际是被继承成员的访问权限: * ''public'' 继承:所有继承自父类的成员访问权限都不会发生改变 * 实际上代表了 ''is-a'' 关系,因为继承了所有的行为 * ''protect'' 继承:继承自父类中的,所有成员权限高于 ''protected'' 的,都会变为 ''protected'' * 实际上代表了派生类的实现是基于基类的这样一种关系 * ''private'' 继承:继承自父类中的,所有成员权限高于 ''private'' 的,都会变为 ''private'' ===using 与继承=== * 使用 ''using'' 可以在条件允许下,**在派生类中**改变继承成员的权限: struct Base { public: int pub; private: int pri; protected: int pro; }; struct Derived: public Base { // inaccessiable 的成员(父类 Private 成员)无法修改权限 public: // 修改继承的 Protected 成员权限为 public using Base::pro; private: // 修改继承的 public 成员权限为 为 private using Base::pub; }; int main() { Derived d; // 修改后可访问 d.pro; // 修改后无法访问 d.pub; } 对派生类不可见的成员(基类中的 ''private'' 类型成员),''using'' 无法改变其权限 ==using 与成员函数== * ''using'' 关键字也可以作用于基类的成员函数(构造函数除外),使用方法: // 在派生类中使用 using Base::func; * 引入的是**函数名**:所有同名的函数都会被引入,在派生类中通过函数匹配规则来调用:比如上面的例子中: // 所有名字为 func 的函数都会被引入派生类 void func(int); void func(int,int); // 派生类中根据函数匹配调用 // ... Derived d; d.func(1); // 调用 func(int) d.func(2,2); // 调用 func(int, int) * 如果派生类中存在同名函数的定义,那么会**隐藏**所有引入的,基类的同名函数: sturct Derived : public Base { void func(int, int, int) {//...} }; // 错误,无法找到匹配函数 d.func(1); 这点同样适应于构造函数。通常情况下,如果派生类中没有引入新的数据成员,那么可以使用 ''using'' 直接 “借” 基类的构造函数逻辑使用。 但当派生类中定义了构造函数时,派生类的初始化则会调用派生类的构造函数。两者达到的效果相同,但的路径不同: * ''using Base::Cstr'':使用 ''Base::Cstr()'' 构造 * 派生类中定义了构造函数时:通过 ''Derived::Cstr()'' -> 调用 ''Base::Cstr()'' 完成派生类对象的构造 ==using 与重写== * ''using'' 不会改变虚函数的修饰(''using'' 的优先级低于重写) * 如果希望使用 ''using'' 实现**改变权限下的重写**: * 最好的办法是使用虚函数的特性,对**特定的重载**进行重写(带函数签名的,比如 ''func(int)'',而不是 ''func'') * 也可以通过在派生类中使用函数隐藏来进行重写 ===基类指针与容器=== C++ 可以通过多态(基类指针)可以(有限)实现容器存储以及访问不同类型的对象: struct Base { // 访问内容函数 virtual double getValue() = 0; // 使用基类指针访问派生类时,释放堆资源必须声明虚析构 virtual ~Base() = default; }; struct DerivedI: public Base { DerivedI(int x):val(x) {} // 注意这里的 double,限制在这里 double getValue() override { return val; }; int val; }; struct DerivedD: public Base { DerivedD(double x): val(x) {} double getValue() override { return val; }; double val; }; int main(int argc, char const *argv[]) { // 使用智能指针作为基类指针 std::vector> vec; // 使用 vec 通过 new 返回的指针,存储 Base 的不同派生对象 vec.emplace_back(new DerivedI(1)); vec.emplace_back(new DerivedD(3.14)); for (auto &obj : vec) { std::cout << obj->getValue() << " "; } std::cout << std::endl; return 0; }; 可以看出来这种实现是有局限性的:虚函数返回的是派生类的公共类型:''int'' 可以转换成 ''double''。如果这种公共类型不存在, 那么这种实现也是不可能的。 ===多重继承与虚继承=== * 虚继承:以 ''virtual'' 的方式继承 Class D1 : virtual public Base { .... }; * 解决的问题:菱形继承带来的数据成员重复的问题,保证最终继承者的数据成员不会因为多重继承而翻倍。 ===空基类优化=== ==空类的大小为 1== 空类的大小被定义为 ''1'',是因为寻址的需求。假设存在一个该类类型的数组,则其寻址是基于起始地址 + 类大小 * 元素数量来计算的: ClassType a[2]; a[1] -> a[0 + 1] -> address(a[0]) + 1 * sizeof(ClassType) 这种情况下,如果空类大小为 ''0'',则会导致 ''a[0]'' 和 ''a[1]'' 的地址相同。C++ 不允许存在两个地址相同但逻辑上不同的单元。 ==空类的问题以及传统解决方案== 有几个前提条件: * 成员函数不占用类的空间 * 根据计算机的不同,类中元素占用空间不足字的,会进行内存对齐:比如空类 ''1'' 和 ''int'' 成员的组合,大小为 ''5'' 字节,但占用 ''8'' 字节空间 根据上述信息,因为这个 ''1'' 的空间占用,我们为上述组合付出的代价是空间占用翻倍。传统的解决方案是将函数放置到基类中, 进行继承。在这种情况下,编译器会进行空基类优化,忽略空基类的大小: struct Base { // some funcs ... }; // empty class // obj = 4 bytes struct Derived1 : Base { int i; }; == C++20 的解决方案== 上述解决方案的问题在于,public 继承的意义是描述 //is-a// 关系,但明显该类关系不是。C++ 20 提供了一种 ''no_unique_address'' 的类型 用于描述空类。被该类类型定义的空类大小为 ''0''。因此,相较于继承,我们可以将函数类直接作为数据成员放置到新类中调用,而不用付出额外的空间成本: struct Empty {}; // empty class struct X { int i; [[no_unique_address]] Empty e; };