一些C++从C语言继承的好用特性 - C++基础

阅读《嵌入式C语言自我修养 - 从芯片、编译器到操作系统》1这本书时学习到了很多C语言中的有用特性,让人不禁感慨C语言也在与时俱进。这些特性在C++中也完全可以使用,学了也不白学,在工作上有用武之地。另外对于宏定义这种功能可以生成代码增加键盘寿命,相较于C++模板晦涩难懂,宏更直观。这次也一并系统的学习宏并且记录成本文。不过需要注意的是有些特性并不是C++标准直接支持而是编译器扩展支持,所以用这些特性会丧失可移植性

C语言的标准发展经历了如下4个阶段。

  • K&R C: C语言的作者Dennis M.Ritchie和Brian W.Kernighan合作的《C程序设计语言》中所定义的标准。
  • ANSI C: 美国国家标准协会(American National Standards Institute,ANSI)在K&R C基础上统一了各大编译器厂商的不同标准,一般也叫做C89/C90标准。
  • C99标准: ANSI在1999年基于C89标准扩充,包括支持变量可以声明在代码块的任何地方、支持//单行注释、栈上可变长数组等。(ps: 大学老师的教授让我在很久以来都认为栈上数组的长度只能在编译期确定)。目前,很少有C语言编译器能完整支持C99,主流的GCC和Clang能支持90%以上,而微软的Visual studio 2015中只能支持70%左右2栈上可变长数组不可定义时初始化,需要定义后手动初始化
  • C11标准: ANSI在2011年基于C99标准扩充,包括支持多线程、增加gets_s()等线程安全的标准库函数等。

编译器也对C语言标准进行了扩展,例如GCC(GNU C Compiler)和Clang编译器,后边会介绍这些有用的扩展。

语句表达式是GNU C对C语言标准作了扩展,类似于Scala中的代码块。如下例子,可以将0~100加和的结果赋值给sum。

c++

int sum = ({
    int s = 0;
    for (size_t i = 0; i < 100; ++i) s += i;
    s;
});

语句表达式允许在表达式中放入代码块,表达式最终的值等于最后一个表达式的值,格式如下:

c++

({
  // 任意表达式;
  // 表达式最终的值等于最后一个表达式的值
})

如上,语句表达式就是一种语法糖,可以避免声明函数的麻烦和调用函数的开销,并且在其内部定义的局部变量不会“污染外部”。在一些特定的场景中,可以使代码更加优雅。它的另一个更重要的功能就是可以让宏定义方便的返回值给调用者,将在下面介绍。

在GNU C可以使用指定字段名来初始化结构体,如下例子。

c++

// 顺序初始化
student t1 = student{"mengjie", 28};
student t2 = {"mengjie", 28};
student t3{"mengjie", 28};
std::vector<student> v1{student{"mengjie", 28}, {"mengjie", 28}, };
// 指定初始化
student t4 = student{.name = "mengjie", .age = 28};
student t5 = {.name = "mengjie", .age = 28};
student t6{.name = "mengjie", .age = 28};
std::vector<student> v2{student{.name = "mengjie", .age = 28}, {.name = "mengjie", .age = 28}, };

未指定的字段将会被默认初始化

指定初始化相较于顺序初始化对字段的初始值可以有一个更直观的表述,而不依赖与其声明顺序。在结构体调整字段顺序、增加字段等操作时,使用指定初始化将不会被影响。当然在C++中只适用于不自定义构造函数的类或结构体才可以使用指定初始化。

c99标准支持的数组指定初始化,如下例子。

c

// 编译器会给a[6]~a[9]默认初始化为0
int a[10] = {0, 1, 2, 3, 4, 5};

// 编译器会给索引为10和30的赋值,其它值默认初始化为0
int b[100] = { [10] = 1, [30] = 2};

GNU C还支持范围数组指定初始化,如下例子。

c

// 索引范围a[10]~a[30]21个元素初始化为1, a[10]和a[20]都是包含的; a[50]~a[60]初始化为2
int c[100] = { [10 ... 30] = 1, [50 ... 60] = 2};

并且…范围扩展不仅可以用在数组初始化,还可以用在switch-case语句中,例如。

c

