C++中的标准库函数对象

1. 什么是函数对象 (Function Object)?

首先,一个函数对象(也常被称为仿函数 Functor),其本质是一个重载了函数调用运算符 operator() 的类的对象

简单来说,它是一个“行为像函数”的对象。你可以像调用一个普通函数一样来“调用”这个对象。

一个最简单的自定义函数对象示例:

#include <iostream>

// 1. 定义一个类
class Greeter {
public:
    // 2. 在类中重载 operator()
    void operator()(const std::string& name) const {
        std::cout << "Hello, " << name << "!" << std::endl;
    }
};

int main() {
    Greeter greet_object; // 3. 创建这个类的对象

    // 4. 像调用函数一样“调用”这个对象
    greet_object("World"); // 输出: Hello, World!
}

在这个例子中,greet_object 就是一个函数对象。

函数对象最大的优势:

  • 可以拥有状态:因为函数对象是类的对象,所以它可以有自己的成员变量来存储状态。这是普通函数做不到的。
  • 性能可能更高:编译器更容易对函数对象进行内联优化,通常比通过函数指针调用要快。

2. 什么是“标准库”函数对象?

C++标准库(主要在头文件 <functional> 中)为我们预先定义好了一系列常用的、开箱即用的函数对象。这样我们就不用自己去手写 std::plusstd::greater 这些简单的操作了。

这些标准库函数对象主要用于配合STL算法(如 std::sort, std::transform 等)使用,让代码更加简洁和标准化。

C++20 <ranges> 中的函数对象

这些是 C++20 引入的现代版本,它们默认是“透明的”(可以处理不同但可比较的类型),并且与范围库无缝集成。

类别 函数对象 (Function Object) 功能说明 等价操作
比较 std::ranges::equal_to 判断 a 是否等于 b a == b
std::ranges::not_equal_to 判断 a 是否不等于 b a != b
std::ranges::less 判断 a 是否小于 b a < b
std::ranges::less_equal 判断 a 是否小于或等于 b a <= b
std::ranges::greater 判断 a 是否大于 b a > b
std::ranges::greater_equal 判断 a 是否大于或等于 b a >= b
其他 std::ranges::identity 返回其参数本身,不做任何改变 x

std::ranges::identity 的特殊用途: 它在需要“投影”(Projection)但又不想改变元素的算法中非常有用。例如,在一个需要投影的 sort 算法中,如果你想按元素自身的值排序,就可以使用 std::ranges::identity作为投影。


<functional> 中的传统函数对象

这些是 C++ 标准库早期版本提供的函数对象。它们功能强大,至今仍在广泛使用。

1. 算术运算 (Arithmetic Operations)

函数对象 (Function Object) 功能说明 等价操作
std::plus 计算 a + b a + b
std::minus 计算 a - b a - b
std::multiplies 计算 a * b a * b
std::divides 计算 a / b a / b
std::modulus 计算 a % b a % b
std::negate 计算 -a (一元操作) -a

2. 比较运算 (Comparison Operations)

函数对象 (Function Object) 功能说明 等价操作
std::equal_to 判断 a == b a == b
std::not_equal_to 判断 a != b a != b
std::less 判断 a < b a < b
std::less_equal 判断 a <= b a <= b
std::greater 判断 a > b a > b
std::greater_equal 判断 a >= b a >= b

注意:从 C++14 开始,<functional> 中的比较和算术运算符可以通过使用空模板参数(如 std::less<>)来实现“透明性”,这使其能力接近于 <ranges> 中的版本。

3. 逻辑运算 (Logical Operations)

