发布于: 2023-4-12最后更新: 2023-10-9字数 00 分钟

学习

2023年8月3日 一刷现代C++教程

语言语法

  • std::this_thread::sleep_for:睡眠。
  • alignas关键字指定的是最小对齐要求,而不是实际的对齐值。例如,如果你使用alignas(1)对一个int进行对齐,那么编译器可能仍然会选择4字节对齐,因为这可以提高内存访问的性能。

常量

nullptr

测试代码
NULL的定义
这段代码是针对 GNU 编译器的条件编译指令,它的作用是将 NULL 宏定义为 __null。__null 是 GNU C++ 扩展的一部分,它表示一个空指针常量,类似于 C++11 标准中的 nullptr。某些编译器可能不支持 nullptr,那么可以使用 GNU C++ 扩展中的 __null 代替。在这种情况下,如果编译器是 GNU 编译器,则将 NULL 宏定义为 __null,否则使用其他方式定义 NULL。

if/switch 变量声明强化

测试代码

结构化绑定

C++17 引入了一种新的语言特性,叫做 "结构化绑定"(Structured Binding)。这个特性允许你在单个声明中创建多个新的变量,这些变量可以用来接收从数组、元组或者结构体解构(destructure)出来的值。
以下是一些使用结构化绑定的示例:
  1. 从元组中解构值
在这个示例中,getSomeData 函数返回一个元组,然后我们使用结构化绑定一次性创建了三个变量 abc,并将元组中的值赋给了它们。
  1. 从数组中解构值
在这个示例中,我们从数组 arr 中解构出两个值,并赋给了变量 xy
  1. 从结构体中解构值
在这个示例中,我们从结构体 s 中解构出两个值,并赋给了变量 idname
结构化绑定是一种非常方便的特性,它可以让你更简洁、更清晰地从复合数据类型中解构出值。

右值引用

  • 右值引用
  • 引用坍缩(引用折叠)
  • 移动语义
  • 通用转发
  • 完美转发
右值引用
在 C++11 中,引入了右值引用的概念,用来支持移动语义和完美转发。右值引用可以绑定到一个右值(一种临时对象,例如函数返回值),然后从这个右值中"窃取"资源,避免不必要的复制。右值引用标记为 T&&
引用折叠(引用坍缩)
在模板和类型别名中,引用可以折叠。引用折叠的规则是:如果形成了引用的引用,它会折叠为一个普通引用。
移动语义
移动语义允许资源在对象之间转移,而不是进行昂贵的复制。这通过使用右值引用来实现,右值引用可以绑定到临时对象,然后从这个临时对象中窃取资源。
通用转发(Perfect Forwarding)
完美转发是指在模板函数中,以与模板参数被调用处完全相同的方式(即,保持其值类别(左值或右值)不变)将参数转发给其他函数。
在这个例子中,如果传递给 wrapper 的是左值,foo 会接收到一个左值;如果传递给 wrapper 的是右值,foo 会接收到一个右值。这就是 "完美转发"。
完美转发
完美转发是C++11中引入的一个特性,允许函数模板将其参数以原始的形式(无论是左值还是右值)转发给其他函数。这主要通过使用类型推导和右值引用来实现。
在这个例子中,forward_to函数模板接受一个函数和一组参数,然后将这些参数完美转发给这个函数。无论原始参数是左值还是右值,它们都会以原始的形式传递给函数。

移动语义