int i = 20;
switch(i) {
  case 0 ... 10: {
    std::cout << "0 ... 10";
    break;
  }
  case 11 ... 20: {
    std::cout << "11 ... 20";
    break;
  }
}

其中case 11 ... 20:就可以匹配[11, 20]之间的数,包含11和20。

零长数组是GNU C标准的扩展,顾名思义可以定义长度为0的数组,它不占用内存存储空间。其一般作为结构体的最后一个成员,从而构成一个变长的结构体。如下例子:

c

struct buffer {
    int len;
    char a[0];
};

int main() {
    buffer* buf = (buffer*)malloc(sizeof(buffer) + 10);
    buf->len = 10;
    strcpy(buf->a, "hello");
}

当然也可以在结构体直接放入指针,但是指针类型本身会占用内存,而不定长数组不需要额外占用内存。其实完全可以用指针+偏移量buf+sizeof(buffer)访问到buffer中多分配的内存,所以零长数组本质上也算是一种语法糖

在C99标准中引入了灵活数组,也是干类似的事情,语法如下:

c

struct flexible_array{
    int len;
    char a[];
};

但是其相比于零长数组来说有如下限制:

  1. 灵活数组是不完整类型,不可以使用sizeof操作符操作;零长数组可以使用sizeof,并且计算结果为0.
  2. 灵活数组作为结构体成员时,结构体必须至少拥有一个命名的非空成员对象;零长数组没有该限制。
  3. 含有灵活数组的结构体不能作为另一个结构体的成员或数组的某个元素;零长数组没有该限制。

Clang编译器是LLVM项目的一个子项目,作为C、C++和Objective-C编程语言的编译器前端。其采用BSD许可证,比GCC的GPL许可证更为宽松。

Google也是从NDK 9开始大力推广LLVM Clang编译工具链。而且在NDK 11中就有官方声明,GCC编译器只升级到4.9,后续将处于维护状态,然后NDK 13版本将直接被丢弃,而只使用LLVM Clang编译工具链。再看看ARM, ARM官方的编译工具链ARM Studio 6也开始基于Clang。而像AMD则是把Clang直接用于自己的OpenCL编译器上。到了2017年,微软也将Clang集成在Visual Studio开发环境中,作为可选的C语言编译器前端,而后端仍然采用MSVC的目标代码生成器以及运行时。2

Clang也对C标准做了自己的扩展,这里介绍下几个有意思的扩展。

  • 函数重载: 众所周知C语言并不支持函数重载,在Clang中可以使用__attribute__((overloadable))标记函数为可重载的,并且需要在每个重载函数都需要加上,否则编译器会报错。

  • Lambda表达式: 在Clang中支持Blocks语法来定义一个闭包Lambda函数,语法类似下例。

    c

    void (^refBlock)(int) = ^void(int i) { printf("a + i = %d\n", a + i); };

    在书2中所讲,该特性可以捕获局部变量的引用,从而延长局部变量的生命周期。比起C++11中的Lambda表达式更加满足标准的闭包。

宏定义(macro definition)是一种预处理指令,在编译器的预处理中进行文本的替换,从而修改即将编译的源代码。其和C++模版都是属于元编程(Metaprogramming),即编写程序来生成或操作其他程序代码,甚至操作自身代码的编程技术。我觉得其功能相较于C++模版来说更加简单容易理解,在一些场景下非它不可。

宏的基本的形式如下,其中替换列表可以省略:

c

#define 标识符 替换列表 换行符
#define 标识符(参数1, 参数2) 替换列表 换行符

宏定义必须写在函数外,宏的作用范围为定义处到当前文件结束,不受函数作用域等影响,因为宏的预处理部分与C源代码部分采用完全不同的文法体系,而且预处理器是独立于编译器存在的。

无参数宏的基本使用如下:

c

#define PI 3.14
#define PATH "/home/"
#define DOCUMENTS_PATH PATH "Documents/"
#define LONG_STR \
    "There is a long string you must deal with it \
as two line of sentence \n"

int main() {
    printf("PI = %f\n", PI);
    printf("/home/" "Documents/\n"); // 替换后的这种语法是可以的
    printf(DOCUMENTS_PATH "\n");
    printf(LONG_STR);
}

