# i、右值引用

# 概述

右值引用是 C++11 引入的一种新的引用类型,用于支持移动语义完美转发。与传统的左值引用(左值引用)不同,右值引用绑定到临时对象将要销毁的对象或者显式转换为右值引用的对象。右值引用的特点是可以接管资源,并实现高效的资源移动操作。

在 C++ 中,我们可以通过在类型名称前添加 && 来声明右值引用。例如, int&& 表示一个右值引用类型的整数。

右值引用的一些关键概念包括:

  1. 左值(Lvalue):左值是指一个具有标识符的、可寻址的对象。它可以出现在赋值表达式的左边或右边,并且具有持久的状态。左值引用(左值的引用)可以绑定到左值。
  2. 右值(Rvalue):右值是指一个临时的、无法寻址的对象。它通常是一个临时表达式的结果,如常量、临时对象、将要销毁的对象等。右值引用可以绑定到右值。
  3. 移动语义(Move Semantics):移动语义是通过右值引用实现的一种特性,允许将资源(如堆内存)从一个对象移动到另一个对象,而不是进行复制。移动语义可以提高性能,避免不必要的内存拷贝和资源分配。
  4. 完美转发(Perfect Forwarding):完美转发是指在函数模板中以原样传递参数,既不进行拷贝也不进行移动,保持其原始类型。通过使用右值引用和模板参数推导,可以实现完美转发,将参数传递给下游函数,保持参数的值类别(左值或右值)和常量性。

右值引用的引入使得 C++ 语言能够更好地处理资源管理和移动语义,避免不必要的数据拷贝,提高程序的效率和性能。它在移动语义、完美转发、智能指针等方面发挥了重要的作用。

# 移动语义

当涉及到资源管理或对象传递时,移动语义完美转发可以提供更高效的操作和灵活性。

移动语义: 移动语义允许将资源(如堆内存)从一个对象移动到另一个对象,而不是进行复制。这可以避免不必要的内存拷贝和资源分配,提高程序的效率。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include <iostream>
#include <vector>

class MyString {
private:
char* m_data;

public:
MyString(const char* str) {
// 分配内存并复制字符串
size_t length = strlen(str);
m_data = new char[length + 1];
strcpy(m_data, str);
std::cout << "Constructor: " << m_data << std::endl;
}

// 移动构造函数
MyString(MyString&& other) noexcept {
// 直接接管资源
m_data = other.m_data;
other.m_data = nullptr;
std::cout << "Move Constructor: " << m_data << std::endl;
}

~MyString() {
delete[] m_data;
}
};

int main() {
MyString str1("Hello"); // 调用构造函数

MyString str2(std::move(str1)); // 调用移动构造函数
// 此时str1不再拥有资源,而是被str2接管了

return 0;
}

在上面的示例中,我们定义了一个简单的字符串类 MyString ,它包含了一个动态分配的字符数组。通过移动构造函数,我们可以直接将资源从一个对象移动到另一个对象,而不需要进行额外的内存拷贝。在 main 函数中,我们创建了 str1str2 两个对象,通过 std::movestr1 的资源移动给了 str2 。这样,资源的所有权从 str1 转移到了 str2 ,并在程序结束时正确释放。

# 完美转发

完美转发允许以原样传递参数,既不进行拷贝也不进行移动,保持其原始类型。 通过使用右值引用模板参数推导,可以实现完美转发,将参数传递给下游函数,保持参数的值类别和常量性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>
#include <utility>

void processValue(int& value) {
std::cout << "Lvalue: " << value << std::endl;
}

void processValue(int&& value) {
std::cout << "Rvalue: " << value << std::endl;
}

template <typename T>
void forwardValue(T&& value) {
processValue(std::forward<T>(value));
}

int main() {
int x = 42;
forwardValue(x); // 传递左值,调用 processValue(int&)
forwardValue(123); // 传递右值,调用 processValue(int&&)
forwardValue(std::move(x)); // 传递右值引用,调用 processValue(int&&)
return 0;
}

在上面的示例中,我们定义了两个重载的 processValue 函数,一个接受左值引用,另一个接受右值引用。然后,我们使用模板函数 forwardValue 来实现完美转发。在 forwardValue 函数中,我们使用 std::forward 来保持参数的值类别(左值或右值)和常量性,并将参数传递给下游函数 processValue 。通过完美转发,我们可以在保持原始参数类型的同时,将参数传递给适当的处理函数。

通过移动语义和完美转发,我们可以更高效地管理资源和实现灵活的参数传递。这些特性在处理大型对象、容器元素和函数传参时特别有用,可以避免不必要的拷贝和资源分配,提高程序的性能和效率。

# 追问

1、移动构造函数中的 noexcept 有何作用,是否一定需要?

noexcept 在移动构造函数中的作用是指定该函数是否可能抛出异常。使用 noexcept 关键字可以提供编译器优化的机会,因为它使得在移动构造函数中执行更轻量级的操作,如移动资源的所有权,而无需进行异常处理。这样可以提高程序的性能。然而, noexcept 并不是必需的,你可以选择是否在移动构造函数中使用它,具体取决于你的需求。

2、调用 std::move 后的变量是否不能够再次被使用?

调用 std::move 后的变量仍然可以被使用,但是它的状态会发生变化。 std::move 将变量转换为右值引用,这意味着它可以被移动而不是复制。移动后的变量的状态通常是不确定的,你不应该对其进行操作或访问其值。它通常被用于将资源所有权转移给其他对象,或作为参数传递给接受右值引用的函数。在移动后,你可以重新赋值给它或销毁它。重要的是要记住, std::move 仅仅是改变了变量的类型,而不会对其值进行任何修改

# i、C++ 内存分布

在 C++ 程序中,内存可以划分为以下几个主要区域:

  1. 栈(Stack):
    • 栈位于内存的较高地址部分。
    • 栈用于存储函数的局部变量、函数参数、函数调用信息等。
    • 栈的分配和释放是由编译器自动管理的,具有自动内存管理的特性。
    • 栈的大小在程序运行时是固定的。
  2. 堆(Heap):
    • 堆位于内存的较低地址部分。
    • 堆用于动态分配内存,由程序员手动管理。
    • 堆的分配和释放需要使用特定的函数(如 newdeletemallocfree 等)进行操作。
    • 堆的大小在程序运行时可以动态变化。
  3. 全局区(Global Area):
    • 全局区也称为静态区或数据段。
    • 全局区存储全局变量、静态变量和常量。
    • 全局区在程序运行期间一直存在,直到程序结束。
    • 全局区的大小在编译时确定。
  4. 常量区(Constant Area):
    • 常量区也称为文字常量区或只读数据区。
    • 常量区存储字符串常量和其他常量数据。
    • 常量区的数据是只读的,不能被修改。
    • 常量区在程序运行期间一直存在,直到程序结束。
  5. 代码区(Code Area):
    • 代码区也称为文本区或只读代码区。
    • 代码区存储程序的执行代码。
    • 代码区的数据是只读的,不能被修改。
    • 代码区在程序运行期间一直存在,直到程序结束。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
+-----------------------+
| 栈 (Stack) |
| |
| |
| |
+-----------------------+
| 堆 (Heap) |
| |
| |
| |
+-----------------------+
| 全局区 (Global) |
| |
| |
| |
+-----------------------+
| 常量区 (Constant) |
| |
| |
| |
+-----------------------+
| 代码区 (Code) |
| |
| |
| |
+-----------------------+

# i、C++ 内存模型

# 概述

