Infer 静态分析实战:NIL_INSERTION_INTO_COLLECTION——Objective-C 集合 nil 插入崩溃的检测与修复指南

Infer 静态分析实战:NIL_INSERTION_INTO_COLLECTION——Objective-C 集合 nil 插入崩溃的检测与修复指南

【免费下载链接】infer A static analyzer for Java, C, C++, and Objective-C 【免费下载链接】infer 项目地址: https://gitcode.com/gh_mirrors/infer/infer

本文基于 Infer 的官方 Issue 文档 NIL_INSERTION_INTO_COLLECTION.md,深入讲解 Infer 如何检测 Objective-C 代码中把 nil 传入集合(数组、字典、集合)所导致的运行时崩溃,并结合作息仓库内的 Pulse 分析器源码与测试用例,说明该 Issue 的触发场景、检测原理、报告解读与修复方案。读完本文,你将能在自己的 Objective-C / ObjC++ 工程中复现并理解这类崩溃,掌握使用 NSNull 表达"显式空值"的正确姿势,并能基于源码级证据规避误报。

1. 问题概述:向集合插入 nil 为什么会导致崩溃

在 Objective-C 中,NSArray、NSDictionary、NSSet 等集合类不接受 nil 作为元素或键。官方文档明确说明:向这些集合传入 nil 会触发崩溃(运行时抛出 NSInvalidArgumentException)。这与 Objective-C 中"向 nil 发消息是合法的、返回 nil"的宽松语义完全不同——集合容器对 nil 是零容忍的。

Infer 的 Pulse 分析器(符号化执行引擎)在 IssueType.ml 中注册了此 Issue:

let nil_insertion_into_collection =
  register_with_latent ~category:RuntimeException ~id:"NIL_INSERTION_INTO_COLLECTION" Error Pulse
    ~hum:"Nil Insertion Into Collection"
    ~user_documentation:[%blob "./documentation/issues/NIL_INSERTION_INTO_COLLECTION.md"]

从注册信息可以确认:

  • 分类(category):RuntimeException,即归入运行时异常一族;
  • 级别:Error,一旦报告即表示存在确定性或高度可疑的崩溃路径;
  • 所属分析器:Pulse,由脉冲符号执行器产生;
  • hum 名称:Nil Insertion Into Collection;
  • register_with_latent:同时注册了默认关闭的 NIL_INSERTION_INTO_COLLECTION_LATENT 潜伏变体(见 infer.txt 帮助页),用于只出现在派生函数中的"隐性"问题。

下面先按官方文档的脉络,逐类看触发场景。

2. 数组场景:addObject、insertObject:atIndex:、replaceObjectAtIndex:withObject:

官方文档指出,向 NSMutableArray 执行以下三类操作时,如果传入的对象是 nil,都会导致崩溃:

[mArray addObject:nil];              // crash
[mArray insertObject:nil atIndex:0]; // crash
[mArray replaceObjectAtIndex:0 withObject:nil]; // crash
  • addObject::向数组末尾追加元素;
  • insertObject:atIndex::在指定下标插入元素;
  • replaceObjectAtIndex:withObject::替换指定下标的元素。

三者对 nil 都一视同仁:只要对象参数是 nil,运行时直接崩溃。

仓库测试用例 NPENilInsertionIntoCollection.m 对这三种情况逐一验证,且在对应的期望文件 issues.exp 中分别记录了三条 NIL_INSERTION_INTO_COLLECTION 报告:

addNilInArrayBad,        ..., NIL_INSERTION_INTO_COLLECTION, ... [is assigned to the null pointer,
                         in call to `NSMutableArray.addObject:` (modelled), invalid access occurs here]
insertNilInArrayBad,     ..., NIL_INSERTION_INTO_COLLECTION, ... [is assigned to the null pointer,
                         in call to `NSMutableArray.insertObject:atIndex:` (modelled), invalid access occurs here]
replaceNilInArrayBad,    ..., NIL_INSERTION_INTO_COLLECTION, ... [is assigned to the null pointer,
                         in call to `NSMutableArray.replaceObjectAtIndex:withObject:` (modelled), invalid access occurs here]

