WAYNETS.ORG

Game and Program

C++知识总结

作者:

发表于

Context Polling System Post-Processing Renderer

1. static关键字的作用

static关键字在 C++ 中有多个用途,主要涉及作用域生命周期以及类的静态成员等方面。接下来我们从全局静态变量、局部静态变量、静态函数、类的静态成员以及类的静态函数几个角度详细解释。

(1)全局静态变量

全局静态变量是在函数外定义的,但带有 static 关键字。它的作用是使得变量仅在定义它的文件内可见,避免在其他文件中进行访问,防止命名冲突。

例子:

// file1.cpp
static int globalVar = 10;  // 全局静态变量

void printGlobalVar() 
{
    std::cout << globalVar << std::endl;
}

// file2.cpp
extern int globalVar;  // 错误:无法访问 file1.cpp 中的 static 变量

在上面的例子中,globalVar 只能在 file1.cpp 文件中访问,file2.cpp 不能访问它,因为它是静态的。

(2)局部静态变量

局部静态变量是使用static关键字在函数内部定义的变量。它的生命周期是整个程序运行期间,而不是函数调用时。不同于普通局部变量,局部静态变量会保留上一次调用时的值。

例子:

#include <iostream>

void counter() 
{
    static int count = 0;  // 静态局部变量
    count++;
    std::cout << "Count: " << count << std::endl;
}

int main() 
{
    counter();  // 输出 Count: 1
    counter();  // 输出 Count: 2
    return 0;
}

这里,count是静态的,因此它不会在每次函数调用时重新初始化,而是保持上一次调用的值。

(3)静态函数

静态函数是指带有static关键字,在类外部或类内部定义的函数。它的作用类似于全局静态变量和局部静态变量,仅限于当前文件或类内访问。外部无法访问这个静态函数。

例子1(类内部静态函数):

class MyClass {
public:
    static void staticFunction() 
    {
        std::cout << "This is a static function!" << std::endl;
    }
};

int main() 
{
    MyClass::staticFunction();  // 正确调用
    return 0;
}

例子2(普通静态函数):

static void printMessage() 
{
    std::cout << "Hello from static function!" << std::endl;
}

int main() 
{
    printMessage();  // 正常调用
    return 0;
}

在第二个例子中,printMessage只能在当前文件内使用,无法在其他文件中访问。

(4)类的静态成员变量

类的静态成员变量是属于类而不是某个对象的变量,所有类的实例共享这一个静态变量

静态成员变量必须在类外进行定义。

例子:

#include <iostream>

class MyClass {
public:
    static int count;  // 静态成员变量

    MyClass() 
    {
        count++;
    }
};

int MyClass::count = 0;  // 在类外定义静态成员变量

int main() 
{
    MyClass obj1;
    MyClass obj2;
    std::cout << MyClass::count << std::endl;  // 输出 Count: 2
    return 0;
}

在这个例子中,count是一个静态成员变量,两个MyClass对象共享这个变量。

(5)类的静态成员函数

静态成员函数可以通过类名直接调用,而不需要实例化对象。

注意:类的静态成员函数只能访问类的静态成员变量,不能访问非静态成员变量。

例子:

#include <iostream>

class MyClass {
public:
    static int count;  // 静态成员变量

    static void printCount() 
    {  
        std::cout << "Count: " << count << std::endl;
    }
};

int MyClass::count = 10;

int main() 
{
    MyClass::printCount();  // 通过类名直接调用静态成员函数
    return 0;
}

在这个例子中,printCount是一个静态成员函数,它可以通过类名直接调用,而无需创建 MyClass类的对象。

(6)总结

  • 全局静态变量:仅在当前文件中可见,避免命名冲突。
  • 局部静态变量:具有全程序生命周期,保留上一次函数调用的值。
  • 静态函数:函数的作用域限制在定义它的文件或类内,无法在外部调用。
  • 类的静态成员变量:类所有实例共享的变量,不依赖于对象。
  • 类的静态成员函数:只能够访问类的静态成员,不能访问实例成员,且可通过类名直接调用。

2. const和constexpr关键字

在 C++ 中,const和 constexpr 都用于声明常量,但它们有不同的含义和用途。

它们的主要区别在于常量的求值时机以及适用范围

(1)const(常量,运行时求值)

const用来声明一个常量,它的值在初始化时被确定后,在后续的代码中无法修改。

const变量的值可以在运行时决定,但一旦初始化后,它的值就不能再改变。

const变量的值是常量,但它不需要在编译时确定,可能是程序运行时确定的。

特性:

  • 值不可修改:声明为const的变量一旦初始化后,不能修改。
  • 运行时常量const变量的值通常在程序运行时确定,可以是运行时计算的结果。
  • 作用范围广泛:可以应用于局部变量、全局变量、成员变量等。

示例:

#include <iostream>

int main() 
{
    int x = 10;
    const int y = x; // y 在编译时不可修改,y 的值是 10,它是运行时确定的
    std::cout << y << std::endl;
    
    y = 20;          // 编译错误:不能修改 const 变量
    
    return 0;
}

(2)constexpr(常量表达式,编译时求值)

constexpr是 C++11 引入的关键字,用于声明常量表达式。

常量表达式是指在编译时求值的常量,因此constexpr变量的值必须在编译时就已知。

使用constexpr可以提高程序性能,因为被它修饰的变量是在编译时计算,从而减少开销

特性:

  • 值必须在编译时确定:constexpr变量的值必须是编译时常量,编译器在编译阶段会对其进行求值。
  • 可以用于常量表达式:constexpr用于声明常量的同时,还可以指定用于编译时求值的常量表达式,比如函数。
  • 常用于数组大小和模板参数:constexpr变量和函数常用于模板编程和静态数组大小的定义。

示例:

#include <iostream>

constexpr int square(int x) { return x * x; }

int main() 
{
    constexpr int n = 10;
    constexpr int result = square(n); // 在编译时计算出 result 的值
    
    std::cout << result << std::endl; // 输出 100
    
    return 0;
}

在上面的例子中,函数square()是一个constexpr函数,它的值在编译时就可以确定。

n和result都是编译时常量。

总结:constexpr变量和函数的值必须在编译时已知,编译器会在编译期进行求值。它们通常用于需要编译时常量的场景。

(3)const和constexpr的区别

特性constconstexpr
值的确定时机可以在运行时确定必须在编译时确定
常量表达式不是必须的,可以是运行时常量必须是常量表达式
适用范围局部变量、全局变量、成员变量局部变量、全局变量、成员变量、函数、模板参数
是否能用于数组大小可以,但不一定是编译时常量可以(要求必须是编译时常量)
是否可以用于模板可以,但通常不用于模板常常用于模板,尤其是模板参数

(4)const和constexpr关键字在函数中的区别

const函数:

  • const函数通常用于指示成员函数不会修改对象的状态(即const成员函数),或者函数参数是只读的。
  • const可以用于局部变量、成员变量和函数参数。
class MyClass {
public:
    // const 成员函数
    void myFunction() const 
    { 
        // 不能修改类成员
    }
};

constexpr函数:

  • constexpr函数要求函数在编译时能够求值,因此它们的参数和返回值必须是常量表达式。
  • constexpr函数可以作为常量表达式在编译时使用,通常用于编译时求值的场景。
constexpr int add(int x, int y) 
{
    return x + y;
}

