仿函数和Lambda表达式

The Redefine Team Lv3

仿函数和lambda表达式

仿函数

仿函数,就是一种类类型,通过重载调用运算符”()”来实现像函数一样的调用功能,所以称之为仿函数

仿函数的优势

  • 仿函数相比普通函数具有以下优势:
  1. 保存状态:仿函数可以在对象中保存状态
  2. 可作为模板参数:比函数指针更灵活
  3. 内联优化:编译器更容易对仿函数进行内联优化
  4. 类型安全:类型在编译期确定

1. 保存状态 (Saving State)

介绍
普通函数通常是无状态的(除非使用全局变量或静态变量,但这有其局限性)。每次调用函数时,它都基于当前的输入执行,不记得之前的调用。而仿函数是对象,对象可以拥有成员变量。这些成员变量可以在多次调用 operator() 之间保持它们的值,从而允许仿函数“记住”或“累积”状态。

代码示例:
这个例子创建了一个仿函数 Accumulator,它会累加所有传递给它的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include <iostream>
#include <vector>
#include <numeric> // 为了 std::accumulate(虽然这里我们手写循环演示)
#include <algorithm> // 为了 std::for_each

// 定义一个仿函数 Accumulator
struct Accumulator {
int sum = 0; // 成员变量,用于保存状态(累加和)

// 重载 operator()
void operator()(int value) {
sum += value; // 每次调用时,累加值到成员变量 sum
}

// 获取最终状态的方法(可选)
int getSum() const {
return sum;
}
};

int main() {
std::vector<int> numbers = {1, 2, 3, 4, 5};

// 创建 Accumulator 对象
Accumulator acc;

for (int num : numbers) {
acc(num); // 像函数一样调用对象
}

std::cout << "累加和 (通过 getSum): " << acc.getSum() << std::endl;
std::cout << "累加和 (直接访问成员): " << acc.sum << std::endl;

// 对比:如果用普通函数,需要外部变量或静态变量来保存状态
int external_sum = 0;
auto accumulate_func = [&external_sum](int value) {
external_sum += value;
};
std::for_each(numbers.begin(), numbers.end(), accumulate_func);
std::cout << "使用 Lambda (类似普通函数+外部状态) 的累加和: " << external_sum << std::endl;

return 0;
}

说明
Accumulator 对象 acc 通过其成员变量 sum 记住了每次调用的结果。这是普通函数难以直接做到的(除非借助外部变量、静态变量或闭包如Lambda)。

2. 可作为模板参数 (Usable as Template Parameters)

介绍
C++ 标准库中的许多算法(如 std::sort, std::find_if, std::transform)都接受函数或函数对象作为模板参数或普通参数,用于自定义其行为(例如,排序准则、查找条件、转换逻辑)。虽然函数指针也可以传递,但仿函数作为 类型 传递时更加灵活:

  • 携带状态: 如上所述,仿函数可以携带状态,这对于需要上下文的比较或操作非常有用。函数指针本身不能携带状态。
  • 不同类型: 你可以定义多个行为不同或状态不同的仿函数 类型。模板可以根据这些不同的类型进行特化或实例化。

代码示例
这个例子定义了一个 CompareNear 仿函数,用于 std::sort,根据元素与某个目标值的接近程度进行排序。这个“目标值”就是仿函数保存的状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
#include <iostream>
#include <vector>
#include <algorithm> // std::sort
#include <cmath> // std::abs

// 定义比较仿函数
struct CompareNear {
int target; // 状态:比较的目标值

// 构造函数,用于设置状态
CompareNear(int t) : target(t) {}

// 重载 operator(),实现比较逻辑
// 如果 a 比 b 更接近 target,则返回 true
bool operator()(int a, int b) const {
return std::abs(a - target) < std::abs(b - target);
}
};

// 普通函数版本(需要某种方式传递 target,不方便)
// bool compareNearFunc(int a, int b, int target) { /* ... */ }
// 但 std::sort 只接受 bool(T, T) 形式的比较器

