前言
C++新特性就是说的现代 c++,指的是从 c++11 开始的 c++,从 c++11 开始,加入一些比较现代的语言特性和改进了的库实现,使得用 c++ 开发少了很多心智负担,程序也更加健壮。
从 c++11 开始,每 3 年发布一个新版本,到今年(2024)已经有 5 个版本了,分别是 c++11、c++14、c++17、c++20、c++23,这 5 个版本引入了上百个新的语言特性和新的标准库特性
新特新主要就有 内联、嵌套命名空间、auto占位符、decltype、函数返回类型后置、右值引用、lambda、协程等
内联
内联的关键字是inline,以inline修饰的函数叫做内联函数,编译时C++编译器会在调用内联函数的地方展开,没有函数调用建立栈帧的开销,内联函数提升程序运行的效率。
- 内联函数最初的目的:代替部分 #define 宏定义;
- 使用内联函数替代普通函数的目的:提高程序的运行效率;
内联和嵌套命名空间
说到嵌套命名空间,就要知道命名空间是啥,命名空间就是将不同模块的名字限定在各自模块的命名空间中,命名空间中的名字的作用域只在命名空间内有效,避免名字的冲突。现代的命名空间:内联命名空间(C++11)和嵌套命名空间(C++17)。
内联命名空间: C++11标准引入了内联命名空间的概念,它的语法就是在namespace前面加个inline关键字
inline namespace MyCode {
// source code
}
内联命名空间中的名字可以被上层命名空间直接使用,也就是说,我们无需在内联空间的名字前添加该命名空间的名字为前缀,通过上层命名空间的名字就可以直接访问他
namespace MyCode {
namespace Lib_V1 {
void foo() {}
}
inline namespace Lib_V2 {
void foo() {}
}
}
int main() {
MyCode::Lib_V1::foo();
MyCode::foo();
}
调用Lib_V1命名空间的foo函数,前面需要加上Lib_V1的前缀,而访问Lib_V2命名空间的foo函数则不需要。内联命名空间的作用之一是,当我们有一个模块,这个模块提供了一组接口供外部调用,有时我们需要升级接口以提供不同的功能,而新接口不与老接口兼容,我们希望新写的代码将调用我们提供的新接口,但是又不希望影响老的代码,所以老的接口需要保留。这时就可以使用内联命名空间的办法来解决,就如上面的例子中,我们把新接口放在命名空间Lib_V2中,并定义为内联的命名空间,使用者只需通过MyCode前缀就可以访问到它们,如:MyCode::foo(),老的代码的逻辑不需要改动,只需将原来调用接口的地方加个前缀,如MyCode::Lib_V1::foo()。
嵌套命名空间: 嵌套命名空间在C++98中已有,当写法比较冗余,如果要定义多重的嵌套则显得更加冗余,特别是在代码缩进时,
namespace A {
namespace B {
namespace C {
void foo() {}
}
}
}
访问foo函数时通过A::B::C::foo()来调用,如果定义命名空间时也可以像这样的话代码将会变得更加简洁,因此C++17标准中引入了更简洁的嵌套命名空间的定义方式,如:
namespace A::B::C {
void foo() {}
}
在C++17中没有解决在嵌套命名空间中定义内联命名空间,也就是说在上面的嵌套命名空间中没法加入inline关键字,使得子命名空间成为内联的,直到C++20标准中完善了这个功能。因此在C++20中,我们可以通过以下的方式来定义命名空间:
namespace A::B::inline C {
void foo() {}
}
// 它等同于如下定义:
namespace A::B {
inline namespace C {
void foo() {}
}
}
// 调用foo函数:
A::B::foo();
// 或者也可以这样定义:
namespace A::inline B::C {
void foo() {}
}
// 它等同于如下定义:
namespace A {
inline namespace B {
namespace C {
void foo() {}
}
}
}
// 调用foo函数:
A::C::foo();
需要注意的是,inline关键字可以出现在除第一个namespace之外的任意namespace之前,上面的代码需要使用支持C++20标准的编译器来编译,在编译时加上参数-std=c++20。(参考博文:C++内联命名空间和嵌套命名空间-CSDN博客)
auto占位符
C++11引入的关键字auto,用于自动类型推导,简化了代码并提高了可读性。
使用auto注意事项
1、auto
关键字必须初始化:
在使用auto
声明变量时,必须进行初始化,编译器通过初始化表达式推导出变量的类型。如果没有初始化,编译器无法确定变量的类型
auto x = 42; // 正确,编译器将推导x的类型为int
auto y; // 错误,没有进行初始化,编译器无法推导y的类型
auto关键字并不是一种实际的数据类型,它是一占位符,在编译阶段替换为真正的类型。
推导准则
1、基本类型推导:如果初始化表达式是一个单值的基本类型(例如整数、浮点数、字符等),那么推导出的类型就是该基本类型。
auto i = 10; // 推导为int类型
auto d = 3.14; // 推导为double类型
auto c = 'A'; // 推导为char类型
2、指针和引用推导:如果初始化表达式是一个指针或引用,那么推导出的类型就是对应的指针或引用类型。
int x = 42;
auto* ptr = &x; // 推导为int*类型
auto& ref = x; // 推导为int&类型
const auto& cref = x; // 推导为const int&类型
3、类模板推导:如果初始化表达式是一个类模板实例化的对象,那么推导出的类型就是该类模板的实例化类型
std::vector<int> vec = {1, 2, 3};
auto v = vec; // 推导为std::vector<int>类型
4、表达式推导:如果初始化表达式是一个复杂的表达式,那么推导出的类型就是表达式的结果类型
auto sum = 2 + 3.14; // 推导为double类型
auto result = func(); // 推导为func()返回值的类型
参考博文:C++ auto详解以及常见使用场景-CSDN博客
decltype
decltype 是 C++11 新增的一个用来推导表达式类型的关键字。和 auto 的功能一样,用来在 编译时期 进行自动类型推导。引入 decltype 是因为 auto 并不适用于所有的自动类型推导场景,在某些特殊情况下 auto 用起来很不方便,甚至压根无法使用。也可以将 decltype 看作是 sizeof 运算符的另一种形式,因为两者都不会真正计算其参数,只充当一种编译期工具的角色。
auto varName = value;
decltype(exp) varName = value;
- auto 根据 = 右边的初始值推导出变量的类型,decltype 根据 exp 表达式推导出变量的类型,跟 = 右边的 value 没有关系;
- auto 要求变量必须初始化,因为 auto 是根据变量的初始值来推导变量类型的,如果不初始化,变量的类型也就无法推导;
- 而
decltype
不要求,因此可以写成如下形式:
decltype(exp) varName;
参考博文;C++ 的 decltype 详细介绍_c++ decltype-CSDN博客
右值引用
在说右值引用时先来看看什么是右值、左值 她们的区别是什么
左值准确来说是:一个表示数据的表达式(如变量名或解引用的指针),且可以获取他的地址(取地址),可以对它进行赋值;它可以在赋值符号的左边或者右边。
右值准确来说是:一个表示数据的表达式(如字面常量、函数的返回值、表达式的返回值),且不可以获取他的地址(取地址);它只能在赋值符号的右边。右值通常也是不可以改变的值
int main()
{
// 以下的a、p、b、c、*p都是左值
int* p = new int(0);
int b = 1;
int a = b;
const int c = 2;
// 以下几个都是常见的右值
10;
x + y;
fmin(x, y);
}
int main()
{
// 左值引用只能引用左值,不能引用右值。
int a = 10;
int& ra1 = a; // ra1为a的别名
//int& ra2 = 10; // 编译失败,因为10是右值
// const左值引用既可引用左值,也可引用右值。
const int& ra3 = 10;
const int& ra4 = a;
//右值引用只能右值,不能引用左值。
int&& r1 = 10;
int a = 10;
//message : 无法将左值绑定到右值引用
int&& r2 = a;
//右值引用可以引用move以后的左值
int&& r3 = std::move(a);
return 0;
}
lambda
lambda表达式(也称为lambda函数)是在调用或作为函数参数传递的位置处定义匿名函数对象的便捷方法。通常,lambda用于封装传递给算法或异步方法的几行代码 。
Lambda表达式示例
Lambda有很多叫法,有Lambda表达式、Lambda函数、匿名函数,为了方便表述统一用Lambda表达式进行叙述。 ISO C++标准官网展示了一个简单的lambda表示式实例:
#include <algorithm>
#include <cmath>
void abssort(float* x, unsigned n) {
std::sort(x, x + n,
// Lambda expression begins
[](float a, float b) {
return (std::abs(a) < std::abs(b));
} // end of lambda expression
);
}
在上面的实例中std::sort
函数第三个参数应该是传递一个排序规则的函数,但是这个实例中直接将排序函数的实现写在应该传递函数的位置,省去了定义排序函数的过程,对于这种不需要复用,且短小的函数,直接传递函数体可以增加代码的可读性。
Lambda表达式语法定义
- 捕获列表。在C++规范中也称为Lambda导入器, 捕获列表总是出现在Lambda函数的开始处。实际上,
[]
是Lambda引出符。编译器根据该引出符判断接下来的代码是否是Lambda函数,捕获列表能够捕捉上下文中的变量以供Lambda函数使用。- 参数列表。与普通函数的参数列表一致。如果不需要参数传递,则可以连同括号“()”一起省略。
- 可变规格。
mutable
修饰符, 默认情况下Lambda函数总是一个const
函数,mutable
可以取消其常量性。在使用该修饰符时,参数列表不可省略(即使参数为空)。- 异常说明。用于Lamdba表达式内部函数抛出异常。
- 返回类型。 追踪返回类型形式声明函数的返回类型。我们可以在不需要返回值的时候也可以连同符号”->”一起省略。此外,在返回类型明确的情况下,也可以省略该部分,让编译器对返回类型进行推导。
- lambda函数体。内容与普通函数一样,不过除了可以使用参数之外,还可以使用所有捕获的变量。
参考博文:C++ Lambda表达式详解_c++ lamda-CSDN博客
协程
随着现代编程范式的发展,协程(Coroutines)已经成为了并发编程和异步编程的一个重要工具。在C++中,协程是一种能够被挂起和重新恢复执行的函数,它允许程序员以一种更加直观和简洁的方式来编写异步代码。
在C++20标准中,协程是通过关键字co_await, co_yield和co_return来实现的。这些关键字可以用于生成器(generators)、异步任务(async tasks)和其它形式的协程。
协程(Coroutine),又称为微线程或者轻量级线程,是一种用户态的、可在单个线程中并发执行的程序组件。协程可以看作是一个更轻量级的线程,由程序员主动控制调度。它们拥有自己的寄存器上下文和栈,可以在多个入口点间自由切换,而不是像传统的函数调用那样在一个入口点开始、另一个入口点结束。
协程与线程、进程的区别
协程、线程和进程都是程序执行的基本单元,但它们之间有一些显著的区别:
- 进程:进程是操作系统分配资源和调度的基本单位,具有独立的内存空间和系统资源。进程间的通信和切换开销较大。
- 线程:线程是进程内的一个执行单元,拥有自己的执行栈和寄存器上下文,但共享进程内的内存空间和系统资源。线程间的切换开销小于进程,但仍受到操作系统调度。
- 协程:协程是在用户态实现的,可以在一个线程内并发执行。协程拥有自己的寄存器上下文和栈,但协程间的切换由程序员主动控制,避免了操作系统调度开销。
在了解协程编程之前,我们需要掌握一些基本概念,包括生成器、协程、堆栈以及协程的状态。
协程基础知识:
- 生成器(generator):生成器是一种特殊的函数,它可以保存当前执行状态,并在下次调用时从保存的状态继续执行。生成器使用关键字yield来暂停函数执行,并返回一个值,下次调用时从yield的位置继续执行。
- 协程(coroutine):协程是一种用户态的程序组件,拥有自己的寄存器上下文和栈。协程可以在多个入口点间自由切换,实现非抢占式的多任务调度。协程与生成器类似,都可以暂停执行并在下次调用时恢复执行,但协程的调度更加灵活。
- 堆栈(stack):堆栈是一种先进后出(LIFO)的数据结构,用于保存函数调用的状态。在协程切换时,会将当前协程的堆栈信息保存起来,下次恢复执行时再加载该堆栈信息。这使得协程能够实现非线性的执行流程。
协程的基本原理包括以下几点:
- 协程控制块:保存协程的状态、栈指针、上下文等信息。
- 协程创建:分配协程控制块和栈空间,初始化协程状态。
- 协程切换:在协程之间进行上下文切换,包括保存和恢复协程的上下文。
- 协程销毁:释放协程占用的资源,如栈空间,删除协程控制块。
- 协程调度器:管理所有协程的创建、调度和销毁。协程调度器负责在多个协程之间进行上下文切换,以实现协程并发执行。
协程状态
在协程的生命周期中,它会经历不同的状态,主要包括运行中、挂起和终止三种。
- 运行中:协程正在执行,具有线程上下文。当协程函数被调用时,协程会进入运行中状态。
- 挂起:协程暂停执行,保存当前的堆栈信息和上下文。当遇到如yield或其他协程操作时,协程会进入挂起状态,等待再次恢复执行。
- 终止:协程执行完毕,释放协程的资源。当协程函数执行到返回值时,协程会进入终止状态。