C11 做不到的事: 10 项 C++17 语言级不可替代能力

2 阅读8分钟

筛选标准: 只保留 C11 在语言层面无法实现的能力。 条件编译、_Alignas_Static_assert(常量表达式)atomic_signal_fence、栈上固定数组等 C11 同样能做到的内容不在讨论范围。

姊妹篇: [newosp 源码中的 C++17 实践]({{< ref "cpp17_claims_in_newosp" >}}) -- 这些语言能力在工业嵌入式库中的具体落地位置与工程决策。


一、类型安全 -- 编译器拒绝类型混用

1. 编译期类型成员校验

C 的 void* 不携带类型信息,编译器无法验证传入类型是否属于合法集合。

// C++: 编译期递归,将类型 T 映射为 variant 中的索引,不存在则编译失败
template <typename T, typename... Types>
struct VariantIndex<T, std::variant<Types...>> {
  static constexpr size_t value =
      detail::VariantIndexImpl<T, 0, std::variant<Types...>>::value;
  static_assert(value != static_cast<size_t>(-1),
                "Type not found in PayloadVariant");
};

// 订阅不在 variant 中的类型 -> 编译失败
bus.Subscribe<GpsData>(handler);  // GpsData 不在 variant 中 -> 编译错误
// C: tag 写错不产生编译错误
subscribe(bus, GPS_TAG, handler);  // GPS_TAG 写错 -> 把 SensorData 按 GpsData 解释
                                    // 编译器无任何警告

C11 的 _Static_assert 只能检查整型常量表达式,无法检查"某类型是否在类型列表中"。

2. 强类型别名

C 的 typedef uint32_t TimerIdtypedef uint32_t NodeId 是同一类型,编译器不阻止互相赋值。

// C++: NewType 创建真正不同的类型
template <typename T, typename Tag>
class NewType final {
 public:
  constexpr explicit NewType(T val) noexcept : val_(val) {}
  constexpr T value() const noexcept { return val_; }
 private:
  T val_;
};

using TimerId   = NewType<uint32_t, struct TimerIdTag>;
using SessionId = NewType<uint32_t, struct SessionIdTag>;

TimerId tid{1};
SessionId sid = tid;  // 编译失败: 不同类型
// C: typedef 不阻止混用
typedef uint32_t TimerId;
typedef uint32_t SessionId;

TimerId tid = 1;
SessionId sid = tid;  // 编译通过,运行时传错 ID

3. enum class -- 枚举值不泄漏、不隐式转整型

// C++: 作用域枚举
enum class Priority : uint8_t { kLow, kMedium, kHigh };
int x = Priority::kLow;       // 编译失败: 不能隐式转 int
if (Priority::kLow == 0) {}   // 编译失败: 不能与 int 比较
// C: 枚举值泄漏到全局
enum Priority { LOW, MEDIUM, HIGH };
enum LogLevel { LOW, HIGH };  // 编译错误: LOW/HIGH 重定义
int x = LOW;                  // 编译通过,LOW 就是 int 0

4. not_null -- 空指针解引用在构造期拦截

// C++: 类型系统标注"不可能为空"
void Process(not_null<Sensor*> sensor) {
  sensor->Read();  // 调用者保证非空,函数内无需检查
}
Process(nullptr);  // 编译期或构造期断言失败
// C: 指针永远可能为空
void process(Sensor* sensor) {
  if (!sensor) return;  // 每个函数都要防御性检查
  sensor->read();       // 忘了检查 -> SIGSEGV
}

二、编译期计算 -- 将运行时工作移至编译期

5. constexpr 函数 -- 保证编译期求值

C 的 const 不是编译期常量合同,#define 宏无法写循环或条件逻辑。

// C++: 编译器保证在编译期求值
constexpr uint32_t Fnv1a32(const char* str) noexcept {
  uint32_t hash = 2166136261u;
  while (*str) {
    hash ^= static_cast<uint32_t>(*str++);
    hash *= 16777619u;
  }
  return hash;
}