11中的移动语义。声明一个类,将其一个对象move到另一个指针,可以吗,发生了什么。
在C++中,移动语义(Move Semantics)是C++11标准中引入的新特性,主要是为了减少不必要的内存拷贝。如果你有一个大的对象,你可以“移动”它而不是复制它,这样可以节省大量的时间和内存。
C++中的移动语义主要通过特殊的类型的引用实现,称为右值引用(rvalue reference)。右值引用是一种特殊类型的引用,它可以绑定到临时对象(也就是右值)。右值引用主要用来支持移动语义和完美转发。
让我们定义一个简单的类,然后演示如何将其一个对象"移动"到另一个对象:
在这个例子中,MyClass有一个移动构造函数,它接受一个右值引用参数。在main函数中,我们创建了一个MyClass对象a,然后我们使用std::movea转化为右值,并传递给b的构造函数。这里,std::move(a)不会移动a,而只是返回一个a的右值引用,这样就可以调用移动构造函数了。这个过程中并没有进行任何的复制操作,所以比复制构造函数更高效。
然而,需要注意的是,移动一个对象可能会使它处于一个有效但未定义的状态。在上面的例子中,a被移动后就不应再被使用。
进一步地。移动语义(std::move)主要就是将一个对象转换为右值,然后就可以相应地调用一个对象的移动构造函数(特殊情况下,如何未定义移动构造函数,就会转而选择拷贝构造函数)。
移动构造函数和拷贝构造函数的主要区别在于它们处理对象数据的方式不同。当你拷贝一个对象时,你实际上是创建了该对象的一个新副本,这个过程需要分配内存并复制数据,这可能会非常消耗资源。而当你移动一个对象时,你实际上是将原对象的数据“转移”给新对象,而不是复制这些数据。这个过程不需要复制数据,因此通常更有效率。
这是一个例子,可以帮助理解移动和复制的区别:
在这个例子中,拷贝构造函数会创建一个新的data数组,并将other.data的数据复制到这个新数组。而移动构造函数则直接将other.data的所有权转移给新对象,然后将other.data设为nullptr,这样other就不再拥有任何数据。
这就解释了为什么移动后的对象通常不能再使用:移动构造函数会将原对象的数据“窃取”给新对象,所以原对象可能会处于一个空的、无效的状态。在上面的例子中,如果你试图访问移动后的other.data,你会得到一个空指针,这通常会导致运行时错误。
然而,虽然移动后的对象通常不能再使用,但是它仍然是一个有效的对象,你可以给它赋予新的值,或者让它调用不依赖于其内部数据的成员函数。你也可以让它调用那些能够处理空状态的成员函数,例如析构函数。
总的来说,如果你需要创建一个对象的副本,并且你需要保留原对象的状态,那么你应该使用拷贝构造函数。如果你不再需要原对象,或者你想要避免代价高昂的数据复制,那么你应该使用移动构造函数。
测试代码
结果:
总结:
结合测试用例,可以看出如果直接std::move一个普通指针的话,只是简单把这个指针变成右值,赋值给一个新的指针变量,普通指针没有移动构造函数。就相当于定义了一个新的指针变量,里面指向地址是原指针的内容。
一般情况不会这么这么使用。
扩展:结合unique_ptrstd::move一起使用
结合移动语义和 unique_ptr 可以实现资源的高效管理,避免资源泄漏或重复释放等问题。unique_ptr 是一个独占所有权的智能指针,它通过 RAII(资源获取即初始化)机制来确保在离开作用域时释放所管理的资源。同时,unique_ptr 支持移动语义,可以实现资源的高效转移。
下面是一个使用 unique_ptr 和移动语义的示例:
在上面的示例中,我们首先创建了一个 unique_ptr 对象 ptr,并将其初始化为指向一个 Object 对象。然后,我们使用移动语义将 ptr 转移给另一个 unique_ptr 对象 ptr2,此时 ptr 不再管理 Object 对象,可以被释放或重用。最后,ptr2 管理 Object 对象,会在离开作用域时自动释放。
需要注意的是,由于 unique_ptr 使用了独占所有权模型,因此我们应该尽量避免直接使用裸指针来操作被 unique_ptr 管理的对象。如果必须使用裸指针,也应该将其转换为智能指针,以确保资源的正确管理。例如:
在上面的示例中,我们将一个裸指针转换为 unique_ptr,以确保在离开函数作用域时正确释放所管理的资源。

并行

语法

  • std::atomic<int>是一个原子类型,它可以用于实现无锁的线程安全编程。原子类型的主要作用是提供了一种方式来保证某些操作在多线程环境中的原子性,即这些操作不会被其他线程打断。例如,增加一个计数器的操作通常包含三个步骤:读取旧值、增加1、写回新值。在多线程环境中,如果这三个步骤不是原子的,那么可能会出现错误的结果。例如,两个线程可能同时读取到旧值,然后都增加1并写回新值,导致实际上只增加了1而不是2。
  • std::unique_lock是一个通用的互斥包装器,它能够提供超出std::lock_guard的灵活性。std::lock_guard在构造时锁定互斥量,然后在析构时解锁,提供了一种方便的方式来避免忘记解锁互斥量,也确保了在函数执行过程中发生的异常情况下,互斥量能够被正确地解锁。然而,std::lock_guard在某些场景下可能过于严格,例如你可能需要在某些条件下提前解锁互斥量,或者在多个互斥量之间转移所有权。std::unique_lock提供了如下的灵活性:
    • 延迟锁定:std::unique_lock可以在构造时不立即锁定互斥量,然后在稍后的某个时间点手动锁定。
    • 可以解锁并重新锁定:std::unique_lock可以在任何时间点解锁互斥量,然后在稍后的某个时间点重新锁定。这对于某些需要在持有锁的期间进行等待的编程模式非常有用。
    • 可转移所有权:std::unique_lock的所有权可以转移,即一个std::unique_lock对象可以将其所有权转移给另一个std::unique_lock对象。这对于需要在函数之间传递锁的所有权的场景非常有用。
    • 可以与条件变量一起使用:std::unique_lock可以与std::condition_variable一起使用,用于等待特定条件的满足。