int main() 
{
    constexpr int result = add(2, 3); // 编译时求值
    return 0;
}

(5)何时使用const和constexpr

  • 使用const:当你想声明一个值在运行时不会改变的变量时,使用const。它适用于需要在运行时确定值的情况。
    • 例如:const int size = get_size();(这里 size 的值由运行时计算得出)
  • 使用constexpr:当你需要在编译时就确定一个值,并且该值必须是常量表达式时,使用constexpr。它适用于需要在编译时已知的常量,如数组大小、模板参数等。
    • 例如:constexpr int size = 100;constexpr int result = square(10);

(6)总结

constexpr:声明一个常量,其值必须在编译时已知,编译器会在编译时计算该值。适用于需要编译时常量的情况,常用于数组大小、模板参数等。

const:声明一个常量,其值可以在运行时确定,但在定义后不能修改。适用于值在运行时确定的情况。

3. 函数指针

函数指针是一个指针,它指向一个函数的地址,允许通过指针来调用该函数。换句话说,函数指针让你可以动态选择要调用的函数,而不必在编译时确定。这在需要根据不同条件执行不同函数时非常有用,比如回调函数、事件处理、或者实现函数表等。

(1)函数指针的声明和定义

函数指针的声明通常需要指定指针所指向的函数的返回类型和参数类型。

语法:

return_type (*pointer_name)(parameter_types);
  • return_type:指针指向的函数的返回类型。
  • pointer_name:函数指针的名称。
  • parameter_types:指针指向的函数的参数类型列表。

例子:

#include <iostream>

// 定义一个普通函数
int add(int a, int b) 
{
    return a + b;
}

int main() 
{
    // 定义一个函数指针,指向返回 int 类型、参数为两个 int 类型的函数
    int (*funcPtr)(int, int) = add;

    // 通过函数指针调用函数
    std::cout << funcPtr(2, 3) << std::endl;  // 输出: Result: 5

    return 0;
}

在这个例子中,funcPtr 是一个指向返回 int 类型、接受两个 int 参数的函数的指针。通过 funcPtr 可以调用 add 函数。

(2)函数指针的应用

函数指针的应用场景非常广泛,以下是一些常见的使用场景:

a. 回调函数

回调函数是指将一个函数的指针作为参数传递给另一个函数,然后在适当的时候调用它。这种机制非常常见,比如在事件驱动编程中。

例子:

#include <iostream>

// 定义一个接受函数指针作为参数的函数
void invokeCallback(int a, int b, int (*callback)(int, int)) 
{
    std::cout << "Callback result: " << callback(a, b) << std::endl;
}

// 定义一个回调函数
int multiply(int a, int b) 
{
    return a * b;
}

int main() 
{
    // 将函数指针传递给 invokeCallback
    invokeCallback(4, 5, multiply);  // 输出: Callback result: 20

    return 0;
}

在这个例子中,invokeCallback 函数接受一个函数指针,并在内部调用它。multiply 函数作为回调函数被传递并执行。

b. 函数表(Function Table)

函数指针常用于实现函数表,它是一个函数指针的数组。通过索引选择函数,这种方式常用于实现状态机或处理不同事件的机制。

例子:

#include <iostream>

// 函数指针数组,用于存放不同的函数
void add(int a, int b) 
{ 
   std::cout << "Addition: " << a + b << std::endl; 
}
void subtract(int a, int b) 
{ 
   std::cout << "Subtraction: " << a - b << std::endl; 
}

int main() 
{
    // 定义一个函数指针数组
    void (*operations[2])(int, int) = {add, subtract};

    // 通过索引选择要调用的函数
    operations[0](10, 5);  // 调用 add 函数,输出: Addition: 15
    operations[1](10, 5);  // 调用 subtract 函数,输出: Subtraction: 5

    return 0;
}

这里,operations 是一个函数指针数组,它存储了指向 addsubtract 函数的指针。通过索引选择要调用的函数。

c. 动态函数调用

函数指针可以用来实现动态函数调用,即在程序运行时决定要调用哪个函数。这对于某些插件系统或扩展功能的设计非常有用。

(3)函数指针与成员函数指针的区别

函数指针适用于普通函数,而成员函数指针适用于类中的成员函数。成员函数需要通过对象或类指针来调用,因此它们的声明和使用方式略有不同。

普通函数指针:

void (*funcPtr)(int, int);  // 普通函数指针

成员函数指针:

成员函数指针需要额外的上下文信息(对象),因为成员函数是与对象的状态相关联的。

示例:

#include <iostream>

class MyClass {
public:
    void printSum(int a, int b) 
    {
        std::cout << "Sum: " << a + b << std::endl;
    }
};

int main() 
{
    MyClass obj;
    // 成员函数指针声明
    void (MyClass::*funcPtr)(int, int) = &MyClass::printSum;

    // 通过对象调用成员函数
    (obj.*funcPtr)(3, 4);  // 输出: Sum: 7

    return 0;
}

在这个例子中,funcPtr 是一个指向 MyClass 类成员函数 printSum 的指针。我们通过对象 obj 来调用成员函数。

(4)总结

  • 函数指针:是一个指向函数的指针,可以通过它动态调用不同的函数。
  • 常见应用
    • 回调函数:将函数指针作为参数传递给另一个函数,在合适的时机调用。
    • 函数表:通过函数指针数组实现根据索引调用不同的函数。
    • 动态函数调用:在运行时根据条件选择要调用的函数。
  • 成员函数指针:与普通函数指针不同,成员函数指针需要通过对象或类指针来调用。

函数指针是 C++ 强大而灵活的一部分,能够实现很多复杂的动态行为,尤其在需要回调、插件、动态函数调用等场景中非常有用。

3. 左值引用与右值引用

(1)左值与右值

首先,我们需要区分左值和右值,这是理解左值引用和右值引用的基础。

左值(Lvalue)表示一个有名称并且可以取地址的对象。简单来说,左值是可以在赋值语句的左边使用的对象。例如,变量和数组元素通常是左值。

  • 示例:
int x = 10; // x 是左值 
x = 20;     // x 出现在赋值语句的左边

右值(Rvalue)表示一个临时的、没有名称的对象。右值通常是一个表达式的结果,不能直接取地址,通常出现在赋值语句的右边。右值是不能被赋值的。

  • 示例:
int y = 5 + 10; // 5 + 10 是一个右值int x = 10;

(2)左值引用(Lvalue Reference)

左值引用是指可以绑定到左值(具有地址的对象)上的引用。

它的语法是使用单个&符号。

语法:

int x = 10;
int& ref = x;  // ref 是 x 的左值引用

特点:

  • 左值引用只能绑定到左值上。
  • 你可以通过左值引用来修改所引用的对象。

示例:

int a = 10;
int& ref = a;    // ref 绑定到左值 a
ref = 20;        // 修改 a 的值为 20
std::cout << a;  // 输出: 20

(3)右值引用(Rvalue Reference)

右值引用是 C++11 引入的,用于绑定到右值(临时对象)上的引用。

它的语法是使用两个&&符号。

语法:

int&& rref = 10;   // rref 是一个右值引用,绑定到右值 10

特点:

  • 右值引用可以绑定到右值(临时对象),而不能绑定到左值。
  • 右值引用是 C++11 引入的,主要目的是为了支持移动语义,使得我们可以避免不必要的对象拷贝,提高性能。

