C++新特性梳理02

之前一直在准备暑期实习的笔试和面试, 因此博客断更了很久…回头做6.5840发现自己的代码逻辑都快忘了…

先整理下实习求职过程中学习的C++的新特性, 由于C++11已经使用得很广泛了, 包括智能指针、各种转换运算符、lambda表达式已经成为面试常见考点了,这里我就不提这些了,因此我只梳理从C++17开始的知识点,包括C++17、C++20、C++23和少量C++26。

之前断更的6.5840的最后一个lab4B也会补上

1 结构体优化

1.1 支持指定项的初始化列表

C++20支持类似golang的结构体声明, 可以显式地为每个成员进行命名, 这在结构体成员数量很多时有助于提高代码可读性:

1
2
3
4
5
6
7
8
struct A {
int x, y, z;
};

int main() {
// C++20 introduces the designated initializer list W
struct A a{.x = 1, .y = 2, .z = 3};
}

1.2 结构体成员绑定

C++17运行直接从结构体成员初始化变量:

1
2
3
4
5
6
7
8
9
10
11
12
struct A {
int x, y, z;
};

int main() {
// C++20 introduces the designated initializer list W
struct A a{.x = 1, .y = 2, .z = 3};

// Structure Binding declaration C++17 binds the specified names to elements of
// initializer:
auto [x, y, z] = a;
}

同时绑定时支持&&&获取引用:

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

using namespace std;

struct A {
int x, y, z;
};

int main() {
// C++20 introduces the designated initializer list W
struct A a{.x = 1, .y = 2, .z = 3};

// Structure Binding declaration C++17 binds the specified names to elements of
// initializer:
auto [x, y, z] = a;
auto &[x2, y2, z2] = a;

a.x++;

cout << x2 << endl; // 2
}

2 放松对constexpr用于函数的限制

constexpr用于修饰函数时,表示该函数可以在编译时求值。这样的函数可以用于计算编译时常量表达式,例如数组的大小、整数模板参数、枚举值等。constexpr修饰的函数存在一些限制, 但不同的版本限制不同, 每一个标准基本上都解除了之前的限制, 先总结如下:

标准 解除的限制
C++14 只能有一个return语句, 且不应包含循环和switch
C++20 不能包含assert
C++23 不能包含static变量

目前所有标准下都不能解除的限制:

  • 不能使用runtime的特性, 如try-catchexception
  • 不能包含goto
  • 不能使用运行时的类型转换reinterpret cast

案例:

1
2
3
4
5
6
7
8
9
10
struct A {
int v { 3 };
constexpr int f() const { return v; }
static constexpr int g() { return 3; }
};
A a1;
// constexpr int x = a1.f(); // compile error, f() is evaluated at run-time
constexpr int y = a1.g(); // ok, same as 'A::g()'
constexpr A a2;
constexpr int x = a2.f(); // ok

注意, 这里的v尽管有默认的初始化值3, 但仍然依赖于运行时的实例, 因此constexpr int x = a1.f()会导致编译器报错, 但静态成员函数g不依赖于类的任何特定实例,因此可以在没有创建任何 A 类型对象的情况下调用。

3 新引入的const*关键字

3.1 consteval关键字

C++20引入了consteval关键字, 这个关键字的含义为: 保证所修饰的函数在编译时就能求值

这一关键字会让人感到迷惑, 其与constexpr的区别是什么? 其区别其实就是一个是建议, 一个是硬性要求:

  • constexpr关键字是指示编译器尽可能在编译时对函数或变量进行求值。但如果编译时无法求值,它不会导致编译错误,函数或变量也可以在运行时求值。
  • consteval关键字表示函数必须在编译时求值,每次调用都必须产生一个编译时常量。如果函数的调用不能在编译时求值,编译器将报错。

3.2 constinit关键字

C++20引入的constinit关键字保证变量的初始化必须在编译时完成, 运行时初始化的变量将引起报错:

1
2
3
4
5
6
7
constexpr int square(int value) {
return value * value;
}
constinit int v1 = square(4); // compile-time evaluation
v1 = 3; // ok, v1 can change
int a = 4; // "v" is dynamic
// constinit int v2 = square(a); // compile error

3.3 if constexpr

C++17引入if constexpr, 允许在编译时根据常量表达式进行条件分支。与普通的if语句不同,if constexpr允许编译器在编译时就丢弃不适用的代码分支。

使用if constexpr的主要作用是在模板编程中提供更多的灵活性。当模板被不同类型的参数实例化时,某些代码可能只对特定类型有效,而if constexpr可以确保只有在条件为真时,相关的代码才会被编译和实例化。这样可以避免编译错误,并减少不必要的代码膨胀。

