访问越界的数组,但提前返回 - UB?

3

我有段代码可以计算一个数组下标,如果这个下标是合法的话,就会访问数组中对应的元素。类似于:

int b = rowCount() - 1;
if (b == -1) return;
const BlockInfo& bi = blockInfo[b];

我担心这可能会引发未定义的行为。例如,编译器可能假定b始终为非负数,因为我用它来索引数组,因此它将优化if语句。

在什么情况下,如果您对无效结果不做任何操作,"访问"数组越界是安全的?如果blockInfo不是实际数组,而是像vector这样的容器,是否会改变情况?如果这是不安全的,我能通过将访问放在else语句中来修复它吗?

if (b == -1) {
    return;
} else {
    const BlockInfo& bi = blockInfo[b];
}

最后,是否有类似于-fno-strict-aliasing-fno-delete-null-pointer-checks的编译器标志,使编译器“做正确的事情”并防止任何不必要的行为?
为了澄清:我的担忧特别是由于另一个问题,即您打算在访问指针之前测试它是否为非空。编译器会将其转换并推断出,由于您正在对其进行解引用,因此它不能为null!类似于这样的东西(未经测试):
void someFunc(struct MyStruct *s) {
    if (s != NULL) {
       cout << s->someField << endl;
       delete s;
    }
 }

我记得听说在C++中仅仅创建越界数组访问就是UB。 因此编译器可以合法地假定数组索引不越界,并删除相反的检查。

2
我不明白你的顾虑在哪里。编译器不会在本来就不存在未定义行为(UB)的程序中添加UB,这似乎是你的一项顾虑。 - cigien
rowCount返回什么?你在问编译器优化是否会在你的代码中添加错误... - Tony
这段代码的两个部分在逻辑上是完全等价的(除了引用超出范围的情况,但显然这只是为了演示目的)。无论编译器对其中一个做了什么逻辑操作,我都希望它也能对另一个做同样的操作。 - Sam Varshavchik
@TonyTannous:rowCount返回一个int,可以是零或正数(还有什么其他意义呢?)。 严格来说,我不是在问“编译器优化会引入错误”(虽然非常口语化地说,是的,这就是我想知道的)。严格来说,我想知道1)何时何时不使用无效索引访问数组是未定义行为,其中“使用”表示该语句不一定被执行,并且2)编译器何时可以使用它来生成与我的意图相违背的代码。 - jdm
@TonyTannous 是的,但是人类“显然”会优化掉的UB仍然是UB,不是吗?例如,请参见此链接:https://kristerw.blogspot.com/2017/09/why-undefined-behavior-may-call-never.html - jdm
显示剩余5条评论
2个回答

2

在您的程序中无法访问blockInfo[-1]。 您的代码明确禁止这样做。


例如,编译器可能会假定b始终为非负数,因为我用它来索引数组,因此它将优化掉if子句。

不,它不能这样做,因为对索引-1(或者更准确地说是(std::size_t)-1)进行访问可能是有效的索引,也可能不是。 语言确实允许您将-1作为索引传递; 它将首先转换为具有带有执行此操作的良好定义的无符号环绕逻辑的std::size_t。 因此,不存在任何规则,使得编译器可以假设您永远不会将int -1作为索引传递。

即使存在这种情况,让编译器完全忽略if语句仍然没有意义。 如果可以这样做,如果我们的if语句不可靠,那么世界上每个程序都是不安全的! 将无法强制执行任何操作的前提条件。


编译器只有在证明这样做会产生与原始指令相同行为的良定义程序时,才可以跳过或重新排序事物。

事实上,这就是UB的来源:当证明正确性非常困难时,标准通常会给编译器一个机会,并说某些内容是“未定义的”,编译器可以随意操作。

一个有趣的例子是与您的情况相反的情况,其中检查[错误地]放置在访问之后,因此编译器因此假定检查通过,无论它是否实际上通过:

void foo(char* ptr)
{
   char x = *ptr;
   if (ptr)
      bar();
   else
      baz();
}

函数foo即使ptr为空也可能调用bar()!这听起来可能不太可能,但实际上确实会发生(例如这个广泛使用的库中的崩溃)。

我能通过将访问放在else子句中来修复它吗?

这两个代码片段在语义上是等价的;它们是同一个程序。


最后,是否有编译器标志类似于-fno-strict-aliasing或-fno-delete-null-pointer-checks可以使编译器“做正确的事情”并防止任何不必要的行为?

只要“正确”是指“符合C++标准”,编译器已经在做正确的事情了。


1

编译器可能会做出假设

如果编译器基于错误的假设进行编译,则结果是错误和有缺陷的。

在什么情况下,即使您对无效结果不做任何操作,"访问"数组越界也是安全的?

访问数组越界从未是安全的,因为在使用或不使用结果之前就会产生UB。但是,在代码中未被执行的分支不算作访问,就像您的第一个或第二个示例一样。因此,如果我理解您的最后一个问题,就不需要特殊标志。


一个可以说的是,使用无效索引形成数组访问并不是未定义行为,只要在达到该语句之前分支跳转即可,即使进入了其作用域。 - jdm
你可以这样说,但只有声明具有作用域而不是语句,并且声明的作用域不包括前面的行。 - Potatoswatter
@jdm,你的程序从未进行这样的数组访问,因为有了 if 语句。这就是使用 if 语句的原因。如果代码按照你所担心的方式运行,那么全世界几乎每一个带有可变索引的数组访问都可能存在未定义的行为。 - Asteroids With Wings

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接