C++新特性(C++11~C++20)

C++新特性(C++11~C++20)

在编程语言的浩瀚星空中,C++ 始终以其独特的魅力占据着不可替代的位置。它兼具高效的性能与灵活的抽象能力,在系统开发、游戏引擎、嵌入式编程等众多领域大放异彩。接下来,就让我们一同踏上探索 C++11 至 C++20 新特性的旅程,感受这门经典语言的焕新之力。
本文涵盖了C++11~C++20语言核心特性,默认读者已具备C++11知识储备,对于C++11只讲部分易忽视的重点内容,主要讲C++14~C++20的新特性。

1、C++11

1.1、内联命名空间

C++11标准增强了命名空间的特性,提出了内联命名空间的概念。内联命名空间能够把空间内函数和类型导出到父命名空间中,这样即使不指定子命名空间也可以使用其空间内的函数和类型了,比如:

#include <iostream>
namespace Parent {
 namespace Child1
 {
 void foo() { std::cout << "Child1::foo()" << std::endl; }
 }
 inline namespace Child2
 {
 void foo() { std::cout << "Child2::foo()" << std::endl; }
 }
}
int main()
{
 Parent::Child1::foo();
 Parent::foo();
}

该特性可以帮助库作者无缝升级库代码,让客户不用修改任何代码也能够自由选择新老库代码。使用内联命名空间,将不同版本的接口归纳到不同的命名空间中,然后给它们一个容易辨识的空间名称,最后将当前最新版本的接口以内联的方式导出到父命名空间中。举个例子:

namespace Parent {
 namespace V1 {
 void foo() { std::cout << "foo v1.0" << std::endl; }
 }
 inline namespace V2 {
 void foo() { std::cout << "foo v2.0" << std::endl; }
 }
}
int main()
{
 Parent::foo();
}

使用这种方式管理接口版本非常清晰,如果想加入V3版本的接口,则只需要创建V3的内联命名空间,并且将命名空间V2的inline关键字删除。

1.2、强枚举类型

强枚举类型不允许匿名,我们必须给定一个类型名,否则无法通过编译。强枚举类型具备以下3个新特性:
1.枚举标识符属于强枚举类型的作用域。
2.枚举标识符不会隐式转换为整型。
3.能指定强枚举类型的底层类型,底层类型默认为int类型。

enum class E : unsigned int {
	e1 = 1,
	e2 = 2
};

1.3、noreturn

noreturn是C++11标准引入的属性,该属性用于声明函数不会返回。注意,这里的所谓函数不返回和函数返回类型为void不同,返回类型为void说明函数还是会返回到调用者,只不过没有返回值;而用noreturn属性声明的函数编译器会认为在这个函数中执行流会被中断,函数不会返回到其调用者帮助编译器优化,避免生成不必要的返回代码。可用于终止程序的函数,当函数会调用 exit()、abort() 或类似函数终止程序时:

[[noreturn]] void fatal_error(const char* message) {
    std::cerr << "Error: " << message << std::endl;
    std::exit(1); // 程序终止,不会返回
}

1.4、可变参数模板

例子1:

#include <iostream>
template<class T, class U>
T baz(T t, U u)
{
	std::cout << t << ":" << u << std::endl;
	return t;
}
template<class …Args>
void foo(Args …args) {}
template<class …Args>
class bar {
public:
	bar(Args …args)
	{
		foo(baz(&args, args) …);
	}
};
int main()
{
	bar<int, double, unsigned int> b(1, 5.0, 8);
}

其中baz(&args, args)…是包展开,而baz(&args, args)就是模式,也可以理解为包展开的方法。所以这段代码相当于:

class bar {
public:
	bar(int a1, double a2, unsigned int a3)
	{
		foo(baz(&a1, a1), baz(&a2, a2), baz(&a3, a3));
	}
};

例子2:

template<class …T>
int baz(T …t)
{
	return 0;
}
template<class …Args>
void foo(Args …args) {}
template<class …Args>
class bar {
public:
	bar(Args …args)
	{
		foo(baz(&args…) + args…);
	}
};
int main()
{
	bar<int, double, unsigned int> b(1, 5.0, 8);
}

在上面这段代码中形参包又是怎么解包的?要理解这个解包过程,需要将其分为两个部分:第一个部分是对函数模板baz(&args…)的解包,其中&args…是包展开,&args是模式,这部分会被展开为baz(&a1, &a2, &a3);第二部分是对foo(baz(&args…) + args…)的解包,由于baz(&args…)已经被解包,因此现在相当于解包的是foo(baz(&a1, &a2, &a3) +args…),其中baz(&a1, &a2, &a3) + args…是包展开,baz(&a1, &a2, &a3) + args是模式,最后的结果为foo(baz(&a1, &a2, &a3) + a1, baz(&a1, &a2, &a3) +a2, baz(&a1, &a2, &a3) + a3)。

1.5、sizeof…运算符

我们知道sizeof运算符能获取某个对象类型的字节大小。不过当sizeof之后紧跟…时其含义就完全不同了。sizeof…是专门针对形参包引入的新运算符,目的是获取形参包中形参的个数,返回的类型是std::size_t,例如:

#include <iostream>
template<class …Args> void foo(Args …args)
{
	std::cout << "foo sizeof…(args) = " << sizeof…(args) <<
		std::endl;
}
template<class …Args>
class bar
{
public:
	bar() {
		std::cout << "bar sizeof…(Args) = " << sizeof…(Args) <<
			std::endl;
	}
};
int main()
{
	foo();
	foo(1, 2, 3);
	bar<> b1;
	bar<int, double> b2;
}
foo sizeof…(args) = 0
foo sizeof…(args) = 3
bar sizeof…(Args) = 0
bar sizeof…(Args) = 2

2、C++14

2.1、auto返回类型推导

C++14标准支持对返回类型声明为auto的推导,例如:

auto sum(int a1, int a2) { return a1 + a2; }

编译器会帮助我们推导sum的返回值,由于a1和a2都是int类型,所以其返回类型也是int,于是返回类型被推导为int类型。如果有多重返回值,那么需要保证返回值类型是相同的。

lambda表达式返回auto引用的方法:

auto l = [](int &i)->auto& { return i; };

起初在后置返回类型中使用auto是不允许的,但是后来人们发现,这是唯一让lambda表达式通过推导返回引用类型的方法了。

2.2、decltype(auto)

在C++14标准中出现了decltype和auto两个关键字的结合体:decltype(auto)。它的作用简单来说,就是告诉编译器用decltype的推导表达式规则来推导auto。另外需要注意的是,decltype(auto)必须单独声明,也就是它不能结合指针、引用以及cv限定符

int i;
int&& f();
auto x1a = i; // x1a推导类型为int
decltype(auto) x1d = i; // x1d推导类型为int
auto x2a = (i); // x2a推导类型为int
decltype(auto) x2d = (i); // x2d推导类型为int&
auto x3a = f(); // x3a推导类型为int
decltype(auto) x3d = f(); // x3d推导类型为int&&
auto x4a = { 1, 2 }; // x4a推导类型为
std::initializer_list<int>
decltype(auto) x4d = { 1, 2 }; // 编译失败, {1, 2}不是表达式
auto *x5a = &i; // x5a推导类型为int*
decltype(auto)*x5d = &i; // 编译失败,decltype(auto)必须单独声明

2.3、lambda初始化捕捉

int main()
{
 int x = 5;
 auto foo = [r = x + 1]{ return r; };
}

2.4、deprecated弃用

deprecated是在C++14标准中引入的属性,带有此属性的实体被声明为弃用,虽然在代码中依然可以使用它们,但是并不鼓励这么做。当代码中出现带有弃用属性的实体时,编译器通常会给出警告而不是错误。

[[deprecated]] void foo() {}
class [[deprecated]] X {};
int main()
{
	X x;
	foo();
}

deprecated属性还能接受一个参数用来指示弃用的具体原因或者提示用户使用新的函数,比如:

[[deprecated("foo was deprecated, use bar instead")]] void foo()
{}
void bar() {}
int main()
{
	foo();
}

3、C++17

3.1、嵌套命名空间的简化语法

C++17标准允许使用一种更简洁的形式描述嵌套命名空间,例如:

namespace A::B::C {
 int foo() { return 5; }
}

3.2、非类型模板形参占位符

C++17标准对auto关键字又一次进行了扩展,使它可以作为非类型模板形参的占位符。

#include <iostream>
template<auto N>
void f()
{
 std::cout << N << std::endl;
}
int main()
{
 f<5>(); // N为int类型
 f<'c'>(); // N为char类型
 f<5.0>(); // 编译失败,模板参数不能为double
}

3.3、lambda捕获*this

class Work
{
 private:
  int value;
 public:
  Work() : value(42) {}
  std::future<int> spawn()
  {
  return std::async([=, *this]() -> int { return value; });
  }
};

在上面的代码中没有再使用tmp=this来初始化捕获列表,而是直接使用this。在lambda表达式内也没有再使用tmp.value而是直接返回了value。编译运行这段代码可以得到预期的结果42。从结果可以看出,[this]的语法让程序生成了一个this对象的副本并存储在lambda表达式内,可以在lambda表达式内直接访问这个复制对象的成员,消除了之前lambda表达式需要通过tmp访问对象成员的尴尬。

3.4、支持初始化语句的if、支持初始化语句的switch

if (init; condition) {}
switch (int a = 0; type)
{
case type::type1:
	break;
case type::type2:
	break;
}

3.5、结构化绑定

C++11中函数返回多个数据:

#include <iostream>
#include <tuple>
std::tuple<int, int> return_multiple_values()
{
 return std::make_tuple(11, 7);
}
int main()
{
 int x = 0, y = 0;
 std::tie(x, y) = return_multiple_values();
 std::cout << "x=" << x << " y=" << y << std::endl;
}

C++17中的结构化绑定

#include <iostream>
#include <tuple>
auto return_multiple_values()
{
 return std::make_tuple(11, 7);
}
int main()
{
 auto[x, y] = return_multiple_values();
 std::cout << "x=" << x << " y=" << y << std::endl;
}
#include <iostream>
#include <string>
struct BindTest {
 int a = 42;
 std::string b = "hello structured binding";
};
int main()
{
 BindTest bt;
 auto[x, y] = bt;
 std::cout << "x=" << x << " y=" << y << std::endl;
}
#include <iostream>
#include <string>
#include <vector>
struct BindTest {
 int a = 42;
 std::string b = "hello structured binding";
};
int main()
{
 std::vector<BindTest> bt{ {11, "hello"}, {7, "c++"}, {42,
"world"} };
 for (const auto& [x, y] : bt) {
 std::cout << "x=" << x << " y=" << y << std::endl;
 }
}