int main() {
std::vector<int> numbers = {1, 9, 2, 8, 3, 7, 4, 6, 5};
int target_value = 5;

std::cout << "原始数组: ";
for (int n : numbers) std::cout << n << " ";
std::cout << std::endl;

// 使用仿函数 CompareNear 对 vector 进行排序
// 创建一个带有状态 (target_value) 的仿函数对象
std::sort(numbers.begin(), numbers.end(), CompareNear(target_value));

std::cout << "按接近 " << target_value << " 排序后: ";
for (int n : numbers) std::cout << n << " ";
std::cout << std::endl; // 输出应为: 5 4 6 3 7 2 8 1 9 (或类似)

// 对比:如果使用普通函数指针,无法方便地将 target_value 传递给比较函数
// std::sort(numbers.begin(), numbers.end(), compareNearFunc); // 编译错误,签名不匹配
// 需要使用 lambda 或其他技巧来捕获 target_value

// 使用 Lambda 实现相同功能(Lambda 本质上是匿名仿函数)
std::vector<int> numbers2 = {1, 9, 2, 8, 3, 7, 4, 6, 5};
std::sort(numbers2.begin(), numbers2.end(),
[target_value](int a, int b) {
return std::abs(a - target_value) < std::abs(b - target_value);
});
std::cout << "使用 Lambda 排序后: ";
for (int n : numbers2) std::cout << n << " ";
std::cout << std::endl;


return 0;
}

其实,lambda的底层实现就是一种匿名的仿函数对象,所以二者可以认为是等价的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 当你写下这样的lambda表达式
auto lambda = [x](int y) { return x + y; };

// 编译器实际上会生成类似以下的匿名仿函数类
/*
class __lambda_unique_name {
private:
int x; // 捕获的变量成为成员变量
public:
__lambda_unique_name(int _x) : x(_x) {} // 构造函数初始化捕获变量

int operator()(int y) const { // 函数调用运算符实现
return x + y;
}
};
// 并创建该类的一个实例
auto lambda = __lambda_unique_name(x);
*/

这里的lambda对象名是由编译器随机生成的,所以lambda表达式也被称为是匿名的函数对象,本质就在于此。

说明
CompareNear(target_value) 创建了一个包含特定 target 值的比较器对象,并将其传递给 std::sort。std::sort 内部会调用这个对象的 operator() 来进行比较。使用函数指针无法如此自然地将 target_value 这个状态传递给比较逻辑。Lambda表达式是现代C++中实现此功能的便捷方式,它在底层通常也是通过生成一个类似仿函数的闭包类型来工作的。

3. 内联优化 (Inline Optimization)

介绍
当编译器处理一个接受仿函数的模板函数(如 std::sort, std::for_each)时,仿函数的类型在编译时是已知的。编译器可以直接看到 operator() 的实现代码。这使得编译器非常容易进行内联优化——即将 operator() 的代码直接嵌入到调用它的算法代码中,消除函数调用的开销(如堆栈操作、跳转等)。

相比之下,通过函数指针调用函数通常涉及间接跳转(indirect call),编译器可能不知道具体调用哪个函数,或者即使知道,内联优化也可能更困难或效果较差。

代码示例
这个例子本身不能直接 证明 内联,因为这取决于编译器和优化级别。但它展示了适合内联的场景。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
#include <vector>
#include <numeric> // std::accumulate
#include <iostream>

// 一个简单的仿函数
struct MultiplyBy {
int factor;
MultiplyBy(int f) : factor(f) {}

int operator()(int value) const {
return value * factor; // 简单操作,非常适合内联
}
};

// 普通函数版本
int multiplyByFunc(int value, int factor) {
return value * factor;
}

// 一个使用函数对象的通用算法(简化示例)
template <typename InputIt, typename UnaryOperation>
void transform_and_print(InputIt first, InputIt last, UnaryOperation op) {
std::cout << "转换结果: ";
while (first != last) {
std::cout << op(*first) << " "; // 调用函数对象
++first;
}
std::cout << std::endl;
}

int main() {
std::vector<int> data = {1, 2, 3, 4, 5};
int multiplier = 10;

// 使用仿函数
MultiplyBy mb(multiplier);
transform_and_print(data.begin(), data.end(), mb);
// 编译器在实例化 transform_and_print<..., MultiplyBy> 时,
// 知道 op 是 MultiplyBy 类型,可以直接看到 op(*first) 就是
// first->operator()(multiplier),这极易内联为 *first * mb.factor

// 使用 Lambda(也容易内联)
transform_and_print(data.begin(), data.end(),
[multiplier](int value) { return value * multiplier; });

// 对比:如果用函数指针(假设有一个接受函数指针的版本)
// int (*pFunc)(int, int) = multiplyByFunc;
// some_algorithm(data.begin(), data.end(), pFunc, multiplier); // 调用 pFunc(value, multiplier)
// 这里的 pFunc 调用可能是间接调用,更难内联

return 0;
}

一般而言,仿函数lambda表达式是最有可能进行内联优化的,其次是普通函数和非虚函数的成员方法,而虚成员函数则一般不会发生内联优化:

内联优化比较表1

