【C++基础】第八十二课:[重载运算与类型转换]函数调用运算符

函数对象,标准库函数对象,function

Posted by x-jeff on August 22, 2023

【C++基础】系列博客为参考《C++ Primer中文版(第5版)》C++11标准)一书,自己所做的读书笔记。
本文为原创文章,未经本人允许,禁止转载。转载请注明出处。

1.函数调用运算符

如果类重载了函数调用运算符,则我们可以像使用函数一样使用该类的对象。因为这样的类同时也能存储状态,所以与普通函数相比它们更加灵活。

举个简单的例子,下面这个名为absInt的struct含有一个调用运算符,该运算符负责返回其参数的绝对值:

1
2
3
4
5
struct absInt {
	int operator() (int val) const {
		return val < 0 ? -val : val;
	}
};

这个类只定义了一种操作:函数调用运算符,它负责接受一个int类型的实参,然后返回该实参的绝对值。

我们使用调用运算符的方式是令一个absInt对象作用于一个实参列表,这一过程看起来非常像调用函数的过程:

1
2
3
int i = -42;
absInt absObj; //含有函数调用运算符的对象
int ui = absObj(i); //将i传递给absObj.operator()

即使absObj只是一个对象而非函数,我们也能“调用”该对象。调用对象实际上是在运行重载的调用运算符。在此例中,该运算符接受一个int值并返回其绝对值。

函数调用运算符必须是成员函数。一个类可以定义多个不同版本的调用运算符,相互之间应该在参数数量或类型上有所区别。

如果类定义了调用运算符,则该类的对象称作函数对象(function object)。因为可以调用这种对象,所以我们说这些对象的“行为像函数一样”。

1.1.含有状态的函数对象类

和其他类一样,函数对象类除了operator()之外也可以包含其他成员。函数对象类通常含有一些数据成员,这些成员被用于定制调用运算符中的操作。

举个例子,我们将定义一个打印string实参内容的类。默认情况下,我们的类会将内容写入到cout中,每个string之间以空格隔开。同时也允许类的用户提供其他可写入的流及其他分隔符。我们将该类定义如下:

1
2
3
4
5
6
7
8
class PrintString {
public:
	PrintString(ostream &o = cout, char c = ' ') : os(o), sep(c) { }
	void operator() (const string &s) const { os << s << sep; }
private:
	ostream &os; //用于写入的目的流
	char sep; //用于将不同输出隔开的字符
};
1
2
3
4
PrintString printer; //使用默认值,打印到cout
printer(s); //在cout中打印s,后面跟一个空格
PrintString errors(cerr, '\n');
errors(s); //在cerr中打印s,后面跟一个换行符

函数对象常常作为泛型算法的实参。例如,可以使用标准库for_each算法和我们自己的PrintString类来打印容器的内容:

1
for_each(vs.begin(), vs.end(), PrintString(cerr, '\n'));

2.lambda是函数对象

我们使用一个PrintString对象作为调用for_each的实参,这一用法类似于使用lambda表达式的程序。当我们编写了一个lambda后,编译器将该表达式翻译成一个未命名类的未命名对象。在lambda表达式产生的类中含有一个重载的函数调用运算符,例如,对于我们传递给stable_sort作为其最后一个实参的lambda表达式来说:

1
2
3
4
//根据单词的长度对其进行排序,对于长度相同的单词按照字母表顺序排序
stable_sort(words.begin(), words.end(), 
	[] (const string &a, const string &b) 
	{ return a.size() < b.size(); });

其行为类似于下面这个类的一个未命名对象:

1
2
3
4
5
class ShorterString {
public:
	bool operator () (const string &s1, const string &s2) const
	{ return s1.size() < s2.size(); }
};

产生的类只有一个函数调用运算符成员。默认情况下lambda不能改变它捕获的变量。因此在默认情况下,由lambda产生的类当中的函数调用运算符是一个const成员函数。如果lambda被声明为可变的,则调用运算符就不是const的了。

用这个类替代lambda表达式后,我们可以重写并重新调用stable_sort:

1
stable_sort(words.begin(), words.end(), ShorterString());

2.1.表示lambda及相应捕获行为的类

如我们所知,当一个lambda表达式通过引用捕获变量时,将由程序负责确保lambda执行时引用所引的对象确实存在。因此,编译器可以直接使用该引用而无须在lambda产生的类中将其存储为数据成员。

相反,通过值捕获的变量被拷贝到lambda中。因此,这种lambda产生的类必须为每个值捕获的变量建立对应的数据成员,同时创建构造函数,令其使用捕获的变量的值来初始化数据成员。举个例子:

1
2
//获得第一个指向满足条件元素的迭代器,该元素满足size() is >= sz
auto wc = find_if(words.begin(), words.end(), [sz](const string &a) { return a.size() >= sz; });

