网站首页 > 技术文章 正文
契约式编程
在如Java和Rust这样的“安全”语言中,通常对每个可能的操作和每个可能的输入都有明确定义的行为。还有一些事情是未明确定义的,比如哈希表中键的顺序或std::vector的增长因子,但这些通常是一些留给实现的小细节,以便将来可能获得性能提升。
相比之下,C和C++将未定义行为的概念提升到了另一个层次。某些操作在编译和运行时并不会导致错误,但就是不被允许。程序员与编译器之间存在一种类似契约或合同的,如果发生未定义行为,编译器被允许做任何事情,包括炸掉你的显示器或格式化你的硬盘。但是编译器工程师对此并不感兴趣。相反,未定义行为被用来保证没有无人关注的角落并用来帮助优化。
为什么存在未定义行为(Why Undefined Behavior Exists)
导致未定义行为的主要分为两大类动作:
- 一些是非故意的bug,如除以零、解引用一个空指针或读取还未初始化的内存。我们希望尽可能早的在测试期间捕获这些bug,所以令程序崩溃或具有一些非确定性行为比总是做一个固定的后备动作(如返回零)要好。我们可以使用编译器的sanitize选项来尽早捕获未定义行为。在GCC和Clang中,你可以使用-fsanitize=undefined标志,一些众所周知会导致未定义行为的操作将在运行时被检测到。
- 在不同平台上有略微不同的可观察行为的操作。例如,将一个整数左移超过31位的结果是未定义的,因为在Arm和x86 CPU上实现它的指令不同。如果你规定一个特定的行为,那么为其他平台编译的所有程序都将不得不花费更多的周期来检查这种边缘情况,所以最好将其保留为未定义。
- 有时,当某个平台特定行为有合法用例时,而不是声明它为未定义,可以将其留为实现定义。例如,右移一个负整数的结果取决于平台:它要么移入零,要么移入一(例如,右移11010110 = -42,一位,可能意味着01101011 = 107或11101011 = -21,两种用例都是现实的)。
将某些情况指定为未定义行为,而不是实现一个确定的定义行为,也有助于编译器进行优化。比如,有符号整数溢出的情况。在几乎所有架构上,有符号整数的溢出方式都与无符号整数相同,INT_MAX + 1 == INT_MIN,然而,根据C++标准,这是未定义行为。这是非常有意义的:如果你不允许有符号整数溢出,那么(x + 1) > x对于int来说总是保证为真,但对于unsigned int来说不是,因为(x + 1)可能会溢出。对于有符号类型,这让编译器优化这样的检查。
举一个更常见的例子,一个使用整数控制变量的循环。现代C++和Rust等语言鼓励程序员使用无符号整数(size_t / usize),而C程序员固执地继续使用int。为了理解为什么,请考虑以下for循环:
for (unsigned int i = 0; i < n; i++) {
// ...
}
这个循环执行多少次?从技术上讲,有两个有效答案:n和无限,第二个是如果n超过2^32,以至于i在每2^32次迭代后重置为零的情况。虽然前者可能是程序员假设的,但为了符合语言规范,编译器仍然必须插入额外的运行时检查并考虑这两种情况,应该以不同的方式优化。同时,int版本将准确地进行n次迭代,因为有符号溢出的可能性被定义为不存在。
移除某些的场景数据(Removing Corner Cases)
“安全”的编程风格通常涉及进行大量运行时检查,但它们不必以性能为代价。
例如,Rust会在索引数组和其他随机访问结构时使用边界检查。在C++ STL中,vector和array有一个“不安全”的[]操作符和一个“安全”的.at()方法,如下所示:
T at(size_t k) {
if (k >= size())
throw std::out_of_range("Array index exceeds its size");
return _memory[k];
}
有趣的是,这些检查在运行时其实很少实际执行,因为编译器通常可以在编译时证明——每次访问都将在边界内。例如,当在for循环中从1迭代到数组大小并在每个步骤上索引第i个元素时,不可能发生非法操作,所以边界检查可以被安全地优化掉。
假设(Assumptions)
当编译器不能证明不存在角落情况,但你可以做到时,这些额外的信息可以使用未定义行为的机制提供。
Clang有一个有用的__builtin_assume函数,你可以在其中放置一个保证为真的声明,编译器将在优化中使用这个假设。在GCC中,你可以用__builtin_unreachable做同样的事情:
void assume(bool pred) {
if (!pred)
__builtin_unreachable();
}
例如,你可以在上面的示例中的at之前放置assume(k < vector.size()),然后边界检查将被优化掉。
将assume与assert和static_assert结合使用以查找bug也非常有用:你可以在调试时中使用同一函数来检查前提条件,然后在生产环境中使用它们来提高性能。
算术(Arithmetic)
当优化一些计算时,你应该牢记角落情况。
对于浮点计算,这是较少关注的,因为你可以简单地用-ffast-math标志(也包含在-Ofast中)禁用严格的标准合规性。你几乎不得不这样做,否则,编译器不能做任何事情,只能按照源代码中的相同顺序执行算术操作而没有任何优化。
对于整数算术,情况不同,因为结果总是必须精确的。考虑除以2的情况:
unsigned div_unsigned(unsigned x) {
return x / 2;
}
一个广为人知的优化是用单个右移(x >> 1)替换它:
shr eax
这对于所有正数来说肯定是正确的,但通用情况呢?
int div_signed(int x) {
return x / 2;
}
如果x是负数,那么简单的移位就不起作用了——无论是移入零还是符号位:
- 如果我们移入零,我们得到一个非负结果(符号位是零)。
- 如果我们移入符号位,那么向负无穷方向舍入会发生(-5 / 2将等于-3而不是-2)。
所以,对于一般情况,我们必须插入一些特殊处理来使其工作:
mov ebx, eax
shr ebx, 31 ; 提取符号位
add eax, ebx ; 如果值是负数则加1以确保向零舍入
sar eax ; 这个移位符号位
当只有正数情况是预期的时,我们也可以使用assume机制来消除负x的可能性,避免处理这个特殊情况:
int div_assume(int x) {
assume(x >= 0);
return x / 2;
}
虽然在这个特定情况下,表达我们只期待非负数的最佳语法可能是使用无符号整数类型。
由于这样的细节,通常有益于扩展中间函数中的代数并手动简化算术,而不是依赖编译器来做。
内存别名(Memory Aliasing)
编译器在优化涉及内存读写的操作时表现得相当糟糕。这是因为它们通常没有足够的上下文来使优化正确。
比如以下示例:
void add(int *a, int *b, int n) {
for (int i = 0; i < n; i++)
a[i] += b[i];
}
由于这个循环的每次迭代都是独立的,它可以并行执行并向量化。但是,技术上是吗?
如果数组a和b相交可能会有问题。考虑当b == a + 1,即如果b只是从其第二个元素开始的a的内存视图。在这种情况下,下一次迭代依赖于前一次,唯一正确的解决方案是顺序执行循环。即使程序员知道它们不可能发生,编译器也必须检查这种可能性。
这就是为什么我们有const和restrict关键字。第一个强制我们不会用指针变量修改内存,第二个是告诉编译器保证内存不会被别名。
void add(int * __restrict__ a, const int * __restrict__ b, int n) {
for (int i = 0; i < n; i++)
a[i] += b[i];
}
这些关键字也是代码的自文档化的好方式。
C++契约
契约式编程是一种未被充分利用但非常强大的技术。
有一个后期提案将设计约定添加到C++标准中,以契约属性的形式,这在功能上等同于我们手工制作的、特定于编译器的assume:
T at(size_t k) [[ expects: k < n ]] {
return _memory[k];
}
有三种类型的属性——expects、ensures和assert——分别用于指定函数中的前置和后置条件以及可以放在程序中任何地方的一般断言。
不幸的是,这个令人激动的新特性还没有最终标准化,更不用说在主流C++编译器中实现了。但也许,在几年后,我们将能够像这样写代码:
bool is_power_of_two(int m) {
return m > 0 && (m & (m - 1) == 0);
}
int mod_power_of_two(int x, int m)
[[ expects: x >= 0 ]]
[[ expects: is_power_of_two(m) ]]
[[ ensures r: r >= 0 && r < m ]]
{
int r = x & (m - 1);
[[ assert: r = x % m ]];
return r;
}
其他面向性能的语言,如Rust和D语言,也提供了某些形式的契约编程。
一个通用且与语言无关的建议是始终检查编译器生成的汇编,如果它不是你所希望的,尝试考虑可能限制编译器优化的特殊情况。
- 上一篇: 写智能算法,到底是用java还是C++好?
- 下一篇: 用 C++ 和 Java 写算法,差别大吗?
猜你喜欢
- 2024-09-23 Java虚拟机类加载机制及双亲委派模式分析
- 2024-09-23 阿里巴巴 Java 开发手册之MySQL 规约
- 2024-09-23 嵌入式C基础编程——5年程序员给你讲解数据类型、运算符与表达式
- 2024-09-23 从linux源码看socket(tcp)的timeout
- 2024-09-23 MySQL高性能优化规范建议,从设计,命名,开发等一条线的建议
- 2024-09-23 十五、Java运算符-赋值运算符与instanceof运算符
- 2024-09-23 面试官:MySQL的自增ID用完了,怎么办?
- 2024-09-23 016:字符串对象在JVM中是如何存放的
- 2024-09-23 Redis 数据结构与内存管理策略(上)
- 2024-09-23 用 C++ 和 Java 写算法,差别大吗?
- 1514℃桌面软件开发新体验!用 Blazor Hybrid 打造简洁高效的视频处理工具
- 569℃Dify工具使用全场景:dify-sandbox沙盒的原理(源码篇·第2期)
- 510℃MySQL service启动脚本浅析(r12笔记第59天)
- 486℃服务器异常重启,导致mysql启动失败,问题解决过程记录
- 485℃启用MySQL查询缓存(mysql8.0查询缓存)
- 467℃「赵强老师」MySQL的闪回(赵强iso是哪个大学毕业的)
- 446℃mysql服务怎么启动和关闭?(mysql服务怎么启动和关闭)
- 444℃MySQL server PID file could not be found!失败
- 最近发表
- 标签列表
-
- c++中::是什么意思 (83)
- 标签用于 (65)
- 主键只能有一个吗 (66)
- c#console.writeline不显示 (75)
- pythoncase语句 (81)
- es6includes (73)
- windowsscripthost (67)
- apt-getinstall-y (86)
- node_modules怎么生成 (76)
- c++int转char (75)
- static函数和普通函数 (76)
- el-date-picker开始日期早于结束日期 (70)
- js判断是否是json字符串 (67)
- checkout-b (67)
- c语言min函数头文件 (68)
- asynccallback (71)
- localstorage.removeitem (74)
- vector线程安全吗 (70)
- & (66)
- java (73)
- js数组插入 (83)
- mac安装java (72)
- eacces (67)
- 查看mysql是否启动 (70)
- 无效的列索引 (74)