3.3. 初始化列表

此讨论发生在 https://reviews.llvm.org/D35216 “在创建 std::initializer_list 时转义符号”。

它触及了在一般情况下建模 C++ 标准库构造的难题,包括在 C++ 标准库对象中建模实现定义的字段,特别是将对象构造到此类字段持有的指针中,以及分析器核心和检查器之间责任的分离。

Artem

我发现了一些误报,这些误报出现是因为我们使用大括号初始化器构造 C++11 std::initializer_list 对象,并且这种构造没有得到正确建模。例如,如果一个新的对象是在堆上构造的,只是为了被放入一个使用大括号初始化的 STL 容器中,那么这个对象会被报告为泄漏。

方法 (0):此补丁可以很容易地解决这个问题,它会导致传递给初始化列表表达式的指针立即转义。

然而,这种修复过于保守。因此,我对如何更好地模拟 std::initializer_list 做了一些调查。

根据标准,std::initializer_list<T> 是一个具有方法 begin(), end(), and size() 的对象,其中 begin() 返回一个指向 size() 个类型为 T 的连续数组的指针,并且 end() 等于 begin() 加 size()。标准确实暗示,实现 std::initializer_list<T> 应该可以使用一对指针,或者使用一个指针和一个大小整数,但是对象包含的特定字段是一个实现细节。

理想情况下,我们应该能够精确地建模初始化列表的方法。或者,至少,应该能够向分析器解释列表如何“持有”放入其中的值。初始化列表也可以被复制,这是一个我在这里没有尝试解决的单独问题。

在检查器中建模 std::initializer_list 的明显方法是为初始化列表对象的内存区域构造一个 SymbolMetadata,该区域的类型为 T* 并代表 begin(),这样我们就可以很轻松地将 begin() 建模为一个返回此符号的函数。该符号指向的数组将被 bindLoc()``ed to contain the list's contents (probably as a ``CompoundVal 以在存储中产生更少的绑定)。这个数组的范围将代表 size(),并且等于列表的长度(按原样写入)。

所以这听起来不错,但是显然它对解决我们的误报毫无帮助:当列表转义时,我们的 RegionStoreManager 不会神奇地猜测附加到它的元数据符号及其内容也应该转义。事实上,从检查器内部触发指针转义是不可能的。

方法 (1):如果我们只启用 ProgramState::bindLoc(..., notifyChanges=true) 来导致指针转义(不仅仅是区域更改)(这听起来是正确的做法),这样的检查器将能够通过在将列表元素绑定到列表时触发转义来解决误报。然而,它将像当前补丁的解决方案一样保守。理想情况下,我们不希望转义发生得太早。相反,我们希望它们被延迟到列表本身转义时。

所以我认为,无论何时它们的基区域转义,转义元数据符号都是正确的做法。目前,我们没有考虑这一点,因为我们既没有指针类型的元数据,也没有非指针转义。

方法 (2):我们可以教会 Store 在每次区域被证明是可达时,在 scanReachableSymbols() 中扫描自身以查找与元数据符号相关的区域的绑定。这不需要检查器方面的任何工作,但听起来性能很差。

方法 (3):我们可以让检查器在程序状态中维护一组活动的元数据符号(理想情况下在 Store 的某个地方,这听起来很奇怪,但会导致最少的层级违规),以便核心知道要转义什么。这会给检查器带来压力,但是使用智能数据映射,这将不是问题。

方法 (4):我们可以允许检查器在任意时刻触发指针转义。如果我们允许在 checkPointerEscape 回调本身内执行此操作,我们将能够表达诸如“当此区域转义时,附加到它的元数据符号也应该转义”之类的断言。这听起来像是一种终极的自由,对检查器施加了最大的压力——当我们拥有智能数据映射时,压力仍然不太大。

我个人比较喜欢方法 (2)——应该可以避免性能开销,而且清晰度看起来不错。

Gabor

在这一点上,我有点想知道两个问题。

我认为,如果我们追求最大限度的自由,我们就不必担心对检查器的潜在压力,并且可以引入抽象来在以后缓解这种压力。如果我们想简化 API,那么也许将语言构造建模移到引擎中更有意义,因为当检查器 API 不够用时,而不是复杂化 API。

现在我对这些替代方案没有偏好或反对意见,但我有一些随机的想法

