透明操作符函数对象

1. 何为透明

在 C++14 之前,标准库中的函数对象(如 std::less)是单态的,这意味着你必须在使用时指定具体的类型。C++14 引入了透明版本,即模板参数为 void 的版本(如 std::less<void>)。

所谓的透明,指的是该函数对象不关心参数的具体类型。它就像一个透明的代理,直接将参数传递给底层的运算符(如 operator<),而不强制进行类型转换

  • 不透明(C++11):std::less<int>
    • 必须传入 int 类型。如果你传一个 short 或者 double,虽然能编译通过(因为有隐式转换),但可能会产生临时对象,或者在关联容器中因为类型不匹配而找不到。
  • 透明(C++14):std::less<void>
    • 它可以接受任意类型的参数,只要这两个参数能被 operator< 比较。
    • 它内部直接调用 std::forward<T>(t) < std::forward<U>(u),不进行任何类型转换。

1.1 为什么需要透明

C++14 中关于透明操作符函数对象的引入解决了我们在编程过程中的两大痛点:

  1. 避免不必要的类型转换

    如果在算法中使用 std::sort,容器里的元素是 int,但你提供的比较逻辑可能会涉及 shortlong。非透明版本可能会强制类型转换,产生临时对象。

  2. 关联容器的高效查找(最重要)

    这是 C++14 引入该特性的最直接原因:异构查找

    假设有一个 std::map<std::string, int>。在某些高性能场景下,想用 const char*(C风格字符串)去查找 key,而不想为了查找而临时构造一个 std::string 对象(这涉及内存分配)。

    • C++11 (麻烦且低效):

      1
      2
      3
      4
      5
      std::map<std::string, int> m = {{"hello", 1}};
      // 必须构造临时 string 对象,产生内存分配开销
      auto it = m.find(std::string("hello"));
      // 进行隐式类型转换,将 const char* 转换为 std::string
      // m.find("hello");
    • C++14 (优雅且高效):

      1
      2
      3
      4
      5
      6
      // 使用 transparent comparator 的 map
      // 注意:需要显式指定比较器为 std::less<>
      std::map<std::string, int, std::less<>> m = {{"hello", 1}};

      // 直接传 C 字符串,无需构造 std::string!性能更高
      auto it = m.find("hello");

1.2 透明的本质实现原理

关于透明的本质我们可以简单地理解为,这些函数对象内部使用了 autodecltype 推导。

如果我们要手写一个简单的透明比较器,大概是这样的:

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
#include <iostream>

// 模拟 C++14 的 std::less<>
struct MyTransparentLess
{
using is_transparent = void;

template <typename T, typename U>
auto operator()(T&& t, U&& u) const
-> decltype(std::forward<T>(t) < std::forward<U>(u)) {
return std::forward<T>(t) < std::forward<U>(u);
}
};

int main()
{
MyTransparentLess less;

// T 推导为 int, U 推导为 double
std::cout << less(10, 20.5) << std::endl;

// T 推导为 int, U 推导为 long
std::cout << less(9L, 2) << std::endl;
return 0;
}

原理解析:

  1. 模板化函数调用operator() 是一个模板函数,它可以接受任意类型 TU
  2. 自动类型推导:编译器根据传入的参数自动推导 TU
  3. 完美转发 (std::forward):参数的引用属性(左值/右值)被保留,不产生多余的拷贝。
  4. decltype 尾返回类型:返回类型由 operator< 的计算结果决定。如果 TU 不能比较,这段代码直接在编译期报错。
  5. using is_transparent = void;是透明的关键标识:标准库容器(如 std::map, std::unordered_map)会检查比较器类里是否有 is_transparent 这个嵌套类型。如果有,容器就知道这个比较器允许异构查找,从而在函数中开启异形参数支持。

1.3 什么时候该用透明版本

  • 应该使用透明版本的情况:
    1. 关联容器查找:当你定义 std::mapstd::set 时,强烈建议默认使用 std::less<> 等透明版本代替默认的 std::less<T>。这不会损失性能,反而为以后可能的类型转换打开方便之门。
    2. 泛型编程:在写模板函数或算法时,如果需要比较两个可能不同但可比较的类型。
    3. 混合类型计算:比如 std::accumulate 时,累加器类型和元素类型可能不同。
  • 不需要使用透明版本的情况:
    1. 当明确知道类型,且希望编译器强制类型检查(非常少见,通常强制类型错误是好事)。
    2. 在某些极度受限的嵌入式环境中,如果模板实例化导致二进制体积增大(通常影响极小)。

从 C++14 开始,编写 C++ 代码涉及比较或排序逻辑时,请习惯性地加上 <>(例如 std::less<>, std::equal_to<>),它是现代 C++ 更高效且安全的写法。

2. 完整列表

所有标准的算术、比较和逻辑运算符在 C++14 中都增加了透明的 void 特化版本:

