
如何在历史上永远写下您的名字? 第一个飞向月球? 第一个遇到外星人的想法? 我们有一个更简单的方法-您可以使自己适应C ++语言标准。
C ++ Ranges的作者Eric Nibler提供了一个很好的例子。 “记住这一点。 他在Twitter上写道: “在2000年2月19日,“生物碱”一词在WG21会议上首次被使用。
的确,如果您转到CppReference,在cpp / algorithm / rangescpp / algorithm / ranges部分中 ,您会在那里找到许多参考(niebloid)。 为此,甚至制作了一个单独的dsc_niebloid Wiki模板。
不幸的是,我没有找到有关此主题的任何正式的正式文章,因此决定写我自己的文章。 这是进入建筑宇航学深渊的一个小而有趣的旅程,我们可以跳入ADL疯狂的深渊并结识生物。
重要提示:我不是真正的焊工,但有时他会根据需要纠正C ++代码中的错误。 如果您花一点时间来帮助发现推理错误,那将是很好的。 “帮助旅行者达莎收集合理的东西。”
查找
首先,您需要确定条款。 这些都是众所周知的事情,但是“显式优于隐式”,因此我们将分别讨论它们。 我不使用真正的俄语术语,而是使用英语。 这是必要的,因为即使在本文中,“限制”一词也可以与至少三个英文版本相关联,两者之间的差异对于理解非常重要。
例如,在C ++中,存在名称搜索或换句话说,即查找的概念:在程序中找到名称时,它将在编译期间使用其声明进行编译。
查找可以是合格的 (如果名称在范围的权限运算符::
的右边),在其他情况下则不是熟练的 。 如果查找合格,则我们绕过类,名称空间或枚举的相应成员。 可以将其称为记录的“完整”版本(就像Straustrup的翻译中所做的那样),但是最好保留原始拼写,因为这是指一种非常特殊的完整性。
ADL
如果查找不合格,那么我们需要确切地了解在哪里查找名称。 此处包含一个称为ADL的特殊功能:基于参数的查找 ,否则-搜索Koenig (创造了“反模式”一词的人,鉴于以下文本,这有点象征意义)。 Nicolai Josuttis在他的“ C ++标准库:教程和参考”一书中对此进行了如下描述:“要点是,如果在此函数的名称空间中定义了至少一个参数类型,则无需限定该函数的名称空间。”
看起来像什么?
#include <iostream> int main() { // . // , operator<< , ADL , // std std::operator<<(std::ostream&, const char*) std::cout << "Test\n"; // . - . operator<<(std::cout, "Test\n"); // same, using function call notation // : // Error: 'endl' is not declared in this namespace. // endl(), ADL . std::cout << endl; // . // , ADL. // std, endl std. endl(std::cout); // : // Error: 'endl' is not declared in this namespace. // , - (endl) - . (endl)(std::cout); }
深入了解ADL
看起来很简单。 还是不行 首先,根据参数的类型,ADL以九种不同的方式工作 ,用扫帚杀死。
其次,纯粹是实际的,假设我们具有某种交换功能。 原来是std::swap(obj1,obj2);
并using std::swap; swap(obj1, obj2);
using std::swap; swap(obj1, obj2);
行为完全不同。 如果启用了ADL,则已经从多个不同的交换中根据参数的名称空间选择了所需的交换! 根据观点,这个习语既可以看作是正面的例子也可以是负面的例子:-)
如果您觉得这还不够,可以将木柴放到帽子烤箱中。 这是最近由Arthur O'Dwyer撰写的 。 我希望他不要因为他的榜样而惩罚我。
想象一下,您有一个这样的程序:
#include <stdio.h> namespace A { struct A {}; void call(void (*f)()) { f(); } } void f() { puts("Hello world"); } int main() { call(f); }
当然,它不会编译错误:
error: use of undeclared identifier 'call'; did you mean 'A::call'? call(f); ^~~~ A::call
但是,如果在其中添加功能f
的完全未使用的重载 ,则一切正常!
#include <stdio.h> namespace A { struct A {}; void call(void (*f)()) { f(); } } void f() { puts("Hello world"); } void f(A::A); // UNUSED int main() { call(f); }
在Visual Studio上,它仍然会中断,但这就是她的命运,无法正常工作。
这是怎么发生的? 让我们深入研究标准 (不进行翻译,因为这样的翻译将是流行语的一个极其可怕的混搭):
如果参数是一组重载函数和/或函数模板的名称或地址,则其关联的实体和名称空间是与该集合的每个成员关联的实体和名称空间的并集,即与其参数关联的实体和名称空间类型和返回类型。 [...]此外,如果上述重载函数集使用模板ID命名,则其关联的实体和名称空间也将包括其类型的模板参数和模板模板参数。
现在,使用如下代码:
#include <stdio.h> namespace B { struct B {}; void call(void (*f)()) { f(); } } template<class T> void f() { puts("Hello world"); } int main() { call(f<B::B>); }
在这两种情况下,都将获得没有类型的参数。 f
和f<B::B>
是重载函数集的名称(根据上面的定义),而这样的集合没有类型。 要将重载折叠为单个函数,您需要了解哪种类型的函数指针最适合最佳call
重载。 因此,您需要收集一组call
候选者,这意味着启动名称call
的查找。 为此,ADL将开始!
但是通常对于ADL,我们应该知道参数的类型! 在这里,Clang,ICC和MSVC错误地中断,如下所示(但GCC没有):
[build] ..\..\main.cpp(15,5): error: use of undeclared identifier 'call'; did you mean 'B::call'? [build] call(f<B::B>); [build] ^~~~ [build] B::call [build] ..\..\main.cpp(4,10): note: 'B::call' declared here [build] void call(void (*f)()) { [build] ^
即使使用ADL的编译器的创建者也有一点紧张的关系。
好吧,ADL似乎仍然是个好主意吗? 一方面,我们不再需要以礼貌的方式编写这样的奴隶式代码:
std::cout << "Hello, World!" << std::endl; std::operator<<(std::operator<<(std::cout, "Hello, World!"), "\n");
另一方面,为了简洁起见,我们换了一个事实,那就是现在有一种系统可以完全不人道地工作。 这是一个悲惨而雄伟的故事,讲述数十年来,《 Halloworld》的易写性如何影响整个语言。
范围和概念
如果打开Nibler Rangers库的描述,那么甚至在提到九倍体之前,您都会偶然发现许多称为(concept)的其他标记。 这已经是不错的东西了,但以防万一(对于老年人和骑手),我会提醒您它是什么 。
这些概念称为约束的命名集,这些约束适用于模板参数以选择最佳的函数重载和最合适的模板专业化。
template <typename T> concept bool HasStringFunc = requires(T a) { { to_string(a) } -> string; }; void print(HasStringFunc a) { cout << to_string(a) << endl; }
在这里,我们施加了一个限制,即该参数必须具有返回字符串的to_string
函数。 如果我们尝试将某些游戏放到不受限制的范围内,那么此类代码将无法编译。
这大大简化了代码。 例如,查看Nibler如何在range-v3中进行排序 ,该功能在C ++ 11/14/17中起作用。 有一个很棒的代码是这样的:
#define CONCEPT_PP_CAT_(X, Y) X ## Y #define CONCEPT_PP_CAT(X, Y) CONCEPT_PP_CAT_(X, Y)
这样以后您就可以:
struct Sortable_ { template<typename Rng, typename C = ordered_less, typename P = ident, typename I = iterator_t<Rng>> auto requires_() -> decltype( concepts::valid_expr( concepts::model_of<concepts::ForwardRange, Rng>(), concepts::is_true(ranges::Sortable<I, C, P>()) )); }; using Sortable = concepts::models<Sortable_, Rng, C, P>; template<typename Rng, typename C = ordered_less, typename P = ident, CONCEPT_REQUIRES_(!Sortable<Rng, C, P>())> void operator()(Rng &&, C && = C{}, P && = P{}) const { ...
我希望您已经希望看到所有这些内容,并且只在全新的编译器中使用准备好的概念。
定制点
在标准中可以找到的下一个有趣的事情是customization.point.object 。 它们在Nibler Ranges库中得到了积极使用。
定制点是标准库使用的功能,因此可以针对用户名称空间中的用户类型重载它,并且可以使用ADL查找这些重载。
在设计定制点时, cust
以下架构原则( cust
是一些虚构的定制点的名称):
- 调用
cust
的代码以std::cust(a)
格式std::cust(a)
或不合格的形式编写: using std::cust; cust(a);
using std::cust; cust(a);
。 两个条目的行为必须相同。 特别是,他们必须在与参数关联的名称空间中找到任何用户重载。 - 以
std::cust; cust(a);
形式使用cust
代码std::cust; cust(a);
std::cust; cust(a);
应该不能规避对std::cust
的限制。 - 自定义点调用应在任何相当现代的编译器上高效且最佳地工作。
- 该决定不应创建任何新的违反单一定义规则(ODR)的行为 。
要了解它是什么,您可以看一下N4381 。 乍一看,它们看起来像是一种编写自己的begin
, swap
, data
等版本的方法,而标准库则使用ADL来选择它们。
问题是,当用户为自己的类型和名称空间的某些begin
编写重载时,这与旧做法有何不同? 为什么他们甚至反对?
实际上,这些是std
中功能对象的实例。 它们的目的是首先对一行中的所有参数进行类型检查(设计为概念),然后将调用分派到std
的正确函数,或者放弃以ADL出售。
实际上,这不是您在常规非库程序中使用的那种东西。 这是标准库的功能,它使您可以在将来的扩展点添加概念检查,如果您弄乱了模板中的内容,那么反过来将导致显示更美丽,更易理解的错误。
当前的定制点方法存在两个问题。 首先,很容易破坏一切。 想象一下这段代码:
template<class T> void f(T& t1, T& t2) { using std::swap; swap(t1, t2); }
如果我们不小心对std::swap(t1, t2)
进行了合格的调用std::swap(t1, t2)
那么无论我们放在哪里std::swap(t1, t2)
我们自己的版本的swap
都将永远不会开始。 但更重要的是,没有办法将概念检查集中地附加到此类自定义函数实现上。 他们在N4381中写道:
“想象一下在将来的某一天, std::begin
将要求将其参数建模为Range
概念。 添加这样的限制对使用std::begin
惯用代码完全没有任何影响:
using std::begin; begin(a);
毕竟,如果将begin
调用分派到用户创建的重载版本,则对std::begin
的限制std::begin
被忽略。”
在propozal中描述的解决方案解决了两个问题,为此,我们使用std::begin
这种推测性实现中的方法(您可以看一下godbolt ):
#include <utility> namespace my_std { namespace detail { struct begin_fn { /* , begin(arg) arg.begin(). - . */ template <class T> auto operator()(T&& arg) const { return impl(arg, 1L); } template <class T> auto impl(T&& arg, int) const requires requires { begin(std::declval<T>()); } { return begin(arg); } // ADL template <class T> auto impl(T&& arg, long) const requires requires { std::declval<T>().begin(); } { return arg.begin(); } // ... }; } // inline constexpr detail::begin_fn begin{}; }
某些my_std::begin(someObject)
的合格调用始终会通过my_std::detail::begin_fn
,这很好。 不合格的通话会怎样? 让我们再次阅读我们的论文:
“如果在范围内my_std::begin
出现后立即my_std::begin
,情况会有所变化。 在查找的第一阶段,名称begin
解析为全局对象my_std::begin
。 因为查找找到了对象而不是函数,所以不执行查找的第二阶段。 换句话说,如果my_std::begin
是一个对象,则使用构造my_std::detail::begin_fn begin; begin(a);
my_std::detail::begin_fn begin; begin(a);
简单地等同于std::begin(a);
“正如我们所看到的,这将启动自定义ADL。”
这就是为什么在ADL调用用户提供的函数之前,可以在std
中的函数对象中进行概念验证的原因。 没有办法欺骗这种行为。
定制点如何定制?
实际上,“定制点对象”(CPO)并不是一个好名字。 从名称上还不清楚它们如何扩展,幕后机构是什么,它们喜欢什么功能...
这使我们想到了“九倍体”一词。 九进制是一个CPO,如果在类中定义了它,则调用函数X;否则,如果存在合适的自由函数,则调用函数X;否则,它将尝试执行函数X的后备。
因此,例如,在调用ranges::swap(a, b)
时,二倍体ranges::swap
将首先尝试调用a.swap(b)
。 如果没有这种方法,它将尝试使用ADL调用swap(a, b)
。 如果这样不起作用,请尝试使用auto tmp = std::move(a); a = std::move(b); b = std::move(tmp)
auto tmp = std::move(a); a = std::move(b); b = std::move(tmp)
auto tmp = std::move(a); a = std::move(b); b = std::move(tmp)
。
总结
正如Matt在Twitter上开玩笑说的那样,出于一致性的考虑, Dave曾建议使功能对象像常规函数一样使用ADL“工作”。 具有讽刺意味的是,他们禁用ADL并对他不可见的能力现在已成为他们的主要优势。
整篇文章为此做了准备。
“ 我只是了解一切,仅此而已。您会听吗?
您是否看过某事,看起来有些疯狂,然后从另一角度来看
看到他们正常的疯狂事情?

不要害怕。 不要害怕。 我心里好好 一切都会好起来的。 多年以来,我一直感觉不太好。 一切都会好起来的。

分钟的广告。 已经在本周 4月19日至20日举行了C ++ Russia 2019,该会议将就语言本身以及多线程和性能等实际问题进行演讲。 顺便说一下,会议由文章中提到的The C ++ Standard Library:A Tutorial and Reference的作者Nicolai Josuttis 开幕 。 您可以熟悉该计划并在官方网站上购买票。 剩下的时间很少,这是最后一次机会。