【持续更新】C++ 并不完全是 C 的超集!

一些容易被忽略的 C 与 C++ 的不兼容特性

头文件和命名空间

C 标准库头文件名在 C++ 中通常去除扩展名,并加上 c 前缀,如:

  • stdio.h -> cstdio
  • stdlib.h -> cstdlib

其中一个重要的区别是后者保证与 C 库兼容的各个函数名可以在 std 命名空间中找到,但并不保证它们不存在于根命名空间中,这可能会引发一些难以发现的 bug。

/*
 * abs.cpp - 求绝对值的小程序
 *
 * 该代码有一处较为隐蔽的 bug,请尽力找出它!
 */
#include <cstdio>
#include <cstdlib>
#include <cerrno>
#include <cctype>
#include <cmath>

int main(int argc, char *argv[])
{
  if(argc == 1) {  // 校验输入命令行参数
    fprintf(stderr, "usage: %s <numbers>\n", program_invocation_short_name);
    exit(EXIT_FAILURE);
  }

  for(int i = 1; i < argc; ++i) {  // 对每个参数,输出运算结果
    char *endp;
    double num = strtod(argv[i], &endp);
    while(isspace(*endp)) ++endp;  // 跳过尾部空白字符,这一般不会出现
    if(*endp) {  // 没有到达字符串结尾,说明出现了错误
      fprintf(stderr, "ERROR: invalid number: %s\n", argv[i]);
    }
    num = abs(num);  // 计算绝对值,即使是无效输入也要输出
    printf("%lf\n", num);
  }

  return 0;  // 此处始终退回成功值,不是 bug
}

无论你是用人脑,IDE,反汇编器还是调试器发现了这个 bug,你都会将矛头指向命名空间。接收浮点参数的 abs() 函数在本案例中仅在 std 命名空间可见,故函数重载解析时不会考虑它们。其中一种解决方案是简洁明了的,即在 main() 函数定义前加上一句:

using std::abs;

现在一切正常。

lyazj@HelloWorld:~$ g++ -Wall -Wshadow -Wextra abs.cpp -o abs
lyazj@HelloWorld:~$ ./abs 0 1 -1 1.5 -1.5
0.000000
1.000000
1.000000
1.500000
1.500000

另一种解决方案是不加 using 申明,但将头文件名修改为 C 风格:

#include <stdio.h>
#include <stdlib.h>
#include <math.h>
#include <errno.h>

结果仍然一切正常。

lyazj@HelloWorld:~$ g++ -Wall -Wshadow -Wextra abs.cpp -o abs
lyazj@HelloWorld:~$ ./abs 0 1 -1 1.5 -1.5
0.000000
1.000000
1.000000
1.500000
1.500000

在本例测试环境下,/usr/include/c++/11/stdlib.h:54 给出了原因(读者应该也已经从文件路径中发现,C++ 版本的同名头文件与 C 版本应当有较大区别,所以并非位于 /usr/include/ 下):

using std::abs;

现在让我们来做最后的测试,不改变上一步的代码,改用 gcc 编译,结果如下:

lyazj@HelloWorld:~$ gcc --version
gcc (Ubuntu 11.3.0-1ubuntu1~22.04.1) 11.3.0
Copyright (C) 2021 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

lyazj@HelloWorld:~$ gcc -xc -Wall -Wshadow -Wextra abs.cpp -o abs -D_GNU_SOURCE
abs.cpp: In function ‘main’:
abs.cpp:25:11: warning: using integer absolute value function ‘abs’ when argument is of floating-point type ‘double’ [-Wabsolute-value]
   25 |     num = abs(num);  // 计算绝对值
      |           ^~~
lyazj@HelloWorld:~$ ./abs 0 1 -1 1.5 -1.5
0.000000
1.000000
1.000000
1.000000
1.000000

gcc 发出了我们所期待的警告,这是在本节开头 g++ 没有做到的事情。

问题补充:

  • 应当避免随意使用 using namespace std;,这并非解决这类冲突的好办法
  • 使用 C 库函数时,遵循 C 风格,使用后缀区别不同类型的 abs() 函数
  • 使用 IDE 或调试器追踪检查函数重载解析结果,确保符合预期

对于 C 和 C++ 风格头文件处理命名空间行为不一致的问题,可以尝试:

  • 不要混用两种风格的头文件
  • 如使用 C++ 风格的头文件,请坚守命名空间规则,确保使用的函数重载在当前作用域可见
  • 如使用 C 风格的头文件,请以兼容 C 的方式编写代码,包括以后缀区分接收不同形参类型的函数

NULL

这个大家应该比较熟悉,只需记住在 C 中它的类型一般是 void *,而在 C++ 中一般是 int,在类型参数匹配时可能有坑,故在 C++ 中应当尽量使用 nullptr

static 变量的初始化和 static 对象的析构

C++ 中 static 变量的初始化可能是有副作用的,特别是当初始化过程可能抛出异常时,编译器会生成复杂的代码来确保线程安全和初始化过程的原子性。同时 C++ 中 static 对象的析构顺序在分离编译时难以确定,当有多个 static 对象析构时可能破坏对象之间的依赖关系,需要避免这样的依赖。

