C语言中容易忽视的小知识

一、在C文件中引用C++函数

  1. 将要使用的函数声明放在一个头文件中
  2. 把要被C语言调用的C++函数的声明放在extern "C"{ ... }语句块里
  3. 注意:标准C++的头文件包含不能放在extern "C"{ ... }语句块里

当然,要在C++文件调用C文件的函数也是可行的。

而要在C文件中引用C++函数,下面给出一个示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
// C function declaration in fun.h

#ifdef __cplusplus
extern "C" {
#endif

// 要调用的C++函数
int add(int a, int b);
int sum(int a[], int num);

#ifdef __cplusplus
}
#endif

extern "C" { ... } 语句块将 C++ 函数的声明包裹起来。然后,完成C++函数的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Cpp function defined in fun.cpp
#include <vector>
#include <iostream>
#include <numeric>
#include "fun.h"

int add(int a, int b) {
std::cout << "a = " << a << std::endl;
std::cout << "b = " << b << std::endl;
return a+b;
}

int sum(int *a, int num) {
std::vector<int> c;
for (int i = 0; i < num; i++)
c.push_back(a[i]);
return std::accumulate(c.begin(), c.end(), 0);
}

注意到,add()sum() 函数都是C++函数,使用了很多“C++才有的”技术,但这并不妨碍C函数使用它们:

1
2
3
4
5
6
7
8
9
10
11
12
// main.c call them in the end
int main(int argc, char const *argv[]) {
int vec[] = {3,4,5,6,7};
int a = 4; int b = 5;
// 调用了Cpp里面的函数
int c = add(a, b);
printf("%d\n", c);
// 复杂点的例子
int d = sum(vec, 5);
printf("%d\n", d);
return 0;
}

为什么要这么做?

extern "C"的主要作用就是为了能够正确实现C++代码调用其他C语言代码。加上extern "C"后,会指示编译器这部分代码按C语言的进行编译,而不是C++的。由于C++支持函数重载,因此编译器编译函数的过程中会将函数的参数类型也加到编译后的代码中,而不仅仅是函数名;而C语言并不支持函数重载,因此编译C语言代码的函数时不会带上函数的参数类型,一般之包括函数名。

二、宏操作——陷阱

宏是C语言中的历史产物,C++为了向前兼容C,完全继承了C语言中的预处理器和宏操作部分。目前主流的认知就是尽量不要用宏实现函数,而改用内联函数或者模板代替。因此这里也建议不要过于依赖宏定义。

宏以及处理它的预处理器最大的缺陷就是,将用宏的地方进行简单的文本替换。这样一来,宏是根本不会进入编译器的symbol table,编译器在报错时就会出现魔数情况,也不会处理可能出现的宏命名重复,此外,宏也没有变量的作用域功能,只有简单的 #ifndef#undef 来控制宏的作用范围。这些还只是最经常出现的小问题。更大的问题在于括号有无或者++--等情况,例如:

1
2
3
4
#define MAX(a,b) ((a)>(b)?(a):(b))
int i = 0;
int j = 2;
MAX(i++,j++)

那么i和j最终等于多少呢?得到的结果又是多少呢?非常烧脑的问题。而且就算我们理清了i++了几次,那么当宏的具体实现出现变化,结果可能会出现变化。

又比如:

1
2
3
4
5
6
7
8
9
#define DOSOMETHING() cmd1; \
cmd2;
// when some_condition() return true, the DOSOMETHING()
if (some_condition()) {
DOSOMETHING()
}
// ????
if (some_condition())
DOSOMETHING()

当然,如果你一定要用宏实现,为避免用户使用不当(用户是真的不知道该用上面的哪一种),可以加一个小技巧:

1
#define DOSOMETHING() do {cmd1; cmd2; } while(0)

此时无论选取那一种写法,都是正确的。

此外该技巧也可以用在其他地方:它可以避免使用goto 语句。若使用do {...} while(0)包裹整个过程,在过程中出现错误或异常时,可以直接使用 break 跳出循环,直接来到循环外的错误处理,而不需要goto。这在程序结构、编译优化上有很多好处。

总之,使用宏时一定要对宏有充分了解,不然会出现非常多奇怪的问题。

三、宏操作——技巧

