C++ (v.size() > 1 || v[0] > 1) 为什么在空容器上必崩 C(v.size() 1 || v[0] 1)为什么在空容器上必崩flyfish写了size() 1v[0]为什么还是越界一、先说一个场景入口处要判断批次是否多路并行。代码是这样写的boolmany(batchSizes.size()1)||(batchSizes[0]1);逻辑上念起来很通顺元素个数大于 1或者第一个元素大于 1就认为批次较多。开发者也想过边界问题特意在前面加了size() 1做判断觉得空容器时 size 是 0条件不满足自然不会走到后面。直到某天上游返回了一个空的 batchSizes程序崩溃了。排查了很久才意识到前面那个 size 判断不但没有保护后面的访问反而让后面的越界读必然发生。二、错误代码与运行结果完整可复现的代码#includeiostream#includevectorintmain(){std::vectorintbatchSizes;// 空 vector模拟调用方忘记填充// 问题表达式左侧为 false 时右侧照常执行boolmany(batchSizes.size()1)||(batchSizes[0]1);std::cout[oob-empty] reading batchSizes[0] on EMPTY vector, many many (UB: 越界读)\n;return0;}UB Undefined Behavior 未定义行为后果无任何保证运行结果不确定取决于编译配置空 vector 的 operator[] 往往等价于解引用空指针多数平台直接崩溃即便不崩溃读到的也是垃圾值混进后续逻辑。为什么结果不确定因为越界读属于未定义行为标准对后果没有任何承诺。vector::operator [] 是纯访问**按位置索引位置不存在就是越界未定义行为。空 vector 读v[0]等价于解引用空指针崩溃。map::operator [] 是查找加插入**按键查找key 不存在就先插入一个默认值再返回引用。它不存在 空容器越界的概念因为任何 key 都能被 造出来。三、根因一operator[] 根本不检查边界std::vector 的 operator[] 语义是返回第 n 个元素的引用不做任何边界检查。索引越界时行为未定义编译器不警告运行库在 Release 下也不拦截。这是它与 at() 的本质区别operator[]无检查越界是未定义行为性能最好。at()每次访问都检查边界越界时抛出 std::out_of_range 异常。标准库刻意保留这两个接口是有道理的性能敏感、调用方已经保证边界时用 operator[]边界可能出问题、需要安全兜底时用 at()。问题在于很多开发者默认了 operator[] 安全把边界保证这一责任完全忘掉了。空 vector 时 operator[] 发生了什么operator[] 等价于解引用 begin() 加偏移的位置空 vector 的 begin() 等于 end()指向容器末尾之后那里没有元素。对空 vector 而言 data() 通常为空指针这一步实际是解引用空指针或读取越界内存属于典型的未定义行为。四、根因二短路求值的保护方向记反了这是这个案例里最反直觉的部分。很多开发者以为用了运算符 || 就安全了左侧不满足就不会执行右侧这句话只对了一半。C 的短路规则是分方向的对于左侧为 false 时整个表达式确定为 false右侧不被求值。这才是保护性写法。对于||左侧为 true 时整个表达式确定为 true右侧不被求值。左侧为 false 时右侧照常求值。空容器时batchSizes.size() 1等于0 1为 false。于是||的右侧batchSizes[0] 1被正常执行越界读发生。正确利用保护空容器的写法是用 boolmany!batchSizes.empty()batchSizes[0]1;空容器时!empty()为 false整个表达式短路为 false右侧batchSizes[0]永远不被求值安全。#includeiostream#includevector// 把右侧表达式包装成函数函数体打印用来证明它到底有没有被执行boolcheckFirst(conststd::vectorintv,constchar*tag){std::cout tag: 右侧表达式被求值v[0]v[0]\n;returnv[0]1;}intmain(){std::vectorintv{5,3};// 非空v[0] 5v.size() 2std::cout 情形1: || 左侧为 true \n;// v.size()1 为 true结果已定右侧不执行boolr1(v.size()1)||checkFirst(v,情形1);std::cout 结果 r1 r1\n\n;std::cout 情形2: || 左侧为 false \n;// v.size()3 为 false结果未定右侧照常执行boolr2(v.size()3)||checkFirst(v,情形2);std::cout 结果 r2 r2\n\n;std::cout 情形3: 左侧为 false \n;// v.size()3 为 false结果已定右侧不执行boolr3(v.size()3)checkFirst(v,情形3);std::cout 结果 r3 r3\n\n;std::cout 情形4: 左侧为 true \n;// v.size()1 为 true结果未定右侧照常执行boolr4(v.size()1)checkFirst(v,情形4);std::cout 结果 r4 r4\n;return0;}情形1:||左侧为true结果 r11情形2:||左侧为false情形2: 右侧表达式被求值v[0]5结果 r21情形3:左侧为false结果 r30情形4:左侧为true情形4: 右侧表达式被求值v[0]5结果 r41不想用脑袋记可以这么做把代码写成右侧执行和不执行两种情况下都安全这样短路规则记不记得住都无所谓。方案一if 展开访问只出现在明确安全的分支里boolmanyfalse;if(batchSizes.size()1){manytrue;// 个数大于 1直接判定}elseif(!batchSizes.empty()){manybatchSizes[0]1;// 恰好 1 个元素看首元素}// 空容器两个分支都不进many 保持 false这个写法的安全理由访问batchSizes[0]只发生在一个明确的位置前面紧挨着!empty()检查而且是按顺序执行的 if 语句。。方案二at() 兜底让越界变成可捕获的异常boolmanyfalse;try{many(batchSizes.size()1)||(batchSizes.at(0)1);}catch(conststd::out_of_range){manyfalse;// 空容器按业务需要处理}把batchSizes[0]换成batchSizes.at(0)。即使||右侧被执行空容器时必然执行at() 也会先检查边界越界时抛出 std::out_of_range 异常而不是越界读。异常被 catch 接住程序不崩。代价at() 每次访问都做边界检查有少量性能开销且 try-catch 结构略笨重。适合这里不该为空但万一为空要兜住的场景。方案三入口拦截把容器为空当错误提前处理// 函数入口处if(batchSizes.empty()){// 空容器是上游错误打日志后直接返回returnfalse;// 或返回错误码}// 走到这里已经保证非空后面的访问天然安全boolmany(batchSizes.size()1)||(batchSizes[0]1);参考地址https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2017/n4659.pdf看8.14 Logical AND operator、8.15 Logical OR operator8.14 逻辑与运算符 [expr.log.and]语法产生式逻辑与表达式 包含或表达式 逻辑与表达式 包含或表达式语义规定 运算符从左到右结合。两个操作数都按第 7 章的规定上下文转换为 bool。当且仅当两个操作数都为 true 时结果为 true否则为 false。与按位与运算符 不同 保证从左到右求值如果第一个操作数为 false第二个操作数不再被求值。结果类型为 bool。如果第二个表达式确实被求值那么第一个表达式关联的所有值计算和副作用都先序于第二个表达式关联的所有值计算和副作用。8.15 逻辑或运算符 [expr.log.or]语法产生式逻辑或表达式 逻辑与表达式 逻辑或表达式 || 逻辑与表达式语义规定|| 运算符从左到右结合。两个操作数都按第 7 章的规定上下文转换为 bool。只要两个操作数中有一个为 true结果就是 true否则为 false。与按位或运算符 | 不同|| 保证从左到右求值此外如果第一个操作数求值为 true第二个操作数不再被求值。结果类型为 bool。如果第二个表达式确实被求值那么第一个表达式关联的所有值计算和副作用都先序于第二个表达式关联的所有值计算和副作用。运算符右侧可不求值右侧必须求值||第一个为 true第一个为 false第一个为 false第一个为 true