C++ 语言规则与具名要求

最后更新: 2023-09-23 12:23:05 +0800 +0800
原文以英文撰写。您正在阅读AI翻译版本。

C++ 语言规则决定程序是否良构以及执行具有何种含义。标准库的具名要求和概念为泛型代码增加契约。相似的术语可能掩盖一个重要区别:编译器可以检查表达式和类型,但无法证明类型作出的每一项语义、复杂度、有效性或相等性承诺。

定义与单一定义规则 #

每个被 odr 使用的非内联函数或变量都需要在程序中有一个定义。只要彼此兼容,声明可以重复,这使前向声明成为可能。

cpp
class Node;

struct Node {
    Node* next = nullptr;
};

类型、模板、内联函数和内联变量只有在满足各自一致性要求时,才能在多个翻译单元中存在定义。头文件保护符可防止在一个翻译单元中重复包含,却无法修复翻译单元之间存在差异的定义。程序即使违反 ODR,也可能通过编译和链接,但其行为将不再可靠。

具名要求与概念 #

在 C++20 之前,标准库的泛型契约通过 LegacyInputIterator 等具名要求表示。C++20 概念将其中许多语法部分表示为约束。

cpp
#include <concepts>
#include <iterator>

template<std::input_iterator Iterator>
void consume(Iterator first, Iterator last);

受约束的声明会拒绝不满足所需表达式和关联类型关系的类型。满足约束并不能确立语义行为。某个类型可以让 ++i、*i 和 i == j 都是良构的,同时违反多趟遍历、相等性保持或规定的复杂度上限。同样的界限也适用于均匀随机位生成器:类型检查无法证明其输出服从均匀分布。

编写泛型契约 #

应分层规定泛型 API:

  1. 说明所需表达式和关联类型,最好使用标准概念或项目概念。
  2. 说明语义不变量:操作的含义、哪些表达式保持相等性,以及值在调用后是否仍然有效。
  3. 说明复杂度、迭代器失效、所有权和并发保证。
  4. 说明运行时调用方必须满足或观察的前置条件和后置条件。

例如,用作键谓词的相等关系必须在算法接受的值上构成等价关系。与该谓词配合使用的哈希必须为相等的键生成相同结果。任何 requires 表达式都无法证明这两项属性。

有效性同样是运行时属性。某种迭代器类型可以在语法上满足 std::input_iterator,但该类型的某个具体迭代器可能已被容器操作置为无效。类型要求以及使现有迭代器、指针或引用失效的操作都应记录在文档中。

编译期说明符具有各自的契约 #

当调用和操作数满足适用规则时,constexpr 允许实体或函数参与常量求值。consteval 使立即函数调用必须进行常量求值。constinit 适用于具有静态或线程存储期的变量,并要求常量初始化;它不会使变量不可变,也不会使变量可用于常量表达式。

cpp
constexpr int square(int value) { return value * value; }
consteval int checked_port(int value) { return value > 0 ? value : throw "port"; }
constinit int process_generation = 1;

应根据这些说明符各自声明的保证来使用它们,而不是将它们当作可互换的“编译期”注解。const 对象可以在运行时初始化;constinit 对象除非同时也是 const,否则仍可在之后修改。

运行时行为仍是契约的一部分 #

模板实例化良构并不意味着程序正确。内存分配可能失败,比较可能抛出异常,回调可能违反可重入性假设,范围也可能为空。应说明无法在类型系统中表示的行为,然后在无效输入或外部状态进入程序的边界进行测试。

复杂度同样属于契约。将常数时间查找替换为线性扫描,可以保留所有必需表达式,却会破坏依赖其复杂度上限的算法。应将具名要求、概念和 API 文档视为互补机制:约束生成有用的诊断,而语义文档定义泛型代码可以依赖的行为。

评论

在 GitHub 上阅读或评论(发表评论需要登录)