示例:

int&& rref = 10;   // rref 绑定到右值 10
rref = 20;         // 可以通过右值引用修改右值的值
std::cout << rref; // 输出: 20

(4)将亡值(Xvalue)

将亡值(eXpiring value)是一种特殊类型的右值,它表示一个已经不再需要的对象,通常在资源管理时使用。

将亡值通常会触发移动语义,即将资源的所有权从一个对象转移到另一个对象,而不是拷贝数据。

将亡值的特点:

  • 将亡值通常是某些操作后返回的对象(例如,通过 std::move)。
  • 它通常表示一个将要被销毁的对象,可能会触发移动构造函数或移动赋值操作。

示例:

int&& x = std::move(a);  // std::move 返回一个将亡值

简而言之:

  • 将亡值是 C++ 中一个特殊的右值,通常通过std::move显示地转换左值为右值。它表示一个即将被销毁、可以被“移动”而非“拷贝”的对象。

(5)纯右值(Prvalue)

纯右值(Pure Rvalue)是一个不带任何地址的临时值,它通常是表达式的结果。

纯右值是最常见的右值,它没有名称,且不能被修改。常见的纯右值有字面值常量、返回临时对象的函数调用等。

特点:

  • 纯右值不绑定到任何对象上,它表示一个临时对象或计算结果。
  • 它可以用来触发移动语义或构造新对象。

示例:

int&& x = 10;      // 10 是一个纯右值
int&& y = 5 + 3;   // 5 + 3 是一个纯右值,表示一个临时对象

总结:

  • 纯右值是最常见的右值,它表示一个临时的、无名的对象,不能直接修改。
  • 将亡值是一种特殊的右值,表示一个即将被销毁的对象,通常用来触发移动语义。

(6)总结

  • 左值引用(Lvalue Reference):引用左值(可以取地址的对象),通过&语法声明。
  • 右值引用(Rvalue Reference):引用右值(临时对象),通过&&语法声明。右值引用支持移动语义。
  • 将亡值(Xvalue):一种特殊的右值,表示即将被销毁的对象,通常由std::move创建。
  • 纯右值(Prvalue):临时值或表达式结果,它没有名称且无法被修改,通常是右值的典型表现。

4. 移动语义和完美转发

移动语义完美转发是 C++11 引入的两个重要特性,它们帮助我们更高效地管理资源、减少不必要的拷贝开销,尤其在处理大型数据结构、容器以及需要动态内存管理的场景中非常有用。

(1)移动语义(Move Semantics)

移动语义是 C++11 引入的一项技术,它允许我们“转移”资源而不是“拷贝”资源,从而提高程序的性能,尤其是在处理大量数据时。

移动语义的核心是右值引用(Rvalue Reference)和std::move,其基本思想是,当我们不再需要一个对象时,可以“移动”它的资源到另一个对象中,而不是进行昂贵的拷贝。

1.1 为什么需要移动语义?

在传统的 C++ 中,拷贝构造和拷贝赋值操作可能会带来不必要的性能开销,特别是在处理需要管理大量内存或动态资源的对象时。

移动语义的目标是通过避免不必要的拷贝来提高程序的效率。

例如,在以下情况下,我们通常会使用拷贝构造函数:

std::vector<int> vec1 = {1, 2, 3};
std::vector<int> vec2 = vec1;  // 拷贝构造

如果std::vector中存储的是大量数据,这个拷贝操作可能非常耗时。

移动语义允许我们通过移动资源,而不是拷贝,从而避免了不必要的内存开销。

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

C++11 引入了移动构造函数移动赋值运算符,它们分别用于在构造对象和赋值对象时使用移动语义。

  • 移动构造函数:用于构造新对象时,从另一个对象“获取”资源,而不是拷贝。
  • 移动赋值运算符:用于对象赋值时,从另一个对象“获取”资源。

移动构造函数和移动赋值运算符的参数都是右值引用。下面是一个简单的例子:

#include <iostream>
#include <vector>

class MyClass {
public:
    std::vector<int> data;

    // 构造函数
    MyClass(std::vector<int>&& vec) : data(std::move(vec)) 
    {
        std::cout << "Move constructor" << std::endl;
    }

    // 移动赋值运算符
    MyClass& operator=(MyClass&& other) 
    {
        std::cout << "Move assignment" << std::endl;
        if (this != &other) 
        {
            data = std::move(other.data);
        }
        return *this;
    }
};

int main() 
{
    std::vector<int> vec1 = {1, 2, 3};
    MyClass obj1(std::move(vec1));   // 使用移动构造

    std::vector<int> vec2 = {4, 5, 6};
    MyClass obj2 = std::move(obj1);  // 使用移动赋值

    return 0;
}

输出:

Move constructor
Move assignment

在上面的代码中,我们使用std::movevec1从一个std::vector<int>移动到MyClass对象obj1中。然后,通过移动赋值将obj1的数据移动到obj2中。

  • std::move()是一个强制转换,将左值转换为右值,从而触发移动语义。
  • 在移动操作中,资源所有权被转移,源对象通常进入一个有效但不确定的状态。

1.3 移动语义的优点

  • 避免不必要的拷贝:通过移动构造函数和移动赋值运算符,可以避免不必要的拷贝,提高性能。
  • 提高效率:尤其在处理大型对象或容器时,移动语义可以显著减少内存的分配和复制操作。

(2)完美转发(Perfect Forwarding)

完美转发是一种技术,允许函数将参数精确地转发给另一个函数,无论这些参数是左值还是右值。完美转发确保了参数的值类别(左值或右值)被保持,从而避免了不必要的拷贝或错误的类型转换。

完美转发依赖于右值引用std::forwardstd::forward 是 C++11 引入的一个工具,用于保持传递参数的类型信息。

2.1 为什么需要完美转发?

当你编写一个函数模板并需要将参数传递给另一个函数时,你通常希望保持参数的值类别。如果参数是左值,应该以左值的形式传递给下一个函数;如果是右值,应该以右值的形式传递。如果不使用完美转发,可能会不小心对右值进行拷贝,导致性能损失。

2.2 完美转发的实现

完美转发通常通过模板函数和std::forward实现。

std::forward只会在参数为右值时进行右值转发,否则保持左值状态。

示例:

#include <iostream>
#include <utility>

template <typename T>
void forwardToOtherFunction(T&& arg) 
{
    otherFunction(std::forward<T>(arg));  // 完美转发
}

void otherFunction(int& x) 
{
    std::cout << "Left value: " << x << std::endl;
}

void otherFunction(int&& x) 
{
    std::cout << "Right value: " << x << std::endl;
}

int main()
{
    int a = 10;

    forwardToOtherFunction(a);         // 左值传递
    forwardToOtherFunction(20);        // 右值传递

    return 0;
}

输出:

Left value: 10
Right value: 20

在这个例子中,forwardToOtherFunction 接受一个转发参数 T&& arg。通过 std::forward<T>(arg),我们可以将参数的值类别(左值或右值)精确地转发给 otherFunction。如果传入左值,otherFunction(int&) 会被调用;如果传入右值,otherFunction(int&&) 会被调用。

