Appearance
把正确性前置:编译期优于运行期
一条贯穿 C++ 实践的设计原则:能让编译器替你抓的错,就别留给运行期。本页把
const、强类型、explicit、static_assert、契约、不可变性这些看似零散的做法,统一到"降低状态空间、提前暴露错误"这条主线上——目标是让非法状态无从表达、非法调用无法编译。
零、总纲:错误越早发现越便宜
一句话:错误发现得越早,修复成本越低;编译期优于运行期,运行期优于上线后。
展开:软件工程的经验规律是"缺陷越晚暴露,代价呈指数上升"。C++ 的独特优势在于它同时在编译期和运行期拥有强表达能力:类型系统、const、concepts、static_assert 能在编译阶段拦截大量逻辑错误;assert、契约、异常则在运行期兜底。设计时的取向应当是:能提升到编译期检查的规则,就不要降级为运行期断言;能靠类型表达的约束,就不要依赖文档约定。本页所有思想点都服务于这个降本目标。
小例子
cpp
// 编译期就能抓到的错误,绝不留到运行期
constexpr int expected = 4;
static_assert(expected * 2 == 8, "规格变了,请检查");
// 运行期才可能发现的错误:用断言尽早失败
int idx = -1;
assert(idx >= 0 && "index out of range");演示同一意图下,编译期断言与运行期断言各守一段防线。
一、const 正确性:把"只读"写进接口
一句话:能 const 就 const,让"我不修改你"成为类型层面的承诺。
展开:const 不只是防御,它是一份可被编译器核验的契约。成员函数标 const 后,编译器保证它不修改对象状态(除非 mutable),调用者从而能对 const 对象调用它、能在并发读时更放心。参数用 const T& 表达"只读且不拷贝"。代价是:const 会沿调用链传播,重构时可能"改一处、动十处";但这恰恰说明原有的可修改面过大,是被纠正的机会。良好设计里,const 覆盖率越高,接口越容易被理解和复用。
小例子
cpp
class Account {
public:
double balance() const { return balance_; } // 承诺不改状态
void deposit(double x) { balance_ += x; } // 修改型,不标 const
private:
double balance_ = 0;
};
void print(const Account& a) { // 只读引用,不拷贝
// a.deposit(1); // 编译错误:const 对象不可改
(void)a.balance();
}演示 const 把"只读"表达进接口,越权修改直接编译失败。
二、强类型与类型安全:让类型承载语义
一句话:用 enum class、nullptr、override 等把"意图"编码进类型,堵住隐式转换。
展开:弱类型表达(0、NULL、裸 int 常量)让含义模糊、易被误用。C++11 起的一组改进把语义显式化:enum class 提供作用域限定 + 禁隐式转 int,消灭枚举值混用;nullptr 有独立类型、不再与整型 0 混淆;override 让"本意是覆写却写错签名"变成编译错误。这些改动的共同点是——把过去靠约定、靠审查的事,交给类型系统强制。代价是写法略长,但换来的是编译期确定性。
小例子
cpp
enum class Color { Red, Green };
enum class State { Idle, Busy };
void f(Color c);
// f(State::Idle); // 编译错误:不同 enum class 不互通
// f(1); // 编译错误:不隐式转 int
struct Base { virtual void g(); };
struct Derived : Base { void g() override; }; // 签名写错即报错演示 enum class 与 override 把"用错枚举""覆写签名错"变成编译错误。
三、fail-fast:尽早失败,别让错误扩散
一句话:假设不成立时立刻停止,避免错误数据继续污染下游。
展开:fail-fast 的哲学是——与其带着损坏的状态继续跑、生成更难排查的后果,不如在问题发生的第一现场崩掉。C++ 提供两级工具:static_assert 在编译期检查编译期可知的条件;assert 在运行期检查不变式(debug 构建生效、release 构建通常被 NDEBUG 关掉)。要点是把断言放在函数入口与关键不变式处,让"本不该发生"的情况第一时间暴露。代价是 assert 在生产环境可能被关闭,因此对外部输入不要用 assert 代替真正的校验。
小例子
cpp
#include <cassert>
int divide(int a, int b) {
assert(b != 0 && "divisor must be non-zero"); // 入口即检查
return a / b;
}
struct Buf {
static constexpr int kSize = 64;
int data[kSize];
void at(int i) {
assert(i >= 0 && i < kSize); // 前置条件,越界立刻炸
data[i] = 0;
}
};演示在函数入口与前置条件处断言,让非法调用当场失败。
四、契约与不变量:让非法状态无从表达
一句话:用构造与封装维持对象不变量,使"合法对象"与"非法状态"在类型上区分开。
展开:契约思想是——每个函数都有前置条件(调用者须满足)与后置条件(实现须保证),而每个类都有不变量(构造后恒成立的性质)。C++ 的落地手段是把不变量封装进类型:构造函数负责建立不变量,私有成员防止外部破坏,公开接口维持它。高级做法是用"解析而非校验"(parse, don't validate):让构造函数只有在能构造合法对象时才成功,于是后续代码拿到的对象必然合法,无需反复校验。这样"非法状态"在类型系统里根本无从表达。
小例子
cpp
class Percentage { // 不变量:0 <= v <= 100
public:
static std::optional<Percentage> make(int v) {
if (v < 0 || v > 100) return std::nullopt; // 非法即拒绝
return Percentage{v};
}
int value() const { return v_; }
private:
explicit Percentage(int v) : v_(v) {}
int v_;
};
// 拿到 Percentage 就好,无需再校验范围——不变量已由类型保证演示工厂函数只在合法时产出对象,把"范围校验"固化进类型。
五、错误处理策略:异常 / 错误码 / expected 各司其职
一句话:可恢复的预期失败用返回值,跨层不可忽略的失败用异常,编译期就定不了的用 expected。
展开:三种策略不是互相取代,而是按语义分工。异常适合"本层无法处理、需要向上传播、且属罕见"的错误,优势是不用污染正常路径的返回值;但异常有运行期开销与二进制膨胀,热路径和实时系统常禁用。错误码/std::optional 适合可预期的常见失败(解析失败、查找未命中),调用点显式处理。C++23 的 std::expected<T, E> 融合两者:既有值语义的返回值通道,又能携带错误信息,且不抛异常——适合"既想显式处理、又不想用异常"的接口。
小例子
cpp
#include <optional>
// 可预期失败:返回 optional,调用点显式判断
std::optional<int> parse_int(const std::string& s) {
try { return std::stoi(s); } catch (...) { return std::nullopt; }
}
// 不可忽略的逻辑错误:抛异常向上传播
void must_exist(int id) {
if (id < 0) throw std::invalid_argument("bad id");
}演示按"可预期/不可忽略"选择 optional 与异常,而非一刀切。
六、编译期检查优于运行期:让类型系统去抓 bug
一句话:凡是编译期可判定的约束,都交给 static_assert/concepts/类型规则,而非运行期断言。
展开:运行期检查的成本是每次执行都要付,且只能在测试覆盖到的路径上生效;编译期检查的成本是一次性,且覆盖所有实例化路径。static_assert 能检查常量表达式与类型性质(尺寸、is_trivially_copyable、枚举完备性);concepts 能约束模板参数、给出可读诊断;类型系统本身(禁止隐式转换、enum class)则是最"免费"的检查。原则很简单:能在编译期拦下的,绝不放到运行期。代价是编译时间与表达复杂度上升,需要在"检查强度"与"构建速度"间权衡。
小例子
cpp
#include <type_traits>
template <class T>
void require_arithmetic() {
static_assert(std::is_arithmetic_v<T>, "T must be arithmetic");
}
struct Header { char magic[4]; std::uint32_t len; };
// 协议头必须是固定紧凑布局,编译期即可验证
static_assert(sizeof(Header) == 8, "Header layout changed!");演示 static_assert 在编译期校验类型性质与内存布局,成本一次性。
七、显式优于隐式:explicit 与 [[nodiscard]]
一句话:把"这个转换/这个返回值很重要"标出来,禁止编译器替你默默做决定。
展开:隐式转换是许多"编译器没报错、运行时炸"事故的根源——单参数构造函数会悄悄参与隐式转换,产生意外临时对象。explicit 阻止这种悄悄发生,要求调用者把意图写出来;[[nodiscard]] 则提醒"这个返回值不能丢",避免像 lock()、empty()、返回错误码这类调用被忽略。二者共同的精神是:重要的事情必须显式。代价是调用处要多写一点、少一些语法糖,但换来的是可读性与安全性。同理,把转换函数也标 explicit 可避免隐式窄化。
小例子
cpp
class Meters {
public:
explicit Meters(double v) : v_(v) {} // 禁止 double->Meters 隐式转换
double value() const { return v_; }
private:
double v_;
};
[[nodiscard]] bool try_lock();
// try_lock(); // 警告:忽略了重要的返回值
void f(Meters m);
// f(3.0); // 编译错误:必须先写 Meters(3.0)演示 explicit 与 [[nodiscard]] 把隐式行为堵死,强制显式表达意图。
八、不可变性:减少状态空间就是减少 bug
一句话:能 const/constexpr 就不让可变,状态越少、能出错的地方越少。
展开:可变状态是并发 bug、时序 bug、状态不一致的温床。减少可变面有直接收益:constexpr 把常量计算前移到编译期;const 成员与 const 参数防止意外写入;constinit 保证静态初始化无运行期歧义;函数式风格(输入到输出的纯变换)减少隐式依赖。当对象构造后不再改变,它天然线程安全(只读共享无需加锁),也更容易推理。代价是有些场景需要重构数据流(例如把"就地修改"改成"返回新值"),但对中等规模对象,这份代价通常换来人更少的调试时间。
小例子
cpp
struct Config { // 构造后不可变
constexpr Config(int n, int m) : n_(n), m_(m) {}
constexpr int size() const { return n_ * m_; }
private:
int n_, m_;
};
constexpr Config kCfg{4, 5};
static_assert(kCfg.size() == 20); // 编译期即可验证
const Config local{2, 3}; // 只读,天然可安全共享演示 constexpr/const 让对象不可变,减少状态空间与并发风险。
九、不变量即测试:把契约写成可执行检查
一句话:把"我认为恒成立"的话写成断言/测试,让它在每次运行与每次构建中被验证。
展开:口头契约最大的问题是不会自动失效提醒——需求一变,约定被悄悄破坏,没人发现。把不变量落成可执行物就能解决:assert 在调试构建中运行时校验,单元测试在 CI 中持续校验,static_assert 在每次编译时校验。三者覆盖不同时机,形成纵深。要点是优先把可静态化的检查升级为编译期断言,只在无法编译期判定时才退化为运行期断言/测试。代价是测试与断言需要维护,但相比"线上才发现",这笔投入回报极高。
小例子
cpp
#include <cassert>
class RingBuffer {
public:
explicit RingBuffer(int cap) : cap_(cap), buf_(cap) { assert(cap > 0); }
int size() const { return size_; }
bool empty() const { return size_ == 0; }
// 不变量:0 <= size_ <= cap_
void check() const { assert(0 <= size_ && size_ <= cap_); }
private:
int cap_, size_ = 0;
std::vector<int> buf_;
};演示把类不变量写成 check(),可在调试与测试中随时验证。
十、把未定义行为关进笼子:UB 才是最大的"不正确"
一句话:可编译、可运行 ≠ 正确;越界、悬垂、数据竞争属于 UB,编译器可任意处置。
展开:C++ 里最危险的不是"报错的代码",而是能编译、能跑、结果却不可信的未定义行为:数组越界、解引用悬垂指针、有符号溢出、对象生命周期结束后的访问、无同步的数据竞争。UB 的可怕之处在于编译器假设它永不发生,从而做激进优化——今天跑通,换编译器/开优化后崩溃。防线的核心是用类型与容器把 UB 关进笼子:用 std::vector/at 替代裸数组、用智能指针替代裸 new/delete、用 std::atomic/锁替代裸共享、用 UBSan/ASan 在测试中主动猎捕。正确性的底线,是"没有 UB",其余才谈得上语义正确。
小例子
cpp
// 反面:越界写是 UB,编译器甚至可能直接优化掉这段
int bad() {
int a[3] = {1, 2, 3};
a[5] = 0; // 未定义行为:越界
return a[5];
}
// 正面:用容器 + at,越界变成可预期的异常
#include <vector>
int good(const std::vector<int>& v, std::size_t i) {
return v.at(i); // 越界抛 std::out_of_range,而非 UB
}演示用容器边界检查把"未定义行为"转化为"可预期的异常"。