下面是一个简单的例子:

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

using namespace std;

template <typename T> auto process(const T &value) {
if constexpr (std::is_integral<T>()) {
cout << "传入的是整数" << endl;
return value + 1;
} else {
cout << "传入的是浮点数" << endl;
return value / 2;
}
}

int main() {
cout << process(5) << endl;
cout << process(5.0) << endl;
}

3.4 is constant evaluated

C++20引入的is_constant_evaluated用于检测函数自身是否在一个常量求值:

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

constexpr int compute(int x) {
if (std::is_constant_evaluated()) {
// 编译时求值的路径
return x * x;
} else {
// 运行时求值的路径
return x + x;
}
}

int main() {
constexpr int compile_time_result = compute(10); // 编译时求值,结果为100
int runtime_result = compute(10); // 运行时求值,结果为20

std::cout << "Compile time: " << compile_time_result << '\n';
std::cout << "Run time: " << runtime_result << '\n';
}

3.5 if consteval

if constevalC++23中引入的一个新特性,它解决了std::is_constant_evaluated()在某些情况下可能带来的问题。具体来说,if consteval可以直接检测当前的上下文是否为常量求值上下文,而不需要任何参数。

C++20中,std::is_constant_evaluated()函数用于检测其调用是否发生在常量求值的上下文中。当std::is_constant_evaluated()被用作if constexpr语句的条件时,它总是返回true,这可能导致逻辑错误。因为if constexpr要求其条件必须是一个编译时已知的常量表达式,所以std::is_constant_evaluated()在这种情况下的行为并不总是符合预期。

if consteval提供了一种更清晰、更直接的方式来检测编译时求值的上下文,而且它不需要包含任何头文件。此外,如果if consteval的结果为true,可以在其内部调用consteval函数,这在使用std::is_constant_evaluated()时是不可能的:

1
2
3
4
5
6
7
8
9
constexpr int g(int i) {
if consteval {
// 如果g在编译时求值的上下文中被调用,则执行此分支
return f(i);
} else {
// 否则,执行此分支
return fallback();
}
}

在这个例子中,if consteval确保只有在编译时求值的上下文中,f(i)才会被调用。这样可以避免在运行时上下文中错误地调用consteval函数,从而解决了std::is_constant_evaluated()可能带来的问题。

最后补充说明if constevalif constexpr的区别

  • if constexpr: 用于编译时的条件分支。如果条件为真,则编译器只会编译该分支内的代码,其他分支的代码会被丢弃。这对于模板编程非常有用,因为它可以在不同的模板实例化中根据类型选择不同的代码路径。if constexpr的条件必须是一个编译时已知的常量表达式¹。

  • if consteval: 用于检测当前的上下文是否为常量求值上下文。如果if consteval的条件为真,那么它内部的代码只能在编译时求值的上下文中执行。这意味着,如果if consteval的条件为真,可以确信当前的代码是在编译时执行的。这对于需要区分编译时和运行时代码路径的情况非常有用²。

4 类型操作的优化

4.1 bit_cast运算符

C++20引入std::bit_cast, 作用是在不同类型之间进行位级别的转换。

使用std::bit_cast的主要优点是它提供了一种类型安全的方式来重新解释数据的位表示,这在之前通常需要使用reinterpret_cast或直接内存操作来完成。std::bit_cast确保了源类型和目标类型的大小完全相同,并且不会违反严格别名规则。

以下是std::bit_cast的基本用法:

1
2
3
4
5
6
7
8
9
10
11
#include <bit>
#include <iostream>

int main() {
float f = 3.14159f;
// 将float类型的变量f的位表示转换为无符号整数
auto bits = std::bit_cast<unsigned int>(f);

std::cout << "Float value: " << f << '\n';
std::cout << "Bitwise representation: " << std::hex << bits << '\n';
}

在这个例子中,std::bit_cast将一个float类型的变量f的位表示转换为了一个unsigned int类型的值。这种转换是通过位复制完成的,而不是通过类型转换。

需要注意的是,std::bit_cast只能在两个类型的大小完全相同的情况下使用。如果尝试在大小不同的类型之间使用std::bit_cast,编译器将会报错。此外,std::bit_cast也不能用于含有引用成员、非平凡的构造函数或析构函数的类型。

std::bit_cast在需要精确控制对象表示的低级编程中非常有用,例如在硬件编程、网络通信或文件I/O中处理二进制数据。

4.2 [[no_unique_address]]

C++20引入新的属性[[no_unique_address]],用于告诉编译器一个类的非静态数据成员不需要有一个独一无二的地址。这意味着如果该成员是一个空类型(例如,没有数据成员的类),编译器可以优化它,使其不占用任何空间,就像它是一个空基类一样。