函数对象类型 调用类型 编译时目标确定性 函数体可见性 默认内联可能性 对 LTO/PGO 依赖
仿函数 直接 通常高/LTO 极高 中等
Lambda 表达式 直接 极高 极高
非虚成员函数 直接 同普通函数 高 (跨文件)
普通函数 直接 同普通函数 高 (跨文件)
虚成员函数 间接 (vtable) 低 (需去虚拟化) 同普通函数 极高
函数指针 间接 (指针变量) 低 (需分析/PGO) 同普通函数 极高

内联优化比较表2

函数对象类型 内联可能性 性能表现 编译器智能优化
仿函数 极高 最佳 完全内联
Lambda表达式 极高 最佳 完全内联
普通函数 很好 基于启发式
非虚成员函数 很好 类似普通函数
虚成员函数 一般 devirtualization
函数指针 很低 较差 有限devirtualization
std::function 几乎没有 最差 特殊情况优化

4. 类型安全 (Type Safety)

介绍:
仿函数是类(或结构体)的对象。它们的类型、成员变量的类型、operator() 的参数和返回类型都在编译时由C++的类型系统进行检查。这提供了强大的类型安全性。

与旧式C风格的回调(通常使用 void* 来传递用户数据)相比,仿函数将操作和其所需的数据(状态)封装在一个强类型的对象中,避免了危险的 void* 转换和潜在的类型错误。

代码示例:
这个例子展示了仿函数如何自然地将操作与其操作的数据类型绑定在一起。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
#include <iostream>
#include <string>
#include <vector>
#include <algorithm>

// 仿函数:处理字符串,添加前缀
struct StringPrefixer {
std::string prefix; // 状态:字符串前缀

StringPrefixer(const std::string& p) : prefix(p) {}

// operator() 只接受 std::string 引用
void operator()(std::string& s) const {
s = prefix + s; // 类型安全的操作
}

// 尝试传递错误类型会编译失败
// void operator()(int i) const; // 不存在,或者如果存在,也是类型安全的重载
};

int main() {
std::vector<std::string> names = {"Alice", "Bob", "Charlie"};
std::string title = "Dr. ";

StringPrefixer prefixer(title); // 创建带有 string 状态的仿函数

// 对 vector 中的每个 string 应用仿函数
std::for_each(names.begin(), names.end(), prefixer);

std::cout << "添加前缀后: ";
for (const auto& name : names) {
std::cout << name << " "; // 输出: Dr. Alice Dr. Bob Dr. Charlie
}
std::cout << std::endl;

// 试图将不兼容的类型传递给仿函数(如果可能的话)会在编译时被捕获
// int number = 10;
// prefixer(number); // 编译错误!operator() 不接受 int

// 对比 C 风格回调和 void*
struct UserData {
const char* prefix;
// 可能还有其他数据...
};

void add_prefix_c_style(void* data, char* str) {
UserData* ud = static_cast<UserData*>(data); // 不安全的类型转换
// 需要手动拼接字符串,容易出错
std::string temp = ud->prefix;
temp += str;
// 假设 str 缓冲区足够大... (潜在风险)
strcpy(str, temp.c_str());
}

// 使用 C 风格回调需要管理 UserData 的生命周期,并且类型转换不安全
// UserData c_data; c_data.prefix = "Mr. ";
// char name_buffer[50] = "Smith";
// add_prefix_c_style(&c_data, name_buffer);
// std::cout << "C 风格: " << name_buffer << std::endl; // 输出 Mr. Smith


return 0;
}

说明
StringPrefixer 仿函数明确地操作 std::string。它的状态 prefix 也是 std::string。编译器会确保 operator() 只被用于 std::string 对象。任何类型不匹配都会导致编译错误。这与使用 void* 传递状态的 C 风格回调形成了鲜明对比,后者依赖于程序员进行正确的类型转换,容易在运行时出错。

总结

仿函数通过将状态和行为封装在对象中,提供了超越普通函数和函数指针的能力。它们特别适用于需要状态、希望获得更好性能(通过内联)以及需要在泛型算法中安全、灵活地传递自定义逻辑的场景。虽然现代 C++ 中的 Lambda 表达式提供了更简洁的语法来创建临时的函数对象(通常在底层实现为仿函数),但理解具名的仿函数结构对于掌握其背后的原理和优势仍然非常重要。

lambda表达式

1
2
3
4
5
6
7
8
9
10
11
12
13
// lambda表达式实现加法运算
#include<iostream>

auto add = [](int a, int b) {return a + b; }; // lambda表达式,需要用auto来接收器返回值
// []中的值为捕获列表,表示一个局部变量,这里的捕获列表为空