2.3 完美转发的关键点

  • 右值引用和 std::forward:完美转发依赖于右值引用 (T&&) 和 std::forwardstd::forward 用于保持传递给函数的参数的值类别(左值或右值)。
  • 避免不必要的拷贝:通过完美转发,我们能够避免对右值的错误拷贝,同时确保函数能够正确处理参数的类型。

(3)总结

  • 移动语义(Move Semantics):通过右值引用和移动构造函数/移动赋值运算符,可以避免不必要的对象拷贝,提高程序的性能。使用std::move可以将左值转换为右值,从而触发移动操作。
  • 完美转发(Perfect Forwarding):完美转发允许我们精确地将函数参数传递给另一个函数,保持参数的值类别(左值或右值)。通过std::forward和右值引用,我们可以在转发时避免不必要的拷贝或类型转换。

这两者通常在泛型编程、库设计、以及性能优化中扮演重要角色。移动语义帮助我们高效地管理资源,完美转发则保证了我们在传递参数时既不丢失信息,又避免了性能损失。

5. volatile关键字

(1)volatile的作用

volatile关键字的主要作用是防止编译器进行优化。

通常,编译器会对代码进行优化,比如将某些变量缓存到寄存器中,从而避免频繁地访问内存。但在某些情况下,变量的值可能由外部硬件、信号、或者另一个线程修改,而编译器的优化可能会导致错误的结果。

所以,volatile关键字告知编译器:每次访问该变量时都需要从内存中读取它,而不是从寄存器缓存中读取

(2)volatile的应用场景

volatile关键字主要用于以下几种情况:

2.1 硬件寄存器映射

在嵌入式编程中,很多硬件寄存器的值可能随时发生变化,而这些变化是外部硬件控制的。编译器不能假设这些值是常数,需要每次访问时都从内存中读取。例如,嵌入式设备中的外部硬件状态寄存器。

volatile int *status_register = (int *)0x4000; // 硬件状态寄存器
if (*status_register == 1) 
{
    // 读取硬件寄存器并根据其值执行操作
}

2.2 多线程编程

在多线程程序中,一个线程可能会修改另一个线程正在使用的变量。为了确保每次读取的是最新的值,而不是编译器优化过的缓存值,通常会使用volatile。

volatile int flag = 0;  // 共享变量,可能由其他线程修改

void thread1() 
{
    while (flag == 0) 
    {
        // 等待 flag 变为 1
    }
    // flag 变为 1 后执行某些操作
}

void thread2() 
{
    // 在其他线程中改变 flag 的值
    flag = 1;
}

2.3 中断服务程序

在使用中断处理程序时,可能需要通过某个变量来传递中断标志或状态。如果不使用volatile,编译器可能会对该变量进行优化,导致程序无法正确响应中断。

volatile int interrupt_flag = 0;

void interrupt_handler() 
{
    interrupt_flag = 1;  // 中断服务程序设置标志
}

void main() 
{
    while (!interrupt_flag) 
    {
        // 等待中断
    }
    // 响应中断后的操作
}

(3)volatile和const的区别

  • volatile 告诉编译器该变量的值可能会随时改变,避免编译器优化。
  • const 告诉编译器该变量的值在程序执行期间不会改变,允许编译器进行优化。

这两个关键字的含义正好是相反的,const是告诉编译器该变量是常量,不会修改,而 volatile是告诉编译器该变量可能随时变化,不能缓存。

(4)volatile不会影响原子性

需要注意的是,volatile关键字并不会使得对该变量的访问变为原子操作。在多线程环境下,如果多个线程同时访问某个volatile变量,仍然可能出现竞争条件。因此,volatile不能代替同步机制。

(5)示例:volatile的使用

下面是一个具体的示例,演示如何在多线程环境下使用volatile来防止变量被优化掉:

#include <stdio.h>
#include <pthread.h>

volatile int flag = 0; // 共享变量,多个线程访问

void* thread1_func(void* arg) 
{
    while (flag == 0) 
    {
        // 等待 flag 被修改
    }
    printf("Thread 1: flag is now 1\n");
    return NULL;
}

void* thread2_func(void* arg) 
{
    flag = 1;  // 修改 flag 的值
    printf("Thread 2: flag set to 1\n");
    return NULL;
}

int main() 
{
    pthread_t thread1, thread2;

    // 创建两个线程
    pthread_create(&thread1, NULL, thread1_func, NULL);
    pthread_create(&thread2, NULL, thread2_func, NULL);

    // 等待线程结束
    pthread_join(thread1, NULL);
    pthread_join(thread2, NULL);

    return 0;
}

在这个例子中,volatile确保了thread1中的循环能够正确地读取到flag的最新值,从而避免了由于编译器优化导致的无限循环。

(6)总结

  • volatile关键字用于告诉编译器某个变量的值可能会被外部事件修改,防止编译器对其进行优化。
  • 它通常用于硬件寄存器访问、并发编程中的共享变量、以及中断服务程序等场景。
  • volatile不会使变量的访问变得原子化,所以它不能代替锁或其他同步机制来解决多线程同步问题。
  • 它与const相反const阻止修改变量,而volatile保证每次访问时都读取最新的值。

8. malloc关键字

malloc 是 C 标准库中的一个函数,用于动态分配内存。它从操作系统的堆内存中分配一块指定大小的内存区域,并返回一个指向该内存的指针。如果分配失败,malloc 会返回 NULL。理解 malloc 的实现原理可以帮助你更好地理解动态内存分配的底层机制,以及如何高效地管理内存。

(1)malloc的基本功能

在 C 中,malloc 函数的声明通常是:

void *malloc(size_t size);
  • size:要分配的内存块的大小(以字节为单位)。
  • 返回值:返回一个指向分配内存块的指针,类型为 void*,可以转换为任何指针类型。分配失败时返回 NULL

(2)malloc的底层实现原理

malloc 在操作系统级别的实现通常依赖于堆内存管理系统。堆内存是程序运行时动态分配内存的区域。为了高效地管理堆内存,malloc 通常依赖于以下几个技术原理:

2.1 内存池(Memory Pool)

内存池是 malloc 实现的核心。内存池是一个预先分配的内存区域,malloc 就是从这个区域中按需分配内存。内存池通常是一个大块的内存区域,分配内存时,malloc 会从池中“切割”出一部分。

内存池的管理:
  • 内存池的大小是固定的,通常由操作系统或者 C 运行时库(CRT)在程序启动时预先分配。
  • 每当 malloc 请求内存时,内存池管理器会查找足够大的空闲区域并返回给调用者。
  • 如果请求的内存大小大于池中的剩余空间,操作系统会通过系统调用(如 sbrkmmap)来请求更多的内存。

2.2 链表结构(Free List)

为了高效管理分配和释放的内存,malloc 通常使用链表(也叫做空闲链表,Free List)来存储空闲的内存块。

  • 每个空闲的内存块都有一个指针,指向下一个空闲块。这些空闲块按大小存储在一个链表中,或者使用其他数据结构来组织(如分隔的大小桶)。
  • malloc 需要分配内存时,它会扫描空闲链表,寻找合适大小的内存块。找到后,它将该内存块从链表中删除并返回给调用者。
  • free 被调用时,内存块会被放回空闲链表中,等待下次分配。

2.3 内存对齐

