Skip to content

think-cell 中检测多个实例化

我们发现了一种在编译时强制执行一段代码只能实例化一次的方法。总之,对于相关代码的每个实例化,我们声明一个具有不同返回类型的函数。如果您尝试实例化代码,则编译将失败并出现以下错误: 尽管这个解决方案很聪明,但它也有一些问题: 可以说,第一点只是不便。我倾向于不同意。想象一下,你已经重构了一些广泛使用的函数。这可能是对许多代码行的更改,无法编译 的子部分。因此,经过几天的工作,您第一次按“构建”,20 分钟后您会收到一条关于仅在返回类型上不同的函数的神秘消息。诚然,大多数编译器都给出了不错的跟踪,以便对所发生的事情的调查不会花费太长时间。然而,调查一开始就不应该是必要的。 第二点肯定更令人担忧。ASSERT_SINGLE_INSTANTIATION 不仅没有断言它承诺要断言的内容,而且由此产生的程序格式不正确,不需要诊断 。 有状态元编程 模板元编程是 C++ 中的一种技术,用于利用类型系统生成代码。在之前的几篇博文中(例如“ 约束用户定义的转换” 和“ 范围适配器的编译时大小 ”),我们利用它将计算从运行时转移到编译时。 直到几周前,我还生活在一个幸福的假设中,即 C++ 模板元编程纯粹是函数式编程 。哦,天哪,我错了吗?在纯函数式编程语言中,所有函数都是无副作用的。对于模板,这是正确的,只是大多数时候。通过一些神奇的朋友注射,我们可以跟踪和改变状态。令人惊讶的是,已经有许多关于有状态元编程的帖子被写了,例如“ 重新审视 C++20 中的有状态元编程 ”和“ 如何使用模板和朋友破解 C++”。 我们之前的解决方案暗示了有状态元编程。现在让我们充分利用它来修复神秘的错误消息。在我们的例子中,我们需要跟踪的状态是之前是否实例化了某些东西。本质可以归结为以下代码。这里,tc::string_template_param 是一个编译时字符串。复制 在 (1) 处,我们声明(而不是定义)一个具有自动返回类型的函数。只要编译器不知道定义,就无法调用此函数。只有当我们实例化 (2) 时,才会注入定义。 现在,我们可以使用调用 (1) 的能力来指示我们是否已经实例化了某些东西。此检查发生在 (3) 中,如果我们已经可以调用 InstantiatedFlag,则返回 false 以指示实例化已经发生。否则,我们为 (1) 注入一个定义。 此实用程序可以与单行一起使用:复制 多个翻译单元怎么办 当使用多个翻译单元时,故事会更加复杂。由于编译器仅在单个转换单元上工作,因此由链接器来验证实例化是否只发生过一次。不幸的是,我们没有找到让链接器抛出错误的可靠方法;C++ 标准包含我们能想到的所有错误的“无需诊断”。 确保单个实例化的唯一剩余方法是在运行时。理想情况下,程序应尽早检测到,并因此可能失败。等到调用两个实例都不是健壮的,并且可能会产生性能损失。使用全局常量初始值设定项,我们可以进行所有检查,甚至在输入 main 之前。复制 将之前的 static_assert 和实例 c_AssertSingleInstantiationBeforeMain 化相结合,会产生以下宏:复制 您可以在编译器资源管理器中自己尝试一下! 在运行时检查(至少原则上)应该在编译期间检查的属性并不好。但是,在 main 之前运行这些检查是下一个最好的选择。如果您能找到一种方法来可靠地让链接器抛出错误,请告诉我们! 奖金 在撰写 MSVC 时,存在一个带有静态变量的代码生成错误 。使用 lambda(而不是静态变量的地址)作为唯一标签可以避免此错误。

think-cell 中强制静态局部变量仅存在一次