C++ 内存模型是描述 C++ 程序在执行过程中,内存访问和操作的规则和保证的规范。它定义了多线程环境下的原子性操作、内存顺序、可见性等行为,以确保多线程程序的正确性和可预测性。

下面是 C++ 内存模型中的几个重要概念:

  1. 原子性(Atomicity):原子操作是不可被中断的单个操作,要么完全执行,要么不执行。C++ 提供了原子操作库(std::atomic),用于在多线程环境下实现原子操作,保证对共享变量的读写操作的原子性。
  2. 内存顺序(Memory Order):内存顺序规定了多个线程对共享变量的读写操作的顺序。C++ 提供了一组枚举类型(如 std::memory_order_relaxedstd::memory_order_acquirestd::memory_order_release 等),用于指定不同的内存顺序要求。
  3. 可见性(Visibility):可见性指的是一个线程对共享变量的修改对其他线程是否可见。C++ 内存模型确保了原子操作的可见性,即一个原子操作对其他线程是立即可见的。
  4. Happens-Before 关系:Happens-Before 关系是 C++ 内存模型中的一个重要概念,它用于描述不同操作之间的顺序关系。如果一个操作 A Happens-Before 于另一个操作 B,那么在多线程环境下,操作 A 的结果对操作 B 是可见的。Happens-Before 关系可以由同步操作(如互斥锁、原子操作等)和特定的内存顺序关系来建立。

C++ 内存模型提供了一系列的规则和保证,以帮助程序员编写正确且具有可移植性的多线程程序。它规定了各种操作之间的可见性和顺序关系,并提供了原子操作和内存顺序的机制,使得程序在多线程环境下能够正确地进行内存访问和操作。

需要注意的是,C++ 内存模型并不是操作系统的内存管理模型,它更关注于程序在多线程环境下的内存访问规则和行为。对于底层的内存管理细节(如页面管理、缓存一致性等),C++ 内存模型通常依赖于操作系统和硬件的支持。

# 内存顺序

当多个线程并发地访问和修改共享变量时,内存顺序定义了这些操作之间的顺序关系。C++ 中的内存顺序通过枚举类型 std::memory_order 来指定。

  1. std::memory_order_relaxed (松散顺序): std::memory_order_relaxed 是最宽松的内存顺序,它不对任何内存操作进行顺序限制。对于使用 std::memory_order_relaxed 的操作,编译器和处理器可以对其进行重排序,且不会对其他线程产生任何顺序上的约束。
  2. std::memory_order_acquire (获取顺序):稍后解释。
  3. std::memory_order_release (释放顺序):稍后解释。

当多个线程并发地读写共享变量时,使用 std::memory_order_acquirestd::memory_order_release 可以确保读写操作之间的顺序关系和可见性。

std::memory_order_acquire 用于读操作,它具有以下特性:

  1. 在使用 std::memory_order_acquire 进行读操作时,该读操作之前的所有读写操作都不会被放置在该读操作之后。这意味着,使用 std::memory_order_acquire 读取的值不会是之前的写操作的过期值。
  2. std::memory_order_acquire 会建立 Happens-Before 关系,使得该读操作之前的写操作对当前线程可见。这意味着,在使用 std::memory_order_acquire 进行读操作后,当前线程能够观察到在该读操作之前发生的写操作所引入的更改。
  3. std::memory_order_acquire 并不会限制该读操作之后的任何操作的顺序,其他线程可以继续并发地读写共享变量。
    相比于 std::memory_order_relaxed ,使用 std::memory_order_acquire 可以提供更强的内存顺序保证,确保了读操作的顺序关系和可见性。

std::memory_order_release 用于写操作,它具有以下特性:

  1. 在使用 std::memory_order_release 进行写操作时,该写操作之后的所有读写操作都不会被放置在该写操作之前。这意味着,使用 std::memory_order_release 写入的值不会影响之后的读写操作的顺序。
  2. std::memory_order_release 会建立 Happens-Before 关系,使得该写操作对其他线程的读操作可见。这意味着,在使用 std::memory_order_release 进行写操作后,其他线程能够观察到该写操作所引入的更改。
  3. std::memory_order_release 并不会限制该写操作之前的任何操作的顺序,其他线程可以继续并发地读写共享变量。

通过使用 std::memory_order_acquirestd::memory_order_release ,可以在多线程环境下实现同步原语,例如互斥锁或读写锁。一个线程在写入共享变量之前,使用 std::memory_order_release 进行写操作,而另一个线程在读取共享变量之前,使用 std::memory_order_acquire 进行读操作。这样可以确保数据的一致性和可见性,防止数据竞争和不确定行为的发生。

需要注意的是, std::memory_order_acquirestd::memory_order_release 是成对使用的。当一个线程使用 std::memory_order_release 顺序进行写操作时,另一个线程使用 std::memory_order_acquire 顺序进行相应的读操作,以建立 Happens-Before 关系。单独使用这两个内存顺序是不够的,要实现正确的同步,需要遵循正确的使用模式和配对操作。

这里需要注意的是, std::memory_order_acquirestd::memory_order_release 是一对配套使用的内存顺序,用于实现同步原语,例如互斥锁、读写锁等。当一个线程使用 std::memory_order_release 顺序进行写操作时,另一个线程使用 std::memory_order_acquire 顺序进行相应的读操作,这样可以建立 Happens-Before 关系,确保数据的正确同步。

std::memory_order_acquirestd::memory_order_release 的主要目的是确保读操作和写操作之间的顺序关系和可见性,并防止编译器和处理器对其进行重排优化。

简单来说,为了确保同步,读用 std::memory_order_acquire ,写用 std::memory_order_release 即可。

# i、全局变量与 static

# 全局变量

全局变量是定义在函数外部、整个源文件都可以访问的变量。它具有全局作用域和静态生存期,意味着它在整个程序的执行过程中都存在,并且可以被程序中的任何函数所使用。

全局变量的特点包括:

  1. 作用域:全局变量的作用域从定义处开始一直延伸到文件的末尾,整个源文件中的任何函数都可以访问这个变量。
  2. 生命周期:全局变量在程序启动时分配内存,在程序结束时释放内存,因此它们的生命周期与整个程序的运行时间一样长。
  3. 默认初始化:如果全局变量没有显式地初始化,那么它们会被默认初始化。
  4. 存储位置:全局变量的存储位置通常位于静态存储区,在程序启动时分配,程序结束时释放。因此,全局变量的内存空间在整个程序的执行过程中都是固定的。
  5. 可见性:全局变量在整个源文件中都是可见的,因此在不同的函数中可以共享数据。但是,过度使用全局变量会导致代码的可读性和维护性降低,因此应该尽量避免滥用全局变量,尤其是在大型项目中。

全局变量的定义和声明一般放在源文件的顶部,在任何函数外部,使用关键字 extern 可以在其他源文件中引用全局变量。虽然全局变量具有方便性,但也容易引起命名冲突和程序设计的复杂性,因此在实际编程中需要谨慎使用。

# 静态全局变量

如果在全局变量的定义前加上 static 关键字进行修饰,则该变量就成为 静态全局变量静态全局变量的作用域仅限于当前文件,不能被其他文件访问。

