参考资料:
- 《C++ Primer》第5版
- 《C++ Primer 习题集》第5版
14.8 函数调用运算符(P506)
如果类重载了函数调用运算符,则我们可以像使用函数一样使用该类的对象。这样的类同时也能存储状态,所以它们比普通函数更加灵活。我们先考虑这样一个简单的类:
struct absInt {
int operator()(int val) const {
return val < 0 ? -val : val;
}
};
int i = -42;
absInt absObj;
int ui = absObj(i); // ui的值为42
调用运算符必须是成员函数。一个类可以定义多个不同版本的调用运算符,相互之间在参数数量或类型上应该有所区别。
如果类定义了调用运算符,则该类的对象称作函数对象(function object)。
含有状态的函数对象类
函数对象类通常含有一些数据成员,这些成员被用于定制调用运算符中的操作:
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; // 分隔符
};
PrintString printer;
printer(s); //在cout中打印s,后面跟一个空格
PrintString errors(cerr, '\n');
errors(s); // 在cerr中打印s,后面跟一个换行符
for_each(vs.begin(), vs.end(), PrintString(cerr, '\n'));
14.8.1 lambda
是函数对象(P507)
当我们编写一个 lambda
后,编译器将该表达式翻译成一个未命名类的未命名对象,该类中有一个重载的函数调用运算符。例如,我们之前传递给 stable_sort
的 lambda
表达式:
stable_sort(words.begin(), words.end(),
[](const string &a, const string &b)
{return a.size()<b.size();});
其行为类似于下面这个类的未命名对象:
class ShorterString {
public:
bool operator()(const string &a, const string &b) const {
return a.size() < b.size();
}
};
lambda
产生的类有一个函数调用运算符成员,其返回值类型、形参列表、函数体均与 lambda
表达式完全一样。默认情况下 lambda
不能改变它捕获的变量,因此 lambda
产生的类中的函数调用运算符是一个 const
成员函数,除非 lambda
被声明成可变的。
我们用上面的类替代 lambda
表达式:
stable_sort(words.begin(), words.end(), ShorterString()); // 创建一个ShorterString对象
表示lambda
及相应捕获行为的类
前面提到过,如果 lambda
引用捕获变量时,程序负责保证 lambda
执行时所引用的对象确实存在,所以编译器可以直接使用该引用而无需在 lambda
类中将其存储为数据成员。
相反,通过值捕获的变量将被拷贝到 lambda
中。因此,此种 lambda
产生的类必须为每个值捕获的变量建立数据成员,同时创建构造函数。例如我们有这样一个 lambda
:
auto wc = find_if(words.begin(), words.end(),
[sz](const string &s)
{return a.size() >= sz; });
它产生的类形如:
class SizeComp{
public:
SizeComp(size_t n): sz(n) { } // 值捕获对应变量
bool operator()(const string &s) const // 返回类型、形参、函数体均与lambda一致
{return s.size() >= sz; }
private:
size_t sz;
}
14.8.2 标准库定义的函数对象(P509)
标准库定义了一组表示算术运算符、关系运算符、逻辑运算符的类,每个类分别定义了一个执行命名操作的调用运算符。这些类定义在头文件 functional
:
![560794de0b9e271cdb847627af39899](https://i-blog.csdnimg.cn/blog_migrate/4acde11796c0151ee30184fef6a8607f.jpeg)
plus<int> intAdd;
negate<int> intNegate;
int sum = intAdd(10, 20);
sum = intNegate(intAdd(10, 20));
在算法中使用标准库函数对象
如果想要执行降序排列,我们可以向 sort
传入一个 greater
类型的对象:
sort(svec.begin(), svec.end(), greater<string>());
我们之前提到过,比较两个无关的指针是未定义的行为。但有时候,我们希望根据内存地址对指针 vector
进行排序,我们可以使用 less
类型的对象:
vector<string*> nameTable;
sort(nameTable.begin(), nameTable.end(),
[](string *a, string *b) {return a < b;}); // 错误,<是未定义行为
sort(nameTable.begin(), nameTable.end(), less<string*>()); // 正确
14.8.3 可调用对象与function
(P511)
C++ 中有集中可调用的对象:函数、函数指针、lambda
表达式、bind
创建的对象、重载了调用运算符的类。可调用对象也有类型,每个 lambda
有自己独有的类型,函数和函数指针的类型由返回值类型和参数类型决定。
然而,两个不同类型的调用对象,却可能共享同一种调用形式(call signature)。调用形式指明了调用的返回类型及调用所需的参数类型,一种调用形式对应一种函数类型:
int(int, int);
不同类型可能具有相同的调用形式
对于不同类型可调用对象共享同一种调用形式的情况,我们有时希望把它们看作相同类型:
// 普通函数
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;
}
};
虽然上述可调用对象的类型各不相同,但是共享同一种调用形式 int(int, int)
。我们可能想使用上述可调用对象构建一个简单的计算器。为了达成这一目的,我们需要定义一个函数表,用于存储这些可调用对象的指针。函数表可以通过 map
实现,将表示运算符号的 string
作为关键字。我们会遇到这样的问题:
map<string, int(*)(int, int)> binops;
binops.insert({"+", add}); // 正确
binops.insert({"%", mod}); // 错误,mod不是一个函数指针
//(然而vs2022和devc++都不报错,且能正常使用)
binops.insert({".", divide}); // 错误,原因同上
标准库function
类型
头文件 functional
头文件中定义了名为 function
新标准库类型,可以解决上面提到的问题:
![a598a10895741947b4d8fcd2d49b31e](https://i-blog.csdnimg.cn/blog_migrate/f1c577add1835c61670e607b80c59c8f.jpeg)
function
是一个模板,在创建一个具体的 function
类型时需要对象的调用形式:
function<int(int, int)> f1 = add;
function<int(int, int)> f2 = divide();
function<int(int, int)> f3 = mod;
使用 function
,我们可以重新定义 map
:
map<string, function<int(int, int)>> binops = {
{"+", add},
{"-", std::minus<int>()},
{"/", divide()},
{"*", [](int i, int j) {return i * j; }},
{"%", mod}
};
重载的函数与function
直接将重载函数的名字存入 funtion
类型的对象中,将产生二义性错误:
int add(int i, int j) { return i + j; }
double add(double i, double j) { return i + j; }
map<string, function<int(int, int)>> binops({ "+", add }); // 错误,不知道是哪个add
解决上述二义性问题的一条途径是存储函数指针:
int (*fp)(int, int) = add;
binops.insert({ "+", add });
14.9 重载、类型转换与运算符(P514)
转换构造函数和类型转换运算符共同定义了类类型转换(class-type conversions)。
14.9.1 类型转换运算符(P514)
类型转换运算符(conversion operator)是类的一种特殊成员函数,负责将一个类类型的值转换为其他类型:
operator type() const;
其中,type
可以是除 void
外的任意一种类型,只要该类型能作为函数的返回类型。类型转换运算符没有显式的返回类型,也没有形参,而且必须定义成类的成员函数。类型转换运算符不应改变转换对象的内容,因此类型转换运算符一般被定义成 const
成员
定义含有类型转换运算符的类
我们定义一个简单的类,用来表示 0~255 之间的一个整数:
class SmallInt {
public:
SmallInt(int i = 0) :val(i) {
if (i<0 || i>255) {
throw out_of_range("Bad SmallInt value");
}
}
operator int() const { return val; }
private:
unsigned val;
};
SmallInt
类既定义了向类类型的转换,也定义了从类类型向替他类型的转换:
SmallInt si;
si = 4;
si + 3; // si被隐式转换成int,然后执行整型加法
编译器一次只能执行一个用户定义的类型转换,但隐式的用户定义类型转换可以置于标准类型转换的之前或之后,并与其一起使用。因此,我们可以将任何算术类型传递给 SmallInt
的构造函数,也能将 SmallInt
对象转换成 int
,再将 int
转换成任何算术类型对象:
SmallInt si = 3.14;
si + 3.14;
由于类型转换运算符是隐式执行的,所以无法给其传递参数,当然也就不能再类型转换运算符的定义中使用任何形参。
避免过度使用类型转换函数,如果类类型和转换的目标类型之间不存在明显的映射关系,则这样的类型转换可能存在误导性。
类型转换运算符可能产生意外结果
在实践中,类很少提供类型转换运算符,一个例外情况是,人们常常会定义向 bool
类型的转换。
然而,如果一个类想定义一个向 bool
的类型转换,常常会遇到一个问题:由于 bool
是一种算术类型,所以类类型转换成 bool
可能被转换成其他算术类型,从而引发意想不到的结果。
显式的类型转换说明符
为了防止上述异常状况的发生,C++11 新标准引入了显式的类型转换运算符(explicit conversion operator):
class SmallInt {
public:
explicit operator int() const { return val; }
};
SmallInt si = 3;
si + 3; // 错误,此处需要隐式的类型转换,但类型转换运算符是显式的
static_cast<int>(si) + 3; // 正确,显式请求类型转换
编译器不会将一个显式的类型转换运算符用于隐式类型转换,唯一的例外是,如果表达式被用作条件,显式的类型转换(必须有转换成 bool
的运算符)将被隐式执行。
转换为bool
向 bool
的类型通常用在条件部分,operator bool
一般被定义成 explicit
。
14.9.2 避免有二义性的类型转换(P517)
如果类中包含类型转换,必须确保类类型和目标类型之间只存在唯一一种转换方式。两种情况可能产生多重转换路径:
- 两个类提供了相同的类型转换:类 A 定义了接受一个 类 B 对象的转换构造函数,类 B 又定义了一个转换目标是类 A 的类型转换运算符。
- 类定义了多个转换规则,而这些转换涉及的类型又可以通过其他类型转换联系在一起。最典型的例子是算术类型,一个类最好只定义一个与算术类型有关的转换。
实参匹配和相同的类型转换
struct B;
struct A {
A() = default;
A(const B &);
};
struct B {
operator A() const;
};
A f(const A &);
B b;
A a = f(b); // 错误,是f(B::operator A()),还是f(A::A(const B&))?
如果我们一定要执行上述函数调用,我们可以显式调用转换构造函数或类型转换运算符:
A a = f(b.operator A());
A a = f(A(b));
二义性与转换目标为内置类型的多重类型转换
struct A {
A(int = 0) { cout << "int -> A"; }
A(double) {cout << "double->A"; }
operator int() const { cout << "A -> int"; return val; };
operator double() const { cout << "A -> double"; return val; }
int val;
};
void f(long double);
A a;
f(a); // 错误,是f(a.operator int()),还是f(a.operator double())?
long lg;
A a2(lg); // 错误,是A(int),还是A(double)?
short s = 0;
A a3(s); // 正确,short被提升成int,然后执行A(int)
除了显式地向
bool
类型的转换之外,我们应尽量避免定义转换目标是内置类型的转换。
重载函数与转换构造函数
struct C {
C(int);
};
struct D {
D(int);
};
void manip(const C &);
void manip(const D &);
manip(10); // 错误,是manip(C(10)),还是manip(D(10))?
重载函数与用户定义的类型转换
当调用重载函数时,如果多个用户定义的类型转换都提供了可行匹配,则编译器认为这些类型转换一样好,且编译器不会考虑任何可能出现的标准类型转换的级别:
struct C {
C(int);
};
struct E {
E(double);
};
void manip(const C &);
void manip(const E &);
manip(10); // 错误,是manip(C(10)),还是manip(E(double(10)))?
只有当重载函数能通过同一个类型转换得到匹配时,编译器才会考虑其中出现的标准类型转换:
struct F{
operator int();
};
void manip(int);
void manip(double);
F f;
manip(f); // 正确,调用manip(int)
14.9.3 函数匹配与重载运算符(P521)
重载的运算符也是重载的函数,当运算符函数出现在表达式中时,候选函数集的范围可能比调用普通重载函数更大。如果运算符的左侧运算对象是类类型,则候选函数将同时包含内置运算符,以及该运算符的非成员版本和成员版本。而对于一般的函数调用,即使成员函数和非成员函数重名,但它们的调用方式是不一样的。
class SmallInt {
public:
SmallInt(int i = 0) :val(i) { }
SmallInt operator+(const SmallInt &a) {
cout << "member plus" << endl;
return SmallInt(a.val + val);
}
friend SmallInt operator+(const SmallInt &, const SmallInt &);
operator int() { return val; }
private:
unsigned val;
};
1 + s1; // "common plus"
s1 + 1; // "member plus",居然没有二义性错误吗?
s1 + s2; // "member plus"
如果在上面的 SmallInt
中加入 operator int() { return val; }
成员,那么 1 + s1
和 s1 + 1
将出现二义性错误,因为无法确定调用重载 +
还是标准 +
。