不过,即使宏的缺点很多,而且大多宏函数都可被内联函数、模板替代,但是宏仍有存在的意义。即使是在 C++ 中,编译器也赐予了一些预定义宏:

  • __FILE__ :编译的文件的绝对路径;
  • __LINE__ :当前行号;
  • __TIME__ :当前时间;
  • __DATE__ :当前日期。
  • __cplusplus :当前编译器完全遵循的 C++ 版本。C++98C++03 对应了 199711LC++11 对应了 201103LC++14 对应了 201402L

这些预定义的宏是 C++ 标准规定的,此外不同版本的 C++ 也有更多的预定义宏。

其次是另一个老生常谈的作用。确保头文件不被二次 include,从而报出重定义的错误。不过也有#pragma once这种写法。

1
2
3
4
5
6
7
8
#ifndef XXXX_XXX_XX
#define XXXX_XXX_XX

class {
// ...
}

#endif XXXX_XXX_XX

此外,还有很多有趣的用法,比如字符串连接:

1
2
3
// 字符串连接 将数字变为字符串
#define Con(x, y) x##y
#define ToString(x) #x

又比如一些耍杂技的宏函数(仍建议使用内联函数):

1
2
3
4
5
6
7
8
// 用宏来求两个数的最大值
/* 通过比较x,y的指针类型,得到警告 若类型一致 通过void消除 */
#define max(x, y) ({ \
typeof(x) _max1 = (x); \
typeof(y) _max2= (y); \
(void) (&_max1 == &_max2); \
_max1 > _max2 ? _max1: _max2; \
})

在特殊情况下,使用宏可以快速定义一批变量,或者写完一整片相同形式的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 使用宏可以快速定义一批变量
#define LIST_OF_VARIABLIES \
X(value1) \
X(value2) \
X(value3)

// 定义了 int value1 int value2 int value3
#define X(name) int name;
LIST_OF_VARIABLES
#undef X

#define Y(x, z) double x = (z);
#define print(t) printf("t = %d\n", t);
#define prints(x) printf("x = %s\n", x);

四、可变参数函数

在编写程序时,我们经常会遇到函数在编译期间接收到的参数数量不确定的情况。比如,经常使用的printf()函数,我们可以传入任意数量的参数。那么,这一切究竟如何实现?

事实上,C语言就已经提供了这样的一个解决方案,示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <stdarg.h>

int add(int num, ...) {
va_list valist;
int sum = 0;
int i;
// 为 传入的参数初始化 valist
va_start(valist, num);
// 访问 所有的参数
for(i = 0; i < num; i++) {
sum += va_arg(valist, int);
}
// 结束调用valist 清理内存
va_end(valist);
return sum;
}

add() 最后一个参数写成省略号,即三个点号(),省略号之前的那个参数是 int,代表了要传递的可变参数的总数。为了使用这个功能,需要 #include <stdarg.h>,该文件提供了实现可变参数功能的函数和宏。具体步骤如下:

  1. 定义一个函数,最后一个参数为省略号,省略号前面可以设置自定义参数。
  2. 在函数定义中创建一个 va_list 类型变量,该类型是在 stdarg.h 头文件中定义的。
  3. 使用 int 参数和 va_start 宏来初始化 va_list 变量为一个参数列表。宏 va_start 是在 stdarg.h 头文件中定义的。
  4. 使用 va_arg 宏和 va_list 变量来访问参数列表中的每个项。
  5. 使用宏 va_end 来清理赋予 va_list 变量的内存。

除此之外,还有一个不被用到的 va_copy,示例如下:

1
2
3
4
5
6
7
8
9
10
va_list valist1;
va_list valist2;
va_start(valist1, num);
// valist2 is dest after that valist2 equals to valist1
va_copy(valist2, valist1);
for (int i = 0; i < num; i++) {
printf("%d, ", va_arg(valist2, int));
}
va_end(valist1);
va_end(valist2);

五、setjmp 和 longjmp

C语言中,即使是goto 语句也是不能跨函数的。为了从深层次的函数调用中立刻返回错误信息(尤其是函数递归调用时),C语言添加了两个用于跨函数跳转的函数: setjmplongjmp 。再说一遍,这两个函数可以实现非本地跳转,将程序控制直接从一个函数转移到另一个函数,不需要经过正常的调用——返回,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int main() {
/* ... */
while(fgets(line, MAXLINE, stdin) != NULL)
do_line(line);
/* ... */
}