我们的计划通常需要资源,这些资源只能设置一次,并且只有在需要时才设置。常见的示例包括将大量数据加载到内存中的数据结构和初始化第三方库。这种情况在大型单体桌面和服务器应用程序中尤其频繁地发生,这些应用程序必须快速启动,并且可能会在需要许多此类资源之前终止。 C++ 编程语言提供的静态局部变量是解决这个问题的一个非常有吸引力的解决方案。它们在控件第一次通过其声明时被初始化,并且一旦初始化,它们的开销通常可以忽略不计,因为现代实现使用双重检查的锁定模式,只需要一个非原子字节比较来检查静态是否已经初始化。 危险 软件会随着时间的推移而发展。函数变长、拆分、内联,并且它们的签名会发生变化。大多数时候,代码演进不需要特别注意静态局部变量。但是,当包含这些变量的函数成为多次实例化的模板时,程序的语义和正确性可能会发生变化。即使函数仅使用单一类型调用,也经常使用 auto 参数的代码库特别容易受到此问题的影响。 这不仅仅是由于开发人员没有注意到静态局部变量造成的。发生这种情况也可能是因为代码不清楚这些变量的语义。 确保安全 记录这些变量在整个程序中应该只存在一次就像编写注释一样简单易行。然而,编译器不读取注释,许多程序员的行为就像编译器一样。编译器检查的解决方案总是比人为错误的解决方案更好。 将所有静态移动到命名空间作用域而不是将它们设为局部变量几乎不是一种选择,因为这会在进入 main 之前无条件地初始化所有静态变量。更糟糕的是,该程序可能会遭受静态初始化顺序的惨败 。 我们让编译器检查给定的静态是否在每个程序中最多实例化一次。这是通过一些有状态元编程来完成的,该元编程使用具有不同返回类型的朋友函数定义,如果包含我们的检测机制的函数被实例化两次,则触发错误。我们还定义了一个简短的 singleton_static 宏,该宏扩展到我们的检查,后跟 static 关键字,开发人员可以使用它不仅可以指示其静态局部变量的所需语义,还可以在编译时验证这些是否确实是单例。复制 如果您想知道,这不会以任何方式更改编译器生成的代码,正如您在编译器资源管理器中亲眼看到的那样。 让它变得更好 这个小工具的一个缺点是编译器错误说仅在返回类型上不同的函数不能重载。对于此用例来说,这不是一个很好的错误消息。是否可以使用允许您报告自定义错误消息的 static_assert 编写相同的检查?我们真的很想看到它。

think-cell 中解析器与 Unicode

Boost.Parser 是一个新库,目前正在审查是否包含在 Boost 中。在介绍中,该文档将 Unicode 意识作为其功能之一。 在 think-cell,多年来,我们一直在 Boost.Spirit 上标准化,以满足所有自定义解析需求,这在精神上与 Boost.Parser 相似(没有双关语)。由于 Boost.Spirit 的维护速度有点慢,我们最近分叉了我们的公共库 。我们使用它的大多数语法都很小,但有些语法更大,比如一张相互引用的 Excel 公式。 当然,我们的输入几乎完全是 Unicode,要么是 UTF-8,要么是 UTF-16。匹配 Unicode 很复杂。按代码点进行比较通常不是正确的。 相反,我们必须正常化,为此我们甚至可以选择接受什么是平等的。 不区分大小写的匹配更加复杂、缓慢,甚至依赖于语言。 通常不能保证输入是有效的 Unicode。例如,Windows 上的文件名是 16 位单位的序列,允许不匹配的代理项,与 Win32 编辑框和文件内容的输入相同。 我们意识到,对于我们拥有的几乎所有语法来说,所有这些复杂性都无关紧要。大多数语法(JSON、XML、C++、URL 等)的保留符号是纯 ASCII。语义上相关的字符串也是 ASCII(“EXCEL.EXE”)。ASCII 可以在每个代码单元的基础上正确快速地匹配。ASCII 的不区分大小写的匹配简单快捷。用户定义的字符串(例如 JSON 字符串值)可能包含 Unicode,但它们通常不会影响解析决策。用户可能希望对这些字符串进行 Unicode 验证,但这可以由叶解析器对这些字符串进行验证,而不是对整个输入完成。 由于如此多的匹配是针对 ASCII 的,我们发现在解析器库中支持编译时已知的 ASCII 文字 (tc::char_ascii) 很有用。有了它们,相同的语法可以用于任何输入编码。解析用户定义的字符串时,它们将具有输入的编码,但这很好。任何编码转换都可以与解析器分开处理。 最后,我们可能想要解析的不仅仅是字符串。解析二进制文件或 DNA 序列应该是可能且高效的。… think-cell 中解析器与 Unicode

C++ 需要未定义的行为,但可能更少丨think-cell

