C语言中在C++中仍然无法工作的构造——以及一些已发生变化的构造
摘要
一篇更新经典调查的博文,关于C语言中在C++中无法工作的构造,涵盖了C++20和C23标准中影响兼容性的变化。
<p><a href="https://lobste.rs/s/za6jjo/c_constructs_still_don_t_work_c_few_changed">评论</a></p>
查看缓存全文
缓存时间: 2026/05/23 18:48
# 在 C++ 中仍然不生效的 C 构造——以及一些已经改变的部分
来源:https://lospino.so/blog/c-constructs-that-still-dont-work-in-cpp/
博客 | 2026 年 5 月 20 日
《C 构造在 C++ 中不生效》的 2026 年续篇:哪些仍然会出问题,C++20 改变了什么,以及 C23 又改变了什么。
- GitHub **JLospinoso/c-constructs-still-dont-work-cpp** (https://github.com/JLospinoso/c-constructs-still-dont-work-cpp)
2019 年我写过一篇简短调查,列举了在 C++ 中无法使用的 C 构造 (https://lospino.so/c/c++/programming/developing/software/2019/04/28/c-constructs-that-dont-work-in-cpp.html)。当时想表达的不是 C 很潦草或者 C++ 更优越,而是 C++ 并不是 C 的超集,跨界的 C 程序员应该知道检查点在哪里。这条建议至今仍然适用。
但边界已经发生了变化。C++20 引入了指定初始化器的一个版本。C++20 还修复了一些关于 `malloc` 的低级对象生命周期案例——这些案例以前很容易被错误描述。与此同时,C23 修改了旧的空参数列表规则,该规则曾使 `void f()` 在 C 和 C++ 中的含义有着危险的不同。
实际操作层面的教训是一样的,但更加尖锐:当你讨论 C/C++ 兼容性时,请注明语言模式。“有效的 C”和“有效的 C++”现在已经不够精确了。你通常需要说明是 C17、C23、C++17、C++20 还是 C++23。
我还把本文后面的示例放在了一个小型配套仓库中 (https://github.com/JLospinoso/c-constructs-still-dont-work-cpp)。该仓库用于可重复检查;里面的 Compiler Explorer 链接 (https://github.com/JLospinoso/c-constructs-still-dont-work-cpp/blob/main/docs/godbolt-links.md) 可用于快速诊断。
## 兼容性对照表
以下是简短的地图。详细信息在后。重点不只是 C++ 不是 C 的超集,而是人们仍在重复的一些经典示例在 C++20 或 C23 下已经发生了变化,因此现在的正确答案取决于语言模式。这张表是一张地图,不能替代示例;在小屏幕上,下面的章节比完整的矩阵更容易阅读。
| 状态 | 构造 | C17 | C23 | C++17 | C++20 / C++23 | 实际建议 |
|------|------|-----|-----|-------|---------------|----------|
| 仍然不同 | `void*` 到对象指针 | 从 `malloc` 隐式转换是惯用的 C 写法。 | 相同。 | 无隐式转换。 | 相同。 | 在 C++ 中,不要将 `malloc` 作为默认分配策略。如果必须使用,请谨慎转换并谨慎处理生命周期。 |
| 自 2019 年已改变 | `malloc` 与对象生命周期 | 按照 C 的规则,C 的分配创建用作对象的存储。 | 相同的广义 C 模型。 | 容易写出带强制转换编译通过但没有 C++ 对象生命周期的代码。 | 一些隐式生命周期的情况已修复。构造函数仍然不会被调用。 | 区分存储、生命周期、初始化、所有权和析构。 |
| 仍然不同 | 丢弃 `const` | 约束违规;编译器常发出警告。 | 相同的基本问题。 | 无强制转换则格式错误。 | 相同。 | 强制转换可能编译通过。但这并不意味着对真正 `const` 的对象进行写入是定义良好的。 |
| C23 已改变 | 枚举 | 枚举常量类似整数;枚举对象之间的转换自由度很大,足以让 C++ 程序员惊讶。 | C23 增加了固定底层类型和更明确的枚举常量类型规则。 | 枚举类型是独立的;`int` 到枚举类型不是隐式的。 | 相同;`enum class` 更严格。 | 对 C++ API 使用 `enum class`。仅在需要 ABI 或 C 互操作时才使用普通枚举。 |
| C23 已改变 | `void f()` | 旧语义下无原型;不匹配的调用可能编译通过,但未定义。 | 行为如同用 `void` 声明。 | 表示无参数。 | 相同。 | 对于共享头文件,除非你能控制语言模式,否则仍应在面向 C 的 API 中写 `void f(void)`。 |
| 自 2019 年已改变 | 指定初始化器 | 完整的 C 风格指定初始化,包括乱序、数组、嵌套和混合形式。 | 同一家族,C23 在其他方面有所演变。 | 非标准 C++。 | 标准化,但比 C 窄。 | 在 C++20 中有用,但仅适用于聚合体、直接成员、声明顺序和全指定子句。 |
| 扩展陷阱 | `restrict` | 标准 C99 限定符。 | 仍是标准 C,C23 用词更新。 | 非标准 C++。 | 非标准 C++。 | 仅在可移植性边界之后使用编译器扩展。 |
| 仍然不同 | 柔性数组成员 | 标准 C99 尾部数组模式。 | 仍是标准 C。 | 非标准 C++。 | 非标准 C++。 | 在 ABI 边界保留 C 布局;转换为 `span`、`vector` 或显式的头部/有效载荷表示。 |
## 指定初始化器:可以,但不是 C 的版本
2019 年的文章说指定初始化器在 C++ 中不可用,并指出它们可能会在 C++20 中到来。这条信息说中了。C++20 为聚合初始化添加了指定初始化器。以下是有效的 C++20:
```cpp
struct Address {
const char* street;
const char* city;
const char* state;
int zip;
};
Address white_house{
.street = "1600 Pennsylvania Avenue NW",
.city = "Washington",
.state = "District of Columbia",
.zip = 20500,
};
```
但这并不是 C 程序员习惯的同一个特性。C++ 的指定符必须命名直接的非静态数据成员,并且必须按照声明顺序出现。这意味着这种乱序形式在 C++ 中仍然无效:
```cpp
struct Options {
int timeout_ms;
bool verbose = false;
int retries = 0;
};
Options o{
.retries = 3, // 无效的 C++20:不符合声明顺序
.timeout_ms = 5000,
};
```
C 还允许 C++ 仍然拒绝的模式,包括数组指定符和嵌套指定符:
```cpp
int table[4] = { [2] = 99 }; // 有效的 C,无效的 C++
struct Inner { int value; };
struct Outer { struct Inner inner; };
struct Outer o = { .inner.value = 7 }; // 有效的 C,无效的 C++
```
C 还允许在同一个初始化器中混合位置子句和指定子句:
```cpp
struct Triple { int first; int second; int third; };
struct Triple t = { 1, .third = 3 }; // 有效的 C,无效的 C++
```
在 C++20 中,类似的聚合初始化是格式错误的:
```cpp
struct Triple { int first; int second; int third; };
Triple t{1, .third = 3}; // 无效的 C++20:混合指定和位置子句
```
这并非随意规定。C++ 有构造函数、析构函数、默认成员初始化器、引用以及代码可观察到的初始化顺序模型。C 风格的自由会与 C++ 对象模型冲突。有一些提案试图放宽 C++ 规则,包括乱序指定初始化器和基类指定初始化。请将这些视为提案。不要在假设它们已被采纳的基础上编写可移植的 C++。
**规则**:C++20 指定初始化器非常适合普通的聚合配置对象。它们并不能直接替代 C99 指定初始化。C++20 的形式并非“C 指定符,现在在 C++ 中”。它是一项受限的聚合初始化特性。有用的思维模型是:直接成员、声明顺序,并且不要混合指定和非指定子句。
## 空参数列表:C 向 C++ 靠拢
这曾经是 C 和 C++ 分歧最清晰的例子之一。在 C++ 中:
```cpp
void fn();
fn(42); // 无效的 C++:fn 不接受参数
```
在 C17 及更早版本中,`void fn();` 并不提供原型。定义为 `void fn() {}` 的函数指定了无参数,但通过非原型声明进行的调用不会像 C++ 程序员期望的那样得到检查。这样的调用在经过默认参数提升后可能编译通过,但如果提供的参数数量与参数数量不匹配,则行为未定义:
```cpp
void fn() { }
int main(void) {
fn(42); // 在 C17 模式下可能编译通过;对于此定义,行为未定义
}
```
C23 移除了旧的分裂:不带参数类型列表的函数声明符行为如同使用了 `void`,提供了原型,并且参数计数必须一致。这是一个真正的兼容性改进。它也带来了一个迁移问题:旧的 C 代码可能在 C17 模式下编译通过,但在 C23 模式下失败。这是好的失败,但它仍然是失败。
**规则**:在面向 C 的头文件中,`void fn(void)` 仍然是最不容易引起误解的写法。在纯 C++ 代码中,`void fn()` 没问题。
## `void*`、`malloc` 和对象生命周期陷阱
简单的不兼容性没有改变。C 允许你这样写:
```c
int* values = malloc(100 * sizeof *values);
```
C++ 不会隐式地将 `void*` 转换为 `int*`:
```cpp
int* values = std::malloc(100 * sizeof *values); // 无效的 C++
```
你可以强制转换:
```cpp
auto* values = static_cast<int*>(std::malloc(100 * sizeof(int)));
```
但强制转换并不是有趣的部分。有趣的部分是生命周期。在当前 C++ 中,尖锐的边缘不是“`malloc` 永远不能给你对象”。C++20 对此进行了收窄。某些操作,包括 C 的分配函数,被指定为如果这样做会使程序定义良好,则会隐式创建隐式生命周期类型的对象;草案中的例子基本上是一个平凡的聚合体,由 `std::malloc` 返回,然后通过其成员进行赋值。这个修复是有意限制的:它不会运行构造函数、初始化标量值、建立不变量,也不会为本身并非隐式生命周期类型的子对象启动生命周期。对于非隐式生命周期类型,在构造发生之前,存储仍然只是存储。
所以,在 C++20 中,这类代码不再是吓人的最佳例子:
```cpp
#include <cstdlib>
struct X { int a; int b; };
X* make_x() {
auto* p = static_cast<X*>(std::malloc(sizeof(X)));
p->a = 1;
p->b = 2;
return p;
}
```
对于像 `X` 这样的隐式生命周期类型,C++20 修复了生命周期问题。但这并不意味着 `malloc` 变成了惯用的 C++。该修复不会调用构造函数、不会初始化值、不会给你异常安全、不会将所有权与析构配对。这也并不意味着下面这样是可以的:
```cpp
#include <cstdlib>
#include <string>
void bad() {
auto* s = static_cast<std::string*>(std::malloc(sizeof(std::string)));
*s = "hello"; // 未定义行为:没有构造 std::string 对象
}
```
安全的底层 C++ 写法是明确的:
```cpp
#include <new>
#include <string>
#include <memory>
void* storage = ::operator new(sizeof(std::string));
auto* s = new (storage) std::string("hello");
std::destroy_at(s);
::operator delete(storage);
```
更好的高层写法通常更简单:
```cpp
auto s = std::make_unique<std::string>("hello");
```
**规则**:`void*` 的强制转换从来不是完整的故事。问五个问题:谁拥有存储?对象生命周期何时开始?对象如何初始化?谁销毁它?失败时会发生什么?
## `const_cast`:编译通过并不等于定义良好
旧文章指出,C++ 强制你要显式丢弃 `const`:
```cpp
const int x = 100;
int* p = &x; // 无效的 C++
```
你可以写强制转换:
```cpp
const int x = 100;
int* p = const_cast<int*>(&x);
```
但这只是移除了类型系统的障碍。它并没有改变对象。通过 `p` 进行写入是未定义行为,因为 `x` 实际上是一个 `const` 对象。有一个有效的用例:
```cpp
int x = 100;
const int* view = &x;
int* p = const_cast<int*>(view);
*p = 101; // 定义良好:原始对象不是 const
```
这个区别在遗留代码集成中很重要。有时 C API 接受 `char*`,即使它承诺不会改变缓冲区。在该边界处使用 `const_cast` 可能是最不坏的选择。把它放在边界处,记录下来,并远离核心逻辑。不要对字符串字面量、内存映射只读存储或最初声明为 `const` 的对象使用这种技巧。如果遗留函数实际上会写入,强制转换只是把 bug 搬了个地方。
**规则**:`const_cast` 不是通行证。它是一个局部逃生舱。
## 枚举:并非简单的“C 使用 int”
对于 2026 年版的文章,旧的简写“C 枚举值由 `int` 支持”已经过于压缩。对于 C17,更安全的简写是:枚举常量具有整数类型,而枚举类型本身与一个实现定义且能够表示其值的整数类型兼容。C23 使模型更加明确:每个枚举都有一个底层类型;可以编写固定的底层类型;枚举常量在完成后的类型取决于该枚举是否有固定底层类型以及这些值是否能放入 `int`。这仍然不是 C++ 的模型。
在 C++ 中,枚举是一个独立的类型。无作用域的枚举常量或枚举类型对象可以参与整型提升或转换,但任意整数不能赋值给枚举类型而不进行强制转换。有作用域的枚举不会隐式转换为 `int` 或 `bool`。
```cpp
enum Mode { off = 0, on = 1 };
int x = on; // OK:无作用域枚举到 int
Mode m = 1; // 无效的 C++:int 到 Mode 不是隐式的
```
如果你需要从整数表示进行转换,请明确说明:
```cpp
Mode m = static_cast<Mode>(1);
```
对于 C++ API,优先使用有作用域的枚举:
```cpp
enum class Mode : unsigned { off = 0, on = 1, };
int x = Mode::on; // 无效的 C++
auto y = static_cast<unsigned>(Mode::on); // 显式
```
**规则**:当枚举是 C ABI 的一部分或你故意想要旧的枚举行为时,使用普通枚举。当枚举是 C++ 中的领域类型时,使用 `enum class`。
## `restrict`:C 的承诺,不是 C++ 的契约
C99 引入了 `restrict`,以便程序员可以承诺在一段时间内某个指针是访问某个对象的唯一路径。这个承诺可以解锁有用的别名优化。如果承诺为假,行为未定义。标准 C++ 没有 `restrict` 关键字。GCC 和 Clang 支持 `__restrict__` 和 `__restrict` 作为扩展。MSVC 有 `__restrict` 用于变量和 `__declspec(restrict)` 用于函数声明和定义,具有返回值别名语义。将这些全部视为工具链契约,而不是可移植的 C++ 接口设计。
**规则**:如果你在 C++ 中需要类似 restrict 的语义,将扩展隔离在一个小边界内,用你实际发布的编译器进行测试,并使别名前提条件不可忽略。
## 柔性数组成员:保留在边缘处
C99 也标准化了柔性数组成员:
```c
struct Packet {
unsigned length;
unsigned char payload[];
};
```
这是一个很好的 C 模式,用于具有固定头部和尾部数据的可变长度对象。这不是标准 C++。一些 C++ 编译器作为扩展接受柔性数组成员。但这并不使代码成为可移植的 C++。此外,它并没有解决 C++ 试图揭示的生命周期和所有权问题。
在 C++ 中,通常选择以下之一:
```cpp
struct Packet {
unsigned length;
std::vector<unsigned char> payload;
};
```
或者,当存储属于其他地方时:
```cpp
struct PacketView {
unsigned length;
std::span<unsigned char> payload;
};
```
在 ABI 边界,你可能需要保留 C 表示。这是可以的。但要将其隔离。解析 C 布局,验证长度,然后转换为具有显式所有权或有界视图的 C++ 表示。
**规则**:柔性数组成员是一种 C 布局工具。它们不是可移植的 C++ 对象模型。
## 迁移规则
将 C 习惯迁移到 C++ 时,我遵循以下规则:
1. 在陈述之前先注明语言模式。
2. 不要假设“在 C 中有效”就意味着“在 C++ 中只带警告编译”。
3. 不要混淆“带强制转换编译通过”和“具有定义良好的行为”。
4. 将 `malloc` 视为存储,而不是构造。
5. 在 ABI 边界保留 C 布局,然后转换为 C++ 类型。
6. 优先选择使所有权和生命周期可见的 C++ 构造。
7. 仅在命名且经过测试的可移植性边界之后才使用编译器扩展。
旧的教训仍然有效:C++ 不是 C 的超集。更新的教训更加精确:这两种语言共享的语法越来越多,但它们并不共享相同的对象模型、初始化模型或不变量。这就是 bug 潜伏的地方。
## 参考资料
- 原始文章:C Constructs That Don’t Work in C++ (https://lospino.so/c/c++/programming/developing/software/2019/04/28/c-constructs-that-dont-work-in-cpp.html)
- 配套示例:C Constructs That Still Don’t Work in C++ 仓库 (https://github.com/JLospinoso/c-constructs-still-dont-work-cpp)
相似文章
C++ 标准库在过去十五年间一直在自我撤步,证据公开
一份详细的目录,列出了从 C++11 到 C++26 期间被正式弃用、非正式不推荐或由于 ABI 约束实际上已损坏但无法修复的 C++ 标准库特性。文章指出,C++ 委员会推出一系列替代品来替换其自身特性的模式始终如一,其中包含一个基准测试,显示 Rust 和 C++ 标准库容器之间的 P99 延迟差异高达 58 倍。
关于C扩展、可移植性和替代编译器
本文讨论了编写可移植C代码的实际挑战,这些挑战源于对非标准编译器扩展和glibc条件头文件的依赖,并通过构建C编译器的示例进行说明。
信任你的编译器:现代C++
本文对比了旧的C++性能技巧与现代编译器的能力,表明编译器现在能够将朴素代码优化得比手工调整的技巧更好。包含在AMD Zen 5上使用Clang 21的基准测试。
现代化一个25年的最小化C++单元测试框架(第二部分)
本文继续系列内容,讨论如何现代化一个25年的最小化C++单元测试框架,重点介绍使用内联变量和模块来解决文件特定计数器和头文件依赖的问题。
@vivekgalatage: 迟到总比不到好。我知道C++26已经全面推出,但许多功能的基础版本源自……
所有C++20核心语言功能的详细概述及示例,作为速查表。