目录

类与面向对象编程

第 11 章笔记


结构体与对象聚合

// 不可以声明结构体
Str1 myStr1; //error
// 但可以声明结构体的指针
Str1* myStr1;

数据成员的初始化

// 结构体定义
struct Str
{
     // 类中初始化
    int x = 3;
    decltype{3} y; // C++11:使用 decltype 定义变量
    // 不能使用 auto
    auto z; // error
    // 可以使用 const,引用,限定
    const int g = 3;
   
};

// 隐式定义了结构体的内部成员
// 定义发生在这里,而不是在结构体中
Str m_str; 

// 类中元素会进行默认初始化
// 默认初始化跟结构体中的变量排序和数量有关
Str m_str2 = {3}; // x = 3, y = 0, g = 3

//C++20,聚合初始化的指派
Str m_str3 = {.x=3, .y = 4, .g = 5};

mutable 限定符

struct Str1
{
   mutable int x = 0;
};
// 修改常量对象中的变量
const Str1 myStr1;
myStr1.x = 1;

静态数据成员

struct Str
{
    // 声明
    static int x;
    int y;
};

// 类外定义(c++98)
// Str::表明 x 属于 Str 域内
// 为了共享,专门引入一个文件用于定义
// 编译器处理顺序 header->发现静态成员->寻找其他翻译单元内的定义,引入定义编译结构体
int Str::x;

int main(int argc, char const *argv[])
{
    Str str1;
    Str str2;

    str2.x = 100;
    // 两个 x 都为100
    std::cout << str1.x << " "<< str2.x << std::endl;

    return 0;
}

c++98 中 提供了在类中可以定义静态成员的功能。这是因为某些静态成员需要作为类型的一部分进行初始化。比如使用静态成员作为数组的长度,此时该静态成员是常量。如果使用该静态成员定义数组,如果不允许在类中初始化静态成员,那么对应的数组将无法定义。因此 C++ 98 中规定可以在类中这么用: const static int array_size = 100; 实际上,编译器会将 array_size 替换为 100

其限制在于,不能使用其地址对其访问(undefined reference)。因为值替换是编译期行为,不会构造存储空间给 array_size。但是通过地址访问时运行期行为。此时,只有使用类外静态成员定义,才能构造该常量静态成员的地址,才可以通过地址访问。

内联静态成员

C++ 17 中提供了内联静态成员。由于静态成员和内联函数的相似性(多个翻译单元 / 多个类对象共用一份),因此可以如下方式在类中声明静态成员:

// 只保留一份静态成员的定义
// 不需要是常量
// 还可以使用 auto 推断成员
struct Str
{
    inline int array_size = 100;
    inline auto array_size2 = 100;
   
};
// 还可以直接进行修改
Str str;
str.array_size = 50;
Str *strPt = &str;
// 直接访问需要指定域
// 静态成员能被所有对象访问
Str::array_size;
// 可以通过指针访问
strPt->array_size;

一般会专门提供一个文件用于存放静态成员的定义。

类内部声明相同类型的静态数据成员

// 不使用 inline
// 类外定义
Str Str::member; 
// 使用 inline 
// 类中使用 inline 是不完全类型,需要在外部定义
inline Str Str::member;

成员函数

声明和定义

class Str
{
    // 类内定义
    // 隐式内联,避免多次包含 header 导致的重定义
    void fun1() {};

    // 类外定义的声明
    void fun2();
};

// 类外定义
// 非内联
// 将声明存储于 Header, 将定义存储于另外一个翻译单元
void Str::fun2() {};
// 类外内联定义
inline void Str::fun2() {};

类会经过编译器的两遍处理

类中处理函数和数据成员的逻辑与外部不同。简单来说,不是通过从上到下的顺序来执行的。对于类来说,成员函数较为重要(接口),往往实现会先写函数(约定俗成)。这种情况下,如果按照外部的编译顺序,当函数调用内部数据成员时,成员是不可见的。为了处理这个问题,C++ 会对类进行两遍处理:

  1. 函数可见时,并不会立即处理函数,而是接着处理其余的部分
  2. 函数内部的逻辑会在第二次扫描中处理。

两次处理区分的是函数外部的内容与函数内部的逻辑内容。

尾部类型返回与成员函数

通常情况下,C++ 在做函数的类外定义时,必须指定所有参与定义部分的来源(域)

struct Str
{
    int x;
    using MyRes = int;
    MyRes fun();
};

