这篇文章用来解决两个问题：如何更新操作系统的时区文件，让它得知目前最新的夏令时信息（这会依当局的行政命令而发生改变）如果遇到了时区信息错误，如何解决第一个需求最为常见，然而第二个问题对于 CentOS 用户来说同样有一定意义。下次有其他倒霉蛋遇到这个问题的时候，就能从本文确定问题和修复方式了。问题当你使用的应用不自己维护时区信息时，他们会从 Linux 系统的 tzdata，也就是系统的时区数据库读取这些信息。这一般依赖$TZDIR指示的路径（因此你可以通过这个环境变量修改它——如果你的应用程序足够聪明，知道先看看这个变量的话），如果没有指示，那么默认是/usr/share/zoneinfo目录。如果你的场景强依赖正确的夏令时数据，那么你必须经常更新这个数据库。因为很多主体的夏令时每年并不一致，而这依赖于当局的行政命令，因此你需要根据新的公告数据去更新，以保证你的软件和现实世界表现一致。如果你使用 CentOS，在某些涉及到时区的场景中，发现涉及 Africa/Casablanca（卡萨布兰卡）这个时区的数据总是不对，而其他绝大多数情况都是正常的，那么大概率是因为：这儿的 tzdata 本来就是坏的。如何解决你需要做的是更新系统的时区文件到一个正确/最新的版本（取决于你的需求）。接下来是过程：首先你需要下载 IANA 时区数据库，这里我们就用最新的，其他也很容易在 ICANN 找到：wget https://www.iana.org/time-zones/repository/tzdb-latest.tar.lz将其解压，然后完成 build 过程，这可能需要 C 编译环境：tar -xf tzdb-latest.tar.lz cd tzdb-2024a/ make TOPDIR="$HOME/tzdir" install # 决定编译到哪里 "$HOME/tzdir/usr/bin/zdump" -v America/Los_Angeles # 读一个文件验证一下然后你在TOPDIR给定的路径就得到了一份新的 tzdata 及相关文件。接下来需要用它们去覆盖本系统中的文件（如果需要，记得备份）：sudo cp -r $YOUR_PATH/usr/share/zoneinfo /usr/share这就足够更新时区数据了。当然如果你需要更全面的更新，包括一些时区相关工具，那么你可以把TOPDIR所有的文件拷贝并覆盖到/目录对应位置下。参考资料Africa/Casablanca timezone missing #10211Africa/Casablanca timezone issue #1442作者在 SelectDB 的内部文档
[scode type="blue" size=""]开拓视听，颇具参考意义。[/scode]语言——JS，yyds！（Rust长势喜人——越难找的员工，越值钱啊——Rust，唯一原神——这么多的人在用C++做游戏？这么少的人用C++写数据库吗。。职业生涯——学历？——内生的兴趣，是计算机行业的独到优势啊——好吧，这个比例远超我的想象AI——毕竟，这是一个需要知识和智力的行业——确实，谁又爱写测试呢数据库——interesting~ redis已经作为八股融入了cn后端人的血液了。另外，在这里好像大家不那么愿意接受新鲜事物（指pgsql——Navicat，CN特供！（破解版）开发者——嗯。。入职即高级！——码农是青春饭（——看起来很有信服力——呃。。。——青春饭和老龄化社会——实际润人的数量开发工作——woo，相当高的比例C++——比我们想象的要快，不是吗？——哈哈，大家都很想要modules，但是谁又能完整支持呢？——好吧，这么多人不写单测——我们需要（而且没有）一个合理的解决方式——GCC远胜于CLANGRust——新兴力量，不是吗？参考文献以上全部数据，来自JetBrains每年进行的开发者生态调查，原文刊于https://www.jetbrains.com/zh-cn/lp/devecosystem-2023/
在大家的不懈催更下（hhh)，这篇文章终于和大家见面了。对于程序设计、软件开发而言，我都只是一个入门水平而已（也许C++略多一点，但也并不很多）。对任何知识的掌握，都不足以保证完美无缺。那么有什么可以和大家说的呢？我想，只有对一些事物的理解与认知。如何快速地养成一位普通的C++工程师，是培训班应该去做的任务。而我们，应该为成为一名优秀的软件工程师打好基础。这些内容显然不会是无上真理，因为它受限于笔者的有限认知。但至少，做到这些内容，成为比笔者更优秀的C++工程师，还是非常容易的。最重要的对于学编程的新手来说，最重要的事情是——先把代码写起来。这件事情对于各个行业来说都很重要，但也许对coding尤为重要。如果说程序员这个行业一定需要一些天分的话，那么最重要的一个，就是有“先把事情搞起来”的执行力。这意味着，也许你还在犹豫“我应该学什么，用哪本书、学谁的课”，什么时候学，要不要沐浴更衣，要不要择良辰吉日——都不必。就用你手头有的资源，写你想到的东西，先写起来。如果没有合适的，那么在这篇文章中，我会提供一些——但它不会是本篇文章的重点。C++需要学什么被问最多的问题是：“有没有推荐的学习路线？”要解答这个问题，必须要解决几个前置问题：你得明确，学C++是为了什么？为了赶紧找一份不错的工作？为了做有价值的东西？为了做很酷的东西让自己开心？自己要去做哪个方面的开发？是hpc，服务端，嵌入式，量化……还是什么？让我们一个一个来说。第一点，你的动机决定了你要去做什么。因为人的精力是有限的，你不可能对C++这么辽阔的版图面面俱到。特别是，C++的世界中有一类特别的人——语言律师，他们对复杂的语法规则——你绝不可能了解的那些——如数家珍，但不一定是非常优秀的软件工程师。你需要决定自己要不要成为这样的人。这并没有优劣之分，只是不同的选择。就像“茴字有几种写法”一样。当然需要有人知道（比如计算机史学家），但如果你希望成为一名优秀的coder的话，显然不是你需要知道的事情。那么，掌握一门语言需要到什么程度呢？对语言的掌握C++是一门非常复杂的语言，与其他语言最大的不同在于，它有一套被称之为“标准”的东西1 。这套标准非常详尽，事无巨细地规定了C++世界中的每一个角落该长什么样子。也正因为如此，C++世界中有一个经典的笑话，关于 “面试者在简历上写了精通C++结果面试官出了一堆元编程打击得面试者无地自容的那些事儿”。这意味着我们永远都不能够说自己“精通C++”了么？我看并不是。很多群友都知道，我在自己的简历上的确写了“精通C++”。因为在我看来，自己已经完全满足这个要求了。我能保证做出cpp quiz中的hard题吗？我不能。给我几个超复杂的候选函数，我能搞懂重载决议会pick哪个吗？我不能。但让这些炫技的东西出现在一个正经项目的代码里，比“不会C++”可怕一万倍。我能保证的是，对于我们的项目中所涉及到以及可能涉及到的C++知识，我已经掌握了至少99%了。我的精力应该放到如何写出更简洁、低耦合、可复用的代码上去，应该研究如何让我的代码对CPU、缓存更加友好，应该研究各个组件的架构应该如何设计才能从宏观角度提高性能和稳定性……——而不是让那些超复杂且有害的东西来占据我本就有限的时间2 。如果一个机制需要非常复杂的语法去实现它，那么相信我，一定有更好的办法。如果你在cr的时候看到了一个完全看不懂的写法，那么相信我，give it a -1.这是第一点：你必须清楚自己学C++是为了什么。 花一些时间做cool的事情来取悦自己没什么不好，但长期来看，你得大体坚持在正确的道路上，才能写出真正很酷的东西。它会是一个从性能、软件工程等角度上都很酷的东西，而并不因为你采取了很先进或很复杂的语法。先进的语法只有在让代码更简单的时候才是真的先进；复杂的代码只有让更多的代码更简单/性能更高的时候才是合适的复杂。所以——比语言更重要的专业能力语言永远只是一个工具。我想大多数人学C++的目的，是因为它容易写出可掌控、高性能的代码，这就意味着语言不是第一性的，它是为了功能而服务。如果对业务确实有利，那么明天去写Rust又怎么样呢？任何人都不应该抵触在合适的时机选择更合适的语言。C++也许的确更难学，但不要因为学了它而拥有可悲的壁垒。你的领域知识远比语言更重要。所以，你应该去考虑：我要成为一名hpc开发？还是嵌入式开发？还是后端开发？……只不过你喜欢C++，那么你可以选一些用C++比较频繁的领域，把C++作为你的核心竞争力之一，而不是“一棵树上吊死”。当然，语言是这一切的基础。所以你首先得把C++[font color="BlueViolet"]学到一个“能写出各类功能代码”的程度，然后就要尽快让领域知识驱动着你去学习和coding[/font]。而这并不难。我们预期花多少时间呢？如果你慢慢学，每天学一点，两三个月总够了；如果你把这作为你的主业，那么顶多需要半个月~一个月的时间。学习方式关键点就在于，不要把做一件实事的bar想象的太高。[scode type="green" size=""]最基本的语法都掌握了吗？很好，去学你想要做的那个领域的知识吧！[/scode]思考能力抓住问题的本质我想，选择C++作为主要语言的人，往往都对高性能有一些执念吧？那么“抓住问题的本质”这件事情简直太重要，太重要了！比如有些acmer第一次接触到编译期计算，会想到——我靠，那我直接把运行时算的东西挪到编译期不就得了？TLE再也与我无关！——当然我们知道这是天方夜谭。但我看到过不少人曾有过这样的想法。问题不在于他们没有掌握元编程的正确知识，而是他们未能建立正确的思考模型。比如说, 下列代码段中，虚函数调用是不是必须的？struct A{ virtual void f(){ ... } }; struct B : A { void f() override { ... } }; { A* a = new B{}; a->f(); }如果你C++学得多一点，可能会很快反应过来：我可以用CRTP优化掉这个虚调用嘛！但这件事的本质并不是CRTP，这件事的本质是“运行前就已经有足够的信息”，即使没有CRTP, 也会有ARTP, BRTP，只要是一个图灵完备的语言，就总有技巧消除掉这个开销。对你来说，[font color="BlueViolet"]最重要的东西不是学会用CRTP，而是“知道什么是理论上可以实现的”。[/font]就像网络工程师常用的“零拷贝优化”一样，即使没有人总结出来这种优化，它也应该是你有能力在业务优化中思考出来的。你不是掌握了一堆兵器，然后在项目上挨个按上去，看看卡不卡得上。而是：如果这个地方理论上可以更好，那么我总能有个技术把它搞了。我认为, 从这个角度——也就是事物的本质分析问题的能力, 比编程能力要重要很多倍。掌握很多技术可能能让你成为一名还算不错的软件工程师，而掌握从本质角度思考问题的能力，你才有可能成为一名架构师。在恰当的抽象层上思考问题这也是非常重要的一种能力，同时也是对软件工程的理解中的一环。就像我们经常说到，XXX对程序员来说是透明的。CPU有乱序发射，编译器有各种优化，这意味着你需要考虑这些东西会不会搞砸你的代码吗？No。所有的优化与变换，首先得是透明的、条件保持的。如果你决定了用swap去交换对象，就不用担心它是否因为应用了针对PIMPL的优化、针对POD的优化，而搞砸你的代码。如果你需要用或者提供一个接口，你所需要关注的全部就是接口的前条件和后条件，不要做跨层的思考。（除非你正打算从数据流的角度优化算法）成长路径原则性的（也是最重要的）东西讲完了，让我们来讲讲成长路径。所谓“成长路径”，简单讲就是“我在各个阶段应该学些什么？”，说直白点，就是“我应该往简历上写些什么？”。首先第一部分，是你的基础能力，这主要是语言能力——毕竟你得先把想到的东西写出来，才有别的东西可谈。所以，开始把代码写起来吧。书籍、参考与练习对于入门来说，有很多书籍是不错的，但它们都比不上找到自己想做的东西，然后一边写一边查。当然，参考书也是有必要的，我会推荐这本：《CPP Primer Plus》这本书的质量据说不错，但我没看过。叶神说过相比之下还不错，我信叶神的（但他好像也没看过）。然后呢？把这本书（或者其他你觉得不错的）看个差不多，就已经足够去写代码了。现在对你来说最重要的事情，就是找到你的目标领域当中适合入门的toy，写起来。[scode type="red" size=""]请坚决摒弃上学时期张嘴等老师“喂饭”的思维，真正的学习绝不是把书一本一本的读下去就有效果的！[/scode]在这个过程中，你需要同步提高自己的C++水平，那么有[font color="DarkGreen"]这两本非常不错的书[/font]：《现代C++32讲》《Effective Modern C++》它们都在群文件里有，后者我更推荐互联网上的gitbook版本，看着更舒服。如果你需要检验一下自己对C++语法的掌握如何，那么你可以在这里找到很多练习：Cpp Quiz请注意，不要痴迷。[font color="Red"]你不需要花费时间在这里的Hard难度上[/font]。而如果你需要[font color="DarkGreen"]确定地查找一个关于C++语法与库的正确答案**[/font]，请到这个对于Cpper最重要的网站：cppreference.com这时候，你就开始需要接触一些C++的[font color="DarkGreen"]最佳实践[/font]了：StackOverflow —— 这个地球上最高质量的程序员问答平台之一CppCon —— C++的前沿发展汇报总结来说，对于绝大多数新生代的C++ coder（或是其他门类的软件工程师）来说，在学习过程中最需要树立的一个观点是：[font color="BlueViolet"]绝对不是已经掌握了对应的技能才去做东西，你要做一个东西，正是因为你还不会它。[/font]当然，这时候，写出更好的代码就已经不能只局限于使用更合适的语法了，而是必须要从软件工程的角度重新思考你的代码。重构最好的方法，就是定期重构自己的代码。重新去思考能不能用更好的抽象，能不能更好地复用？更好的解耦合？隔一段时间回头看，总会觉得自己的代码像依托答辩，那么，这就是你提升的时候了！请不要忘记……能够实际做一些东西，总比纸上谈兵要好，不是吗？所以一定不要忘了，学会Linux、Cmake、gtest、、git、perf、valgrind、benchmark……这些工具的使用！那么，我该怎么去学它们呢？在什么时机去接触呢？答案是当你意识到你需要的时候。 请再次提醒自己，[font color="BlueViolet"]写代码已经不是中学时期等老师来喂你知识的时候了，你必须自己进行思考。[/font]当你思考如何改进自己的代码的时候，这些工具就会自然进入到你的世界。——这种学习的过程，将会伴随你的整个职业生涯。如果你说，我写了很多代码(xcpc coding除外)，但是仍然好像没有接触到这些工具的使用？那么，这就是你的问题了，其他人无能为力。的确有的人不具备主动思考和学习的能力，那么你要做的应该是关闭这篇文章，去找个培训班让他们想办法帮你物色一个好的工作。项目哦，到了大家（中的很多人）最为关心的部分了！所以我把这一小项提升为一个章节来说——其实人的眼界和机遇，比我们想象当中重要太多了。对于绝大多数人来说，无论你找到的工作是什么样的，这个世界上几乎一定存在着更轻松、待遇更优渥的另一个，只是你无法发现。所以多去拓展消息渠道，多接触更优秀的人，对你的意义比想象中的大。至少对我来说，如果大一就能有这样一个QQ群的话，我想自己一定能成长到另一个高度。所以，好好利用这些资源，去决定自己要做什么。[scode type="yellow" size=""]还是那句话，领域知识才是根本。你要成为一名后端/前端/嵌入式/区块链……工程师，而不是“C++工程师”。[/scode]名校Lab如果说接触商业项目相对困难的话，那么去做lab（课程附带实验）很容易成为你写实际代码的第一步！这些国外顶尖名校的lab与我们普通大学的实验课有天壤之别，它们往往要求你完成一个非常系统且颇具难度的软件，这其中覆盖了几乎全部这个课程的关键基础知识。因此，它们很适合作为新手入门的第一步。例如以下Lab：CMU 15-445 数据库CMU 15-721 数据库系统进阶MIT 6.824 分布式系统MIT 6.828/s.081 操作系统MIT 6.S191 深度学习……当然，随着互联网卷度增加，lab也有“泛滥”之嫌。但相信我，如果你真的搞懂了一个lab，它已经足以让你在对应领域成为一名合格的（校招）工程师了。开源项目实际参与开源项目的含金量，我认为不需要我多说了。它远超你的Lab、xcpc牌子、绩点、奖学金……这对你来说会不会很遥远？我可以告诉你的是，完全不。开源社区中的人们，大多是一群非常友好的家伙，而且非常多的项目都有一些容易让新人上手的case，而且会有很友好的mentor去对应的协助你完成。所以，你只需要摆脱胆怯的情绪，就可以很容易地去参与其中。例如3我们的大型分布式数据库DORIS，就给新手提供了非常友好的Good First Issues栏目，你可以非常容易地选择一个自己想要去完成的内容，然后接受导师的指导。之后你会发现，原来参与一个开源项目并不是那么困难嘛~GSoC当你拥有了足够多的参与开源项目的经验，那么参加GSoC就不再是一件奢望了！如果有朝一日能做到这一步，那你的职业生涯就拥有了绝大多数人无法企及的起点了。关于GSoC的相关信息，可以直接参考这里：Google Summer of Code 学生申请指南。虽然略微有些过时，不过作为了解，还是没有问题的。当然，说到最后，对你来说最有用的项目和知识，一定是你最感兴趣的那一部分。如果你某一天头脑当中冒出了一个点子，即使在你看来目前难以实现，相信我，它一定是最适合你的那一个。 毕竟，我们还有万能的C++群嘛，遇到问题，解决问题，然后提升。 这个标准由国际标准化组织(ISO)中的C++标准委员会(WG21 committee)负责修订，自2011年起每三年发布一个正式版本，例如ISO/IEC 14882:2020即是我们俗称的“C++20标准”。 ↩ 我的时间还要用来喝喝威士忌、下下围棋、玩玩儿德扑、踢踢足球，很珍贵的！ ↩ 这里夹带一点私货，嘿嘿~ ↩
C++中一个隐蔽的小坑：std::pair<const ColumnPtr&, bool> unpack_if_const(const ColumnPtr& ptr) noexcept { // ... return std::make_pair(ptr, false); }以上代码有什么问题？如果没有看出来，我们可以搞一个最小可复现实例：std::pair<const int&, int> f(const int& arg) { return std::make_pair(arg, 1); } int main() { int a = 1; const auto& [b, _] = f(a); a = 2; std::cout << b; return 0; }运行一下，会发生什么？我们会发现输出结果是：1说明我们没能够正确地通过std::pair传递arg的引用。其实这时候b捕获的是一个临时对象的引用。这是因为：std::make_pair默认不按引用捕获，而是通过std::decay捕获到非引用类型，从而按值传递！正确写法同时，上面的信息也给出了对应的解决方案：用引用包装器传递参数。因此我们可以将最初的代码改写成：std::pair<const ColumnPtr&, bool> unpack_if_const(const ColumnPtr& ptr) noexcept { // ... return std::make_pair(std::cref(ptr), false); }就能使得std::make_pair正确捕获引用了。当然，pair的默认构造函数不会进行相似的decay，因此我们可以通过初始化列表去完成一样的目的：std::pair<const ColumnPtr&, bool> unpack_if_const(const ColumnPtr& ptr) noexcept { // ... return {ptr, false}; }
1 整型提升#include <iostream> void f(unsigned int) { std::cout << "u"; } void f(int) { std::cout << "i"; } void f(char) { std::cout << "c"; } int main() { char x = 1; char y = 2; f(x + y); }答案：Implement Defined。解析首先：“signed char”, “short int”, “int”, “long int”, and “long long int”. In this list, each type provides at least as much storage as those preceding it in the list.也就是说int不一定比char更大，只有不小于的关系是得到保证的。我们知道加法会首先对操作数进行整型提升，而又因为：It is implementation-defined whether a char object can hold negative values.所以当我们需要将它提升到可容纳它的（至少是int的）类型时，如果当前环境char可以包含负数，x+y就是signed char+signed char得到int；反之，x+y就是unsigned char+unsigned char得到unsigned int。而产生分歧的原因是ID不是UB，所以最终的结果是ID的。2 重载决议中的隐式转换#include <iostream> void f(float &&) { std::cout << "f"; } void f(int &&) { std::cout << "i"; } template <typename... T> void g(T &&... v) { (f(v), ...); } int main() { g(1.0f, 2); }答案：if解析首先, 在进入g()以后, 我们实际产生的调用是g(float&& v1 = 1.0f, int&& v2 = 2), 然而这两个右值引用的值类别是左值! (关于这一点, 我的文章的#0302节有详细的解释, 不再赘述).确认了这一点, 那么9行实际上就会产生f(float)和f(int)先后两个调用. 那么结果是fi吗? 此时就要进行一下重载决议了, 而这时就有一件容易被遗忘的事情: 右值引用不能绑定到左值上!从而f(1.0f)不能调用f(float)重载, int那一支同理.然而, 经由标准转换序列中的数值转换, int左值可以转换为一个float亡值, float左值也可以转换为一个int亡值, 从而f(1.0f)可以调用f(int&&), 而f(2)可以调用f(float&&)!于是展现给我们的结果就是一个float调用了int重载, int调用了float重载, 似乎"颠倒"了过来.这件事情看起来诡异, 但其实质是合理的: 左值和右值之间不能随意转换, 否则区分左右值就没有意义了. 看起来相比于float&&, int转化成int&&更为自然, 但这种转换是std::move的任务. 而隐式转换序列在重载决议时又是应当允许的, 这二者组合就产生了这一看似奇怪的结果.3 初始化列表#include <iostream> #include <vector> int f() { std::cout << "f"; return 0;} int g() { std::cout << "g"; return 0;} void h(std::vector<int> v) {} int main() { h({f(), g()}); }答案:fg解析函数实参求值是不定序的, 然而这里有一个初始化列表, 初始化列表是by order求值的.当然, 前提也是因为std::vector有接收init_list的构造函数重载. 如果一个类型既没有这样的重载, 又不是聚合类(不能聚合初始化), 那么这里也有隐含的一个坑.4 静态成员初始化#include<iostream> int foo() { return 10; } struct foobar { static int x; static int foo() { return 11; } }; int foobar::x = foo(); int main() { std::cout << foobar::x; }答案:11解析静态数据成员是可以延迟定义的, 此时类内的声明不是默认定义. 从而17行是static变量的有效定义, 而它本质上还在类内, 所以根据static成员初始化的调用如同成员函数中的调用, 此时调用的foo()是类内的重载.5 综合编译期类型判断#include <iostream> #include <type_traits> using namespace std; int main() { int i, &j = i; [=] { cout << is_same<decltype ((j)), int >::value << is_same<decltype (((j))), int & >::value << is_same<decltype ((((j)))), int const& >::value << is_same<decltype (((((j))))), int && >::value << is_same<decltype((((((j)))))), int const&& >::value; }(); }答案:00100解析非常综合的一道题, 依次列举考点:首先对于decltype((x)), 这个双层小括号是有实际意义的, 它用来区分左值引用和非引用. 如果内部为左值, 那么decltype(())将产生左值引用. 括号更多没有其他意义, 与此等价.捕获符[=]说明以复制作为默认的捕获方式, 但decltype并非ODR使用, 因此j未被捕获, 而是直接使用原对象! decltype推导的是j原本的int&类型, 这又是一大坑点.由于lambda未加说明符mutable, 因此j带有const限定.std::is_same完全以模板方式(即, 使用重载决议规则)判断两类型是否一致, 因此是否带const指示两不同类型.
一则简短的小知识： decltype(auto)是干嘛的？auto作为占位类型说明符自C++11就出现了，它本质上是个语法糖，能够让我们把type var = expr;简写成auto var = expr;，主要带来了两方面的好处，一是减少了代码的复杂程度，特别是type = std::some_container<some_type>::some_iterator这种情况，降低了编码的负担；另一方面提高了代码的统一程度。[scode type="blue" size=""]那么既然auto已经足以推导类型了，我们又为什么需要decltype(auto)的存在？[/scode]看起来auto和decltype都起到了推导的功效，为什么还要多此一举呢？考虑一个最简单的例子：template<typename T> auto f(T&& arg) { return std::forward<T>(arg); }我想要在模板代码中，将参数按照原本的值类别引用转发出来，然而这是做不到的，auto只会得到非引用的类型：#include <bits/stdc++.h> template<typename T> T check(); // 一种奇特的编译期值类别检测技巧 int a = 1; template<typename T> auto f(T&& arg) { return std::forward<T>(arg); } int main() { check<decltype(f(a))>(); // undefined reference to `int c<int>()' check<decltype(f(1))>(); // undefined reference to `int c<int>()' return 0; }原因是auto 自身不包含值类别和cv限定，它的推导规则是依据模板实参推导来的：这样，当我们在写一些模板代码的时候，如果需要返回转发引用，似乎只能抛弃万能引用而分别实现了。或许有人想到了使用auto&&，但当我们需要处理在返回值中转发引用的时候，这种方法也失效了，因为它还可能包含非引用类型：template<typename T> /* ref or non-ref */ g(T); template<typename T> auto&& f(T&& arg) { return std::forward<T>(arg); // 解决了 return g(std::forward<T>(arg)); // 不奏效 }也就是说，我们需要在返回值上引入一个真正的完美转发。为了解决这一问题，C++在14就引入了decltype(auto)这一特定搭配写法，用于推导出带引用类别的值类型。其推导结果将等价于decltype(expr)，其中expr为对应位置的初始化器（在这里是return的操作数）。于是上面的代码就可以实现为：#include <bits/stdc++.h> template<typename T> T check(); // 一种奇特的编译期值类别检测技巧 int a = 1; template<typename T> decltype(auto) f(T&& arg) { return std::forward<T>(arg); } int main() { check<decltype(f(a))>(); // undefined reference to `int& c<int&>()' check<decltype(f(1))>(); // undefined reference to `int&& c<int&&>()' return 0; }[scode type="share" size=""]有一点需要注意的是，对于一个名字进行decltype，为了能够正确区分引用和非引用，嵌套括号的decltype((a))将产生左值引用，而decltype(a)不会。[/scode]这种手法主要用处在于完美转发上：template<class Fun, class... Args> decltype(auto) Example(Fun fun, Args&&... args) { return fun(std::forward<Args>(args)...); }通过decltype(auto)，如果fun返回引用，我们实现了正确地将引用转发出去，非引用的结果也是正确的（返回值的完美转发）。这种情形下，它是唯一的方法。其实，在理解上，这仍然可以看做是一个语法糖，auto就是起到了占位符的功能。例如上面的代码，其实就是通过decltype(fun(std::forward<Args>(args)...))，来得到返回值类型。简单总结的话就是，在返回值中，auto只是类型，而decltype(auto)像完美转发一样带可能的引用。
C++性能表现上的技术报告（TR18015） - Exception Handling 异常处理（中文翻译）
5.4 异常处理异常处理提供了一个系统化且健壮的方法来处理那些无法在它们被检测到的地方就地处理完成的错误。传统的异常处理方法（在C、C++和其他语言中）包括：返回错误代码（return非零值）设置错误状态指示器（如errno）调用错误处理函数使用longjmp直接从上下文跳转到错误处理代码每次调用时传递一个指向状态对象的指针在考虑异常处理时，必须将其与其他处理error的可能方式进行对比。比较时，合理的考虑范畴包括：编程风格错误处理代码的稳健性和完整性运行时系统（内存）开销处理一个错误的额外开销考虑一个平凡的例子：double f1(int a) { return 1.0 / a; } double f2(int a) { return 2.0 / a; } double f3(int a) { return 3.0 / a; } double g(int x, int y, int z) { return f1(x) + f2(y) + f3(z); }这段代码不包含错误处理程序。在C++异常处理机制出现之前，有几种技术可以检测和报告错误：void error(const char *e) { // handle error } double f1(int a) { if (a <= 0) { error("bad input value for f1()"); return 0; } else return 1.0 / a; } int error_state = 0; double f2(int a) { if (a <= 0) { error_state = 7; return 0; } else return 2.0 / a; } double f3(int a, int *err) { if (a <= 0) { *err = 7; return 0; } else return 3.0 / a; } int g(int x, int y, int z) { double xx = f1(x); double yy = f2(y); if (error_state) { // handle error } int state = 0; double zz = f3(z, &state); if (state) { // handle error } return xx + yy + zz; }理想情况下，真正的程序会使用一致的错误处理风格，但这种一致性在大型程序中往往很难实现。请注意，error_state技术不是线程安全的，除非实现提供对线程unique static数据的支持，而且用if (error_state)产生的分支可能会干扰处理器中的管道优化。还要注意的是，在error()可能不会终止程序，这种情况下程序很难高效率地使用它。然而，这里的关键点是，任何处理不能在本地处理的错误的方法都意味着空间和时间的额外开销。它还会使程序的结构复杂化。使用“异常”，这个例子可以写成这样：struct Error { int error_number; Error(int n) : error_number(n) {} }; double f1(int a) { if (a <= 0) throw Error(1); return 1.0 / a; } double f2(int a) { if (a <= 0) throw Error(2); return 2.0 / a; } double f3(int a) { if (a <= 0) throw Error(3); return 3.0 / a; } int g(int x, int y, int z) { try { return f1(x) + f2(y) + f3(z); } catch (Error &err) { // handle error } }在考虑异常处理的开销时，我们必须要通盘考虑可选的异常处理技术的成本。Exception的使用将错误处理代码与正常的程序执行流隔离开来，而且与error code方案不同，它无法被忽略。另外，在抛出异常时自动销毁栈上对象，使程序更不容易泄漏内存或其他资源。使用exception，一旦发现问题，它就无法被忽略——如果不能捕捉和处理异常，就会导致程序终止。关于使用异常的技术的讨论，见 "The C++ Programming Language"的附录E [BIBREF-30]。异常处理的早期实现导致了代码规模的显著增加和一定的运行时overhead。这导致一些程序员会选择避免使用它，编译器供应商也提供开关来抑制异常的使用。在一些嵌入式和资源受限的环境中，由于担心overhead，或者由于异常机制的实现不能满足项目的可预测性要求，exception的使用被有意地排除。我们可以区分三种overhead的来源：try块 与每个try-block或catch子句相关的数据和代码。常规内容 与正常执行的函数相关的数据和代码，如果不存在exception，可能就不会需要的内容——例如错过的优化机会。抛出异常 与抛出异常相关的数据和代码。在使用传统技术处理错误时，每种来源都有相应的开销。5.4.1 异常处理的实现细节与技术异常处理（Exception Handling，简称EH）的实现必须解决几个问题：try块 为异常的捕获建立上下文。捕获 EH的实现必须提供一些运行时的类型识别机制，以便在抛出异常时找到捕获条款。 RTTI和EH功能所需的信息有一些重叠，但不完全相同：EH的类型信息机制必须能够将派生类与基类相匹配，即使是没有虚函数的类也是如此。同时也要能够识别诸如int这样的内置类型。另一方面，EH类型信息不需要支持下移或交叉转换。由于这种重叠，一些实现要求在启用EH时要启用RTTI。异常的清理 没有被重新抛出的异常必须在退出catch子句时被销毁。异常对象的内存必须由EH实现来管理。具有非平凡析构的自动存储期或临时对象 如果对象在构造后、生命周期结束前发生异常，则必须调用析构函数——即使没有try-catch的存在也是如此。EH的实现需要跟踪所有这些对象。具有非平凡析构的对象的构造 如果在构造过程中发生异常，所有构造完成的基类和子对象必须被销毁。这意味着EH实现必须跟踪对象的当前构造状态。throw表达式 被抛出的异常对象的副本必须被分配到EH实现提供的内存中。然后必须使用EH类型信息找到最接近的匹配的catch子句。最后，在控制权转移到catch子句之前，必须执行自动存储期对象、临时对象和部分构造对象的析构。强制异常规范 必须检查抛出的类型与异常规范中允许的类型列表的一致性。如果发现不匹配，必须调用意外处理程序。operator new 如果在构建具有动态存储期的对象时抛出了一个异常，在调用部分构建对象的析构函数后，必须调用相应的operator delete来释放内存。 这里同样可以使用一个类似于try-catch实现的机制。实现方案在如何平衡上述成本方面各不相同。两种主要的策略是：“代码”方法，即代码与每个try-catch块相关联；“表”方法，使用编译器生成的静态表。当然，还存在一些混合的方法，本文只讨论上述两种主要方案。5.4.1.1 “代码”方法使用这种方法的实现必须动态地维护辅助数据结构，以管理执行上下文的捕获和传输，还需要维护动态数据结构，以跟踪在发生异常的情况下需要被解绑的对象。这种方法的早期实现使用setjmp/longjmp来返回到上层上下文。然而，使用特定用途的代码来完成此项工作可以获得更好的性能。也可以通过系统地使用（编译器生成的）返回代码来实现这种模式。代码方法处理5.4.1中确定的问题的典型方法如下：try块 保存执行环境，并在try块入口处向EH栈中push一个捕获代码的引用。具有非平凡析构的自动存储期或临时对象 对于每个已构造的对象，提前注册它的析构函数以备使用。典型的实现在EH栈内使用链式存储的列表来存储。如果抛出了一个异常，这个列表会决定哪些对象需要被析构。具有非平凡析构的对象的构造 一个众所周知的实现是在构建基类和子对象时为它们增加一个计数器。如果在构建过程中出现异常，该计数器将被用来确定哪些部分需要被销毁。throw表达式 在找到catch子句后，为EH栈中位于throw表达式 和选中的catch子句之间的所有构造的对象1 调用析构函数。恢复与catch子句相关的执行环境。5.4.1.1.1 “代码”方法的空间开销异常处理的成本与对象无关，所以对象的大小不受影响。异常处理意味着一种RTTI的形式，这可能需要增加一些代码大小、数据大小。异常处理代码需要插入到每个try-catch的对象代码中。注册析构需求的代码被插入到对象代码中，用于每个具有非平凡析构的栈对象。检查被调用的函数的抛出规范具有开销。5.4.1.1.2 “代码”方法的时间开销在进入每个try块时：提交包围try块的变量变化保存执行上下文（入栈）存储对应的catch块（入栈）在离开每个try块时：移除执行上下文移除记录的catch子句调用一般函数时：如果函数存在异常规范，记录以供检查创建局部或临时对象时：记录全部throw或者re-throw时：定位到匹配的catch子句（如果有的话）——这会导致一些运行时检查（可能类似于RTTI检查）如果找到，那么：销毁记录的局部变量检查调用过程的的函数的异常规范使用catch子句相关的上下文否则：调用terminate_handler（终止处理）进入每个catch子句时：移除相关catch子句离开每个catch子句时：抛弃当前exception对象（如有必要，销毁之）“代码”模型将代码和相关数据结构分布在整个程序中。这意味着不需要单独的运行时支持系统。这样的实现可以是可移植的，与将C++翻译成C或其他语言的实现兼容。“代码”模型有两项主要的缺点：相关的栈和运行时间开销对于try-block入口来说可能很高；即使没有抛出异常，也必须对自动的、临时的和部分构造的对象记录在EH栈上。也就是说，仅仅因为异常“有可能”产生，与错误处理无关的代码就会变慢。这与始终检查错误状态或返回值的错误处理策略类似。这种（在此模式下，不可避免的）记录的成本在不同的实现中差异很大。然而，一个供应商报告说，从C++到ISO C的翻译器的速度影响约为6%。这通常被认为是一个非常好的结果。5.4.1.2 “表”方法使用这种方法的典型实现将生成一些只读表，用于确定当前的执行环境、定位捕获条款和跟踪需要销毁的对象。“表”方法处理5.4.1中确定的问题的典型措施如下：try块 这种方法不产生任何运行时开销。所有的记录都是预先计算好的，作为程序计数器和异常情况下要执行的代码之间的映射。表增加了程序镜像的大小，但可以从工作集上移除以提高引用局部性。表可以放在ROM中，或者在有虚拟内存的宿主系统中，可以保持被换出的状态，直到实际抛出一个异常。具有非平凡析构的自动存储期或临时对象 正常执行没有运行时间成本。只有在出现异常的情况下，才有必要侵入正常执行。throw表达式 静态生成的表被用来定位匹配的处理程序（handler）和这之间需要销毁的对象。同样，在正常的执行过程中没有任何运行时开销。5.4.1.2.1 “表”方法的空间开销异常处理的成本与对象无关，所以对象的大小不受影响。异常处理隐含RTTI的一种应用，意味着代码和数据大小的一些增加。这个模型使用静态分配的表和一些库的运行时支持。检查被调用的函数的抛出规范具有开销。5.4.1.2.1 “表”方法的时间开销在进入每个try块时：某些实现将try块外围变量的执行状态提交，另一些使用更为复杂的状态表解决2 。在离开每个try块时：无额外开销调用一般函数时：无额外开销创建局部或临时对象时：无额外开销throw或者re-throw时：取决于是否有匹配的catch子句如果有，那么：销毁全部在throw与catch子句间的局部的、临时的、部分构造的变量。检查异常是否符合throw和handler之间所有函数的异常规范。将控制流转到catch子句否则：调用terminate_handler 3进入每个catch子句时：无额外开销离开每个catch子句时：无额外开销这种方法的主要优点是，在管理try-catch或对象记录方面没有栈或运行时开销。除非有异常被抛出，否则不会产生运行时的开销。缺点是实现起来比较复杂，而且不容易翻译成另一种高级语言（如C）的实现。静态表可能相当大。这对有虚拟内存的系统来说可能不是一个负担，但对一些嵌入式系统来说，其成本可能很高。所有相关的运行时成本只在抛出异常时发生。然而，由于需要检查潜在地大且复杂的状态表，响应一个异常所需的时间可能很大且不定，并且取决于程序的大小和复杂性。这需要考虑到异常可能出现的频率。极端的情况是，一个为不经常发生的异常而优化的系统，第一次抛出的异常可能会导致磁盘访问。一个供应商报告说，生成的表对代码和数据空间的影响约为15%。这也许可以进一步优化，因为这个供应商没有对空间进行优化的需求。5.4.2 异常处理开销的可预见性5.4.2.1 try-catch表现的预测对于一些程序来说，难以预测将控制权从throw表达式传递到正确的的catch子句所需的时间是一个问题。这种不确定性来自于销毁自动对象的需要，以及——在“表”模型中——来自于查阅表的需要。在某些，特别是那些有实时性要求的系统中，能够准确地预测操作需要花费多长时间是很重要的。由于这个原因，目前的异常处理实现可能不适合某些应用。然而，如果可以静态地确定调用树，并且使用EH实现的表方法，就有可能静态地分析将控制权从一个给定的throw表达式转移到相应的catch子句所需的事件序列。然后可以对每个事件进行静态分析，以确定它们对开销的影响，并将整个事件序列汇总到一个单一的成本域（最坏情况、最好情况、无界的、不确定的）。这样的分析在原则上与目前用于异常以外的代码的时间估计方法没有区别。对EH表示的保留意见之一是，在自动对象被销毁时、throw表达式之后且控制权传递给catch子句之前，可能会有不可预知的时间。应该可以准确地确定EH机制本身的成本，而任何被调用的析构函数的成本都需要按照确定任何其他函数的成本的方式来确定。鉴于这样的分析，“不可预测”一词是不恰当的。成本可能是相当可预测的，有一个很好确定的上下限。在某些情况下（递归上下文，或条件调用树），成本可能无法静态确定。对于实时应用程序，一般来说，最重要的是有一个确定的时间域，在上下限之间有一个小的偏差。实际的执行速度往往不那么重要。5.4.2.2 异常规范一般来说，异常规范必须于运行时检测，例如：void f(int x) throw (A, B) { // whatever }在一个简单的实现中，将产生大致相当于以下程序的代码：void f(int x) { try { // whatever } catch (A&) { throw; } catch (B&) { throw; } catch (...) { unexpected(); } }原则上，静态分析（特别是全程序分析）可以用来消除这些测试。这对于不支持动态链接的应用程序来说可能特别重要，这些应用程序不会大或复杂到让分析失效，也不会频繁变化而使分析变得昂贵。根据实现不同，空的异常规范可能对优化特别有帮助。使用空的异常规范应该可以减少开销。具有空的异常规范的函数的调用者可以根据被调用的函数永远不会抛出任何异常的知识来进行优化。特别是，在一个不能抛出异常的区块中，带有析构函数的对象不需要对异常进行保护。也就是说，在“代码”模型中不需要注册，而在“表”模型中不需要为该对象建立表项。比如：int f(int a) throw (); char g(const std::string& s) { std::string s2 = s; int maximum = static_cast<int>(s.size()); int x = f(maximum); if (x < 0 || maximum <= x) x = 0; return s2[x]; }在这里，编译器不需要为了考虑在构建s2之后抛出异常的可能性而调整行为。当然，没有要求编译器必须执行这种优化。然而，一个用于高性能的编译器很可能会执行这种优化。 译者注：例如，所抛出的对象。 ↩ 在这样的实现中，这显著地使变量变得部分volatile，并可能因此而影响其他优化。 ↩ 当terminate_handler因为没有找到合适的异常处理而被调用，局部变量是否被析构、栈是否被解开是实现定义的。 ↩
[scode type="green" size=""]照例先来一个30s速览版本：[/scode]CP（定制点）指的是对于一个特定名称、特定功能的函数调用可能需要派发给不同具体实现的调用点，此谓之“定制”；CPO（定制点对象）是用来解决定制点问题的一个工具；CPO的实现原理是：写成函数对象+operator()的实现，根据规则，二阶段的ADL查找在一阶段名字查找发现对象实体的时候不发生；niebloid是指ranges算法的调用。它和CPO的关系是：niebloids现阶段使用CPO作为具体实现方式。那么，正文开始：CPO和niebloid应该说是C++20当中最为晦涩的概念了，对于大多数人来说，我们永远都不需要自己去实现这种东西。但它们当中蕴含的技巧非常之巧妙，能够让我们对函数调用和库功能设计的理解提高一个档次。1 Intro大多数讲解CPO的文章都会提到swap的例子，我们这里也不能免俗。简单来说，我们考虑如何交换两个对象：最基本的做法是，我们可以swap(a, b)，标准库有一个经典的三赋值实现，这很容易理解。然而问题来了，[font color="Crimson"]如果这个实现不是我们想要的呢？[/font]Situation——定制点一个典型的情况是，我们要交换的对象是pimpl的：[collapse status="false" title="pimpl对象"]PIMPL（pointer to implementation，指向实现的指针）是一种C++当中的编程技巧，在类的公开接口中不包含具体实现，而是使用指针指向一个实现类，而实现类可以在分离的翻译单元中进行开发。它的原理是，指针可以指向不完整类型（因为指针指的是被指对象首地址，而不需要知道对象具体内存情况），这样我们在定义中使用一个前置声明，就可以把实现扔出当前翻译单元了：// in header file class widget { public: widget(); ~widget(); private: class impl; impl* pimpl; }; // in implementation file class widget::impl { // ::: };这样就形成了所谓的“编译防火墙”：对实现类impl的改动不会引发使用了对应header的文件的重编译，极大地减轻了编译期的依赖；对类成员布局的修改，也不会破坏接口的ABI。[/collapse]对于这样一个对象，我们如果调用三赋值的swap会发生什么呢？那会引发三次深拷贝，因为pimpl类型通过指针管理实际对象，显然这里调用的拷贝构造（赋值），我们会把它实现成深拷贝的。而这里我们实际需要做的是交换pimpl指针就可以了。这种情况下，我们应该自己去实现一个特殊的、高效的swap(widget&, widget&)函数，然后调用这个实现。也就是说，出现了这样一种情况：[font color="red"]我们希望对同一个函数名的调用，会根据不同的条件（主要是特定类别参数）实际调用不同的合适实现。[/font]这样的函数调用点，我们就称之为[font color="red"]定制点[/font]。它定制的是处理不同的实参时，函数不同的实际表现。怎么做呢？容易想到两种思路：Method 1——模板特化 ×最容易想到的就是给对应函数直接添加特化，然而这并不总是有效。原因是函数模板无法偏特化，见下例：namespace My{ struct A{}; template<typename T> struct B{}; } template<> void std::swap<::My::A>(::My::A& a, ::My::A& b) noexcept(__and_<is_nothrow_move_constructible<::My::A>, is_nothrow_move_assignable<::My::A>>::value) {} // ok template<typename T> void std::swap<::My::B<T>>(::My::B<T>& a, ::My::B<T>& b) noexcept(__and_<is_nothrow_move_constructible<::My::B<T>>, is_nothrow_move_assignable<::My::B<T>>>::value) {} // ill-form这里对My::A的特化是成功的，然而My::B<T>则不行，因为这属于一种偏特化，而函数模板是无法进行偏特化的。因此像hash<user_type>一样添加模板特化的方式，并不是我们需要的答案。既然函数模板不能偏特化，而一般我们要偏特化函数模板的时候，都是通过functor（仿函数，它的本质是类，可以偏特化）去实现的。那么这里采用同样的手法实现可不可以呢？这也是不行的。因为像swap这样，我们已经有了一个作为函数的实现，如果再出现一个作为对象实现的functor和它同时可见，那么重载决议会失败。Method 2——ADL √第二个方法则是借助ADL（Argument-dependent Lookup，实参依赖查找）机制。[collapse status="false" title="什么是“ADL”"]我在这里默认这篇文章的读者对模板编程已经有了初步的了解。不过以防万一，我们还是解释一下“ADL”是什么。在无限定函数调用中，我们会将实参所在命名空间可见的重载也加入候选集——因此称之为实参依赖查找（ADL）。例如，在如下代码中，有限定（即使限定为空也算）调用::f(x)会找到调用点可见的f(T)，而无限定调用会引入a所在的namespace local中的f(A)：namespace local{ struct A{}; void f(A){ std::cout<<"local\n"; } } template<typename T> void f(T) { std::cout<<"global\n"; } int main() { local::A x; f(x); //local by ADL ::f(x); //global return 0; }此时加入候选集的void f(A)由于是更好的匹配，所以得到了调用。当然，这要求它的确比f(T)更为特殊。那么ADL会在什么时候发生呢？——简而言之，如果ADL之前在局部作用域就找到了函数声明，或者找到任何非函数的声明，就会拒斥ADL的发生。[/collapse]我们知道，对于具体类型参数所编写的一个函数，总是比模板的一般重载特殊的。放在一起看，一般是：namespace std{ template<typename T> return_type foo(T arg); // 1, default implementation } namespace My{ return_type foo(my_type arg); // 2, user implementation }那么，如果让这两个重载都在调用点处可见，不是就能满足我们的需求了吗？当我们有一个“更好”的用户定义实现的时候，ADL会让它被发现，然后成为最佳匹配函数被调用；当我们没有给出user implementation的时候，default implementation是可见的，会成为兜底的调用。为了让这个机制生效，我们的调用就需要是：using std::foo; foo(x);这样std::foo会被显式引入后找到；而My::foo如果存在，由于是非限定调用，会通过x的ADL被找到。——这种先using后调用的方案，我们下文会称之为[font color="red"]两步法[/font]。对于我们的user implementation，可以通过[font color="BlueViolet"]hidden friend function[/font]去实现。相比于普通的友元函数，使用hidden friend的好处在于[font color="BlueViolet"]它仅供ADL使用[/font]，无法被外部调用找到：namespace My{ struct A{ private: friend void foo(A); // can be found only by ADL }; }于是我们就可以这样去解决我们的问题：namespace My{ struct A{ private: friend void swap(A&, A&) { // ... } }; struct B{}; } int main() { using std::swap; //intro My::A a1, a2; swap(a1, a2); // called My::swap(A&, A&) My::B b1, b2; swap(b1, b2); // called std::swap return 0; }到此为止，我们初步解决了函数定制派发的问题。但它还不是真正的“定制”1。2 CPO（定制点对象）2.1 “定制”的含义之所以说前面的解决方案没有实现真正的“定制”，是因为它只提供了ADL实现和库实现之间的选择，这肯定不足以称之为真正“定制”了一个功能。[scode type="yellow" size=""]那么什么是我们需要的“定制”呢？[/scode][font color="BlueViolet"]真正的定制，应该能够允许我们对一个宏观的行为，根据调用者的不同而因地制宜地实现不同的具体表现2 [/font]，而不是仅限于在用户实现和库实现之间做出选择。比如，我们现在需要定制一个begin函数，它的功能是对一个范围获取它的首地址（指针/迭代器）。这就会涉及到多种情况：如果这个范围是个native array，那么得到的应该是它的首元素地址；如果这个范围是一个标准或自定义的容器，例如std::array等，那么它提供了成员函数xxx.begin()，我们应该调用它并返回；如果这个范围是某个我们自定义的东西，但通过ADL的方式在命名空间内提供了begin(xxx)的实现，应该得到调用的则是这个实现。可以看出，一个功能的具体实现可以是相当不同的。真正的定制应该能够顺利地处理这些不同情况。写成伪代码的话，大概是这样：auto begin(T x) { if is_array(x) return x; else if is_container(x) return x.begin() else if has_adl_begin(x) return begin(x) else fail }但其实有一部分功能在我们的定制时是不可控的，那就是通过ADL查找找到的重载，我们怎么能够保证它提供的实际作用是我们想要的呢？比如begin，我们希望它是一个指向range头的指针或迭代器。然而它是否也可能是一个开始某个异步任务的命令，并且返回一系列动作的结果呢？所以真正的定制还需要满足第二个条件：[font color="BlueViolet"]在定制点处就检查用户提供的重载是否符合预期，并决定是否启用它。[/font]特别地，在C++20时代，由于有了concept和requires，我们还可以把“不符合预期”甚至“实参完全无法实现该功能”的错误，在编译的早期就用human-friendly的形式产生报错。这也是我们所希望的。2.2 交给ADL+重载决议？我们前文所提出的“两步法”显然不足以实现如此（甚至需要更加）复杂的逻辑，但在这个基础上继续扩充，是否可能实现我们的目的呢？就是说，既然我们要实现更加复杂的派发，那就添加更多、或者更复杂的库函数模板重载不就好了吗？更复杂的派发逻辑，只是意味着我们需要更复杂的重载决议罢了。只要能够封装到两步法中的using foo;所代表的库实现中去，上面的if-else再复杂，都不会给用户添加使用负担。[scode type="green" size=""]核心逻辑是，把“两步法”封装到库实现去，再复杂的逻辑都不是问题。[/scode]比如标准库的std::distance，针对迭代器是否可以随机访问，就有两种不同的实现：// 输入（非随机访问）迭代器版本 template<typename _InputIterator> inline _GLIBCXX14_CONSTEXPR typename iterator_traits<_InputIterator>::difference_type __distance(_InputIterator __first, _InputIterator __last, input_iterator_tag) { // concept requirements __glibcxx_function_requires(_InputIteratorConcept<_InputIterator>) typename iterator_traits<_InputIterator>::difference_type __n = 0; while (__first != __last) { ++__first; ++__n; } return __n; } // 随机访问版本 template<typename _RandomAccessIterator> inline _GLIBCXX14_CONSTEXPR typename iterator_traits<_RandomAccessIterator>::difference_type __distance(_RandomAccessIterator __first, _RandomAccessIterator __last, random_access_iterator_tag) { // concept requirements __glibcxx_function_requires(_RandomAccessIteratorConcept< _RandomAccessIterator>) return __last - __first; }这种模板的复杂是不会外溢到使用当中的，如果能够实现，那就非常完美了！况且在C++17标准后，if constexpr和constexpr函数的功能非常强大，我们上面的begin(T x)是很容易实现的！比如这样（我们可以形象地称之为“派发器”）：template<__maybe_borrowed_range _Tp> requires is_array_v<remove_reference_t<_Tp>> || __member_begin<_Tp> || __adl_begin<_Tp> constexpr auto begin(_Tp&& __t) const noexcept(_S_noexcept<_Tp&>()) { if constexpr (is_array_v<remove_reference_t<_Tp>>) { static_assert(is_lvalue_reference_v<_Tp>); return __t + 0; } else if constexpr (__member_begin<_Tp>) return __t.begin(); else return begin(__t); }然后我们就只需要把那些很麻烦但并不会让麻烦外溢的is_array_v、__member_begin……补齐就好了……吗？[scode type="red" size=""]NO! 这个函数不会像我们想象的一样得到调用！[/scode]原因是[font color="red"]通过ADL找到的函数往往都更为特殊！[/font]，它们会永远优先于我们的上述“派发器”重载被调用。原因是在实参所属的namespace中，我们明确知道它的类型，所编写的实现往往都更特殊。[collapse status="true" title="函数重载决议"]函数重载决议是一个很复杂的过程，但简单来说：非模板的函数重载比模板的更特殊，有约束的函数重载比无约束的更特殊，需要实参隐式转换更少的函数更为特殊。而最后，如果有相比于其他重载都更特殊的重载，那么它成为“最佳匹配函数”而被选中调用。[/collapse]例如：namespace User { struct A {}; void foo(A); //non-template, more specific √ } namespace lib { template<typename T> void foo(T arg); //templated, more general } int main() { User::A a; using lib::foo; foo(a); return 0; }这就导致了，ADL重载拥有了超过定制逻辑的最高优先级，它如果存在就总是会生效。这与我们的“定制”需求完全背道而驰。我们无法定制优先于ADL重载的派发，甚至也无法[font color="Blue"]检查ADL重载到底是否是正确的[/font]。2.3 用functor来阻止ADL但是正如上文的begin(_Tp&& __t)实现的一样，ADL重载在我们的“派发器”中是可以自由安排的，只要进入“派发器”当中，各种逻辑都可以灵活实现。所以我们现在面临的问题就剩下一个了：[font color="Red"]如何屏蔽ADL？[/font]所幸这种方法在现有语言机制里是存在的：如果无限定调用找到了非函数的声明，那么ADL就不会发生了！……非函数的声明？[font color="ForestGreen"]Functor！[/font]我们用functor去实现就好了呀！由于需要保持写法一致，那么我们需要使用functor的实体——也就是函数对象（以下代码来自libstdc++）3：inline namespace __cust { inline constexpr __cust_access::_Begin begin{}; // ... } namespace __cust_access // in std::ranges { struct _Begin { // ... public: template<__maybe_borrowed_range _Tp> requires is_array_v<remove_reference_t < _Tp>> || __member_begin <_Tp> || __adl_begin <_Tp> constexpr auto operator()(_Tp &&__t) const noexcept(_S_noexcept<_Tp &>()) { if constexpr (is_array_v < remove_reference_t < _Tp >>) { static_assert(is_lvalue_reference_v < _Tp > ); return __t + 0; } else if constexpr (__member_begin < _Tp >) return __t.begin(); else return begin(__t); } }; }事实上，这正是标准库中一个[font color="ForestGreen"]完整的CPO的实现[/font]。由于它是一个函数对象，所以当我们：using namespace std::ranges; begin(x);的时候4 ，就只会正确找到ranges::begin啦！然后它内部封装好了我们之前所预期的复杂的定制过程。所以CPO本质上，就是一个[font color="BlueViolet"]能够正确解决复杂定制问题的functor对象。[/font]来看一眼标准的定义，如下：1 A customization point object is a function object (20.14) with a literal class type that interacts with program-defifined types while enforcing semantic requirements on that interaction.2 The type of a customization point object, ignoring cv-qualififiers, shall model semiregular (18.6).template<class T> concept semiregular = copyable<T> && default_initializable<T>;[scode type="green" size=""]现在我们可以总结一下CPO解决的问题了：5 [/scode][font color="MediumSlateBlue"]它能够在编译期对于简单的无限定调用，根据实参的不同情况进行判断，并转发到正确的实现；[/font][font color="MediumSlateBlue"]它能够检查实参与ADL重载（若存在）是否符合语法要求，并在需要的时候报错。[/font]3 Niebloid对象花了这么久介绍CPO，std::ranges中的函数调用和它有什么关系？首先，这些算法大多都需要派发到标准算法去实现，例如sort、kth_element等等，不存在视情况转发的需求。那我们看看实际情况好了。当我们用类似的两步法，using namespace std::ranges之后去非限定调用一个ranges算法，会发生什么？注意到如果我们实参使用了STL Container等，那么两步法会通过ADL引入namespace std当中的同名函数，因此这里就需要进行重载决议了，我们举一些例子：// std template< class InputIt, class UnaryFunction > UnaryFunction for_each( InputIt first, InputIt last, UnaryFunction f ); // std::ranges template< std::input_iterator I, std::sentinel_for S, class Proj = std::identity, std::indirectly_unary_invocable<std::projected<I, Proj>> Fun > constexpr ranges::for_each_result<I, Fun> for_each( I first, S last, Fun f, Proj proj = {} ); // std template< class InputIt, class OutputIt > constexpr OutputIt copy( InputIt first, InputIt last, OutputIt d_first ); // std::ranges template< std::input_iterator I, std::sentinel_for S, std::weakly_incrementable O > requires std::indirectly_copyable<I, O> constexpr ranges::copy_result<I, O> copy( I first, S last, O result );有没有注意到什么？std当中的重载普遍比std::ranges当中的更为特殊！ 这是因为std::ranges当中的算法普适性更强——虽然派发的终点是一样的，但是接收的ranges可能有不同的情况，std::ranges中的算法可以全部接纳他们并且做正确的调整以适配到实际算法的调用！但由于这一点，跟我们之前所面临的情况一样，ADL查找会使得std::ranges当中的重载劣于std当中的，从而无法被调用。[font color="Red"]所以这里我们需要做的事情就是：屏蔽掉ADL查找！[/font][scode type="blue" size=""]这种std::ranges中的需要屏蔽ADL的算法调用，我们根据提出者niebler的名字，命名为：Niebloid。[/scode]然后……我们是不是刚发明了一个现成的工具来着？对！就是CPO。所以当前版本的C++标准（20），就选择了使用CPO来解决这一问题。把上述对象实现成CPO的形式，非预期的ADL查找就被解决啦！比如std::ranges::copy就实现如下： struct __copy_fn { template<input_iterator _Iter, sentinel_for<_Iter> _Sent, weakly_incrementable _Out> requires indirectly_copyable<_Iter, _Out> constexpr copy_result<_Iter, _Out> operator()(_Iter __first, _Sent __last, _Out __result) const { return ranges::__copy_or_move<false>(std::move(__first), std::move(__last), std::move(__result)); } template<input_range _Range, weakly_incrementable _Out> requires indirectly_copyable<iterator_t<_Range>, _Out> constexpr copy_result<borrowed_iterator_t<_Range>, _Out> operator()(_Range&& __r, _Out __result) const { return (*this)(ranges::begin(__r), ranges::end(__r), std::move(__result)); } }; inline constexpr __copy_fn copy{};所以，现在我们可以给Niebloid下一个定义了：[font color="MediumSlateBlue"]Niebloid是指需要屏蔽掉ADL查找才能正确得到调用的一系列ranges算法。[/font]而它和CPO的关系是：[font color="MediumSlateBlue"]目前所有主流编译器都使用CPO去实现Niebloid。[/font]CPO的弊病前面我们提到了，CPO一个重要的作用就是帮我们筛选“正确”的重载。然而它真的能够实现吗？还是用begin的例子来看，研究下__adl_begin到底做了哪些检查： template<typename _Tp> concept __adl_begin = __class_or_enum<remove_reference_t<_Tp>> && requires(_Tp& __t) { { __decay_copy(begin(__t)) } -> input_or_output_iterator; };啊！所以其实它只能检测语法而非语义。这似乎是很自然的事情，但引发了一个大问题：[font color="DodgerBlue"]我们怎么就能保证这样的函数真的做了我们想要begin去做的事情呢？[/font][collapse status="true" title="一种可能的糟糕情况"]比如我们当然可以有一个begin()函数，调用它会发起一个网络连接，接收一串数据并存储，然后返回数据容器的begin迭代器。这完全是合规的。[/collapse]如果这种事情真的发生了，不会有任何报错，也不会有任何提示。它就是会产生一个错误的逻辑，极难排查。当然你可以说：用户定制功能属于库的一部分。程序员有责任对此注意并负责。当然，我们很容易意识到begin是一个会具有特殊含义的名字，于是可以规避它。但当sender/receiver加入标准库以后呢？当更多的第三方库杂糅在一起以后，并且都使用了CPO来解决问题呢？CPO方案正在做的事情，是引入一种全新的耦合形式——我将它称之为[font color="BlueViolet"]名字耦合[/font]。仅仅是名字和语法要求的匹配，就能够在我们不经意的情况下扰乱函数调用的派发。如果这种情况变的普遍，那么产生意外的可能性就会急剧升高。我们可不可以审慎的考察我们对于函数名字的使用并且尽量让它符合库的预期？当然可以，但这没有从根本上解决问题，除非你用的库既简单、又清晰，否则当CPO的数量增多的时候，总有失察的风险。——况且，你不会知道第三方库作者的脑子里在想些什么的。那么我们就需要在为我们的对象设计函数时就需要多一份这种额外的注意：这个名字是否在哪里具有特殊的含义？我是否应该保留它？当然，如果这样注意了，按现在大家对名字的使用数量，暂时不会有什么大的风险。但是问题是：[font color="Red"]当我们需要开始考虑这种额外的小心时，证明有些事情就已经不对劲了。[/font]所以，如果有更好的方法，为什么不采取呢？新方案1 - tag_invoketag_invoke就是我所说的——更好的方法。就像重载一样，CPO的本质也是静多态。那么回想一下我们原本的C++语言系统中，静多态都是怎么实现“定制”的？[tabs][tab name="模板特化" active="true"]当我们要给一个自定义类型提供hash算法，特别是以供其他操作（例如std::unordered_set）调用时，我们会怎么做？是这样的：template <> struct std::hash<User::A> { std::size_t operator()(const User::A& arg) const { // ... } };[/tab][tab name="策略模式"]当我们要给优先队列指定一个偏序的时候，形式是这样的：namespace User{ struct my_type; struct my_type_greater : std::binary_function<my_type, my_type, bool> { bool operator()(const my_type& lhs, const my_type& rhs) const { // ... } }; } std::priority_queue<User::my_type, vector<User::my_type>, User::my_type_greater> pq;[/tab][/tabs]可以看到，以上两种方式大相径庭，但它们都有一个共同点：必须显式指明给某种使用机制去使用。我们既不可能在User空间里声明一个hashfunctor然后让它自动生效，也不可能让my_type_greater声明过后就自动生效。前者需要我们明确地给std::hash添加特化，后者需要我们把策略明确地写入到priority_queue的模板实参当中去。[font color="Red"]只有当我们明确地让某个机制去使用我们代码当中的某个实体，这个实体才有可能被它使用。[/font]——这是上述传统用法与CPO最大的不同；反之，[font color="Red"]这种“无需授意”的使用，也是CPO“名字耦合”最大的不安全来源。[/font]tag_invoke方案解决了这一问题：它通过一种“显式的注册机制”引入了这种对于“主动性”的要求，并且把CPO对于global name的占有缩小到了仅有一个。简而言之，这个方案是这样的：在CPO方案里，假设我们有一个叫foo(arg0, arg1)的CPO在库中，那么我们user namespace当中的foo(arg0, arg1)也就此被该用法占据了。只要我们用符合该CPO的constraint的形式声明了，那么它就可能被自动的纳入std::foo的派发考虑当中。像这样：namespace Lib { namespace detail { template<typename T> void foo(T, T) // default implementation { std::cout << "Lib define." << std::endl; } struct cpo_foo { template<typename T> void operator()(T arg0, T arg1) const // dispenser { foo(arg0, arg1); // distribute } }; } inline detail::cpo_foo foo{}; } namespace User { struct A { friend void foo(A, A) // ADL implementation { std::cout << "User define." << std::endl; } }; struct B {}; // No ADL implementation }问题的关键就是在于，dispenser可以直接去找到User中暴露的foo，这很不安全。而tag_invoke方案所做的，就是把cpo_foo的派发[font color="BlueViolet"]约束在“用户明示用于CPO的实现”中[/font]。这要怎么明示呢？我们上面的std::hash模板特化已经给出了答案：[font color="BlueViolet"]把所有候选注册成同一个模板的重载[/font]。而对标std::hash模板的框架，我们这里称之为tag_invoke。CPO是通过类型+对象实现的，这点给予了tag_invoke很大的便利：tag_invoke是一个函数，他的第一个参数为CPO，仅仅用作“标签”，标明当前tag_invoke派发或调用的是什么函数，后随该函数的实参。原本的派发器将对foo(arg0, arg1)的调用变为了tag_invoke(foo{}, arg0, arg1)（第一个参数传入重载对应的CPO，以表明当前派发谁），因此不管是Lib还是User中，原本的foo实现，当且仅当我们用于CP的时候，将其定义为tag_invoke(Lib::foo{}, arg0, arg1)。由于CPO不存在转型问题，所以重载决议根据第一个实参就控制在了全部foo的tag_invoke实现上。将上例用tag_invoke改写，完整例子如下：#include <iostream> namespace Lib { template<auto &tag_name> using tag_t = std::decay_t<decltype(tag_name)>; // tool for parsing CPO's type. namespace detail { struct cpo_foo { template<typename T> void operator()(T arg0, T arg1) const // dispenser { tag_invoke(cpo_foo{}, arg0, arg1); // distribute } }; template<typename T> void tag_invoke(cpo_foo, T, T) // default implementation { std::cout << "Lib define." << std::endl; } } inline detail::cpo_foo foo{}; } namespace User { struct A { friend void tag_invoke(Lib::tag_t<Lib::foo>, A, A) // ADL implementation { std::cout << "User define." << std::endl; } }; struct B {}; // No ADL implementation } using namespace Lib; int main() { User::A a; foo(a, a); User::B b; foo(b, b); return 0; }输出：User define. Lib define.就这样，大量不同global name的独占，变成了一个global name+不同的标签。这本质是一种封装。[scode type="share" size=""]我的看法：[/scode]CPO在名字使用上的新的、不易察觉的、危险的耦合，让我无法喜欢这个方案。正因如此，tag_invoke是我目前最喜欢的解决办法。相比于下面要叙述的语言机制方案，它也足够保守：如果能够通过技巧去解决问题，还是尽量避免对语言进行改动，尤其是增加新的关键字用法。略微增加库的代码量不是问题，只要它有合理的对外屏障；但提出新的关键字总是一个需要谨慎的问题。新方案2 - 定制点函数现在我们去考虑一些更本质的问题——我们之前提到过，有没有觉得“定制点”和一些C++中已经存在的东西非常的相似？“根据不同的对象把同一功能（接口）派发给不同的具体实现”——这不就是[font color="BlueViolet"]虚函数[/font]嘛！从本质上说，我们可以认为定制点对象就是静态、非类的虚函数6，那么自然有一种想法，就是把它的实现通过“虚函数”一样的新机制去实现，不再需要我们为了一些ADL、functor之类的tricky method去绞尽脑汁了。P2547R0 - Language support for customisable functions (open-std.org)这个提案就给出了这样的建议，我们直接把virtual、override、=0……这些类继承当中的东西挪到非类的函数调用中来，把具体细节让编译器去实现，不就好了么？比如这样7：namespace shapes { template<typename T> virtual float area(const T& shape) noexcept = 0; } namespace mylib { struct circle { float radius; }; inline float shapes::area(const circle& c) noexcept override { return c.radius * c.radius * std::numbers::pi_v<float>; } }当然，该提案只是目前借鉴了虚函数的语法，但它和虚函数有最大的区别：如我们前面一直做的一样，定制点函数是可以编译期解决重载的，它并不会真正引入“虚表”机制而带来运行时开销！[scode type="share" size=""]我的看法：[/scode]引入新的关键字、新的语言机制总是要慎之又慎。能够通过库内手法去解决的问题，尽量不要这样做。事实上，LWG这些年一直也在秉持这种态度，我认为是正确的。因为在库内通过tricky method去解决问题，即使会很麻烦，这种麻烦并不会外溢到使用时，是我们可以忽略的。而对语言的改变，不免会带来一些不可忽略的弊端——哪怕是最简单的关键字冲突。总结作为后来者，本文在很大程度上参考了下文罗列参考文献当中的第一篇，这篇文章非常详实，并且涉及了许多细节问题，十分建议拓展阅读。然而在关键的逻辑叙述上，本文与之存在很大不同。该文与许多其他分析CPO的文章，都把“两步法的繁琐和易错”当做一个主要的问题（可能是因为，niebler也这么认为了吧~），但私以为这完全不涉及问题的核心。CPO解决的问题核心如文中所说，是：两步法没有解决真正的“定制”问题，它只支持很简陋的“ADL优先”逻辑。因此，本文重新以该角度切入，引出了CPO和niebloid的发明。除了对CPO和niebloid这两个语言技术、模型的了解外，它们引出的对于“静多态”的思考，同样是值得进一步研究的：tag_invoke的本质就是std::hash的基于特化的多态；定制点函数的本质就是虚函数的基于语言机制的多态。那么还有没有其他的静多态手法可以用于解决这个问题呢？值得思考。参考文献本文主要的参考文献是：如何理解 C++ 中的 定制点对象 这一概念？为什么要这样设计？ - 知乎 (zhihu.com)下Mick235711的回答，而它的主要参考文献可能来自Niebloids and Customization Point Objects，这篇文章已经整理的比较不错了。如果想要多一些思考，我推荐继续阅读Barry的另一篇文章：Why tag_invoke is not the solution I want，它提供了多种“定制”方案的比较思路。tag_invoke的提案在p1895r0.pdf (open-std.org)；定制点函数（关键字支持）的提案则是P2547R0 - Language support for customisable functions (open-std.org)。 关于using的使用这里还有个可能的小误区，第一篇参考文献有所提及。 ↩ 有没有觉得这种需求和C++当中已经存在的一个机制比较相似？我们后面会从这个角度做思考的。 ↩ 使用inline namespace是一种防止名字污染的小技巧，第一篇参考文献有所提及。 ↩ 注意这里的using namespace和两步法当中的using foo不同，它只需要一次引入即可。并且和std当中的同名函数是冲突的（原因前文已述）。 ↩ 需要注意的是，后列的是我们对于CPO的功能认知。但按照语言标准，只要满足前述语法条件的，理论上就是一个CPO。 ↩ 参考文献中提到的Barry的文章，给出了类似的宏观视角。我猜他会比较喜欢语言方案。 ↩ 例子来自于原提案。 ↩
[scode type="blue" size=""]这篇文章是一个使用、编写向的源码速读笔记，不进行深度分析。所分析代码基于C++20标准定义接口和libstdc++，gccversion为11.2.0[/scode]Range——作为C++20四大组件之一——给我们提供了一套全新的便捷而友好的手法去实现管道语义（pipeline）。现在我们可以像很多现代语言一样，去对一个范围进行连贯的复杂操作，而不需要函数调用的嵌套了！比如说，我们可以简单地用这样一段代码去实现输出100以内的倒数10个素数：int main() { for (int x : views::iota(1, 100) | views::filter(isprime) | views::reverse | views::take(10) | views::reverse ) std::cout<<x<<' '; return 0; }输出是53 59 61 67 71 73 79 83 89 97最重要的是，它不仅如此的方便，而且是zero-overhead的！那么这是怎么做到的呢，我们来对照源代码逐步分析：分析1 ranges我们都知道range可以很容易的代表一个范围（可迭代）对象，有了它我们可以非常方便的调用ranges算法。例如：既然vector存储了一个范围（我们假设它叫arr），那么我们就可以用std::ranges::sort(arr)去代替std::sort(arr.begin(), arr.end())。这是怎么实现的呢？首先，range如何表示一个范围的呢？我们看一下它的定义： template<typename _Tp> concept range = requires(_Tp& __t) { ranges::begin(__t); ranges::end(__t); };喔！具有begin和end属性的对象就可以被看做是一个range——这也非常合理，我们只要能保证它能够通过begin和end去迭代，那么它就具有了作为“范围”被使用的资格。但begin和end的语义怎么保证的呢？它们也有可能表示某个事态的开始和结束，而非我们想要的迭代器。这点我们的ranges::begin和ranges::end当然做了检查，我们以ranges::begin为例： template<__maybe_borrowed_range _Tp> requires is_array_v<remove_reference_t<_Tp>> || __member_begin<_Tp> || __adl_begin<_Tp> constexpr auto operator()(_Tp&& __t) const noexcept(_S_noexcept<_Tp&>()) { if constexpr (is_array_v<remove_reference_t<_Tp>>) { static_assert(is_lvalue_reference_v<_Tp>); return __t + 0; } else if constexpr (__member_begin<_Tp>) return __t.begin(); else return begin(__t); } };可以看到检查是通过requires来实现的，看看调用者是否可以进行begin调用或者具有begin语义（例如数组类型），并且派发到正确的调用。对于通过成员函数和ADL查找提供的迭代器，这里就做了检查： template<typename _Tp> concept __member_begin = requires(_Tp& __t) { { __decay_copy(__t.begin()) } -> input_or_output_iterator; }; template<typename _Tp> concept __adl_begin = __class_or_enum<remove_reference_t<_Tp>> && requires(_Tp& __t) { { __decay_copy(begin(__t)) } -> input_or_output_iterator; };可以看到这里检测了它们是否真的能够提供用来遍历range的迭代器。考虑一个极端情况，如果我们的range提供了begin和end成员，也满足了提供input_or_output_iterator的requires检测，但实际上该迭代器并不是用来迭代范围的怎么办呢？答案是没有办法。形式检查无法检测实质，确实是concept体系所面临的一个普遍问题，只能通过程序员自己意识到才能保证不发生这种情况。幸运的是这不难避免。当你去使用某个concept的时候你至少有机会意识到它会对哪些名字的特定使用敏感。总之，有了range就意味着可以获取begin和end，那么两种调用很容易等价。只不过range算法会需要通过niebloid手法保证非ADL调用——为什么需要这样，我们会有专门的文章讲解。现在以sort为例看一下range algorithm的实现：struct __sort_fn { //... template<random_access_range _Range, typename _Comp = ranges::less, typename _Proj = identity> requires sortable<iterator_t<_Range>, _Comp, _Proj> constexpr borrowed_iterator_t<_Range> operator()(_Range&& __r, _Comp __comp = {}, _Proj __proj = {}) const { return (*this)(ranges::begin(__r), ranges::end(__r), std::move(__comp), std::move(__proj)); } }; inline constexpr __sort_fn sort{};在里面正常使用begin和end即可，并且最终将其转发到std当中的对应实现。niebloid本身一般只对range参数做拆分和分类调用处理。2 viewsrange保证能够代表“范围”了，接下来是views。view本身是什么，和range有什么关系和区别？首先我们来看看标准当中的规定：就像早在C++17就引入的string_view一样，view（视图）这个概念本身代表的含义是：对对象的一种固定的观察视角，而并不建立任何原对象的数据拷贝。它的核心是轻量。再看一下语言上是如何实现这种规定的：template<typename _Tp> concept view = range<_Tp> && movable<_Tp> && default_initializable<_Tp> && enable_view<_Tp>; template<typename _Derived> requires is_class_v<_Derived> && same_as<_Derived, remove_cv_t<_Derived>> class view_interface : public view_base { //... } struct view_base { }; template<typename _Tp> inline constexpr bool enable_view = derived_from<_Tp, view_base>; template<typename _Tp> concept viewable_range = range<_Tp> && (borrowed_range<_Tp> || view<remove_cvref_t<_Tp>>); template<typename _Tp> concept borrowed_range = range<_Tp> && __detail::__maybe_borrowed_range<_Tp>;由此可以看到，一个view本身：需要是一个range需要是可移动的（作为视图的性能要求）需要无歧义地从view_base派生（意味着我们希望它成为一个view）也就是说，一个view本质上就是一个range，但不复制原对象资源，而是通过一种固定的观察方式每次灵活地使用。而附带地，一个range想要被作为view使用（例如，我们在一个pipeline中一开始传入一个range作为本体），则需要满足borrowed_range概念，它是指：我们能按值传递这个range（就像我们所做的一样）且函数能够返回从它上得到的无悬垂的迭代器（以供管道下游使用）有了viewable_range这个概念，我们就可以通过范围适配器非常容易的生成一个view了。事实上，对于一个view而言，一般存在2个相关工具，我们以take_while为例来看一下：可以看到，这里主要出现了take_while_view类和take_while对象：take_while_view类型被称作范围适配器： template<view _Vp, typename _Pred> requires input_range<_Vp> && is_object_v<_Pred> && indirect_unary_predicate<const _Pred, iterator_t<_Vp>> class take_while_view : public view_interface<take_while_view<_Vp, _Pred>> 同时这里还给出了一个**显式推导指引**来帮助推导： template<typename _Range, typename _Pred> take_while_view(_Range&&, _Pred) -> take_while_view<views::all_t<_Range>, _Pred>; ——它会自动通过`views::all`来帮助我们把合规的`range`推导成`view`，非常重要。由此我们就能够通过一个`range`去生成各种`view`啦，例如： auto less_than_five_view = take_while_view(arr, [](int x){ return x < 5; });take_while则是一个范围适配器对象，它的本质是一个niebloid（如有不解，请参看我的另一篇文章——这会儿还没写完）。调用ranges::views::take_while(e, f)就等价于take_while_view(e, f)。至此我们知道，view是一种特殊的range，通过范围适配器们，我们可以轻松地用range产生我们所需要的view。而这个view就需要我们使用技巧去完成轻量的（必须是懒惰的） 操作辣。比方说，我现在就可以实现一个简易版的“素数视图”：template<view T> struct prime_view : view_interface<prime_view<T>> { template<typename InnerIter, typename Sentinel> struct Iter { InnerIter now_pointer; Sentinel sentinel; typedef std::iterator_traits<InnerIter> __traits_type; typedef InnerIter iterator_type; typedef typename __traits_type::iterator_category iterator_category; typedef typename __traits_type::value_type value_type; typedef typename __traits_type::difference_type difference_type; typedef typename __traits_type::reference reference; typedef typename __traits_type::pointer pointer; Iter() = default; Iter(const InnerIter &innerIter, const Sentinel &sentinel) : now_pointer(innerIter), sentinel(sentinel) {} constexpr static bool isprime(const int &arg) { if (arg < 2) return false; for (int i = 2; i <= sqrt(arg); i++) if (arg % i == 0) return false; return true; } constexpr Iter &operator++() { do { ++now_pointer; } while (!isprime(*now_pointer) && now_pointer != sentinel); return *this; } constexpr Iter operator++(int) { auto t = now_pointer; do { ++now_pointer; } while (!isprime(*now_pointer) && now_pointer != sentinel); return t; } constexpr bool operator!=(const Iter &arg) { return now_pointer != arg.now_pointer; } constexpr value_type operator*() { return *now_pointer; } }; constexpr prime_view() = default; constexpr prime_view(T &&m_base) : m_begin(m_base.begin()), m_end(m_base.end()) {} constexpr auto begin() const { return Iter(m_begin, m_end); } constexpr auto end() const { return Iter(m_end, m_end); } private: decltype(std::declval<T>().begin()) m_begin; decltype(std::declval<T>().end()) m_end; }; template<viewable_range range> prime_view(range &&) -> prime_view<views::all_t<range>>; int main() { std::vector<int> arr(100); std::iota(arr.begin(), arr.end(), 0); for (auto v: prime_view(arr)) std::cout << v << std::endl; return 0; }由此也可以看到：视图的实现没有一定之规，主要在于如何巧妙地满足views的概念要求：它需要是轻量的。3 pipeline接下来是我们所关心的最后一个问题：pipeline究竟是如何实现的？——也就是文章一开头的那个例子，那种特别pythonic的写法究竟是什么东西？那个operator|是怎么实现的？首先我们要找到operator|的实现，它在这里：struct _RangeAdaptorClosure { // range | adaptor is equivalent to adaptor(range). template<typename _Self, typename _Range> requires derived_from<remove_cvref_t<_Self>, _RangeAdaptorClosure> && __adaptor_invocable<_Self, _Range> friend constexpr auto operator|(_Range&& __r, _Self&& __self) { return std::forward<_Self>(__self)(std::forward<_Range>(__r)); } // Compose the adaptors __lhs and __rhs into a pipeline, returning // another range adaptor closure object. template<typename _Lhs, typename _Rhs> requires derived_from<_Lhs, _RangeAdaptorClosure> && derived_from<_Rhs, _RangeAdaptorClosure> friend constexpr auto operator|(_Lhs __lhs, _Rhs __rhs) { return _Pipe<_Lhs, _Rhs>{std::move(__lhs), std::move(__rhs)}; } };可以看到，operator|定义在_RangeAdaptorClosure类型中。这里有两个重载，第一个从line6可以看出，是个方便调用，让我们可以直接在管道开始处使用range_arg_for_adaptor | generate_view_adaptor | ...来替代generate_view_adaptor(range_arg_for_adaptor) | ...或者是views::all(range_arg_for_adaptor) | generate_view_adaptor | ...。从第二个重载可以看到：所谓的管道（pipe）就是不断从一个闭包应用下一个范围适配器，接连产生新的闭包的过程。而由于闭包需要是一元的，因此普通的范围适配器通过偏调用的辅助类型拆分为闭包对象：//普通范围适配器 template<typename _Derived> struct _RangeAdaptor { // Partially apply the arguments __args to the range adaptor _Derived, // returning a range adaptor closure object. template<typename... _Args> requires __adaptor_partial_app_viable<_Derived, _Args...> constexpr auto operator()(_Args&&... __args) const { return _Partial<_Derived, decay_t<_Args>...>{std::forward<_Args>(__args)...}; } }; //其中一个偏调用模板 // A range adaptor closure that represents partial application of // the range adaptor _Adaptor with arguments _Args. template<typename _Adaptor, typename... _Args> struct _Partial : _RangeAdaptorClosure { //... // Invoke _Adaptor with arguments __r, _M_args... according to the // value category of this _Partial object. template<typename _Range> requires __adaptor_invocable<_Adaptor, _Range, const _Args&...> constexpr auto operator()(_Range&& __r) const & { auto __forwarder = [&__r] (const auto&... __args) { return _Adaptor{}(std::forward<_Range>(__r), __args...); }; return std::apply(__forwarder, _M_args); } template<typename _Range> requires __adaptor_invocable<_Adaptor, _Range, _Args...> constexpr auto operator()(_Range&& __r) && { //... } //... };至此我们知道了，C++是如何使用范围适配器（闭包）这一设计来提升我们的编码体验的。它本质上是在整个pipeline被使用前，就巧妙地将所有适配器连接在一起形成了一个全新的闭包（而不是在调用时再让数据依次通过他们1——虽然实质是相同的）。本质上类似于将一串嵌套的函数调用f(g(h(x)))组合成一个nested_func(x)。在pipeline中如何被添加到下一个RangeAdaptor对应参数位置？我们来追踪一个具体调用就一目了然了：for (auto x: arr | views::take_while([](int x) { return x < 5; })) std::cout << x << std::endl;比如说这个合并，take_while会通过上述的_Partial生成一个偏调用的对象——就像bind函数所做的一样，给arr留下一个slot： template<typename _Derived> struct _RangeAdaptor { // Partially apply the arguments __args to the range adaptor _Derived, // returning a range adaptor closure object. template<typename... _Args> requires __adaptor_partial_app_viable<_Derived, _Args...> constexpr auto operator()(_Args&&... __args) const { return _Partial<_Derived, decay_t<_Args>...>{std::forward<_Args>(__args)...}; } };然后具体而言我们就拿到了一个views::__adaptor::_Partial<std::ranges::views::_TakeWhile, <lambda> >对象。这样再调用_RangeAdaptorClosure的对应重载就可以了。 这在语法上也是不可能的，读者可以自行思考原因。 ↩
committee的取舍——为什么显式实例化可以打破访问控制
在考虑一个经典面试题——“如何在类外访问类的私有成员”的时候，群友在网上发现了如下一个做法：通过显式实例化对access control打洞#include <cassert> #include <iostream> class A { public: int X() { return x_; } private: int x_; }; int A::*FiledPtr(); //返回A的int成员指针的函数声明 template <int A::*M> //这里A是非待决的一个限定，该模板形参是A成员指针 struct Rob { friend int A::*FiledPtr() { return M; } }; template struct Rob<&A::x_>; int main() { A o; o.*FiledPtr() = 10; assert(o.X() == 10); }这个方法是通过模板的显式实例化（Explicit Instantiation）实现的，因为在显式实例化的规则中有这样一个特例：The usual access checking rules do not apply to names in a declaration of an explicit instantiation or explicit specialization, with the exception of names appearing in a function body, default argument, base-clause, member-specifification, enumerator-list, or static data member or variable template initializer.1也就是说，当我们去做显式实例化的时候，实际上是可以合法访问某个class的private成员的。由此就催生了如上面代码所示的，对类的访问控制机制的打洞。按常理来说，这是一个巨大的隐患——因为我们可以合法的绕过访问控制了，而一般来说这种情况都是UB的2。那么，是什么需求使得标准不得不给予显式实例化以特权，哪怕是以给access control留下后门为代价呢？请看下面的例子：需求——导出显式实例化class Foo { private: struct Bar; template<typename T> class Baz { }; public: void f(); // does things with Baz<Bar> }; // explicit instantiation declaration extern template class Foo::Baz<Foo::Bar>;这是个很好的例子，给了我们对于需求的直观感受：当你需要导出一个显式实例化模板，而这个模板实例化涉及到私有成员的时候，访问控制和实例化的需求就产生了一个必然的冲突。而由于显式实例化是一个确实存在的现实需求，C++的做法从来就不是为了维护其他规则而抹杀实现合理需求的可能性。如果真的要去通过这样的方法对access control打洞，那么可能产生的后果应该由程序员自己来掌控。大佬的评价（翻译）Johannes Schaub是这样说的（甚至还给出了另外的方法）：但实际上，这并不表明c++的访问规则不可靠。语言规则的设计是为了防止意外的错误——如果你试图抢夺一个对象的数据，语言的设计不会采取很长的方式来阻止你。而著名的GotW中也讨论过这个问题，给出了这样的见解：以上给出了两个C++特性之间的有趣互动。访问控制模型和模板模型。事实证明，成员模板似乎隐含了 "破坏封装"的意思，因为它们有效地提供了一种可移植的方式来绕过类的访问控制机制。这实际上并不是一个问题。这里的问题是保护Murphy还是保护Machiavelli......也就是说，保护意外的滥用（该语言做得很好）与保护故意的滥用（这实际上是不可能的）。最后，如果一个程序员非常想颠覆这个系统，他就会找到一个方法，正如上面的例子1到3所证明的。这个问题的真正答案是。不要这样做！无可否认，甚至Scott Meyers也公开说过，有些时候，我们很想有一种快速的方法可以暂时绕过访问控制机制，比如为了在调试时产生更好的诊断输出......但这不该是你在编写代码时养成的习惯，而且它应该出现在你的开发环境的"警告行为"列表中。来自GotW的编码标准忠告：永远不要颠覆语言；例如，永远不要试图通过复制一个类的定义并添加一个友元声明来打破封装，或者提供一个模板成员函数的本地实例（GotW #76)总结来说，通过技巧来故意绕过某些控制永远都是可能的，标准和语法检查想要阻止的不是这样的行为，而是无意识的错误。 ISO/IEC-14882:2020-13.9.1.6 ↩ 大多数时候，其原因是：不同访问控制级别的非静态成员存储顺序是不确定的。手动操作指针偏移一定是不具有普适性的。 ↩