请注意以上代码的for循环部分。在这个基于范围的for循环中,通过结构化绑定直接将x和y绑定到向量bt中的结构体子对象上,省去了通过向量的元素访问成员变量a和b的步骤。

3.6、inline内联定义静态变量

在C++17标准之前,定义类的非常量静态成员变量是一件让人头痛的事情,因为变量的声明和定义必须分开进行。为了解决上面这些问题,C++17标准中增强了inline说明符的能力,它允许我们内联定义静态变量,例如:

#include <iostream>
#include <string>
class X {
public:
	inline static std::string text{ "hello" };
};
int main()
{
	X::text += " world";
	std::cout << X::text << std::endl;
}

3.7、fallthrough

fallthrough是C++17标准中引入的属性,该属性可以在switch语句的上下文中提示编译器直落行为是有意的,并不需要给出警告。比如:

void bar() {}
void foo(int a)
{
	switch (a)
	{
	case 0:
		break;
	case 1:
		bar();
		[[fallthrough]];
	case 2:
		bar();
		break;
	default:
		break;
	}
}
int main()
{
	foo(1);
}

3.8、nodiscard

nodiscard是在C++17标准中引入的属性,该属性声明函数的返回值不应该被舍弃,否则鼓励编译器给出警告提示。

class [[nodiscard]] X {};
[[nodiscard]] int foo() { return 1; }
X bar() { return X(); };
int main()
{
	X x;
	foo();
	bar();
}

在上面的代码中,函数foo带有nodiscard属性,所以在main函数中忽略foo函数的返回值会让编译器发出警告。类X也被声明为nodiscard,不过该属性对类本身没有任何影响,编译器不会给出警告。但是当类X作为bar函数的返回值时情况就不同了,这时候相当于声明了函数[[nodiscard]] X bar()。在main函数中,忽略bar函数返回值的行为也会引发一个警告。需要注意的是,nodiscard属性只适用于返回值类型的函数,对于返回引用的函数使用nodiscard属性是没有作用的。

nodiscard属性有几个常用的场合
1.防止资源泄露,对于像malloc或者new这样的函数或者运算符,它们返回的内存指针是需要及时释放的,可以使用nodiscard属性提示调用者不要忽略返回值。
2.对于工厂函数而言,真正有意义的是回返的对象而不是工厂函数,将nodiscard属性应用在工厂函数中也可以提示调用者别忘了使用对象,否则程序什么也不会做。
3.对于返回值会影响程序运行流程的函数而言,nodiscard属性也是相当合适的,它告诉调用方其返回值应该用于控制后续的流程。

3.9、预处理器__has_include

C++17标准为预处理器增加了一个新特性__has_include,用于判断某个头文件是否能够被包含进来,例如:

#if __has_include(<optional>)
# include <optional>
# define have_optional 1
#elif __has_include(<experimental/optional>)
# include <experimental/optional>
# define have_optional 1
# define experimental_optional 1
#else
# define have_optional 0
#endif

如果__has_include()中的头文件optional可以被包含进来,那么表达式求值为1;否则求值为0。请注意,__has_include的实参必须和#include的实参具有同样的形式,否则会导致编译错误。另外,__has_include并不关心头文件是否已经被包含进来。

3.10、折叠表达式

使用折叠表达式的特性改写递归的例子:

#include <iostream>
template<class… Args>
auto sum(Args …args)
{
	return (args + …);
}
int main()
{
	std::cout << sum(1, 5.0, 11.7) << std::endl;
}

如果读者是第一次接触折叠表达式,一定会为以上代码的简洁感到惊叹。在这份代码中,我们不再需要编写多个sum函数,然后通过递归的方式求和。需要做的只是按照折叠表达式的规则折叠形参包(args +…)。根据折叠表达式的规则,(args +…)会被折叠为arg0 + (arg1 + arg2),即1 + (5.0 + 11.7)。

在C++17的标准中有4种折叠规则,分别是一元向左折叠、一元向右折叠、二元向左折叠和二元向右折叠折叠表达式必须用括号 () 包围,否则编译器无法识别参数包的展开模式。上面的例子就是一个典型的一元向右折叠:

(args op …)折叠为(arg0 op(arg1 op …(argN - 1 op argN)))

对于一元向左折叠而言,折叠方向正好相反:

(… op args)折叠为((((arg0 op arg1) op arg2) op …) op argN)

二元折叠总体上和一元相同,唯一的区别是多了一个初始值,比如二元向右折叠:

(args op … op init)折叠为(arg0 op(arg1 op …(argN - 1 op(argN op init)))

二元向左折叠也是只有方向上正好相反:

(init op … op args)折叠为(((((init op arg0) op arg1) op arg2) op …) op argN)

在折叠规则中最重要的一点就是操作数之间的结合顺序。如果在使用折叠表达式的时候不能清楚地区分它们,可能会造成编译失败,例如:

#include <iostream>
#include <string>
template<class… Args>
auto sum(Args …args)
{
	return (args + …);
}
int main()
{
	std::cout << sum(std::string("hello "), "c++ ", "world") <<
	std::endl; // 编译错误
}

上面的代码会编译失败,理由很简单,因为折叠表达式(args + …)向右折叠,所以翻译出来的实际代码是(std::string(“hello”) + (“c++ ” + “world”))。但是两个原生的字符串类型是无法相加的,所以编译一定会报错。要使这段代码通过编译,只需要修改一下折叠表达式即可:

template<class… Args>
auto sum(Args …args)
{
	return (… + args);
}

最后让我们来看一个有初始化值的例子:

#include <iostream>
#include <string>
template<class …Args>
void print(Args …args)
{
	(std::cout << … << args) << std::endl;
}
int main()
{
	print(std::string("hello "), "c++ ", "world");
}

在上面的代码中,print是一个输出函数,它会将传入的实参输出到控制台上。该函数运用了二元向左折叠(std::cout <<…<<args),其中std::cout是初始化值,编译器会将代码翻译为(((std::cout << std::string(“hello “))<< “c++ “)<< “world”) << std::endl;。

一元折叠表达式中空参数包的特殊处理
比如:

#include<iostream>
using namespace std;
template<typename ...Args>
int sum(Args ...args)
{
	return (args + ...);
}
int main(void)
{
	cout << sum() << endl;
	return 0;
}

编译器报错:“+上的一元fold表达式必须具有非空扩展”。改成二元折叠表达式即可支持空参数包:

#include<iostream>
using namespace std;
template<typename ...Args>
int sum(Args ...args)
{
	return (args + ... + 0);
}
int main(void)
{
	cout << sum() << endl;
	return 0;
}

3.11、通过初始化构造推导类模板的模板实参

在C++17标准之前,实例化类模板必须显式指定模板实参,例如:

std::tuple<int, double, const char*> v{5, 11.7, "hello world"};

C++17标准支持了类模板的模板实参推导,上面的代码可以进一步简化为:

std::tuple v{ 5, 11.7, "hello world" };

类模板例子:

template<class T1, class T2>
struct foo
{
	foo(T1, T2) {}
};
int main()
{
	foo v1(5, 6.8); // 编译成功
	foo<> v2(5, 6.8); // 编译错误
	foo<int> v3(5, 6.8); // 编译错误
	foo<int, double> v4(5, 6.8); // 编译成功
}

在上面的代码中,v1和v4可以顺利通过编译,其中v1符合类模板实参的推导要求,而v4则显式指定了模板实参。v2和v3就没那么幸运了,它们都没有完整地指定模板实参,这是编译器不能接受的。

4、C++20

4.1、lambda捕获[=, this]

这一次修改没有加强lambda表达式的能力,而是让this指针的相关语义更加明确。我们知道[=]可以捕获this指针,相似的,[=,this]会捕获this对象的副本。但是在代码中大量出现[=]和[=,this]的时候我们可能很容易忘记前者与后者的区别。为了解决这个问题,在C++20标准中引入了[=, this]捕获this指针的语法,它实际上表达的意思和[=]相同,目的是让程序员们区分它与[=,*this]的不同。
在C++20标准中还特别强调了要用[=, this]代替[=]。
如果用GCC编译下面这段代码:

template <class T>
void g(T) {}
struct Foo {
	int n = 0;
	void f(int a) {
		g([=](int k) { return n + a * k; });
	}
};

编译器会输出警告信息,表示标准已经不再支持使用[=]隐式捕获this指针了,提示用户显式添加this或者*this。最后值得注意的是,同时用两种语法捕获this指针是不允许的。

4.2、模板对lambda的支持

语法非常简单:

[] <typename T>(T t) {}

以及

auto f = []<typename T>(std::vector<T> vector) {
// …
};

4.3、使用using打开强枚举类型

C++20标准扩展了using功能,它可以打开强枚举类型的命名空间。在一些情况下,这样做会让代码更加简洁易读,例如:

const char* ColorToString(Color c)
{
	switch (c)
	{
		using enum Color;
	case Red: 
        return "Red";
	case Green: 
        return "Green";
	case Blue: 
        return "Blue";
	default:
		return "none";
	}
}

4.4、范围for循环初始化语句

for (T thing = foo(); auto & x :thing.items()) {}

4.5、三向比较运算符

三向比较就是在形如lhs <=> rhs的表达式中,两个比较的操作数lhs和rhs通过<=>比较可能产生3种结果,该结果可以和0比较,小于0、等于0或者大于0分别对应lhs < rhs、lhs == rhs和lhs > rhs。举例来说:

bool b = 7 <=> 11 < 0; // b == true

C++20标准规定,如果用户为自定义类型声明了三向比较运算符,那么编译器会为其自动生成<、>、<=和>=这4种运算符函数。为什么标准只允许自动生成4种运算符函数,却不能自动生成==和=!这两个运算符函数呢?实际上这里存在一个严重的性能问题。假设有一个结构体:

struct S {
	std::vector<std::string> names;
	auto operator<=>(const S&) const = default;
};

它的三向比较运算符的默认实现这样的:

template<typename T>
std::strong_ordering operator<=>(const std::vector<T>& lhs, const
	std::vector<T>& rhs)
{
	size_t min_size = min(lhs.size(), rhs.size());
	for (size_t i = 0; i != min_size; ++i) {
		if (auto const cmp = std::compare_3way(lhs[i], rhs[i]);
			cmp != 0) {
			return cmp;
		}
	}
	return lhs.size() <=> rhs.size();
}

这个实现对于<和>这样的运算符函数没有问题,因为需要比较容器中的每个元素。但是==运算符就显得十分低效,对于==运算符高效的做法是先比较容器中的元素数量是否相等,如果元素数量不同,则直接返回false。现在C++20标准已经推荐使用 <=> 和 == 运算符自动生成其他比较运算符函数

4.6、协程

4.6.1、什么是协程

协程(Coroutine)是一种用户态轻量级线程,允许在多个执行上下文之间进行协作式切换。与操作系统管理的线程不同,协程的调度完全由程序控制,具有以下核心特征:
协作式调度:主动让出执行权(非抢占)
状态保持:挂起时自动保存局部变量
高效切换:无内核态切换开销
这些特性使得协程非常适合处理 I/O 密集型任务,例如网络请求、文件读写等。

4.6.2、协程分类体系

图片[1]-C++新特性(C++11~C++20)-阿尔法欧欧

C++20采用无栈协程(Stackless Coroutine)方案,通过状态机转换实现协程切换。与传统线程相比,协程切换成本极低(约纳秒级),且完全由程序控制。

特性有栈协程无栈协程(C++20采用)
内存占用每个协程独立栈(通常MB级)共享栈(KB级)
切换开销较高(需要切换栈指针)极低(仅修改状态机)
挂起深度支持任意嵌套挂起只能在顶层函数挂起
典型代表Go goroutine, Lua协程C# async/await, Python生成器

4.6.3、协程的核心部分

  • Coroutine Frame (协程帧):协程帧是协程的运行时状态的存储区域,包含了协程的局部变量、参数以及恢复执行所需的信息。协程帧通常分配在堆上,由编译器自动生成和管理。
  • Promise (承诺):Promise 是一个接口,用于定义协程的行为。它包含了协程的返回值类型、异常处理方式以及挂起/恢复逻辑。你可以通过自定义 Promise 类型,来控制协程的行为。
  • Coroutine Handle (协程句柄):协程句柄是一个轻量级的指针,用于操作协程。你可以通过协程句柄来恢复、销毁协程。
  • Awaitable (可等待对象):Awaitable 是一个类型,用于表示一个异步操作。当协程遇到一个 Awaitable 对象时,它可以选择挂起执行,等待异步操作完成。Awaitable 对象需要提供 await_ready()await_suspend() 和 await_resume() 三个方法,用于控制协程的挂起、恢复逻辑。

4.6.4、深入理解 Promise 类型

Promise 类型是协程的核心,它定义了协程的行为,包括返回值类型、异常处理方式以及挂起/恢复逻辑。你可以通过自定义 Promise 类型,来控制协程的行为。让我们更深入地了解 Promise 类型。
一个 Promise 类型需要提供以下方法:

  • get_return_object():这个方法用于返回协程的返回值。返回值类型可以是 void,也可以是其他类型。例如,你可以返回一个 Task 对象,用于表示一个异步任务。
  • initial_suspend():这个方法用于控制协程的初始挂起行为。如果返回 std::suspend_always,则协程在开始执行时会立即挂起。如果返回 std::suspend_never,则协程会立即执行。
  • final_suspend():这个方法用于控制协程的最终挂起行为。当协程执行完毕或抛出异常时,会调用这个方法。如果返回 std::suspend_always,则协程会一直挂起,直到被手动恢复或者手动的调用 destroy 来释放协程。如果返回 std::suspend_never,则协程会被立即销毁。
  • return_void():这个方法用于处理协程的正常返回。当协程执行到 co_return 语句时,会调用这个方法。
  • return_value(T value):这个方法用于处理协程的返回值。当协程执行到 co_return value 语句时,会调用这个方法。T 是协程的返回值类型。
  • unhandled_exception():这个方法用于处理协程的异常。当协程抛出异常时,会调用这个方法。
  • yield_value(T value):这个方法用于处理协程的 co_yield 语句。co_yield 语句用于生成一个值,并暂停协程的执行。T 是生成的值的类型。

4.6.5、示例代码

#include<iostream>
#include<coroutine>
using namespace std;
template<typename T>
class FutureType
{
public:
	class promise_type
	{
	public:
		// 这个方法用于返回协程的返回值。返回值类型可以是 void,也可以是其他类型。
		auto get_return_object()
		{
			return coroutine_handle<promise_type>::from_promise(*this);
		}
		// 这个方法用于控制协程的初始挂起行为。如果返回 std::suspend_always,则协程在开始执行时会立即挂起。
		// 如果返回 std::suspend_never,则协程会立即执行。
		auto initial_suspend()
		{
			return std::suspend_never();
		}
		// 这个方法用于控制协程的最终挂起行为。当协程执行完毕或抛出异常时,会调用这个方法。
		// 如果返回 std::suspend_always,则协程会一直挂起,直到被手动恢复。
		// 如果返回 std::suspend_never,则协程会被立即销毁。
		auto final_suspend()noexcept(true)
		{
			return std::suspend_always();
		}
		// 这个方法用于处理协程的异常。当协程抛出异常时,会调用这个方法。
		void unhandled_exception()
		{
			std::terminate();
		}
		// 这个方法用于处理协程的正常返回。当协程执行到 co_return 语句时,会调用这个方法。
		// 协同例程的承诺不可同时包含“return_value”和“return_void”函数
		//auto return_void()
		//{
		//	return;
		//}
		// 这个方法用于处理协程的返回值。当协程执行到 co_return value 语句时,会调用这个方法。
		// 协同例程的承诺不可同时包含“return_value”和“return_void”函数
		auto return_value(T value)
		{
			m_vul = value;
		}
		// 这个方法用于处理协程的 co_yield 语句。co_yield 语句用于生成一个值,并暂停协程的执行。
		auto yield_value(T value)
		{
			m_vul = value;
			return std::suspend_always();
		}
		T m_vul;
	};
	FutureType(coroutine_handle<promise_type> h) :m_handle(h)
	{
	}
	// 恢复协程
	void resume()
	{
		if (!m_handle.done())
		{
			m_handle.resume();
		}
	}
	// 销毁协程
	void destroy()
	{
		m_handle.destroy();
	}
	// 协程是否立即返回
	auto await_ready()
	{
		return false;
	}
	// 挂起时的动作
	auto await_suspend(coroutine_handle<> h)
	{
		h.resume();
		return true;
	}
	// 恢复协程后的返回值
	auto await_resume()
	{
		return m_handle.promise().m_vul;
	}
	auto getValue()
	{
		return m_handle.promise().m_vul;
	}
private:
	coroutine_handle<promise_type> m_handle;
};

FutureType<int> fun()
{
	co_return 22;
}
int main(void)
{
	auto i = fun();
	cout << "i=" << i.getValue() << endl;
	i.resume();
	i.destroy();

	return 0;
}

4.7、模块module

模块(module)是C++20标准引入的一个新特性,它的主要用途是将大型工程中的代码拆分成独立的逻辑单元,以方便大型工程的代码管理。模块能够大大减少使用头文件带来的问题,例如在使用头文件时经常会遇到宏和函数的重定义,而模块则会好很多,因为宏和未导出名称对于导入模块是不可见的。使用模块也能大幅提升编译效率,因为编译后的模块信息会存储在一个二进制文件中,编译器对于它的处理速度要远快于单纯使用文本替换的头文件方法。可惜到目前为止并没有主流编译器完全支持该特性,所以这里只做简单介绍:

// helloworld.ixx
export module helloworld;
import std.core;
export void hello() {
	std::cout << "Hello world!\n";
}
// modules_test.cpp
import helloworld;
int main()
{
	hello();
}

4.8、概念和约束

类模板、函数模板和非模板函数(通常是类模板的成员)可能与约束(constraint)相关联,该约束指定对模板参数的要求(requirements),可用于选择最合适的函数重载和模板特化。约束是使用模板时需要通过模板参数满足的条件或要求这些要求的命名集合称为概念(concept)。每个概念都是一个谓词(predicate),在编译时进行评估,并成为模板接口的一部分,用作约束。由于在编译时评估约束,因此它们可以提供更有意义的错误消息和运行时安全性。约束是表达式,概念是定义这些表达式的一种方式。

语法:

template < 模板形参列表 >
concept 概念名 = 约束表达式;

例子:

template<class T, class U>
concept isChildOf = std::is_base_of<U, T>::value;//类型约束, T必须继承自U

/***
    使用概念
    注意:概念在类型约束中接受的实参要比它的形参列表要求的要少一个,
    因为按语境推导出的类型会隐式地作第一个实参
***/
template<isChildOf<Base> T>
void f(T); // T 被 isChildOf<T, Base> 约束

组成概念的约束表达式也可以用requires字句定义:

#include <concepts>
 
// 概念 "Hashable" 的声明可以被符合以下条件的任意类型 T 满足:
// 对于 T 类型的值 a,表达式 std::hash<T>{}(a) 可以编译并且它的结果可以转换到 std::size_t
template<typename T>
concept Hashable = requires(T a)
{
    { std::hash<T>{}(a) } -> std::convertible_to<std::size_t>;
};
 
//在函数模板中使用概念 
template<Hashable T>
void f(T); // 受约束的 C++20 函数模板

也可以按如下格式使用概念:

template<typename T> requires Hashable<T> //requires子句放在template<>之后
void f(T) 
{
    //...
}
 
template<typename T>
void f(T) requires Hashable<T>  //requires子句放在函数参数列表之后
{
    //...
}

require表达式具有如下语法:

requires { 要求序列 }		
requires ( 形参列表(可选) ) { 要求序列 }

其中,要求序列根据复杂程度可以分为以下四种:

  • 简单要求(simple requirement)
  • 类型要求(type requirement)
  • 复合要求(compound requirement)
  • 嵌套要求(nested requirement)
// 简单要求
template<typename T>
concept Addable = requires (T a, T b)
{
    a + b; // “表达式 a + b 是可编译的合法表达式”
};
 
// 类型要求
template<typename T>
using Ref = T&;
 
template<typename T>
concept C = requires
{
    typename T::inner; // 要求的嵌套成员名
    typename S<T>;     // 要求的类模板特化
    typename Ref<T>;   // 要求的别名模板替换
};
 
// 嵌套要求
template <class T>
concept Semiregular = DefaultConstructible<T> &&
    CopyConstructible<T> && Destructible<T> && CopyAssignable<T> &&
requires(T a, size_t n)
{  
    requires Same<T*, decltype(&a)>; // 嵌套:“Same<...> 求值为 true”
    { a.~T() } noexcept; // 复合:"a.~T()" 是不抛出的合法表达式
    requires Same<T*, decltype(new T)>; // 嵌套:“Same<...> 求值为 true”
    requires Same<T*, decltype(new T[n])>; // 嵌套
    { delete new T }; // 复合
    { delete new T[n] }; // 复合
};

复合要求有自己的语法,因此我们将它单列出来,其语法形式为:

{ 表达式 } noexcept(可选) 返回类型要求(可选) ;		
返回类型要求	-> 类型约束

例子:

// 复合要求
template<typename T>
concept C2 = requires(T x)
{
    // 表达式 *x 必须合法
    // 并且类型 T::inner 必须合法
    // 并且 *x 的结果必须可以转换为 T::inner
    {*x} -> std::convertible_to<typename T::inner>;
 
    // 表达式 x + 1 必须合法
    // 并且 std::Same<decltype((x + 1)), int> 必须被满足
    // 也就是说,(x + 1) 必须是 int 类型的纯右值
    {x + 1} -> std::same_as<int>;
 
    // 表达式 x * 1 必须合法
    // 并且它的结果必须可以转换到 T
    {x * 1} -> std::convertible_to<T>;
};

4.9、范围

C++20 引入了一系列称为“范围(Ranges)”的新特性,这些特性为处理容器和范围提供了更简洁、更高效的方法。范围库主要包括视图(views)、算法(algorithms),旨在提高代码的可读性和性能。视图不会复制或者修改源数据,算法作用于源数据,会对源数据进行修改。

视图(Views)
视图是轻量级的、不可变的范围适配器,它们可以对现有范围进行转换或过滤,生成新的范围。View是Ranges的核心,相当于一个透镜,通过它可以观察Ranges的不同视图。视图是一个对数据的非拥有、可能懒加载的引用,它不会复制或修改原始数据,而是提供了一种方式来查看或处理这些数据。这使得我们可以在不修改原始数据的情况下,对数据进行过滤、映射、切片等各种复杂的操作。
简而言之,视图只是源数据的观察方式,如果源数据发生改变,视图也随之改变。以下是一些常用的视图:

std::vector<int> vec{ 12,565,48,32,44,18,21,0,98,99 };
std::vector<std::string> words = { "Hello", "World", "C++" };
std::map<std::string, int> mp{
	{ "C++98", 1998 },
	{ "C++03", 2003 },
	{ "C++11", 2011 },
	{ "C++14", 2014 },
	{ "C++17", 2017 },
	{ "C++20", 2020 }
};

// 返回一个表示整个范围的视图
auto a = std::ranges::views::all(vec); 
// 根据谓词对全部范围进行过滤(过滤出偶数)
auto a = std::ranges::views::filter(vec, [](int m) {return m % 2 == 0; }); 
// 提取前缀中满足条件的元素,遇到第一个不满足条件的元素即停止
auto a = std::ranges::views::take_while(vec, [](int n) { return n % 2 == 0; });
// 取前N个数据
auto a = std::ranges::views::take(vec, 3);
// 丢弃前N个数据
auto a = std::ranges::views::drop(vec, 3);
// 对范围中的每个元素应用一个函数,并生成一个新的范围(取平方)
auto a = std::ranges::views::transform(vec, [](int m) {return m * m; }); 
// 用于将一系列Range(通常为Range的Range)连接成一个单一的Range。
auto a = std::ranges::views::join(words);
// 逆转范围的元素顺序
auto a = ranges::views::reverse(vec);
// 0到容器大小的顺序数字序列的视图
auto a = std::ranges::views::iota(0, int(vec.size()));
// 取 std::vector<std::pair<A, B>>或者std::map<A, B>类型的值
auto a = mp | std::ranges::views::values;
// 取 std::vector<std::pair<A, B>>或者std::map<A, B>类型的索引key
auto a = mp | std::ranges::views::keys;

// 丢弃前两个数据,从第三个数据开始连续取5个数据
auto a = vec | std::ranges::views::drop(2) | std::ranges::views::take(5);

管道| 是 C++20 为范围库(Ranges Library)引入的语法糖,其本质是函数调用的语法转换:

vec | std::ranges::views::drop(2)  // 等价于 std::ranges::views::drop(vec , 2)
vec | std::ranges::views::all // 等价于 std::ranges::views::all(vec)

视图可组合,这一点很强大。传统写法虽然直观,但在链式操作时会变得冗长,管道语法更符合人类阅读习惯,尤其在处理复杂的数据转换时优势明显。所有标准视图适配器(如 transformtakedrop 等)都支持管道语法:

// 获取前3个偶数的平方
auto result = vec
    | std::views::filter([](int x) { return x % 2 == 0; })  // 过滤偶数
    | std::views::take(3)                                     // 取前3个
    | std::views::transform([](int x) { return x * x; });    // 平方

// 等价于嵌套调用:
auto result = std::views::transform(
    std::views::take(
        std::views::filter(vec, [](int x) { return x % 2 == 0; }),
        3
    ),
    [](int x) { return x * x; }
);

例子(将rgb字符串颜色转为三个数字)

#include <iostream>
#include <ranges>
#include <algorithm>
#include <vector>
#include <string>

using namespace std;

int main(void)
{
	std::string str{ "255,0,0" };
	std::vector<int> rgbvec(3, 0);
	auto rgb = str
		| ranges::views::split(',')
		| ranges::views::transform([](const auto& rng) {return std::stoi(std::string(rng.begin(), rng.end())); });
	size_t i = 0;
	for (const int& c : rgb)
	{
		rgbvec[i++] = c;
	}

	return 0;
}

算法(Algorithms)
C++20 范围库中的算法是对标准算法库(<algorithm>)的扩展,旨在与范围更好地协作。范围算法与std命名空间中的相应迭代器对算法几乎完全相同。 区别在于,它们具有概念强制约束,它们接受范围参数或更多迭代器参数对。 它们可以直接针对容器运行,并可以轻松链接在一起。

std::vector<int> vec{ 12,565,48,32,44,18,21,0,98,99 };

// 对源数据进行排序
std::ranges::sort(vec);
// 查找数据
auto it = std::ranges::find(vec, 18);
// 对范围中的每个元素应用一个函数(作用于源数据)
std::ranges::for_each(vec, [](int& n) {n *= 2; });
// 计算范围内等于某值的元素数量
int k = std::ranges::count(vec, 0);

5、重要细节及性能考虑

5.1、std::map 的元素类型

下述代码中it类型是std::pair<const std::string, int>,而不是std::pair<std:: string, int>,因为 std::map 的键(key)在其生命周期内必须保持不变。这是由 std::map 的底层实现决定的 —— 它通常基于红黑树等自平衡二叉搜索树,键的修改会破坏树的结构和排序规则,导致未定义行为。
因此std::map 的键(key)是 const 类型,即使用户不显式指定 const
底层实现要求std::map 依赖键的有序性(如红黑树的中序遍历),若键被修改,树的结构会被破坏。
安全设计:C++ 通过语法强制禁止修改键,防止用户意外破坏容器的一致性。

std::map<std::string, int> str2int;
for (std::pair<const std::string, int> &it : str2int) {}

5.2、字典结构中数据的顺序

在 C++ 里,标准的std::mapstd::unordered_map都无法保证元素按照插入顺序排列。std::map会依据键的比较函数对元素进行排序,而std::unordered_map则是根据哈希函数来存储元素,元素的顺序是不确定的。

5.3、容器性能

在 C++ 中,std::vector 是最常用的容器之一,其性能优化对于提升整体程序效率至关重要。

5.3.1、std::vector 默认扩容策略

标准要求:每次扩容后容量必须至少大于等于原有容量,且保证 push_back() 的均摊时间复杂度为 O(1)
常见实现:采用指数增长策略(通常为 2 倍 或 1.5 倍):
GCC/Clang:每次扩容为原有容量的 2 倍。例如:容量从 1 → 2 → 4 → 8 → 16 …
MSVC:每次扩容约为原有容量的 1.5 倍。例如:容量从 1 → 2 → 3 → 4 → 6 → 9 …

5.3.2、预分配内存

预分配内存:reserve() 避免重复扩容。实际开发中,建议通过 reserve() 主动控制内存分配,避免依赖默认扩容策略。
原理reserve() 预先分配足够内存,减少 push_back() 时的动态扩容次数(每次扩容可能导致元素复制)。
适用场景:已知或大致估计元素数量时(如读取文件前已知行数)。

std::vector<int> vec;
vec.reserve(1000);  // 预分配1000个元素的空间
for (int i = 0; i < 1000; ++i) {
    vec.push_back(i);  // 避免多次内存重新分配
}

精准控制容量:shrink_to_fit() 减少内存占用

vec.shrink_to_fit();  // 释放多余内存(C++11及以后)

在 C++ 中,reserveresize 和 shrink_to_fit 是 std::vector 用于管理内存和元素数量的三个重要方法,它们的功能和用途差异较大。以下是详细对比:

std::vector<int> vec;
vec.reserve(100);      // 容量 = 100,大小 = 0
vec.resize(10);        // 容量 = 100,大小 = 10
vec.shrink_to_fit();   // 请求容量收缩为10(实际效果依赖实现)

5.3.3、优先使用移动语义

优先使用移动语义:emplace_back() 替代 push_back()

// 使用emplace_back直接构造对象(避免拷贝/移动)
vec.emplace_back(10);  // 直接在尾部构造int(10)

利用 std::move() 转移所有权

std::vector<int> createVector() {
    std::vector<int> tmp = {1, 2, 3};
    return std::move(tmp);  // 强制转移所有权,避免拷贝
}

5.3.4、批量插入

insert() 替代循环插入,单次内存分配,比循环调用 push_back() 更高效。

std::vector<int> src = {1, 2, 3, 4, 5};
vec.insert(vec.end(), src.begin(), src.end());  // 批量插入

5.3.5、权衡 vector 与其他容器

  • 若需频繁随机访问 + 尾部操作 → vector
  • 若需频繁中间插入 / 删除 → list 或 deque
  • 若需高效查找 → map 或 unordered_map

5.4、多线程和协程

使用场景:

任务类型硬件环境最优方案关键优势
纯 CPU 密集单核单线程 + 无协程零调度开销,最大化 CPU 利用率
纯 CPU 密集多核多线程 / 多进程充分利用多核并行计算
IO 密集(少量 CPU)单核单线程 + 异步协程单线程处理海量并发 IO,避免线程切换开销
IO 密集(少量 CPU)多核多线程 + 异步协程利用多核并行处理 IO 请求
CPU 密集 + 大量 IO单核单线程 + 异步协程在单线程内交替执行 CPU 和 IO,最大化资源利用率
CPU 密集 + 大量 IO多核多线程 + 异步协程每个线程运行独立协程,同时利用多核计算和异步 IO

避免过度设计,若任务简单,优先使用同步代码(实现简单,调试方便),仅在性能瓶颈明确时引入异步或多线程。

6、代码规范

6.1、什么时候使用auto

1.当一眼就能看出声明变量的初始化类型的时候可以使用auto。
2.对于复杂的类型,例如lambda表达式、bind等直接使用auto。

6.2、lambda捕捉[=]

在C++20标准中特别强调了要用[=, this]代替[=]。

6.3、枚举

在编程过程中应该总是优先考虑强枚举类型

© 版权声明
THE END
喜欢就支持一下吧
点赞0赞赏 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容