内存对齐是指将数据存储在内存中地址的边界上。不同的系统对内存的对齐要求不同,但通常是 4 字节或 8 字节对齐。malloc 会根据系统的对齐要求,调整分配的内存地址。

  • 例如,在 64 位系统上,malloc 返回的内存地址通常是 8 字节对齐的。
  • 对于较大的分配,malloc 可能会将内存池中的内存按对齐规则进行拆分。

2.4 大块内存的分配(大内存块的分配策略)

对于非常大的内存请求(如大于 128KB 的请求),malloc 不会从空闲链表中分配内存,而是直接向操作系统申请一块大内存区域。这个过程通常涉及到以下步骤:

  • 操作系统通过 sbrkmmap 系统调用向堆申请更多的内存区域。
  • 分配内存时,操作系统会为这些大块内存提供一个更为独立的管理方式,通常这些内存区域不参与空闲链表的管理。

2.5 内存碎片(Fragmentation)

随着内存分配和释放的进行,堆内存中的空闲内存块可能会变得非常分散。内存碎片问题通常分为两种:

  • 外部碎片:指的是空闲内存块分散在堆中的情况,导致没有足够的连续内存来满足较大的请求。
  • 内部碎片:指的是分配的内存块大于实际需要的内存,导致浪费一部分内存。

为了缓解碎片问题,malloc 可能会采取以下策略:

  • 合并相邻的空闲块:当 free 被调用释放内存时,malloc 会检查是否有相邻的空闲内存块,并将它们合并成一个大的空闲块,减少外部碎片。
  • 延迟释放内存malloc 可能会延迟某些内存块的释放,等到程序空闲时才进行整理和清理,以减少碎片。

(3)内存分配的流程

void* p = malloc(1024);
  • 用户调用malloc(n)后,向libc请求n字节内存。
  • libc判断内部内存池(heap)是否足够:
    • 足够:从已有内存池中分配。
    • 不够:调用系统调用申请新的内存页(通过 brkmmap)。
  • 分配:
    • 小块内存(<128KB):使用brk()分配。
    • 大块内存(>=128KB):使用mmap()分配。

  1. 查找合适的空闲内存块malloc 会查找空闲链表,尝试找到一个足够大的内存块。
  2. 分配内存块:如果找到合适的内存块,malloc 将其从链表中删除,并返回内存块的指针。如果内存块的大小足够大,它可能会被拆分为两个块(一个用于分配,另一个剩余的块保留在空闲链表中)。
  3. 系统调用(如果内存不足):如果没有合适的内存块,malloc 会调用操作系统的内存分配机制(如 sbrkmmap)来申请更多的内存。
  4. 返回指针malloc 返回内存块的起始地址给调用者。

brk():扩展程序的堆空间(heap)

(4)malloc底层实现的关键函数和系统调用

  • sbrk:一种老式的系统调用,操作系统通过它来管理进程的堆内存。sbrk 用于增加或减少进程的堆内存大小。现代的实现中,sbrk 已经不常使用,通常由 mmap 来替代。
  • mmap:现代操作系统中,malloc 通常使用 mmap 系统调用来分配大块内存。mmap 直接从操作系统请求内存区域,并将其映射到进程的虚拟内存空间中。

(5)free的实现原理

  • 将内存块放回空闲链表:当 free 被调用时,malloc 会将内存块标记为空闲,并将其插入空闲链表中。
  • 合并相邻空闲块:为了减少碎片,free 通常会尝试合并相邻的空闲块。如果释放的内存块的前后有空闲块,它们会被合并成一个更大的空闲块。
  • 返回内存池:对于大块内存,free 可能会将其返回操作系统,释放给操作系统管理。

(6)优化策略

  • 分离空闲块的链表(Segregated Free Lists):为了减少搜索时间,malloc 会将空闲块按大小分成多个链表,每个链表管理大小相似的内存块。这种策略可以加速内存分配和回收。
  • 内存池和区域化分配(Memory Pooling)malloc 可能会使用内存池(尤其是针对小块内存的分配)来提高性能,减少频繁的系统调用。
  • 延迟合并:为了避免频繁的合并操作,malloc 会在空闲内存块的合并上进行优化,避免碎片化的快速增长。

(7)总结

  • malloc 是一种基于内存池的动态内存分配函数,底层实现通常使用链表来管理空闲块,并通过系统调用(如 sbrkmmap)来申请大块内存。
  • malloc 分配内存的过程中可能涉及内存池管理、链表管理、内存对齐、内存碎片合并等机制。
  • free 会将释放的内存块插入空闲链表,并通过合并相邻的空闲块来减少内存碎片。
  • 了解 malloc 的底层原理能够帮助我们更好地理解内存管理的复杂性,并提高程序的性能和内存利用效率。

9. 四种强制类型转换

在 C++ 中,强制类型转换用于将一种类型的数据转换为另一种类型。C++ 提供了四种不同的强制类型转换方式,每种方式有不同的适用场景和行为。这四种类型转换是:

  1. static_cast
  2. dynamic_cast
  3. const_cast
  4. reinterpret_cast

(1)static_cast:常规类型转换

static_cast是最常用的类型转换,它用于在已知类型之间进行转换,比如在基类和派生类之间转换、数值类型之间转换等。

static_cast在编译时进行类型检查,因此它适用于静态转换,不会执行运行时检查。

用途:

  • 转换不同类型的指针或引用(包括基类和派生类之间的转换)。
  • 转换数据类型(如int到float,float到double等)。
  • 转换枚举类型和其他类型之间的转换。

注意事项:

  • static_cast不会检查指针转换是否安全,因此它适用类型关系已知且正确的情况。

示例:

#include <iostream>

class Base {
public:
    virtual void show() { std::cout << "Base class" << std::endl; }
};

class Derived : public Base {
public:
    void show() override { std::cout << "Derived class" << std::endl; }
};

int main() 
{
    Derived d;
    Base* basePtr = &d;

    // 静态类型转换,将 Base* 转换为 Derived*
    Derived* derivedPtr = static_cast<Derived*>(basePtr);
    derivedPtr->show();  // 输出: Derived class

    // 数值类型转换
    int i = 10;
    double dVal = static_cast<double>(i);  // int 到 double
    std::cout << dVal << std::endl;        // 输出: 10.0

    return 0;
}

(2)dynamic_cast:多态类型转换

dynamic_cast主要用于在继承体系中进行指针或引用的转换,特别是在多态类之间。

它用于安全地将基类指针或引用转换为派生类指针或引用。

dynamic_cast在运行时检查转换是否有效(比如检查指针是否有效,是否确实是目标类型),因此它更安全。

用途:

  • 用于安全地在类层次结构中转换指针或引用(特别是基类和派生类之间的转换)。
  • 如果转换无效,dynamic_cast返回nullptr(对于指针),或者抛出std::bad_cast异常(对于引用)。

注意事项:

  • dynamic_cast只能用于带有虚函数的类(即多态类),因为它需要运行时的类型信息(RTTI)。
  • 如果类型转换无效,返回nullptr(对于指针),抛出std::bad_cast(对于引用)。

示例:

#include <iostream>
#include <typeinfo>

class Base {
public:
    virtual void show() { std::cout << "Base class" << std::endl; }
};

class Derived : public Base {
public:
    void show() override { std::cout << "Derived class" << std::endl; }
};