这个属性的主要用途是优化内存布局,特别是在使用空类型作为成员时。在没有[[no_unique_address]]的情况下,即使是空类型的成员也会占用一定的空间(通常是1字节),以确保它有一个独一无二的地址。但是使用了[[no_unique_address]]之后,这个空间就可以被省略,从而减少整个对象的大小。

下面是一个使用[[no_unique_address]]的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct Empty {}; // 空类型

struct WithoutNoUniqueAddress {
int data;
Empty e; // 即使是空类型,也会占用空间
};

struct WithNoUniqueAddress {
int data;
[[no_unique_address]] Empty e; // 不会占用额外空间
};

int main() {
// 通常情况下,空类型成员会占用至少1字节的空间
static_assert(sizeof(WithoutNoUniqueAddress) > sizeof(int));
// 使用了[[no_unique_address]],空类型成员可以不占用空间
static_assert(sizeof(WithNoUniqueAddress) == sizeof(int));
}

5 lambda表达式的优化

5.1 在lambda中使用constevalconstexpr

C++17支持在lambda中使用constexpr, C++20支持在lambda中使用consteval:

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

using namespace std;

int main() {
// constexpr lambda
auto func1 = [](int value) constexpr {
int ret = 1;
for (int i = 2; i <= value; i++)
ret *= i;
return ret;
};
auto func2 = [](int v) consteval { return v * 2; };

constexpr int v1 = func1(4) + func2(5);

cout << v1 << endl;
}

这个例子一开始很容易让人误解: consteval 的语义是保证所修饰的函数在编译时就能求值, 但这里的func2int v是未知的, 不是不满足这个语义吗?

原因是func2虽然被声明为consteval,但它是在constexpr表达式的上下文中使用的,这意味着它的参数在编译时是已知的。

5.2 支持模板&&模板参数类型校验

C++引入了lambda对模板的支持:

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

using namespace std;

int main() {
auto func1 = []<typename T>(T val) { cout << val << endl; };

func1(5);

auto func2 = []<typename T>(T val)
requires std::is_arithmetic_v<T>
{
cout << val << endl;
};

func2(5.4);
}

5.3 [[nodiscard]]属性

C++23引入[[nodiscard]]属性, 表示lambda表达式的返回值不能被丢弃:

1
2
3
auto lambda = [] [[nodiscard]] (){ return 4; };
lambda(); // compiler warning
auto x = lambda(); // ok

6 支持更多的工具处理宏

6.1 更多的预处理宏

c++23引入了如下的预处理宏:

语义
#if defined(MACRO) #ifdef MACRO一致
#elif defined(MACRO) #elifdef MACRO一致
#if !defined(MACRO) #ifndef MACRO一致
#elif !defined(MACRO) #elifndef MACRO一致

PS:尽量减少宏的使用, 尤其是在头文件中

6.2 更多处理源码位置宏的工具

首先先回顾源码位置相关的宏:

描述 示例 备注
__FILE__ 当前源文件的名称。 "main.cpp"
__LINE__ 当前源代码的行号。 42
__func__ 当前函数的名称。 "main"
__PRETTY_FUNCTION__ 当前函数的装饰过的名称,通常包括返回类型和参数类型。 "int main(int, char**)" GCC特有的扩展,在标准C++中并不保证可用

下面给出相关宏的一个代码示例:

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

void log(const std::string &message) {
std::cout << "File:" << __FILE__ << ", Line:" << __LINE__
<< ", func:" << __func__
<< ", PRETTY_FUNCTION:" << __PRETTY_FUNCTION__ << '\n';
}

int main() { log("Hello, World!"); }

C++20引入了一个新的工具std::source_location,提供了一种类型安全的方式来获取源代码位置的信息,而不需要依赖预处理器宏。std::source_location可以捕获调用点的文件名、行号、列号和函数名等信息,使得源代码位置的处理更加灵活和方便:

6.3 测试宏

C++17提供了__has_include判断头文件是否存在, C++20提供了__cpp_*判断是否支持指定的特性:

1
2
3
4
5
6
7
#if __has_include(<iostream>)
#include <iostream>
#endif

#if __cpp_constexpr
constexpr int square(int x) { return x * x; }
#endif

6.4 错误警告

C++23引入了 #warning "text"用于在编译时发出用户自定义的警告:

1
2
3
4
5
6
7
8
9
10
#include <iostream>
using namespace std;

#define SELFWARN

#if defined(SELFWARN)
#warning "自定义警告"
#endif

int main() { cout << "hello world" << endl; }