uncategorized

现代C++惯用法

现在是 9102 年了,再来总结 C++ 的惯用法似乎有点落伍,但是 C++ 的强大依然毋庸置疑。最近我也把目光投向 rust,C++ 不管怎么演进,历史包袱还是太沉重了。而且,rust 的设计其实也参考了很多C++的思想(如 RAII)所以在 C++ 中继续深入也是有价值的

内容大纲

  1. Assertion
  2. Marcos
  3. SFINAE
  4. Meyers Singleton
  5. RAII
  6. Type Trait

0. Assertion

运行期断言

1
2
3
4
5
#ifdef NDEBUG
#define assert(condition) ((void)0)
#else
#define assert(condition) /*implementation defined*/
#endif

assert 其实是在来源于C语言的头文件 中定义的宏

一般来说,如果condition 是false,那么assert 首先向stderr 打印错误信息,然后调用 abort (触发一个SIGABRT中断信号)来非正常结束程序

  • assert 不是 error handling, assertion 处理的是代码的 bug 而不是程序预期的输入错误或者系统错误等
  • 想象有这样一个函数,下面哪种实现更”安全“呢?:
1
2
3
4
5
6
7
8
9
10
void foo1(int score) {
if(score > 0 && score <= 100){
// internal logics
}
}

void foo2(int score) {
// require bar between 1 and 100
assert(score > 0 && score <= 100 && "score must between 1 and 100!");
}
  • foo1看起来是安全的,因为程序没有崩溃,很完美,可惜没有对异常输入做任何处理,因此来自调用方的逻辑错误就被隐藏了

  • 在这里,assert 和 if 都提供了一个内置的validate检查,在执行真正的逻辑之前,模块的开发者可以视为自己对外部调用做了一个“假设”,这样就把异常处理的责任转移到调用端,开发者只需要关注自己的逻辑闭环。不同的是,assert能发现外部调用的错误

让错误尽早发现(Fail Fast[1])

  • 一个 fail fast 的系统通常倾向于直接报告错误给上层逻辑。
  • 此类设计通常会在操作中的多个点检查系统的状态,因此可以及早检测到故障。

assert 优点

  • 帮助程序员做好模块化
  • 在内部逻辑执行之前提供可行性检查
  • 造成程序的 fast fail
  • 在 release 模式下会被 compiled out, 不影响性能

缺点

  • 只在 debug 模式下工作,如果在 assert 中写有副作用的逻辑,会造成 release 版本的严重错误
  • 使得程序的内在逻辑对外部部分可见

编译期断言

编译期断言对应 static_assert,是 C++ 11 提供的让错误更早发现的机制。

static_assert 十分强大,附带的自定义信息会直接被当成编译错误输出,但由于是编译期断言,因此限制也很大。他的输入只能是bool_constexpr类型。因此要用好static assert,需要程序员分辨出代码哪些部分是编译期即可确定的部分。

下面的代码可以限制 Vector 的 dimension 始终大于 1

1
2
3
4
5
6
7
8
template<typename ElemType, int N>
class Vector {
static_assert(N > 1, "1D Vector is not allowed, use scalar types");
ElemType elem_[N];
};

Vector<int, 1> vec1;// compilation error
Vector<int, 2> vec2;

另外,利用 static_assert + type_traits 也能实现语义约束,比如我们想写一个检查无符号加法溢出的函数,并希望调用方不传递有符号类型,那么我们可以:

1
2
3
4
5
6
template<typename T>
bool check_overflow_add_unsigned(T a, T b) {
static_assert(std::is_unsigned<T>::value, "T must be unsigned types!");
...
...
}

由于自 C++ 11 开始,鼓励大家多用编译期计算,把一些 constant 的数据和代码段尽量挪到编译期,因此可能很多类型成为 literal type (比如上面的 Vector )。这些类既能在运行期运行,又能在编译期预计算,因此,只要是涉及 literal type 的函数/类/对象 就应尽可能的用 static_assert

1. Marcos

这里说的宏是指 replacing text macros,是由预处理器支持的文本替换功能,同样支持带参数的文本替换

宏是许多编程语言都支持的功能,C/C++ 中的宏是较为简单的文本替换,而在 rust 和 lisp 等语言中,宏会直接展开AST,因此更为强大,既有类型系统支持,又能做到zero cost abstraction