注意 trace 中的 (modelled) 标记:Infer 并不逐行编译 Foundation 框架源码,而是对集合 API 建立"模型",在遇到这些方法调用时按模型执行检查。对应的建模位于 PulseModelsObjC.ml,其中 NSMutableArray.addObject: 被映射为 insertion_into_collection_key_or_value ~value_kind:\Value,而insertObject:atIndex:与replaceObjectAtIndex:withObject:被映射为insert_object_at ~disallow_nil_obj:true。disallow_nil_obj:true` 意味着建模函数会强制校验插入对象的有效性,这正是检查的落点。

3. 字典场景:值、键与 NSNull 的正确用法

字典(NSDictionary / NSMutableDictionary)比数组更复杂,因为键和值两端的 nil 都会崩溃。

3.1 值为 nil:用 [NSNull null] 表达"显式空"

官方文档示例:

id value = nil;
[mDict setObject:value forKey:@"somestring"];  // crash

[mDict setObject:[NSNull null] forKey:@"somestring"];  // ok

如果业务上确实需要"键存在但值为空"的语义,必须使用 [NSNull null] 单例对象代替 nil。NSNull 是一个合法的 Objective-C 对象(非 nil),可以安全地存放在集合中;读取时通过 if (obj == [NSNull null]) 或 [obj isKindOfClass:[NSNull class]] 判断"空值"。

测试用例 addNSNullInDictOk 与 addNilInDictBad(L10-L13)恰好构成一对对照:前者不报、后者报 NIL_INSERTION_INTO_COLLECTION。

3.2 键为 nil:赋值与删除同样崩溃

官方文档强调,检索或删除时使用 nil 键同样崩溃:

id key = nil;
mDict[key] = @"somestring";   // crash(键为 nil 的下标赋值)

[mDict removeObjectForKey:nil]; // crash

注意上面的第一个例子:mDict[key] = value 走的是下标语法(setObject:forKeyedSubscript:),当键为 nil 时崩溃。测试用例 addNilKeyInDictBracketsBad 精确复现了这一场景。

这里有一个容易混淆的细节值得单独说明:当键非 nil、值赋 nil 时,下标语法的行为恰恰相反——是合法的。测试用例 addNilInDictBracketsOk 的注释写得很清楚:

void addNilInDictBracketsOk(NSMutableDictionary* mDict) {
  mDict[@"key2"] = nil; // Passing nil will cause any object corresponding
                        // to a key to be removed from the dictionary.
}

也就是说,dict[key] = nil 等价于 removeObjectForKey:(删除语义),是 Foundation 定义的合法用法;但 dict[nil] = value 和 [dict setObject:value forKey:nil] 都是崩溃点。Pulse 的建模也体现了这一差异:在 PulseModelsObjC.ml 中,setObject:forKey: 使用 insertion_into_collection_key_and_value(键和值两端都检查),而 setObject:forKeyedSubscript: 与 removeObjectForKey: 只检查键(value_kind:\Key`)。

3.3 跨函数传播:nil 参数穿透调用边界

崩溃不一定发生在字面量 nil 上,也可能来自某个函数参数的传递。测试用例 removeObjectFromDictKeyNilBad 展示了这种情况:removeObjectFromDict(mDict, nil) 把 nil 作为参数传给 removeObjectFromDict,后者再调用 [mDict removeObjectForKey:key]。Pulse 的 trace 会跨调用链给出完整证据(when calling removeObjectFromDict here, parameter key of removeObjectFromDict, in call to NSMutableDictionary.removeObjectForKey (modelled)),这正是它优于纯词法/模式匹配检查的地方。

4. 更多触发面:集合字面量、NSSet 与工厂方法

官方文档只覆盖了数组和字典的增、插、改、删,但同属一个缺陷家族的触发点还有很多,仓库测试与建模给出了完整的覆盖面。

4.1 集合字面量:@[...] 与 @{...}

字面量语法是崩溃重灾区。测试用例:

void arrayLiteral(id object) { NSArray* array = @[ object ]; }        // arrayLiteralNilBad: 传 nil 崩溃
void dictionaryLiteral(id key, id object) { NSDictionary* dict = @{key : object}; }
// dictionaryLiteralKeyNilBad: 键为 nil 崩溃
// dictionaryLiteralObjectNilBad: 值为 nil 崩溃

见 NPENilInsertionIntoCollection.m 与 L331-L335。

