Functors 并没有过时: 双重 Functor 技巧
一、简介
当 C++11 出现时,Lambda 表达式被广泛用于之前使用 Functor 的地方。Lambda 表达式更优雅,需要更少的代码,并且可以完成 Functor 的大部分功能(几乎是,但并不完全)。
使用 Lambda 表达式使代码更具表现力,但仍然有一些地方需要使用 Functor,其中之一就是“双重 Functor 技巧”。
二、用例:使用不同类型的值比较元素
有一个包含类型为 T
的元素的集合,想用一个或多个类型为 U
的值来比较它们。但是 T
和 U
无法隐式转换为彼此。需要一个操作来从 U
获取 T
,或者从 T
获取 U
,或者甚至可能只能从一个推导出另一个,而不能反过来。
一个典型的用例是搜索对象的子部分。例如,以下类的对象有一个 ID
:
class Employee
{
public:
int getId() const
...
private:
int id_;
};
在vector
中有多个这样的对象,并且它们没有特定的顺序:
std::vector<Employee> employees;
或者按 ID
排序:
bool operator<(Employee const& e1, Employee const& e2)
{
return e1.getId() < e2.getId();
}
std::set<Employee> employees;
并且有一个 ID
(类型为 int
),需要检索与这个 ID
对应的对象(类型为 Employee
)。
大多数 STL 算法(例如 std::count
、std::find
、std::equal_range
、std::set_intersection
等)接受它们操作的范围中元素类型的值(或隐式可转换为该类型的值)。并且无法从 ID
创建 Employee
对象。
这是一个更普遍需求的特殊情况:使用应用于元素的操作结果来比较元素。这里操作是 getId
,但可能需要应用更复杂的计算,并搜索会产生正在寻找的结果的元素。
如何使用 STL 完成此操作?
三、STL 涵盖的用例:*_if 算法
一个包含对象的无序集合:
std::vector<Employee> employees;
无法使用 std::find
搜索 ID
为 42
的employees
:
std::find(employees.begin(), employees.end(), 42); // 无法编译
STL 通过提供 std::find_if
来解决这个问题,它允许解释如何将 ID
与Employee
进行比较,并确定是否存在匹配:
std::find_if(employees.begin(), employees.end(), [](Employee const& e){return e.getId() == 42;}); // OK
同样的逻辑也适用于 std::count
和 std::count_if
,尽管在这种特殊情况下,每个 ID
可能在一个集合中最多出现一次。
3.1、std::lower_bound 和 std::upper_bound
一个排序的集合:
bool operator<(Employee const& e1, Employee const& e2)
{
return e1.getId() < e2.getId();
}
std::set<Employee> employees;
如何有效地按 ID
搜索employees
?第一反应是:应该使用 equal_range
,最好通过调用 set
类的方法。
但这里行不通:
auto employeesWith42 = employees.equal_range(42); // 无法编译
事实上,42
无法与 Employee
类型的对象进行比较。
C++03 对 C++98 的标准进行了一些更改,其中之一解决了这个问题。它涉及算法 std::lower_bound
和 std::upper_bound
。C++03 为它们添加了保证,即它们始终以相同的顺序将集合中的元素与搜索值进行比较。
std::lower_bound
对操作符左侧的元素和右侧的搜索值进行比较。
std::upper_bound
对操作符右侧的元素和左侧的搜索值进行比较。
因此,可以将一个比较函数传递给它们,该函数比较employee
和 ID
:
bool compareWithIdLeft(Employee const& employee, int id)
{
return employee.getId() < id;
}
auto lowerPosition = std::lower_bound(employees.begin(), employees.end(), 42, compareWithIdLeft);
对于 std::upper_bound
:
bool compareWithIdRight(int id, Employee const& employee)
{
return id < employee.getId();
}
auto upperPosition = std::upper_bound(lowerPosition, employees.end(), 42, compareWithIdRight);
请注意,compareWithIdLeft
和 compareWithIdRight
不能有相同的名称,否则将它们作为参数传递给算法会产生歧义。还要注意,如果发现 Lambda 机制不会影响此示例的可读性,则所有这些都可以使用 Lambda 表达式实现。
最后,请注意如何在调用 std::upper_bound
时重用 std::lower_bound
的输出,以便有效地获取 std::equal_range
返回的两个迭代器。
在这种特殊情况下,最多只有一个employee
具有给定的 ID
,可能会发现将 lower_bound
的结果与集合的末尾和值 42
进行比较,而不是调用 upper_bound
并检查其结果是否与 lower_bound
的结果不同,会更好。
四、双重 Functor 技巧
到目前为止,已经涵盖了针对特定算法的解决方案,但这些绝不是通用解决方案。
以集合上的算法为例:有一个排序的employee
集合,一个排序的 ID
集合,想要与任何employee
都不对应的 ID
,例如,清理不再在公司的员工的 ID
。这是 std::set_difference
的工作。
但是无法将不同类型的集合传递给集合上的算法,并且与上面看到的 std::lower_bound
不同,它们不提供任何关于它们将使用哪种顺序来比较两个集合中元素的保证。希望传递两个函数,一个在左侧接受 ID
,另一个在右侧接受 ID
,但只能传递一个比较器给算法。
这就是 Functor 从死亡中复活的地方:
struct CompareWithId
{
bool operator()(Employee const& employee, int id)
{
return employee.getId() < id;
}
bool operator()(int id, Employee const& employee)
{
return id < employee.getId();
}
};
Functor 允许将多个函数打包到一个函数对象中,Lambda 表达式无法做到这一点。
然后,Functor 以以下方式使用:
std::set<Employee> employees = ...
std::set<int> ids = ...
std::vector<int> idsToClean;
std::set_difference(ids.begin(), ids.end(),
employees.begin(), employees.end(),
std::back_inserter(idsToClean),
CompareWithId());
Functor 终于扳回一局。
五、Functor 的未来
随着C++的不断发展,总有一天,Functor 应该会消失。在 STL 之外,在其他地方也发现了对同一个函数对象中多个重载的需求。在使用 std::variant
(以及之前的 boost::variant
)时,具有多个运算符的函数对象用于创建访问者。出于这个原因,有人提议为语言添加一个函数 std::overload
,该函数从传递给它的多个 Lambda 表达式构建一个函数对象,从而避免手动编写整个 Functor 模板代码。
当然,也可以通过继承 Lambda 表达式来实现等效的功能。通过使用 C++17 中可用的功能组合,甚至可以更优雅地实现这一点。
但在 C++17 之前,双重 Functor 技巧只使用标准组件,并且易于在本地实现,即使它可能不是最酷的解决方案。