int main() 
{
    Base* basePtr = new Base();
    Derived* derivedPtr = dynamic_cast<Derived*>(basePtr);

    if (derivedPtr) 
    {
        derivedPtr->show();
    } 
    else 
    {
        std::cout << "Conversion failed!" << std::endl;
    }

    delete basePtr;
    return 0;
}

(3)const_cast

const_cast用于修改对象的常量性。它可以用来移除或添加常量性(const或volatile)。

通过const_cast,可以将const数据类型转换为非const类型,或者反之。

用途:

  • 修改const或volatile修饰符。
  • 用于去除const限制,从而修改原本不可修改的对象。

注意事项:

  • const_cast只能改变类型的const属性,而不能改变对象的值
  • 不要通过const_cast去除const修饰符来修改常量对象,因为这可能导致未定义行为(例如尝试修改常量值)。
  • const_cast只能用于修改const或volatile属性,不能改变其他类型。

示例:

#include <iostream>

void modifyValue(const int* ptr) 
{
    int* modifiablePtr = const_cast<int*>(ptr);
    *modifiablePtr = 20;  // 修改值,警告:应该小心使用
}

int main() 
{
    const int x = 10;
    modifyValue(&x);  // 编译器允许,但会修改原本是 const 的值

    // 输出修改后的值,注意:这里有潜在的未定义行为
    std::cout << "Modified value: " << x << std::endl;  

    return 0;
}

(4)reinterpret_cast

reinterpret_cast是最危险的类型转换,它可以在任何两种类型之间进行转换,甚至是完全不相关的类型。

它通过直接对内存进行操作,常常用于低级别的指针类型转换(如将一个指针转换为整数,或将整数转换为指针等)。

用途:

  • 用于指针和整数之间的转换。
  • 用于完全不同类型之间的强制转换,常见于底层编程。

注意事项:

  • reinterpret_cast使得指针可以指向任何类型,但它是非常不安全的,因为它可能导致类型不一致,进而导致未定义行为。
  • 通常不建议在高层次应用中使用reinterpret_cast,除非你对底层内存布局有明确的了解,并且需要做一些特殊操作。

示例:

#include <iostream>

int main() 
{
    int a = 10;

    // 将 int* 转换为 float*,但它们类型不相关
    float* ptr = reinterpret_cast<float*>(&a);  
    
    // 输出的值不可预期,因为这种转换没有语义上的正确性
    std::cout << *ptr << std::endl; 
 
    return 0;
}

总结

  • static_cast:用于常规的类型转换,包括数值类型转换、指针类型转换等。
  • dynamic_cast:用于多态类型之间的安全转换,尤其是指针或引用的转换。会进行运行时检查。
  • const_cast:用于修改对象的常量性(const或volatile)。
  • reinterpret_cast:用于在两种完全不同类型之间进行强制转换,通常涉及指针和整数之间的转换,使用时要非常小心

10. 浅拷贝和深拷贝

浅拷贝(Shallow Copy)和深拷贝(Deep Copy)是对象进行复制时的两种不同方式,它们的主要区别在于对对象中引用类型成员的处理方式。

(1)浅拷贝(Shallow Copy)

浅拷贝是指创建一个新的对象,但并不递归地复制对象中引用类型的成员(如数组、列表、对象等)。相反,它会将引用类型的成员直接复制给新对象,即新对象和原对象共享对引用类型成员的引用。

特点:

  • 创建一个新对象,复制基本数据类型的字段(例如:整数、字符等),这些字段的值会被直接复制。
  • 对于引用类型的字段(如数组、对象等),浅拷贝只复制引用,而不是引用的内容。也就是说,原对象和新对象中的引用类型字段指向的是同一个对象

举个例子:

class Person
{
    public string Name;
    public int Age;
    public List<string> Hobbies;
}

Person original = new Person { Name = "Alice", Age = 30, Hobbies = new List<string> { "Reading", "Traveling" } };
Person shallowCopy = (Person)original.MemberwiseClone(); // 浅拷贝

shallowCopy.Name = "Bob";
shallowCopy.Hobbies.Add("Cooking");

Console.WriteLine(original.Name);     // 输出 "Alice"
Console.WriteLine(shallowCopy.Name);  // 输出 "Bob"
Console.WriteLine(string.Join(", ", original.Hobbies));                 // 输出 "Reading, Traveling, Cooking"
Console.WriteLine(string.Join(", ", shallowCopy.Hobbies));              
// 输出 "Reading, Traveling, Cooking"

在上面的例子中,Name字段的修改不会影响原对象,因为它是一个基本类型。而Hobbies是一个引用类型,在浅拷贝之后,originalshallowCopy共享相同的Hobbies列表。因此,当你向shallowCopy.Hobbies添加新的爱好时,original.Hobbies也发生了变化。

(2)深拷贝(Deep Copy)

深拷贝是指创建一个新对象,并且递归地复制原对象中所有的字段,包括其中的引用类型字段。也就是说,新对象和原对象中的所有字段,甚至是引用类型字段的内容,都被完全独立地复制了。这样,新对象和原对象完全独立,任何对新对象的修改都不会影响原对象,反之亦然。

特点:

  • 创建一个新对象,复制所有字段的值,包括引用类型字段。
  • 对于引用类型字段,深拷贝会创建一个新的副本,而不是仅仅复制引用。这样原对象和新对象中引用类型的字段会指向不同的内存地址。

举个例子:

class Person
{
    public string Name;
    public int Age;
    public List<string> Hobbies;
}

Person original = new Person { Name = "Alice", Age = 30, Hobbies = new List<string> { "Reading", "Traveling" } };
Person deepCopy = new Person
{
    Name = original.Name,
    Age = original.Age,
    Hobbies = new List<string>(original.Hobbies)  // 创建Hobbies的副本
};

deepCopy.Name = "Bob";
deepCopy.Hobbies.Add("Cooking");

Console.WriteLine(original.Name);  // 输出 "Alice"
Console.WriteLine(deepCopy.Name);  // 输出 "Bob"
Console.WriteLine(string.Join(", ", original.Hobbies));                 // 输出 "Reading, Traveling"
Console.WriteLine(string.Join(", ", deepCopy.Hobbies));                 // 输出 "Reading, Traveling, Cooking"

在深拷贝的例子中,originaldeepCopyHobbies列表是独立的,修改deepCopy.Hobbies不会影响original.Hobbies

(3)浅拷贝 vs 深拷贝

特性浅拷贝(Shallow Copy)深拷贝(Deep Copy)
对象复制的方式仅复制对象的引用,引用类型的成员不被复制复制对象及所有引用类型的成员,生成独立的副本
引用类型成员引用类型成员指向同一个内存地址引用类型成员也被复制,指向不同的内存地址
性能较快,因为不需要递归复制引用类型的成员较慢,因为需要递归复制所有引用类型的成员

什么时候使用浅拷贝,什么时候使用深拷贝?

  • 浅拷贝:适用于你只关心复制对象的基本数据类型字段,或者当你希望新对象和原对象共享某些引用类型的资源时。例如,你希望多个对象共享一个列表或数组。
  • 深拷贝:适用于你希望创建一个完全独立的对象副本,避免新对象与原对象共享引用类型成员的修改时。这通常用于需要完全独立的对象副本的场景,例如某些数据结构的备份或需要独立操作的对象集合。