C++ 程序的行为由 C++ 标准定义。但是,它没有完全描述行为,而是将其中一些行为悬而未决: 实现定义的、 未指定的和未定义的行为 。 应该很清楚为什么 C++(以及在类似设计空间中运行的语言)需要实现定义的行为 :如果标准完全指定了所有内容,那么在需要模拟该行为的平台上性能会受到影响。出于类似的原因, 未指定的行为是必要的,但可以通过定义所有未指定的行为实现来删除该类别。 未定义的行为更具争议性。有些人认为应该删除它,因为它会导致危险的编译器优化;有些人将其与实现定义的行为混为一谈(“如果我知道硬件,就没有未定义的行为”)。虽然我同意第一点,并且删除一些未定义的行为可能是有意义的,但删除所有未定义的行为会使代码变得不可能。 未定义的行为是必不可少的 考虑以下标识函数:复制 根据 C++ 标准,x 是一个对象,它有一个地址。但是,在程序集级别,x 的值是在没有地址的 CPU 寄存器中传递的。忽略任何优化,为了满足标准,编译器需要生成汇编代码,为 x 分配内存并将寄存器值存储在其中。返回需要从内存中加载值,然后将其放入结果寄存器中:复制 身份的未优化版本 这有点愚蠢——没有人关心 x 是否有地址;任何地方都没有运营商。 一个明智的优化是消除内存中的存储/加载并直接使用 mov eax、edi。那么你也不需要担心函数设置:复制 身份的优化版本 在标准的假设规则下允许这种优化。身份的未优化版本和优化版本具有相同的可观察行为。即使在优化的版本中 ,x 不再有地址,程序员无法观察到,我们仍然在遵守。 请注意,优化是否由优化器标志启用(如 clang 的情况),或者编译器是否直接生成此类程序集,与讨论无关。严格来说,优化的程序集不是函数的 1:1 表示,但没有人能说出来,这并不重要。 但是,如果没有未定义的行为 ,我们将能够判断优化发生了! 假设我们有一个 main 函数,如下所示: 在后台执行其他作时调用标识 在调用 identity 的同时,我们还在执行一个在地址 0x12345678 存储 42 的后台线程。如果没有额外的知识,这是 C++ 中未定义的行为 。因此,让我们假设我们正在编写一个 C++ 版本,其中所有未定义的内容都是实现定义的 。 如果恰好 0x12345678 是未优化版本中 x 函数参数的地址,恰好后台线程在身份的实现中存储和 load 之间存储了 42,那么优化改变了程序行为!未优化的程序返回 42,而优化的程序返回 0。 是的,这个例子是人为的(这是一个更现实的例子 ,它不依赖于后台线程和凭空提取数字),没有人应该编写这样的代码,但这不是重点: 像这样的代码的可能存在完全禁用了许多关键的优化。 优化器不允许更改程序语义,因此需要进行整个程序分析来确定是否正在发生此类恶作剧。 如果是实现定义的 ,则 *reinterpret_cast<int*>(0x12345678) = 42 需要优化的实现可以将其定义为“将 42 存储到该地址中存储的任何内容,这可能会触发访问冲突,并且其行为可能会随着优化而改变”。然而,这只是一种拼写未定义行为的复杂方式。出于营销原因,不将未定义的行为称为“未定义的行为”可能是有意义的,但它本质上仍然是一回事。 因此,如果一种语言具有不受限制的指针,并且实现想要进行加载/存储消除,则规范需要为其引入(类似) 未定义的行为 。出于类似的原因,基本未定义行为的列表还包括竞争条件和可能的更多作。 未定义的行为太过分了 然而,并非所有未定义行为的情况都是必不可少的,有些是有问题的。考虑有符号整数溢出:由于它是未定义的,编译器可以对表达式进行一些算术简化 ,例如 x * c1 / c2 可以优化为 x * c1_div_c2(如果 c1 能被 c2 整除)。… C++ 需要未定义的行为,但可能更少丨think-cell

小组件通常提供修改其属性的功能丨think-cell

在今天的代码审查中,我提出了我们不久前的一些见解。 我们有一个带有小部件的跨平台 UI 库。UI 小部件本质上服务于两个主节点:使用它们的代码想要设置它们的样式并预填充它们的内容。使用它们的用户希望与它们交互并修改其内容。 因此,小组件通常提供修改其属性的功能,包括内容,以及通知内容更改的事件。例如,编辑框可能提供 SetText 函数和 OnTextChange 事件。 问题如下:如果 SetText 修改文本,则是否应该触发 OnTextChange 事件?当然,使用小部件的代码必须做类似的事情,无论谁进行更改。 但是,代码很容易调用回调本身。更改回调的作用要困难得多:代码需要将上下文传输到回调中。更糟糕的是,处理 SetText 方案的行为现在在回调中实现,可能远离对 SetText 本身的调用,对于代码的读取器来说没有明显的连接。 因此,在我们的 UI 库中,我们遵循只有用户交互才会触发事件的规则。