现在是 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
12
#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
22
#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)
}