函数对象 (Function Object) 功能说明 等价操作
std::logical_and 计算 a && b a && b
std::logical_or 计算 `a
std::logical_not 计算 !a (一元操作) !a

4. 位运算 (Bitwise Operations)

函数对象 (Function Object) 功能说明 等价操作
std::bit_and 计算 a & b a & b
std::bit_or 计算 `a b`
std::bit_xor 计算 a ^ b a ^ b
std::bit_not 计算 ~a (一元操作) ~a

3.统一初始化 (Uniform Initialization)

什么是统一初始化?

统一初始化,也称为列表初始化 (List Initialization)花括号初始化 (Brace Initialization),是C++11引入的一种全新的、通用的初始化语法。它的核心就是使用花括号 {} 来初始化任意类型的对象

这个语法旨在提供一种单一、无歧义的方式来初始化变量,无论它是普通的基本类型、数组、还是类的对象。

基本语法形式:

T object {arg1, arg2, ...};
T object = {arg1, arg2, ...}; // = 号是可选的

C++11之前的问题

在C++11之前,对象的初始化语法非常混乱,甚至会引发一些诡异的问题。

混乱的初始化方法:

对于聚合类型(struct/class)

struct Point {
    int x, y;
};
Point p = {10, 20}; // 使用花括号

对于类对象:

#include <string>
#include <vector>

// 使用小括号调用构造函数
std::string s("hello");
std::vector<int> v(5, 10); // 5个元素,值都为10

对于普通变量和数组:

int x = 5;
int arr[] = {1, 2, 3}; // 使用花括号

需要根据不同的类型,记忆使用 =() 还是 {},非常不统一。


统一初始化的好处

使用 {} 的统一初始化语法,不仅统一了风格,还带来了几个非常重要的好处:

统一初始化场景

现在,几乎所有初始化场景都可以使用 {},代码风格更加一致,降低了学习和记忆成本。

使用统一初始化后的代码:

// 普通变量
int x{5};
double pi{3.14};

// 数组
int arr[]{1, 2, 3};

// 类对象
std::string s{"hello"};
Point p{10, 20};

// 动态分配的对象
int* p_int = new int{10};
std::string* p_str = new std::string{"world"};

// 容器
std::vector<int> v{1, 2, 3, 4, 5};

可防止“窄化转换” (Narrowing Conversion)

“窄化转换”指的是一种可能丢失数据精度的隐式类型转换,比如把 double 赋值给 int,或者把 int 赋值给 char。在旧的初始化语法中,这种转换是合法的,但可能隐藏bug。

旧语法的风险:

double pi = 3.14159;
int x = pi; // 合法,但pi的小数部分被截断,x的值为3。编译器可能只给一个警告。
int y(pi);  // 同上,y的值为3。

统一初始化的安全性: 统一初始化禁止窄化转换,如果发生这类转换,代码将无法通过编译。

double pi = 3.14159;
// int x{pi}; // 编译错误!不允许从 double 到 int 的窄化转换
// int y = {pi}; // 同样编译错误!

这个特性极大地提高了代码的健壮性和安全性,能在编译阶段就发现潜在的数据丢失问题。

解决歧义

这是C++中一个经典的语法歧义问题。看下面的代码:

MyClass obj();

你可能是想:用 MyClass 的默认构造函数创建一个名为 obj 的对象。 但C++编译器会认为:你声明了一个名为 obj函数,这个函数不接受任何参数,返回一个 MyClass 类型的对象。

这是一个让无数C++初学者困惑的陷阱。而统一初始化完美地解决了这个问题。

使用统一初始化解决:

MyClass obj{}; // 毫无歧义!这绝对是创建一个对象。

编译器看到 {} 就知道这一定是在定义一个变量,而不是声明一个函数。


注意:std::initializer_list

统一初始化有一个非常重要的规则,也是它和 () 初始化的最大区别所在:

如果一个类同时有“普通构造函数”和“以 std::initializer_list 为参数的构造函数”,那么使用 {} 进行初始化时,编译器会强烈优先选择 std::initializer_list 版本的构造函数。

最典型的例子就是 std::vector

#include <vector>
#include <iostream>

int main() {
    // 使用小括号 () -> 调用普通构造函数
    // 创建一个包含10个元素的vector,每个元素的值都是5
    std::vector<int> v1(10, 5);

    // 使用花括号 {} -> 优先调用 initializer_list 构造函数
    // 创建一个包含2个元素的vector,这两个元素分别是10和5
    std::vector<int> v2{10, 5};

    std::cout << "v1 size: " << v1.size() << std::endl; // 输出: v1 size: 10
    std::cout << "v2 size: " << v2.size() << std::endl; // 输出: v2 size: 2
}

结论:

  • 当你希望用花括号里的内容作为元素列表来初始化容器时,用 {}
  • 当你希望调用initializer_list 的普通构造函数(比如指定大小和初始值)时,请继续使用 ()

4.类模板参数推导 (CTAD)

我们可以注意到 std::ranges::equal_to 在使用时候没有加 < > ,而老的比较运算 std::equal_to<int> 往往要加上 < >。这是一个从C++14到C++17做出的便利性变化。

函数模板

在C++中,其实编译器一直在做“模板参数推导”,只是以前它主要用于函数模板

比如,当写下这样的代码:

#include <utility>

// make_pair 是一个函数模板
auto p = std::make_pair(10, "hello");

从来不需要写成 std::make_pair<int, const char*>(10, "hello")。编译器会根据你传入的参数 10"hello"自动推导出模板参数应该是 intconst char*

C++17 :类模板参数推导 (CTAD)

在C++17之前,这种自动推导的功能对类模板是不起作用的。如果你想创建一个 std::pair 对象,你必须明确地写出类型:

C++17 之前:

// 必须显式提供模板参数 <int, const char*>
std::pair<int, const char*> my_pair(10, "hello");

C++17 之后 (有了CTAD): 编译器现在也可以根据构造函数的参数来推导类模板的参数了!

// 无需提供尖括号,编译器会自动推导!
std::pair my_pair(10, "hello"); // 编译器知道这是 std::pair<int, const char*>

这就是CTAD的核心。

应用到 std::ranges::equal_to

std::equal_tostd::ranges::equal_to 的定义本质上都是一个类模板,我们可以把它简化理解成这样:

template <typename T = void> // 注意这里有一个默认模板参数 void
struct equal_to {
    // ... 内部实现 ...
    // 它有一个默认的构造函数 equal_to() {}
};

注意这个 T = void,它是一个默认模板参数。当你不想指定具体类型(比如 int),而是想让它能比较任意类型时,就使用这个 void 版本(也称为“透明的函数对象”)。

现在我们来看演化过程:

  • C++17/20 的做法 (std::ranges::equal_to{}) 有了CTAD之后,当你写下 std::ranges::equal_to{} 时,编译器会进行推导:
    1. 编译器看到 std::ranges::equal_to 是一个类模板。
    2. 你正在调用它的默认构造函数(因为 {} 是空的)。
    3. 构造函数没有任何参数,无法帮助推导 T 的类型。
    4. 于是,编译器会去查找这个模板有没有默认模板参数
    5. 它找到了!默认参数是 void
    6. 因此,编译器自动推导出你想要的是 std::ranges::equal_to<void>

C++14 的做法 (std::equal_to<>) C++14引入了透明函数对象,但当时还没有CTAD。所以,为了创建一个void版本的对象,你必须提供一个空的尖括号 <> 来告诉编译器采用默认参数。

// 必须有<>,这是在C++14/11中实例化一个模板类对象的语法
auto op = std::equal_to<>{};

所以,std::ranges::equal_to{} 就等价于 std::ranges::equal_to<void>{}。所以在C++之后,这些尖括号均不必添加。

总结

语法 (Syntax) 含义 (Meaning) 所需C++版本 (Required C++ Version)
std::equal_to<int>{} 显式指定类型为int的函数对象 C++11
std::equal_to<>{} 显式使用默认模板参数(void),创建透明函数对象 C++14
std::equal_to{} 使用CTAD自动推导出默认模板参数(void) C++17
std::ranges::equal_to{} 使用CTAD自动推导出默认模板参数(void) C++20