10.6 编码指南
我对 C++ 语言的最终目标是:
- 使用和学习上都要比 C 或当前的 C++ 容易得多
- 完全类型安全——没有隐式类型违规,没有悬空指针
- 完全资源安全——没有泄漏,不需要垃圾收集器
- 为其构建工具要相对简单——不要有宏
- 跟当前 C++ 一样快或更快——零开销原则
- 性能可预测——适用于嵌入式系统
- 表达力不亚于当前的 C++——很好地处理硬件
这和《C++ 语言的设计和演化》[Stroustrup 1994] 及更早版本中阐述的设计目标并没有太多不同。显然,这是一项艰巨的任务,并且与较旧的 C 和 C++ 的多数用法不兼容。
最早,在 C++ 还是带类的 C
的时候,人们就建议创建语言的安全子集,并使用编译器开关来强制执行这种安全性。但是,由于许多原因中的某一个原因,这些建议失败了:
- 没有足够的人在
安全
的定义上达成一致。 - 不安全特性(对每种
不安全
的定义来说)是构建基本安全抽象的基础。 - 安全子集的表达能力不足。
- 安全子集效率低下。
第二个原因意味着,你不能仅仅通过禁止不安全的功能来定义一个安全的 C++。通过限制以达到完美
这个方法,对于编程语言的设计来说,在极其有限的场合下才能发挥作用。你需要考虑那些一般来说不安全但有安全用途的特性的使用场景和特征。此外,该标准不能放弃向后兼容(§1.1),所以我们需要一种不同的方法。
从一开始,C++ 就采用了不同的哲学 [Stroustrup 1994]:
让良好的编程成为可能比防止错误更重要。
这意味着我们需要良好使用
的指南,而不是语言规则。但是,为了在工业规模上有用,指南必须可以通过工具强制执行。例如,从 C 和 C++ 的早期开始,我们就知道悬空指针存在的问题。例如:
int* p = new int[]{7,9,11,13};
// ...
delete[] p; // 删除 p 指向的数组
// 现在 p 没有指向有效对象,处于`悬空`状态
// ...
*p = 7; // 多半会发生灾难
虽然许多程序员已经开发出防止指针悬空的技术。但是,在大多数大型代码库中,悬空指针仍然是一个主要问题,安全性问题比过去更加关键。一些悬空的指针可以作为安全漏洞被利用。
10.6.1 一般方法
在 2004 年,我帮助制定了一套用于飞行控制软件 [Lockheed Martin Corporation 2005] 的编码指南,这套指南接近于我对安全性、灵活性和性能的构想。2014 年,我开始编写一套编码指南,以解决这一问题,并在更广泛的范围内应用。这一方面是为了回应对用好 C++11 的实用指南的强烈需求,另外一方面是有人认为的好的 C++11 让我看着害怕。与人们交谈后,我很快发现了一个明显的事实:我并不是唯一沿着这样的路线思考和工作的人。因此,一些经验丰富的 C++ 程序员、工具制作者和库构建者齐心协力,与来自 C++ 社区的众多参与者一起启动了 C++ 核心指南项目 [Stroustrup and Sutter 2014–2020]。该项目是开源的(MIT 许可证),贡献者列表可以在 GitHub 上找到。早期,来自摩根士丹利(主要是我)、微软(主要是 Herb Sutter、Gabriel Dos Reis 和 Neil Macintosh)、Red Hat(主要是 Jonathan Wakely)、CERN、Facebook 和谷歌的贡献者都做出了突出贡献。
核心指南绝不是唯一的 C++ 编码指南项目,但却是最突出、最雄心勃勃的。它们的目标明确而清晰,那就是显著提升 C++ 代码的质量。例如,早在 Bjarne Stroustrup、Herb Sutter 和 Gabriel Dos Reis 的论文中 [Stroustrup et al. 2015] 就阐明了关于完全类型和资源安全的理想和基础模型。
为了实现这些雄心勃勃的目标,我们采用了一种鸡尾酒式
的混合方法:
- 规则:一套庞大的规则集,意图在 C++ 里实现使用上的类型安全和资源安全,推荐那些已知的有效实践,并禁止已知的错误和低效的来源。
- 基础库:一组库组件,使程序员可以有效的编写低层次程序,而无需使用已知的容易出错的功能,并且从总体上为编 程提供更高层次的基础。大多数组件来自标准库,其中一些来自以 ISO 标准 C++ 编写的小型指南支持库(Guidelines Support Library,GSL)。
- 静态分析:检测违规行为、并强制执行指南关键部分的工具。
这些方法中的每一种都有很长的历史,但是每一项都无法单独在工业规模上解决这些问题。例如,我是静态分析的忠实拥护者,但是如果程序员使用动态链接的方式在一个单独编译的程序中编写任意复杂的代码,那么我最感兴趣的分析算法(例如,消除悬空指针)是不能求解成功的。这里的不能
是指一般说来,理论上是不可能的
,以及对于工业规模的程序而言在计算上过于昂贵
。
基本方式不是简单的限制,而是我称之为超集的子集
或 SELL 的方法 [Stroustrup 2005]:
- 首先,通过库功能对语言进行扩展,从而为正确的使用语言奠定坚实的基础。
- 然后,通过删除不安全、易出错及开销过高的功能来设置子集。
对于库,我们主要依赖标准库的各个部分,例如 variant
(§8.3)和 vector
。小型指南支持库(GSL)提供了类型安全的访问支持,例如 span
可以提供在给定类型的连续元素序列上的带范围检查的访问(§9.3.8)。我们的想法是通过将 GSL 吸收到 ISO 标准库中,从而最终也就不需要它了。例如,span
已被添加到 C++20 标准库中。当时机成熟时,GSL 中对于契约的微弱支持也应当被合适的契约实现所替代(§9.6.1)。
10.6.2 静态分析
为了能规模化,静态分析完全是局部的(一次仅一个函数或一个类)。最难的问题与对象的生命周期有关。RAII 是必不可少的:我们已经不止一次的看到,手动资源管理的方法在很多语言中都很容易出错。此外,也有很多现存的程序,以一种有原则的方式使用指针和迭代器。 我们必须接受此类使用方式。要使一个程序安全很容易,我们只需禁止一切不安全的功能。然而,保持 C++ 的表现力和性能是核心指南的目标之一,所以我们不能仅仅通过限制来获得安全。我们的目的是一个更好的 C++,而不是一个缓慢或被阉割的子集。
通过阐明原则、让那些优秀的做法更加显而易见、以及对已知问题进行机械化检查,这些指南可以帮助我们把教学的重点放在那些让 C++ 更有效的方面。这些指南还有助于减轻对语言本身的压力,以适应最新的发展趋势。
对于对象的生命周期,主要有两个要求:
- 切勿指向超出范围的对象。
- 切勿访问无效的对象。
考虑以下基础模型
论文中的一个例子 [Stroustrup et al. 2015]):
int glob = 666;
int* f(int* p)
{
int x = 4; // 局部变量
// ...
return &x; // 不行,会指向一个被销毁的栈帧
// ...
return &glob ; // 可以,指向某个`永远存在`的对象
// ...
return new int{7}; // 可以(算是可以吧:不悬空,
// 但是把所有者作为 int* 返回了)
// ...
return p; // 可以,来自调用者
}
指针指向已知会超过函数生命周期的对象(例如,作为参数被传递到函数中),我们可以返回它,但对于指向局部资源的指针就不行。在遵循该指南的程序中,我们可以确保作为参数的指针指向某资源或为 nullptr
。
为避免泄漏,上面示例中的裸
new``操作应当通过使用资源句柄(RAII)或所有权标注来消除。
如果指针所指向的对象已重新分配,则该指针会变为无效。例如:
vector<int> v = { 1,2,3 };
int* p = &v[2];
v.push_back(4); // v 的元素可能会被重新分配
*p = 5; // 错误:p 可能已失效
int* q = &v[2];
v.clear(); // v 所有的元素都被删除
*q = 7; // 错误:q 无效
无效检查甚至比检查简单的悬空指针还要困难,因为很难确定哪个函数会移动对象以及是否将其视为失效(指针 p
仍然指向某个东西,但从概念上讲已经指向了完全不同的元素)。尚不清楚在没有标注或非本地状态的情况下,静态分析器是否可以完全处理无效检查。在最初的实现中,每个将对象作为非 const
操作的函数都被假定为会使指针无效,但这太保守了,导致了太多的误报。最初,关于对象生命周期检查的详细规范是由 Herb Sutter [Sutter 2019] 编写的,并由他在微软的同事实现。
范围检查和 nullptr
检查是通过库支持(GSL)完成的。然后使用静态分析来确保库的使用是一致的。
静态分析设想最早是由 Neil Macintosh 实现的,目前已作为微软 Visual Studio 的一部分进行发布。有一些检查规则已经成为了 Clang 和 HSR 的 Cevelop(Eclipse 插件)[Cevelop 2014–2020] 的一部分。一些课程和书籍中都加入了关于这些规则的介绍(例如 [Stroustrup 2018f])。
核心指南是为逐步和有选择地采用而设计的。因此,我们看到其中一部分在工业和教育领域被广泛采用,但很少被完全采用。要想完全采用,良好的工具支持必不可少。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论