Artem: 这些都是一些很好的问题,我认为公开讨论它们会更好。作为对我当前情绪的快速倾倒

Artem

> 方法 (2):我们可以教会 Store 在每次区域被证明是可达时,在 scanReachableSymbols() 中扫描自身以查找与元数据符号相关的区域的绑定。这不需要检查器方面的任何工作,但听起来性能很差。

不,这种方法是错误的。元数据符号可能会过时:当对象改变时,附加到它的元数据符号不会改变(因为符号根本不会改变)。相同的元数据可能在不同的时间点具有不同的符号来表示它的值,但最多只有一个符号代表实际的元数据值。所以我们会转义比必要更多的内容。

如果我们只有“幽灵字段”(https://lists.llvm.org/pipermail/cfe-dev/2016-May/049000.html),它会容易得多,因为幽灵字段只会包含实际的元数据,而 Store 将始终知道它。这个例子加强了我对幽灵字段正是大多数 C++ 检查器所需要的这一观点。

Devin

在这种情况下,我不会介意某种 AbstractStorageMemoryRegion,它的意思是“这里有一个内存区域,并且在某个可达的地方,存在另一个类型为 T 的区域”。甚至可以使用多个具有不同标识符的区域。这不会指定内存是如何可达的,但它将允许转移函数访问这些区域,并且它将允许失效。

对于 std::initializer_list,这个可达区域将是支持数组的区域,而 begin() 和 end() 的转移函数将为它生成开头和结尾元素区域。

在我看来,这与幽灵变量不同,因为 (1) 此存储实际上存在(只是库实现细节决定了存储的位置),(2) 返回到此存储的指针是完全有效的,并且程序的另一部分可以从此存储读取或写入。 (好吧,在这种情况下只是读取,因为它被允许是只读内存)。

我不赞成将抽象分析状态(例如,NSMutableArray 的计数或文件句柄的类型状态)建模为存储在 Store 中某个构造区域的值。这将一个分析器擅长的简单问题(类型状态建模)变成了一个分析器不擅长的难题(推理堆的内容)。

我认为这里关键的标准是:“该区域是否可以从库外部访问”。也就是说,库是否将该区域公开为一个指针,该指针可以在客户端程序中读取或写入?如果是这样,那么将其放在存储中是有意义的:我们正在将可达到的存储建模为存储。但是,如果我们只是对需要在指针逃逸时失效的任意分析事实进行建模,那么我们不应该为了免费获得失效而尝试为它们生成存储。

Artem

> 在这种情况下,我愿意使用某种 AbstractStorageMemoryRegion > 表示“这里有一个内存区域,并且在从这里可以到达的地方存在 > 另一个类型为 T 的区域”。或者甚至多个具有不同 > 标识符的区域。这不会指定内存是如何可达到的,但它会 > 允许传递函数访问这些区域,并且会允许 > 失效。

是的,这正是我们现在可以轻松实现的,作为基于元数据符号的符号区域(尽管如果我们例如想要对它进行类型化,我们可以为此创建一个新的区域类)。问题是,这种存储区域与其父对象区域之间的关系本质上是不重要的,类似于 SymbolRegionValue 及其父区域之间的关系。区域内容是可变的:今天抽象存储可以从其父对象访问,明天它就不能访问了,也许其他东西变得可以访问,甚至不是抽象的东西。因此,抽象存储的父区域在大多数情况下充其量只是一个“好的知道”的东西 - 我们不能依靠它来完成任何实际工作。我们无论如何都需要依靠检查器来完成这项工作。

> 对于 std::initializer_list,这个可达区域将是 > 后备数组的区域,begin() 和 end() 的传递函数将产生 > 它的开始和结束元素区域。

所以也许实际上对于 std::initializer_list 来说,它可能运行良好,因为你无法在对象构造后更改数据 - 所以这个区域的内容本质上是不可变的。对于将来,我觉得它是一个死胡同。

我想考虑另一个有趣的例子。假设我们正在尝试对

std::unique_ptr. Consider::

  void bar(const std::unique_ptr<int> &x);

  void foo(std::unique_ptr<int> &x) {
    int *a = x.get();   // (a, 0, direct): &AbstractStorageRegion
    *a = 1;             // (AbstractStorageRegion, 0, direct): 1 S32b
    int *b = new int;
    *b = 2;             // (SymRegion{conj_$0<int *>}, 0 ,direct): 2 S32b
    x.reset(b);         // Checker map: x -> SymRegion{conj_$0<int *>}
    bar(x);             // 'a' doesn't escape (the pointer was unique), 'b' does.
    clang_analyzer_eval(*a == 1); // Making this true is up to the checker.
    clang_analyzer_eval(*b == 2); // Making this unknown is up to the checker.
  }

检查器不需要完全确保 *a == 1 通过 - 即使指针是唯一的,它理论上也可能在上面 .get()-ed,并且代码当然可以破坏唯一性不变式(尽管我们可能希望这样做)。检查器可以说,“即使 *a 确实逃逸了,它也不是因为被直接塞入 bar() 中”。

然而,检查器的直接责任是解决 *b == 2 问题(这实际上是我们在这个补丁中正在处理的问题 - 对象的存储区域逃逸)。

因此,我们正在谈论对程序状态进行一项额外的操作(扫描可达到的符号和区域),该操作无法在没有检查器支持的情况下进行。

我们可能可以添加一个新的回调“checkReachableSymbols”来解决这个问题。这实际上也与死符号问题有关(我们正在分别在存储和检查器中扫描活动符号,但我们需要使用单个工作列表同时执行此操作)。嗯,实际上这听起来是个好主意;我们可以用 checkReachableSymbols 替换 checkLiveSymbols。

或者我们可以只使用幽灵成员变量,根本不需要检查器支持。对于幽灵成员变量,它们与其父区域(将是它们的超级区域)之间的关系实际上是有用的,它们的内容的可变性可以自然地表达,并且存储会自动看到可达到的符号、活动符号、逃逸、失效,等等。

> 我认为这与幽灵变量不同,因为 (1) 这种存储 > 实际上存在(它只是库实现细节,存储 > 所在的位置)以及 (2) 将指向该存储的指针 > 返回,并让程序的另一部分从该 > 存储读取或写入,这是完全有效的。 (好吧,在这种情况下只是读取,因为它被允许是只读 > 内存)。

> 我不能接受的是,将抽象分析状态(例如,NSMutableArray 的 > 计数或文件句柄的类型状态)建模为存储在 > 存储中某个生成的区域中的值。 这将一个分析器擅长的简单问题(类型状态建模)变成一个分析器不擅长的难题(关于堆内容的推理)。

是的,我倾向于同意这一点。对于简单的类型状态,这可能是一种过度的做法,所以让我们绝对抛弃我之前提到的“幽灵符号区域”的想法。

但是,为了总结一下,在我们的当前情况下,我们正在寻找的类型状态是堆的内容。当我们尝试在任何检查器中对这种类型状态(以这种特定方式复杂,即堆状)进行建模时,我们必须在每个这样的检查器中重新进行这种建模(这确实是分析器擅长的,但代价是使检查器变重)之间做出选择,或者依靠 Store 来执行它被设计用来做的事情。

> 我认为这里关键的标准是:“该区域是否可以从外部访问 > 库”。也就是说,库是否将该区域公开为一个指针, > 该指针可以在客户端程序中读取或写入?如果是这样,那么它 > 有意义地存在于存储中:我们正在将可达到的存储建模为 > 存储。但是,如果我们只是对需要被 > 失效的任意分析事实进行建模,那么我们不应该尝试生成 > 存储来免费获得失效。

作为一个比喻,我可能会将其与人体农场进行比较 - 幽灵成员变量和元数据符号之间的区别在我看来就像人体农场和 evalCall 之间的区别。两者都是不错的选择,人体农场使用起来非常愉快,即使不是无所不能。我认为在人体农场中,函数声明体中有一个局部变量是没问题的,即使这样的变量实际上不存在,即使它不能从函数调用外部看到。我认为“它确实存在”和“它实际上不存在,只是一个方便的抽象”之间没有直接的实际区别。同样,我认为如果我们有一个 CXXRecordDecl,它具有实现定义的内容,并且尝试将成员变量作为方便的抽象进行模拟(我们甚至不需要知道它的名称或偏移量,只需要知道它存在于某个地方),这也是没问题的。

Artem

我们与 Devin 当面讨论过,他提供了更多需要思考的观点

因此,由于这需要进一步深入研究 C++ 的整体支持,并且引发了太多问题,我将推迟对这个问题的更好方法,并将回到最初的简单补丁。