\[ Yoi\ 's \ Note \]

C++相关知识总结

内存区域

由编译器自动管理,函数调用时分配、函数返回时自动释放。容量有限(通常几 MB),但分配极快(只需移动栈指针)。存放的变量包括:局部变量、函数参数、函数返回地址、局部数组(定长)。

程序员通过 new/delete(或 malloc/free)手动管理,生命周期可跨函数。容量大(受系统内存限制),但分配较慢,且必须手动释放,否则内存泄漏。

数据段

分为两部分。.data 存放有初始值的全局变量和 static 变量;.bss 存放未显式初始化的同类变量(操作系统会自动零初始化)。两者均在程序启动时分配,程序退出时销毁。

代码段

存放函数的机器码指令,以及字符串字面量(只读)。通常是只读的,写入会触发段错误。

数据段
分配方式 自动 手动 自动
生命周期 函数调用期间 手动控制 程序运行期间
速度 --
大小 受限 静态,编译时确定
风险 栈溢出 内存泄漏 全局状态难管理

常见关键字

extern

extern是一个关键字,用于声明一个变量或函数在其他文件中定义。它告诉编译器该变量或函数的定义在其他地方,编译器会在链接阶段找到它们的定义并进行链接。

// file1.cpp
int globalVar = 42; // 定义一个全局变量
// file2.cpp
extern int globalVar; // 声明在其他文件中定义的全局变量

extern C是一个特殊的声明,用于告诉编译器按照C语言的方式来链接函数。这通常用于在C++代码中调用C语言编写的函数,或者在C语言代码中调用C++编写的函数。

extern "C" {
    int strcmp(const char* str1, const char* str2); // 声明一个C语言函数
}

值得注意的是,全局变量具有全局可见性,但静态变量的作用域仅限于定义它的文件,无法被其他文件访问。因此,extern关键字不能用于声明静态变量,因为它们无法在其他文件中访问。

static

静态变量是指在函数内部或类中使用static关键字声明的变量。它们具有以下特点:

  1. 生命周期:静态变量在程序运行期间一直存在,直到程序结束才会被销毁。
  2. 作用域:静态变量的作用域取决于它们的定义位置。函数内部的静态变量只能在该函数内访问,而类中的静态成员变量可以通过类名或对象来访问。
  3. 初始化:静态变量在程序启动时被初始化,如果没有显式初始化,则会被自动初始化为零(对于基本类型)或调用默认构造函数(对于类类型)。

对于类的静态成员变量,需要在类外进行定义和初始化。例如:

class MyClass {
public:
    static int count; // 声明静态成员变量
};
int MyClass::count = 0; // 定义和初始化静态成员变量

静态成员函数不可以被定义为虚函数,因为它们不属于任何对象,而虚函数需要通过对象来调用。

静态成员变量不可以作为成员函数的默认参数,因为默认参数是在编译时解析的,而静态成员变量是在运行时初始化的。

const

const包含下列作用: 1. 修饰成员变量,表示该成员变量的值不能被修改。相比于宏,const变量具有类型安全,且能节省内存空间。 2. 修饰函数参数,表示该参数在函数内部不能被修改。 3. 修饰成员函数,表示该函数不会修改对象的状态(即不会修改成员变量,用mutable关键字修饰的变量除外)。在const成员函数中,this指针被隐式地转换为一个指向常量对象的指针,因此不能通过this指针修改对象的成员变量。

指针

指针是指向内存地址的变量。

在64位系统中,指针的大小为8字节(64位)。

悬空指针是原本指向的内存空间被释放后,指针仍然指向那个地址的情况。

野指针是指未初始化的指针,或者指向一个不合法地址的指针。

与引用的区别:

是否可变:指针可以被重新赋值,指向不同的对象;而引用一旦绑定到一个对象,就不能再绑定到另一个对象。

是否可以为null:指针可以为null,表示不指向任何对象;而引用必须绑定到一个有效的对象,不能为null。

是否占内存:指针本身在内存中占有空间,而引用仅仅是一个别名,不占用额外的内存空间。

是否能为多级:指针可以有多级指针,但引用只能是一级引用。

常量指针是指向一个由const修饰的对象的指针,表示指针所指向的对象是不可修改的。指针常量则是指针本身是常量,表示指针的值(即指向的地址)不能被修改,但指针所指向的对象可以被修改。

模板

函数模板是C++中的一种泛型编程机制,允许我们编写与类型无关的函数。通过使用模板参数,我们可以创建一个函数模板,该模板可以接受不同类型的参数,并在编译时生成相应的函数实例。

#include <iostream>
using namespace std;

template<typename T>
T max(T a, T b) {
    return (a > b) ? a : b;
}

int main() {
    cout << max(3, 5) << endl;
    cout << max(3.5, 5.5) << endl;
    return 0;
}

尖括号内的typename可以替换为class,两者在模板参数列表中是等价的:

可以存在多个模板参数:

template<typename T1, typename T2>
void print(T1 a, T2 b) {
    cout << a << " " << b << endl;
}

本质上,模板就是编译器在编译时根据模板参数生成具体的函数或类实例。模板参数可以是类型参数(如上例中的T)或非类型参数(如整数、指针等)。这种自动生成代码的机制被称为隐式实例化。

显式实例化是指程序员手动指定模板参数来生成特定类型的函数或类实例。例如:

template class MyClass<int>; // 显式实例化MyClass<int>
显式实例化可以用于减少编译时间或控制生成的代码量,例如,在头文件中对显式实例化进行声明:
// MyClass.h
extern template class MyClass<int>; // 声明显式实例化
随后,在源文件中进行定义:
// MyClass.cpp
template class MyClass<int>; // 定义显式实例化

这样,即使有复数文件包含了MyClass<int>的使用,编译器也只会生成一个实例,从而减少编译时间和代码冗余。

特化(Specialization)是指为特定类型或特定条件提供不同的实现。C++支持两种特化:完全特化和部分特化。完全特化是指为特定类型提供一个完全不同的实现。例如:

template<>
class MyClass<int> {
public:
    void display() {
        cout << "This is a specialization for int." << endl;
    }
};
对于类模板,特化需要对构造函数和成员变量、成员函数等进行重新定义。 特化模板类可以包含原模板类不曾有的成员函数或成员变量,但不能删除原模板类中的成员函数或成员变量。

部分特化(又称偏特化)是指为满足特定条件的类型提供一个不同的实现。例如:

template<typename T>
class MyClass<T*> {
public:
    void display() {
        cout << "This is a partial specialization for pointer types." << endl;
    }
};
此外,在具有两个模板参数的类模板中,把其中一个参数固定为特定类型,而另一个参数保持为模板参数的特化也属于偏特化。例如:
template<typename T>
class MyClass<T, int> {
public:
    void display() {
        cout << "This is a partial specialization for T and int." << endl;
    }
};
对于有复数个模板参数的类模板,如果一个偏特化模板把第M个模板参数固定为类型A,另一个偏特化模板把第N个模板参数固定为类型B,而编写代码时定义了第M个模板参数类型为A,第N个模板参数类型为B的实例,此时会发生二义性错误,因为编译器无法确定应该使用哪个偏特化模板。这种情况下,需要定义一个更具体的偏特化模板来解决二义性问题。

偏特化仅适用于类模板,函数模板不支持偏特化。

当同时存在函数模板和普通函数时,具体使用哪个取决于是否需要进行自动类型转换。如果调用函数时提供的参数类型与普通函数完全匹配,编译器会优先选择普通函数。如果需要进行自动类型转换,编译器会选择函数模板。

对于类模板,模板之内的成员函数定义可以放在类定义内,也可以放在类定义外。如果成员函数定义在类定义内,则该函数会被隐式地视为内联模板成员函数;如果成员函数定义在类定义外,则需要使用template关键字来指明这是一个模板成员函数。例如:

// MyClass.h
template<typename T>
class MyClass {
public:
    void display(T value);
};

// MyClass.cpp
template<typename T>
void MyClass<T>::display(T value) {
    cout << "Value: " << value << endl;
}

非类型参数是指模板参数列表中除了类型参数之外的参数,例如整数、指针等。这些参数在编译时必须是常量表达式。例如:

template<typename T, int N>
class MyArray {
public:
    T arr[N];
};
在这个例子中,N是一个非类型参数,表示数组的大小。使用时需要提供一个常量表达式作为参数,例如:

MyArray<int, 10> myArray; // 创建一个包含10个整数的数组
非类型参数可以用于控制模板的行为,例如指定数组的大小、控制循环次数等。需要注意的是,非类型参数必须是编译时常量,这意味着它们必须在编译时能够确定其值。

模板类型参数和非类型参数都可以有默认值。例如:

template<typename T = int, int N = 10>
class MyArray {
public:
    T arr[N];
};
在这个例子中,如果用户在实例化MyArray时没有提供模板参数,编译器会使用默认值int10

模板参数可以是模板。例如,如果我们想把std::vector作为一个模板参数,可以这样定义:

template<template<typename> class Container, typename T>
class MyClass {
public:
    Container<T> data;
};
在这个例子中,Container是一个模板参数,它接受一个类型参数T。我们可以使用MyClass来创建一个包含std::vector<int>的实例:
MyClass<std::vector, int> myClass;

类型转换

为了使两个不相关的类可以进行隐式转换,我们需要定义转换构造函数或转换运算符。转换构造函数是指一个类的构造函数接受一个参数,并且没有使用explicit关键字修饰,这样就允许编译器在需要该类型的地方自动调用这个构造函数进行类型转换。例如:

class A {
public:
    A(int x) {
        // 构造函数实现
    }
};
class B {
public:
    operator A() {
        // 转换运算符实现
        return A(0); // 返回一个A类型的对象
    }
};
在这个例子中,类A有一个接受int参数的构造函数,而类B定义了一个转换运算符,可以将B类型的对象转换为A类型的对象。当我们需要将一个B类型的对象赋值给一个A类型的变量时,编译器会自动调用这个转换运算符进行类型转换。

拷贝构造函数是指一个类的构造函数接受一个同类型的对象作为参数,用于创建一个新的对象并将其初始化为传入对象的副本。拷贝构造函数是转换构造函数的一种特殊情况

为了避免显式转换带来的问题,C++提供了四种类型转换运算符:static_castdynamic_castconst_castreinterpret_cast。这些运算符提供了更安全和明确的类型转换方式,避免了隐式转换可能引起的错误。 - static_cast:用于进行基本类型之间的转换,例如从int转换为float,或者从一个类类型转换为另一个类类型(如果存在合适的转换构造函数或转换运算符)。static_cast编译时进行检查,如果转换不合法会产生编译错误。

  1. static_cast既可以用于类和派生类的向下转换,也可以用于向上转换。
  2. static_cast仅在编译时进行类型检查,不会进行运行时检查,因此在进行向下转换时需要确保转换是合法的,否则可能会导致未定义行为。
  3. 静态转换不要求基类必须具有虚函数,因此可以用于非多态类型之间的转换。
  • dynamic_cast:用于进行多态类型之间的转换,主要用于将基类指针或引用转换为派生类指针或引用dynamic_cast在运行时进行检查,如果转换不合法会返回nullptr(对于指针)或抛出std::bad_cast异常(对于引用)。

    使用dynamic_cast时需要注意: 1. 只有当基类具有至少一个虚函数时,dynamic_cast才有效。 2. dynamic_cast只能用于指针或引用类型,不能用于基本类型。 3. 当转换失败时,dynamic_cast会返回nullptr(对于指针)或抛出std::bad_cast异常(对于引用),因此在使用dynamic_cast时需要进行适当的错误处理。 4. dynamic_cast依赖于RTTI(Run-Time Type Information)机制,RTTI信息在编译时生成,需要在编译器选项中启用RTTI支持。

  • const_cast:用于去除对象的constvolatile属性const_cast只能用于指针或引用类型,不能用于基本类型。如果尝试使用const_cast去除一个对象的const属性,并且该对象原本是一个常量,那么对该对象的修改将导致未定义行为。

    主要用于调用一些需要非const参数的函数,但我们只有一个const对象的指针或引用时,可以使用const_cast来去除const属性。

  • reinterpret_cast:用于进行低级别的类型转换,例如将一个指针转换为一个整数,或者将一个整数转换为一个指针。reinterpret_cast不进行任何类型检查,因此使用时需要非常小心,避免引起未定义行为。

类与成员

类与结构体

C++中,类与结构体的功能相似,差异主要体现在默认访问权限和默认继承方式上。类的默认访问权限是private,而结构体的默认访问权限是public

此外,类的默认继承方式是private,而结构体的默认继承方式是public(即外部无法通过子类访问父类的成员)。

多态

多态是面向对象编程中的一个重要特性,指的是同一操作作用于不同的对象时,可以产生不同的行为。

C++ 的多态分为两种:

编译期多态,通过函数重载和模板实现。编译器在编译阶段就确定了调用哪个函数。

运行时多态,通过继承和虚函数实现。基类中用virtual声明虚函数,派生类override重写,通过基类指针或引用调用时,程序在运行时根据对象的实际类型决定调用哪个版本。其底层机制是虚函数表(vtable)——每个含虚函数的类维护一张函数指针表,对象内部有一个 vptr 指向它,调用虚函数时通过 vptr 查表跳转到正确的实现。

虚函数表

每个含有虚函数的类,编译器会为它生成一张虚函数表(vtable),本质上就是一个函数指针数组,按声明顺序存放该类所有虚函数的地址。同时,每个对象的内存布局中会隐含一个 vptr(虚表指针),指向所属类的 vtable。

class Base {
public:
    virtual void f() { cout << "Base::f"; }
    virtual void g() { cout << "Base::g"; }
};

class Derived : public Base {
public:
    void f() override { cout << "Derived::f"; }
    virtual void h() { cout << "Derived::h"; }
};
编译器会生成两张 vtable:
Base 的 vtable:       [0] -> Base::f
                      [1] -> Base::g

Derived 的 vtable:    [0] -> Derived::f   // 重写了,替换为派生类版本
                      [1] -> Base::g       // 未重写,继承基类版本
                      [2] -> Derived::h    // 新增的虚函数追加在后面

vptr在对象构造时被初始化为指向所属类的vtable。因此,构造函数中调用虚函数时,vptr已经指向正确的vtable,可以实现多态行为。

vtable是每个类共享的静态数据结构,而vptr是每个对象独有的成员变量。

override和final

overridefinal是C++11引入的两个关键字,用于增强类的继承和多态性。 - override:用于标识一个成员函数是重写(override)基类中的虚函数。当我们在派生类中重写基类的虚函数时,使用override关键字可以让编译器检查我们是否正确地重写了基类的函数。如果我们没有正确地重写(例如,函数签名不匹配),编译器会产生一个错误。这有助于避免由于拼写错误或参数类型不匹配而导致的意外行为。

class Base {
public:
    virtual void foo() {
        cout << "Base::foo" << endl;
    }
};
class Derived : public Base {
public:
    void foo() override { // 正确重写了Base::foo
        cout << "Derived::foo" << endl;
    }
};
- final:用于标识一个成员函数或一个类不能被进一步重写或继承。当我们在一个成员函数上使用final关键字时,表示这个函数不能被派生类重写;当我们在一个类上使用final关键字时,表示这个类不能被继承。这有助于防止意外的重写或继承,增强代码的安全性和可维护性。
class Base {
public:
    virtual void foo() final { // 这个函数不能被重写
        cout << "Base::foo" << endl;
    }
};

静态成员

静态成员是指属于类而不是属于某个对象的成员。静态成员在内存中只有一份,无论创建多少个对象,所有对象共享同一个静态成员。静态成员可以是变量、函数或嵌套类型。 静态成员变量需要在类外进行定义和初始化。例如:

class MyClass {
public:
    static int count; // 声明静态成员变量
};
int MyClass::count = 0; // 定义和初始化静态成员变量
对于静态函数,则只能访问静态成员变量和其他静态函数,不能访问非静态成员变量或非静态函数。

const成员函数

const成员函数是指在成员函数的声明后面加上const关键字,表示该函数不会修改对象的状态(即不会修改成员变量)。在const成员函数中,this指针被隐式地转换为一个指向常量对象的指针,因此不能通过this指针修改对象的成员变量。

class MyClass {
public:
    int value;
    void setValue(int v) {
        value = v; // 可以修改成员变量
    }
    int getValue() const {
        return value; // 不能修改成员变量
    }
};
const对象只能调用const成员函数,因为const对象的状态不能被修改,而非const成员函数可能会修改对象的状态,因此不能被const对象调用。

子类访问父类函数

当子类需要访问父类的函数时,可以使用作用域解析运算符::来指定要调用的父类函数。例如:

class Base {
public:
    void foo() {
        cout << "Base::foo" << endl;
    }
};
class Derived : public Base {
public:
    void foo() {
        cout << "Derived::foo" << endl;
    }
    void callBaseFoo() {
        Base::foo(); // 调用父类的foo函数
    }
};
在这个例子中,Derived类重写了Base类的foo函数,但在callBaseFoo函数中,我们使用Base::foo()来调用父类的版本。

友元

友元(friend)是C++中的一个特性,允许一个函数或一个类访问另一个类的私有成员和保护成员。友元关系是单向的,即被声明为友元的函数或类可以访问另一个类的私有成员,但反过来不成立。

class MyClass {
private:
    int secret;
public:
    MyClass(int s) : secret(s) {}
    friend void revealSecret(MyClass& obj); // 声明友元函数
};
void revealSecret(MyClass& obj) {
    cout << "The secret is: " << obj.secret << endl; // 访问私有成员
}
在这个例子中,revealSecret函数被声明为MyClass的友元函数,因此它可以访问MyClass的私有成员secret。友元函数可以是普通函数、成员函数或其他类的成员函数。友元关系可以跨越类之间的层次结构,即一个类可以是另一个类的友元,而不需要它们之间存在继承关系。

运算符重载

运算符重载是C++中的一个特性,允许我们为自定义类型定义自己的运算符行为。通过重载运算符,我们可以使自定义类型的对象能够使用常见的运算符进行操作,例如加法、减法、乘法等。

class Complex {
public:
    double real;
    double imag;
    Complex(double r, double i) : real(r), imag(i) {}
    Complex operator+(const Complex& other) {
        return Complex(real + other.real, imag + other.imag);
    }
};
在这个例子中,我们定义了一个Complex类,并重载了加法运算符+,使得我们可以直接使用+运算符来相加两个Complex对象。 重载运算符不一定非得在类内定义,也可以在类外定义。例如:
Complex operator+(const Complex& c1, const Complex& c2) {
    return Complex(c1.real + c2.real, c1.imag + c2.imag);
}
在这个例子中,我们在类外定义了加法运算符+,并且接受两个Complex对象作为参数。

特殊成员函数

delete

delete是C++11引入的一个关键字,用于显式地删除一个函数或一个成员函数,使其无法被调用。当我们在函数声明后面使用= delete时,表示该函数被删除,编译器会禁止对该函数的调用。

delete通常用于禁止某些不合理的函数调用,例如禁止拷贝构造函数或拷贝赋值运算符,以防止对象被意外地复制。此外,也可以用于禁止特定类型的编译期多态函数。

class MyClass {
public:
    MyClass(const MyClass&) = delete; // 禁止拷贝构造函数
    MyClass& operator=(const MyClass&) = delete; // 禁止拷贝赋值运算符
};

bool isEven(int x) {
    return x % 2 == 0;
}
bool isEven(double x) = delete; // 禁止对double类型调用isEven函数

default

default是C++11引入的一个关键字,用于显式地指定一个函数或一个成员函数使用编译器生成的默认实现。

class MyClass {
public:
    MyClass() = default; // 默认构造函数
    MyClass(const MyClass&) = default; // 默认拷贝构造函数
    MyClass& operator=(const MyClass&) = default; // 默认拷贝赋值运算符
};
当我们显式声明一个构造函数时,编译器就不会生成默认构造函数。此时,如果我们需要一个默认构造函数,就可以使用= default来告诉编译器生成一个默认构造函数。

声明类的成员变量时,可以给它赋予一个默认值。调用默认构造函数时,就会使用这个默认值来初始化成员变量。

拷贝构造函数和拷贝赋值运算符

拷贝构造函数是指一个类的构造函数接受一个同类型的对象作为参数,用于创建一个新的对象并将其初始化为传入对象的副本。

拷贝构造函数的定义有以下几种形式:

A(const A& other); // 以const引用的形式接受参数,这是最常见的形式
A(A& other); // 以非常量引用的形式接受参数
A(const A& other, T b = 默认值...); // 以const引用的形式接受参数,并且有默认参数

若未显式定义,编译器会自动生成一个默认的拷贝构造函数,该函数会逐成员地复制对象的成员变量。对于类类型的成员变量,默认的拷贝构造函数会调用它们的拷贝构造函数进行复制

调用默认拷贝构造函数的初始化方式叫做直接初始化(direct initialization)拷贝初始化(copy initialization),二者有细微的差别:

A a1; // 默认构造函数
A a2{a1}; // 直接初始化,调用默认拷贝构造函数
A a3 = a1; // 拷贝初始化,调用默认拷贝构造函数

拷贝赋值运算符是指一个类的成员函数,用于将一个对象的值赋给另一个对象。它的定义形式如下:

A& operator=(const A& other); // 以const引用的形式接受参数

A obj1(10);
A obj2;
obj2 = obj1; // 调用拷贝赋值运算符,将obj1的值赋给obj2

同样地,当我们没有显式定义拷贝赋值运算符时,编译器会自动生成一个默认的拷贝赋值运算符,该函数会逐成员地复制对象的成员变量。对于类类型的成员变量,默认的拷贝赋值运算符会调用它们的拷贝赋值运算符进行复制

当我们自定义拷贝构造函数时,通常也需要自定义拷贝赋值运算符以及析构函数,以确保对象的正确复制和资源管理

移动构造函数和移动赋值运算符

移动构造函数是指一个类的构造函数接受一个右值引用作为参数,用于创建一个新的对象并将其资源从传入对象转移到新对象。

A(A&& other); // 以右值引用的形式接受参数
移动赋值运算符是指一个类的成员函数,用于将一个对象的资源从另一个对象转移到当前对象。它的定义形式如下:

A& operator=(A&& other); // 以右值引用的形式接受参数

可平凡复制类型

枚举、指针、内置类型和满足特定条件的类类型都属于可平凡复制类型。 当类满足以下条件,就属于可平凡复制类型:

  1. 该类没有虚函数。

这是因为虚函数会引入虚表指针,它不能简单地通过内存复制来复制对象。

  1. 该类没有用户自定义的特殊成员函数,即拷贝构造函数、移动构造函数、拷贝赋值运算符或移动赋值运算符。
  2. 该类有自动生成的析构函数。
  3. 该类的所有非静态数据成员都是可平凡复制类型。
  4. 该类的拷贝构造函数、移动构造函数、拷贝赋值运算符和移动赋值运算符至少有一个是未被删除的。
  5. 若该类有父类,则父类也必须满足上述条件。

可平凡复制类型的对象在内存中是连续的,可以通过简单的内存复制(如memcpy)来进行复制,而不需要调用任何构造函数或赋值运算符。

简单地说,对于可平凡复制类型,我们无需担心对象的复制和资源管理问题,因为它们的复制行为是简单且高效的。这些类型可以以二进制方式进行复制,也可以通过网络传输。

<type_traits>头文件中,静态成员std::is_trivially_copyable<T>::value可以用来检查一个类型是否是可平凡复制类型。

此外,模板is_trivially_constructibleis_trivially_copy_assignableis_trivially_move_assignable可以用来检查一个类型是否具有平凡的构造函数、拷贝赋值运算符和移动赋值运算符。

标准布局类型

所有标量类型(如整数、浮点数、指针等)都是标准布局类型。一个类类型是标准布局类型,如果它满足以下条件: 1. 该类没有虚函数。 2. 该类若有父类,则这个类和它的所有父类中,只有一个类具有非静态数据成员,(即,只有其他类都只能有静态成员或者函数)且这个类必须是标准布局类型。 3. 该类的所有非静态数据成员都具有相同的访问控制(即全部为publicprotectedprivate)。 4. 该类的所有非静态数据成员都是标准布局类型。

则满足上述条件的类被称为标准布局类。

对于标准布局类型,成员按照出现顺序,在内存中按照地址从低到高进行排列,并且没有任何填充字节。

可以使用type_traits头文件中的std::is_standard_layout<T>::value来检查一个类型是否是标准布局类型。

如果一个类既是平凡可复制类型,又是标准布局类型,那么它就与C语言中的结构体具有相同的内存布局,可以与C语言进行互操作

宏定义

宏定义是C++中的一个预处理器指令,用于定义常量、函数或代码片段。宏定义使用#define指令来创建一个宏,宏可以接受参数,也可以不接受参数。 井号#在宏定义中有特殊的作用: - 当宏定义中使用#运算符时,它会将宏参数转换为字符串。例如:

#define TO_STRING(x) #x
在这个例子中,TO_STRING(Hello)会被预处理器替换为"Hello"。 - 当宏定义中使用##运算符时,它会将两个宏参数连接在一起。例如:
#define CONCAT(a, b) a##b
在这个例子中,CONCAT(Hello, World)会被预处理器替换为HelloWorld。 宏定义在编译时进行文本替换,因此它们没有类型检查和作用域限制,使用时需要小心,避免引起意外的行为。建议在可能的情况下使用const变量、函数或模板来替代宏定义,以获得更好的类型安全和可维护性。

要定义多行宏,可以使用反斜杠\来连接多行代码。

C++中有若干预定义宏,例如: - __FILE__:表示当前源文件的名称。 - __LINE__:表示当前行号。 - __DATE__:表示当前编译的日期。 - __TIME__:表示当前编译的时间。 - __cplusplus:表示当前编译器支持的C++标准版本,例如199711、201703等。

可变参数宏是指宏定义中使用了省略号...来表示可接受任意数量的参数。例如:

#define LOG(format, ...) printf(format, __VA_ARGS__)
在这个例子中,LOG宏可以接受一个格式字符串和任意数量的参数,并将它们传递给printf函数进行输出。使用可变参数宏时,需要使用__VA_ARGS__来表示传递给宏的可变参数列表。

智能指针

智能指针是C++11引入的一种资源管理工具,用于自动管理动态分配的内存,避免内存泄漏和悬空指针等问题。智能指针通过重载运算符来提供类似于普通指针的使用方式,同时在对象生命周期结束时自动释放资源。 C++标准库提供了三种主要的智能指针类型:std::unique_ptrstd::shared_ptrstd::weak_ptr

std::unique_ptr

std::unique_ptr是一种独占所有权的智能指针,表示一个对象只能有一个unique_ptr拥有。当unique_ptr被销毁时,它所管理的对象也会被自动释放。unique_ptr不支持复制,但支持移动语义。

移动语义是指资源的所有权可以从一个对象转移到另一个对象,而不是复制资源。通过使用std::move函数,我们可以将一个unique_ptr的所有权转移给另一个unique_ptr,从而避免了不必要的资源复制和内存泄漏。

std::unique_ptr<int> ptr1(new int(5)); // 创建一个unique_ptr
std::unique_ptr<int> ptr2 = std::move(ptr1); // 转移ptr1的所有权给ptr2,此时ptr1不再拥有该对象

它包含下列常用成员函数: - get():返回unique_ptr所管理的原始指针。 - release():释放unique_ptr所管理的对象,并返回原始指针。调用release()后,unique_ptr不再拥有该对象,调用者需要负责释放该对象的内存。 - reset():释放unique_ptr当前管理的对象,并可选择性地将其替换为一个新的对象。例如,reset(new T())会释放当前对象并管理一个新的对象。 - swap():交换两个unique_ptr的所有权。例如,ptr1.swap(ptr2)会交换ptr1ptr2所管理的对象。

我们可以用std::make_unique函数来创建一个unique_ptr,它提供了更安全和简洁的方式来创建智能指针。例如:

auto ptr = std::make_unique<int>(5); // 创建一个unique_ptr,管理一个值为5的整数对象

std::shared_ptr

std::shared_ptr是一种共享所有权的智能指针,表示一个对象可以有多个shared_ptr拥有。当最后一个拥有该对象的shared_ptr被销毁时,该对象才会被自动释放。shared_ptr支持复制和移动语义。 它包含下列常用成员函数: - get():返回shared_ptr所管理的原始指针。 - use_count():返回当前有多少个shared_ptr实例共享所有权。 - unique():返回一个布尔值,表示当前shared_ptr是否是唯一拥有该对象的实例。 - reset():释放shared_ptr当前管理的对象,并可选择性地将其替换为一个新的对象。 - swap():交换两个shared_ptr的所有权。

我们可以用std::make_shared函数来创建一个shared_ptr

std::weak_ptr

std::weak_ptr是一种弱引用的智能指针,表示一个对象可以被多个shared_ptr拥有,但weak_ptr本身不拥有该对象。当最后一个拥有该对象的shared_ptr被销毁时,该对象会被自动释放,而所有指向该对象的weak_ptr会变为无效。weak_ptr主要用于解决循环引用的问题。 它包含下列常用成员函数: - lock():返回一个shared_ptr,如果原始对象仍然存在,则该shared_ptr拥有该对象;如果原始对象已经被销毁,则返回一个空的shared_ptr一般我们需要获取weak_ptr所管理的对象时,首先调用lock()函数来获取一个shared_ptr,然后检查该shared_ptr是否为空,以确定原始对象是否仍然存在。此外,lock()为原子操作,可以在多线程环境中安全地使用。

std::weak_ptr<int> weakPtr; // 创建一个weak_ptr
{
    auto sharedPtr = std::make_shared<int>(5); // 创建一个shared_ptr,管理一个值为5的整数对象
    weakPtr = sharedPtr; // weak_ptr现在指向shared_ptr管理的对象
} // sharedPtr超出作用域被销毁,此时weakPtr变为无效
auto lockedPtr = weakPtr.lock(); // 尝试获取shared_ptr
if (lockedPtr) {
    // lockedPtr不为空,原始对象仍然存在
    cout << *lockedPtr << endl; // 输出5
} else {
    // lockedPtr为空,原始对象已经被销毁
    cout << "Object has been destroyed." << endl;
}
- expired():返回一个布尔值,表示原始对象是否已经被销毁。 - reset():释放weak_ptr当前管理的对象,并可选择性地将其替换为一个新的对象。 - swap():交换两个weak_ptr的所有权。

weak_ptr是如何解决循环引用的问题的? 循环引用是指两个或多个对象相互持有对方的shared_ptr,导致它们的引用计数永远不会降为零,从而无法自动释放内存。通过使用weak_ptr,我们可以打破这种循环引用。例如,如果对象A持有一个shared_ptr指向对象B,而对象B持有一个weak_ptr指向对象A,那么当对象A被销毁时,对象B的weak_ptr会变为无效,而不会阻止对象A的销毁,从而避免了内存泄漏的问题。

Lambda表达式

Lambda表达式是C++11引入的一种匿名函数,可以直接在需要函数对象的地方定义和使用。Lambda表达式的语法如下:

[捕获变量](参数列表) 可选限定符-> 返回类型 {
    // 函数体
}
例如:
int a = 3;
auto addA = [a](int x) -> int {
    return a + x;
};
cout << addA(5) << endl; // 输出8
捕获变量包括值捕获和引用捕获两种方式。值捕获会将变量的值复制到Lambda表达式中,而引用捕获会将变量的引用传递给Lambda表达式,使得Lambda表达式可以修改外部变量的值。
int a = 3;
auto addA = [&a](int x) -> int {
    a += x; // 修改外部变量a的值
    return a;
};
cout << addA(5) << endl; // 输出8
cout << a << endl; // 输出8,说明外部变量a的值被修改了
类变量/结构体变量在被值捕获时会调用拷贝构造函数。若拷贝构造函数被删除或不可访问,则会导致编译错误。

在一个类中,如果我们想在成员函数中使用Lambda表达式,并且需要捕获类的成员变量,我们可以使用this指针来捕获整个对象。例如:

class MyClass {
public:
    int value;
    MyClass(int v) : value(v) {}
    void printValue() {
        auto lambda = [this]() {
            cout << "Value: " << value << endl; // 捕获整个对象,访问成员变量
        };
        lambda();
    }
};

使用按值捕获时,默认无法修改捕获的变量,但可以使用mutable关键字来允许修改捕获的变量。例如:

int a = 3;
auto addA = [a]() mutable -> int {
    a += 5; // 修改捕获的变量a的值
    return a;
};
cout << addA() << endl; // 输出8
cout << a << endl; // 输出3,说明外部变量a的值没有被修改

左值与右值

左值(lvalue)是指一个表达式所表示的对象具有一个持久的地址,可以被赋值或取地址。右值(rvalue)是指一个表达式所表示的对象没有持久的地址,通常是一个临时对象或字面值,不能被赋值或取地址。

int x = 5; // x是一个左值,5是一个右值
int* ptr = &x; // 可以取地址,ptr指向x
带有const修饰的左值被称为常量左值(const lvalue),它们不能被修改,但仍然具有持久的地址。

由于函数可以取地址,因此函数名也是一个左值。

在C++11后,左右值在宏观上被分为glvalue(generalized lvalue)和rvalue两大类。glvalue包括左值和将亡值(xvalue),而右值则包括纯右值(prvalue)和将亡值(xvalue)。

将亡值(xvalue)是指一个表达式所表示的对象即将被销毁,可以被移动语义所利用。std::move函数会将一个左值转换为一个将亡值,以便触发移动构造函数或移动赋值运算符。

std::string str = "Hello";
std::string movedStr = std::move(str); // str被转换为一个将亡值,触发移动构造函数
纯右值(prvalue)是指一个表达式所表示的对象没有持久的地址,通常是一个临时对象或字面值。例如,字面值5、函数返回的临时对象等都是纯右值。
int getValue() {
    return 5; // 返回一个纯右值
}

此外,表达式也是纯右值。例如m+n,它的计算结果是一个临时对象,没有持久的地址,因此是一个纯右值;显式转换表达式也是纯右值。例如(int)x;未命名类的对象也是纯右值,返回这种对象的函数也是纯右值。例如:

struct Point {
    int x, y;
};
Point getPoint() {
    return {1, 2}; // 返回一个未命名类的对象,是一个纯右值
}

普通的引用(类似于int&)只能绑定到左值,而右值引用(类似于int&&)只能绑定到右值。这是C++11引入的一种新的引用类型,用于支持移动语义和完美转发。

右值引用作为函数参数时,可以用于区分传递的是左值还是右值,从而实现不同的行为。例如:

void process(int& x) {
    cout << "Processing lvalue: " << x << endl;
}
void process(int&& x) {
    cout << "Processing rvalue: " << x << endl;
}

这五个概念的关系图为:

在底层,左值存放在内存中,而右值通常存放在寄存器中。

移动语义

对于自定义类,如果我们不定义拷贝构造函数,编译器默认生成的拷贝是浅拷贝。假设类中有一个指针成员变量,浅拷贝会导致多个对象共享同一个指针,可能会引发内存泄漏或悬空指针等问题。

通过定义移动构造函数和移动赋值运算符,我们可以实现移动语义,允许资源的所有权从一个对象转移到另一个对象,而不是复制资源,从而避免了不必要的资源复制和内存泄漏。

class MyClass {
public:
    MyClass(MyClass&& other) {
        // 移动构造函数实现
    }
    MyClass& operator=(MyClass&& other) {
        // 移动赋值运算符实现
        return *this;
    }
};
移动构造函数接受一个右值引用参数,表示要移动的对象;移动赋值运算符也接受一个右值引用参数,并返回一个指向当前对象的引用。

std::move函数是一个标准库函数,用于将一个左值转换为一个右值引用,以便触发移动构造函数或移动赋值运算符。例如:

MyClass obj1;
MyClass obj2 = std::move(obj1); // obj1被转换为一个右值,触发移动构造函数

拷贝省略(copy elision)是指编译器在某些情况下可以优化掉不必要的临时对象创建,直接构造对象到目标位置。

在构造一个对象时,直接将一个类的构造函数的返回值作为另一个对象的初始化值时,编译器可以省略掉临时对象的创建,直接构造目标对象。例如:

MyClass obj = MyClass(); // 直接构造obj,而不是先创建一个临时对象再拷贝到obj

当函数返回一个对象时,编译器也可以省略掉临时对象的创建,直接构造返回值到调用者的目标位置。例如:

MyClass createObject() {
    return MyClass(); // 直接构造返回值,而不是先创建一个临时对象再拷贝到调用者
}

这种技术被称为返回值优化(Return Value Optimization,RVO)或命名返回值优化(Named Return Value Optimization,NRVO)。二者区别在于RVO适用于返回一个匿名对象,而NRVO适用于返回一个命名对象。

前面提到,对于同一块内存区域,同时只能有一个unique_ptr指向它。因此,当需要将一个unique_ptr的所有权转移给另一个unique_ptr时,必须使用std::move来显式地表示所有权的转移。

右值引用本身是一个左值,因为它有一个持久的地址,可以被赋值或取地址。例如:

int&& rvalueRef = 5; // rvalueRef是一个右值引用,但它本身是一个左值
因此,假设有一个函数接收一个右值引用参数A(类型为T,重写了移动赋值运算符),并在函数体内声明一个新的T变量B,把A赋值给B,此时不会调用移动赋值运算符,而是调用拷贝赋值运算符,因为A是一个左值。如果非要调用移动赋值运算符,可以在函数体内使用std::move将A转换为一个右值引用。例如:
void func(T&& A) {
    T B;
    B = std::move(A); // 使用std::move将A转换为一个右值引用,调用移动赋值运算符
}

完美转发

上面提到的右值引用本身为左值导致的问题可以通过完美转发来解决。

完美转发是指使用std::forward函数来将函数参数完美地转发给另一个函数,保持参数的值类别(左值或右值)不变。在非模板函数中,它等效于static_cast,而在模板函数中,它会根据参数的值类别进行适当的转发。例如:

template<typename T>
void wrapper(T&& arg) {
    process(std::forward<T>(arg)); // 完美转发参数arg
}

模板函数参数中的T&&被称为转发引用(Forwarding Reference)或万能引用(Universal Reference),它可以绑定到左值或右值。当我们调用wrapper函数时,编译器会根据传入参数的值类别来推断T的类型。

在参数为万能引用的模板函数中,当模板实例化时,T&&中的T会被替换为实际的类型。例如,对于类型A的引用,实例化时会被替换为A& &&。这实际上是不合法的。例如:

template<typename T>
void wrapper(T&& arg) {
    // ...
}

wrapper<string&>("Hello");

// 实例化:
template<>
void wrapper<string&>(string& && arg) { // <- 这里的string& &&是不合法的
    // ...
}

为了解决这一问题,C++使用引用折叠规则来处理这种情况。根据引用折叠规则:

  • T& &会被折叠为T&
  • T& &&会被折叠为T&
  • T&& &会被折叠为T&
  • T&& &&会被折叠为T&&

总结:仅有传参为右值引用时,才会折叠为右值引用;其他情况都会折叠为左值引用。

常量表达式与constexpr

常量表达式是由编译器在编译时求值的表达式,它由字面量、常量、函数和运算符组成。

C++11引入了constexpr关键字,用于声明一个函数或变量是一个常量表达式。constexpr函数必须满足以下条件: - 函数体必须包含一个单一的return语句。 - 函数参数和返回类型必须是字面类型(literal type)。

字面类型是指可以直接用字面量表示的类型,例如整数、浮点数、指针、引用等。用户定义的类型如果满足某些条件(例如,所有成员都是字面类型,并且有一个constexpr构造函数,如有其他函数,也都声明为constexpr),也可以被视为字面类型。

struct Point {
    int x, y;
    constexpr Point(int xVal, int yVal) : x(xVal), y(yVal) {}
};

如果一个函数被声明为constexpr,它可以在编译时被求值,也可以在运行时被调用。例如,假设一个constexpr函数被调用的参数是常量表达式,那么编译器会在编译时求值该函数的结果;如果参数不是常量表达式,那么该函数会在运行时被调用。

  • 函数体内不能包含任何会导致运行时行为的语句,例如循环、条件语句等。此外,还不能有局部变量,(但C++14后允许,static变量、未初始化的变量除外)不能由调用非constexpr函数,不能抛出异常。

在常用库中,只有<cmath>中的函数被声明为constexpr

虽然不能有循环,但可以用递归来代替;不能有条件语句,但可以用三元运算符来代替。

递归有深度上限。具体是多少由编译器实现决定。

对于变量,constexpr变量必须在声明时进行初始化,并且初始化表达式必须是一个常量表达式。例如:

constexpr int maxValue = 100;
constexpr int b{maxValue * 2};

constexpr表达式中不能包含由const修饰的变量。

常量表达式可以提高程序的性能,因为它们在编译时就被求值了,避免了运行时的计算开销。此外,常量表达式还可以用于定义数组大小、枚举值等需要编译时常量的场景。

decltype

decltype是C++11引入的一个关键字,用于查询表达式的类型。它可以用于获取变量、函数返回值、成员函数等的类型信息。除了类信息以外,它还可以保存引用类型、const以及volatile修饰符等类型属性。

const int a = 3;
decltype(a) b = 5; // b的类型与a相同,即const int

autodecltype的区别在于,auto会根据初始化表达式的类型进行类型推断(不含引用以及限定符),而decltype会直接返回表达式的类型,不进行类型推断。

decltype对变量和表达式的类型获取有较大差异。对于变量,decltype会返回变量的类型,包括引用和限定符。对于表达式,则是:

  1. 先获取表达式的值去除引用后的基本类型。
  2. 如果表达式是一个左值,则返回类型为T&
  3. 如果表达式是一个将亡值,则返回类型为T&&
  4. 否则,返回类型为T

例如:

int a;
decltype(a) x; // x的类型为int
decltype((a)) y = a; // y的类型为int&,因为(a)是一个左值表达式
decltype(a + 1) z; // z的类型为int,因为a + 1是一个纯右值表达式

decltype通常用于定义函数模板时,函数返回值类型的自动推导。例如:

// C++11
template<typename T1, typename T2>
auto add(T1 a, T2 b) -> decltype(a + b) {
    return a + b;
}
// C++14
template<typename T1, typename T2>
decltype(auto) add(T1 a, T2 b) {
    return a + b;
}

为什么不直接用auto来定义函数返回值类型,而要使用decltype(auto)呢?

因为auto会进行类型推断,去除引用和限定符,而decltype(auto)会保留表达式的类型属性,包括引用和限定符。在某些情况下(例如转发中间层,只转手数据,不改变类型),我们希望函数返回值的类型能够完全匹配表达式的类型,而不是被自动推断为一个新的类型。