一句话:这不是「把语法翻译一遍」,而是把编译器原来替你做的三件事——初始化与销毁、类型分派、错误传播——全部改成手写代码。语法层面的改写是机械的,这三件事才是工作量所在。
这份文档里每一段代码、每一个输出、每一句报错原文,都是用本机
gcc 16.2.0 / g++ 16.2.0(MSYS2 UCRT64)真编译真跑出来的:
17 组对照实验(同一个程序分别写 C++ 版和 C 版)在
-O2 -std=c++17 -Wall -Wextra 与 -O2 -std=c17 -Wall -Wextra 下编译通过、运行输出逐字节一致,
另有 6 组探针单独取证。源码与完整报告在 _w2c/(matrix2c.py 可一键重跑,report.txt 是原始记录)。
改写 C++ 到 C 是降级:你能换来可移植性和可控性,代价是把编译器保证过的东西重新变成「靠人记得」。所以第一件事不是动代码,是算账。
| 该转 | 不该转 |
|---|---|
| 目标平台只有 C 编译器(裸机、RTOS、某些内核/驱动、部分嵌入式 SDK) | 只是「觉得 C++ 复杂 / 编译慢 / 体积大」——换语言解决不了设计问题 |
| 这份代码要当底层库,给 Python / Rust / Go 等多种语言调用(它们都只认 C ABI) | 代码里 std::map、std::string、模板、RAII 用得很重——手写等价物的代码量会超过原代码 |
| 要通过 ABI 稳定 / 认证 / 老编译器(C89)的硬性要求 | 时间紧、又要长期维护:改错了没人能判断 |
| 需要确定性的内存行为(不想要异常、不想被隐式分配拖住) | 代码本来就是「C 风格的 C++」(不用 STL、不用异常)——那它多半已经能直接当 C 编,见第 6 节 |
成本公式(经验值):改写成本 ≈ 手工管理的内存/资源点数 × 2 + 用到的 STL 设施数 × 3。 后一项里的 3 指的是「写数据结构 + 写它的迭代/查找/排序 + 写测试」三份活。所以第 4.4~4.6 节(容器)通常是整个迁移里最贵的部分。
不许一刀切:不要「停掉所有开发,重写三个月」。正确做法是第 2.3 节的逐模块迁移——一个模块转成 .c,头文件加 extern "C" 守卫,其余部分继续用 C++ 编,随时可发版、随时可回退。
.c(最彻底)选一个依赖最少、接口最窄的模块(比如一个数据结构或一个协议解析器)先转。转完这个模块的编译单元里不再有 C++ 语法,编译器换成 gcc,链接进去。好处是边界清楚:这个 .c 里的内存错误不会跑到别处去。
如果「不能用 C++」的约束来自风格或审查而不是工具链,那么最省力的做法是:代码仍然是 .cpp,但规定不用异常、不用 RTTI、不用 STL 容器、不用 new。这样代码形态和第 4 节的 C 版几乎一致,需要时还能用模板和 constexpr 这类零运行时成本的特性。
转好的模块用 gcc 编成 .c,头文件同时给 C 和 C++ 用,C++ 那侧的调用方继续不变。关键只有一个:头文件里必须给 C++ 编译器加 extern "C" 守卫。
lib.h —— 同时给 C 和 C++ 编译器的写法
#ifdef __cplusplus
extern "C" {
#endif
int c_add(int a, int b);
#ifdef __cplusplus
}
#endif
impl.c(gcc 编)与 main_ok.cpp(g++ 编,包含上面的 lib.h)
/* impl.c */
#include "lib.h"
int c_add(int a, int b) { return a + b; }
/* main_ok.cpp */
#include <cstdio>
#include "lib.h"
int main() { std::printf("2+3=%d\n", c_add(2, 3)); }
链接方式与实测结果
gcc -c impl.c # 转好的模块用 C 编译器
g++ -c main_ok.cpp # 老的调用方还是 C++ 编译器
g++ main_ok.o impl.o -o mixed_ok
./mixed_ok
2+3=5
如果调用方忘了加 extern "C"(比如自己手写了一句 int c_add(int a, int b);),编译照样通过,链接才会炸——这是迁移中最常见的「明明转对了却连不上」:
main_bad.o:main_bad.cpp:(.text.startup+0x14): undefined reference to `c_add(int, int)'
collect2.exe: error: ld returned 1 exit status
注意报错里的名字是 c_add(int, int) 而不是 c_add:C++ 编译器按「参数类型」去找符号,而 C 导出的符号就叫 c_add,两边对不上。解决办法不是加 -fno-mangle 之类的开关,而是让 lib.h 里那句声明带上 extern "C"。
左边是 C++ 写法,中间是 C 里的等价物,右边是「改完你失去了什么」。红表示这一步会产生新的手工维护点。
| C++ | C 替代 | 代价 |
|---|---|---|
std::cout / iostream | printf / stdio.h | 没有类型安全、没有格式检查 |
class + 成员函数 | struct + 普通函数,this 变成第一个参数 | 调用点要写 obj_method(&obj, …) |
| 构造函数 / 析构函数 | xxx_init() / xxx_destroy() | 每条退出路径都要手写,漏一个就泄漏 |
| 赋值 / 拷贝构造 | memcpy 或手写字段赋值 | 深拷贝要自己判(指针成员会共享) |
std::vector | malloc/realloc + size/capacity 两个变量 | 扩容、越界保护、释放全写 |
std::string | char 数组 / char* | 长度、'\0'、溢出边界全自己管 |
std::map / unordered_map | 手写数组 + 查找,或第三方(uthash) | 哈希函数、冲突、有序性都要自己实现 |
std::sort + lambda | qsort + 比较函数 | 比较函数不能返回差值(会溢出) |
| 函数模板 | _Generic 宏(C11)或显式列出每个类型 | 支持的类型要枚举,不能对任意类型生成代码 |
| 函数重载 | 不同函数名(add_int / add_double) | 名字变长,调用方要改 |
| 默认参数 | 两个函数,或一个包装函数 | 组合数会随参数个数爆炸 |
T& 引用参数 | T* 指针 | 调用点写 &x,空指针要自己防 |
| 运算符重载 | 普通函数(frac_add) | 可读性下降,表达式要拆开写 |
throw / catch | 返回错误码 / errno / goto cleanup | 可以被忽略,错误会静默传播 |
继承 + virtual | 手写虚表:函数指针结构体 + 首成员上转 | 每一层都要自己接线,没有 override 检查 |
namespace | 名字前缀(geo_scale) | 只有约定,没有强制 |
new / delete | malloc / free + 手写初始化 | 没有构造/析构,malloc 会失败 |
auto / 范围 for | 显式类型 / 索引循环 | 长度和边界自己写对 |
nullptr | NULL(就是 0) | 没有类型,重载/模板里容易歧义 |
bool / true | stdbool.h | 本质是 _Bool,打印要用 %d |
| 智能指针 / RAII 包装 | 引用计数 + 手写 release,或 arena 一次性释放 | 循环引用没人管 |
std::function | 函数指针 + void* ctx | lambda 捕获的变量要自己打包 |
try 里的栈展开 | goto cleanup; 集中释放 | 标签和清理顺序要人工维护 |
constexpr / 模板元编程 | 宏 + 手算常量 | 没有编译期类型检查 |
下面每一项都是「同一件事的两份实现」。左边的 C++ 版与右边的 C 版我都编译运行过,输出完全一致,输出贴在每项末尾。
C++
#include <iostream>
int main() {
int n = 3 * 7;
std::cout << "n=" << n << " name=" << "dijkstra" << "\n";
}
C
#include <stdio.h>
int main(void) {
int n = 3 * 7;
printf("n=%d name=%s\n", n, "dijkstra");
return 0;
}
两边输出(一致)
n=21 name=dijkstra
改写要点:std::endl 要换成 "\n"(顺带丢掉「每次都 flush」的隐含行为);<< 链式输出要拆成格式串,格式串和参数要人工对齐——%d 配 double 在 C 里不是编译错误,运行时才出乱码。
this 变成第一个参数)C++
#include <cstdio>
struct Vec2 {
double x, y;
Vec2 add(const Vec2& o) const { return Vec2{x + o.x, y + o.y}; }
double norm2() const { return x * x + y * y; }
};
int main() {
Vec2 a{1, 2}, b{2, 3};
Vec2 c = a.add(b);
std::printf("sum=%.1f,%.1f norm2=%.1f\n", c.x, c.y, c.norm2());
}
C
#include <stdio.h>
typedef struct { double x, y; } Vec2;
/* 成员函数被“抬”到外面:this → 第一个参数(const 成员函数的 this 是 const Vec2*) */
static Vec2 vec2_add(Vec2 a, Vec2 b) { Vec2 r; r.x = a.x + b.x; r.y = a.y + b.y; return r; }
static double vec2_norm2(Vec2 a) { return a.x * a.x + a.y * a.y; }
int main(void) {
Vec2 a = {1, 2}, b = {2, 3};
Vec2 c = vec2_add(a, b);
printf("sum=%.1f,%.1f norm2=%.1f\n", c.x, c.y, vec2_norm2(c));
return 0;
}
两边输出
sum=3.0,5.0 norm2=34.0
改写要点:① 函数名加类型前缀(vec2_)防止撞名,加 static 把它限制在本编译单元;② const 成员函数对应的写法是按值传(小结构)或 const Vec2*(大结构);③ 要「原地改」就传 Vec2*,这是 C 里区分 const/非 const 成员的唯一手段;④ typedef struct 之后类型名是 Vec2,匿名结构体不能自引用(链表结点必须写 struct Node* next; 或先 typedef struct Node Node;)。
C++
#include <cstdio>
#include <cstdlib>
static int live = 0; // 存活对象计数:用来观察析构到底跑没跑
struct Buf {
int* p; int n;
Buf(int n_) : n(n_) { p = (int*)std::malloc(sizeof(int) * n); live++; }
~Buf() { std::free(p); live--; }
};
int main() {
{
Buf a(4);
Buf b(8);
std::printf("in scope live=%d\n", live);
} // 离开作用域,两个析构函数自动跑
std::printf("after scope live=%d\n", live);
}
C
#include <stdio.h>
#include <stdlib.h>
static int live = 0;
typedef struct { int* p; int n; } Buf;
static void buf_init(Buf* b, int n) { b->n = n; b->p = (int*)malloc(sizeof(int) * n); live++; }
static void buf_destroy(Buf* b) { free(b->p); b->p = NULL; live--; }
int main(void) {
{
Buf a, b;
buf_init(&a, 4);
buf_init(&b, 8);
printf("in scope live=%d\n", live);
buf_destroy(&b);
buf_destroy(&a); // 必须手写:顺序自己定,漏一个就泄漏
}
printf("after scope live=%d\n", live);
return 0;
}
两边输出
in scope live=2
after scope live=0
看起来 C 版只是「多写两行」。真正的差别在于漏掉的时候会发生什么——把 buf_destroy(&a); 删掉,程序照样编过、照样跑完,只在退出时留下计数:
live=1 (退出前还有 1 块内存没释放)
这是整份文档里唯一无法用代码补回来的损失。析构函数的价值不在「少写两行」,而在每一条退出路径都保证释放:提前 return、break、throw、goto,只要有对象在作用域里,栈展开一定跑析构。C 里没有栈展开,所以要么每条 return 前手写一遍释放,要么用第 4.12 节的 goto cleanup 集中收口——不要两种混用,混用就一定会漏。
std::vector → 动态数组C++
#include <cstdio>
#include <vector>
int main() {
std::vector<int> v;
for (int i = 0; i < 10; i++) v.push_back(i * i);
std::printf("size=%zu capacity=%zu last=%d\n", v.size(), v.capacity(), v.back());
}
C
#include <stdio.h>
#include <stdlib.h>
int main(void) {
size_t size = 0, cap = 0;
int* v = NULL;
for (int i = 0; i < 10; i++) {
if (size == cap) {
cap = cap ? cap * 2 : 1; // 扩容策略得自己定
v = (int*)realloc(v, sizeof(int) * cap);
}
v[size++] = i * i;
}
printf("size=%zu capacity=%zu last=%d\n", size, cap, v[size - 1]);
free(v);
return 0;
}
两边输出
size=10 capacity=16 last=81
改写要点:① size 和 cap 是两个必须自己维护的变量,不要用「遍历时 i < 某个数」当长度;② realloc 会失败并返回 NULL,失败时原来的指针仍然有效,所以不能写 v = realloc(v, …)——那会漏掉旧指针。生产代码写成 void* t = realloc(v, n); if (!t) { /* 处理 */ } v = t;;③ 取地址传给别处的指针会在 realloc 之后失效,这是 C 里最典型的悬空指针来源。
std::string → char 数组C++
#include <cstdio>
#include <string>
int main() {
std::string s = "abc";
s += "def";
s += '!';
std::printf("len=%zu s=%s\n", s.size(), s.c_str());
}
C
#include <stdio.h>
#include <string.h>
int main(void) {
char buf[16];
buf[0] = '\0';
strcat(buf, "abc");
strcat(buf, "def");
size_t n = strlen(buf);
buf[n] = '!'; buf[n + 1] = '\0'; // 追加一个字符:'\0' 也得自己补
printf("len=%zu s=%s\n", strlen(buf), buf);
return 0;
}
两边输出
len=7 s=abcdef!
改写要点:① 每次「追加」都要自己保证还有空间放结尾的 '\0'——strcat 没有边界检查,缓冲区溢出是 C 代码里最常见的安全问题,固定缓冲用 snprintf(buf + n, sizeof buf - n, "%s", s) 更安全;② strlen 是 O(n),std::string::size() 是 O(1)——循环里反复 strlen 是真实存在的性能坑;③ 二进制数据(含 '\0')不能用 str* 函数,要带长度传(memcpy + size_t len)。
std::map → 手写表(这一项最容易被低估)C++
#include <cstdio>
#include <map>
#include <string>
int main() {
std::map<std::string, int> cnt;
const char* words[] = {"cpp", "c", "cpp", "port"};
for (const char* w : words) cnt[w]++;
for (const auto& kv : cnt) std::printf("%s=%d\n", kv.first.c_str(), kv.second);
}
C
#include <stdio.h>
#include <string.h>
#define CAP 8
typedef struct { char key[16]; int val; } Entry;
int main(void) {
Entry t[CAP]; int n = 0;
const char* words[] = {"cpp", "c", "cpp", "port"};
for (int i = 0; i < 4; i++) {
int j = 0;
while (j < n && strcmp(t[j].key, words[i]) != 0) j++; /* 线性查找 */
if (j == n) { strcpy(t[n].key, words[i]); t[n].val = 0; n++; }
t[j].val++;
}
/* std::map 天然按 key 有序,这里得自己维护(插入排序) */
for (int i = 1; i < n; i++) {
Entry cur = t[i]; int j = i - 1;
while (j >= 0 && strcmp(t[j].key, cur.key) > 0) { t[j + 1] = t[j]; j--; }
t[j + 1] = cur;
}
for (int i = 0; i < n; i++) printf("%s=%d\n", t[i].key, t[i].val);
return 0;
}
两边输出
c=1
cpp=2
port=1
改写要点:① std::map 有两个特性——按 key 查找和遍历时按 key 有序,上面这段为了输出一致,把第二个性也手工实现了(插入排序);如果你的代码依赖有序遍历,忘了这一步会静默产生不同顺序的输出;② 线性查找是 O(n),元素多了要换哈希表或有序数组 + 二分(标准做法是用 uthash 这类成熟实现,别自己写哈希);③ 键长度定死在 char key[16] 里,超长键会被 strcpy 写坏——这是 4.5 溢出问题在结构体里的另一副面孔。
std::sort + lambda → qsort + 比较函数C++
#include <cstdio>
#include <vector>
#include <algorithm>
int main() {
std::vector<int> v{5, 1, 9, 3, 7};
std::sort(v.begin(), v.end(), [](int a, int b) { return a > b; }); // 降序
for (int x : v) std::printf("%d ", x);
std::printf("\n");
}
C
#include <stdio.h>
#include <stdlib.h>
/* 不能写成 return *(int*)pb - *(int*)pa:差值会整型溢出。用三向比较返回 -1/0/1 */
static int cmp_desc(const void* pa, const void* pb) {
int a = *(const int*)pa, b = *(const int*)pb;
return (b > a) - (b < a);
}
int main(void) {
int v[] = {5, 1, 9, 3, 7};
size_t n = sizeof(v) / sizeof(v[0]);
qsort(v, n, sizeof(v[0]), cmp_desc);
for (size_t i = 0; i < n; i++) printf("%d ", v[i]);
printf("\n");
return 0;
}
两边输出
9 7 5 3 1
改写要点:① 比较函数签名固定是 int (*)(const void*, const void*),参数要先转类型再解引用;② 不要 return a - b:当 a、b 相差超过 INT_MAX 时差值溢出,比较结果反号,排序结果错(而且不报错)。要么用 return (a > b) - (a < b);,要么用 if 分三种情况返回 -1/0/1;③ 比较函数不能修改元素(参数是 const),也不能在里面改全局状态;④ lambda 捕获的上下文在 C 里没有位置放——它得变成文件级 static 变量(于是排序就不再线程安全),这正是 C 版最容易被忽略的连带损失。
_Generic / 宏C++
#include <cstdio>
template <class T> T tmax(T a, T b) { return a > b ? a : b; }
int main() {
std::printf("%d %.1f\n", tmax(3, 7), tmax(2.5, 1.5));
}
C(C11 的 _Generic)
#include <stdio.h>
static int imax(int a, int b) { return a > b ? a : b; }
static double dmax(double a, double b) { return a > b ? a : b; }
/* C11 的 _Generic 只能按类型选函数,不能像模板那样对任意类型生成新代码 */
#define tmax(a, b) _Generic((a), int: imax, double: dmax)(a, b)
int main(void) {
printf("%d %.1f\n", tmax(3, 7), tmax(2.5, 1.5));
return 0;
}
两边输出
7 2.5
_Generic 的行为像「编译期 switch」,只能在你列出的类型里选。如果不想枚举类型,退路是用 void* + 元素尺寸 + 比较函数(qsort 就是这种风格),代价是所有类型信息手工传递、调用处到处是强制转换。
用宏必须知道的代价:宏会重复求值。下面两段代码看起来做同一件事,结果不一样——
C++:实参只求值一次
static int calls = 0;
static int bump() { calls++; return calls; } // 第 1 次返回 1,第 2 次返回 2
template <class T> T tmax(T a, T b) { return a > b ? a : b; }
int main() {
int m = tmax(bump(), 0); // 求值一次
std::printf("max=%d calls=%d\n", m, calls);
}
C:bump() 在展开后出现两次,于是被调用两次
static int calls = 0;
static int bump(void) { calls++; return calls; } /* 第 1 次返回 1,第 2 次返回 2 */
#define MAX(a, b) ((a) > (b) ? (a) : (b))
int main(void) {
int m = MAX(bump(), 0); /* 展开后调用两次 */
printf("max=%d calls=%d\n", m, calls);
return 0;
}
C++ 版:max=1 calls=1
C 版:max=2 calls=2
所以宏的参数如果是 i++、函数调用、*p++ 这类带副作用或耗时的表达式,必须先用临时变量接住再传进去。判断办法:把宏展开在纸上写一遍,数一数每个参数出现了几次。
C++
#include <cstdio>
int add(int a, int b) { return a + b; }
double add(double a, double b) { return a + b; }
int main() { std::printf("%d %.1f\n", add(1, 2), add(1.5, 2.25)); }
C
#include <stdio.h>
int add_int(int a, int b) { return a + b; }
double add_double(double a, double b) { return a + b; }
int main(void) { printf("%d %.1f\n", add_int(1, 2), add_double(1.5, 2.25)); return 0; }
两边输出
3 3.8
默认参数在 C 里最省事的写法是「两个函数」:void draw_rect(Rect r, Color c); 和 void draw_rect_default(Rect r) { draw_rect(r, BLACK); }。参数一多(3 个以上带默认值)就会退化成 8 个函数,这是设计上需要重来的信号。
C++
#include <cstdio>
void inc(int& x) { ++x; } // 引用:调用处看不出会被改
int main() { int n = 41; inc(n); std::printf("n=%d\n", n); }
C
#include <stdio.h>
static void inc(int* x) { ++*x; } // 指针:调用处必须写 &n
int main(void) { int n = 41; inc(&n); printf("n=%d\n", n); return 0; }
n=42
改写要点:const T& 参数对应 const T*;引用一定非空,指针可以是 NULL——C 版函数入口该不该加 if (!x) return; 是个真问题,别默认「调用方一定传对」。返回引用(T& get();)改成返回指针时,要额外想清楚「谁拥有这个对象、什么时候失效」——C++ 里这个答案常常藏在引用返回的语义里,而 C 里没有任何机制替你记住。
C++
#include <cstdio>
struct Frac { int n, d; };
Frac operator+(Frac a, Frac b) { return Frac{a.n * b.d + b.n * a.d, a.d * b.d}; }
void print(const Frac& f) { std::printf("%d/%d\n", f.n, f.d); }
int main() { Frac a{1, 4}, b{1, 4}; print(a + b); }
C
#include <stdio.h>
typedef struct { int n, d; } Frac;
static Frac frac_add(Frac a, Frac b) { Frac r = {a.n * b.d + b.n * a.d, a.d * b.d}; return r; }
static void frac_print(Frac f) { printf("%d/%d\n", f.n, f.d); }
int main(void) { Frac a = {1, 4}, b = {1, 4}; frac_print(frac_add(a, b)); return 0; }
两边输出(1/4 + 1/4,未约分)
8/16
改写要点:a + b 变成 frac_add(a, b),复合表达式要一层层拆开,临时变量变多;operator[]、operator() 这类被大量使用的重载,改完会让代码可读性明显下降——这类地方可以考虑保留 C++ 编译(第 2.2 节)。
C++
#include <cstdio>
#include <stdexcept>
int root(int x) {
if (x < 0) throw std::runtime_error("negative");
return x;
}
int main() {
try {
std::printf("root=%d\n", root(9));
root(-1);
} catch (const std::exception& e) {
std::printf("caught: %s\n", e.what());
}
}
C
#include <stdio.h>
/* 返回 -1 表示失败,错误信息通过指针参数带回 */
static int root(int x, const char** err) {
if (x < 0) { *err = "negative"; return -1; }
return x;
}
int main(void) {
const char* err = NULL;
int r = root(9, &err);
if (r >= 0) printf("root=%d\n", r);
r = root(-1, &err);
if (r < 0) printf("caught: %s\n", err);
return 0;
}
两边输出
root=9
caught: negative
改写要点(三条比代码重要得多):
__attribute__((warn_unused_result)) 让编译器帮忙提醒。goto cleanup 集中收口。多层资源分配 + 多层可能失败,最清晰的 C 写法是:正常路径往下走,出错就 goto cleanup,把所有释放写在函数末尾,释放函数要能容忍 NULL(free(NULL) 合法,自己写的 destroy 也该先判 NULL)。const char** err 传字符串指针;工程上更常见的是返回枚举错误码 + 一个 xxx_strerror(int) 函数,或者写 errno。别混用(一半返回码、一半 errno),混用是 bug 温床。这是整份文档里最能说明问题的一项:C++ 的多态在 C 里可以 1:1 复刻,代价是接线全手写。
C++
#include <cstdio>
struct Shape {
virtual double area() const = 0;
virtual const char* name() const = 0;
virtual ~Shape() {}
};
struct Circle : Shape {
double r;
explicit Circle(double r_) : r(r_) {}
double area() const override { return 3.14159265358979 * r * r; }
const char* name() const override { return "circle"; }
};
struct Square : Shape {
double a;
explicit Square(double a_) : a(a_) {}
double area() const override { return a * a; }
const char* name() const override { return "square"; }
};
int main() {
Circle c(1.0); Square s(3.0);
Shape* shapes[] = {&c, &s};
for (Shape* p : shapes) std::printf("%s %.4f\n", p->name(), p->area());
}
C
#include <stdio.h>
/* 手写虚表:把函数指针放进结构体首成员,对象用「首成员」上转成 Shape* */
typedef struct Shape {
double (*area)(const void* self);
const char* (*name)(void);
} Shape;
typedef struct { Shape base; double r; } Circle;
typedef struct { Shape base; double a; } Square;
static double circle_area(const void* self) { const Circle* c = (const Circle*)self; return 3.14159265358979 * c->r * c->r; }
static const char* circle_name(void) { return "circle"; }
static double square_area(const void* self) { const Square* s = (const Square*)self; return s->a * s->a; }
static const char* square_name(void) { return "square"; }
/* 相当于 p->area():Base* 上调用时把 self 传回去 */
static double shape_area(Shape* s) { return s->area(s); }
int main(void) {
Circle c = {{circle_area, circle_name}, 1.0};
Square s = {{square_area, square_name}, 3.0};
Shape* shapes[] = {(Shape*)&c, (Shape*)&s};
for (int i = 0; i < 2; i++) printf("%s %.4f\n", shapes[i]->name(), shape_area(shapes[i]));
return 0;
}
两边输出
circle 3.1416
square 9.0000
改写要点:① 函数指针表放在结构体首成员,对象指针转成基类指针时才不用做地址运算((Shape*)&c 与 &c.base 是同一个地址);② 「成员函数」必须显式收 self,这就是虚表里函数指针多一个参数的原因;③ 没有 override 检查——C++ 里签名写错编译器会报错,C 里只会静默换成一个没人调用的新函数;④ 析构同理:C 版必须在基类虚表里也放一个 destroy 指针,否则删对象时不知道要释放什么;⑤ 这套东西的性能和 C++ 虚函数基本一致(都是一次间接跳转),所以「C 更快所以不用虚函数」通常是误会——真正的差别是可读性和类型检查。
C++
#include <cstdio>
namespace geo { double scale(double v) { return v * 2; } }
namespace app { double scale(double v) { return v * 3; } }
int main() { std::printf("%.1f %.1f\n", geo::scale(2), app::scale(2)); }
C
#include <stdio.h>
static double geo_scale(double v) { return v * 2; } /* 命名空间 → 名字前缀 */
static double app_scale(double v) { return v * 3; }
int main(void) { printf("%.1f %.1f\n", geo_scale(2), app_scale(2)); return 0; }
4.0 6.0
改写要点:前缀只有约定,没有强制——所以工程上要配「名字前缀检查」(代码审查清单或脚本)。嵌套命名空间 a::b::c 会长成 a_b_c_ 这种长前缀,名字超过 20 个字符时要考虑缩短模块名。函数级 using namespace 带来的便利在 C 里完全消失,调用点全部要写全名。
new / delete → malloc / freeC++
#include <cstdio>
struct Node { int v; explicit Node(int x) : v(x * 2) {} }; // 构造函数自动跑
int main() {
Node* p = new Node(21);
std::printf("v=%d\n", p->v);
delete p;
}
C
#include <stdio.h>
#include <stdlib.h>
typedef struct { int v; } Node;
int main(void) {
Node* p = malloc(sizeof *p);
if (!p) return 1; /* malloc 会失败,必须查 */
p->v = 21 * 2; /* 没有构造函数,初始化自己写 */
printf("v=%d\n", p->v);
free(p);
return 0;
}
v=42
改写要点:① malloc 不调用构造,成员初始化要自己写;② malloc 失败返回 NULL,工业代码里必须处理(new 失败默认抛 bad_alloc,所以原代码里那些「不检查」的地方不是疏忽,是语言保证);③ 用 sizeof *p 而不是 sizeof(Node),改类型时不会漏改;④ new[] 要配 delete[],C 里数组就是 malloc(n * sizeof *p) + free,但要注意 n * sizeof 的乘法溢出(n 来自外部输入时尤其危险)。
auto / 范围 for / nullptr / boolC++
#include <cstdio>
int main() {
int v[4] = {1, 2, 3, 4};
for (auto& x : v) x *= 2; // 范围 for + 引用语义
int* p = nullptr; // 强类型空指针
bool ok = (p == nullptr);
for (int x : v) std::printf("%d ", x);
std::printf("ok=%d\n", (int)ok);
}
C
#include <stdio.h>
#include <stdbool.h>
int main(void) {
int v[4] = {1, 2, 3, 4};
for (size_t i = 0; i < 4; i++) v[i] *= 2; /* 索引循环,边界自己写对 */
int* p = NULL; /* 空指针就是 0,没有类型信息 */
bool ok = (p == NULL);
for (size_t i = 0; i < 4; i++) printf("%d ", v[i]);
printf("ok=%d\n", (int)ok);
return 0;
}
2 4 6 8 ok=1
改写要点:① 范围 for 改成索引循环后,长度写死在循环条件里——用 sizeof(v)/sizeof(v[0]) 或先用宏算好,别抄错;② auto 在有隐式转换的地方要显式写出目标类型,顺便检查一下原来是否依赖了隐式转换;③ stdbool.h 提供 bool/true/false,但 printf 里得转成 int 打 %d;④ NULL 就是 0(或 (void*)0),拿它做函数实参时没有类型信息,_Generic 或函数指针赋值处容易出歧义。
把前面每一项拼起来,就是一个真实程序的改写。下面这份是典型的「竞赛 / 工程两栖」写法:vector 邻接表 + priority_queue 小根堆 + lambda + 结构化绑定。两版都是 -O2 编译后运行,输出逐字节一致。
C++ 版(dijkstra_full.cpp)
#include <cstdio>
#include <vector>
#include <queue>
#include <utility>
using namespace std;
const int N = 6; // 点 1..5,0 号不用
vector<pair<int, int>> g[N]; // 邻接表:{to, weight}
int main() {
auto add = [](int u, int v, int w) { g[u].push_back({v, w}); g[v].push_back({u, w}); };
add(1, 2, 2); add(1, 3, 5); add(2, 3, 1); add(2, 4, 7); add(3, 4, 2); add(4, 5, 1);
const int INF = 1000000000;
vector<int> d(N, INF);
priority_queue<pair<int, int>, vector<pair<int, int>>, greater<>> pq;
d[1] = 0; pq.push({0, 1});
while (!pq.empty()) {
auto [du, u] = pq.top(); pq.pop();
if (du > d[u]) continue;
for (auto [v, w] : g[u])
if (du + w < d[v]) { d[v] = du + w; pq.push({d[v], v}); }
}
for (int i = 1; i <= 5; i++) printf("%d:%d ", i, d[i]);
printf("\n");
}
C 版(dijkstra_full.c)
#include <stdio.h>
#define N 6
#define INF 1000000000
/* 邻接表:数组存边,head[u] 是链头,nxt[e] 是下一条边 */
static int head[N], nxt[64], to[64], wt[64], ecnt = 0;
static void add(int u, int v, int w) {
to[ecnt] = v; wt[ecnt] = w; nxt[ecnt] = head[u]; head[u] = ecnt++;
}
/* 手写小根堆(原来是 std::priority_queue) */
static int hd[128], hu[128], hn = 0;
static void push(int d, int u) {
int i = hn++;
hd[i] = d; hu[i] = u;
while (i > 0) {
int p = (i - 1) / 2;
if (hd[p] <= hd[i]) break;
int td = hd[p], tu = hu[p];
hd[p] = hd[i]; hu[p] = hu[i]; hd[i] = td; hu[i] = tu;
i = p;
}
}
static void pop(int* d, int* u) {
*d = hd[0]; *u = hu[0];
hn--;
hd[0] = hd[hn]; hu[0] = hu[hn];
int i = 0;
for (;;) {
int l = 2 * i + 1, r = l + 1, m = i;
if (l < hn && hd[l] < hd[m]) m = l;
if (r < hn && hd[r] < hd[m]) m = r;
if (m == i) break;
int td = hd[i], tu = hu[i];
hd[i] = hd[m]; hu[i] = hu[m]; hd[m] = td; hu[m] = tu;
i = m;
}
}
int main(void) {
for (int i = 0; i < N; i++) head[i] = -1;
add(1, 2, 2); add(2, 1, 2);
add(1, 3, 5); add(3, 1, 5);
add(2, 3, 1); add(3, 2, 1);
add(2, 4, 7); add(4, 2, 7);
add(3, 4, 2); add(4, 3, 2);
add(4, 5, 1); add(5, 4, 1);
int d[N];
for (int i = 0; i < N; i++) d[i] = INF;
d[1] = 0;
push(0, 1);
while (hn > 0) {
int du, u;
pop(&du, &u);
if (du > d[u]) continue;
for (int e = head[u]; e != -1; e = nxt[e]) {
int v = to[e], w = wt[e];
if (du + w < d[v]) { d[v] = du + w; push(d[v], v); }
}
}
for (int i = 1; i <= 5; i++) printf("%d:%d ", i, d[i]);
printf("\n");
return 0;
}
两边输出(一致)
1:0 2:2 3:3 4:5 5:6
这个案例里发生的六次改写:
| 原来 | 改成了 | 为什么要多想一步 |
|---|---|---|
vector<pair> g[N] | 数组存边:head / nxt / to / wt + ecnt | 无向边要调两次 add(C++ 版里 lambda 替你做了两次);边数上限 64 是硬编码,要算准或改成动态分配 |
priority_queue<…, greater<>> | 手写二叉堆 push/pop | 堆容量 128 也得算准;pop 里 hn-- 与「取堆顶」的先后顺序写错就会读到脏数据 |
auto add = [](…){…} | 普通 static 函数 add | 不捕获任何东西的 lambda 本来就等价于普通函数,这一项改写几乎零代价 |
auto [du, u] = pq.top() | pop(&du, &u) 两个出参 | 没有结构化绑定,返回值要拆成出参;C 版还能进一步用 struct 返回值,可读性更好 |
vector<int> d(N, INF) | int d[N] + 循环赋 INF | 固定数组不能像 vector 那样「按值初始化」,漏掉赋值循环就会带着栈上的垃圾值做松弛 |
for (auto [v, w] : g[u]) | for (int e = head[u]; e != -1; e = nxt[e]) | 链式遍历的终止条件是 -1(head 的哨兵),与 vector 的 end() 一样是约定,但编译器不会帮你查 |
行数与代价的直观对比:C++ 版 23 行、C 版 61 行,改写后代码量接近三倍——多出来的部分几乎全在数据结构(邻接表 + 二叉堆),而不是算法本身。这也解释了第 1 节的成本公式:STL 用量的系数比手写内存管理的系数更大。
.cpp 当 .c 编改写时不要靠肉眼找 C++ 特性。把源文件复制一份、后缀改成 .c,交给 gcc 编,编译器会把所有障碍按行号列出来——这是免费的、不会漏的检查表。
做法(用一个「C 风格但其实是 C++」的小类做实验)
class Point {
public:
Point(int x_) : x(x_) {}
int get() const { return x; }
private:
int x;
};
int main() {
Point p(3);
bool ok = p.get() == 3;
return ok ? 0 : 1;
}
g++ 编这份源码:rc=0(合法 C++)。改名 _asc.c 后用 gcc 编:rc=1,共 6 行报错
gcc_on_cpp_asc.c:1:1: error: unknown type name 'class'
gcc_on_cpp_asc.c:1:13: error: expected '=', ',', ';', 'asm' or '__attribute__' before '{' token
gcc_on_cpp_asc.c:9:5: error: unknown type name 'Point'; did you mean 'int'?
gcc_on_cpp_asc.c:9:13: error: expected declaration specifiers or '...' before numeric constant
gcc_on_cpp_asc.c:10:5: error: unknown type name 'bool'
gcc_on_cpp_asc.c:10:15: error: 'p' undeclared (first use in this function)
换成 4.2 节那个「带成员函数的结构体」(去掉了 #include,因为缺头文件会变成 fatal error 提前终止,看不到后面的问题),报错更长也更碎——注意最后两条 error 是前面错误引发的连锁反应:
gcc_on_cpp_vec2_asc.c:3:5: error: unknown type name 'Vec2'
gcc_on_cpp_vec2_asc.c:3:20: error: type defaults to 'int' in declaration of 'Vec2' [-Wimplicit-int]
gcc_on_cpp_vec2_asc.c:3:24: error: expected ';', ',' or ')' before '&' token
gcc_on_cpp_vec2_asc.c:4:1: warning: no semicolon at end of struct or union
gcc_on_cpp_vec2_asc.c:6:5: error: unknown type name 'Vec2'; use 'struct' keyword to refer to the type
gcc_on_cpp_vec2_asc.c:6:11: error: expected '=', ',', ';', 'asm' or '__attribute__' before '{' token
gcc_on_cpp_vec2_asc.c:7:5: error: unknown type name 'Vec2'; use 'struct' keyword to refer to the type
gcc_on_cpp_vec2_asc.c:7:14: error: 'a' undeclared (first use in this function)
gcc_on_cpp_vec2_asc.c:8:13: error: request for member 'x' in something not a structure or union
gcc_on_cpp_vec2_asc.c:7:10: warning: variable 'c' set but not used [-Wunused-but-set-variable=]
有用的工作流是「改一轮、编一次、只看前三条错」:改掉类型名和成员函数,再编一次,报错会跳到下一批问题。一次盯着几十条报错改是低效的。
编译开关就要开满,别等上线再开:-Wall -Wextra -Wpedantic(C 的标准符合性检查)+-Wwrite-strings(字符串字面量当 const char*,防止改写时把 char* 的旧习惯带进来)+调试期 -fsanitize=address,undefined。C 编译器默认宽容得多:很多在 C++ 里是错误的东西,在 C 里只是「类型默认成 int」的一个 warning——比如上面第 2 行的 type defaults to 'int'。
改写通常是「一边 C、一边 C++」(老代码还在用 C++ 调它)。那么两边的数据能不能直接互通?纯 POD 结构体、枚举、基本类型是可以的,实测 gcc 与 g++ 对同一份声明算出的尺寸和偏移完全一致:
struct Record { char tag; int id; double score; };
enum Color { RED, GREEN, BLUE };
同一个结构体分别在 g++ 与 gcc 下打印 sizeof / offsetof
C++ 版:sizeof(Record)=16 offset(score)=8 sizeof(Color)=4 sizeof(bool)=1 sizeof(long)=4 sizeof(long long)=8 sizeof(size_t)=8 sizeof(void*)=8
C 版:sizeof(Record)=16 offset(score)=8 sizeof(Color)=4 sizeof(bool)=1 sizeof(long)=4 sizeof(long long)=8 sizeof(size_t)=8 sizeof(void*)=8
注意 offset(score)=8:char 后面有 3 字节填充,int 后面又有 4 字节填充,所以 1+4+8 的结构体占 16 字节。这不是「编译器随便排的」,而是两边都遵守同一套对齐规则——只要声明的字段类型和顺序完全一致,布局就一致。
| 可以跨语言传 | 不能跨语言传 |
|---|---|
基本类型(int/double/指针)、struct(纯数据)、enum、函数指针、数组 |
std::string/std::vector/std::map 等模板容器(内部布局是实现细节,两个版本一升级就可能变) |
以 void* 表示的「句柄」(C 侧分配、C++ 侧只传来传去) |
带虚函数的对象(虚表布局是 ABI 细节)、异常(跨语言抛接行为未定义)、long double |
回调函数(C++ 侧传 extern "C" 的自由函数或静态成员函数) |
依赖位域布局的二进制协议(打包规则由实现定义)、按 ABI 宽度区分的整型协议 |
一条实测数据值得记住:sizeof(long)=4、sizeof(long long)=8。这是 Windows(LLP64)的结果;到了 Linux(LP64)long 会变成 8。跨平台的二进制接口里不要用 long 和裸 size_t 传数据——用固定宽度的 int32_t/int64_t(stdint.h)。这一条跟语言无关,但迁移时最容易被顺手带出去。
C 版「能编能跑」远不等于「改对了」。按下面的顺序过一遍,成本很低但能拦住绝大多数改写引入的 bug:
matrix2c.py 里 same = (cppout == cout and rc_cpp == rc_c),连退出码一起比)。输出不一致时先怀疑自己改错了,不要先怀疑「C 版算得不一样」。-Wall -Wextra -Wpedantic 全绿。改写过程中最容易留下「忘了用」的变量(-Wunused-but-set-variable)和「默认 int」的类型(-Wimplicit-int),这两个 warning 都直接指向改写漏洞。-fsanitize=address,undefined 跑一遍测试。越界写、use-after-free、有符号溢出——这三类是 C 改写最典型的产物,ASan/UBSan 能直接定位到行。注意 sanitizer 会让程序变慢、内存变多,测试期用、发布版关掉。live 计数器就是最小可用版本:给每个 init 加一、每个 destroy 减一,正常结束时必须归零。工程上等价的做法是挂一个自定义分配器统计 malloc/free 配对。extern "C" 就是为这一步准备的)。| 坑 | 症状 / 后果 | 怎么防 |
|---|---|---|
少了 xxx_destroy | 内存/句柄泄漏,长跑进程慢慢涨 | 分配/释放计数必须归零;goto cleanup 集中收口 |
v = realloc(v, n) 直接覆盖 | 失败时旧指针丢失 → 泄漏 + 后续崩溃 | 用临时指针接 realloc 结果,判空后再赋回 |
memcpy/结构体赋值里有指针成员 | 两个对象共享同一块内存 → 双重释放 | 挨个字段写 xxx_copy,指针成员单独深拷贝 |
qsort 比较函数 return a - b | 大数差值溢出,排序结果错且不报错 | 返回 (a > b) - (a < b) |
| 宏参数带副作用 | 重复求值:MAX(i++, 0) 让 i 加了两次 | 展开在纸上看一遍;先接临时变量再传 |
| 忘记检查返回值 | 错误码被忽略,程序带垃圾值继续跑 | 每个可失败调用后立刻检查;warn_unused_result |
malloc 失败没处理 | NULL 解引用直接崩 | if (!p) { … };或包一层 xmalloc 统一失败策略 |
C++ 侧忘了 extern "C" | undefined reference to `c_add(int, int)' | 头文件里加 #ifdef __cplusplus 守卫(第 2.3 节) |
用 long 跨平台传数据 | Windows 4 字节 / Linux 8 字节,协议错位 | 用 int32_t/int64_t |
| 字符串长度假设 | strcpy/strcat 越界写(安全问题) | 固定缓冲优先 snprintf;带长度传参 |
| 虚函数签名写错 | 静默变成新函数,多态失效 | 虚表初始化处集中放函数指针,编译期加断言(_Static_assert) |
| 固定数组容量算小 | 越界写(第 5 节 nxt[64]/hd[128] 这类) | 容量用 #define 集中定义,加运行时断言 assert(ecnt < MAXE) |
| 错误处理混用两种风格 | 一半返回码、一半 errno,调用方漏判 | 工程内统一一种,写进代码规范 |
Q1:C++ 成员函数里的 this,改写成 C 之后变成了什么?
Q2:原来靠析构函数自动释放的资源,改成 C 之后由谁负责?
Q3:qsort 的比较函数,下面哪种写法是安全的?
Q4:C++ 调用方要调用改写后的 C 模块,头文件里必须有什么?
Q5:用 C11 的 _Generic 替代函数模板,最主要的限制是什么?
MEM(内存)、STR(字符串)两章收益最大。realloc 失败语义、snprintf 返回值、qsort 比较函数要求)最准。#include 即用)。改成 C 之后要保留 map 语义,多半会用到它。-fsanitize=address 达到类似效果。两者都值得在迁移完成后跑一次。_w2c/(matrix2c.py 可重跑,report.txt 是 17 组用例 + 6 组探针的原始输出)。把 C++ 程序改写成 C · 实测于 gcc / g++ 16.2.0(MSYS2 UCRT64,Windows)· 持续更新 · 姊妹篇:C 与 C++ 的兼容边界 · 返回资源列表