从源码结构看,Clang 前端在翻译字面量时做了专门的处理:cTrans.ml 的注释展示了数组字面量的翻译结果——每个元素先调用 objc_insert_value(...)(nil 插入检查),再执行一次空指针解引用检查;字典字面量则在 cTrans.ml 中对键调用 objc_insert_key(...)、对值调用 objc_insert_value(...)。这两个"内建过程名"定义于 BuiltinDecl.ml(_objc_insertKey / _objc_insertValue),最终在 PulseModelsObjC.ml 中被建模为 insertion_into_collection_key_or_value(键/值两个变体)。这也是 trace 中会出现 key insertion into collection literal (modelled) / value insertion into collection literal (modelled) 的原因。

4.2 NSSet / NSMutableSet

集合同样禁止 nil 元素。建模覆盖了 NSSet.setWithObject:、NSSet.setByAddingObject:、NSMutableSet.addObject:、NSMutableSet.removeObject:(见 PulseModelsObjC.ml 与 L619-L624),测试用例 setWithObjectBad、setByAddingObjectBad、addObjectInMSetBad 逐一验证。

4.3 一次性构造的工厂方法

除增删改外,一批"用 nil 参数构造集合"的工厂方法也在检查范围内,例如:

以上方法在 PulseModelsObjC.ml 中都有对应的 insertion_into_collection_* 建模。可以推断:凡是被 Foundation 文档禁止传 nil 的集合构造/写入 API,Pulse 的 Objective-C 模型均按"键或值非 nil"的前提进行校验。

5. 修复行动指南(Action)

官方文档给出的行动建议非常明确:凡是传入 nil 会导致崩溃的地方,要么保证传入对象永远不为 nil,要么在调用前显式判空。

以下是对应的可落地修复模板:

// 方案一:调用前判空
id obj = [self maybeReturnNil];
if (obj != nil) {
  [mArray addObject:obj];
} else {
  // 处理空值分支,例如记日志或使用默认值
}

// 方案二:用默认值兜底
NSString* safe = obj ?: @"";
[mArray addObject:safe];

// 方案三:确需"显式空值"时使用 NSNull
[mDict setObject:[NSNull null] forKey:@"key"];
// 读取侧
id v = mDict[@"key"];
if (v != nil && v != [NSNull null]) {
  // 真正有值的分支
}
  • 数组:插入/替换前对对象判空,或用 ?:(ELVIS 运算符)给默认值;
  • 字典(值):判空,或按语义选择 NSNull;
  • 字典(键/下标/删除):先确保 key 非 nil 再操作;dict[key] = nil 合法(删除语义),但 dict[nil] = value 非法;
  • 字面量与工厂方法:构造 @[...]、@{...}、arrayWithObject: 等之前同样先判空。

对照测试中的 OK 用例(addObjectInArrayOk、insertObjectInArrayOk、replaceObjectInArrayOk、addNSNullInDictOk、addObjectInDictOk 等,见 NPENilInsertionIntoCollection.m),它们均不产生报告,可作为"修复后应该长什么样"的参考实现。

6. 从源码看检测原理:Pulse 如何捕获 nil 插入

理解这个 Issue 的检测机制,可以沿着三条源码线索深入。

6.1 失效原因(Invalidation Cause)

Pulse 在 PulseInvalidation.ml 中定义了两个专门的失效原因:

| InsertionIntoCollectionKey
| InsertionIntoCollectionValue

当 Pulse 以 ~must_be_valid_reason:InsertionIntoCollectionKey(或 Value)为前提对某个表达式执行读访问时,若发现该表达式的值来自 nil(即零常量),就在 L144-L145 将其映射为 IssueType.nil_insertion_into_collection 报告。换句话说:"作为键/值插入集合"和"普通空指针解引用"在 Pulse 内部是两种不同的失效原因,最终映射到不同的 Issue 类型。

6.2 模型函数(Model Functions)

PulseModelsObjC.ml 中的三个核心建模函数:

  • insertion_into_collection_key_and_value:对值做 InsertionIntoCollectionValue 前提的读取、对键做 InsertionIntoCollectionKey 前提的读取(用于 setObject:forKey: 等键值都校验的 API);
  • insertion_into_collection_key_or_value:按 value_kind 只校验键或只校验值(用于下标赋值、removeObjectForKey:、addObject: 等单端校验的 API);
  • insert_object_at:在 disallow_nil_obj:true 时先校验插入对象,再向数组单元格写入(用于 insertObject:atIndex:、replaceObjectAtIndex:withObject: 等)。