功能 普通版本 透明版本 (C++14)
算术运算
加法 std::plus std::plus<>
减法 std::minus std::minus<>
乘法 std::multiplies std::multiplies<>
除法 std::divides std::divides<>
取模 std::modulus std::modulus<>
取反 std::negate std::negate<>
比较运算
等于 std::equal_to std::equal_to<>
不等于 std::not_equal_to std::not_equal_to<>
大于 std::greater std::greater<>
小于 std::less std::less<>
大于等于 std::greater_equal std::greater_equal<>
小于等于 std::less_equal std::less_equal<>
逻辑运算
逻辑与 std::logical_and std::logical_and<>
逻辑或 std::logical_or std::logical_or<>
逻辑非 std::logical_not std::logical_not<>

为了让大家清晰地理解 C++14 中透明操作符 (Operator<>) 与普通操作符 (Operator<T>) 的区别,下面将针对列表中的每一类操作符提供具体的代码示例。

2.1 算术运算

普通版本要求两个操作数类型必须一致(或者能隐式转换),而透明版本允许类型不同,只要它们能互相运算。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <iostream>

int main()
{
std::plus<> add; // 透明版本
std::multiplies<> mul; // 透明版本

// 1. 加法:int 与 double 混用
// std::plus<void> 直接推演返回 double,无需将 int 转换为 double
int a = 10;
double b = 5.5;
std::cout << "int + double: " << add(a, b) << std::endl; // 输出: 15.5

// 2. 乘法
auto result = mul(a, b);
std::cout << "int * double: " << result << std::endl; // 输出: 55
return 0;
}

2.2 比较运算

这是最实用的部分。特别是结合 std::stringconst char*,或者自定义类型之间的比较。

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
#include <iostream>
#include <string>

int main()
{
std::less<> less_cmp;
std::greater_equal<> ge_cmp;
std::equal_to<> eq_cmp;

// 1. 小于:避免 string 临时构造
std::string s = "hello";
const char* cstr = "world";

// std::less<std::string> 需要两个参数都是 string(如果不小心传 char* 会先构造临时对象)
// std::less<> 直接利用 string 重载的 operator<(const char*)
std::cout << "string vs c-string (less): " << less_cmp(s, cstr) << std::endl; // 输出: 1 (true)

// 2. 大于等于:int 与 long 混用
int x = 10;
long y = 20;
std::cout << "int >= long: " << ge_cmp(x, y) << std::endl; // 输出: 0 (false)

// 3. 等于:浮点数比较(尽管直接比较浮点数有精度问题,但透明版本允许混用类型)
float f = 10.0f;
double d = 10.0;
std::cout << "float == double: " << eq_cmp(f, d) << std::endl; // 输出: 1 (true)
return 0;
}

2.3 逻辑运算

逻辑运算通常隐式进行布尔转换。透明版本使得我们可以传入任意能被转换为布尔值的类型,甚至不需要强制类型转换。

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
#include <iostream>
#include <vector>

struct MyObject
{
int value;
// 自定义布尔转换(C++11 explicit bool 防止意外隐式转换,但逻辑符通常需要调用)
explicit operator bool() const { return value != 0; }
};

int main()
{
std::logical_and<> logic_and;
std::logical_or<> logic_or;
std::logical_not<> logic_not;

// 1. 逻辑与:指针 和 整数
int* ptr = nullptr;
int num = 0;

// 普通版本 std::logical_and<bool> 需要你先写成 bool(ptr) && bool(num)
// 透明版本直接接受指针和整数,利用隐式转换
std::cout << "nullptr && 0: " << logic_and(ptr, num) << std::endl; // 输出: 0 (false)

// 2. 逻辑或:自定义类型和 int
MyObject obj{0};
int number = 5;

// 虽然 MyObject 有 explicit operator bool,但标准库透明操作符会处理这种语境
// 注意:explicit 转换在 && || 表达式中是合法的
std::cout << "CustomObj || int: " << logic_or(obj, number) << std::endl; // 输出: 1 (true)

// 3. 逻辑非
std::cout << "!5: " << logic_not(5) << std::endl; // 输出: 0 (false)
return 0;
}

MyObject类中用到了自定义布尔转换,这是 C++11 引入的一个特性,用于定义一个显式的布尔类型转换

1
explicit operator bool() const { return value != 0; }

这行代码的含义可以从以下几个部分详细拆解:

  • operator bool:
    这是 C++ 中重载类型转换运算符的语法。它告诉编译器:当前类的对象可以转换成 bool 类型。
  • const:
    放在函数括号后面,表示这是一个 const 成员函数。这意味着它承诺不会修改类的成员变量(即它不会修改 value)。
  • explicit (关键字):
    这是 C++11 及以后版本新增的关键字,用在这里表示“显式”。它的作用是禁止隐式类型转换,只允许显式调用。
  • { return value != 0; }:
    这是转换的具体逻辑。当对象被转换为布尔值时,结果的真假取决于成员变量 value 是否不等于 0。