静态全局变量的特点包括:

  1. 作用域:与全局变量相同,静态全局变量的作用域也从定义处开始一直延伸到文件的末尾,但它只能在当前文件内部访问,其他文件无法访问。
  2. 生命周期:静态全局变量在程序启动时分配内存,在程序结束时释放内存,生命周期与整个程序的执行过程一样长。
  3. 默认初始化:与全局变量相同,如果静态全局变量没有显式地初始化,它们会被默认初始化。
  4. 存储位置:静态全局变量也存储在静态存储区,在程序启动时分配,程序结束时释放,内存空间在整个程序的执行过程中都是固定的。
  5. 可见性:静态全局变量仅在当前文件内可见,不能被其他文件引用,因此不会引起命名冲突。

由于静态全局变量的作用域受限于当前文件,因此它们通常被用于实现文件内部的全局状态或共享数据,同时避免了全局变量可能引发的命名冲突问题。

# 全局变量与静态成员变量

假设我现在有一个 tool.cpp 和 tool.h 文件,其中我定义并实现了 Tool 这个类。
那么,对于某些变量,出于某些共享的需求,我既可以把它设置为全局变量,也可以把它设置成静态成员变量,这两种方式有什么区别?

在 C++ 中,你可以通过将变量设置为全局变量或将其设置为类的静态成员变量来实现共享的需求,但这两种方式有一些区别:

  1. 作用域

    • 全局变量的作用域是整个源文件,其他源文件也可以通过 extern 关键字来引用全局变量。(如果希望其只对 Tool 类可见,可添加 static 关键字使其局限在该 tool.cpp 文件中)
    • 类的静态成员变量的作用域是整个类,可以通过 类名::静态成员变量名 来访问。
  2. 可见性

    • 全局变量的可见性是整个源文件,其他源文件也可以通过 extern 关键字来引用全局变量。
    • 类的静态成员变量的可见性是类的范围内,只有类的成员函数和友元函数可以直接访问静态成员变量。
  3. 命名空间

    • 全局变量属于全局命名空间,可能会导致命名冲突问题,特别是在大型项目中。
    • 类的静态成员变量属于类的命名空间,可以通过类名限定来避免命名冲突。
  4. 初始化

    • 全局变量在整个程序启动时会被初始化,如果没有显式地初始化,则会被默认初始化。
    • 类的静态成员变量需要在类外部进行初始化,可以在类的定义外部进行初始化,也可以在类的实现文件中进行初始化。

综上所述,如果变量需要在多个源文件中共享,则通常选择全局变量;如果变量与类密切相关,并且只需要在类的范围内共享,则选择类的静态成员变量更为合适。
(比如两个源文件要进行线程同步,那么可选择使用全局变量的互斥锁、条件变量。)

# i、静态成员函数

在 C++ 中,静态成员函数是属于类而不是对象实例的函数。它们与类关联,而不是与类的具体实例关联。

# 定义:

1
2
3
4
5
6
7
8
9
10
11
12
class MyClass {
public:
// 静态成员函数
static void staticFunction() {
// 可以访问静态成员和其他静态成员函数
}

// 非静态成员函数
void nonStaticFunction() {
// 可以访问非静态成员和其他成员函数
}
};

# 调用:

1
MyClass::staticFunction(); // 直接通过类名调用,而不需要创建类的实例

# 特点和用途:
  • 与类关联:
    静态成员函数与类关联,而不是与类的具体实例关联。因此,它们不访问任何特定对象的成员,也不能使用非静态成员变量或函数

  • 无 this 指针:
    静态成员函数没有隐含的 this 指针,因为它们不与特定的对象实例相关联。这使得它们在不涉及实例数据的情况下执行更为灵活。(如果涉及,则需要进行绑定)

  • 访问静态成员:
    静态成员函数可以访问类的静态成员变量和其他静态成员函数。这使得它们适用于处理与类而不是与对象实例相关的操作。

  • 全局访问点:
    静态成员函数常用于提供一个全局访问点,例如工厂方法或全局配置设置。

# i、野指针调用类的成员函数

在 C++ 中,当使用野指针(悬空指针)调用类的成员函数时,会发生未定义行为。野指针是指指向已释放的内存或者未初始化的内存的指针。使用野指针访问对象的成员函数可能导致程序崩溃或者产生其他不可预测的结果。

示例代码中的野指针就是将指针 pa 初始化为 nullptr,然后尝试通过它调用类 A 的成员函数 func ()。在这种情况下,由于 pa 指针指向了一个未定义的内存地址(nullptr),调用 func () 会导致未定义行为,这可能会导致程序崩溃。

在编写 C++ 代码时,应该始终确保指针的有效性,避免使用空指针和野指针。在使用指针之前,最好将其初始化为 nullptr,并在使用前进行有效性检查。可以使用条件语句或者智能指针来确保指针指向有效的对象,从而避免悬空指针的问题。

追问:经过我的实践,野指针应该是仍然可以调用到类的成员函数的,只不过其结果是未定义的,是危险的。

实际上,当野指针调用类的成员函数时,确实可以执行函数体内的代码,因为在编译时,编译器只需要知道函数的地址就可以调用它,不会检查指针是否为 nullptr 或者是否指向有效的对象。因此,代码在运行时仍然会执行类的成员函数,但是由于指针是悬空的,因此这样的行为是未定义的,可能会导致程序崩溃或者产生其他不可预测的结果。

虽然野指针调用类的成员函数可能不会立即导致程序崩溃,但这样的行为是非常危险的,可能会导致严重的后果,例如内存访问错误、数据损坏等。因此,应该始终避免使用空指针和野指针,并在使用指针之前进行有效性检查,以确保指针指向有效的对象。

追问:但编译器是怎么知道成员函数的地址的呢?又或者说,类的成员函数存储在哪里?

编译器在编译阶段会为类的成员函数生成代码,并将这些成员函数存储在程序的可执行文件中的代码段(text segment)中。成员函数的地址是由编译器在编译时决定的,通常在编译后的可执行文件中,成员函数会被编译成与类的对象无关的静态函数(static function),这意味着它们不依赖于任何特定的对象实例。

当调用类的成员函数时,实际上是通过类的对象(或类的指针 / 引用)来调用的,编译器会将对象的地址作为隐式的参数传递给成员函数,从而让成员函数知道自己是属于哪个对象的。在函数体内,成员函数可以通过 this 指针来访问类的成员变量和其他成员函数。

由于成员函数存储在代码段中,它们的地址在程序运行时是固定的。当通过类的对象(或指针 / 引用)调用成员函数时,编译器会在运行时使用正确的地址来调用相应的函数。

需要注意的是,对于虚函数(virtual function),编译器会通过虚函数表(vtable)来实现动态绑定,使得在运行时可以根据实际对象的类型来调用正确的虚函数。虚函数表是一个由编译器生成的数据结构,其中存储了每个虚函数的地址。这样,当通过指向基类的指针或引用调用虚函数时,会根据对象的实际类型来查找正确的虚函数地址。

# i、委托构造函数

#CPP 新特性 #CPP11
委托构造函数是 C++11 引入的一个特性,它允许一个构造函数调用同一个类中的另一个构造函数,从而避免代码重复。通过委托构造函数,可以在构造过程中重用已有的构造函数代码。

即,当在一个类中定义多个构造函数,并且其中一些构造函数有共同的初始化逻辑时,委托构造函数可以帮助你避免重复编写相同的初始化代码。

简单举例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Point {
public:
// 假设我们在构造函数中都需要做一系列相同的初始化操作
// 比如这里我们简单举例,我们需要计算 x + y 的值

Point() : x(0), y(0) {
// 默认构造函数
int sum = x + y;
}

Point(int xCoord, int yCoord) : x(xCoord), y(yCoord) {
// 带参数构造函数
int sum = x + y;
}

Point(int value) : x(value), y(value) {
// 参数为相同值的构造函数
int sum = x + y;
}

private:
int x;
int y;
};