// lambda表达式还可以与尾置返回相联系
auto n_add = [](int a, int b)->decltype(a + b) {return a + b;};

int main(void) {
std::cout << n_add(10, 20);
return 0;
}

lambda表达式的基本模板:

1
2
3
4
5
6
auto function_name = [](int T, ...)->decltype(T) {return T; };
// 这里的function_name表示一个lambda表达式名,可以理解成一个函数名
// []中的值是捕获值列表,它表示一个lambda表达式所在函数中定义的局部变量的值的列表,通常为空
// ()中的值是lambda表达式的参数列表,可以理解成函数的参数列表
// 这里的->表示一个尾置返回一般用于decltype声明的返回类型和较为复杂的返回类型
// {}中的是lambda表达式的函数体,如果函数体只是一个return语句,则返回类型由返回的表达式来推断,否则,返回类型为void

注意:模板中的参数列表和返回类型可以省略,但是必须永远包含捕获列表和函数体。

这里的返回类型指的是lambda表达式所要求的尾置返回类型,对于大括号中的return语句,不受影响,因为它属于函数体中的内容。

1
2
3
4
5
6
7
8
#include<iostream>
auto func = [] {return 10; };
// 这里的func函数省略了参数列表和返回类型,对于器返回类型,只要它不是复杂类型,编译器可以推导出返回类型

int main(void) {
std::cout << func() << std::endl;
return 0;
}

下面再演示一下捕获列表内不为空的情况

1
2
3
4
5
6
7
8
9
10
11
#include<iostream>

auto add = [c = 30](int a, int b) ->decltype(a + b) {return a + b + c; };
// 注意,这里捕获列表中的值,只限于在函数体中才能使用,也就是必须在大括号内的内容才可以使用捕获列表中的值
// 捕获列表中不可以使用全局变量或静态变量
// 捕获列表中的值无需声明类型,编译器会自动推导器类型
int main(void) {
int ans = add(10, 20);
std::cout << ans << std::endl;
return 0;
}

捕获列表

捕获列表的使用分为三种形式

值捕获

1
2
3
4
5
6
#include<iostream>
int main(void) {
int x = 5;
auto lambda = [x]() {std::cout << x << std::endl;};
return 0;
}

使用外部变量的值创建lambda的副本。捕获后,lambda函数体内不能修改这些变量的值。
注意,这里只是外部变量的拷贝而非引用

引用捕获

1
2
3
4
5
6
#include<iostream>
int main(void) {
int y = 10;
auto lambda_ref = [&y]() {y++; std::cout << y << std::endl; }; //无return语句,默认返回void
return 0;
}

使用外部变量的引用。捕获后,lambda函数体内可以修改这些变量的值。
注意,是在函数体内修改

隐式捕获

按值隐式捕获
1
2
3
4
5
6
7
#include<iostream>
int main(void) {
int a = 3, b = 7;
auto lambda_implicit = [=]() {std::cout << a + b << std::endl;};

return 0;
}
按引用隐式捕获
1
2
3
4
5
6
7
8
9
#include<iostream>
int main(void) {
int aa = 10;
int& a = aa;
auto lambda_implicit_ref = [&]() {std::cout << a << std::endl; };

lambda_implicit_ref();
return 0;
}
混合类型捕获(显隐式均有)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include<iostream>
auto add = [](int a, int b) {return a + b; };

int main(void) {
int a = 10;
int b = 20;
int c = 30;
int& cc = c;
auto n_add = [=, &cc]() {return a + b + cc; };
// 这里调用了混合类型的捕获
// 注意,使用混合捕获时,必须先声明隐式捕获,正如上例中先在捕获列表中声明了隐式按值捕获=,随后进行了显式的捕获&cc
// 还有一点,对于混合捕获列表,应该保证两个捕获类型不同,就是说必须显隐式均存在于捕获列表中才可以,单一类型的捕获对于混合捕获是错误的

std::cout << n_add();

return 0;
}

由捕获列表的=或&表示。= 表示按值捕获,& 表示按引用捕获。通过使用 = 或 & ,可以捕获所有可见的外部变量。
这两种捕获同上述捕获的特性一样,比如引用捕获可以修改捕获列表的值

  • 标题: 仿函数和Lambda表达式
  • 作者: The Redefine Team
  • 创建于 : 2024-12-07 12:00:00
  • 更新于 : 2025-05-06 01:09:57
  • 链接: https://redefine.ohevan.com/2024/12/07/仿函数与lambda表达式/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
评论