上边的例子需要注意的点:

  • 宏名被双引号括起来时不做宏替换,例如"PI = %f\n"中的PI不会被替换成3.14。
  • 宏定义允许嵌套使用,例如DOCUMENTS_PATH就使用了前面定义的PATH宏。
  • 字符串字面量是可以拼接的,两个拼接的字符串字面量中间可以有0个或多个空格。例如"/home/" "Documents/\n"
  • 宏的替换列表如果过长的话可以使用\换行。

有参数的宏的使用注意,参考1中的例子。实现一个简单的MAX宏用于取两个数的最大值,最简单的写法如下:

c

#define MAX(x, y) x > y ? x : y

int main() {
    std::cout << (MAX(1 != 1, 1 != 2)) << std::endl; // 预期为1,实际为0
    std::cout << (3 + MAX(1, 2)) << std::endl; // 预期为5,实际为1
}

上述简单的写法,有展开后的运算优先级问题:

  • MAX(1 != 1, 1 != 2)展开后为1 != 1>1 != 2 ? 1 != 1 : 1 != 2,其中运算法>的优先级大于!=,所以运算顺序不符合预期,导致意外的结果。
  • 3 + MAX(1, 2)展开后为3 + 1 > 2 ? 1 : 2,又因为运算符+的优先级大于运算>,实际变为4 > 2? 1: 2不符合预期。

所以需要将参数用括号包起来,从而保证符合预期的运算优先级,如下:

c

#define MAX1(x, y) ((x) > (y) ? (x) : (y))

不过,上述宏定义还存在一个问题,当使用自增运算符的变量作为参数时,如MAX1(i++, ++j)就会在展开的宏中多次执行自增运算符,变量的值就改为非预期的值,这也不太能接受。所以有了如下究极体。

c++

#define MAX2(x, y)          \
    ({                      \
        auto&& _x = x;      \
        auto&& _y = y;      \
        (void)(&_x == &_y); \
        _x > _y ? _x : _y;  \
    })

上述究极体MAX宏定义有如下几个需要注意的地方。

  • 使用万能引用引用变量,从而避免自增运算符被多次执行。参考右值、右值引用、万能引用与完美转发 - C++基础中的万能引用介绍。

    原书中使用的是GNU扩展关键字typeof来获取数据类型,从而拷贝x,y参数,如下:

    c

    typeof(x) _x = x;
    typeof(y) _y = y;

    这里会造成变量的拷贝,而用万能引用的方式则不会。typeof关键字类似C++中的decltype移除引用属性的操作。

  • (void)(&_x == &_y);用来在x和y类型不同时增加编译器警告。由于运算的结果没有用到加上(void)后就可以消除编译器运算结果未使用的警告。void表示不存在的值类型,如果表达式的计算结果为void则其值和表示的标识符都会被丢弃。

  • 使用了上述的语句表达式,最后一句_x > _y ? _x : _y;为表达式最终的值。

宏定义替换列表中#后边跟参数名可以将实参内容以字符串字面量形式表示。例如:

c

#define AREA(x, y) std::cout << "x: " << #x << ", y: " #y << ", area: " << x * y << std::endl;
int main() {
    AREA(3, 4) // x: 3, y: 4, area: 12
}

其中,#x#y就被替换成字符字面量,替换是在编译器替换,不会有运行期损耗。

另外当使用宏函数时没有传入参数,则替换的参数字面量为空,例如AREA(,4)或者AREA(3,)。

宏定义替换列表中##可以将前后两个预处理符号拼接在一起,##是个二元的操作,#一元的操作。##的使用例子如下:

c

#define A(x) a##x

int A() = 0;   // int a = 0;
int A(1) = 1;  // int a1 = 1;
std::cout << a << ", " << a1 << std::endl;

如上例子,##可以拼接两个预处理符号,并且最终的结果并不是字符字面量。

宏函数的替换顺序是先处理替换列表中出现的#与##操作符,然后对替换列表中所出现的宏进行展开替换;接着检查实参是否引用了宏,如果引用了则先对所有引用了宏的实参进行完全的宏替换;最后才将替换列表中出现的形参替换为宏扩展后的实参对应的预处理符号。

