Infer 静态分析实战:NIL_INSERTION_INTO_COLLECTION——Objective-C 集合 nil 插入崩溃的检测与修复指南
本文基于 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 参数构造集合"的工厂方法也在检查范围内,例如:
[NSArray arrayWithObject:nil](测试 arrayWithObjectNilBad);[NSDictionary dictionaryWithObject:value forKey:nil]/dictionaryWithObject:nil forKey:key](测试 dictionaryWithObjectForKeyNilBad);[NSMutableDictionary dictionaryWithSharedKeySet:nil](测试 dictionaryWithSharedKeySetBad);[NSDictionary sharedKeySetForKeys:nil](测试 dictionaryWithSharedKeySetForKeysBad);- 批量接口如
dictionaryWithObjects:forKeys:count:、setWithObjects:count:、arrayWithObjects:count:等(同文件 L212-L354 一带)。
以上方法在 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,你既可以在工程实践中快速定位问题,也能深入理解其检测原理并合理评估误报风险。
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考



