STL 函数对象:无状态即无压力
- 一、简介
- 二、函数对象
- 三、避免在函数对象中保存状态
- 3.1、函数对象
- 3.2、lambda 表达式
- 四、选择合适的更高层次的结构
- 五、总结
一、简介
在使用 C++ 标准模板库 (STL) 时,函数对象 (Function Object) 是一种强大的工具,它可以帮助你编写更具表现力和更健壮的代码。函数对象本质上是可调用对象,它们可以像普通函数一样被调用,但同时可以拥有自己的状态和行为。本文将深入探讨函数对象,并重点讲解如何避免在函数对象中保存状态,从而使你的代码更简洁、更易于维护。
二、函数对象
先简要回顾一下函数对象。函数对象是一个可以在函数调用语法中使用的对象:
myFunctionObject(x);
即使它是在类(或结构体)中声明的。这种语法是通过声明一个 operator()
运算符实现的:
class MyFunctionObject
{
public:
void operator()(int x)
{
....
}
};
与简单函数相比,函数对象的优势在于它们可以包含数据:
class MyFunctionObject
{
public:
explicit MyFunctionObject(Data data) : data_(data) {}
void operator()(int x)
{
//....使用 data_ ....
}
private:
Data data_;
};
在调用位置:
MyFunctionObject myFunctionObject(data);
myFunctionObject(42);
这样,函数调用将使用 42
和 data
来执行。这种类型的对象被称为函数对象。
在 C++11 中,lambda 表达式以更轻量的语法满足了相同的需求:
Data data;
auto myFunctionObject = [data](int x){/*....使用 data....*/};
myFunctionObject(42);
自从 C++11 中引入 lambda 表达式后,函数对象的使用频率大大降低,尽管仍然存在一些必须使用函数对象的情况。
函数、函数对象和 lambda 表达式可以使用相同的函数调用语法。因此,它们都是可调用对象。
可调用对象在 STL 中被广泛使用,因为算法具有通用的行为,这些行为由可调用对象定制。以 for_each
为例。for_each
遍历集合中的元素,并对每个元素执行某些操作。这个操作由可调用对象描述。以下示例将集合中的每个数字增加 2,并展示了如何使用函数、函数对象和 lambda 表达式来实现:
使用函数,值 2 必须硬编码:
void bump2(double& number)
{
number += 2;
}
std::vector<double> numbers = {1, 2, 3, 4, 5};
std::for_each(numbers.begin(), numbers.end(), bump2);
使用函数对象,增加的值可以作为参数传递,这提供了更大的灵活性,但语法更繁重:
class Bump
{
public:
explicit Bump(double bumpValue) : bumpValue_(bumpValue) {}
void operator()(double& number) const
{
number += bumpValue_;
}
private:
double bumpValue_;
};
std::vector<double> numbers = {1, 2, 3, 4, 5};
std::for_each(numbers.begin(), numbers.end(), Bump(2));
lambda 表达式提供了相同的灵活性,但语法更轻量:
std::vector<double> numbers = {1, 2, 3, 4, 5};
double bumpValue = 2;
std::for_each(numbers.begin(), numbers.end(),
[bumpValue](double& number){number += bumpValue;});
这些示例展示了使用 STL 操作函数对象的语法。现在,以下是如何有效使用它们的准则:避免在其中保存状态。
三、避免在函数对象中保存状态
在使用 STL 的初期,可能会很想在函数对象中使用数据成员变量来保存状态。例如,用于存储在遍历集合过程中更新的当前结果,或用于存储哨兵值。
尽管 lambda 表达式在标准情况下取代了函数对象,但许多代码库仍在赶上 C++11,还没有使用 lambda 表达式。此外,仍然存在一些只能通过函数对象解决的情况。因此,本文将涵盖函数对象和 lambda 表达式,特别是看看如何将避免状态的准则应用于两者。
3.1、函数对象
示例:统计集合 numbers
中值 7 出现的次数。
class Count7
{
public:
Count7() : counter_(0) {}
void operator()(int number)
{
if (number == 7) ++counter_;
}
int getCounter() const {return counter_;}
private:
int counter_;
};
在调用位置,函数对象可以这样使用:
std::vector<int> numbers = {1, 7, 4, 7, 7, 2, 3, 4};
int count = std::for_each(numbers.begin(), numbers.end(), Count7()).getCounter();
在这里,实例化一个 Count7
类型的函数对象,并将其传递给 for_each
(搜索的数字可以在函数对象中参数化,以便能够编写 Count(7)
,但这并不是重点。相反,更想关注函数对象中维护的状态)。for_each
将传递的函数对象应用于集合中的每个元素,然后返回它。这样,就可以在 for_each
返回的匿名函数对象上调用 getCounter()
方法。
这段代码的复杂性暗示着它的设计存在问题。这里的问题是函数对象有一个状态:它的成员 counter_
,而函数对象不适合保存状态。为了说明这一点,你可能想知道:为什么使用 for_each
返回值的这个相对不为人知的特性?为什么不简单地编写以下代码:
std::vector<int> numbers = {1, 7, 4, 7, 7, 2, 3, 4};
Count7 count7;
std::for_each(numbers.begin(), numbers.end(), count7);
int count = count7.getCounter();
这段代码创建了计数函数对象,将其传递给 for_each
,并检索计数结果。这段代码的问题在于它根本无法工作。如果尝试编译它,会发现 count
中的值是 0。这是为什么呢?
原因是,count7
从未进入 for_each
的内部。实际上,for_each
按值获取其可调用对象,因此 for_each
使用的是 count7
的副本,并且该副本的状态已被修改。
这是应该避免在函数对象中保存状态的第一个原因:状态会丢失。
这在上面的示例中很明显,但它不止于此:for_each
的特殊之处在于它在整个集合遍历过程中始终保持相同的函数对象实例,但并非所有算法都是如此。其他算法不保证它们在遍历集合的过程中会使用相同的可调用对象实例。因此,可调用对象的实例可能会在算法执行过程中被复制、赋值或销毁,从而导致状态无法维护。要确切了解哪些算法提供了这种保证,可以查看标准,但一些非常常见的算法(如 std::transform
)却没有。
现在,应该避免在函数对象中保存状态的另一个原因是:它会使代码变得更加复杂。大多数情况下,存在更好的、更干净、更具表现力的方法。这也适用于 lambda 表达式。
3.2、lambda 表达式
使用 lambda 表达式的代码,统计 numbers
中数字 7 出现的次数:
std::vector<int> numbers = {1, 7, 4, 7, 7, 2, 3, 4};
int count = 0;
std::for_each(numbers.begin(), numbers.end(),
[&count](int number){ if (number == 7) ++count;});
std::cout << count << std::endl;
这段代码调用 for_each
来遍历整个集合,并在每次遇到 7 时递增变量 counter
(按引用传递给 lambda 表达式)。
这段代码不好,因为它用于执行的任务过于复杂。它展示了通过暴露其状态来计数元素的技术方法,而它应该简单地说明它正在统计集合中的 7,任何实现状态都应该被抽象掉。这实际上与尊重抽象层次的原则相一致,这是编程最重要的原则。
那么该怎么办呢?
四、选择合适的更高层次的结构
有一种简单的方法可以重写上面的特定示例,并且与所有版本的 C++ 兼容。它包括将 for_each
移除,并用 count
替换它,因为 count
专门用于此任务:
std::vector<int> numbers = {1, 7, 4, 7, 7, 2, 3, 4};
int count = std::count(numbers.begin(), numbers.end(), 7);
当然,这并不意味着永远不需要函数对象或 lambda 表达式 —— 确实需要它们。但这里想要传达的信息是,如果发现自己在函数对象或 lambda 表达式中需要状态,那么应该重新考虑正在使用的更高层次的结构。可能存在一个更适合所需解决的问题的结构。
看看另一个在可调用对象中保存状态的经典示例:哨兵值。
哨兵值是一个用于预期算法终止的变量。例如,在以下代码中,goOn
是哨兵值:
std::vector<int> numbers = {8, 4, 3, 2, 10, 4, 2, 7, 3};
bool goOn = true;
for (size_t n = 0; n < numbers.size() && goOn; ++n) {
if (numbers[n] < 10)
std::cout << numbers[n] << '\n';
else
goOn = false;
}
这段代码的目的是打印集合中的数字,只要它们小于 10,并在遍历过程中遇到 10 时停止。
当重构这段代码以利用 STL 的表现力时,可能会很想将哨兵值作为函数对象/lambda 表达式中的状态保存。
函数对象可能如下所示:
class PrintUntilTenOrMore
{
public:
PrintUntilTenOrMore() : goOn_(true) {}
void operator()(int number)
{
if (number < 10 && goOn_)
std::cout << number << std::endl;
else
goOn_ = false;
}
private:
bool goOn_;
};
在调用位置:
std::vector<int> numbers = {8, 4, 3, 2, 10, 4, 2, 7, 3};
std::for_each(numbers.begin(), numbers.end(), PrintUntilTenOrMore());
使用 lambda 表达式的类似代码如下所示:
std::vector<int> numbers = {8, 4, 3, 2, 10, 4, 2, 7, 3};
bool goOn = true;
std::for_each(numbers.begin(), numbers.end(), [&goOn](int number)
{
if (number < 10 && goOn)
std::cout << number << '\n';
else
goOn = false;
});
但是,这些代码片段存在几个问题:
- 状态
goOn
使它们变得复杂:需要一些时间才能在脑海中理清它的作用。 - 调用位置存在矛盾:它说它对每个元素执行某些操作,但也说它不会在 10 之后继续执行。
有很多方法可以解决这个问题。一种方法是使用 find_if
将测试从 for_each
中移除:
auto first10 = std::find_if(numbers.begin(), numbers.end(), [](int number){return number >= 10;});
std::for_each(numbers.begin(), first10, [](int number){std::cout << number << std::endl;} );
不再有哨兵值,不再有状态。
这在这种情况下效果很好,但如果需要根据转换结果进行过滤,例如将函数 f
应用于数字的结果呢?也就是说,如果初始代码是:
std::vector<int> numbers = {8, 4, 3, 2, 10, 4, 2, 7, 3};
bool goOn = true;
for (size_t n = 0; n < numbers.size() && goOn; ++n)
{
int result = f(numbers[n]);
if (result < 10)
std::cout << result << '\n';
else
goOn = false;
}
那么要使用 std::transform
而不是 std::for_each
。但在这种情况下,find_if
也需要对每个元素调用 f
,这是没有意义的,因为对每个元素应用两次 f
,一次在 find_if
中,一次在 transform
中。
这里的一个解决方案是使用范围(Range)。
五、总结
本文深入探讨了 STL 函数对象,并重点讲解了避免在函数对象中保存状态的重要性。通过使用更高级的 STL 算法,例如 count
和 find_if
,可以避免使用函数对象来管理状态,从而使代码更简洁、更易于维护。
记住,函数对象应该专注于执行特定的操作,而不是管理状态。
希望本文能帮助你更好地理解 STL 函数对象,并学会编写更优雅、更健壮的 C++ 代码。