如下例子,宏先进行##的替换,然后再进行替换列表的宏展开替换B1和B2。

c

#define B1 1
#define B2 2

#define B(x) B##x

int main() {
    std::cout << B(1) << std::endl; // 相当于引用B1
    std::cout << B(2) << std::endl; // 相当于引用B2
}

目前对这个替换顺序的理解不太深入,感觉上在写一些复杂的宏时必须得理解这个顺序,将来需要的时候再去深入理解。

在写程序时对于if、else语句,在只执行一行时,一般省略括号。但是如果执行的一行是宏的话,就要非常小心了。例如:

c

#define LOG_LEVEL 0
#define DEBUG_LOG \
    if (LOG_LEVEL >= 1) printf("debug!\n");

int main() {
    if (true)
        DEBUG_LOG
    else
        printf("else");
    // 展开后的if/else为
    if(true)
    if(false) printf("debug!\n");
    else
        printf("else");
}

这样的结果肯定是不符合预期的。所以在if/else只执行一行宏语句时,一定要加上{},否则就等着踩坑吧

而对于写宏定义时,应当通过do{...}while(0){}避免这种情况。

c

#define LOG_LEVEL 0

#define DEBUG_LOG1                              \
    {                                           \
        if (LOG_LEVEL >= 1) printf("debug!\n"); \
    }

#define DEBUG_LOG2                              \
    do {                                        \
        if (LOG_LEVEL >= 1) printf("debug!\n"); \
    } while (0)

int main() {
    if (true)
        DEBUG_LOG1 // {}不能加;结束
    else
        printf("else");

    if (true)
        DEBUG_LOG2; // do while需要加;结束
    else
        printf("else");
}

如上do{...}while(0){}的区别在于使用宏时需要加;和不能加;分号结束符。一般加;才更符合使用习惯,所以只推荐使用do{...}while(0)的形式。

从C99标准起,C语言的宏支持不定个数的参数,与可变参数的函数类似,例如:

c

#define LOG(fmt, ...) printf(fmt, __VA_ARGS__)  // C99标准
#define LOG2(fmt, ...) printf(fmt, ##__VA_ARGS__)
#define LOG3(fmt, myargs...) printf(fmt, ##myargs)  // GNU C扩展

int main() {
    LOG("hello %s\n", "world");
    // LOG("hello\n"); // 不能编译
    LOG2("hello %s\n", "world");
    LOG2("hello\n");
    LOG3("hello %s\n", "world");
}

上述例子有如下需要注意的点。

  • __VA_ARGS__会将参数列表展开并且以逗号间隔,如果没有参数则为空。

  • 在C99标准需要使用标识符__VA_ARGS__来引用可变参数列表,GNU C扩展了可以自定义名字的方式LOG3(fmt, myargs...)

  • printf(fmt, __VA_ARGS__)在调用参数列表为空时,展开为printf(fmt,)多了逗号不可编译。故LOG("hello\n");不可编译。

  • printf(fmt, ##__VA_ARGS__)扩展了##操作符的语义,使其可以做到在列表为空时将前面的逗号删除,不为空时则使用正常的__VA_ARGS__语义以逗号为间隔进行参数列表展开。此为GNU C的扩展。

    在C++ 20标准增加了__VA_OPT__宏来在列表为空时消除逗号,使用方法类似__VA_OPT__(,) __VA_ARGS__,感觉不如##操作符简洁,具体可以参考《现代C++语言核心特性解析》332.3节。

利用可变参数宏,曾经实现过函数调用前打印函数名称和其参数列表,如下:

c++

#define PRINT_CALL(func, args...)                                           \
    ({                                                                      \
        std::cout << "func: " << #func << ", args: " << #args << std::endl; \
        func(args);                                                         \
    })

int main() {
    std::cout << "test1: " << PRINT_CALL(twoSum, {2, 7, 11, 15}, 22) << std::endl;
    std::cout << "test2: " << PRINT_CALL(twoSum, {2, 7, 11, 15}, 13) << std::endl;
    std::cout << "test3: " << PRINT_CALL(twoSum, {2, 7, 11, 15}, 1) << std::endl;
}