那么上面的代码就是会造成很多冗余,尤其是当 int sum = x + y; 所简化的代码实际上是很多复杂的初始化操作的时候。于是我们就可以用到委托构造函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Point {
public:
Point() : Point(0, 0) { // 这里调用另一个构造函数
// 默认构造函数委托给带参数构造函数
}

Point(int xCoord, int yCoord) : x(xCoord), y(yCoord) {
// 带参数构造函数
int sum = x + y;
}

Point(int value) : Point(value, value) { // 这里调用另一个构造函数
// 参数为相同值的构造函数委托给带参数构造函数
}

private:
int x;
int y;
};

如上所示,通过委托函数,不再需要每个构造函数都执行 int sum = x + y; 减少了代码的冗余.

委托构造函数指的是主动发起调用的那个构造函数,也就是在构造函数内部通过委托调用另一个构造函数来共享初始化逻辑。被委托调用的构造函数称为目标构造函数。

通过使用委托构造函数,我们避免了重复编写相同的初始化代码。当我们改变构造函数的初始化逻辑时,只需要修改一处,所有委托该逻辑的构造函数都会自动更新。

需要注意以下几点:

  • 委托构造函数的初始化列表中只能调用同一个类中的其他构造函数,而不能调用基类的构造函数或其他类的构造函数。
  • 委托构造函数的参数列表必须与被调用构造函数的参数列表匹配。
  • 委托构造函数只能在成员初始化列表中调用另一个构造函数,不能在函数体中调用
  • 委托构造函数不需要包含构造函数的实际初始化操作,因为它会调用被委托的构造函数来完成初始化。

再举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class MyClass {
public:
MyClass() : MyClass(0) {
// 委托给其他构造函数
// 可以在委托构造函数中执行一些通用的初始化操作
}

MyClass(int value) : member(value) {
// 实际构造函数,完成特定的初始化操作
}

private:
int member;
};

int main() {
MyClass obj1; // 调用委托构造函数
MyClass obj2(42); // 调用实际构造函数
return 0;
}

委托构造函数的函数体中的代码先执行还是委托构造函数的初始化列表中的目标构造函数先执行?

在委托构造函数中,委托构造函数的初始化列表中的目标构造函数会先执行,然后才会执行委托构造函数的函数体中的代码。

这是因为委托构造函数的目的是将初始化工作委托给另一个构造函数完成,因此首先会执行目标构造函数的初始化列表中的代码,以确保对象的成员变量得到正确的初始化。然后,委托构造函数的函数体中的代码会在目标构造函数初始化之后执行。

以下是一个示例来说明这个顺序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>

class MyClass {
public:
MyClass() : MyClass(0) {
std::cout << "委托构造函数的函数体中的代码" << std::endl;
}

MyClass(int value) : member(value) {
std::cout << "目标构造函数的初始化列表中的代码" << std::endl;
}

private:
int member;
};

int main() {
MyClass obj; // 输出顺序:目标构造函数 -> 委托构造函数的函数体
return 0;
}

在上述示例中,对象 obj 的创建过程中,== 首先会执行目标构造函数的初始化列表中的代码,然后执行委托构造函数的函数体中的代码。== 这是因为目标构造函数负责实际的初始化操作,而委托构造函数负责调用目标构造函数并共享初始化逻辑.

总结一下,委托构造函数的好处是提高了代码的可维护性和可读性,避免了重复编写相同的初始化代码,同时确保所有构造函数都使用了一致的初始化逻辑。这对于类的构造函数重用和管理非常有帮助。

实际工程中遇到的委托构造函数的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class [[nodiscard]] thread_pool
{
public:
thread_pool() : thread_pool(0, [] {}) {}

explicit thread_pool(const concurrency_t num_threads) : thread_pool(num_threads, [] {}) {}

explicit thread_pool(const std::function<void()>& init_task) : thread_pool(0, init_task) {}

thread_pool(const concurrency_t num_threads, const std::function<void()>& init_task) : thread_count(determine_thread_count(num_threads)), threads(std::make_unique<std::thread[]>(determine_thread_count(num_threads)))
{
create_threads(init_task);
}

...
}

# i、using 和 typedef 的区别

#CPP 新特性 #CPP11
using 别名是 C++11 标准引入的特性,它提供了更多的灵活性和可读性。使用 using 别名可以轻松地为现有类型创建别名,甚至可以为模板类型创建别名。现代 C++ 更倾向于使用 using,更加直观明了。

使用 typedef 的方式:

1
2
typedef int MyInt; // 创建 int 的别名 MyInt
typedef std::vector<int> IntVector; // 创建 std::vector<int> 的别名 IntVector

使用 using 的方式:

1
2
using MyInt = int; // 创建 int 的别名 MyInt
using IntVector = std::vector<int>; // 创建 std::vector<int> 的别名 IntVector

using 别名有一些明显的优势:

  1. 可读性更强: using 别名的语法更加自然,易于理解。它直观地表达了创建类型别名的意图。
  2. 支持模板别名: using 别名支持为模板类型创建别名,而 typedef 在处理模板类型时可能会显得复杂。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    template <typename T>
    using MyVector = std::vector<T>; // 为 std::vector<T> 创建一个别名 MyVector

    int main() {
    MyVector<int> numbers; // 使用 MyVector 作为 std::vector<int> 的别名
    numbers.push_back(42);
    numbers.push_back(73);

    for(const auto& num : numbers) {
    std::cout << num << " ";
    } std::cout << std::endl;

    return 0;
    }
  3. auto 关联性更强: using 别名与 C++11 中引入的 auto 关联性更强,使得在使用类型推断时更加便利。

特别地,如果是对结构体 struct 的别名,两个关键字的语法分别如下:

1
2
3
4
5
6
7
8
9
typedef struct {
int x;
int y;
} Point;

typedef struct {
float width;
float height;
} Size;

1
2
3
4
5
6
7
8
9
using Point = struct {
int x;
int y;
};

using Size = struct {
float width;
float height;
};

# i、cast 类型转换

在 C++ 中,类型转换(Type Casting)是一种将一个数据类型的值转换为另一个数据类型的过程。C++ 提供了几种不同类型的类型转换,可大致分为隐式转换和显式转换,显式转换又包含:C 风格转换、 static_castdynamic_castconst_castreinterpret_cast

1. static_cast

  • 用途: static_cast 主要用于执行静态类型转换,可以在合理范围内进行类型转换。它用于处理通常是安全的、定义良好的转换。
  • 示例:
    1
    2
    int integer = 42;
    float floatingPoint = static_cast<float>(integer);

2. dynamic_cast

  • 用途: dynamic_cast 主要用于在继承层次结构中执行基类到派生类的安全转换。它在运行时进行类型检查,只有当对象实际上是目标类型的派生类时,才会执行转换。通常与多态相关。
  • 示例:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    class Base { virtual void foo() {} };
    class Derived : public Base { void foo() {} };

    Base* basePtr = new Derived;
    Derived* derivedPtr = dynamic_cast<Derived*>(basePtr);
    if (derivedPtr) {
    // 转换成功,derivedPtr 指向 Derived 类型的对象
    } else {
    // 转换失败,basePtr 不是 Derived 类型的对象
    }

3. const_cast

  • 用途: const_cast 用于去除变量的 const 修饰符,允许修改原本被视为只读的对象。它通常用于修复旧代码或在需要时更改数据的常量属性。
  • 示例:
    1
    2
    3
    const int readOnlyValue = 42;
    int& readWriteReference = const_cast<int&>(readOnlyValue);
    readWriteReference = 100; // 合法,修改了原本的只读变量