线程互斥量mutex

std::mutex是C++11标准库中提供的一个线程同步原语,用于保护共享资源的访问。std::mutex提供了基本的互斥锁功能,即在多个线程中对共享资源进行访问时,只有一个线程可以访问该资源。当一个线程持有锁时,其他线程需要等待该锁被释放后才能获得锁并继续执行。以下是一些std::mutex的使用场景:
  1. 保护共享资源:在多线程环境下,当多个线程同时访问共享资源时,需要使用std::mutex保护共享资源,防止数据竞争问题的发生。例如:多个线程对一个共享的计数器进行更新时,需要使用std::mutex进行保护,以确保计数器的值是正确的。
  1. 等待/通知机制:在多线程编程中,有时需要一个线程等待另一个线程的某个事件的发生,然后再继续执行。这可以通过std::mutexstd::condition_variable实现。std::condition_variable可以用于等待另一个线程的通知,并在条件满足时通知等待的线程继续执行。
  1. 递归锁:当一个线程需要多次获取同一个锁时,可以使用std::recursive_mutex,它允许同一个线程多次获取锁,而不会发生死锁。但是,需要注意的是,std::recursive_mutex会增加锁的开销,因此在不需要递归锁的情况下,应该使用std::mutex
  1. 超时锁:如果需要在等待锁的过程中设置超时时间,可以使用std::timed_mutexstd::unique_lockstd::timed_mutex提供了带超时时间的lock()操作,而std::unique_lock提供了更灵活的锁定方式。
除了上述标准库提供的互斥锁类之外,C++标准库还提供了其他类型的锁,如读写锁(std::shared_mutex)、自旋锁(std::spin_lock)和屏障(std::barrier)等,以满足不同的需求。
需要注意的是,使用锁是一种确保线程安全的方式,但也会带来一定的性能开销。因此,在使用锁的同时,应该避免过度锁定,尽量减少锁的持有时间,以提高程序的性能。
具体的示例
  1. std::mutex
std::mutex是C++11标准库中最基本的互斥锁类,用于保护共享资源的访问。下面是一个简单的示例,演示了如何使用std::mutex来保护共享资源:
在上述示例中,我们定义了一个全局变量count,并创建了两个线程t1t2,它们都调用了increment()函数。由于count是一个共享资源,我们需要使用std::mutex来保护它的访问。在increment()函数中,我们使用std::mutexlock()unlock()函数来保证对count的访问是互斥的。
  1. std::recursive_mutex
std::recursive_mutex是一个递归互斥锁,允许同一个线程多次获取锁,而不会发生死锁。下面是一个使用std::recursive_mutex的示例:
在上述示例中,我们定义了一个递归互斥锁std::recursive_mutex,并创建了两个函数foo()bar(),它们都使用了该锁来保护共享资源。在bar()函数中,我们首先获取mtx的锁,然后调用foo()函数。在foo()函数中,我们再次获取相同的锁,输出"foo",然后释放锁。最后在bar()函数中输出"bar",并释放锁。
需要注意的是,如果在foo()函数中使用了非递归互斥锁来保护共享资源,那么在bar()函数中再次获取该锁时就会发生死锁,因为尝试获取已经被当前线程占用的锁会导致线程阻塞。而使用std::recursive_mutex可以避免这种情况,因为它允许同一个线程多次获取锁,而不会发生死锁。
  1. std::timed_mutexstd::unique_lock
std::timed_mutex是一个带有超时时间的互斥锁,允许等待一段时间后自动释放锁。std::unique_lock是一个RAII锁封装,提供了更灵活的锁定方式。下面是一个使用std::timed_mutexstd::unique_lock的示例,演示了如何等待一段时间后自动释放锁:
在上述示例中,我们定义了一个函数foo(),它尝试获取锁并在1秒钟后自动释放锁。在foo()函数中,我们使用std::unique_lock的构造函数,指定超时时间为1秒。如果在1秒钟内成功获取锁,则输出"foo",否则输出"foo failed to acquire lock"。
  1. std::shared_mutex
std::shared_mutex是一个读写锁,允许多个线程同时读取共享资源,但只允许一个线程写入共享资源。下面是一个使用std::shared_mutex的示例:
在上述示例中,我们定义了两个读线程和一个写线程,它们都使用了std::shared_mutex来保护共享资源count的访问。在读线程中,我们使用std::shared_lock来获取读锁,允许多个线程同时读取共享资源。而在写线程中,我们使用std::unique_lock来获取写锁,只允许一个线程写入共享资源。
  1. 自旋锁:
自旋锁是一种基于忙等待的锁,它避免了线程切换的开销,但会消耗CPU资源。C++标准库中提供了std::atomic_flag来实现自旋锁。下面是一个使用std::atomic_flag实现自旋锁的示例:
在上述示例中,我们使用std::atomic_flag来实现自旋锁。在increment()函数中,我们使用test_and_set()函数获取锁,然后执行增加操作,最后使用clear()函数释放锁。需要注意的是,自旋锁会消耗大量的CPU资源,因此在使用自旋锁时需要谨慎。
自旋锁的进一步示例
概念:
自旋锁是一种基于忙等待的锁,即线程不断地尝试获取锁,直到锁可用为止。在自旋锁中,如果线程尝试获取锁时发现锁已经被占用,那么它会不断地循环检查锁的状态,直到锁被释放。自旋锁的优点是避免了线程切换的开销,但会消耗CPU资源。
函数:
C++标准库中提供了std::atomic_flag来实现自旋锁。std::atomic_flag是一个原子布尔标志,支持原子测试和设置操作。在使用自旋锁时,我们可以使用std::atomic_flagtest_and_set()函数获取锁,clear()函数释放锁。
使用场景:
自旋锁通常用于保护非常短的代码段,这些代码段不需要等待太长时间就可以完成。如果需要保护的代码段执行时间较长,那么自旋锁会消耗大量的CPU资源,影响系统的性能。因此,在使用自旋锁时需要根据具体情况选择合适的锁类型。
下面是一个使用自旋锁的示例,演示了如何使用std::atomic_flag实现自旋锁:
在上述示例中,我们定义了一个std::atomic_flag类型的变量flag,并创建了两个线程t1t2,它们都调用了increment()函数。在increment()函数中,我们使用test_and_set()函数获取锁,然后执行增加操作,最后使用clear()函数释放锁。
除了std::atomic_flag,还有一些其他的自旋锁实现,如std::atomic<int>std::atomic<bool>std::atomic<intptr_t>等等。此外,一些操作系统也提供了自旋锁的实现,如Linux内核中的spinlock_t
对于高并发的情况,可以使用更高级的自旋锁实现,如Ticket Spinlock、MCS Spinlock等。这些自旋锁实现可以更好地支持多核CPU,避免竞争和锁争用等问题。
下面给出一个使用Ticket Spinlock的示例:
在上述示例中,我们定义了一个Ticket Spinlock类,并创建了两个线程t1t2,它们都调用了increment()函数来增加计数器count的值。在TicketSpinLock类中,我们使用两个原子变量m_nextm_now_serving来实现自旋锁。在lock()函数中,我们首先获取当前的票号my_ticket,然后不断循环检查m_now_serving的值,直到它等于my_ticket,表示当前线程获取到了锁。在unlock()函数中,我们将当前服务的票号加1,表示当前线程已经完成了对共享资源的操作,可以释放锁。
需要注意的是,实际使用自旋锁时需要根据具体情况选择合适的锁类型,并考虑到锁的开销和线程切换的开销,以及竞争和锁争用等问题。

线程池实现

实现代码
注意:
std::condition_variable::wait函数的第二个参数是一个谓词函数,该函数用于检查是否应该从wait函数返回。
std::condition_variable::wait函数会阻塞当前线程,直到另一个线程调用了std::condition_variablenotify_onenotify_all函数。但是,即使notify_onenotify_all被调用,wait函数也可能不会立即返回。这是因为wait函数可能会因为假唤醒(spurious wakeups)而被唤醒,也就是无故的、没有明确原因的唤醒。
为了避免因假唤醒而导致的问题,wait函数接受一个谓词函数作为参数。只有当这个谓词函数返回true时,wait函数才会返回。如果谓词函数返回falsewait函数会再次阻塞当前线程。
意义:
  1. 减少线程创建和销毁的开销
  1. 限制并发线程的数量
  1. 提供任务队列
  1. 提供更高级的特性
  1. 更好的抽象和代码组织

自旋互斥锁实现

实现代码
基于释放/获取模型。对于选项 std::memory_order_acq_rel 而言,则结合了这两者的特点,唯一确定了一个内存屏障,使得当前线程对内存的读写不会被重排并越过此操作的前后。

容器-元组