C++ 中的类与 C 中的结构体有着根本区别

GCC 以虚函数表的方式支持 C++ 运行时多态,在 C 中我们可能习惯使用 memset() 来初始化结构体,在 C++ 中,如果 memset() 的地址仍然写对象地址,则可能破坏多态对象的虚表。就算是不需要分配资源,初始化 C++ 类也应当在构造函数中进行。在 C 中我们可以将以相同方式对齐的具有相同成员变量的结构体指针互相转化,来向用户隐藏其内部结构,在 C++ 中这则可能导致严重错误。在 C++ 中,应当使用封装继承或嵌套类等方式实现底层实现的隐藏。

new/delete 不简单等同于带有构造/析构语义的 malloc/free

不同于 malloc/freenew/delete 支持以全局或成员的方式重载,故不能永远假设 new 会调用 malloc(),也不能永远假设 delete 会调用 free(),虽然默认如此。内存的分配和释放应当始终坚持成对原则。

C++ 中的异常处理不仅限于长跳

C 程序员常使用 setjmp()longjmp() 系列函数处理异常,这在 C++ 中通常是不可取的,因为前者不能保证异常调用栈上的对象正确析构。同时,C 和 C++ 代码混合调用时如需支持异常,需要在 C 代码编译时显式打开相应 GCC 编译开关,否则可能缺少足够的上下文信息来捕捉异常。

构造/析构函数与一般的函数不同

在一个构造函数中,使用定位 new 表达式委托另一构造函数是严重的错误行为,因为当后者抛出异常时,所有的成员变量将被析构,若前者未捕捉异常,则造成 double free 问题,若前者捕捉异常,则将构造出一个畸形的对象。无论如何,这不是一个很自然的解决方案。在析构函数中抛出异常,如前一异常尚未处理完毕,程序将异常终止(在本测试环境下,打印出 terminate called after throwing an instance of... 并引发 SIGABRT 终止程序)。

C++ 表达式的类型包含引用

char buf[64];
printf("%zu\n", sizeof(0, buf));

这是一个经典的例子。在 C 中输出 4 或 8(或其它,取决于 sizeof(char *)),在 C++ 中输出 64。

布尔类型

考虑对 C++ 的兼容性,应当避免在 C 代码中使用 _Bool,而是引入头文件 stdbool.h,并将布尔类型写作 bool

关系运算表达式值类型

printf("%zu\n", sizeof(0 == 0));

在 C 中,0 == 0int 类型,其大小一般为 4;在 C++ 中,0 == 0 是布尔类型,其大小一般为 1。

宏定义

在本测试环境下,GCC 为 C++ 编译环境默认定义了宏 _GNU_SOURCE,但在 C 环境下默认没有定义。直接使用 #define _GNU_SOURCE 定义该宏的代码可能在 C++ 编译器下产生宏重复定义的警告。当然,类似这样的差别还有很多。

auto 关键字的语义差别

在 C 中,auto 并不常用;在 C++11 及以后标准中,auto 用于值类型自动推导,实现类型简写和编译器多态。

restrict, _Noreturn, register 等关键字

部分关键字为 C 特有的,如常见于 string.h 中 restrict,用于指导编译器优化代码的 _Noreturnregister 关键字,它们并不在最新的 C++ 标准中。

运行时链接

运行时链接,包括 libdl.so 提供的接口甚至是 Python3 ctypes 提供的接口,对 C++ 的支持都非常差,因为 C++ 中的对象有许多需要构造和析构的过程,且不同编译系统实现的名字重整规则也不相同,再加上运行时的对象生命周期管理非常困难,一个较好的解决方案是在外围套一层 C 外壳,隐藏复杂 C++ 对象的实现细节。

编译和链接

C++ 程序,特别是包含大量模板(比如使用了 STL)的 C++ 程序编译比 C 程序慢得多,且二进制文件大得多,但恰当地使用 STL 和其它模板库无疑对开发效率和程序质量非常有帮助。

常量定义

enumconstexpr 等是 C++ 推荐的应当尽可能使用的定义常量的方式,特别是 constexpr,比起宏定义而言,这种方式可以让名字进入作用域和类型系统,增强程序的严谨性。

const 关键字

众所周知,const 关键字是 C++ 语言非常重要的一部分,const 重载也是经常使用的手段之一。然而,C 对 const 的违例行为只会施加以警告,而不会报出 error。有趣的是,C 并不强制字符串字面值常量必须赋给带有底层 const 的指针变量,而 C++ 强制要求这一点。

非平凡的指定初始化器

int nums[100] = {
  [99] = 1,
};

在 GCC 中,这是合法的 C99 代码,但在 C++ 编译环境下 GCC 并不认可这样的代码:

lyazj@HelloWorld:~/develop/work$ g++ -Wall -Wshadow -Wextra test.cpp -o test
test.cpp:3:1: sorry, unimplemented: non-trivial designated initializers not supported
    3 | };
      | ^
posted @ 2023-07-22 21:24  lyazj  阅读(97)  评论(0编辑  收藏  举报