模板的奇妙之处:测试、调试和基准测试模板代码¶
标题:Template Shenanigans: Testing, Debugging and Benchmarking Template Code
日期:2021/03/17
作者:Jonathan O’Connor
链接:https://www.youtube.com/watch?v=TQQf4z-laBA
注意:此为 AI 翻译生成 的中文转录稿,详细说明请参阅仓库中的 README 文件。
备注一:有点意思的演讲,[[deprecated]]
用于模板调试的技巧其实我也在用🙃
备注二:个人推荐一款调试器 Templight,可以调试模板推导过程。我给这个项目水过 patch。
备注三:演讲提到的 Metashell 其实和 Templight 算是兄弟项目?没仔细研究过。
备注四:幻灯片看一眼,草泥马(动物)很可爱。
好的,这篇演讲不会谈论钟琴(Glockenspiels),但会讨论模板代码。你们可能知道,我编程已经很长时间了,而且我一直对元编程很感兴趣。比如,我在大学里学过一点 Lisp 的元编程,然后我们接触了一些 C++ 和模板,之后我还在 Java 和 Ruby 中做过元编程。这是我喜欢做的事情,对我来说也是一种挑战。我读了很多相关的书,但所有这些书、所有这些演讲中,很少有——或者说几乎没有——会谈论你该如何去开发它。我的意思是,我不知道你们是否能看到这本书,这是一本很棒的《C++ Templates》。在 800 页的内容里,我记得只有 11 页是关于实际测试和调试模板代码的。这里还有另一本 Ivan Čukić 的书,也是一本很棒的书。同样,他可能也只有一页内容介绍了一种实现这个目的的技术。所以,这次演讲我们将讨论如何编译代码,我们将讨论如何测试、调试和进行基准测试。我们不会讨论具体的模板技巧之类的东西,那些内容你可以在很多其他人的演讲中找到。好的。哦,抱歉。
啊,是的。好的。我住在乡下,在这场疫情中我非常幸运,因为我有动物,我可以出门散步。对我来说,这里的生活和封锁前没有太大区别。所以,在这方面我感到很幸福。如我所说,我们的议程是,首先看看如何编译代码,这并不总是一件容易的事。然后看看当出现问题时如何调试它。我们还会研究测试和基准测试。就像 Klaus 刚才提到的,我会在每个部分之间稍作停顿,请大家在聊天区提问,我会在各部分之间回答这些问题。好的。我想,嗯,Klaus 已经介绍过我了。在过去一年左右的时间里,我一直在用 C++20 编写一个内存数据库。所以,我在创建、编写模板代码以及发现各种问题和困难方面积累了很多经验。因此,我现在对于哪些工具有用、哪些工具没用有了一些心得。好了,这是我的羊驼。
编译¶
好的。编译。编译时遇到的主要问题是,你的代码根本就编译不过。当这种情况发生时,你会得到长长的错误信息。它们难以阅读,没完没了地延伸。至少,像我这样的 CLANG 用户,当我编译时,错误信息非常长。它们所在的终端窗口不会自动截断消息,你必须向右滚动,而且要滚动很长一段距离。很少有人——至少我通常不会向右滚动,向下滚动感觉自然得多。所以,这是个问题。
这里有一个我自己代码中的例子,是一个简化版。我有一个整数集合(set),一切正常,这很棒。然后我想,好吧,我看过一个 John Lakos 的演讲,他以使用这些分配器(memory allocators)来优化代码、使其更快更好而闻名。所以我想,哦,我也要用一下。我在我的 set
中加入了一个分配器。我刚这么做,一尝试编译,就得到了这一长串可怕的错误信息。通常发生这种情况时,我就会僵住,大脑一片空白,感觉信息量太大了。而且这些信息难以阅读,里面有很多无关紧要的东西。事实上,在这个屏幕上你可以看到,屏幕的左半部分被指向实际文件路径和出错文件名占满了。然后,只有在右半部分你才能真正看到它在抱怨什么。所以,这很困难。
我发现最好的处理方法是,首先,你做一次深呼吸,然后你真的强迫自己去阅读这些错误信息。当你这样做时,你能找到一些有帮助的片段。就我们的例子而言,如果我一直读到最后,在某个地方,我会看到它告诉我,with key = int, val = int, key of value, blah
。然后它说了这句很棒的话:compare = std::allocator<int>
。这部分就是我应该思考的地方,“咦?std::allocator
,为什么它被用作 set<int>
的比较函数?”然后我恍然大悟,啊,我搞错了。我给 set
类型漏掉了一个模板参数。这就是问题所在。
在我去年第一次准备这个演讲时,我向社区寻求了建议。Ben Dean 建议使用 Clang format。你的做法是,把声明复制到一个 .cpp 文件里,然后尝试格式化它。我用我的代码试了一下,但得到的是这个结果。对我来说,这个方法似乎并不奏效。但也许在其他人的代码库里,这会很有用。
我个人不是很喜欢这种方法。然后,Vittorio Romeo,他有一个叫做 Camomila 的项目。Camomile(洋甘菊),我想这是一种舒缓的饮品。或许这也是一种阅读错误信息的舒缓方式。因为它所做的基本上就是对命名空间和类型进行搜索和替换,并创建出更小、更短的版本。这使得阅读变得更容易、更简短。
举个例子,它是一个 Python 脚本。你可以这样运行它:你输入 echo
,然后给出你的类型。你也可以把错误信息的输入直接通过管道传给 Camomila 命令。它会丢掉一大堆东西,让输出变得更短、更容易阅读。在这里,-D zero
是一个特殊的标志,告诉你,我只想深入到一层。你可以丢掉所有其他层级。这就是为什么我们最终只得到了 meta::vector
of something。是的,它在那里放了一个问号,作为所有其他嵌套类型的占位符。是的,这就是 Camomila -D0
,以及我们的输出。
如果你想要完整的深度,你可以试试像 --depth=100
这样的参数。这个会给出一个更详细的描述。所以我们看到它是一个 vector
of pair
s of a short
and an int
。
我实际上不经常使用那个特定的工具。为了这次演讲我用过一两次,但我没有其他建议了。我认为我的建议是:冷静下来,做个深呼吸,然后阅读错误信息,强迫你的大脑去读,而不是假设你已经知道你在读什么。好的。我要停下来问问有没有问题。
Klaus: 目前还没有问题。
好的。好的。好的。行。我们继续。
调试¶
好的。我们现在来看看调试,调试我们的模板。模板调试。最简单的方法就是你可以直接使用你的调试器,你常用的 C++ 调试器,比如 GDB,来实际调试模板代码。这没问题。如果模板在实时代码中,你可以步入它们,单步执行。这很好。我们还要看看如何打印类型名称。在模板编程中,你经常写代码时以为自己知道正在处理的东西的类型,但很明显你搞错了。代码无法编译,或者某些地方出了问题。然后你想要确切地知道这个东西的类型到底是什么。我们将看到几种不同的打印类型的方法,以及一些可以帮助我们做到这一点的技巧和函数。然后,还有一些模板实例化工具。这些工具可以让我们看到底层生成了什么样的代码。这也帮助我们理解我们的代码在做什么,以及实际上产生了什么代码。
这里有一些代码,来自我过去一年左右一直在做的项目。我只是在这个 downgrade
函数上设置了一个断点,它是一个模板函数。它会调用自身的另一个实例化版本。我可以在这里看到所有这些信息。我想如果我能,我的鼠标在哪儿?是的,这是我的鼠标。所以这里是在 downgrade<1>
中,但 downgrade<1>
是由 downgrade<2>
调用的,这是这个函数的另一个实例化版本。然后是我的 main
函数。所以,调试过程和普通代码一样。
好的。那么,“我手里的这个到底是什么类型?”这是我经常、经常使用的技巧。我经常发现自己犯了错,想搞清楚我到底做了什么。语言和标准库本身已经对这个问题提供了一些支持。你可以尝试使用 typeid
表达式。它会返回一个 type_info
对象,然后你可以请求它的名称。但不幸的是,如果你这样做,它给你的名称并不是那么有用。如果你对一个 T
是 std::string
的类型这样做,你会得到这样的结果,看起来很不友好,也不太可读。所以,这并不是一个很好的解决方案。
Klaus: 抱歉,我打断一下,有个问题刚刚弹出来。
Jonathan: 当然。
Klaus: 是关于你提到的那个工具的。那个 Python 工具… Camomila。是的,没错。那个 Python 工具如何处理在不同命名空间中有多个同名类型的情况?
Jonathan: 我不知道。我运行它的次数不够多。我发现它对我的代码没什么用。而且我知道它会剥离命名空间,正如你所见。所以,我没有深入研究过它,无法确切知道。但是链接在那里,如果你去 Vittorio Romeo 的 GitHub 账户,那个脚本就在那里。它只是一个 Python 脚本,但我又不是个 Python 程序员,所以让它跑起来对我来说有点棘手。我大概能读懂代码,但也就这样了。
Klaus: 如果你愿意再回答一个问题的话。
Jonathan: 好的。
Klaus: Thomas 想知道,你是否知道目前有没有人在努力改进模板或常规代码的错误信息?比如,Elm 语言就以其错误信息为荣,使用起来非常愉快,感觉就像在结对编程。
Jonathan: 是的。我想,是的,我听说过 Elm 的好评。我也听说过 Rust,我觉得它们在错误信息方面也做得很好。对于 C++20,社区和委员会希望做出更友好的错误信息。这就是我们得到 concepts 的原因。不幸的是,据我所见,错误信息仍然相当长。你会得到一连串的错误信息,或者当 concept 被违反时,你会得到一个错误信息。当你没有满足 concept 的要求时,然后你会得到像“这里出错了,因为它在这里被调用,然后又在这里被调用,再然后又在这里被调用”等等。所以你得到的不是一行错误,而是多行。而且它们都是很长的行。这就是开发者难以处理和阅读的地方。所以,是的,concepts 本应让错误信息变得更好。
另一件事我很好奇,我没有足够深入地研究过,但我很好奇,在各种编译器中,标准库是否为 C++20 进行了重构,并且基本上把 concepts 应用到了所有旧的函数和类型中。我怀疑他们没有,因为那可能会破坏一些代码,破坏 pre-concept 时代的代码。是的。所以,我不知道。但如果他们不加入 concepts,那么你就无法得到更好的错误信息。就像我展示的那个例子,我有一个set
,然后我忘记了放入比较器,我以为既然比较器有默认类型,我就不需要指定它了。然后我直接去指定了第三个模板参数,也就是分配器。我想,在 C++20 和当前的标准库实现下,我仍然会得到同样可怕的错误信息。所以,是的,不幸的是,编译器开发者确实会时常尝试改进错误信息,但这很难。确实如此。
Klaus: 谢谢你。
Jonathan: 好的。
好,回到打印类型的话题。一种可能性是,语言中有一个特殊的变量或符号,我想大多数编译器现在都支持,也可能是标准的一部分了,叫做 __FUNCTION__
。如果你在任何类型的函数内部使用它,你会得到一个函数名的 const char*
。我想它不仅会打印出函数名,还会把参数类型作为字符串的一部分包含进去。所以我们或许可以用这个。但很不幸,当我尝试时,它不会显示模板参数。所以我们无法知道模板类型是什么。我曾希望通过编写一个名为 print_type
的函数,然后在内部使用 __FUNCTION__
来获取类型,但这行不通。
但是在 GCC 和 Clang 中,有一个类似的东西叫做 __PRETTY_FUNCTION__
。__PRETTY_FUNCTION__
提供了更多的信息,字符串也更长,它实际上会给出模板类型的字符串表示,这非常好。所以我做的是,我写了一个模板函数,它在内部使用 __PRETTY_FUNCTION__
,然后我用一些字符串解析来提取出类型。
总之,这是 __PRETTY_FUNCTION__
。如果你只是在一个普通函数里使用 __PRETTY_FUNCTION__
,你会得到 void print_me(int)
,这很好。但正如我所说,如果我们对一个模板函数这样做,我们会得到 void print_me() [with T = int]
。所以这里我们能看到整数类型。事实上,我想,哦是的,就是这样。因为 print_me
with T = int
,这部分是关键,我现在可以写一个函数,它会去寻找这个模式,然后只提取出 int
这部分。
这就是我写的函数。为了让它在 Clang 中工作,我必须确保传递一个参数。如果你不传递一个模板类型的参数,那么那个模板类型就不会出现在 __PRETTY_FUNCTION__
的符号里。所以我拿到字符串,寻找等号 =
,我知道等号永远不会出现在函数声明中,因为它不是那种语法的一部分。所以我找到等号,丢掉它之前的所有东西,然后我知道我可以根据长度等等做一些字符串运算,提取出我需要的部分。然后在 GCC 中调用它时,它会打印出这部分,在 Clang 中会长一点,但它会显示出你的类型,这正是我们想要的。
/* 生成代码,仔细甄别 */
// 示例代码,用于从 __PRETTY_FUNCTION__ 提取类型
template<typename T>
void print_type_from_pretty() {
std::string pretty_func = __PRETTY_FUNCTION__;
// ... 解析字符串 ...
std::cout << extracted_type << std::endl;
}
所以,如果你想在运行时找出类型是什么,你可以用这个方法。但通常我们甚至不知道类型是什么,或者说我们的代码根本就编译不过。所以我们需要在编译时找出类型。Ivan Čukić,我之前在他的《Functional Programming》一书中提到过,他提到了一个在编译时推导类型的技巧。他的做法是,基本上是生成一个错误。他有一个只有声明没有定义的模板,然后他尝试在他的 print
模板中实例化它。这会导致一个错误,编译器就会停止工作。这个方法的一个坏处是它确实会生成一个错误并停止编译。不过这也不是太糟糕的事情,因为通常你遇到了问题,你只是想临时加入这个来找出你得到了什么。
他是这么做的。他声明了一个模板类 print_me_as_error
,但只有声明,没有定义。然后当我们用像 print_me_as_error<std::vector<int>::element_type>
这样的东西去实例化它时,这就会产生一个像这样的错误:“class print_me_as_error<int>
invalid use of incomplete type”,因为编译器无法实例化这个 print_me_as_error
。
但有一个更好的方法,它不会产生错误,而是生成一个警告。这样它仍然会出现在编译器生成的消息列表中。为了做到这一点,我们使用了一个技巧,这个技巧是 Daniel Frey 告诉我的。他将一个类型声明为 deprecated
(已弃用)。当它被实例化时,编译器会说,“哦,这个被弃用了,你应该知道你不该用它”,于是它会发出一个警告。他用了一个标记为 deprecated
的 constexpr
值,实际上他用了一个布尔值,这个布尔值可以让你通过 static_assert
来触发。这是一个模板变量,它会生成警告,但编译会继续。
/* 生成代码,仔细甄别 */
// Daniel Frey 的技巧
template<typename T>
[[deprecated]] constexpr bool print_type = true;
// 使用方法
static_assert(print_type<MyType>); // 编译器会发出关于 print_type<MyType> 的警告
这就是代码。你有一个名为 print_type
的模板变量,我们把它设为 true
,并标记为 deprecated
。当我们对它进行 static_assert
时,它就会生成一个警告信息。我们就能看到类型是什么。我们知道内部类型 std::vector<int>::element_type
实际上是 int
。所以这就是推导出的类型。这很好,而且相对简短。
这是其中一种方法。大约在去年十一月,我在 Meeting C++ 大会上做了这个演讲。几周后,我在写代码时突然茅塞顿开。我想,哦,我不喜欢必须调用 static_assert
。这是额外的代码。我不想这么做。那我为什么要用一个模板变量呢?我应该用一个模板函数。然后我就可以调用函数,而不必再调用 static_assert
了。我觉得这很好。当然,我对自己很满意,就在 Twitter 上发了推文,结果收到了一大堆人的改进建议。我们玩得很开心。所以我要分享一些这些改进。
首先,把变量模板改成函数模板。这是我的 print_type
函数,它什么也不做,被标记为 deprecated
。当我调用它时,我把类型作为模板参数传入,然后它就生成了消息。没有 static_assert
。这看起来……我总觉得在使用之前那个 deprecated
技巧时,很难记住要用 static_assert
。所以这是一个改进。
第二个改进,来自瑞典的 Björn Fahller 建议使用可变参数类型模板(variadic type template)。这样你就可以一次打印多个类型。他有一个库,是一个模拟框架(mocking framework),哦,名字我忘了,是个法语词,我现在想不起来了,抱歉。
Klaus: Trompeloeil。
Jonathan: 非常感谢,Klaus。Trompeloeil。
他的那个库 Trompeloeil
就使用了这个技巧,作为他调试代码时的辅助手段。所以这肯定是一个有用的东西。要做到这一点,你只需加入 typename... Ts
。当我们有两个模板参数时,我们会得到关于两者的警告。它会推断出类型一个是 int
,另一个是 bool
。
然后我想,哦,是的,但如果你没有类型,而是有变量,并且不确定变量的类型怎么办?我想,好吧,我应该写一个函数,它接受任意类型的函数参数,而不是模板参数。然后通过函数参数,我就可以获取类型。我仍然可以把它标记为 deprecated
,我觉得这会很棒。是的,这是我的好主意。但很不幸,这是个非常愚蠢的想法。原因是它因为参数衰变(argument decaying)而无法工作。当你调用函数时,C++ 会获取事物的实际类型,然后在调用时对它们进行一些小小的改变。我无法更好地解释了,抱歉。但是像 int
引用或 int
右值引用会变成 int
引用等等。
我给个例子。这是我的绝妙想法。我这里有一个通用引用 TS&&
。如果我创建一个右值引用和一个普通变量,然后调用 print_type
,不幸的是,它显示的类型是 int&
和 bool&
。而我期望的是 int&&
和 bool
。这就是参数衰变过程的一部分。而且情况更糟,我试了各种方法,但似乎都不行。所以,这个想法是个坏主意。
好了,关于打印类型就到这里。下一个部分,我将转向如何可视化你的模板代码,看它实际上产生了什么。当然,有这个由 Andreas Fertig 创建的绝佳网站,cppinsights.io
。如果你没见过,它就像 godbolt.org
。事实上,如果你在 Godbolt 上,有一个链接可以点击 cppinsights
。它会把你输入的代码带过去,然后你可以在 cppinsights
中运行它。它做的是生成更简单的 C++ 代码,而不是汇编代码。这种更简单的 C++ 让你能看到,比如,它是如何处理基于范围的 for 循环的,它是如何进行模板实例化的。这是一个非常好的网站,当你抓耳挠腮想知道你的代码到底做了什么时,这是一个很棒的工具。
我知道过去它在支持的编译器数量上不如 Godbolt 那么及时。但我昨天查了一下,它支持 GCC 10.2,支持 Clang 11。我相信它也支持最新的 MSVC,但我不属于 MSVC 那个圈子,所以我无法告诉你任何相关信息,但他确实增加了更多不同的编译器。所以这可能会有帮助。
这里是一个例子,是我库里的一些代码,我可以对一个 payload 对象,比如一个 POD(Plain Old Data)对象进行版本控制。我可以有多个版本,因为我的数据库需要能够管理,需要能够读入那些对象的旧版本。比如那些六个月前创建的对象,结构已经改变了。所以我用这段代码来实现这个功能,我可以从一个版本的 payload 对象升级或降级到另一个版本。但你可以看到,左边是我的实际代码。这里是 Person
类的第二个版本,有名字、姓氏、出生年份和 ID 号。我们还有一个 downgrade
函数,它会生成一个更早版本的 Person
类。如果我看一下我的 downgrade
函数,这是实际的代码,它适用于任何版本。当我实例化它时,它在右边向我展示了编译器在底层实际生成的代码。
所以,如果我从版本 1 降级到版本 0,它会检查 if constexpr (1 > 0)
,然后我执行降级。你可以看到,对于这个函数模板的实例化,这里只有一个 if
分支。而在真实代码中,我有 if (version > 0)
就调用 downgrade
,if (version == 0)
就调用 current
,否则就生成一个错误。所以这里的三个分支在展开时,在这个函数模板的实例化中,被转换成了单个分支。这里也有类似的情况。这里我们从版本 0 降级,好吧,在这种情况下,无事可做。所以我只返回同样的东西。这就是生成的代码。所以这是一个非常有用的工具,非常好。但你必须确保你的代码能编译通过,因为如果编译不过,这个工具就没法用。这是唯一的缺点。
这是一个工具。还有另一个工具,我觉得它潜力巨大,但可惜它没有像 Godbolt 或 CPP Insights 那样漂亮的网站。它叫 Metashell,在 metashell.org
。它是一个元编程调试器。它允许你在模板实例化等地方设置断点。但不幸的是,它真的很难用,设置起来很复杂。他们确实有一个网站,你可以在上面输入一些代码尝试一下。但可惜,那个网站用的是一个非常旧的版本……我想……不确定是 GCC 还是 Clang,但它用的是一个旧版本。这对我想要的代码来说没用,因为我的代码太新了。我确实说过,如果有人能做一个很棒的前端就好了。也许最初的开发者会做。但确实有人……如果你能让它跑起来,它可以做……
你可以……它确实有一些……是的,你可以在线尝试。还有一个基于 Metashell 代码的 QT 应用叫做 MSGUI。我想它只在 Windows 上运行。它可能非常棒。我给你们看看。我在这里看到的这个,我觉得太棒了。他们有一个斐波那契……嗯,是斐波那契吗?是的,是斐波那契函数……他们这里有一个斐波那契函数模板。你可以在右边看到它的实例化过程。是的,这可能是一个非常棒的工具。但这个只能在 Microsoft Windows 上运行。所以我没法测试。而且我怀疑它可能和 Metashell 有同样的问题,就是它没有跟上新编译器的步伐。
好的。我们现在就调试部分提问。如果有人有问题的话。
Klaus: 聊天区讨论很热烈,但没有新问题。不过,我完全理解你为什么记不住那个法语名字。那个确实难记。不是一个特别上口的名字。
Jonathan: 是的。其实,我想……是的,我唯一一次遇到 Trompe-l’œil 是很多年前在纽伦堡。我去参观了施佩尔设计的大体育场。他们曾试图炸掉它。但显然,那个地方到处都是这种视觉陷阱效果,让它看起来更大。
Klaus: 好的。刚才有一个问题,不过不是 C++ 特定的。就是幻灯片之后会提供吗?
Jonathan: 幻灯片已经在我 GitHub 账户上了。所以,我会确保……嗯,之前版本的幻灯片已经在线上了。我会确保在 Twitch 或你们的……我马上把链接发过去。
Klaus: 好的。现在人们被提醒可以提问了。Peter 问,Metashell 如何与模板值参数的使用保持一致?
Jonathan: 我完全不知道。说实话,我从没成功运行过 Metashell。我只是发现有这么个工具。但要让它在我的机器上跑起来太难了,我放弃了。而且……仔细想想,我想它底层肯定用的是 Clang。它只是钩入了编译器。你得修改那些……你得修改编译器,然后……非常困难。这就是为什么它需要……如果你有一个网站,有人已经完成了让它在系统上运行、编译、构建和工作的艰苦工作,那么每个人都可以共享它。我的意思是,这就是 Godbolt 的伟大之处。我不需要为了在某个版本的编译器上尝试代码而去安装那个版本。所以这……但这会……我不知道。
Klaus: 好的,明白了。
Jonathan: 但它确实有钩子能介入所有模板实例化的东西,所以你能知道当你的模板代码发生什么时,当编译器在做什么时。这是一个很棒的想法。
Klaus: 谢谢。
Jonathan: 好的。我们继续讲测试。
测试¶
好的。测试……嗯,最简单的测试……你仍然可以对你的模板代码进行运行时测试。就像你可以运行……是的。我这里用的是 Catch2。我有一个函数模板 double_it
,它接受任何整数类型。我在这里指定了 C++20 的 concept。然后我就可以写我的测试用例。我可以说 REQUIRE(double_it(3) == 6)
。这个应该会通过。这有点像……是的。跟你仍然可以调试模板代码一样,你也可以用这类测试来测试它。而且你可能应该写这类测试,以确保一切正常。
但有时你想进行编译时测试。所以你实际上想在系统编译代码的时候就做这些事。我们将使用 static_assert
。它源于 Boost 的静态断言库,那大概是……嗯,2000 年以前的事了。所以它很老了。我猜那时是用一些宏实现的。它是由 Robert Klarer 和 John Maddock 博士提出的。那是最早进入 C++09 标准……抱歉,是 11 标准的东西之一。不好意思,记错了。所以他们有了 static_assert
关键字。我相信你们大多数人都很熟悉这个。你给它一个布尔表达式作为第一个参数,而且它必须是一个常量表达式。否则,它就行不通,你会得到一个编译器错误信息。然后你可以加入一些错误文本,如果布尔值为 false,这些文本就会出现。
在 C++17 中,因为人们经常给出一个布尔表达式,然后又把那个表达式的文本作为错误文本重复一遍,所以 C++17 委员会决定让错误文本变为可选的。所以这里有个例子,static_assert(false);
,这会产生一个错误信息。如果你在代码里这么做,你可以直接写。我们有我们的函数,然后我们对它调用 static_assert
。如果它不等于 6,那么它就会输出一个错误信息。
那么你可以在什么时候测试它呢?我经常在类型声明的末尾加入 static_assert
,声明诸如“这个东西是可移动的”或“这个东西是不可复制的”之类的事情,这样我就能保持这个属性。如果后来有人去修改代码,他们就会得到一个错误信息,告诉他们这个属性必须保持。所以可移动和可复制属性是很有用的测试点。
你经常在 constexpr
语句或者 if constexpr
语句中使用它。if constexpr
是在 C++17 中引入的吗?C++20 里肯定有。我想 C++17 就有了。但这是我喜欢并且经常使用的东西。我喜欢用它的原因是,你可以在离调用点更近的地方生成错误。这样你就不会让错误信息在一长串调用链之后才出现。这很有用。这可以减少错误信息的长度,但并非总是如此。
这是我之前在 CPP Insights 例子中用过的版本化数据的例子。我们把 Person
声明为一个版本化的类型,我们给出了第 0 个版本的 Person
的实例化,只有一个名字和姓氏。第一个版本有名字、姓氏和出生年份。它还有一个 migrate_down
函数,可以回到更早的版本。所以这是我的两个结构体。然后我有我的 versioned_data
和我的 downgrade_once
函数模板。我接受任何版本的 Person
,然后进行一次降级。我使用 if constexpr
,如果 n
等于 0,那么我可以返回这个东西。如果它有一个 migrate_down
函数,我就调用它。否则,我就要做一个 static_assert
。
现在,在这里使用 static_assert
的问题是,即使它在 if constexpr
的 else
分支里,编译器并不这么看。编译器说,“哦,这个 static_assert
函数里有个 false
。它求值为 false
。这意味着我在这里输出一个错误。”所以它总是会产生一个错误,不管我传递给 downgrade_once
函数的参数是什么对象。所以这并不怎么有用。
有一个技巧可以避免这个问题。这个技巧你可以在 CPP Reference 网站上找到。他们在 variant
的 visit
函数的说明中提到了它。他们在那里指定了一个 constexpr
的变量模板,它总是 false
。但因为它是一个模板,所以只有在被实例化时才会被触发。而在我们之前的代码里——让我回到上一页。是的,在我们之前的代码里,如果我们把那个 false
改成 always_false<Person<n>>
,那么因为它只有在需要被实例化时才会被实例化,所以它就不会在 n
等于 0 或者 Person
对象有 migrate_down
函数时被实例化。
/* 生成代码,仔细甄别 */
// 总是为 false 的变量模板
template<class>
inline constexpr bool always_false = false;
// 在 if constexpr 中使用
if constexpr (/* ... */) {
// ...
} else {
static_assert(always_false<T>, "Unsupported type");
}
这就避免了我们的问题。所以这是很多模板代码在测试时使用的一个非常有用的技巧。好的,这是在编译时测试。
最后一组测试。这是《C++ Templates》那本书里实际描述的东西。它描述了一种叫做 archetype(原型)的类。Archetype 试图测试你的代码是否能在支持最低限度行为的情况下工作。它就是为了测试这个。是的。好的。它最早是由 David Abrahams 和 Douglas Gregor 提到的。我想 David Abrahams 后来去为苹果写 Swift 或参与 Swift 的工作了。我不确定……总之,这个想法很老了。至少有 20 年的历史了。
有人可能会说,concepts 的出现让它过时了,但不完全是这样。所以它仍然很有用。事实上,有一位波兰的开发者——让我看看我能不能念对他的名字——Andrzej Krzemieński。他有一个非常有趣的博客,大概一个月或两个月更新一次。他最近写了很多关于在 concepts 上使用 archetype 的有趣文章。他有一个系列的三篇文章。我建议你们去读一读。他在那里描述了如何为 concepts 使用 archetype,但这和直接在模板代码上使用它们非常相似。他还解释了你为什么要使用它等等。内容很好。
所以它们是什么呢?它们总是你创建的非常非常小的类。通常你会确保删除功能而不是包含功能。这是因为你想确保你的代码能在满足最低要求的情况下工作。这里有一个函数,它在一个数组中查找某个东西的索引。它非常简单。如果我们想测试它,让我回到这里。所以如果我们想用 archetype 来测试这个,我们需要检查 T 上有什么约束。我们对 T 做的操作,这里我们有一项是比较 T 的两个值。我想……是的,这是我们这里唯一的约束。所以我们需要能够比较两个值。
为此,我们需要创建一个 EqualityComparable
的 archetype。所以它没有……所以这将是我们的 T 类型。我们得说,我们有一个 operator==
,它返回一个布尔值。它接受两个 const
引用。但这还不够。让我回到这里。这说的是返回布尔值。但标准里没有任何规定说你的相等运算符必须返回一个布尔值。它必须返回一个可以被转换为布尔值的东西。这是不同的。所以这里我们创建了另一个 archetype,ConvertToBool
,它有一个转换运算符,返回一个布尔值。而我们的相等运算符返回的是这种类型的东西。
当你把所有这些都放进去,然后我们尝试实例化这个 find
,不幸的是,我们得到了一个错误信息。错误信息是我们不能……没有……我们不能使用 operator!=
。我们得……!=
不起作用。我们得修改我们的代码来避免使用 !=
。
提问者: Archetype 被 concepts 取代了吗,还是它们仍然有用?
Jonathan: 从阅读 Andrzej 的博客来看,我会说它们仍然有用。所以,是的,你可能需要用它们的频率降低了,但它们仍然有用。是的,你不需要那么频繁地使用它们,但绝对有用。
所以,如果我们修改……抱歉,回到代码。当我们在这里使用 !=
时,我们得到了一个错误信息。所以我们必须改变求值方式。我们只调用 ==
运算符。所以我们只是用一组括号把它包起来,在前面加上 !
,然后就可以了。就像,《C++ Templates》这本书里有一个例子,比这个更深入。如果你感兴趣,可以去那里读,或者读 Andrzej 的博客。它们都很有用。
好了,关于测试就到这里。还有其他问题吗?
Klaus: 没有新问题。你已经回答了刚才那个关于 archetype 的问题。
Jonathan: 好的。是的。我想……这不是我经常用的东西。我用过一两次来测试东西,但我想我很幸运,因为我用的是 C++20,我可以在我的代码里使用 concepts,而且它似乎能工作。但是,是的,这取决于你的编译器,取决于你运行的版本。
基准测试¶
好的。最后一部分,我们来看看基准测试,让事情变得精简高效。基准测试不仅是优化我们的模板代码,也是优化我们的构建时间。因为这是人们一直提到的一个问题,就是一旦你开始使用模板,你的构建时间就会变得越来越长,它们会不断膨胀,人们会因此感到非常沮丧。所以有一些工具可以帮助我们避免这个问题,也有工具可以帮助我们衡量它。
提问者:
always_false
是标准允许的吗?
Jonathan:always_false
不是标准的一部分,但它就像两行代码,你就可以使用它。所以……是的,我想标准委员会可能觉得它太简单了,不需要放进标准里。
好的。Tracer。Tracer 是《C++ Templates》这本书里提到的另一个特性,另一个工具。它们被用来测试你的实现有多好。你调用特定构造函数的频率是多少?在模板代码中调用特定函数的频率是多少?这可能非常关键,因为它是模板代码,你不确定代码会创建什么样的实例化,你不确定那些代码都会做什么。所以,你能够写一个测试来检查你没有创建过多的临时对象,没有过多地复制东西,这一点非常重要。
事实上,前几天我有一段代码,是从一个线程向另一个线程传递消息。我有一个类,它是一个队列。当我想发送消息时,我向队列的末尾追加一个对象。然后读取的另一个线程会查看那里并把它取走。我想检查构造函数被调用了多少次。最后我发现我调用了大概四个移动构造函数。我觉得移动构造函数还行。但我也发现了一种方法……我能看到一种方法可以把它减少到三个。但重要的是,我已经写了一个测试,现在我知道情况不会变得更糟了。
后来的人,或者如果我自己去修改,测试会被运行。如果我破坏了什么,它会告诉我我变慢了。这段计算调用的代码,我在 GCC 的库代码里见过类似的版本。不幸的是,我大约一年前看到了,并在脑子里记了一下。然后,当然,想再找到它对我来说是绝对不可能的。所以,不幸的是,它就留在那了。有一些类……有一个 tracer 类,大概有六个不同的成员函数,都用两个字母的名字来代表它正在计数的不同类型的构造函数。我之所以能认出这段代码,只是因为我知道 tracer 是什么。但这是一个更可读的版本。
我们有一个 TracedItem
结构体。在里面,我们为所有我们要计数的东西设置了静态计数器,记录那些函数被调用的频率。所以我们有默认构造函数的数量,拷贝构造函数的数量,移动构造函数的数量。然后我们的默认构造函数只是递增那个静态计数。我们的拷贝构造函数递增拷贝构造函数数量的静态计数。移动构造函数对移动计数做同样的事情。然后我们就可以测试我们的 vector
有多好,比如当我们调用 emplace_back
或 push_back
时,有多少构造函数被调用了。
对于这个特定的函数,当我们调用 emplace_back()
时,它调用了一个默认构造函数。如果我调用 push_back(TracedItem())
,我们有一个默认构造函数和一个移动构造函数。然后 emplace_back(TracedItem())
也是一个默认构造函数和一个移动构造函数。所以我想……在这个特定的例子中,不带参数的 emplace_back
是最快的。push_back
也还行。但通过这个,我们实际上可以计数。你可以写一个测试,说默认构造函数的计数小于等于 1,移动构造函数也一样,等等。所以这很有用。而且很简单,这很好。
接下来是比较构建时间。我曾经写过代码——我相信我们都写过——编译需要几分钟甚至几小时。如果你见过……嗯,是的,其中一部分是实例化的数量。有过各种各样的演讲。Odin Holmes 做过这方面的演讲。他偶尔会提到……我想他在某个地方有一篇博客文章。现在是一篇很老的博客文章了,他在里面描述了哪种类型的实例化编译起来比其他类型更快。
好的。编译器有标志来衡量这个。我知道大约六个月前,Microsoft Visual Studio 推出了一个新工具叫 Build Insights。我不是微软用户,我不用 Windows,所以我不在他们的技术领域。但它看起来是个非常酷的工具。所以如果你用微软的东西,去看看吧。还有一个是 buildbench.com
,由……是 Fred Tingo,一位法国开发者,我想是。它也是与 Godbolt 和 Cpp Insights 同一系列的网站之一。它允许你有两个代码样本,并比较它们构建需要多长时间。
最后,还有 Metabench。Metabench 工具是一种衡量和绘制编译时间变化图的方法,当你创建代码的多个不同实例化时。比如你开始测试 std::tuple
,用一个参数,两个参数,10 个参数,50 个参数等等。Metabench 会为你的每个不同版本的代码实际运行编译器。然后它会给你一个漂亮的图表,这非常酷。
第一个简单的事情是,你可以传递额外的标志,看看它们的作用。比如 -ftime-report
。抱歉。-ftime-report
会给你一大堆统计数据。我想这主要是给编译器开发者自己看的。但对我们来说,它输出的各种变量、统计数据中,我发现最有趣的是模板实例化时间(template instantiation time),常量表达式求值(constant expression evaluation),和约束满足(constraint satisfaction)。我甚至不确定约束满足是否重要。但模板实例化是重要的。所以你可以看到在那个领域花了多少时间,以及它消耗了多少内存。所以这可能很有用。然后是 -fmem-report
,类似的功能。我没觉得这个特别有用。但,你知道,也许它有用。
这是一张 MSBuild Insights 的截图。我相信它告诉你编译器在实例化特定的结构体、特定的函数模板等等上花了多长时间。然后是 BuildBench。我喜欢 BuildBench。我看到,实际上,我看到 Bartłomiej Filipek 今天发了一条推文。他今天早上用 BuildBench 比较了三种不同的实现,我想是关于访问 std::variant
或使用 unique_ptr
的。是的,它是由 Fred Tingo 创建的。它就像 Godbolt。你可以尝试它。你可以比较同一段代码的多个版本,看看哪个编译效率最高。
你会得到关于编译时间和进程大小的漂亮图表。这是我自己代码的一个例子。我写了一个 constexpr
函数,来检查一个整数序列中的所有 ID 是否都是唯一的。我写了几个实现。在 C++20 中,有趣的是,其中一个实现是你把 ID 扔进一个数组里。然后你可以在 constexpr
时间里对你的数组调用 sort
,调用 std::sort
。现在这是允许的。至少 GCC 10.2 允许了。当我做这张幻灯片时,我想那个网站只支持到 10.1。所以我没法测试,很遗憾。但是,是的,这很有趣。你可以看到一个实现,和手写的另一个实现。然后你能看到编译速度的比较。你也能看到内存。它也会显示内存。所以这是一个很好用的工具。但同样,它假设你只有几行代码,一小段代码来测试。但它让你很容易看到事情是否工作得好。
最后是 Metabench。这是 Louis Dionne 开发的一个工具。据我所知,这是少数几个用 Ruby 编写的工具之一,Ruby 是我最喜欢的语言之一。大多数工具似乎都在用 Python,我想原因很明显。但它做的是,它计时你程序不同版本的编译时间。
Odin Holmes,我之前提到过,他认为这是过去十年对模板元编程最有用的贡献。所以这是一个很高的评价。Odin 在模板元编程方面比我懂得多。可能比我们大多数人都懂。所以这是一个值得一看的好工具。
在这个项目里,我把它放在我的 GitHub 账户上了。所以你可以在那里看到所有的代码。但你必须设置一个 CMake 模块路径指向你的 Metabench 应用。然后,是的,你包含这个 metabench.cmake
。这是关键。你必须设置一个数据集。你为每个……嗯,我有两个实现,但我想设置,我想检查当我使用 sort_unique
时会发生什么。这里的这段代码是一小段 Ruby。它基本上创建了一个从 1 到 40 的序列。然后把它转换成一个数组。所以我可以在这个文件里使用它,这个文件会生成 C++ 代码。但它用了一种叫做 ERB 的东西,是 Embedded Ruby 的缩写。它在 Ruby web 框架中被广泛用于生成 HTML 之类的东西。你可以做循环和各种事情。但 Louis 用它来让你创建 C++ 代码。我们对手写的版本也做同样的事情。我们从 1 到 40。Ruby 里有各种语法让你跳跃,比如每 10 个取一个。或者,你知道,你可以做各种事情,因为它就是 Ruby。最后,你在 CMake 里调用这个 metabench_add_chart
命令。它会根据这两个数据集生成一个图表。
这是我们的 Ruby 文件。这是我们的 C++ 文件看起来的样子。你必须加上这个 #if defined(METABENCH)
。因为这里面的东西不是 C++。它有点像 C++ 和一点这种嵌入式 Ruby 的混合物。所以它无法编译。Louis 的工具所做的就是寻找这些 #if defined(METABENCH)
。然后它用 Ruby 运行这里面的东西,生成一个可以被编译的真正的 C++ 文件。所以这里有一点……我们取 1 到 n
。n
是一个会传入的参数。我们把它转换成一个数组,反转它,然后连接起来。我只是想看看 tuple
或者……嗯,是的,tuple
会怎么样。所以那是 Ruby 的部分。当你用 n
等于 10 这么做时,这是它会生成的 C++ 代码。你会得到 10, 9, 8, 7 等等。
所以当你运行它时,这是它运行时的样子。你看到你得到了百分比,它从 n=1
到 n=40
。然后它生成图表。就这样。我得到的图表是这样的。但是这个……当我做这些幻灯片时,我一定是慌了,事情似乎不太顺利。所以我做了一个非常简单的版本。我没能测试我自己的真实代码。所以……但它现在似乎能工作了。所以它……正如我说的,它在我的……在我的 GitHub 账户上。
就是这样了。现在如果有问题的话……我们基本上讲完了。
Klaus: 首先,非常感谢。我想我们学到了很多。也得到了很多新的参考资料和工具。现在没有新问题了。但我们等几秒钟。也许人们还在……
Jonathan: 好的。那我总结一下结论。
Klaus: 好的。
总结¶
编译: 是的。阅读你的错误信息。这是我能说的。这可能是最好的建议。这不是一个很好的建议。对于一些代码,你可以用 Camomila 来简化你的错误信息。我猜对于一些代码,你可以让你的编辑器来格式化声明。然后……是的。就在你的编辑器里用标准格式化工具。那应该也行。但在我的代码上,它没怎么帮上忙。
调试: 你可以用你的普通调试器。我们看了用
deprecated
技巧来打印推导出的类型。我们给它加上了可变参数模板。然后我们把它从一个变量模板改成了函数模板。我想这可能是最有用的。Code Insights… 抱歉。Cpp Insights。那是……Andreas Fertig 的 Cpp Insights 是一个了不起的工具。真的,非常有用。然后是 Metashell 和 MSGUI,你知道,它们有潜力。但它们需要有人去爱护和改进它们。测试: 你仍然可以用你旧的……你普通的单元测试。我们看了
static_assert
。我们也看了……当你在if constexpr
里用static_assert
时,你需要用always_false
技巧来防止那个实例化。防止总是生成错误信息。然后我们看了 archetype,帮助测试模板和 concepts。确保代码能在最低要求下工作。基准测试: 我们看了 tracer 类来计数函数调用。我们看了编译时基准测试。我们看了各种标志,编译器标志。有 BuildBench,它可以很好地为你绘制图表。你可以轻松比较不同版本。然后我们看到用 Metabench,Louis Dionne 的工具,你可以生成一大堆不同版本的 C++ 代码。这样你就可以用 10 个参数,50 个参数,100 个参数来实例化某个东西。你可以看到你的代码表现如何,编译器表现如何。你也可以比较不同版本,看看哪个在重负载下更好。
就是这样了。还有几件事。Stack Overflow 也……总是很有用。还有 #include Discord 服务器。我过去四五个月偶尔会用。你去那里,人们非常友好,非常乐于助人。他们给你非常好的答案。而且他们做得非常快。太神奇了。还有 Godbolt,一如既往。
最后,我一直想说这个。Conor Hoekstra,你知道的,羡慕去吧。这是一个旋转!
[笑声]
就是这样了。谢谢。
Klaus: 好的。非常感谢。没有更多问题了。但现在也许是时候再次指出,我们有一个会后聊天环节。是的。人们已经宣布他们会在会后聊天环节向你提问。好的。我们现在会把会后聊天环节的链接发到……哦,抱歉。刚刚发到我们的聊天里了。所以我们非常希望能在那里见到尽可能多的人。人越多越好。好的。再次感谢 Jonathan 的精彩演讲。对于所有不加入我们的朋友,我们下次再见。祝你们有个愉快的夜晚。
Jonathan: 祝你们有个愉快的夜晚。再见。拜拜。