Niebloids
Niebloids 作为一个概念和模式已经存在了很长时间,它们最初由 Eric Niebler 在他的 C++14 range-v3 库中引入。
这个名字是作者本人在 2018 年Twitter 投票中建议的。
在深入了解 niebloids 之前,我们首先需要对以下主题有一些基本的了解:
- 非限定/限定查找
- ADL (Argument Dependent Lookup - 实参依赖查找)
- 定制点
- 定制点对象
我们将简要介绍它们。如果您已经熟悉它们,可以跳到最后一节。
限定查找与非限定查找
当我们在代码中使用任何类型的标识符时,会发生查找——编译器试图确定标识符的来源以及它在代码中的含义——它是一个类/一个对象/一个函数吗?它有什么类型?它在哪个命名空间中?等等。
我们不会深入探讨细节,因为两者都相当复杂,但我们只想知道后者——非限定查找——才是我们想要的。
从语法上看,限定查找意味着使用作用域解析运算符——即::
。
{
// auto value = my_ns::x; // Qualified lookup
// The above fails, because `x` is not present in my_ns namespace
// krabby_patty::patty_krabby(mr_krabs::secret_recipe); // Qualified lookup
// The above fails, because namespaces `krabby_patty` and `mr_krabs` are not present
std::print("Hello {}!", "World"); // Qualified lookup
auto value = my_ns::MyEnum::One; // Qualified lookup
::printf("Hello %s!", "World"); // Qualified lookup
// If there's nothing to the left of the scope resolution operator, it's refering to the global namespace
}
Hello hello; // Unqualified lookup to Hello, which is found in global scope
{
// auto value = x; // Unqualified lookup, no scope resolution operator
// The above fails, because `x` is nowhere to be found
// foo(1, 2, 3); // Unqualified lookup, no scope resolution operator
// The above fails, because `foo` is nowhere to be found
std::print("{}", hello.world()); // Qualified lookup to `std::print`, unqualified lookup to `hello`, which is found in upper scope
// Unqualified searches starting from the current scope up
print_me(my_ns::MyEnum::One);
// Unqualified lookup to `print_me`, qualified lookup to my_ns::MyEnum::One
// This works.
// But wait... print_me is not present in this scope, upper scope, nor global scope,
// unqualified lookup didn't pick it up, so how does that work??
// Is it a bug, magic, or maybe... ADL? :P
}
这两种方式有些许不同,但非限定查找有一个特殊属性,即在其之后会进行实参依赖查找。
实参依赖查找
实参依赖查找(有时也称为Koenig 查找),简称 ADL,本身就是一个非常高级的话题,但简而言之,它允许调用来自实参各自命名空间中的函数,而无需显式限定它们。
这听起来很复杂,但这里有一个你可能已经见过并写过无数次的简单例子
std::cout << "Hello, world!";
是的。如果没有 ADL,我们上面看到的就不会起作用。下面是上面的代码在编译器看来是什么样子:
operator<<(std::cout, "Hello, world!");
您甚至可以自己编写此代码,并看到它会编译(当然,没有人会真正编写这样的代码,它不是很实用,并且消除了运算符重载的便利性)。
这是上一节关于查找的例子
print_me(my_ns::MyEnum::One);
您可能会惊讶它能正常工作——直觉告诉我们它不应该,因为即使我们有一个完全限定的 my_ns::MyEnum::One
,它通过限定查找正确解析,但 print_me
没有限定,不应该出现在这个作用域中,非限定查找不应该找到它,这段代码不应该编译。
你的直觉是正确的……至少如果没有 ADL 的话。
ADL 所做的事情实际上非常简单*——当你对一个函数执行非限定调用时,ADL 会接着查看函数参数的类型,并将参数类型所在的每个命名空间中的所有函数添加到解析集中。
也就是说,当对此函数调用执行实参依赖查找时
print_me(my_ns::MyEnum::One);
我们查看参数的类型,在本例中是 my_ns::MyEnum
,并将其命名空间 (my_ns
) 添加到解析集中。
my_ns
确实包含接受 MyEnum::One
作为参数的 print_me
,因此它被找到并随后被调用。
* - 嗯,实际上它比这更复杂,而且它做了更多的事情,但这是主要思想,也是这里真正重要的。
非限定查找与 ADL 协同工作
非限定查找和实参依赖查找协同工作,一个接一个。关于 ADL 需要记住的重要规则是——在几种不同情况下,它不会在非限定查找之后执行。
其中一个我们感兴趣的案例是当非限定查找找到一个声明时,该声明
- 既不是函数
- 也不是函数模板
auto x = 2137;
auto y = x + 1; // unqualified lookup for x, no ADL because x is an object
如果你仔细想想——这是有道理的,ADL 应该只适用于函数调用,将其应用于被查找的对象是没有意义的。一个对象不接受任何我们可以检查的参数。
我为什么要谈论它?因为它对于理解 niebloids 至关重要。
所以再次强调,请记住——如果非限定查找找到的既不是函数也不是函数模板,那么 ADL 就不会发生
定制点
我们利用 ADL 的方法之一是创建所谓的定制点。
你可能不知道,但你可能已经自己使用过好几次了。
std::swap
、std::data
、std::begin
、std::end
——这些都是定制点。
如果您有一个自定义类型,并且对上述任何函数有特殊的、更高效或以其他方式更好的实现,您可以通过在您的类型命名空间中创建具有匹配接口的自由函数来“挂钩”您自己的实现。
namespace my_ns {
struct MyAwesomeType { };
auto swap(MyAwesomeType& first, MyAwesomeType& second) -> void {
// awesome implementation
}
}
这很好,但它有后果。这种设计迫使程序员记住这些定制点并正确处理它们。这意味着,每次编写通用代码时,都必须记住将默认实现带到当前作用域并进行非限定调用,例如:
template <typename T, typename U>
auto awesome_function(T t, U u)
{
using std::swap;
swap(t, u);
}
非限定调用 swap:swap(t, u);
确保如果类型已“挂钩”到定制点,它将被调用。
但是,如果没有用户提供的实现,这将产生错误,这就是我们使用 using std::swap;
将 std::swap
引入作用域的原因,这样当没有定义自定义行为时,将选择标准库中的默认行为。
所以,请记住,每当您看到这样的代码时
std::swap(a, b);
其中 a
和 b
是某些通用参数——这可能是一个 bug。
定制点有缺陷,定制点对象来救援
定制点存在两个问题:
- 程序员必须记住在通用代码中正确使用它们(为了实现正确的事情需要做更多事情)
- 如果标准决定对这些定制点的类型(例如,对于
begin
的Range
,这很合理)添加一些要求,那么将无法将其应用于“挂钩”的自定义实现。
这些问题通过定制点对象(简称 CPOs)解决。
它们是随 C++20 标准引入 C++ 的。
CPO 主要有两个目标
- 定义一个中心点来应用要求(这很容易做到,只需将要求应用于 CPO,并告诉程序员使用 CPO 而不是经典的定制点)
- 定义一个中心调用点,它将调度到“挂钩”实现或默认标准实现。
第二个目标是通过抑制(禁止)ADL 来完成的。我们如何做到这一点?目前只有两种方法可以实现。
其中一种方法是将它们制成函数对象,正如非限定查找和 ADL 协同工作一节中指定的,如果非限定查找发现的既不是函数也不是函数模板,那么 ADL 就不会发生——函数对象不属于这两种情况,因此 ADL 被禁用。
另一种实现这种行为的方法是使用内部编译器扩展,但目前没有已知的实现这样做。
CPO 在标准库的 std::ranges
命名空间下实现——std::ranges::swap
、std::ranges::begin
等。
Niebloids
最后,我们来到了本文的巅峰——niebloids。
Cppreference* 告诉我们 niebloid 的关键特征是:
- Niebloids 抑制 ADL
- 无法指定显式模板参数**
您可能还会遇到有人提到,尽管它们是模板化的,但可以毫无问题地传递给高阶函数。***
* 查看算法描述的末尾,每个范围化算法都包含相同的定义。
实现
您可能会注意到,niebloids 与 CPO 有相似的目标。是的,它们几乎相同,只是 niebloids 不允许任何定制点,也就是说,std::ranges::find
与 std::ranges::swap
几乎相同,只是创建名为 find
的自由函数实际上没有任何作用,因为这些标识符不用作定制点。
与 CPO 类似,它们通常也使用函数对象实现,尽管这不是必需的。
** 指定的特性之一是无法指定显式模板参数,对于所有主要供应商目前实现 niebloid 的方式来说,这是正确的,但请记住,这不是 niebloid 的计划特性,而只是 niebloid 作为函数对象实现的副作用。如果供应商决定为了方便实现/维护而发明特殊扩展,这种行为将来可能会改变。
*** 人们有时还会提到,niebloid 相对于普通函数的一个优点是,您可以毫无问题地将它们传递给高阶函数(在这种上下文中,即以其他函数作为参数的函数)。
#include <algorithm>
#include <vector>
template <typename Fun>
auto do_thing(std::vector<int>& ints1, std::vector<int>& ints2, Fun fun)
{
fun(ints1, ints2);
}
auto main() -> int
{
auto ints1 = std::vector { 5, 4, 3, 2, 1 };
auto ints2 = std::vector { 1, 2, 3, 4, 5 };
do_thing(ints1, ints2, std::ranges::swap); // Would not compile with std::swap
}
虽然这对于 range-v3 是正确的,因为其关键设计是使用函数对象并具有这些优点,但对于 std::
niebloid 而言并非如此,标准并未规定它们必须以允许此类行为的方式实现。
这意味着您不应该依赖这种行为,因为您的代码将来可能会崩溃。
将此类函数传递给 HOF 的方法
如果您需要将此类函数传递给某些 HOF,可以使用模拟预期接口的 lambda
#include <iostream>
#include <algorithm>
#include <vector>
template <typename Fun>
auto do_thing(std::vector<int>& ints1, std::vector<int>& ints2, Fun fun)
{
fun(ints1, ints2);
}
auto main() -> int
{
auto ints1 = std::vector { 5, 4, 3, 2, 1 };
auto ints2 = std::vector { 1, 2, 3, 4, 5 };
do_thing(ints1, ints2, [](auto& a, auto &b) { std::swap(a, b); }); // Compiles, tho requires thinking and typing
std::cout << ints1[0] << ' ' << ints2[0];
}
或者写一个看起来很吓人的通用宏,它将这些抽象化
#include <algorithm>
#include <vector>
#include <algorithm>
#include <vector>
#define LIFT_FWD_(...) (static_cast<decltype(__VA_ARGS__)&&>(__VA_ARGS__))
#define LIFT(...) \
[](auto&&... args) \
noexcept(noexcept(__VA_ARGS__(LIFT_FWD_(args)...))) \
-> decltype(__VA_ARGS__(LIFT_FWD_(args)...)) \
{ return __VA_ARGS__(LIFT_FWD_(args)...); }
template <typename Fun>
auto do_thing(std::vector<int>& ints1, std::vector<int>& ints2, Fun fun)
{
fun(ints1, ints2);
}
auto main() -> int
{
auto ints1 = std::vector { 5, 4, 3, 2, 1 };
auto ints2 = std::vector { 1, 2, 3, 4, 5 };
do_thing(ints1, ints2, LIFT(std::swap)); // Compiles, black boxed magic that just works^tm
}
上述宏的解释
上面的宏有很多魔法,是的,但它基本上为我们正确而恰当地处理了所有事情。
首先我们定义 LIFT_FWD_
,它基本上做了 std::forward
所做的事情。如果您不知道它是如何工作的,请点击上面的链接并阅读那里的解释。
我们将其定义为一个宏,而不是直接使用标准库中的函数,因为为我们的宏包含 <utility>
头文件没有意义。
提升宏本身可以分为四个部分
[](auto&&... args) // just the starting declaration of a lambda, nothing fancy, we are talking a parameter pack of whatever arguments
noexcept(noexcept(__VA_ARGS__(LIFT_FWD_(args)...))) // we are correctly handling the noexcept case - if our function call is noexcept, our lambda is noexcept as well
-> decltype(__VA_ARGS__(LIFT_FWD_(args)...)) // we are taking the return type of our function call and setting it as our lambda return type
{ return __VA_ARGS__(LIFT_FWD_(args)...); } // the body, actual function call
唯一令人困惑的部分可能是 __VA_ARGS__
,为什么不直接 #define LIFT(X) [...] X(LIFT_FWD_(args)...)
?
答案是——这可能会在 99% 的情况下奏效,但会禁止传入带有显式模板参数且其中包含空格的类型,例如
LIFT(something<int, std::string>)
其中的空格会使 X
只变为 something<int
,所以这只是一种防止这种情况发生的方法。
理由
Niebloids 的全部意义在于使正确的事情变得简单方便,而程序员无需关心或记住任何规则。
通过抑制(禁止)ADL,调用算法的行为与限定和非限定查找都保持一致
auto const numbers = std::vector{ 1 ,2, 3, 4, 5 };
{
auto const min = std::ranges::min(numbers); // Calls std::ranges::min
}
{
using namespace std::ranges;
auto const min = min(numbers); // Calls std::ranges::min
}
然后,niebloids 在内部正确处理一切。它们在必要时调用定制点对象,这些对象负责正确处理定制点,并且所有类型要求都得到正确应用。