学习
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)出来的值。
以下是一些使用结构化绑定的示例:
- 从元组中解构值:
在这个示例中,
getSomeData
函数返回一个元组,然后我们使用结构化绑定一次性创建了三个变量 a
、b
、c
,并将元组中的值赋给了它们。- 从数组中解构值:
在这个示例中,我们从数组
arr
中解构出两个值,并赋给了变量 x
和 y
。- 从结构体中解构值:
在这个示例中,我们从结构体
s
中解构出两个值,并赋给了变量 id
和 name
。结构化绑定是一种非常方便的特性,它可以让你更简洁、更清晰地从复合数据类型中解构出值。
右值引用
- 右值引用
- 引用坍缩(引用折叠)
- 移动语义
- 通用转发
- 完美转发
右值引用
在 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::move
将a
转化为右值,并传递给b
的构造函数。这里,std::move(a)
不会移动a
,而只是返回一个a
的右值引用,这样就可以调用移动构造函数了。这个过程中并没有进行任何的复制操作,所以比复制构造函数更高效。然而,需要注意的是,移动一个对象可能会使它处于一个有效但未定义的状态。在上面的例子中,
a
被移动后就不应再被使用。进一步地。移动语义(std::move)主要就是将一个对象转换为右值,然后就可以相应地调用一个对象的移动构造函数(特殊情况下,如何未定义移动构造函数,就会转而选择拷贝构造函数)。
移动构造函数和拷贝构造函数的主要区别在于它们处理对象数据的方式不同。当你拷贝一个对象时,你实际上是创建了该对象的一个新副本,这个过程需要分配内存并复制数据,这可能会非常消耗资源。而当你移动一个对象时,你实际上是将原对象的数据“转移”给新对象,而不是复制这些数据。这个过程不需要复制数据,因此通常更有效率。
这是一个例子,可以帮助理解移动和复制的区别:
在这个例子中,拷贝构造函数会创建一个新的
data
数组,并将other.data
的数据复制到这个新数组。而移动构造函数则直接将other.data
的所有权转移给新对象,然后将other.data
设为nullptr
,这样other
就不再拥有任何数据。这就解释了为什么移动后的对象通常不能再使用:移动构造函数会将原对象的数据“窃取”给新对象,所以原对象可能会处于一个空的、无效的状态。在上面的例子中,如果你试图访问移动后的
other.data
,你会得到一个空指针,这通常会导致运行时错误。然而,虽然移动后的对象通常不能再使用,但是它仍然是一个有效的对象,你可以给它赋予新的值,或者让它调用不依赖于其内部数据的成员函数。你也可以让它调用那些能够处理空状态的成员函数,例如析构函数。
总的来说,如果你需要创建一个对象的副本,并且你需要保留原对象的状态,那么你应该使用拷贝构造函数。如果你不再需要原对象,或者你想要避免代价高昂的数据复制,那么你应该使用移动构造函数。
测试代码
结果:
总结:
结合测试用例,可以看出如果直接
std::move
一个普通指针的话,只是简单把这个指针变成右值,赋值给一个新的指针变量,普通指针没有移动构造函数。就相当于定义了一个新的指针变量,里面指向地址是原指针的内容。一般情况不会这么这么使用。
扩展:结合
unique_ptr
和std::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
的使用场景:- 保护共享资源:在多线程环境下,当多个线程同时访问共享资源时,需要使用
std::mutex
保护共享资源,防止数据竞争问题的发生。例如:多个线程对一个共享的计数器进行更新时,需要使用std::mutex
进行保护,以确保计数器的值是正确的。
- 等待/通知机制:在多线程编程中,有时需要一个线程等待另一个线程的某个事件的发生,然后再继续执行。这可以通过
std::mutex
和std::condition_variable
实现。std::condition_variable
可以用于等待另一个线程的通知,并在条件满足时通知等待的线程继续执行。
- 递归锁:当一个线程需要多次获取同一个锁时,可以使用
std::recursive_mutex
,它允许同一个线程多次获取锁,而不会发生死锁。但是,需要注意的是,std::recursive_mutex
会增加锁的开销,因此在不需要递归锁的情况下,应该使用std::mutex
。
- 超时锁:如果需要在等待锁的过程中设置超时时间,可以使用
std::timed_mutex
和std::unique_lock
。std::timed_mutex
提供了带超时时间的lock()
操作,而std::unique_lock
提供了更灵活的锁定方式。
除了上述标准库提供的互斥锁类之外,C++标准库还提供了其他类型的锁,如读写锁(
std::shared_mutex
)、自旋锁(std::spin_lock
)和屏障(std::barrier
)等,以满足不同的需求。需要注意的是,使用锁是一种确保线程安全的方式,但也会带来一定的性能开销。因此,在使用锁的同时,应该避免过度锁定,尽量减少锁的持有时间,以提高程序的性能。
具体的示例
std::mutex
:
std::mutex
是C++11标准库中最基本的互斥锁类,用于保护共享资源的访问。下面是一个简单的示例,演示了如何使用std::mutex
来保护共享资源:在上述示例中,我们定义了一个全局变量
count
,并创建了两个线程t1
和t2
,它们都调用了increment()
函数。由于count
是一个共享资源,我们需要使用std::mutex
来保护它的访问。在increment()
函数中,我们使用std::mutex
的lock()
和unlock()
函数来保证对count
的访问是互斥的。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
可以避免这种情况,因为它允许同一个线程多次获取锁,而不会发生死锁。std::timed_mutex
和std::unique_lock
:
std::timed_mutex
是一个带有超时时间的互斥锁,允许等待一段时间后自动释放锁。std::unique_lock
是一个RAII锁封装,提供了更灵活的锁定方式。下面是一个使用std::timed_mutex
和std::unique_lock
的示例,演示了如何等待一段时间后自动释放锁:在上述示例中,我们定义了一个函数
foo()
,它尝试获取锁并在1秒钟后自动释放锁。在foo()
函数中,我们使用std::unique_lock
的构造函数,指定超时时间为1秒。如果在1秒钟内成功获取锁,则输出"foo",否则输出"foo failed to acquire lock"。std::shared_mutex
:
std::shared_mutex
是一个读写锁,允许多个线程同时读取共享资源,但只允许一个线程写入共享资源。下面是一个使用std::shared_mutex
的示例:在上述示例中,我们定义了两个读线程和一个写线程,它们都使用了
std::shared_mutex
来保护共享资源count
的访问。在读线程中,我们使用std::shared_lock
来获取读锁,允许多个线程同时读取共享资源。而在写线程中,我们使用std::unique_lock
来获取写锁,只允许一个线程写入共享资源。- 自旋锁:
自旋锁是一种基于忙等待的锁,它避免了线程切换的开销,但会消耗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_flag
的test_and_set()
函数获取锁,clear()
函数释放锁。使用场景:
自旋锁通常用于保护非常短的代码段,这些代码段不需要等待太长时间就可以完成。如果需要保护的代码段执行时间较长,那么自旋锁会消耗大量的CPU资源,影响系统的性能。因此,在使用自旋锁时需要根据具体情况选择合适的锁类型。
下面是一个使用自旋锁的示例,演示了如何使用
std::atomic_flag
实现自旋锁:在上述示例中,我们定义了一个
std::atomic_flag
类型的变量flag
,并创建了两个线程t1
和t2
,它们都调用了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类,并创建了两个线程
t1
和t2
,它们都调用了increment()
函数来增加计数器count
的值。在TicketSpinLock
类中,我们使用两个原子变量m_next
和m_now_serving
来实现自旋锁。在lock()
函数中,我们首先获取当前的票号my_ticket
,然后不断循环检查m_now_serving
的值,直到它等于my_ticket
,表示当前线程获取到了锁。在unlock()
函数中,我们将当前服务的票号加1,表示当前线程已经完成了对共享资源的操作,可以释放锁。需要注意的是,实际使用自旋锁时需要根据具体情况选择合适的锁类型,并考虑到锁的开销和线程切换的开销,以及竞争和锁争用等问题。
线程池实现
实现代码
注意:
std::condition_variable::wait
函数的第二个参数是一个谓词函数,该函数用于检查是否应该从wait
函数返回。std::condition_variable::wait
函数会阻塞当前线程,直到另一个线程调用了std::condition_variable
的notify_one
或notify_all
函数。但是,即使notify_one
或notify_all
被调用,wait
函数也可能不会立即返回。这是因为wait
函数可能会因为假唤醒(spurious wakeups)而被唤醒,也就是无故的、没有明确原因的唤醒。为了避免因假唤醒而导致的问题,
wait
函数接受一个谓词函数作为参数。只有当这个谓词函数返回true
时,wait
函数才会返回。如果谓词函数返回false
,wait
函数会再次阻塞当前线程。意义:
- 减少线程创建和销毁的开销
- 限制并发线程的数量
- 提供任务队列
- 提供更高级的特性
- 更好的抽象和代码组织
自旋互斥锁实现
实现代码
基于释放/获取模型。对于选项
std::memory_order_acq_rel
而言,则结合了这两者的特点,唯一确定了一个内存屏障,使得当前线程对内存的读写不会被重排并越过此操作的前后。容器-元组
元组,运行时索引
std::variant
是一个C++17新引入的类模板,它是一个类型安全的联合体。一个std::variant
实例在任何时候都只能保持其模板参数类型中的一个值。例如,std::variant<int, float>
可以持有一个int
或float
,但不可以同时持有。
std::variant
的主要优点之一是它的类型安全性。与C联合相比,std::variant
知道它当前持有的类型,这使得在运行时能够安全地访问值。下面是一个
std::variant
的例子,它展示了如何使用std::variant
和std::get
来存储和检索值:在这个例子中,我们首先创建了一个可以持有
int
或float
的std::variant
。然后我们在variant
中存储了一个int
并打印它,然后我们在variant
中存储了一个float
并打印它。注意我们使用std::get<T>
来获取variant
中的值,其中T
是我们想要获取的类型。在你的代码中,你使用了
std::variant
的std::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
持有的值的类型。实现
代码实现
解释:
_tuple_index
函数:
_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
后的代码才会被编译。tuple_index
函数:
tuple_index
是一个辅助函数,它调用_tuple_index<0>(tpl, i)
来获取元素。这样用户可以不必手动指定模板参数n
。<<
运算符重载:
这段代码还提供了一个对
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>
- 作者:Olimi
- 链接:https://olimi.icu/article/da408ce5-7a31-475b-b765-96225a8d75e2
- 声明:本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。