该lambda表达式产生的类将形如:

1
2
3
4
5
6
7
8
class SizeComp {
	SizeComp(size_t n) : sz(n) { } //该形参对应捕获的变量
	//该调用运算符的返回类型、形参和函数体都与lambda一致
	bool operator () (const string &s) const
		{ return s.size() >= sz; }
private:
	size_t sz; //该数据成员对应通过值捕获的变量
};

这个合成的类不含有默认构造函数,因此要想使用这个类必须提供一个实参:

1
2
//获得第一个指向满足条件元素的迭代器,该元素满足size() is >= sz
auto wc = find_if(words.begin(), words.end(), SizeComp(sz));

lambda表达式产生的类不含默认构造函数、赋值运算符及默认析构函数;它是否含有默认的拷贝/移动构造函数则通常要视捕获的数据成员类型而定。

3.标准库定义的函数对象

标准库定义了一组表示算术运算符、关系运算符和逻辑运算符的类,每个类分别定义了一个执行命名操作的调用运算符。例如,plus类定义了一个函数调用运算符用于对一对运算对象执行+的操作;modulus类定义了一个调用运算符执行二元的%操作;equal_to类执行==,等等。

这些类都被定义成模板的形式,我们可以为其指定具体的应用类型,这里的类型即调用运算符的形参类型。例如,plus<string>令string加法运算符作用于string对象;plus<int>的运算对象是int;plus<Sales_data>对Sales_data对象执行加法运算,以此类推:

1
2
3
4
5
6
7
8
plus<int> intAdd; //可执行int加法的函数对
negate<int> intNegate; //可对int值取反的函数对象
//使用intAdd::operator(int, int)求10和20的和
int sum = intAdd(10, 20); //等价于sum=30
sum = intNegate(intAdd(10,20)); //等价于sum=-30
//使用intNegate::operator(int)生成-10
//然后将-10作为intAdd::operator(int, int)的第二个参数
sum = intAdd(10, intNegate(10)); //sum=0

表14.2所列的类型定义在functional头文件中。

3.1.在算法中使用标准库函数对象

表示运算符的函数对象类常用来替换算法中的默认运算符。如我们所知,在默认情况下排序算法使用operator<将序列按照升序排列。如果要执行降序排列的话,我们可以传入一个greater类型的对象。该类将产生一个调用运算符并负责执行待排序类型的大于运算。例如,如果svec是一个vector<string>

1
2
//传入一个临时的函数对象用于执行两个string对象的>比较运算
sort(svec.begin(), svec.end(), greater<string>());

则上面的语句将按照降序对svec进行排序。第三个实参是greater<string>类型的一个未命名的对象,因此当sort比较元素时,不再是使用默认的<运算符,而是调用给定的greater函数对象。该对象负责在string元素之间执行>比较运算。

需要特别注意的是,标准库规定其函数对象对于指针同样适用。我们之前曾经介绍过比较两个无关指针将产生未定义的行为,然而我们可能会希望通过比较指针的内存地址来sort指针的vector。直接这么做将产生未定义的行为,因此我们可以使用一个标准库函数对象来实现该目的:

1
2
3
4
5
6
vector<string *> nameTable; //指针的vector
//错误:nameTable中的指针彼此之间没有关系,所以<将产生未定义的行为
sort(nameTable.begin(), nameTable.end(),
	[](string *a, string *b) { return a < b; });
//正确:标准库规定指针的less是定义良好的
sort(nameTable.begin(), nameTable.end(), less<string*>());

关联容器使用less<key_type>对元素排序,因此我们可以定义一个指针的set或者在map中使用指针作为关键值而无须直接声明less。

4.可调用对象与function

C++语言中有几种可调用的对象:函数、函数指针lambda表达式bind创建的对象以及重载了函数调用运算符的类。

和其他对象一样,可调用的对象也有类型。例如,每个lambda有它自己唯一的(未命名)类类型;函数及函数指针的类型则由其返回值类型和实参类型决定,等等。

然而,两个不同类型的可调用对象却可能共享同一种调用形式(call signature)。调用形式指明了调用返回的类型以及传递给调用的实参类型。一种调用形式对应一个函数类型,例如:

1
int(int, int)

是一个函数类型,它接受两个int、返回一个int。

4.1.不同类型可能具有相同的调用形式

对于几个可调用对象共享同一种调用形式的情况,有时我们会希望把它们看成具有相同的类型。例如,考虑下列不同类型的可调用对象:

1
2
3
4
5
6
7
8
9
10
//普通函数
int add(int i, int j) { return i + j; }
//lambda,其产生一个未命名的函数对象类
auto mod = [](int i, int j) { return i % j; };
//函数对象类
struct divide {
	int operator()(int denominator, int divisor) {
		return denominator / divisor;
	}
};

