目录

类的细节

第 12 章笔记


运算符重载

运算符重载是指在 C++ 中基于 operator 关键字引入对运算符的重载。经过重载的运算符可以被视作函数,典型的结构(以减法为例):

return Type + operator - + (parameter list)

运算符重载的特点

成员 / 非成员函数

运算符重载可以以成员函数或非成员函数的方式来实现:

// 非成员函数重载
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;
}

运算符重载的分类

对称性运算符

指可以左右算子互换位置的运算符(比如 +== 等)。对称性运算符需要被设计为非成员重载,是因为:

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;
}

对称性运算符的额外要求

移位运算符

移位运算符必须重载为非成员函数。这是因为 C++ 中移位运算符有一个更重要的应用:输入输出流运算符。输入输出流运算符一般的格式为:

StreamObjReference << Object;
这种情况下,成员函数版本无法支持该功能。

移位运算符的实现

// 简单的输出流重载实现
// 需要声明为友元函数
std::ostream& operator << (std::ostream& ostr, const Str &output)
{
    ostr << output.val;
    return ostr;
}

赋值运算符

// 简单的实现
Str& Str::operator= (const Str& rhs)
{
    if(this != &rhs)
    {
        val = rhs.val;
    }
    return *this;
}

下标运算符

该运算符重载用于模拟下标操作。典型的应用是 std::vector。需要考虑的问题有:

主要的问题在第二个子问题上:

// 简单的实现,基于 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
};

自增自减运算符

前缀版本与后缀版本

以加法为例, 前缀版本:

后缀版本:

Str& Str::operator++()
{
    ++val;
    return *this;
}

Str Str::operator++(int)
{
    Str ret = *this;
    ++*this;
    return ret;
}

解引用 / 箭头运算符

两者都是模拟指针的行为。

解引用运算符

int& Str::operator*() 
{
    return *ptr;
}

箭头运算符

箭头运算符的重载是一个通过递归的方式访问最终数据成员的过程:

当使用箭头运算符的重载时,只要返回值不是指针类型,则编译器会尝试在当前对象中继续寻找 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;
}

类型转换运算符

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 中:

bool operator ==(Str obj, int val) { return obj.x == val; }
// 可以调用
obj == 3;
// C++ 20 之前错误,无法找到匹配的函数
// C++ 20 编译通过
// 编译器会自动尝试 bool operator ==(int val, Str obj)
3 == obj;

类的继承

基础概念

继承的三种方式
  • class 默认的继承方式是 privatestruct 默认的继承方式是 public
  • 继承方式的部分不算做声明的一部分(:public Base)这一部分
继承的使用

可以通过基类的指针和引用指向派生类的对象:

Derive d;
Base& ref = d;
Base* ptr = &d;

被继承类的类型
派生类的 scope
派生类中调用基类的构造函数

虚函数

当基类(或派生类)中某个函数被定义为虚函数时,意味着该函数可以通过基类的指针或者引用对其进行调用。其作用是为了实现运行期的动态多态:也就是说,根据调用者的动态类型不同,对应的虚函数可能存在不同的实现方式。这些不同的实现方式通过重写override)实现。一些注意事项:

虚函数与虚表

虚函数之所以能在运行期与特定类型的对象进行绑定,原因是其维护了一个被称为虚表(vitrual table)的数组。该数组会在派生类对象实例化时生成,包含两部分重要的信息:

虚函数的动态绑定会根据这个表中的信息完成。在派生类中,只要定义了虚函数的重写,那么虚表中对应的同名函数的绑定,更新就会到当前的派生类上。

虚函数通过虚表达实现 C++ 中动态多态的功能:在不改变函数签名的情况下,根据实例化的对象来选择对应的函数。

纯虚函数

纯虚函数是作为类的接口而存在的。与虚函数不同,纯虚函数没有实现。几个特点:

纯虚函数通过以下方式定义:

virtual returnType Func(parameter_list) = 0;

继承与特殊成员函数

构造与析构的顺序

补充知识

继承方式的影响

影响的实际是被继承成员的访问权限:

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 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 与重写

基类指针与容器

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<std::shared_ptr<Base>> 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。如果这种公共类型不存在, 那么这种实现也是不可能的。

多重继承与虚继承

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 的空间占用,我们为上述组合付出的代价是空间占用翻倍。传统的解决方案是将函数放置到基类中, 进行继承。在这种情况下,编译器会进行空基类优化,忽略空基类的大小:

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;
};