把 C++ 程序改写成 C

一句话:这不是「把语法翻译一遍」,而是把编译器原来替你做的三件事——初始化与销毁、类型分派、错误传播——全部改成手写代码。语法层面的改写是机械的,这三件事才是工作量所在。

这份文档里每一段代码、每一个输出、每一句报错原文,都是用本机 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 是原始记录)。

1. 先判断值不值得

改写 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++ 编,随时可发版、随时可回退。


2. 三种落地策略

2.1 整模块重写成 .c(最彻底)

选一个依赖最少、接口最窄的模块(比如一个数据结构或一个协议解析器)先转。转完这个模块的编译单元里不再有 C++ 语法,编译器换成 gcc,链接进去。好处是边界清楚:这个 .c 里的内存错误不会跑到别处去。

2.2 留在 C++ 里,但只用 C 子集

如果「不能用 C++」的约束来自风格或审查而不是工具链,那么最省力的做法是:代码仍然是 .cpp,但规定不用异常、不用 RTTI、不用 STL 容器、不用 new。这样代码形态和第 4 节的 C 版几乎一致,需要时还能用模板和 constexpr 这类零运行时成本的特性。

2.3 逐模块迁移(推荐)

转好的模块用 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"。


3. 速查总表

左边是 C++ 写法,中间是 C 里的等价物,右边是「改完你失去了什么」。红表示这一步会产生新的手工维护点。

C++C 替代代价
std::cout / iostreamprintf / stdio.h没有类型安全、没有格式检查
class + 成员函数struct + 普通函数,this 变成第一个参数调用点要写 obj_method(&obj, …)
构造函数 / 析构函数xxx_init() / xxx_destroy()每条退出路径都要手写,漏一个就泄漏
赋值 / 拷贝构造memcpy 或手写字段赋值深拷贝要自己判(指针成员会共享)
std::vectormalloc/realloc + size/capacity 两个变量扩容、越界保护、释放全写
std::stringchar 数组 / char*长度、'\0'、溢出边界全自己管
std::map / unordered_map手写数组 + 查找,或第三方(uthash)哈希函数、冲突、有序性都要自己实现
std::sort + lambdaqsort + 比较函数比较函数不能返回差值(会溢出)
函数模板_Generic 宏(C11)或显式列出每个类型支持的类型要枚举,不能对任意类型生成代码
函数重载不同函数名(add_int / add_double)名字变长,调用方要改
默认参数两个函数,或一个包装函数组合数会随参数个数爆炸
T& 引用参数T* 指针调用点写 &x,空指针要自己防
运算符重载普通函数(frac_add)可读性下降,表达式要拆开写
throw / catch返回错误码 / errno / goto cleanup可以被忽略,错误会静默传播
继承 + virtual手写虚表:函数指针结构体 + 首成员上转每一层都要自己接线,没有 override 检查
namespace名字前缀(geo_scale)只有约定,没有强制
new / deletemalloc / free + 手写初始化没有构造/析构,malloc 会失败
auto / 范围 for显式类型 / 索引循环长度和边界自己写对
nullptrNULL(就是 0)没有类型,重载/模板里容易歧义
bool / truestdbool.h本质是 _Bool,打印要用 %d
智能指针 / RAII 包装引用计数 + 手写 release,或 arena 一次性释放循环引用没人管
std::function函数指针 + void* ctxlambda 捕获的变量要自己打包
try 里的栈展开goto cleanup; 集中释放标签和清理顺序要人工维护
constexpr / 模板元编程宏 + 手算常量没有编译期类型检查

4. 逐项改写手册

下面每一项都是「同一件事的两份实现」。左边的 C++ 版与右边的 C 版我都编译运行过,输出完全一致,输出贴在每项末尾。

4.1 输入输出:iostream → stdio

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 里不是编译错误,运行时才出乱码。

4.2 类 → struct + 函数(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;)。

4.3 构造 / 析构 → init / destroy(本章最贵的一项)

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 集中收口——不要两种混用,混用就一定会漏。

4.4 容器: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 里最典型的悬空指针来源。

4.5 容器: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)。

4.6 容器: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 溢出问题在结构体里的另一副面孔。

4.7 算法: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 版最容易被忽略的连带损失。

4.8 泛型:函数模板 → _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++ 这类带副作用或耗时的表达式,必须先用临时变量接住再传进去。判断办法:把宏展开在纸上写一遍,数一数每个参数出现了几次。

4.9 重载 → 命名;默认参数 → 包装函数

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 个函数,这是设计上需要重来的信号。

4.10 引用 → 指针

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 里没有任何机制替你记住。

4.11 运算符重载 → 普通函数

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 节)。

4.12 异常 → 错误码

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

改写要点(三条比代码重要得多):

4.13 继承 + 虚函数 → 手写虚表

这是整份文档里最能说明问题的一项: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 更快所以不用虚函数」通常是误会——真正的差别是可读性和类型检查。

4.14 命名空间 → 名字前缀

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 里完全消失,调用点全部要写全名。

4.15 new / delete → malloc / free

C++

#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 来自外部输入时尤其危险)。

4.16 语法糖:auto / 范围 for / nullptr / bool

C++

#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 或函数指针赋值处容易出歧义。


5. 综合案例:Dijkstra

把前面每一项拼起来,就是一个真实程序的改写。下面这份是典型的「竞赛 / 工程两栖」写法: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 用量的系数比手写内存管理的系数更大。


6. 借编译器之力:把 .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'。


7. 边界的另一面:什么能跨语言传

改写通常是「一边 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)。这一条跟语言无关,但迁移时最容易被顺手带出去。


8. 改写完怎么验证

C 版「能编能跑」远不等于「改对了」。按下面的顺序过一遍,成本很低但能拦住绝大多数改写引入的 bug:

  1. 拿同一批输入跑两版,逐字节 diff 输出。本页 17 组实验就是这么做的(matrix2c.py 里 same = (cppout == cout and rc_cpp == rc_c),连退出码一起比)。输出不一致时先怀疑自己改错了,不要先怀疑「C 版算得不一样」。
  2. 开 -Wall -Wextra -Wpedantic 全绿。改写过程中最容易留下「忘了用」的变量(-Wunused-but-set-variable)和「默认 int」的类型(-Wimplicit-int),这两个 warning 都直接指向改写漏洞。
  3. 用 -fsanitize=address,undefined 跑一遍测试。越界写、use-after-free、有符号溢出——这三类是 C 改写最典型的产物,ASan/UBSan 能直接定位到行。注意 sanitizer 会让程序变慢、内存变多,测试期用、发布版关掉。
  4. 对照分配/释放次数。本页 4.3 节的 live 计数器就是最小可用版本:给每个 init 加一、每个 destroy 减一,正常结束时必须归零。工程上等价的做法是挂一个自定义分配器统计 malloc/free 配对。
  5. 把 C 版当库,用原来的 C++ 测试套件跑它。这是最省事、覆盖最全的回归手段:接口没变、测试没改,只是链接目标换了实现(第 2.3 节的 extern "C" 就是为这一步准备的)。

9. 坑点清单

坑症状 / 后果怎么防
少了 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 替代函数模板,最主要的限制是什么?


推荐资源

把 C++ 程序改写成 C · 实测于 gcc / g++ 16.2.0(MSYS2 UCRT64,Windows)· 持续更新 · 姊妹篇:C 与 C++ 的兼容边界 · 返回资源列表