它们共同构成了一个"插入点检查表":凡是映射到这些模型的调用,其键/值参数都会被强制要求非 nil。

6.3 前端翻译:字面量也被插桩

对于字面量语法,检查不是由模型完成的,而是在 Clang 前端翻译阶段就插入了内建调用(见上文 4.1 节引用的 cTrans.ml 与 cTrans.ml),随后 Pulse 再对内建调用建立模型。这样设计的好处是:无论元素来自字面量常量、函数返回值还是方法参数,最终都在统一的模型层被检查,保证语义一致。

7. 在 Infer 中使用与配置

7.1 默认行为

从 IssueType.ml 的 register_with_latent 以及 帮助页 可以看到:

NIL_INSERTION_INTO_COLLECTION (enabled by default),
NIL_INSERTION_INTO_COLLECTION_LATENT (disabled by default),

即主 Issue 默认开启,随 Pulse 分析器自动启用(infer run --pulse 或默认检查器组合即包含);_LATENT 潜伏变体默认关闭,用于只报告"通过其它路径传播到的 nil 插入"。

7.2 按需开启 / 关闭

Infer 提供细粒度的 Issue 类型过滤选项(定义见 Config.ml):

# 关闭该 Issue(不影响 Pulse 检查器本身运行)
infer run --disable-issue-type NIL_INSERTION_INTO_COLLECTION

# 显式开启(默认已开)
infer run --enable-issue-type NIL_INSERTION_INTO_COLLECTION

注意帮助文本中的提示:enabling issue types does not make the corresponding checker run——这些选项只控制报告过滤,不决定 Pulse 是否执行;要真正关闭分析请使用 checker 级别的开关。

7.3 报告 trace 的阅读方法

以 pulse_messages_objc 中的两条报告为例:

`object` could be nil (from the call to `returns_nil()` on line 93) and is used as a
value when inserting into a collection, potentially causing a crash.

报告会标注:nil 的来源(哪一行调用返回 nil) → 流经的赋值/传参 → 触发点(哪个集合 API 被调用) → 失效位置(invalid access occurs here)。对照源码 nil_issues.mm 可以完整还原这条数据流:returns_nil() 返回 nil → 赋值给 object → [mArray addObject:object] 触发崩溃检查。

8. 已知局限与误报说明

Pulse 对该问题的检测整体上很精确,但仓库测试也标注了若干误报(_FP 后缀)与漏报(_FN 前缀)场景,使用时应心中有数:

  • nil 消息发送语义带来的误报:testNilMessagingForModelNilNilOK_FP(L104)中,接收者本身就是 nil,addObjectInDict(nil, nil) 实际不会执行任何操作(向 nil 发消息返回 nil),但模型仍报告了 nil 值插入。这是把"接收者为 nil 时方法体不执行"与"方法体内对象为 nil 会崩溃"两种语义简化处理的结果。
  • 模型缺口导致的误报:pass_non_empty_array_ok_FP(L389-L392)的注释指出"missing models for collections"——get_object_from_array 内部已对空数组判空并返回非 nil,但由于缺少相关集合模型的精确摘要,分析器仍认为返回值可能为 nil。
  • 显式 0 等价 nil:dictionaryInsertZeroNilBad(L394-L399)说明在对象上下文中字面量 0 会被当作 nil(id(0)),因此 s ?: 0 这类写法同样会被识别为 nil 插入。

这些用例均被保留在 issues.exp 中作为回归基线,也从侧面说明:该 Issue 的质量是由一整套 bad/ok/FP/FN 对照测试持续保障的。

9. 小结

NIL_INSERTION_INTO_COLLECTION 是 Infer Pulse 分析器为 Objective-C / ObjC++ 开发者提供的一项默认开启的运行时崩溃检查:它覆盖数组、字典、集合的增删改查、下标语法、字面量语法与工厂方法等全部 nil 插入入口,并跨函数传播追踪 nil 的来源。遇到报告时,遵循官方文档的 Action——判空、给默认值或使用 NSNull——即可稳妥修复。结合 PulseModelsObjC.ml、PulseInvalidation.ml 与测试基线 NPENilInsertionIntoCollection.m,你既可以在工程实践中快速定位问题,也能深入理解其检测原理并合理评估误报风险。

【免费下载链接】infer A static analyzer for Java, C, C++, and Objective-C 【免费下载链接】infer 项目地址: https://gitcode.com/gh_mirrors/infer/infer

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值