void do_line(char *ptr) {
/* ... */
while((cmd = get_token()) > 0)
cmd_add();
}

void cmd_add() {
/* ... */
}

上述程序有非常多的函数调用,且嵌套多层,设想若在 cmd_add 函数发现一个错误,需要返回 main 函数并读下一个输入行,我们就不得不将函数返回值逐层返回,操作非常麻烦。

使用 setjmplongjmp 函数,可以使程序在栈上跳过若干调用帧,返回到当前函数调用路径上的某一个函数。

1
2
3
4
5
6
#include <setjmp.h>

int setjmp(jmp_buf env);
int sigsetjmp(sigjmp_buf env, int savesigs);
void longjmp(jmp_buf env, int val);
void siglongjmp(sigjmp_buf env, int val);

其中,sigsetjmpsiglongjmp 函数都是 setjmplongjmp 的可以被信号处理程序使用的版本。

首先要在希望返回到的位置调用 setjmp()setjmp 的参数 env 的类型是 jmp_buf 。这一数据类型是某种形式的数组,用于存放在调用 longjmp() 时,用来恢复栈状态的所有信息。通常 env 为全局变量,因为需要在另一个函数中使用。然后,longjmp() 函数在出现错误时被调用,程序跳转到调用 setjmp() 的地方。来看一个具体示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
jmp_buf env;
/* ... */
switch(setjmp(buf)) {
case 0:
foo(); break;
case 1:
printf("Error 1!\n"); break;
case 2:
printf("Error 2!\n"); break;
}

void foo() {
if (error1)
longjmp(buf, 1);
bar();
}

void bar() {
if (error2)
longjmp(buf, 2);
}

当一个错误发生,longjmp 函数跳过了所有中间调用,直接返回到 switch 语句中,其中第二个函数参数 val将成为从 setjmp 处返回的值。显然,对于一个 setjmp 可以收到多个 longjmp 的返回值,并用 switch 语句处理。

如果还是难以记住,可以将 setjmp 看作 Java 中 try 中的 catch 语句,将 longjmp 函数看作 throw 语句。

五、C中的类型

C语言中有哪些类型是被重新命名的?它们的用处是什么?本质又是什么呢?

注意:在C++中用到 size_t 或其他在C中定义的类型时,推荐使用 #include<stddef.h>

类型 实质 描述
ptrdiff_t signed int 两个指针相减的结果的有符号整数类型
size_t unsigned int 通常用于数组的索引以及表示变量存储空间的大小
wchar_t int 宽字节字符类型,可以表示,在支持的语言环境中,指定的最大扩展字符集的所有成员
nullptr_tC++11 关键字nullptr的类型 该类型表示它不是指向某个东西的指针类型,当多个重载函数接收不同的指针类型时,重载一个接收std::nullptr_t用于处理传入null指针的情况
max_align_tC++11 实现定义 一种对齐要求,至少与每个标量类型一样严格(大)的类型
NULL 宏 (void*) 0 or 0 C语言中表示空指针,但在C++中仅表示0,C++中的空指针推荐使用nullptr

六、GNC C中的Attributes

在GNC C中,可以将你要调用的函数用“特殊的东西”声明,可以帮助编译器优化你的函数,并可以帮你挑出一些更加细微的错误。

那么这些特殊的东西,就是这里介绍的关键字__attribute__了。在声明时,它可以帮你给函数设定一个特殊的属性。这个关键字通常会带有如下几个东西:

  • noreturn

    1
    void err_sys(const char * fmt, ...) __attribute__((noreturn))

    通常用在一些标准库函数中,例如abort或者exit,意思是不能return,GCC会得知这一点,并且GCC就会对其进行优化:不会考虑如果err_sys出现返回值的情况。更重要的是,可以避免出现未初始化变量的警告。

  • noinline

    阻止函数内联。

事实上,GCC的attribute有三大类:

  • Function attributes described here
  • Variable attributes described here
  • Type attributes described here

这里就不一一介绍了。


C语言中容易忽视的小知识
https://dingfen.github.io/2021/08/23/2021-8-23-non-trivial-thing-in-C/
作者
Bill Ding
发布于
2021年8月23日
更新于
2024年4月9日
许可协议