上面这些可调用对象分别对其参数执行了不同的算术运算,尽管它们的类型各不相同,但是共享同一种调用形式:

1
int(int, int)

我们可能希望使用这些可调用对象构建一个简单的桌面计算器。为了实现这一目的,需要定义一个函数表(function table)用于存储指向这些可调用对象的“指针”。当程序需要执行某个特定的操作时,从表中查找该调用的函数。

在C++语言中,函数表很容易通过map来实现。对于此例来说,我们使用一个表示运算符符号的string对象作为关键字;使用实现运算符的函数作为值。当我们需要求给定运算符的值时,先通过运算符索引map,然后调用找到的那个元素。

假定我们的所有函数都相互独立,并且只处理关于int的二元运算,则map可以定义成如下的形式:

1
2
//构建从运算符到函数指针的映射关系,其中函数接受两个int、返回一个int
map<string, int(*)(int, int)> binops;

我们可以按照下面的形式将add的指针添加到binops中:

1
2
//正确:add是一个指向正确类型函数的指针
binops.insert({"+",add}); //{"+",add}是一个pair

但是我们不能将mod或者divide存入binops:

1
binops.insert({"%",mod}); //错误:mod不是一个函数指针

问题在于mod是个lambda表达式,而每个lambda有它自己的类类型,该类型与存储在binops中的值的类型不匹配。

4.2.标准库function类型

我们可以使用一个名为function的新的标准库类型解决上述问题,function定义在functional头文件中,表14.3列举出了function定义的操作。

function是一个模板,和我们使用过的其他模板一样,当创建一个具体的function类型时我们必须提供额外的信息。在此例中,所谓额外的信息是指该function类型能够表示的对象的调用形式。参考其他模板,我们在一对尖括号内指定类型:

1
function<int(int, int)>

在这里我们声明了一个function类型,它可以表示接受两个int、返回一个int的可调用对象。因此,我们可以用这个新声明的类型表示任意一种桌面计算器用到的类型:

1
2
3
4
5
6
7
function<int(int, int)> f1 = add; //函数指针
function<int(int, int)> f2 = divide(); //函数对象类的对象
function<int(int, int)> f3 = [](int i, int j) {return i*j;}; //lambda

cout << f1(4, 2) << endl; //打印6
cout << f2(4, 2) << endl; //打印2
cout << f3(4, 2) << endl; //打印8

使用这个function类型我们可以重新定义map:

1
2
3
4
//列举了可调用对象与二元运算符对应关系的表格
//所有可调用对象都必须接受两个int、返回一个int
//其中的元素可以是函数指针、函数对象或者lambda
map<string, function<int(int, int)>> binops;

我们能把所有可调用对象,包括函数指针、lambda或者函数对象在内,都添加到这个map中:

1
2
3
4
5
6
7
map<string, function<int(int, int)>> binops = {
	{"+", add}, //函数指针
	{"-", std::minus<int>()}, //标准库函数对象
	{"/", divide()}, //用户定义的函数对象
	{"*", [](int i, int j) { return i*j; }}, //未命名的lambda
	{"%", mod} //命名了的lambda对象
};

我们的map中包含5个元素,尽管其中的可调用对象的类型各不相同,我们仍然能够把所有这些类型都存储在同一个function<int (int, int)>类型中。

一如往常,当我们索引map时将得到关联值的一个引用。如果我们索引binops,将得到function对象的引用。function类型重载了调用运算符,该运算符接受它自己的实参然后将其传递给存好的可调用对象:

1
2
3
4
5
binops["+"](10, 5); //调用add(10, 5)
binops["-"](10, 5); //使用minus<int>对象的调用运算符
binops["/"](10, 5); //使用divide对象的调用运算符
binops["*"](10, 5); //调用lambda函数对象
binops["%"](10, 5); //调用lambda函数对象

4.3.重载的函数与function

我们不能(直接)将重载函数的名字存入function类型的对象中:

1
2
3
4
int add(int i, int j) { return i+j; }
Sales_data add(const Sales_data&, const Sales_data&);
map<string, function<int(int, int)>> binops;
binops.insert({"+", add}); //错误:哪个add?

解决上述二义性问题的一条途径是存储函数指针而非函数的名字:

1
2
int (*fp) (int, int) = add; //指针所指的add是接受两个int的版本
binops.insert({"+", fp}); //正确:fp指向一个正确的add版本

同样,我们也能使用lambda来消除二义性:

1
2
//正确:使用lambda来指定我们希望使用的add版本
binops.insert( {"+", [](int a, int b) {return add(a,b);} } );

lambda内部的函数调用传入了两个int,因此该调用只能匹配接受两个int的add版本,而这也正是执行lambda时真正调用的函数。

新版本标准库中的function类与旧版本中的unary_function和binary_function没有关联,后两个类已经被更通用的bind函数替代了。