// 注意返回类型 MyRes 是在类中定义的
// 因此必须指定域
Str::MyRes Str::fun()
{
    return x;
}
但如果使用尾部返回的写法,C++可以自动推断出该返回类型的来源:
// 通常返回复杂的类型可以使用这种写法
auto Str::fun() -> MyRes
{
    return x;
}

成员函数与this指针

struct Str
{
    int x = 3;
    // 实际上参数是 fun(Str *this)
    void fun()
    {
        std::cout << x << std::endl;
    }
    void fun2(int x)
    {
        // 如果不使用 this, 则返回的是 fun2 的参数 x(局部变量)
        // std::cout << x << std::endl;
        // 使用 this 返回类对象中的 x
        std::cout << this->x << std::endl;
    }
};

int main(int argc, char const *argv[])
{
    Str myStr;
    Str* myStrP = &myStr;
    // 调用的是 Str::fun(&myStr)
    myStr.fun();
    // 使用箭头操作符
    // 等同 (*myStr).fun2()
    // 打印 类中的 x,值为 3
    myStrP->fun2(5);

    return 0;
}

this 指针的类型是指向类对象的指针。由于指向不能变换,this 本身不能被修改;但因为我们要通过 this 修改对象中的内容,因此 thistop-const 类型的指针(const Str*

常量成员函数

由于 this 只能保证自身无法被修改,当需要阻止成员函数修改类中数据成员时,我们需要将成员函数声明为常量成员函数:

// 不允许该接口改变类成员
// 实际上,是将 this 的类型从 const Str* 转换为了 const Str* const
void fun() const {...}

基于 const 的重载

基于上述的特性,C++ 允许同名的函数基于 constness 进行重载:

// 这是两个不同的函数
// 参数类型不同
void fun() {...} // plain this, const Str*
void fun() const {...} // low-const this, const Str* const

成员函数的查找与隐藏
静态成员函数

从原理上来说,成员函数使用 this 指针来访问内部成员。而静态成员函数因为被共享,其参数不再是 this(编译器也不可能知道当前的类对象是哪个)。因此,对象无法通过静态成员函数来访问当前对象中的普通成员。

使用静态成员函数的两种方式:

// 使用静态函数返回静态成员
struct Str {
    static int size()
    {
        return x;
    }
    inline static int x = 11;
};
// 对象调用
myStr1.fun();
// 域调用
Str::fun();

注意局部静态成员与类静态成员的区别

static int size()
{
    // 局部静态成员,生存周期从函数被调用到程序结束
    static int x; 
    // 会返回上面的 x,而不是类中静态成员 x
    return x;
}

  • 这个特性可以利用起来。当处理需要按需使用的共享资源时,可以将静态存储于静态成员函数中;这样只有调用静态成员函数时,才会申请该资源。
  • 另外一种应用更有名:singleton
基于引用限定符的重载

// 该重载基于调用者的左右值属性来重载
// C++11 的写法:不可与 98 混用。只要后面有一个加了 &,那么所有的重载都要加 (&)
// 调用者为左值时调用
void foo() &;
// 调用者为右值时调用
void foo() &&;
// 调用者为常量左值时
void foo() const &;
// 通常不使用
void foo() const &&;

关键字:ref-qualified-member-functions。

访问限定符和友元

友元函数 / 类

  • 友元破坏封装,慎用
  • 友元的权限由被访问的类提供
  • 友元的访问是单向
  • 友元不受访问限定符的限定
函数 / 类的声明可以以友元的方式在类中声明

函数的定义受声明顺序的影响,因此在某些函数的定义牵涉到在其之后(不可见的)类型时,需要对这些类型进行前置声明。如果函数会被定义为友元函数,C++ 允许以友元的方式在类中完成首次声明:

class Str
{
    // fun() 被声明,并视作为 Str 的友元函数
    friend void fun();
    // Str2 类被声明,并被视作 Str 的友元类
    friend class Str2;
    
    // 注意:使用域限定符会破坏友元的首次声明
    // 此时必须要提前对其作出声明
    // friend void ::fun();
    
    int x = 100;
};

class Str2 {
public:
    void print()
    {
        Str str;
        std::cout << str.x << std::endl;
    }
};

void fun()
{
    Str str;
    std::cout << str.x << std::endl;
}

友元函数的类内定义和类外定义

在类中定义的友元函数会被隐藏:

class Str
{
    // 隐藏友元
    // fun() 是友元,不是成员
    // 因此 fun() 的作用域处于 Str 外部
    // 此时编译器会隐藏友元的首次定义,也就是认为 fun() 并没有声明
    friend void fun()
    {
        Str val;
        std::cout << val.x << std::endl;
    }
    int x = 100;
};

int main(int argc, char const *argv[])
{
    // undefined
    fun();
    
    return 0;
}

  • 减轻编译器负担:友元函数的声明会使搜寻范围扩大。
  • 使用友元函数的类外定义即可解决问题
  • 或使用参数,使用 const 实参类型的依赖查找来实现

class Str
{
    // 使用实参依赖关系进行类中的友元函数定义
    friend void fun2(Str& val)
    {
        std::cout << val.x << std::endl;
    }
    int x = 100;
};

int main(int argc, char const *argv[])
{
    Str val;
    fun2(val);
    return 0;
}

特殊成员:构造/析构/拷贝

构造函数

委托构造函数

class Str
{
    public:

    // 委托构造函数
    // 委托单参数版本给 x 赋值 3

    // 2. 再执行委托构造函数
    Str():Str(3) { // 最后执行委托构造函数的函数体}

    // 1. 先执行被调用的函数
    Str(int x)
    {
        this->x = x;
    }

    void fun() const 
    {
        std::cout << x << std::endl;
    }
    private:
    int x;
};

int main(int argc, char const *argv[])
{
    Str str;
    str.fun();
    return 0;
}

构造函数的初始化列表

class Str
{
    public:
    Str(const std::string& strVal)
    {
        // val 以默认初始化构造
        // 复制初始化,低效
        val = strVal;
    }

    // 初始化列表版本
    // 可读性:初始化列表需要与声明顺序一致
    // 初始化列表会覆盖类内成员初始化
    Str(const std::string& strVal, int& refSource): val(strVal), y(0),ref(refSource) {}

    std::string val;
    // y 被初始化为 0,而不是 2
    int y = 2;
    // 必须通过初始化列表初始化
    int& ref;
};

C++ 中构造与销毁是一个栈的结构,先创建后销毁。如果变量的初始化根据初始化列表中的顺序进行,那么变量的构造销毁顺序就是基于构造函数来进行。这样做会导致 C++ 必须记录每一个构造函数的初始化列表顺序来进行构造和销毁,这对性能会有非常大的影响。因此,初始化顺序只与类中成员声明顺序有关。

默认构造函数
单一参数构造函数

struct Str
{
    Str(int x)
        : val(x)
        {}

    int val;
};

void fun(Str str)
{}

int main(int argc, char const *argv[])
{
    // 内置->抽象:将 int 类型转化为了 Str 类型
    Str str = 3;
    // 该转换支持隐式转换
    // 函数调用时,int 转换为了 Str 类型
    fun(3);
    return 0;
}

explicit Str(int x): val(x) {}
// 使用括号,大括号显式转化即可
fun(Str{3});
fun(Str(3));
// 或者使用显式的 cast
Str a = static_cast<Str>(3);
fun(a);

拷贝构造函数

struct Str
{
    Str() = default;
    //拷贝构造函数的定义
    Str(const Str& x):val(x.val) { std::cout << "copy cstr called!"; }

    int val = 3;
};

int main(int argc, char const *argv[])
{
    Str m1;
    // 第一种拷贝方式
    Str m2 = m1;
    // 第二种拷贝方式
    Str m3(m1);
    return 0;
}

  • 为什么使用 const classType& ?

拷贝构造函数需要类对象的拷贝作为参数进行初始化;如果不使用引用,那么参数的传递需要通过拷贝构造函数来进行。这样就进入了一个死循环。

默认拷贝构造函数

// 默认拷贝构造函数的定义
Str(const Str&) = default;

移动构造函数

class Str
{
public:
    Str() = default;
    Str(const Str&) = default;

    // 拷贝内置类型,移动抽象类型
    // 注意不能加 const,移动本身就会修改类对象的内容,而拷贝不会
    Str(Str&& newStr):val(newStr.val), a(std::move(newStr.a)) { std::cout << "move cstr called." ; }

    // 打印函数
    void fun()
    {
    std::cout << val << " " << a << std::endl;
    }

private:
    int val = 3;
    std::string a = "abc";
};

int main(int argc, char const *argv[])
{

    Str myStr;
    myStr.fun();
    // 调用拷贝构造函数
    Str myCopiedStr = myStr;
    // 调用移动构造函数
    Str myMovedStr = std::move(myStr);
    // 3 是拷贝的,myStr 中存在 3
    // "abc" 是移动的,因此 myStr 中不存在 "abc"
    myStr.fun();
    return 0;
}

编译器能否成功合成特殊函数的逻辑类似:

  • 合成的核心点是对每一个数据成员都采取相同的构造方法(比如拷贝都拷贝,移动都移动)
  • 如果是内置类型,则直接拷贝,如果是抽象类型,则调用抽象类型中对应的构造函数(拷贝构造调用拷贝构造等等)
  • 如果没有对应的构造函数时:
    • 拷贝构造会失败
    • 移动构造会查看是否有拷贝构造的方式,如果没有,也失败 (C++ 14)

struct Str2
{
    // 没有该构造函数时,Str 也无法使用合成构造函数
    // 构造抽象类型成员时,所有的合成构造函数都会调用其本身的构造函数
    Str2() = default;
    Str2(const Str2&) {std::cout << "copy cstr called."; }
    // 开启或关闭看看区别
    // Str2(Str2&&) {std::cout << "mv cstr called."; }
};

class Str
{
public:
    Str() = default;
    Str(const Str&) = default;
    Str(Str&&) = default;

    // 打印函数
    void fun()
{
    std::cout << val << " " << a << std::endl;
}

private:
    int val = 3;
    std::string a = "abc";
    // 当成员包含抽象数据成员时,会调用对应类型的拷贝 / 移动构造函数
    Str2 str2;
};

int main(int argc, char const *argv[])
{

    Str myStr;
    // 有 Str2 的移动构造函数则会调用移动构造函数
    // 否则,有拷贝构造函数调用拷贝构造函数
    // 否则报错
    Str myMovedStr = std::move(myStr);
    return 0;
}

移动构造函数与异常

Str(Str&&) noexcept = default;

不引入异常的好处:

  • 异常会带来额外的逻辑,会带来性能上的损失
  • 移动机制决定了其不能抛出异常。假设移动的过程是从旧的内存区域到新的区域(比如 vector 的扩容),如果移动过程中出现异常,则:
    • 旧区域的内容已经被移动
    • 内容没有移动到新的区域

结果就会导致数据完全丢失。实际上,vector 的实现中,如果移动构造函数没有 noexcept 时,vector 会在扩容期间调用拷贝构造函数来确保数据安全。

noexcept 也有链式效应。如果上游类型定义了 noexcept 的移动构造函数,那么下游构造函数也必须是不会抛出异常的。否则当下游函数抛出异常,程序将无法处理这种现象。

右值引用对象作为表达式时是左值

// 函数体中的 x 是左值
// 参数的类型是右值引用,意味着可以从里面做移动操作
// 但在具体的执行中,我们决定不移动操作,而是进行访问
// 此时是将 x 作为左值来使用
Str(Str&& x) { std::string temp = x.a; }

Copy & Move Assignment

自我赋值的处理

以移动元素为例,如果参数是指针,通常赋值经过三步:

但问题在于,如果赋值的两遍都是同一个对象,上面这套逻辑会在第一步就清除掉所有信息。在第二步进行的时候,无论是 ptr 还是 rhs.ptr,都成为了悬挂指针。 解决办法是提前做一次判断:如果赋值运算符两边为同一个对象,则直接返回当前的类对象:

if(&rhs == this) { return *this; }

析构函数

特殊成员函数的补充

指针类

class PtrStr
{
public:
    // 构造函数
    PtrStr(): ptr(new int()) {}

    // 拷贝构造函数
    PtrStr(const PtrStr& rhs):ptr(new int())
    {
        std::cout << "call copy cstr\n";
        *ptr = *(rhs.ptr);
    }

    // 拷贝赋值运算符
    PtrStr& operator=(const PtrStr& rhs)
    {
        std::cout << "call copy assignment\n";
        if(this == &rhs)
        {
            return *this;
        }
        // 如果不需要 reallocate
        *ptr = *(rhs.ptr);

        // 如果需要 reallocate
        // delete ptr;
        // ptr = new int(*rhs.ptr);
        return *this;
    }

    // 移动构造函数
    PtrStr(PtrStr&& rhs) noexcept 
        :ptr(rhs.ptr)
    {
        std::cout << "call move cstr\n";
        rhs.ptr = nullptr;
    }
    
    // 移动赋值运算符
    PtrStr& operator=(PtrStr&& rhs)
    {
        std::cout << "call move assignment\n";
        if(this == &rhs)
        {
            return *this;
        }
        delete ptr;
        ptr = rhs.ptr;
        rhs.ptr = nullptr;
        return *this;
    }

    // 析构函数
    // 析构函数只能释放当前对象拥有的资源
    ~PtrStr()
    {
        std::cout << "call dstr\n";
        delete ptr;
    }
private:
    int* ptr;
};

int main(int argc, char const *argv[])
{
    PtrStr myStr;
    PtrStr myCopyStr = myStr;
    PtrStr myMoveStr = std::move(myStr);
    PtrStr myCopyAss, myMoveAss;
    myCopyAss = myCopyStr;
    myMoveAss = std::move(myMoveStr);
    return 0;
}

default 关键字
delete 关键字

void fun(int) = delete;

特殊成员的合成行为列表


Ref: Engineering Distinguished Speaker Series: Howard Hinnant

字面值类 / 成员指针 / bind

字面值类

字面值类(Literal class)指可以用于构造编译期常量对象类型的类。要求:

«WRAP center round tip 100%>

</WRAP>

C++ 14 之后允许在 constexpr 函数内部实现复杂的逻辑,因此取消了在 C++11 中默认 constexpr 函数的 this 指针只读的限制。

class Str
{
    public:
    // 常量版本
    constexpr Str(int val):x(val) {};
    // 运行期执行版本
    Str(double val):x(val) {};

    // 平凡的析构函数
    ~Str() = default;

    // 接口成员函数
    // 需要是 constexpr 或 consteval
    // C++14 之后默认 constexpr 函数不带 const
    constexpr int fun() const
    {
        return x + 1;
    }
    private:
    // 字面值类型
    int x = 3;
};

// error
// 字面值类要求构造函数的类型必须是 constexpr 或 consteval
constexpr Str Stra(1);

注意 constexpr 与 consteval 的混用问题

由于 constexpr 函数在编译期和运行期均可以被调用;因此 constexpr 内部允许存在运行期的构造函数版本。但如果使用该版本进行构造,那么得到的对象不是编译期常量对象,因此不能调用 consteval 版本的成员函数:

// error, b 不是常量表达式,无法调用 consteval 成员
class Str
{
    //....
    consteval int fun() const
    {
        return x + 1;
    }
}
int main {
    // error, b 不是常量表达式,无法调用 consteval 成员
    Str b(3.0);
    b.fun();
}

类成员指针

C++ 允许定义对指定类域成员进行访问的指针。

类成员指针不支持指针的相减操作。

类成员指针声明

声明只要求有的声明,不需要类的定义:

class Str
{
    public:
    int x;
    void fun() {};
};
int main(int argc, char const *argv[])
{
// 可以用 auto 简化声明的写法

// 数据成员指针的声明
// 类名 + 指针名
int Str::*mem_ptr = &Str::x;

// 成员函数指针的声明
// 返回类型 + 域::函数指针名 + 参数列表
void (Str::*memFunc_ptr)() = &Str::fun;
}

通过成员指针访问

当通过成员指针访问指定成员时,访问的是具体对象中的成员。因此,成员必须实例化。访问方式有两种:

int main(int argc, char const *argv[])
{
    // 访问前必须初始化类对象
    Str obj;
    // 定义类指针
    Str *ptr_obj = &obj;
    // 通过成员指针访问 x
    obj.*mem_ptr;
    // 通过成员指针访问函数 fun
    // 注意括号不能省
    (obj.*memFunc_ptr)();
    // 通过类对象指针访问 x
    ptr_obj->*mem_ptr;
    // 通过类对象指针访问成员函数 fun
    (ptr_obj->*memFunc_ptr)();
}

std::bind

std::bind 允许将成员函数标定到一个特定的对象上,从而使得可以通过调用该对象来对成员函数进行调用。该函数定义于 <functional> 头文件中,写法如下:

std::bind(&className::Function, classInstance, placeholder...);
应用实例:
class MyClass {
public:
    void display(int x) {
        cout << "Display called with value: " << x << endl;
    }

    int add(int a, int b) {
        return a + b;
    }
};

int main() {
    MyClass obj;

    // 使用 std::bind 绑定 display 成员函数
    auto boundDisplay = std::bind(&MyClass::display, &obj, std::placeholders::_1);
    boundDisplay(10);  // 调用 boundDisplay,相当于 obj.display(10)

    // 使用 std::bind 绑定 add 成员函数
    auto boundAdd = std::bind(&MyClass::add, &obj, std::placeholders::_1, std::placeholders::_2);
    int result = boundAdd(5, 7);  // 调用 boundAdd,相当于 obj.add(5, 7)
    cout << "Result of add: " << result << endl;

    return 0;
}