尽管如此,在C/C++中 使用宏很多时候能大大简化代码,在一些情况下能代替C/C++中比较啰嗦的逻辑。例如当我们想在每个类的定义插入特定的信息用来做反射,以及当我们想做一个循环展开以便更好的利用SIMD的性能,宏都能给我们提供这样的能力,但有时候也会给开发者带来困惑,尤其多层嵌套的宏在可读性和维护性上比较差。但目前,宏在C/C++ 中的地位仍然是不可取代的

开发中,我们常用下面几种宏的写法

单行宏

1
2
3
#define a 1
#define b (a + 1)
#define c a * b

多行宏

1
2
3
#define Vec \
static int x; \
static int y;

多行宏会在在使用处被替换为多行内容

宏参数

1
2
3
4
5
6
7
8
9
10
11
#include <iostream>
#define DUPLICATE(index, times) for(int index = 0; index < times; ++index)

int main(void)
{
DUPLICATE(i, 100)
{
std::cout << i << std::endl;
}
return 0;
}

# 和 ## 操作符 (预处理器)

Stringizing operator (#)

在类函数的宏中 # + 形参名可以在宏替换中 直接把参数字面量换成字符串

例:

1
2
3
4
5
6
7
8
9
#define DEFINE_AND_PRINT(type, varname) \
type varname; \
std::cout << "declare " << #type << " " << #varname << std::endl;

int main(int argc, char const *argv[])
{
DEFINE_AND_PRINT(int, bar); // declare int bar
return 0;
}

Token-Pasting Operator(##)

## 直接把前后的字符合并成一个token,可以用来自动生成出一些新的token(类名,函数名,变量名等)

1
2
3
4
5
#define CREATE_3_VARS(name) name##1, name##2, name##3
int CREATE_3_VARS(myInts);
myInts1 = 13;
myInts2 = 19;
myInts3 = 77;

其他注意

  • 由于宏本质上是预处理器对代码做的文本替换,因此一般需要一到两个括号包裹来指定优先级。
  • 注意宏替换发生在整个编译器起作用之前,即早于所谓的词法分析之前

2. Meyer’s Singleton

我们怎样在C#中实现单例?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public sealed class Singleton
{
static Singleton instance = null;

public void Show()
{
Console.WriteLine( "instance function");
}
private Singleton()
{
}

public static Singleton Instance
{
get
{
if (instance == null)
{
instance = new Singleton();
}
return instance;
}
}
}

线程安全

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public sealed class Singleton
{
static Singleton instance = null;
private static readonly object padlock = new object();

private Singleton()
{
}

public static Singleton Instance
{
get
{
lock (padlock)
{
if (instance == null)
{
instance = new Singleton();
}
}

return instance;
}
}
}

Double-Check

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public sealed class Singleton
{
static Singleton instance = null;
private static readonly object padlock = new object();

private Singleton()
{
}

public static Singleton Instance
{
get
{
if (instance == null)
{
lock (padlock)
{
if (instance == null)
{
instance = new Singleton();
}
}
}
return instance;
}
}
}

Meyers’ Singleton in C++

1
2
3
4
5
static Singleton& instance()
{
static Singleton s;
return s;
}

(Scott Meyers 是Effective C++系列的作者)

相对于C#的单例实现,Meyers提供了一种相当简洁的书写方式

Meyers 单例利用了C++11 的 Dynamic Initialization and Destruction with Concurrency[2] 特性,该特性要求编译器对于每一个静态对象都绑定一个哨兵变量(guard variable)用来做线程相关的操作,保证 static local variable 在并发条件下只执行一次Lazy Initialization。

以clang 为例,Meyers 单例生成的汇编代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
Singleton::instance():               # @Singleton::instance()
push rbp
mov rbp, rsp
sub rsp, 16
cmp byte ptr [guard variable for Singleton::instance()::singleton], 0
jne .LBB1_4
movabs rdi, offset guard variable for Singleton::instance()::singleton
call __cxa_guard_acquire
cmp eax, 0
je .LBB1_4
mov eax, offset Singleton::instance()::singleton
mov edi, eax
call Singleton::Singleton() [base object constructor]
jmp .LBB1_3
.LBB1_3:
movabs rdi, offset guard variable for Singleton::instance()::singleton
call __cxa_guard_release
.LBB1_4:
movabs rax, offset Singleton::instance()::singleton
add rsp, 16
pop rbp
ret
mov ecx, edx
mov qword ptr [rbp - 8], rax
mov dword ptr [rbp - 12], ecx
movabs rdi, offset guard variable for Singleton::instance()::singleton
call __cxa_guard_abort
mov rdi, qword ptr [rbp - 8]
call _Unwind_Resume
Singleton::Singleton() [base object constructor]: # @Singleton::Singleton() [base object constructor]
push rbp
mov rbp, rsp
mov qword ptr [rbp - 8], rdi
mov rdi, qword ptr [rbp - 8]
mov dword ptr [rdi], 0
pop rbp
ret
Singleton::instance()::singleton:
.zero 4

guard variable for Singleton::instance()::singleton:
.quad 0 # 0x0

3.SFINAE(subtitition failure is not an error) [3]

类型 SFINAE

注意:如果有条件使用 static_assert 或者 concepts,尽量不要使用 SFINAE

我们经常要根据模板类型参数的不同来展开不同的代码,这这个工作有时候能利用特化完成,但也有特化无法完成的时候,比如我们想实现overload function template,就像普通函数的重载一样,这时候就需要SFINAE的帮助

SFNIAE 是C++模板技术的衍生,属于模板元编程的一部分

Subtitition Failure

在C++ 中,模板参数的subtitition(替换) 发生在模板展开的过程中。参数替换有两步:

  • 首先发生显式指定的替换
  • 其次发生类型推断的替换,以及默认模板参数的替换

所谓默认模板参数很好理解,和函数的默认参数一样,例子如下:

1
template<typename T, typename U = int> // int is the default template parameter for argument U

当不指定 U 时,U默认为int

由于替换的次序不同,在替换默认模板参数的时候,实际上可以利用已经替换过的参数来替换默认参数

1
template<typename T, typename U = typename T::internal_type>

而替换失败(subtitition failure) 指的是上述的替换产生了问题,比如传递给类型参数A的类型可能没有一个叫internal_type的嵌套类型,

然而,要使编译器产生compilation error,还需要尝试有没有别的函数模板能替换成功,这个过程可能发生多次subtitition failure。

有人说那我改一下默认参数是不是能产生subtitition failure is not an error呢?很遗憾不行。只有default template parameter 不同的template是有歧义的

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

struct A {
using internal_type = int;
};

struct B {};

template <typename T, typename U = typename T::internal_type> void foo() {
std::cout << "T has internal_type" << std::endl;
}

template <typename T> void foo() {
std::cout << "T has no internal_typ" << std::endl;
}

int main(int argc, char const *argv[]) {
foo<A>(); // call to 'foo' is ambiguous
foo<B>();
return 0;
}

那么到底如何产生 subtitition failure is not an error呢?

A simple and explicit Example

Example from wikipedia

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct Test {
typedef int foo;
};

template <typename T>
void f(typename T::foo) {} // Definition #1

template <typename T>
void f(T) {} // Definition #2

int main() {
f<Test>(10); // Call #1.
f<int>(10); // Call #2. Without error (even though there is no int::foo) thanks to SFINAE.
}

Typedef detector

这是 SFINAE 的一个应用,用来检查类参数有没有 T 有在类内部 typedef internal_type 类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>
struct A {
using internal_type = int;
};

template <typename T>
decltype(std::declval<typename T::internal_type>(), std::declval<void>())
foo(const T &) { // compare to the varadic version below, this one is more specifc
std::cout << "T has internal_type" << std::endl;
}

void foo(...) { std::cout << "T has no internal_type" << std::endl; }

// template <typename T> void foo() { foo(int()); }

int main(int argc, char const *argv[]) {
A a;
foo(a); // subtitition suceeded for the first time
foo(1); // subtitition failed, but succeeded for the second time
foo("test"); // subtitition failed, but succeeded for the second time
return 0;
}

我们看到foo有两个重载版本,为了告诉编译器我期望 T 有嵌套 internal_type 类型,我们使用了 decltype + 逗号表达式 + declval。decltype(std::declval(), std::declval()) 的真正类型是 void,但对T::internal_type进行了求值,因此编译器知道这里 T 是 more specific 的

member functor detector

更有用的例子是判断类型有没有包含某个名字的成员函数,这一点对template programming 中的duck typing 很重要

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include <iostream>
#include <string>
#include <type_traits>

template <class T>
struct Stringlizable
{
// We test if the type has serialize using decltype and declval.
template <typename C>
static constexpr decltype(std::declval<C>().serielize(), bool())
test(int /* unused */)
{
// We can return values, thanks to constexpr instead of playing with sizeof.
return true;
}

template <typename C>
static constexpr bool test(...) { return false; }

// int is used to give the precedence!
static constexpr bool value = test<T>(int());
};

struct A
{
int data;
std::string to_string() { return std::to_string(data); };
};

struct B
{
};

template <typename T>
std::string Serielize(T obj) { return obj.to_string(); }

int main(int argc, char const *argv[])
{
A a;
B b;
std::cout << Serielize(a) << std::endl;
return 0;
}

表达式 SFINAE (略)

4. Type Traits

终于来到了 type traits。

上文中曾提到,static_assert 以及 SFINAE 和 type_traits 结合可以在编译器做一些类型约束的事情,,那么这个type_traits到底是什么呢

要理解Type Traits,我们首先要理解C++的类型系统

C++是一门静态类型语言,其类型系统十分强大,由三个部分组成:基本类型,RTTI, Type Traits

Type traits 定义了一套编译期的,基于模板的用来查询或修改类型属性的接口

cppreference [4]中列出了这些接口,它们是以 helper class 来实现的

一个简单的例子是上文提到的 match unsigned 类型的泛型函数:

1
2
3
4
5
6
template<typename T>
bool check_overflow_add_unsigned(T a, T b) {
static_assert(std::is_unsigned<T>::value, "T must be unsigned types!");
...
...
}

现在我们终于可以尝试解释一下这个 std::is_unsigned::value 是个什么东西了

cppreference 中给出的可能实现如下:

1
2
3
4
5
6
7
8
9
10
namespace detail {
template<typename T,bool = std::is_arithmetic<T>::value>
struct is_unsigned : std::integral_constant<bool, T(0) < T(-1)> {};

template<typename T>
struct is_unsigned<T,false> : std::false_type {};
} // namespace detail

template<typename T>
struct is_unsigned : detail::is_unsigned<T>::type {};

首先,is_unsigned 是一个struct,其有一个static constexpr bool member 叫 value,value 继承自 std::integral_constant<bool, T(0) < T(-1)> 或者 false_type,是一个编译期计算的量。

我们从上面的代码中可以再次发现了 SFNIAE 的存在,编译器首先替换继承自 std::integral_constant 的版本,如果这个替换是invalid 的(可能是无法显式 从 -1 构造,或者编译期构造出来的 -1 > 0),那么将考虑更加generic 的版本,这个版本 的 is_unsigned 继承自 false_type ,这样value 就继承到了constexpr false

除了 is_unsigned, cppreference 中还有非常多的helper_class, 能帮助我们写出更加 generic 的代码

(注意,尝试特化<type_traits> 中的template是未定义行为)

5. RAII(Resource acquisition is initialization)[5]

RAII 可能是C++在某种程度上优于其他基于GC的语言最主要的原因

简单来说,C++提供了确定性析构过程,尤其对于栈对象,异常,多返回分支语句,多线程环境C++都能保证在栈上分配的对象一定会被清理,而清理之前一定会调用对象的析构函数。

同时,我们通常会在栈上创建很多临时资源,如网络连接,文件描述符,申请堆内存等。这些临时资源是需要被清理的。手动去清理他们通常会考虑不周全,通过和临时对象的析构绑定,我们可以确保资源被释放。

最典型的例子其实来自于 C++ STL

RAII 在 STL 中被大量使用。文件流是一个很好的例子。销毁文件流对象,也会同步关闭文件,而不需要手动关闭文件描述符

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

#include <mutex>
#include <iostream>
#include <string>
#include <fstream>
#include <stdexcept>

void write_to_file (const std::string & message) {
// mutex to protect file access (shared across threads)
static std::mutex mutex;

// lock mutex before accessing file
std::lock_guard<std::mutex> lock(mutex);

// try to open file
std::ofstream file("example.txt");
if (!file.is_open())
throw std::runtime_error("unable to open file");

// write message to file
file << message << std::endl;

// file will be closed 1st when leaving scope (regardless of exception)
// mutex will be unlocked 2nd (from lock destructor) when leaving
// scope (regardless of exception)
}
Share