不在翻译中迷失¶
标题:Not getting lost in translations
日期:2025/01/10
作者:Daniela Engert
链接:https://www.youtube.com/watch?v=rxBQIQFQirE
注意:此为 AI 翻译生成 的中文转录稿,详细说明请参阅仓库中的 README 文件。
我的演讲题目是《不在翻译中迷失,以及这一切到底是关于什么的?》(Not Getting Lost in Translation and What is It All About?)。我将谈论我所在公司的一台机器,它随着时间的推移是如何演进的,特别是针对这里的这台机器。
你可能会问,这台机器是用来干什么的?这其实是我们公司为客户建造的一台质量检测机器。具体来说,这一台是用于铁路轮轴检测的。它看起来是什么样的呢?这是一个货运列车车库的内部视角。在中间,这个橙色的东西是一个轮对,包含轮轴和两侧的车轮,但这不是我们今天要讨论的重点。那太简单了。我们探讨的是这些特定的列车,以及我们将用这些机器检测的轮轴。正如我们在底部看到的,这些轮轴的几何结构要复杂得多。在中间,有一个不受轮轴运动应力影响的孔。我们可以将传感器推入这些轮轴内部,从内部检查轮轴的无缺陷性。这就是这台机器的作用,也是我们软件要处理的核心业务。
我们公司的起源实际上源于大约 25 年前发生的一场火车事故。那是德国历史上最大的一起火车事故,很多人因此丧生。当时制造这些轮轴和车轮的是一家兄弟公司,他们之所以能免于受罚(逃过一劫),唯一的条件就是承诺:研发出一台能够自动完成这种检测的机器。这就是公司的创立故事,也是我设计这台机器、开发软件以及你在之前图片中看到的大部分内容的原因。在这里,你可以看到几个装满各种安装件的轮轴大概长什么样。所以现在,你可能对这台机器到底是干什么的有了一个更好的了解。
公司成立于 2009 年,当时可以说是一无所有。一切都是从零开始发明和建造的。当时公司规模很小,大概只有屈指可数的几个人。所以我们的目标是:实现功能,实现更多功能,打响名气;而我的目标是:把架构弄对。如果你一开始的架构很糟糕,随着时间的推移,你是无法进行演进的。
早期的架构选择与 Boost.Format¶
所以我必须决定的事情之一,就是我们实际上该如何格式化我们的字符串。这是一个常见的任务。我们有很多选择,但我决定使用 boost::format。它是当时我能找到的最先进的库。在底部你会看到,我们有包含所有文本和占位符规范的格式化说明符,然后是一系列用于填充这些占位符的格式化参数。
好的,这是最开始的情况。让我们把时间往后推移五年。当时我们迎来了第一个非德语客户。所以我遇到了一个问题:我该如何支持捷克语?
走向国际化:引入 Boost.Locale 与 GetText¶
解决方案是使用 boost::locale。但随之而来的问题是:我们该如何进行字符串翻译?我们有几个必须回答的问题。例如,我们应该使用哪个翻译系统?它有多成熟?翻译系统有配套的工具吗?它适合我们的软件吗?这是一件非常重要的事情。但最重要的问题是:我们的最终用户能使用它吗?
关键在于,最终用户实际上是领域专家。我们可以制造机器,我们可以编写软件,但最终用户才是领域专家,而领域专家通常并不是编写软件的人。但我们可以建立一个属于领域专家的领域。那么翻译格式应该是什么样的?二进制?对我们来说很好,但对最终客户不行。XML 也是一样,甚至 JSON 在某种程度上也是如此。这么长时间以来,我们有这么多的客户,我们代码中使用过或分发出去的每一种格式,字面上来说,每一个都被搞砸过。唯一从未被搞砸过的格式是:文本文件。
因此,我们决定在翻译系统中使用文本文件。而这恰好也是 GetText 作为一个库所支持的格式。这是非常标准的做法。你可以在许多许多计算机语言中找到它。对于 C 或 C++,我们有 GNU GetText 作为实现,你可能对它很熟悉。Linux 用户有 libintl 库,这是它的实现。而在 C++ 中,我们有 Boost.Locale。是的,这个选择并不差。就在三个月前,它实际上被 POSIX 和 ISO 双双标准化了。所以现在我可以自豪地说,我们的软件中有一个标准的翻译系统了。
那么它是如何工作的呢?如果你想在你的软件中加入翻译系统,你要做的第一件事就是,你需要某种方法来标记你要翻译的文本。所以,从这行代码开始,我会将其改成另一行特定的代码。这里的格式化说明符在语法上被标记为需要翻译的内容。
在下一步中,GetText 工具链自带的一个工具会遍历所有的源文件(可能还有构建系统指示的其他文件),扫描这些语法标记。最后,我们会得到一个所谓的 PO 模板文件(POT)。这只是它找到的所有待翻译文本的集合。这里的原始字符串只是翻译的基准,它不应该被固化存储,它只是瞬态的。
接下来,我们或我们的客户会被要求翻译这些字符串。这些翻译可能取决于工人实际居住并使用该语言的国家。在某些情况下,取决于地区。甚至在某些情况下,特定公司有自己的行业黑话(jargon)。所以我们必须把所有这些都考虑进去。
在翻译过程之后,我们为每个国家、地区或公司得到了一堆文件。这个文件被称为 PO 文件(Portable Object,可移植对象文件)。这是根据客户需求量身定制的,而且它是稳定的。所以我们可以把它签入到我们的代码仓库中。正如之前提到的,对于每一种翻译、每一种语言、每一个国家,我们都有许多这样的文件。
这样的文件看起来大概是这样的。我们在顶部看到这样一个 PO 文件的头部,然后是一系列翻译。特别是这一个,是针对捷克语的。这里面另一个有趣的信息是,每种语言都有不同的规则来计算到底该使用哪种语言形式(language form)。在这里的捷克语中,我们有三种语言形式。这被称为“三种复数(three plurals)”。我们有一个公式(prescription),指示如何从一个数值推导出我们应该使用哪种语言形式,以此来填充或选择对应的文本。在这里,我们不仅有像我们日耳曼语系那样的单数或复数,在捷克语中,我们还有双数(dual)。
在最后一步,我们必须编译这些可移植对象(PO)文件,并将它们链接到最终的输出中。我们有额外的工具来做这件事。而这里的最后一步引出了我们的第一个核心见解:该工具能够理解字符串来源的语言。里面有标记表明“这是一个 C++ 格式化字符串”。而且 msgfmt 工具理解里面格式化规范的语法。它不仅理解 C++,还理解 Qt、Boost 等等。有多种格式。对于其他不同的语言也是如此,比如 COBOL、Fortran,无论什么都可以。这很重要。
这是一个在构建期(build time)运行的工具,它实际上会检查我们的翻译以及其中的格式化规范。输出的结果通常是一种叫做 MO 而不是 PO 的东西。它是机器对象(Machine Object),专门为机器使用而定制。它的本质只是一个字符串表格的列表。这个格式是有文档说明的,所以我们完全可以依赖这些 MO 文件的格式。里面的字符串是 Unicode 编码的,这正如我们今天通常所期望的那样。
Unicode 规范与复杂的复数规则¶
如果我们查看 Unicode 规范的网站,它看起来是这样的。这正是我们所期望的:字形(Glyphs)以及这类东西。但 Unicode 规范中包含的内容远不止于此。这是我们可以在网页底部看到的东西。里面还提到了一个所谓的通用语言数据仓库(CLDR,Common Language Data Repository)。它包含了大量关于语言、文字、规则等所有东西的信息。特别是,它了解复数规则(plural rules)。
如果你看看这些规则长什么样,你会发现一张巨大的表格。开头就写着:每种语言都是特殊的,每种语言都是不同的。所以不要想当然,去查这些表格就行了。在表格里,你能找到如何命名语言、它区分了哪些类别,以及这些类别的规则是什么。
里面还有很好的图表。在右边,你会看到许多数字,从零到非常非常大。这里提到了几个语系。通过颜色编码,你可以看出这些规则有多简单或多有规律。例如,如果你看亚洲语言,它们根本没有“语言形式”的概念。它们只有一种形式。如果我们看日耳曼语系,我们有单数和复数。如果我们看捷克语(就像我们之前看到的那样),我们还有双数。但这都还算简单。
然后这是布列塔尼语(Breton)。这是此处提到的最复杂的语言形式和公式。单从图表你就能看出,它是非常不规则的。所以这些语言差异和复数形式同样也出现在 GetText 中,就像我们之前看到的。日语、德语、捷克语和布列塔尼语。公式从极其简单的,一直到相当复杂的,布列塔尼语就向我们展示了这一点。
而 Boost.Locale 必须实现所有这些逻辑。
转向 fmt 库与现代 C++ 模板技术¶
但是有一件事让我对 boost::format 感到不满意。就我的口味而言,它做得太多了。它不仅做翻译,还做编码转换(encoding conversion)。
如前所述,如今我们已经习惯使用 Unicode 了。所以编码转换对我们来说其实已经不是个事儿了。在过去它是必需的。但是编码转换有一个很大的缺点:它需要字符串内存分配。对我来说,任何不必要的字符串分配都是混乱的。我不想这么做。所以,尽管 Boost.Locale 很好,但这是我最不喜欢它的一点。
回到字符串格式化的话题,让我们再往前推移一段时间。出现了一个新的格式化库:fmt 库的 fmt::format。我对它了解很多。自 2016 年左右以来,我就参与了 fmt 库的开发。我想,嗯,也许这是一个好选择,可以让我们从基于 Boost 的东西走向更现代的东西。
这就是为什么我不再使用 boost::format,不再将格式化参数的核心类型作为一系列重载运算符(%)来连接。相反,fmt::format 不使用运算符连接,而是使用可变参数(variadic variables)。这就是 format 的本质,这也是 fmt 库的构建基础。但是,许多人并不太喜欢在接口和函数调用中使用可变参数模板。
实际上,format 库有两个接口。第一个是我们在前面看到的带有可变参数模板和模板参数包的强类型接口(typeful interface)。而在底层,接口不再包含模板参数。强类型接口仅仅是**类型擦除接口(type-erased interface)的一层包装。为了做到这一点,我们需要在这里做一些类似类型擦除的事情。或者在这个场景下,它其实不叫擦除,称之为类型分类(type classification)**会更贴切。
为了实现这一点,它几乎使用了你在实现这类库时能想到的所有技术。它使用模板进行类型计算;它使用偏特化(partial template specializations)进行模式匹配;它使用重载决议和转换序列来进行基于转换的元编程;最后,它使用了 SFINAE。
对于那些从未听说过 SFINAE 的人来说,这是一个缩写,代表“替换失败并非错误(Substitution Failure Is Not An Error)”。如果你碰巧有一个模板,而在替换后语法不再正确,编译器只会简单地把这个特定的模板实例化排除掉,而不会报错。
但是所有这些都是有代价的。在 2017 年左右,我的一位朋友对这类技术到底有多昂贵进行了一项调查。他特别提出了这张表格。进行这项调查的是 Chiel Douwes,因此它被称为“Chiel 法则(Rule of Chiel)”。它指出:SFINAE 的使用极其昂贵,而模板本身在实例化和记忆化(memoization,缓存)之后,其开销是极低的。所以如果我们用模板和相关操作做一些事情,请记住这一点。注意你实际上产生了多少个模板实例化,尽量少用它们,而应优先使用它们被记忆化后的版本。
但是在库中,我们全都是手工编写这些东西。如果你自己做,且不熟悉或不适应使用这类东西,请使用现成的库。也许你可以使用 Boost.Hana、Mp11、Brigand、Meta 等等,这里只提几个。但是请务必、务必、千万不要使用 Boost.MPL。是的,就算你有大把的时间,使用 Boost.MPL 也会让你的编译时间疯涨。不幸的是,许多 Boost 库在内部仍然使用 Boost.MPL。
这就引出了一个问题:实际上当我们谈论函数时,我们指的是什么?如前所述,那里有类型计算。这涉及函数。那么什么是函数?回到学校,我们学到:函数是一个从源集合中提取元素,并将它们映射到目标集合中的元素的东西。而这些映射可以是任何东西,真的是任何东西。集合本身不一定非得是数字。这是我们在学校学到的。但数学实际上对源集合和目标集合并没有那么多前提条件。而且映射本身也不需要是一对一的。你可以将源集合中的多个元素映射到目标集合中的一个元素。
如果我们看 C++ 中的语法,我们看到括号里有运行期参数。总的来说,我们编写的大多数代码中,这些真的是运行期参数,但它们潜在地也在编译期有效。我们稍后会讲到这一点。如果我们看尖括号里的参数,这些参数必须在编译期已知。
所以这取决于上下文,以及到底哪个参数是必需的。由于历史原因,我们不能把所有的函数参数都放在圆括号或其他符号里。我们必须对它们进行区分(编译期用 <>,运行期用 ())。
C++20 标准化与 consteval 的挑战¶
让我们继续。再往前推移三年。在 2021 年,我们不仅有了 fmt 库,而且它实际上被标准化了。现在它是 std::format。我们仍然可以使用 Boost.Locale 作为我们的翻译系统。
但在同一年,C++20 采纳了 P2216 提案。在 2020 年的布拉格会议上,我们对 std::format 应该是什么样子有了这样的表述。跟我们之前在 format 库中看到的几乎一模一样。随着这个提案的通过,它看起来变成了这样:现在 format 调用的签名发生了变化。我们引入了一个称为 format_string 的中间类型。这带来了一些影响。
最初,我们接收一个 string_view 作为格式规范。现在,我们接收这个 format_string,而这个中间类型的构造函数接收了 string_view。
这看起来似乎没必要,但我们同时也看到,这个中间类型还将参数的类型包(type pack)也作为输入。因此,借助于 consteval 关键字,我们有机会基于所有的函数参数,在编译期完成大量的工作。这就是 consteval 成为 C++ 绝佳补充的地方。但我们首先需要弄清楚**常量求值(constant evaluation)**究竟意味着什么。
我们需要看看编译器在编译期间做了什么。它必须查看我们所有的声明,并且必须记住它在编译期间看到的一切东西。标识符、实体、声明、定义,凡是你叫得上名字的。还有模板以及它迄今为止看到的所有模板实例化。几乎包含一切。所以我们也需要看同样的东西。这实际上促使了 C++ 模块(modules)的诞生。我看在场没有人参加那个研讨会。好吧。
那么这些代码行实际上意味着什么?我们看到了所有这些黄色的部分。所有这些都涉及常量。所有这些都涉及常量表达式。所有这些都涉及常量求值。这就是常量求值。你在指定数组大小时需要它。你在枚举器(enumerators)中需要它。你在许多事情上都需要它。特别是如果你自己定义常量的话。常量在任何地方都是必需的。
这意味着编译器不仅有一个处理所有“类型(type)”相关事务的子系统。它还需要有一个处理“值(value)”相关事务的子系统。这就是常量求值器(constant evaluator)。
这不是什么新东西。它其实挺古老的。但随着时间的推移,它一直在演进,并在过去几年里大幅增长。它的下一个化身将是反射(reflection)。反射严重依赖于常量求值器。所以在编译器中,我们有“类型”这一侧,这是左边的部分;我们还有“值”这一侧,即右侧的常量求值器。而这两个子系统会相互交互。
回到 P2216 提案。这就是它在库实现中的样子。这段代码来自 MSVC STL 的实现。你会看到在第 7 行和第 8 行,有常量。常量求值。这是此处声明的右半部分。你还会看到对这些常量进行操作的函数。所有这一切都发生在编译期。在第 12 行,你实际上还看到了调用了一个类型函数(type function)。这来自标准库。它是 C++11 的一部分。所以它本身不是什么新鲜事。但是你在编译期能做的事情的数量实际上在不断演进。
这一切意味着:格式化语法和参数类型可以在编译期进行检查。而且它们确实会在编译期被检查。这意味着我们不再在运行期进行这种检查了。这很好。我们不再得到异常了。这就更好了。
但这也有缺点。
如果我们再往后看一年,随着 Visual Studio 中 std::format 改变接口的实现落地,并将其投入生产环境……请注意,我们现在有了编译期检查和编译期求值。这对我们公司意味着什么?
Boost.Locale 死掉了。
我们不能再使用里面帮我们做字符串翻译的函数了,因为格式化字符串必须是一个常量。但是字符串翻译是一件在运行期进行的事情。我该怎么办?我不想退回到运行期去对格式化字符串或其他这类东西进行求值。绝不。我仍然想保留这种能力(编译期检查)。我仍然想标记我的文本以便翻译。甚至更好的是,我想确保决定我该使用哪种语言形式的那个参数,不需要被我放进翻译标记里面,而是作为格式化调用的一个普通参数传进去。这就是我想要达到的效果。
但是,这个中间类有一个在编译期求值的构造函数,没有任何办法能在里面拦截(intercept)一些东西。从中提取出字符串也是一样,我做不到。
破局之法:构建 FormatStringTranslator¶
所以像往常一样,在其中放入另一层抽象(interaction/indirection),提出一个额外的重载,我发明了我自己的中间类型。这被称为 FormatStringTranslator(格式化字符串翻译器)。
从外面看,它看起来和以前几乎一样。但在内部,我现在可以对内容和参数做更多的事情。我可以找出在参数列表中,哪个参数决定了语言形式,计算出如何翻译我的格式化字符串,并检查其一致性和有效性,还可以知道如何翻译以格式化调用的参数形式提供给我的字符串。如果这些参数碰巧是正确的类型,它们也需要被翻译。
所以,这就是 FormatStringTranslator。
和 format_string 本身一样,它也有一个编译期构造函数。我把所有通过翻译提供的字符串都拉进来。那些被标记为可翻译的字符串。这就是我在这里拉取的东西。我只是将单数和复数形式的有效性检查委托给标准库。这就是这段代码在做的事情。这需要大量的工作,我不自己做,我委托出去了。但是在此之上,我需要确保当我提供一个复数形式的文本时,我也需要提供一个复数参数,反之亦然。这也是我可以在编译期检查的东西,而我确实这么做了。
再深入一层。如果我能检查我引入的实际复数格式。但这只是一个中间过程。真正有趣的是,现在我可以基于提供的复数参数得到一些东西。我可以请求翻译器,得到一种形式,这种语言形式来自于控制语言形式的那个复数参数的内容和值。或者,我也可以在没有这些多语言形式的情况下进行处理。
最后,在所有这些中间计算之后,我做的事情是:我只存储一个单一的值。我一开始有三个字符串,其中两个是可选的。一共三个字符串,并将它们在编译期浓缩成一个单一的值。所有这些都可以且只能在编译期完成。我在这里计算出的值,实际上是这三个字符串中所有文本内容的哈希值(hash value)。无论编码如何,我从中计算出的总是相同的哈希值。这就是我在这里存储的值。
而这两个函数是我唯一必须在运行期调用的函数。它接收这个哈希值。它接收几个映射表(maps)。这些映射表来自于读取由 GetText 编译出的翻译文件。我可以查找这些表格,并找出带有正确语言形式的对应翻译。
这给我留下了三个问题:
问题一:在众多参数中,到底是哪一个参数决定了语言形式?
问题二:我该如何访问它?如你所见,这是一个模板参数包或参数包。
问题三:如何弄清楚剩余参数的翻译方式?
最后一个问题很容易回答。做一点类型计算即可。它返回给我一个帮我执行实际翻译的函数。所以我将调用这个函数。而它具体是哪种函数,取决于这个函数参数,也就是这个格式化参数的类型。所以,对于每种类型,我必须决定到底需要哪个函数来翻译它。这就是我在做的。
听起来可能不太熟悉。所以,我打算在这里使用一些变量。左边是一个类型函数(type function),它返回给我一个函数本身。那个函数接受一个运行期参数,并产生该函数参数翻译后的字符串。
C++ 异构序列与折叠表达式的应用¶
在 C++26 中,我们将得到提案 P2663(参数包索引,pack indexing)和提案 P2996(反射,reflection)。这意味着所有这些问题都能极其简单地得到解答。我认为这个语法是正确的,至少在几个月前是这样的。现在可能不是了,目前关于操作符应该是什么样子还有争议,但核心思想就是这样。它可以如此简单。我们只需将我们的类型提升到元(meta)层面,就可以对其应用我们的 ranges 算法,应用 C++ 基于值的所有语言规则,并且你可以直接查看参数包中特定索引位置的元素。极其简单。
但不幸的是,我们现在还没有 C++26。所以我需要一种看起来像 C++26,但实际上能在 C++20 中实现的东西。
这就把我们带到了异构序列(heterogeneous sequences)。你注意到我之前有模板参数包,以及模板类型包。特别指出,类型包是类型的异构序列。处理异构序列的最佳方式是什么?是折叠(folds)。折叠表达式是未来的方向。如果你想多了解一下折叠表达式到底能做什么,我推荐阅读这篇特定的文章(幻灯片展示)。那是一篇上个千禧年(非常久远)的文章了。
利用折叠,你几乎可以做任何你想做的事情。例如,如果我们对同构的值序列(例如值的 vector)和像类型列表这样的异构序列进行比较,我们会看到:
在左边,我们有来自标准库的所有算法。在右边,我们有使用折叠的等效表达。每次我们在左边有一个圆括号 (),我们在右边就有一个尖括号 <>。在左边,我们进行值计算和值操作。在右边,至少在某种程度上,我们有类型函数、类型计算。因此有了这些尖括号。但除此之外,它们全都是一样的。回想一下:什么是函数?一些参数需要放进尖括号里,一些参数需要放进圆括号里。在类型列表中,总有一些东西必须放进尖括号里。而它们输出的结果永远是值。
现在让我们回到第一个问题。我需要一个类似 std::ranges::find_if 的东西,但是作用在一个类型列表上。我该怎么做?
好的。首先,我们定义一个“未找到(not found)”的索引值。也许它应该看起来像这样。在 find_if 中,你需要一个谓词(predicate)。这是一个针对类型的谓词。不幸的是,我必须把它包装成一个类型,但它实际上接收一个 T 并返回一个 bool,表示谓词是否满足条件。
然后我可以写出一个函数。让我们称之为 find_first_index。它接收这个类型列表。接收谓词。并返回给我该参数的索引。我该怎么做呢?
从简单的开始。返回 not found(未找到)。第一个测试通过了。这是一个无符号整数,所以我可以从里面加减常量。目前什么都没变。
现在,把我们的函数参数,我们的函数形参添加进去。在右边,你看到类型列表。它被称为 types。在函数参数的左边,这是谓词。注意,它接收一个 T 并返回一个 bool。因此这里有一个模板的模板参数(template template parameter)。有了类型列表和谓词,我就可以对其进行折叠操作了。逗号运算符(,)提供了顺序保证。现在我有两个通过的测试,还有一个,不,两个失败的测试。
我可以把从谓词返回的布尔值替换为一个三元表达式。到目前为止一切都没变。这只是一种更花哨的说法而已。我还可以引入一个索引变量(index),并将索引应用到我的类型列表中的每个元素上。从 1 一直遍历到我提供的类型的数量(N)。依然,到目前为止没有任何改变。
在最后一步,我用一个**赋值表达式(assignment expression)**替换了 true 分支。赋值表达式的值从 1 变到 N,所以它的计算结果永远为真(如果找到匹配的话,就会把当前的 index 赋给返回值并使后续的三元计算短路/覆盖失效)。
现在,我所有的测试都通过了!我成功找出了在类型列表中,我的复数参数到底在哪个位置。
如果你不习惯这种表达式,尤其是折叠表达式,这听起来可能非常陌生。这看起来就像我是在“把一头固执的驴硬推过马路”。但在 C++20 中,这是我们能做到的最好方式,用来找出异构序列(类型列表)中某个特定类型到底在哪里。这还不算太糟。我希望我们会有一个库来做这件事,让它对你们来说更容易一些。类似于 std::ranges 库。也许你们能提出类似的东西。
好的。现在来看最后一个问题。现在我们有了参数索引。我们到底该如何获取参数列表中格式化参数的值?
我们知道的线索是,它是一个引用的参数包。这意味着我们可以获取它的地址。获取其中每一个参数的地址。并将其强制转换为一个整数(integral)。这是完全合法的。而所有这些整数都可以用来初始化一个数组。现在我们就从一个异构序列转变成了一个同构序列(包含内存地址的数组)。利用我们在上一步中找到的索引,我们就可以索引到这个数组中。把类型转回来,查看参数的值。我们就大功告成了。
这是完全合法的代码。
只是给你们一点提示,看看问题三曾经是什么样的。这种包装起来的东西。这个 translate 函数从哪里来?它只是模板。偏特化模板。和全特化模板。它返回给我们正在寻找的 translate 函数。这就是模式匹配。这就是我们在 1998 年甚至更早就习惯使用的模式匹配。这是简单的部分。
编译期集成测试与自定义后端¶
好的。现在三个问题都解答了。我重载的格式化函数起作用了。希望能行。
我仅仅是在做一个测试。因为我们现在是 2024 年了。正如之前提到的,我不太喜欢 Boost.Locale。而我们有一个非常新且功能强大的 Visual Studio 版本。我们公司只使用 Visual Studio。Clang 和 GCC 也应该能做得一样好。我想进行一个集成测试。所以我不想再使用 Boost.Locale 了。我想要使用我自己的实现,来实现实际执行翻译所需的所有功能。
前面所有的代码只是前端。也就是你放进源代码里的东西。我现在要讲的是后端。以及前端和后端的集成。确保一切工作正常。这是测试代码。
这个集成测试不仅涉及编译器。它还涉及构建系统。因为构建工具在构建系统内。还有字符串翻译工具。这是来自前端的代码:语法标记 translate 包含三个字符串,然后它被 GetText 工具处理。首先我们得到 POT 文件。如前所述,得到 PO 文件。为了让它稍微复杂一点,我提取了这台机器中所有的翻译,将它们链接起来,并从中生成机器对象(MO 文件)。为了让 C++ 能够接收它,我加了一个中间步骤:创建一个 bin 文件,并将其引入 C++ 中。总而言之,这里面大约有 500 个翻译,我在编译期把它们引进来。我们目前还没有 std::embed,但作为 C++ 中的一个编译期函数,我很容易就能实现它。就是将 MO 文件的相同二进制内容作为 C++ 中的编译期常量引入。
接下来,我们有这个 load 函数。这是后端的一部分。我使用的是布列塔尼语(Breton)。因为它是最复杂的语言,拥有最庞大的语言形式公式。这里发生的事情是:MO 文件包含所有这些文本字符串。而复数形式也是作为一个字符串提供的。这会被编译成一个可以被求值的函数。load 函数还会创建我之前提到过的所有的映射表。
同时,这还是 translate 字符串中文本内容的哈希函数。这就是那个单一的值。我实际用来做某些事情的值。
这是我正在使用的测试用例基准。这一百万仅仅是复数公式中的最后一行。我期望得到的是:我获得“语言形式 3”。这就是整个集成测试的内容。
特别要指出的是,我在编译期做这件事。这是第 19 行。我在编译期调用测试。所以你在这张幻灯片上看到的一切。它实际上都是编译期代码。这仅仅是一次求值。
这意味着,如果我能编译这段代码。我就确信我库的实现是正确的。而且我可以在编译期检查它。我不需要任何额外的库来测试这个。编译器就是我的测试框架。
但我在编译期所做的事,我同样也可以在运行期使用。我只需省略掉某些部分。把它作为一个常量引进来。在运行期加载它。做同样的事情。它会给我相同的结果。
结语:坚持编译期检查,拒绝运行期异常¶
那么,所有这些到底意味着什么?
我们有字符串格式化。它依赖于编译期常量。一切都是常量。字符串字面量是常量。我们不能提供运行期值作为字符串格式化的参数。
我们不能、我们绝不想在运行期看到任何异常。我们想确保我们所做的一切,都能在编译期完成。或者至少在构建系统内,在构建期完成。这是你想做到的。
但如果我们想的话,我们仍然需要在运行期翻译我们的字符串。这就是我试图向你们展示的。这仍然是可能的。只是比以前更复杂(involved)了。这就是我一直试图向你们展示的。它比以前更复杂了。以前的库做不到这一点。
但是,只要对 C++20、编译期求值以及如何使用正确的工具有一点了解,你今天依然可以做到这一点。你不必求助于对格式化字符串和格式化说明符进行运行期求值。这(指运行期求值)目前在委员会中正被提议用于 C++26。也就是所谓的 std::runtime_format 之类的东西。但我认为,我们应该坚持编译期检查。并确保我们在毫无防备时不会收到异常。
在我们的机器中,我们想要做的是:确保我们不会因为其中一个意外的字符串而遭受拒绝服务(Denial of Service)。我们可以尽可能多地测试。但有时,用户会在图形界面(GUI)中输入一些东西,它可能会调用某些操作,然后导致抛出一个异常。我们不想那样。抛出异常只是一个症状,我们不知道如何处理它。所以这意味着,我们会把整个系统搞崩。我们不想那样做。
这就是我演讲的全部内容。这里有几个参考资源。也许,也许我会把这部分库的代码开源到 GitHub 上。前端代码已经在生产环境中使用。但后端还没有。我仍在对其进行改进。我仍在对其应用更多的测试。
但这就是我想告诉你们的关于运行期字符串翻译的一切。这样,你们希望就不会在翻译中再次迷失了(Not get lost anymore in translations)。
谢谢。谢谢大家。