现代 C++ 随记

序言

现代 C++ 相关的笔记。

inline 和 static

唯一定义原则

https://en.cppreference.com/w/cpp/language/definition

extern int a; 是声明。
int a;, int a = 1;, extern int a = 1; 都是定义。

void foo(); 是声明。
void foo() {} 是定义。

static

  • 将定义的可见性限制到当前翻译单元,使得不同翻译单元之间同名定义不冲突。
  • 实际上根据不同的用法 static 可以控制可见性,也可以控制生命周期,这里我们只讨论可见性相关的功能。

inline

  • 可以在多个翻译单元内存在同名 inline 定义,编译器选择其中一份作为唯一的定义,具体选择哪份定义则是未定义行为。
  • 既有 static 的优点,可以写在头文件内被不同翻译单元包含,又有 extern 的优点,可以全局共享。

对于成员变量

inline static 的成员变量可以直接在头文件内声明与定义,免去了再在 cpp 文件里写一份成员变量定义的繁琐。

1
2
3
4
class Class
{
inline static int member = 42;
};

对于函数

  • 写在头文件里的 static 函数定义自然不会产生冲突,但是 static 也导致不同翻译单元之间各包含一份函数定义,导致最终生成的目标体积增大。inline 很好地解决了这个问题,因为连接器最终只会将 inline 函数链接到同一份定义,指向同一个函数地址。
  • include 同一份头文件更不会产生连接器链接到不同的定义(函数体)的问题。
  • 同时,整个定义都在 class / struct / union 内的函数,隐式的是 inline 的。
  • 顺便,直接写定义的模板函数是也默认 inline 的。

对于类

  • 可惜不能将 class 声明为 static,但是要解决这个问题也很简单,将类的定义放在一个匿名命名空间即可将该类的可见范围限制在当前翻译单元。
  • 匿名命名空间下的函数定义和 static 的函数定义效果也是一样的。

至于内联

  • 自 C++17 之后 inline 的意义产生了巨大的变化,对于函数的含义已经变为“容许重定义”而不是“建议内联”。
  • 比如对于 MSVC,可以使用 __forceinline 放宽编译器对内联函数的判断,不过编译器依旧不一定会听你的。

前向声明与 std::unique_ptr

假设我们前向声明了一个 T
当一个类持有 std::unique_ptr<T> 类型的成员变量时,该类的析构函数必须也只能在 cpp 内有定义(当然一般来说这时 cpp 内是有 T 的完整定义的)。
理由很简单,该类析构时需要调用 unique_ptr 所包裹对象的析构函数,但是默认生成的析构函数是头文件中的内联函数,而在头文件内自然是不知道 T 该如何析构的。
将类的析构函数的定义移至 cpp 相当于向其告知了 T 的析构方式。

static std::unique_ptr

前向声明不可用于 inline static std::unique_ptr<T> 类型的成员变量。
静态成员变量的生命周期已经脱离了类实例的生命周期,此时 unique_ptr 需要在该静态成员变量的定义处得知 T 的完整定义。
inline static 成员变量的定义处自然是头文件,故此时会编译失败。将静态成员变量的定义分离至 cpp(总之是有类完整定义的地方)内才能编译。

decltype(auto)

在完美转发返回值时尤为有用

1
2
3
4
5
template<class F, class... Args>
decltype(auto) PerfectForward(F fun, Args&&... args)
{
return fun(std::forward<Args>(args)...);
}
1
2
3
4
5
6
7
8
9
10
    auto c0 = a;             // c0 的类型是 int,保有 a 的副本
decltype(auto) c1 = a; // c1 的类型是 int,保有 a 的副本
auto c2 = (a); // c2 的类型是 int,保有 a 的副本
decltype(auto) c3 = (a); // c3 的类型是 int&,它是 a 的别名

auto d = {1, 2}; // OK:d 的类型是 std::initializer_list<int>
auto n = {5}; // OK:n 的类型是 std::initializer_list<int>
// auto e{1, 2}; // C++17 起错误,之前是 std::initializer_list<int>
auto m{5}; // OK:DR N3922 起 m 的类型是 int,之前是 initializer_list<int>
// decltype(auto) z = { 1, 2 } // 错误:{1, 2} 不是表达式

简写函数模板

在 C++20 中,auto 能推导出作为参数的函数对象,也能推导出可变长参数
上面的 PerfectForward 也可以写成:

1
2
3
4
decltype(auto) PerfectForward(auto fun, auto &&...args)
{
return fun(std::forward<decltype(args)>(args)...);
}

C++ 的最终形态一定是 auto auto = auto(auto); 罢

概念与约束(concept & requires)

模板的报错往往极度缺乏可读性,我们希望在编写模板时就对可能发生的情况做出一定的约束,使得违反约束的调用不要实例化,以避免产生难以阅读的报错信息。
这项技巧叫做 SFINE(Substitution Failure Is Not An Error),自 C++20 起 concept 与 requires 加入了标准,大大简化了 SFINE 的编写与理解难度。

concept

这是一个概念:

1
2
template<模板形参列表>
concept 概念名 = 编译期布尔表达式;

ex:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 无条件成立
template<class T>
concept C0 = true;

// 要求 T 为整数
template<class T>
concept C1 = std::is_integral_v<T>;

// 要求 T 为浮点或者有符号
template<class T>
concept C2 = std::is_floating_point_v<T> || std::is_signed_v<T>;

// 要求 T 为无符号整数
template<class T>
concept C3 = C1<T> && !std::is_signed_v<T>;

C0<int> // 编译期返回 true
C1<int> // 编译期返回 true
C2<int> // 编译期返回 true
C2<float> // 编译期返回 true
C3<uint> // 编译期返回 true

这是一个受 concept 约束的函数模板:

1
2
3
4
5
6
7
8
9
// 要求 T 为无符号整数
template<class T>
concept C = std::is_integral_v<T> && !std::is_signed_v<T>;

template<C T>
void Foo(T t){}

Foo(42U); // OK
Foo(42.0); // 错误

这是一个受 concept 约束的简写函数模板:

1
2
3
4
5
6
7
8
// 要求 T 为无符号整数
template<class T>
concept C = std::is_integral_v<T> && !std::is_signed_v<T>;

void Foo(C auto t){}

Foo(42U); // OK
Foo(42.0); // 错误

标准库已经提供了很多简单且常用的概念:

1
2
3
4
5
6
7
#include <concepts>

// 要求 t 为无符号整数
void Foo(std::unsigned_integral auto t){}

Foo(42U); // OK
Foo(42.0); // 错误

requires 表达式

这是一个 requires 表达式:

1
requires{ 要求序列 }

因为他在编译期返回一个布尔,所以可以接在 concept 后面:

1
2
template<class T>
concept C = requires{ 要求序列 };

这是一个简单要求:

1
2
3
4
5
6
7
8
9
10
11
12
struct Test
{
static void StaticFoo() {}
void Foo() {}
}

template<class T>
concept C0 = requires{ T::StaticFoo(); }

// 可以有形参
template<class T>
concept C1 = requires(T t){ t.Foo(); }

requires 表达式不实际运行大括号内的代码,只检测代码的有效性。

这是一个类型要求,以关键字 typename 开头:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct Test
{
struct Type0{};
using Type1 = int;
};

template<class T>
struct S{};

template<class T>
using Ref = T&;

template<typename T>
concept C = requires
{
typename T::Type0; // 要求嵌套类型
typename T::type1; // 要求嵌套类型
typename S<T>; // 要求类模板特化
typename Ref<T>; // 要求别名模板代换
}

这是一个复合要求,不但检查代码的有效性,还可以检查返回值类型和 noexcept:

1
2
3
4
5
6
7
8
9
10
template<class T>
concept C = requires(T t)
{
// 要求 T 重载了 operator+,并且要求其返回值可以被转换为 bool
{ t + t } -> std::convertible_to<bool>;
// 要求 T 重载了 operator-,并且要求其返回值为 int
{ t - t } -> std::same_as<int>;
// 要求 `x.~T()` 是不会抛出异常的合法表达式
{ t.~T() } noexcept;
};

这是一个嵌套要求,由关键字 requires 开头:

1
2
3
4
requires
{
requires 编译期布尔表达式;
};

ex:

1
2
3
4
5
template<class T>
concept C = requires(T t)
{
requires std::is_same_v<T, int>;
};

这种写法完全等价于:

1
2
template<class T>
concept C = std::is_same_v<T, int>;

意义在于允许我们在要求序列中加入编译期布尔表达式,并以此影响整个 requires 表达式的结果:

1
2
3
4
5
6
template<class T>
concept C = requires(T t)
{
{ t + t } -> std::convertible_to<bool>;
requires std::is_same_v<T, int>;
};

其实这种写法也等价于:

1
2
3
4
5
template<class T>
concept C = std::is_same_v<T, int> && requires(T t)
{
{ t + t } -> std::convertible_to<bool>;
};

requires 子句

这是一个 requires 子句,可以在声明模板时直接写在 template<模板形参列表> 后面:

1
requires 编译期布尔表达式

这是一个被 requires 子句约束的函数模板:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
template<class T>
requires std::is_same_v<T, int>
void Foo0(T t){}

template<class T>
concept C = std::is_same_v<T, int>;

template<class T>
requires C<T>
void Foo1(T t){}

template<class T>
requires requires(T t)
{
requires std::is_same_v<T, int>;
}
void Foo2(T t){}
// 第一个 requires 代表“requires 子句”
// 第一个 requires 代表“requires 表达式”
// 第一个 requires 代表“嵌套要求”

以上写法全部等价,你甚至可以:

1
2
3
4
5
6
template<class T>
requires requires(T t)
{
requires requires{ std::is_same_v<T, int> };
}
void Foo2(T t){}

R"()"

不使用转义字符打印特殊字符

1
2
3
4
5
6
7
8
9
10
11
12
13
const char *str =
R"(
"test"'test'
`~!@#$%^&*()-_=+[{]}\|;:'",<.>/?
)";
std::cout << str << std::endl;
/*
output :

"test"'test'
`~!@#$%^&*()-_=+[{]}\|;:'",<.>/?

*/

R"()" 会忠实的存储两个小括号之间的所有符号,包括每一个换行和缩进。

offsetof

1
2
3
4
5
6
7
8
9
10
11
12
13
struct Vertext
{
int a, b;
const int c = 0;
};

int main()
{
Vertext vert;
*(int *)((char *)(&vert) + offsetof(Vertext, c)) = 42;
std::cout << vert.c << std::endl;
// output : 42
}

pragma warning

  • 完全忽略指定警告
1
#pragma warning(disable: XXXX)
  • 指定警告只显示一次
1
#pragma warning(once: XXXX)
  • 指定警告为错误
1
#pragma warning(error: XXXX)
  • 重置警告,可能会影响更早的警告设置
1
#pragma warning(default: XXXX)
  • push, pop 之间所有的更改都会在 pop 之后被放弃
1
2
3
#pragma warning(push)
// ...
#pragma warning(pop)
  • suppress 仅忽略下一行的警告,然后恢复到 suppress 之前的状态,无法影响多行
1
#pragma warning(suppress: XXXX)

nodiscard

  • 若没有使用函数的返回值,编译器可以发出警告。
1
2
// nodiscard( 字符串字面量 ) (C++20 起):
[[nodiscard("PURE FUN")]] int strategic_value(int x, int y) { return x ^ y; }
  • 若该类型作为返回值没有被使用,编译器可以发出警告。
1
struct [[nodiscard]] error_info { /*...*/ };

VA_OPT

当可变参数宏不为空时才展开 __VA_OPT__ 内的内容,反之则忽略 __VA_OPT__ 内的内容。
例如:

1
#define ASSERT(x, ...) { if(!(x)) { __VA_OPT__(LOG_FATAL(__VA_ARGS__);) DEBUGBREAK(); } }

会将 ASSERT(false, "Hello") 展开为

1
2
3
4
5
6
7
{
if(!(false))
{
LOG_FATAL("Hello");
DEBUGBREAK();
}
}

而会将 ASSERT(false) 展开为

1
2
3
4
5
6
{
if(!(false))
{
DEBUGBREAK();
}
}

VS

在 VS2022 中启用 __VA_OPT__ 等较新的预处理器需要开启编译器开关 /Zc:preprocessor
https://learn.microsoft.com/en-us/cpp/preprocessor/preprocessor-experimental-overview?view=msvc-170

Premake

按理来说在 Premake 中启用该 VS 开关对应的是 usestandardpreprocessor
https://premake.github.io/docs/usestandardpreprocessor/
但是实测不生效,可能是一个 bug
现在而言的话可以写:

1
2
3
filter { "action:vs*" }
buildoptions { "/Zc:preprocessor" }
filter {}