constexpr auto kTopicHash = Fnv1a32("sensor/imu");  // 编译结果: 立即数

C 可以用宏做简单常量折叠 (#define MAKE_IID(a,b) ((a)<<16|(b))),但无法在宏中写循环来实现哈希函数。

6. if constexpr -- 基于类型属性的编译期分支消除

C 的 #ifdef 只能基于宏开关,无法检测类型属性。C 的运行时 if 在函数未内联时无法消除死分支。

(a) 按 trivially copyable 选择拷贝策略:

if constexpr (std::is_trivially_copyable_v<T>) {
  std::memcpy(&buffer[offset], src, count * sizeof(T));
} else {
  for (size_t i = 0; i < count; ++i) {
    buffer[(offset + i) & mask] = src[i];
  }
}
// 编译后只保留命中的分支,另一条完全不存在于二进制中

(b) 按回调返回类型选择控制流:

if constexpr (std::is_same_v<decltype(fn(entry)), bool>) {
  if (!fn(entry)) { break; }  // 返回 bool -> 可提前终止
} else {
  fn(entry);                   // 返回 void -> 无条件执行
}

(c) 编译期递归展开多后端分派:

template <typename First, typename... Rest>
auto DispatchFile(const char* path, ConfigFormat format) {
  if (First::kFormat == format)
    return ConfigParser<First>::ParseFile(*this, path);
  if constexpr (sizeof...(Rest) > 0)
    return DispatchFile<Rest...>(path, format);
  return error(ConfigError::kFormatNotSupported);
}
// 未启用的后端不生成任何代码

C 没有类型 trait 系统,无法在编译期查询类型属性并据此选择代码路径。

7. 模板实例化 -- 参数化专用代码生成

C 的 void* + size_t 传参让编译器丢失常量信息。模板将参数编码为类型的一部分,编译器可据此生成专用指令。

template <typename T, size_t BufferSize>
class SpscRingbuffer {
  static_assert((BufferSize & (BufferSize - 1)) == 0, "Must be power of 2");
  static constexpr size_t kMask = BufferSize - 1;
  // index & kMask -> 单条 AND 立即数指令
};

// 不同实例化 -> 不同类型 -> 各自生成最优代码
SpscRingbuffer<SensorData, 256> sensor_rb;   // 版本 A
SpscRingbuffer<MotorCmd, 64>    motor_rb;    // 版本 B

C 的通用函数接收 void*index % depth 变成运行时除法。

同一机制还可做编译期字面量长度检查:

template <uint32_t Capacity>
class FixedString {
  template <uint32_t N, typename = std::enable_if_t<(N <= Capacity + 1)>>
  FixedString(const char (&str)[N]) noexcept : size_(N - 1) {
    static_assert(N - 1 <= Capacity, "String literal exceeds capacity");
    std::memcpy(buf_, str, N);
  }
};

FixedString<8> name("too_long_string");  // 编译失败

C 无法从 const char* 推导字面量长度并做静态断言。strncpy 静默截断。


三、内存安全 -- 编译器管理资源生命周期

8. RAII -- 编译器自动在每条退出路径插入清理代码

标准 C 没有析构函数。GCC 的 __attribute__((cleanup)) 是非标准扩展。

// C++: 编译器在每条退出路径自动插入析构
auto fd = ::socket(AF_INET, SOCK_STREAM, 0);
ScopeGuard guard([fd]{ ::close(fd); });

if (::connect(fd, ...) < 0) return unexpected(kConnectFailed); // 自动 close
if (::setsockopt(...) < 0) return unexpected(kOptionFailed);   // 自动 close
guard.release();
return TcpSocket(fd);  // 成功路径,所有权转移
// C: 每条路径手动 close,漏一个就泄漏
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (connect(fd, ...) < 0) { close(fd); return -1; }
if (setsockopt(...) < 0) { return -1; }  // 忘了 close -> fd 泄漏
return fd;                                // 编译器不警告

RAII 还保证析构顺序,可用于锁外释放资源避免死锁:

bool Unsubscribe(const Handle& handle) noexcept {
    Callback old;                   // 最后析构
    {
      std::unique_lock lock(mtx_);  // 先析构 -> 先释放锁
      old = std::move(slot.callback);
    }
    // old 在锁释放后才析构,避免析构函数内部再次获取锁
    return static_cast<bool>(old);
}

9. Move 语义与拷贝控制

C 没有语言级所有权转移,也无法禁止结构体赋值。

Move 语义 -- 零拷贝所有权转移:

auto socket = TcpSocket::Connect("host", 8080);
auto socket2 = std::move(socket);  // 所有权转移,源对象进入已知空状态
socket.Send(data);  // 静态分析工具警告 use-after-move
int fd = connect_to("host");
int fd2 = fd;           // 复制了 fd,两处都能 close
close(fd);              // 关闭后 fd2 变成悬空句柄
write(fd2, data, len);  // 写入已关闭的 fd -> 未定义行为

拷贝删除 -- 禁止危险的复制:

Bus(const Bus&) = delete;
Bus& operator=(const Bus&) = delete;
// C: struct Bus b2 = b1; 编译通过,两份指向同一资源

expected 错误处理 -- 编译器强制检查:

auto result = pool.CreateChecked(args...);
auto ptr = result.value();  // 未检查 has_value() -> Debug 断言失败
void* ptr = pool_alloc(&pool);     // 返回 NULL 表示失败
memcpy(ptr, data, size);           // ptr == NULL -> SIGSEGV,编译器不警告

四、类型分发 -- 编译器穷举检查

10. variant + visit 穷举式分发与 Fold Expression

C 的 switch (msg->tag) 缺少 case 只产生 -Wswitch 警告,且对 void* 载荷无效。

// C++: 缺少任何一个 variant 类型的处理 -> 编译失败
template <class... Ts>
struct overloaded : Ts... { using Ts::operator()...; };
template <class... Ts>
overloaded(Ts...) -> overloaded<Ts...>;

std::visit(overloaded{
    [](const SensorData& d) { process(d); },
    [](const MotorCmd& c)   { execute(c); },
    // 缺 SystemStatus -> 编译错误,不是警告
}, payload);
// C: 缺少 case -> 运行时静默丢消息
switch (msg->tag) {
    case SENSOR: handle_sensor(msg->data); break;
    case MOTOR:  handle_motor(msg->data);  break;
    // 忘了 STATUS -> 消息丢失,可能运行数天才发现
}

Fold Expression 为 variant 中每个类型自动展开操作:

template <typename... Types>
void SubscribeAll(std::variant<Types...>*) noexcept {
  (MaybeSubscribe<Types>(), ...);  // 为每个类型展开一次调用
}
// C: 需要手动枚举或 X-Macro 生成

新增消息类型时,C++ 在所有未更新的 visit 处报编译错误,强制补全。C 的 -Wswitch 只是警告,经常被忽略。


总结

#C++17 能力C11 的局限
1编译期类型成员校验void* 无类型信息,_Static_assert 无法检查类型列表
2强类型别名 (NewType)typedef 是别名非新类型
3enum classenum 隐式转 int,枚举值泄漏到全局
4not_null 空指针拦截指针永远可能为空,每处需防御性检查
5constexpr 保证编译期求值const 非编译期合同,宏无法写循环
6if constexpr 分支消除无 type trait,无编译期分支选择
7模板参数化专用代码void* + size_t 丢失常量和类型信息
8RAII 自动资源清理无析构函数,goto cleanup 编译器不强制
9Move / = delete / expected无所有权转移,无法禁止拷贝,错误码被忽略
10variant + visit 穷举分发switchcase 仅警告,void* 无法分发

本质: C++17 让编译器掌握更多信息 -- 模板给类型和常量,constexpr 给求值合同,RAII 给生命周期,variant 给完整类型列表,NewType 给语义区分。信息越多,编译器能做的检查和优化就越多。C 的 void*、宏、手动 cleanup 在隐藏信息,编译器看到的只是指针和整数。