4. reinterpret_cast

  • 用途: reinterpret_cast 用于执行低级别的转换,通常用于处理指针和引用之间的转换。这是最不安全的类型转换,因此应谨慎使用。
  • 示例:
    1
    2
    3
    int integerValue = 42;
    int* intPointer = &integerValue;
    double* doublePointer = reinterpret_cast<double*>(intPointer);
# static_cast 和 dynamic_cast 的区别和联系

区别
static_cast

  1. 静态类型转换static_cast 是一种在编译时进行类型转换的操作。它对于已知的、合法的转换非常有用,如整数到浮点数、指针之间的类型转换等。
  2. 非安全性static_cast 不执行运行时类型检查,因此在进行类型转换时,开发者需要确保转换是安全的,否则可能导致未定义的行为。
  3. 用途:主要用于基本的类型转换,如整数之间的转换、指针类型的转换,以及在继承层次结构中进行向上或向下的类型转换。

dynamic_cast

  1. 动态类型转换dynamic_cast 是一种在运行时进行类型转换的操作,主要用于处理继承层次结构中的类型转换。
  2. 安全性dynamic_cast 执行运行时类型检查,确保只有当对象实际上是目标类型的派生类时,才会执行转换。如果类型不匹配,它返回 nullptr (对于指针类型)或抛出 std::bad_cast 异常(对于引用类型)。
  3. 用途:主要用于处理多态性,以安全地进行基类到派生类的类型转换。它在运行时提供了类型检查,可用于确保只有正确类型的对象才会进行转换。

联系

  1. static_castdynamic_cast 都用于类型转换,但它们的用途和时机不同。 static_cast 主要用于基本类型和向上 / 向下的继承层次结构中,而 dynamic_cast 主要用于多态性继承中的安全类型转换。
  2. static_cast 是在编译时执行的,因此更快,但不提供类型检查。 dynamic_cast 是在运行时执行的,提供了类型检查,但可能更慢。
  3. 在多态性和继承的情况下,应优先选择 dynamic_cast 以确保类型安全。 static_cast 更适用于普通的类型转换,如整数之间的转换。
  4. static_castdynamic_cast 都需要开发者谨慎使用,以避免不安全的类型转换和潜在的错误。

在编写代码时,应根据具体情况和安全性需求来选择使用哪种类型转换方式。如果不确定,最好选择更安全的 dynamic_cast

# i、C++ 内部链接

内部链接(internal linkage)是 C++ 语言中的一个概念,用于描述声明或定义的实体在编译单元内部可见,而在其他编译单元中不可见的特性。

具有内部链接的实体只能在其定义所在的编译单元(通常是一个源文件)内部访问,而无法在其他编译单元中进行访问。这样做的目的是将实体限制在特定的作用域内,从而提高代码的模块化性和封装性。

在 C++ 中,可以使用两种方式来实现内部链接:

  1. 未命名命名空间(unnamed namespace):通过将实体放置在未命名命名空间中,可以确保这些实体仅在当前编译单元中可见,而无法在其他编译单元中访问。

    1
    2
    3
    namespace {
    ...
    } // 未命名 namespace

  2. static 关键字:将函数或变量声明为 static,可以使它们具有内部链接。这意味着它们只能在当前编译单元中访问,而不能在其他编译单元中使用。

内部链接是 C++ 编程中常用的技术之一,用于控制实体的可见性和访问范围,有助于提高代码的可维护性和安全性。

# i、extern 关键字

extern 是 C++ 中的一个关键字,用于说明变量或函数的链接性(linkage)和作用域(scope)。它可以用在不同的上下文中,通常用于以下两个方面:

1. 链接性(Linkage):
extern 用于说明一个变量或函数是在其他文件中定义的,而不是当前文件中定义的。它告诉编译器在链接时在其他文件中查找这个变量或函数的定义。在 C++ 中,变量和函数默认情况下具有外部链接性,也就是可以在其他文件中使用。但如果你想明确地指定一个变量或函数为外部链接性,你可以使用 extern 关键字。例如:

1
2
3
4
5
// 声明一个全局变量 x,该变量在其他文件中定义
extern int x;

// 声明一个函数 foo,该函数在其他文件中定义
extern void foo();

这些声明告诉编译器在链接时查找变量 x 和函数 foo 的定义。

特别地,我们会用到 extern C 来告诉编译器使用 C 语言的函数链接性。
extern "C" 是一种用于修改函数链接性和名称修饰的用法,通常与 C 和 C++ 混合编程时使用。它有以下作用:

a. 函数链接性:
在 C++ 中,函数默认会被名称修饰(name-mangling),以便支持函数重载。这导致 C++ 函数的名称在目标文件中不再是原始的函数名。然而,C 语言不使用名称修饰,因此在 C 和 C++ 混合编程时,需要确保 C++ 函数能够与 C 函数进行链接。使用 extern "C" 可以告诉编译器使用 C 语言的函数链接性。
b. 函数名称修饰:
在 C++ 中,函数名称会根据参数的类型和个数进行修饰,以支持函数重载。而 C 语言不支持函数重载,因此函数名称不会被修饰。使用 extern "C" 可以防止 C++ 对函数名称进行修饰,使函数名与 C 语言一致。

下面是一个示例:

1
2
3
4
5
6
7
8
9
// 声明一个C函数
extern "C" {
void c_function(int arg);
}

// C++函数
void cpp_function(int arg) {
// ...
}

在上面的示例中, c_function 声明使用了 extern "C" ,这表示它的链接性和名称修饰与 C 语言兼容。这样,C 和 C++ 代码可以正确链接并一起工作。

2. 作用域(Scope):
extern 也可以用于指示一个变量或函数的作用域是全局的,即它可以在程序的任何地方访问。通常,全局变量和函数默认具有全局作用域,但 extern 可以用于强调这一点。例如:

1
2
// 声明全局变量 y 具有全局作用域
extern int y;

这个声明表示变量 y 具有全局作用域,可以在整个程序中访问。

总之, extern 是一个用于说明变量或函数链接性和作用域的关键字,它在 C++ 中通常用于与其他文件共享变量或函数的定义。

# i、dlopen、dlsym 和 dlclose

通过  dlopen()  函数以指定模式加载指定的动态链接库,并返回一个句柄。

dlsym()  可通过 dlopen()  返回的句柄来调用指定的函数。

通过  dlclose()  来卸载已加载的动态库。

dlopen() 的函数原型:

1
void *dlopen(const char* pathname, int mode);

pathname 是动态库的路径。

mode 是打开方式,有多种,这里列举两个: RTLD_LAZY  执行延迟绑定。仅在执行引用它们的代码时解析符号。如果从未引用该符号,则永远不会解析它(只对函数引用执行延迟绑定。在加载共享对象时,对变量的引用总是立即绑定)。 RTLD_NOW  如果指定了此值,或者环境变量  LD_BIND_NOW  设置为非空字符串,则在  dlopen()  返回之前,将解析共享对象中的所有未定义符号。如果无法执行此操作,则会返回错误。

打开失败时返回 NULL,打开成功则返回该动态库的句柄。

dlsym() 的函数原型:

1
voiddlsym(void* handle, const char* symbol);

该函数的作用是根据动态链接库的操作句柄 (handle) 与符号 (symbol),返回符号对应的地址。这里的符号既可以是函数名,也可以是变量名。于是我们通过这个函数,就可以获取动态库中的函数或变量的地址,就可以调用动态库中的相关函数。