元组,运行时索引

  • std::variant是一个C++17新引入的类模板,它是一个类型安全的联合体。一个std::variant实例在任何时候都只能保持其模板参数类型中的一个值。例如,std::variant<int, float>可以持有一个intfloat,但不可以同时持有。
    • std::variant的主要优点之一是它的类型安全性。与C联合相比,std::variant知道它当前持有的类型,这使得在运行时能够安全地访问值。
      下面是一个std::variant的例子,它展示了如何使用std::variantstd::get来存储和检索值:
      在这个例子中,我们首先创建了一个可以持有intfloatstd::variant。然后我们在variant中存储了一个int并打印它,然后我们在variant中存储了一个float并打印它。注意我们使用std::get<T>来获取variant中的值,其中T是我们想要获取的类型。
      在你的代码中,你使用了std::variantstd::in_place_index<n>构造函数。这个构造函数允许你直接在std::variant中构造一个值,而不需要先创建一个临时对象然后复制或移动到std::variant中。这可以提高性能,特别是在处理大对象或需要避免复制的对象时。
      下面是一个使用std::in_place_index<n>构造函数的例子:
      在这个例子中,我们使用std::in_place_index<1>std::variant中直接构造了一个float。注意索引是从0开始的,所以索引1对应的类型是float。索引指的是构造的模板类型列表对应的索引。
  • std::visit是C++17中引入的一个功能,它用于访问std::variant中的值。std::visit函数接受一个访问者(visitor)和一个或多个std::variant对象。访问者是一个可调用对象,例如函数或者lambda表达式。
    • std::visit被调用时,它会查看std::variant当前持有的值的类型,然后调用访问者,传入该值作为参数。例如:
      std::visit接受一个lambda表达式作为访问者。这个lambda表达式接受一个通用引用参数x,并输出它到std::ostream对象s。通用引用是一个可以匹配任何类型的引用,包括左值引用和右值引用。
      std::visit被调用时,它会查看std::variant对象v当前持有的值的类型,然后调用lambda表达式,传入该值作为参数。这样,无论std::variant持有何种类型的值,都将被输出到std::ostream对象。
      总的来说,std::visit提供了一种安全、简洁和优雅的方式来访问std::variant中的值,无需我们自己去手动检查std::variant持有的值的类型。

实现

代码实现
解释:
  1. _tuple_index函数:
    1. _tuple_index是一个模板函数,它接受一个编译时常量n和一组类型参数T...。它的功能是从给定的std::tuple中获取索引为n的元素。如果n等于运行时传入的i,那么它会返回一个std::variant,该std::variant的值是索引为n的元素。如果n不等于i,它会递归调用自己,n+1,直到n等于i或超过sizeof...(T),也就是元素的数量。
      注意这里使用了if constexpr,它是C++17引入的一种编译时if语句。if constexpr后的条件必须是一个常量表达式,只有在条件为true时,if后的代码才会被编译。
  1. tuple_index函数:
    1. tuple_index是一个辅助函数,它调用_tuple_index<0>(tpl, i)来获取元素。这样用户可以不必手动指定模板参数n
  1. <<运算符重载:
    1. 这段代码还提供了一个对std::variant类型的<<运算符重载,以便可以将std::variant的值打印到std::ostream。它使用std::visit函数来访问std::variant中的值,并将该值打印到std::ostream
进一步解释:核心是_tuple_index函数。
在C++中,模板参数必须在编译时可知。所以,std::get函数的参数必须是一个编译时常量,这就是为什么我们不能直接传递一个运行时变量给std::get
然而,我们可以通过模板元编程在编译时生成所有可能的索引,然后在运行时选择正确的索引。这就是_tuple_index函数的工作原理。
_tuple_index函数的关键部分:_tuple_index<(n < sizeof...(T)-1 ? n+1 : 0)>(tpl, i);是一个编译时表达式,它的值在编译时就已经确定了。这个表达式的结果就是模板参数n的新值。这个新值在编译时就已经确定,所以我们可以将它作为模板参数。
在每次递归调用_tuple_index函数时,我们都会生成一个新的模板实例,模板参数n的值为(n < sizeof...(T)-1 ? n+1 : 0)
这样,我们就在编译时生成了所有可能的模板实例(即所有可能的索引)。然后,在运行时,我们根据实际的运行时索引i选择正确的模板实例(即正确的索引)。
示例:
比如:std::tuple<std::string, double, double, int> t("123", 4.5, 6.7, 8); 会生成以下模板实例:
  • _tuple_index<0, std::string, double, double, int>
  • _tuple_index<1, std::string, double, double, int>
  • _tuple_index<2, std::string, double, double, int>
  • _tuple_index<3, std::string, double, double, int>
📡
智能指针总结

秋招复盘7.25:cpp
秋招复盘7.25:cpp