C#中的拷贝方法:

  • MemberwiseClone():这是C#中的一个浅拷贝方法,用于复制对象,但它只能复制对象本身,不会递归地复制引用类型的字段。
  • 手动实现深拷贝:深拷贝通常需要手动实现,可以通过在对象中递归地复制引用类型字段来实现深拷贝。也可以使用序列化方法(如将对象序列化为字节流,再反序列化回新的对象)来实现深拷贝。

11. void*指针

在C和C++中,void*是一种特殊的指针类型,称为“空指针”或“无类型指针”。它的作用是指向任何类型的数据,但它本身不拥有数据的类型信息,因此在使用时需要进行类型转换。

(1)void*的基本概念:

void* 是一种指向未知类型的指针,意味着它可以指向任何数据类型的对象,但不能直接解引用它,因为它没有具体的类型信息。

void* ptr;
int a = 10;
ptr = &a;  // void* 可以指向任何类型的对象

(2)为什么使用void*?

void*指针常用于以下场景:

  • 通用数据结构: 当你设计一个可以容纳不同数据类型的结构或函数时,可以使用void*来通用地处理数据。比如,很多库(如C标准库中的malloc)会返回void*,因为它能指向任何类型的数据。
  • 函数参数: 一些函数希望能够处理不同类型的对象,void*提供了一种方便的方式。例如,某些回调函数可能需要处理任意类型的对象。

(3)如何使用void*:

void*指针可以指向任何类型的数据,但是,直接解引用一个void*指针是不允许的,因为编译器无法知道它指向的是什么类型的数据。

为了安全地使用void*,通常需要将其强制类型转换为目标类型的指针。

示例:

#include <iostream>

void printValue(void* ptr, char type) 
{
    if (type == 'i') 
    {
        // 如果是整数类型,强制转换为int指针
        std::cout << *(static_cast<int*>(ptr)) << std::endl;
    } 
    else if (type == 'f') 
    {
        // 如果是浮点数类型,强制转换为float指针
        std::cout << *(static_cast<float*>(ptr)) << std::endl;
    }
}

int main() 
{
    int a = 10;
    float b = 3.14f;

    void* ptr = &a;
    printValue(ptr, 'i'); // 输出 10

    ptr = &b;
    printValue(ptr, 'f'); // 输出 3.14

    return 0;
}

在上面的例子中,printValue函数接受一个void*指针,并根据传递的类型信息将其强制转换为正确的类型(int*float*)进行解引用。

(4)void*的常见用法:

4.1. 内存分配:

许多内存分配函数(如malloccalloc)返回void*,因为它们不会预先知道分配的内存应该存储哪种类型的数据。通过void*,你可以将返回值转换为适当的类型。

int* arr = (int*)malloc(10 * sizeof(int));  // 分配10个整数的内存
if (arr != nullptr) 
{
    for (int i = 0; i < 10; ++i) 
    {
        arr[i] = i;  // 使用数组
    }
    free(arr);  // 释放内存
}

4.2. 回调函数:

void*通常用作回调函数的参数,以便传递任意类型的用户数据。

#include <iostream>

void process(void* data) 
{
    int* p = static_cast<int*>(data);  // 转换为正确类型
    std::cout << "Processing data: " << *p << std::endl;
}

int main() 
{
    int value = 42;
    process(&value);  // 将整数的地址传递给回调函数
    return 0;
}

4.3. 通用容器:

在实现一些通用容器(例如链表或栈)时,void*指针可以用来存储不同类型的元素,而不需要为每种类型都实现单独的容器。

#include <iostream>

struct Node 
{
    void* data;
    Node* next;
};

void printInt(void* data) 
{
    std::cout << *(static_cast<int*>(data)) << std::endl;
}

int main() 
{
    int a = 10;
    Node node;
    node.data = &a;
    printInt(node.data);  // 输出 10
    return 0;
}

(5)void*的限制:

  • 类型安全: 使用void*指针时,必须小心进行类型转换,错误的类型转换可能导致未定义的行为(如内存损坏、访问错误等)。
  • 不可直接解引用: 不能直接对void*进行解引用,因为编译器不知道指针指向的数据类型。你必须先将其转换为正确的类型。

(6)与其他指针类型的比较:

  • void*char*int*等类型的指针相比,具有更大的通用性,但也因此失去了类型信息,增加了错误发生的风险。
  • 其他类型的指针通常只会指向一种特定类型的数据,而void*可以指向任意类型的数据。

(7)总结:

  • void*是一种通用的指针类型,可以指向任何类型的数据,但需要强制转换为适当的类型才能使用。
  • 它在需要处理多种类型数据、内存管理和回调机制等场景中非常有用,但使用时需要谨慎,确保进行正确的类型转换以避免错误。

12. Inline函数

在类(class)、结构体(struct)或联合体(union)的定义体中,如果直接定义了一个函数的完整实现,那么该函数会自动被当作隐式的inline函数处理,即使你没有显式写inline关键字。

13. 智能指针

智能指针是现代 C++ 提供的资源自动释放机制,解决内存管理、安全、异常处理、资源共享等一系列问题。

(1)shared_ptr:共享所有权指针

特点:

  • 可以有多个shared_ptr管理同一资源,每创建一个shared_ptr拷贝,对象的引用计数+1
  • 引用计数(内部控制块)主要用于管理资源释放:当所有shared_ptr都销毁后,引用计数归 0,对象自动销毁。。
  • 常用于多个地方共享同一个对象,它会自动管理其内存时使用。

举例:在堆上动态分配一个类MyClass的对象,shared_ptr拥有它的生命周期,多个shared_ptr指向同一资源,当最后一个shared_ptr被销毁时,这个对象也会被销毁

#include <memory>
#include <iostream>

class MyClass {
public:
    MyClass() { std::cout << "Construct\n"; }
    ~MyClass() { std::cout << "Destruct\n"; }
    void SayHello() { std::cout << "Hello!\n"; }
};

int main() 
{
    std::shared_ptr<MyClass> ptr1 = std::make_shared<MyClass>();
    std::shared_ptr<MyClass> ptr2 = ptr1; // 引用计数 +1
    std::cout << "Use count: " << ptr1.use_count() << "\n"; // 输出 2

    ptr1->SayHello();
} // 最后一个 shared_ptr 析构时才释放资源

如何解决shared_ptr循环引用的问题?

shared_ptr的循环引用(cyclic reference)是一个非常常见也非常隐蔽的问题,尤其是涉及多个对象相互持有shared_ptr的时候。

当两个对象互相持有shared_ptr指向对方时,就会发生循环引用,即:

  • 它们互相增加了引用计数;
  • 彼此都不会先释放;
  • 最终导致内存泄漏(destructor 永远不会被调用)。
#include <iostream>
#include <memory>

class B; // 提前声明

class A {
public:
    std::shared_ptr<B> b_ptr;
    ~A() { std::cout << "A destroyed\n"; }
};

class B {
public:
    std::shared_ptr<A> a_ptr;
    ~B() { std::cout << "B destroyed\n"; }
};

int main() 
{
    auto a = std::make_shared<A>();
    auto b = std::make_shared<B>();

    a->b_ptr = b;
    b->a_ptr = a;

    // 退出作用域时,A和B互相引用 -> 引用计数永远不是0
    return 0;
}

为了解决这个问题,可以让一方持有的智能指针变为weak_ptr:

class B {
public:
    std::weak_ptr<A> a_ptr;  // 改为 weak_ptr,不增加引用计数
    ~B() { std::cout << "B destroyed\n"; }
};

一般来说:

  • 父对象(容器)持有子对象的 shared_ptr
  • 子对象持有父对象的 weak_ptr

(2)weak_ptr:弱引用指针

特点

  • 不拥有对象,只是观察一个由shared_ptr管理的对象是否存在。
  • 不增加引用计数,当对象销毁时,weak_ptr会自动失效。
  • 常用于观察者模式或缓存系统中,避免循环引用。
  • 可以在在你需要“弱引用”一个对象但不希望它的存在依赖于你时使用。

用法:

std::shared_ptr<Scene> scene = std::make_shared<Scene>();
std::weak_ptr<Scene> weakScene = scene;

if (auto shared = weakScene.lock()) 
{
    // 成功:shared 是 shared_ptr,说明对象还活着
} 
else 
{
    // 失败:对象已被销毁
}

举例1:

struct Scene 
{
    void Print() const { std::cout << "Scene is alive!\n"; }
};

int main() 
{
    std::shared_ptr<Scene> sharedScene;

    // 创建一个 Scene 并用 shared_ptr 拥有它
    sharedScene = std::make_shared<Scene>();

    // weak_ptr 只是观察,不增加引用计数
    std::weak_ptr<Scene> weakScene = sharedScene;

    std::cout << "shared_ptr use count: " << sharedScene.use_count() << "\n"; 

    // weak_ptr 无法直接使用,需要先 lock()
    if (auto lockedScene = weakScene.lock()) 
    {
        lockedScene->Print(); // 输出: Scene is alive!
    } 
    else 
    {
        std::cout << "Scene has expired.\n";
    }

    // 删除 shared_ptr,Scene 被销毁
    sharedScene.reset();

    return 0;
}

举例2:

#include <memory>
#include <iostream>

class MyClass {
public:
    MyClass() { std::cout << "Construct\n"; }
    ~MyClass() { std::cout << "Destruct\n"; }
    void SayHello() { std::cout << "Hello!\n"; }
};

int main() {
    std::weak_ptr<MyClass> weak;
    {
        std::shared_ptr<MyClass> shared = std::make_shared<MyClass>();
        weak = shared;

        if (auto locked = weak.lock()) {
            locked->SayHello(); // 通过 lock 得到 shared_ptr
        }
    } // shared 析构后,资源释放

    if (weak.lock() == nullptr) {
        std::cout << "Object expired\n";
    }
}

(3)unique_ptr:唯一所有权指针

特点:

  • 独占资源,不能拷贝,只能移动
  • 离开作用域后,自动释放资源。
  • 是最轻量、最安全的智能指针。

举例:

#include <memory>
#include <iostream>

class MyClass {
public:
    MyClass() { std::cout << "Construct\n"; }
    ~MyClass() { std::cout << "Destruct\n"; }
    void SayHello() { std::cout << "Hello!\n"; }
};

int main() 
{
    std::unique_ptr<MyClass> ptr = std::make_unique<MyClass>();
    ptr->SayHello();

    // std::unique_ptr<MyClass> ptr2 = ptr; // ❌ 编译错误,不能拷贝
    std::unique_ptr<MyClass> ptr2 = std::move(ptr); // ✅ 可以移动
    if (!ptr) std::cout << "ptr is null\n";
}

unique_ptr是如何实现独占资源的?

unique_ptr的本质是一个类,但它的拷贝构造函数和赋值构造函数被定义为delete,因此它只支持移动构造和赋值。

(4)auto_ptr(不再使用)

该智能指针在C++11中已废弃,在C++17被移除。

它会造成以下问题:

  • 拷贝构造会转移所有权(而不是共享),行为隐晦。
  • 在 STL 容器中使用极易引发错误。

(5)智能指针可以解决的问题

内存泄漏(Memory Leak):手动使用 new 分配内存但忘记 delete,导致堆内存永远无法释放。

void foo() 
{
    // 没有delete,内存泄漏!
    int* ptr = new int(10);
    // 使用unique_ptr,离开作用域时自动释放内存
    std::unique_ptr<int> ptr = std::make_unique<int>(10);
}

异常安全:函数抛出异常时,可能没执行到 delete,导致资源泄漏。

void risky() 
{
    int* p = new int(5);
    risky_operation();  // 抛出异常
    delete p;           // 永远执行不到 ❌,使用unique_ptr可以解决
}

悬空指针(Dangling Pointer):对象被释放,但指针仍然指向那块无效内存,继续访问会造成未定义行为。使用智能指针,销毁资源时会把自己的指针置空(或禁止再使用),避免误操作。

int* p = new int(10);
delete p;
*p = 100;  // 悬空指针 ❌

资源共享与引用计数:使用shared_ptr自动管理资源生命周期。

(6)总结

智能指针C++版本所属头文件特点是否共享所有权是否自动释放资源
std::unique_ptrC++11<memory>独占所有权,不能拷贝
std::shared_ptrC++11<memory>引用计数共享所有权
std::weak_ptrC++11<memory>不拥有资源,观察 shared_ptr🚫(非拥有)
std::auto_ptr(已废弃)C++98(C++11中废弃)<memory>拥有所有权,拷贝时转移⚠️(不安全)

智能指针只适用于堆上对象,因为栈内存的生命周期是自动的

  • 栈上对象的生命周期由作用域自动控制。
  • 栈上对象一旦出了作用域,系统自动析构、回收,不需要也不应该交由智能指针管理。

14. 静态库与动态库

(1) 静态库(Static Library)

定义与特点:

  • 对函数库代码,静态库会在编译时期就将其编译进可执行文件中。
  • 编译器会直接把静态库中需要的代码复制进最终的.exe.out文件。
  • 静态库运行快,但是浪费空间和资源,因为所有相关文件与函数库都被链接成可执行文件。
  • 在更新的时候需要重新编译,导致需要用户更新全部应用程序,即:全量更新
  • 文件后缀:.lib

(2) 动态库(Dynamic Library)

定义与特点:

  • 对于函数库,动态库会在程序运行时再加载(即:运行期链接)。
  • 编译器只记录库的“引用地址”,真正的库内容在程序运行时从外部加载。如果不同的应用程序调用相同的动态库,则在内存中仅需要一份该共享库的实例。
  • 动态库可以实现进程之间的资源共享。
  • 在更新的时候,只需要更新动态库即可,即:增量更新
  • 文件后缀:.dll

(3) 对比

特点对比:

特性静态库 .a/.lib动态库 .so/.dll
绑定时间编译时运行时
可执行文件体积大(包含库代码)小(库分离)
运行时依赖无(不需要额外文件)有(需要动态库文件)
更新库是否需重编译否(直接替换 .so/.dll)
加载速度稍慢(需加载动态库)
是否可共享内存否(每个程序拷贝一份)是(多个程序共享同一库)
隐藏实现一般更强(可以延迟加载)
使用复杂度稍高(需设置路径等)

使用场景对比:

场景建议使用库类型
小程序、无模块复用静态库(方便)
大型项目,多个程序复用模块动态库(节省空间)
需要热更新、替换库逻辑动态库(无需重编译)
关注启动速度、安全性静态库(无需依赖)
封装跨平台 SDK动态库(可抽象接口)

Leave a comment