该函数的返回值  void*  指向函数的地址,供调用使用。

dlclose() 就没啥好说的了,卸载相应的动态库。

具体使用示例:

1
2
3
4
void *handle = dlopen(str.c_str(), RTLD_LAZY);
typedef void (*so_config)(int argc, char **argv);
so_config config_func = dynamic_cast<so_config>(dlsym(handle, "Config"));
config_func(ptr_len, ptr_ptr);

dlerror() 是一个用于获取动态链接库错误信息的函数,通常在使用动态链接库时发生错误时调用。该函数位于 <dlfcn.h> 头文件中,是 POSIX 标准的一部分。

当调用动态链接库相关函数(如 dlopen()dlsym()dlclose() 等)失败时,系统会设置一个全局错误状态,并通过 dlerror() 函数返回错误信息。如果前一个动态链接库相关函数调用成功,那么 dlerror() 将返回 NULL。

可以通过如下方式来使用 dlerror() 函数:

  1. 当一个动态链接库相关函数返回 NULL 时,首先调用 dlerror() 函数来确定是否有错误发生。
  2. 如果 dlerror() 返回非 NULL 指针,则表示有错误发生,可以通过返回的字符串来获取错误信息。
  3. 如果 dlerror() 返回 NULL,则表示前一个动态链接库相关函数调用成功,没有错误发生。

通过这种方式,可以在动态链接库加载、符号查找等过程中及时捕获并处理错误,提高程序的健壮性和可靠性。

# i、error 预处理指令

#error 是 C/C++ 预处理器的一条指令,用于在预处理阶段生成编译错误信息。当预处理器遇到 #error 指令时,它会输出指定的错误消息,并终止编译过程。这对于在编译时进行条件检查非常有用,例如版本检查、平台检查或配置检查。

1
2
3
#if (BS_THREAD_POOL_TEST_VERSION_MAJOR != BS_THREAD_POOL_VERSION_MAJOR)
#error The versions do not match. Aborting compilation.
#endif

# i、quiet_NaN

std::numeric_limits<float>::quiet_NaN() 返回表示 “静默 NaN(Not a Number)” 的浮点数。
NaN 是 IEEE 浮点数标准中定义的特殊值,用于表示不是数字的结果,通常用于错误处理或特殊情况下的标记。

这个函数返回的值是一个 float 类型的静默 NaN,它的位表示是特殊的,用于标记不是数字的情况。它不会抛出异常,因为它只是返回一个特定的值,不涉及任何运算或转换。

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <iostream>
#include <limits>

int main() {
// 获取 float 类型的 NaN
float nan = std::numeric_limits<float>::quiet_NaN();

// 检查 NaN 是否等于自身
if (nan != nan) {
std::cout << "nan is not equal to itself" << std::endl;
} else {
std::cout << "nan is equal to itself" << std::endl;
}

return 0;
}

在示例中,我们获取了 float 类型的 NaN,并检查它是否等于自身。
由于 NaN 的特性,它与自身不相等,因此程序输出 "nan is not equal to itself"。

# NaN 的打印

NaN 值是一个特殊的浮点数,它在打印时通常会显示为 "nan" (不区分大小写)。
因此,使用 std::cout 打印 std::numeric_limits<float>::quiet_NaN() 将会输出 "nan"

示例代码:

1
2
3
4
5
6
7
8
#include <iostream>
#include <limits>

int main() {
float nan = std::numeric_limits<float>::quiet_NaN();
std::cout << "NaN value: " << nan << std::endl;
return 0;
}

输出结果将会是:

1
NaN value: nan

# i、unordered_map/unordered_set 存储自定义类型

在使用 unordered_mapunordered_set 存储自定义结构体时,需要注意以下几点:

  1. 哈希函数和相等比较函数:
    默认情况下, unordered_mapunordered_set 需要对键值进行哈希计算和相等比较。对于内置类型,这些操作已经定义好了,但对于自定义结构体,需要提供这两个函数。
  2. 哈希函数:
    必须提供一个自定义的哈希函数来计算自定义结构体的哈希值。你可以通过定义一个结构体或者函数对象来实现,也可以使用 C++11 的 std::hash 模板特化。
  3. 相等比较函数:
    需要提供一个自定义的相等比较函数,以确保 unordered_mapunordered_set 能正确比较两个自定义结构体是否相等。可以通过重载 operator== 或者定义一个函数对象来实现。

以下是一个具体的示例,展示了如何为 testStruct 定义哈希函数和相等比较函数,使其可以用于 unordered_mapunordered_set

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
#include <iostream>
#include <unordered_map>
#include <unordered_set>

// 定义自定义结构体
struct testStruct {
int a;
float b;

// 重载 operator==,以便在哈希容器中进行比较
bool operator==(const testStruct& other) const {
return a == other.a && b == other.b;
}
};

// 自定义哈希函数
struct testStructHash {
std::size_t operator()(const testStruct& ts) const {
std::size_t h1 = std::hash<int>()(ts.a);
std::size_t h2 = std::hash<float>()(ts.b);
// 组合两个哈希值
return h1 ^ (h2 << 1);
}
};

// 用于测试的 main 函数
int main() {
// 使用自定义结构体作为键的 unordered_map
std::unordered_map<testStruct, std::string, testStructHash> umap;
testStruct ts1 = {1, 1.1f};
testStruct ts2 = {2, 2.2f};

umap[ts1] = "test1";
umap[ts2] = "test2";

for(const auto& pair : umap) {
std::cout << "Key: (" << pair.first.a << ", " << pair.first.b << "), Value: " << pair.second << std::endl;
}

// 使用自定义结构体的 unordered_set
std::unordered_set<testStruct, testStructHash> uset;
uset.insert(ts1);
uset.insert(ts2);

for(const auto& elem : uset) {
std::cout << "Element: (" << elem.a << ", " << elem.b << ")" << std::endl;
}

return 0;
}

在这个示例中,我们做了以下几步:

  1. 定义 testStruct 并重载 operator== : 以便在哈希容器中进行结构体比较。
  2. 定义 testStructHash 结构体:用于计算 testStruct 的哈希值。我们使用了 std::hashintfloat 类型进行哈希计算,并将它们组合起来。
  3. main 函数中测试:使用 unordered_mapunordered_set 存储和访问 testStruct

这样, testStruct 就可以顺利地用于 unordered_mapunordered_set 中。

为什么不使用 lambda 表达式来实现哈希,而是要采用结构体?
为什么说 lambda “可读性和扩展性相对较低”,请你具体举例说明?

用 lambda 表达式定义哈希函数确实可以简洁地实现某些功能,但在某些情况下,其可读性和扩展性相对于结构体方式可能会稍显不足。下面具体举例说明其不足之处:

  • 可读性问题
  1. 代码量较多时可读性下降:
    当哈希计算逻辑复杂时,lambda 表达式可能会变得难以阅读。例如:

1
2
3
4
5
6
auto complexHash = [](const testStruct& ts) -> std::size_t {
std::size_t h1 = std::hash<int>()(ts.a);
std::size_t h2 = std::hash<float>()(ts.b);
std::size_t h3 = std::hash<int>()(ts.a + static_cast<int>(ts.b * 100));
return h1 ^ (h2 << 1) ^ (h3 << 2);
};

相对于这种复杂的 lambda 表达式,使用结构体来封装会使逻辑更清晰:

1
2
3
4
5
6
7
8
struct testStructHash {
std::size_t operator()(const testStruct& ts) const {
std::size_t h1 = std::hash<int>()(ts.a);
std::size_t h2 = std::hash<float>()(ts.b);
std::size_t h3 = std::hash<int>()(ts.a + static_cast<int>(ts.b * 100));
return h1 ^ (h2 << 1) ^ (h3 << 2);
}
};

  • 扩展性问题
  1. 不能直接存储状态:
    lambda 表达式本身不太适合存储状态。如果哈希函数需要存储一些额外的数据,使用结构体会更方便。例如:

1
2
3
4
5
6
7
8
9
10
11
struct testStructHashWithState {
int someState;

testStructHashWithState(int state) : someState(state) {}

std::size_t operator()(const testStruct& ts) const {
std::size_t h1 = std::hash<int>()(ts.a);
std::size_t h2 = std::hash<float>()(ts.b);
return (h1 ^ (h2 << 1)) + someState;
}
};

用 lambda 表达式存储状态则相对麻烦:

1
2
3
4
5
6
7
8
int someState = 42;
auto hashFn = [someState](const testStruct& ts) -> std::size_t {
std::size_t h1 = std::hash<int>()(ts.a);
std::size_t h2 = std::hash<float>()(ts.b);
return (h1 ^ (h2 << 1)) + someState;
};

std::unordered_map<testStruct, std::string, decltype(hashFn)> umap(10, hashFn);

  • 模板特化的灵活性

如果需要在模板特化中定义哈希函数,使用结构体会更自然。示例:

1
2
3
4
5
6
7
8
9
10
11
12
namespace std {
template <>
struct hash<testStruct> {
std::size_t operator()(const testStruct& ts) const {
std::size_t h1 = std::hash<int>()(ts.a);
std::size_t h2 = std::hash<float>()(ts.b);
return h1 ^ (h2 << 1);
}
};
}

std::unordered_map<testStruct, std::string> umap;

  • 代码复用

结构体的方式便于代码复用和维护。例如,如果哈希计算逻辑需要在多个地方使用,用结构体封装的哈希函数可以直接复用,而不用在每个地方都写一次 lambda 表达式:

1
2
std::unordered_map<testStruct, std::string, testStructHash> umap1;
std::unordered_set<testStruct, testStructHash> uset1;

如果用 lambda 表达式,每个地方都需要定义一次:

1
2
3
4
5
6
7
8
auto hashFn = [](const testStruct& ts) -> std::size_t {
std::size_t h1 = std::hash<int>()(ts.a);
std::size_t h2 = std::hash<float>()(ts.b);
return h1 ^ (h2 << 1);
};

std::unordered_map<testStruct, std::string, decltype(hashFn)> umap1(10, hashFn);
std::unordered_set<testStruct, decltype(hashFn)> uset1(10, hashFn);

  • 总结
    尽管 lambda 表达式在简单情况下非常方便,但当涉及复杂逻辑、状态存储、代码复用以及模板特化时,结构体的方式更具优势。因此,综合考虑可读性和扩展性,结构体定义方式通常是更好的选择。

这里重载 operator== 的作用是什么,为什么不用重载大于 > 或者小于 < ?

在 C++ 中, operator==operator!= 通常用于相等性比较,而 operator<operator> 等用于排序。对于 std::unordered_mapstd::unordered_set 来说,使用哈希表实现,因此需要的是相等性比较而不是排序比较。

std::unordered_mapstd::unordered_set 使用哈希表来存储元素。当我们插入、查找或删除元素时,这些容器会先根据哈希值(由哈希函数计算得出)将元素分配到某个桶中,然后在桶内使用 operator== 来比较元素是否相等。因此,为了在这些容器中使用自定义类型 testStruct ,我们必须重载 operator== 以定义两个 testStruct 对象何时被认为是相等的。

1
2
3
4
5
6
7
8
9
class testStruct {
public:
int a = 0;
float b = 0;

bool operator==(const testStruct& other) const {
return a == other.a && b == other.b;
}
};

  • 为什么不用 operator<operator>

operator<operator> 主要用于排序比较,在需要排序功能的容器如 std::mapstd::set 中会用到。这些容器是基于红黑树等平衡二叉树实现的,因此需要知道两个元素之间的顺序关系。

但是, std::unordered_mapstd::unordered_set基于哈希表实现的,哈希表不需要排序元素,只需要将元素正确地放入桶中并能正确地比较桶内的元素是否相等。因此,在这种情况下,重载 operator<operator> 是没有意义的,哈希表并不会使用这些操作符。

  • 总结
  1. operator== 的作用operator== 用于定义两个 testStruct 对象何时被认为是相等的,这对于哈希表在桶内比较元素是必要的。
  2. 不用 operator<operator> std::unordered_mapstd::unordered_set 基于哈希表实现,不需要排序元素,因此不需要 operator<operator> 。这些操作符主要用于排序容器如 std::mapstd::set
# 标准示范

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
#include <iostream>
#include <unordered_map>
#include <unordered_set>
#include <map>
#include <set>

class MyStruct {
public:
int a = 0;
float b = 0;

// for unordered_map and unordered_set
bool operator==(const MyStruct& other) const {
return a == other.a && b == other.b;
}

// for map and set
bool operator<(const MyStruct& other) const {
return a < other.a && b < other.b;
}
};

// for unordered_map and unordered_set
struct MyHash {
// this should be double const
std::size_t operator()(const MyStruct& ms) const {
std::size_t h1 = std::hash<int>()(ms.a);
std::size_t h2 = std::hash<float>()(ms.b);
return h1 ^ (h2 << 1);
}
};



int main() {
std::unordered_map<MyStruct, int, MyHash> um;
um.clear();

MyStruct aa;
MyStruct bb;

um[aa] = 0;
um[bb] = 1;

std::unordered_set<MyStruct, MyHash> us;

us.insert(aa);
us.insert(bb);

std::set<MyStruct> s;
s.insert(aa);
s.insert(bb);

std::map<MyStruct, int> m;
m[aa] = 0;
m[bb] = 1;

return 0;
}

# i、std::unordered_map 的使用

unordered_map 是 C++ 标准库中的一种关联容器,它提供了一种无序、基于键值对的数据存储结构,具有快速的查找、插入和删除操作。 unordered_map 基于 哈希表 实现,可以在平均情况下以接近 O(1) 的时间复杂度进行元素的查找、插入和删除。

下面是 unordered_map 的一些常见用法:

  1. 创建 unordered_map 对象

    1
    2
    3
    4
    #include <unordered_map>

    std::unordered_map<int, std::string> myMap;
    // 创建一个键为int类型,值为std::string类型的unordered_map对象

  2. 插入元素

    1
    2
    myMap[1] = "value1"; // 插入键值对 <1, "value1">
    myMap.insert({2, "value2"}); // 使用insert方法插入键值对 <2, "value2">

    注意,如果是用 insert 的话,需要构造一个 std::pair 的对象。

  3. 访问元素

    1
    std::string value = myMap[1]; // 访问键为1的值,如果键不存在,则会插入一个默认构造的值

    注意!!!这种方式是存在副作用的,即 “键不存在时创建默认值”。
    如果我们需要通过 map 的 size 来决定代码的逻辑,则应该避免使用这种方式来访问元素,以免对 size 造成影响!

1
2
3
4
auto iter = myMap.find(2); // 使用find方法查找键为2的元素
if(iter != myMap.end()) {
std::string value = iter->second; // 访问找到的值
}

  1. 删除元素

    1
    myMap.erase(1); // 删除键为1的元素

  2. 遍历 unordered_map

    1
    2
    3
    for(const auto& pair : myMap) {
    std::cout << "Key: " << pair.first << ", Value: " << pair.second << std::endl;
    }

  3. 获取 unordered_map 的大小

    1
    int size = myMap.size(); // 获取unordered_map中键值对的个数

  4. 检查 unordered_map 是否为空

    1
    bool isEmpty = myMap.empty(); // 判断unordered_map是否为空

  5. 清空 unordered_map

    1
    myMap.clear(); // 清空unordered_map中的所有元素

  6. 使用自定义哈希函数
    如果 unordered_map 的键类型不是基本类型(如 intstd::string 等),或者需要自定义哈希函数,可以通过 unordered_map 的第三个模板参数指定自定义的哈希函数,例如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    struct MyKey {
    int id;
    std::string name;
    };

    struct MyKeyHash {
    std::size_t operator()(const MyKey& key) const {
    return std::hash<int>()(key.id) ^ (std::hash<std::string>()(key.name) << 1);
    }
    };

    std::unordered_map<MyKey, int, MyKeyHash> myMap; // 使用自定义的哈希函数

unordered_map 提供了丰富的功能,可以用于各种数据存储和处理场景,是 C++ 中非常常用的数据结构之一。


# i、实现一个二分查找算法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <iostream>
#include <vector>

// 自定义二分查找函数
template <typename T>
int binarySearch(const std::vector<T>& arr, const T& target) {
int low = 0;
int high = arr.size() - 1;

while(low <= high) {
int mid = low + (high - low) / 2;

if(arr[mid] == target) {
return mid; // 找到目标值,返回索引
} else if (arr[mid] < target) {
low = mid + 1; // 目标值在右半部分,调整区间的下界
} else {
high = mid - 1; // 目标值在左半部分,调整区间的上界
}
} return -1; // 没有找到目标值,返回 -1
}

int main() {
std::vector<int> arr = {2, 4, 6, 8, 10, 12, 14, 16, 18}; // 注意,二分法需要应用于已排序的数组
int target = 12;
int index = binarySearch(arr, target);

if(index != -1) {
std::cout << "Target found at index " << index << std::endl;
} else {
std::cout << "Target not found in the array" << std::endl;
}

return 0;
}

另外,STL 其实有封装二分查找的库,所以其实调个 API 就能实现了。见:binary_search

# i、用 C++ 实现 strcpy 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <iostream>

void strcpy(char* dest, const char* src) {
// 检查指针是否为空
if(dest == nullptr || src == nullptr) {
return;
}

// 复制字符串内容直到遇到空字符 '\0'
while(*src != '\0') {
*dest = *src;
dest++;
src++;
}

// 在目标字符串末尾添加空字符 '\0'
*dest = '\0';
}

int main() {
const char* source = "Hello, world!";
char destination[20];

strcpy(destination, source);

std::cout << "Copied string: " << destination << std::endl;

return 0;
}

在上述代码中,strcpy 函数接受两个参数, dest 表示目标字符串的指针, src 表示源字符串的指针。函数通过遍历源字符串的每个字符,逐个将其复制到目标字符串,直到遇到空字符 \0 ,表示字符串的结束。最后,将目标字符串的末尾设置为空字符 \0 ,以确保复制后的字符串正确终止。

# 内存踩踏

当源字符串指针(src)和目标字符串指针(dest)所指向的内存有重叠部分时,使用标准的 strcpy 函数或自定义的 strcpy 函数可能会导致意想不到的结果。

在 C++ 中,按照标准的行为, strcpy 函数不应该用于处理重叠内存区域的字符串复制。这是因为在重叠内存区域中,复制的操作可能会导致数据的不可预测的改变。

具体来说,如果在 strcpy 函数中源字符串和目标字符串的内存区域重叠,复制过程中的操作可能会覆盖尚未被复制的数据,导致数据损坏或不正确的复制结果。

为了处理重叠内存区域的字符串复制,可以使用 memmove 函数,它被设计为可以处理重叠内存区域的内存复制操作。 memmove 函数的实现可以通过使用指针操作来实现内存块的移动。下面是一个用 C++ 编写的简单 memmove 函数的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include <iostream>

void* memmove(void* dest, const void* src, size_t size) {
// 检查指针是否为空
if (dest == nullptr || src == nullptr) {
return nullptr;
}

// 创建临时缓冲区
char* temp = new char[size];

// 复制源内存块到临时缓冲区
const char* srcPtr = static_cast<const char*>(src);
char* tempPtr = temp;
for(size_t i = 0; i < size; ++i) {
*tempPtr++ = *srcPtr++;
}

// 将临时缓冲区的内容复制到目标内存块
char* destPtr = static_cast<char*>(dest);
tempPtr = temp;
for(size_t i = 0; i < size; ++i) {
*destPtr++ = *tempPtr++;
}

// 释放临时缓冲区
delete[] temp;

return dest;
}

int main() {
char source[] = "Hello, world!";
char destination[20];

std::cout << "Before memmove: " << destination << std::endl;

memmove(destination, source, sizeof(source));

std::cout << "After memmove: " << destination << std::endl;

return 0;
}

在上述代码中, memmove 函数接受三个参数:目标指针( dest )、源指针( src )和要复制的字节数( size )。函数首先创建一个临时缓冲区,然后将源内存块的内容复制到临时缓冲区,最后再将临时缓冲区的内容复制到目标内存块。

请注意,在实际编写代码时,需要考虑更多的边界条件和错误处理,以确保函数的正确性和安全性。此外,C++ 标准库也提供了 std::memmove 函数,它是一个更为优化和健壮的实现,建议在实际使用中使用标准库提供的函数。

可以在不使用额外临时缓冲区的情况下实现 memmove 函数。一种常见的方法是使用指针操作和适当的条件判断来处理重叠内存块的移动。

下面是一个不使用额外临时缓冲区的 memmove 函数的示例实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include <iostream>

void* memmove(void* dest, const void* src, size_t size) {
// 检查指针是否为空
if(dest == nullptr || src == nullptr) {
return nullptr;
}

char* destPtr = static_cast<char*>(dest);
const char* srcPtr = static_cast<const char*>(src);

// 判断重叠情况
if(srcPtr < destPtr) {
// 从后往前复制
for(size_t i = size; i > 0; --i) {
destPtr[i - 1] = srcPtr[i - 1];
}
} else {
// 从前往后复制
for(size_t i = 0; i < size; ++i) {
destPtr[i] = srcPtr[i];
}
}

return dest;
}

int main() {
char source[] = "Hello, world!";
char destination[20];

std::cout << "Before memmove: " << destination << std::endl;

memmove(destination, source, sizeof(source));

std::cout << "After memmove: " << destination << std::endl;

return 0;
}

在上述代码中, memmove 函数首先判断源内存块和目标内存块的位置关系。如果源指针在目标指针之前,则从后往前逐个复制数据;如果源指针在目标指针之后,则从前往后逐个复制数据。通过这种方式,可以确保正确处理重叠内存块的复制,而无需使用额外的临时缓冲区。

需要注意的是,这种实现方式在处理重叠内存块时可能需要更多的指针操作和条件判断,因此在性能上可能不如使用临时缓冲区的实现方式高效。在实际使用时,根据具体的情况选择适合的实现方式,权衡性能和内存使用的需求。

更新于

请我喝杯咖啡吧~

Rick 微信支付

微信支付

Rick 支付宝

支付宝