类设计者的工具(三):重载运算与类型转换

本文为《C++ Primer》的读书笔记

基本概念

重载的运算符是具有特殊名字的函数: 它们的名字由关键字operator 和其后要定义的运算符号共同组成。除了重载的函数调用运算符operator()之外,其他重载运算符不能含有默认实参

如果一个运算符函数是成员函数,则它的第一个(左侧)运算对象绑定到隐式的this指针上

一个运算符函数或者是类的成员, 或者至少含有一个类类型的参数。这一约定意味着当运算符作用于内置类型的运算对象时, 我们无法改变该运算符的含义:

// 错误: 不能为int重定义内置的运算符
int operator+(int, int);

我们可以重载大多数(但不是全部) 运算符。表14.1指明了哪些运算符可以被重载,哪些不行。我们只能重载已有的运算符, 而无权发明新的运算符号

在这里插入图片描述

有四个符号(+, -, *, &)既是一元运算符也是二元运算符, 所有这些运算符都能被重载, 从参数的数量我们可以推断到底定义的是哪种运算符

直接调用一个重载的运算符函数

通常情况下, 我们将运算符作用于类型正确的实参, 从而以这种间接方式” 调用“ 重载的运算符函数。然而, 我们也能像调用普通函数一样直接调用运算符函数:

// 一个非成员运算符函数的等价调用
data1 + data2;
operator+(data1, data2);

我们也可以像调用其他成员函数一样显式地调用成员运算符函数:

data1 += data2;
data1.operator+=(data2);

某些运算符不应该被重载

某些运算符指定了运算对象求值的顺序。因为使用重载的运算符本质上是一次函数调用,所以这些运算对象求值顺序的规则无法应用到重载的运算符上。特别是, 逻辑与运算符、逻辑或运算符和逗号运算符的运算对象求值顺序规则无法保留下来&&||运算符的重载版本也无法保留内置运算符的短路求值属性

因为上述运算符的重载版本无法保留求值顺序和/或短路求值属性,因此不建议重载它们。当代码使用了这些运算符的重载版本时, 用户可能会突然发现他们一直习惯的求值规则不再适用了

还有一个原因使得我们一般不重载逗号运算符和取地址运算符: C++语言已经定义了这两种运算符用于类类型对象时的特殊含义

结论:通常情况下, 不应该重载逗号、取地址、逻辑与 和 逻辑或运算符

赋值和复合赋值运算符

如果类含有算术运算符或者位运算符, 则最好也提供对应的复合赋值运算符

选择作为成员或者非成员

定义重载的运算符时, 必须首先决定是将其声明为类的成员函数还是声明为一个普通的非成员函数。在某些时候我们别无选择, 因为有的运算符必须作为成员; 另一些情况下, 运算符作为普通函数比作为成员更好

下面的准则有助于我们在将运算符定义为成员函数还是普通的非成员函数做出抉择:

  • 赋值(=)、下标([])、调用(())和成员访问箭头( ->)运算符必须是成员
  • 复合赋值运算符一般来说应该是成员,但并非必须
  • 改变对象状态的运算符或者与给定类型密切相关的运算符, 如递增、递减和解引用运算符, 通常应该是成员
  • 具有对称性的运算符可能转换任意一端的运算对象, 例如算术、相等性、关系和位运算符等, 因此它们通常应该是普通的非成员函数
    程序员希望能在含有混合类型的表达式中使用对称性运算符。例如, 我们能求一个int 和一个double 的和,因为它们中的任意一个都可以是左侧运算对象或右侧运算对象,所以加法是对称的。如果我们想提供含有类对象的混合类型表达式, 则运算符必须定义成非成员函数

当我们把运算符定义成成员函数时, 它的左侧运算对象必须是运算符所属类的一个对象。例如:

string s = "world";
string t = s + "!"; // 正确:我们能把一个const char*加到一个string对象中
string u = "hi" + s; // 如果+是string的成员, 则产生错误

如果operator+string类的成员,则上面的第一个加法等价于s.operator+("!")"hi"+s 等价于"hi".operator+(s)。显然"hi"的类型是const char*, 这是一种内置类型, 根本就没有成员函数。而如果string+定义成了普通的非成员函数, 则"hi"+s 等价于operator+("hi",s)。和任何其他函数调用一样, 每个实参都能被转换成形参类型。唯一的要求是至少有一个运算对象是类类型, 并且两个运算对象都能准确无误地转换成string

输入和输出运算符

重载输出运算符 <<

通常情况下, operator<<

  • 第一个形参是一个非常量ostream对象的引用。之所以ostream是非常量是因为向流写入内容会改变其状态
  • 第二个形参—般来说是一个常量的引用, 该常量是我们想要打印的类类型
  • 一般要返回它的ostream形参

例如:

ostream &operator<<(ostream &os, const Sales_data &item)
{
    
    
	os << item.isbn() << " " << item.units_sold << " "
		<< item.revenue << " " << item.avg_price();
	return os;
}

输出运算符不要打印换行符等格式控制字符。令输出运算符尽量减少格式化操作可以使用户有权控制输出的细节

输入输出运算符必须是非成员函数

iostream标准库兼容的输入输出运算符必须是普通的非成员函数, 而不能是类的成员函数。否则, 它们的左侧运算对象将是我们的类的一个对象:

Sales_data data;
data << cout; // 如果operator<<是Sales_data的成员

当然,IO运算符通常需要读写类的非公有数据成员,所以IO运算符一般被声明为友元

重载输入运算符 >>

通常情况下,operator>>

  • 第一个形参是运算符将要读取的流的引用
  • 第二个形参是将要读入到的(非常量) 对象的引用
  • 该运算符通常会返回某个给定流的引用

例如:

istream &operator>>(istream &is, Sales_data &item)
{
    
    
	double price;	
	is >> item.bookNo >> item.units_sold >> price;
	if (is) 					//检查输入是否成功
		item.revenue = item.units_sold * price;
	else
		item = Sales_data(); 	//输入失败: 对象被赋予默认的状态
	return is;
}

输入运算符必须处理输入可能失败的情况,而输出运算符不需要

输入时的错误

在执行输入运算符时可能发生下列错误:

  • 当流含有错误类型的数据时读取操作可能失败
  • 当读取操作到达文件末尾或者遇到输入流的其他错误时也会失败

在程序中我们没有逐个检查每个读取操作,而是等读取了所有数据后赶在使用这些数据前一次性检查

如果在发生错误前对象已经有一部分被改变,则适时地将对象置为合法状态显得异常重要。例如在之前的例子中, 我们可能在成功读取新的bookNo后遇到错误, 这意味着对象的units_soldrevenue成员并没有改变,因此有可能会将这两个数据与一条完全不匹配的bookNo组合在一起。通过将对象置为合法的状态, 我们能(略微)保护使用者免于受到输入错误的影响

标示错误

一些输入运算符需要做更多数据验证的工作。例如, 我们的输入运算符可能需要检查bookNo是否符合规范的格式

通常情况下, 输入运算符只设置failbit。除此之外,设置eofbit表示文件耗尽,而设置badbit表示流被破坏。最好的方式是由IO 标准库自己来标示这些错误

算术和关系运算符

通常情况下,我们把算术和关系运算符定义成非成员函数以允许对左侧或右侧的运算对象进行转换。因为这些运算符一般不需要改变运算对象的状态,所以形参都是常量的引用

如果类定义了算术运算符,则它一般也会定义一个对应的复合赋值运算符。此时,最有效的方式是使用复合赋值来定义算术运算符

// 假设两个对象指向同一本书
Sales_data
operator+(const Sales_data &lhs, canst Sales_data &rhs)
{
    
    
	Sales_data sum = lhs;
	sum += rhs;
	return sum;
}

如果类同时定义了算术运算符和相关的复合赋值运算符,则通常情况下应该使用复合赋值来实现算术运算符

相等运算符

通常情况下,C++中的类通过定义相等运算符检验两个对象是否相等。也就是说,它们会比较对象的每一个数据成员,只有当所有对应的成员都相等时才认为两个对象相等

依据这一思想,我们的Sales_data类的相等运算符不但应该比较bookNo, 还应该比较具体的销售数据:

bool operator==(const Sales_data &lhs, const Sales_data &rhs)
{
    
    
	return lhs.isbn() == rhs.isbn() &&
			lhs.units_sold == rhs.units_sold &&
			lhs.revenue == rhs.revenue;
}

bool operator!=(const Sales_data &lhs, const Sales_data &rhs
{
    
    
	return !(lhs == rhs);
}
  • 如果类定义了operator== , 则这个类也应该定义operator!=

  • 相等运算符和不相等运算符中的一个应该把工作委托给另外一个

关系运算符

定义了相等运算符的类也常常(但不总是)包含关系运算符。特别是,因为关联容器和一些算法要用到小于运算符,所以定义operator<会比较有用

通常情况下关系运算符应该:

  1. 定义顺序关系,令其与关联容器中对关键字的要求一致
  2. 如果类同时也含有==运算符的话,则定义一种关系令其与==保持一致。特别是,如果两个对象是!=的,那么一个对象应该<另外一个

尽管我们可能会认为Sales_data类应该支持关系运算符, 但事实证明并非如此,其中的缘由比较微妙,值得读者深思。一开始我们可能会认为应该像compareisbn那样定义<,该函数通过比较ISBN来实现对两个对象的比较。然而,尽管compareisbn提供的顺序关系符合要求1, 但是函数得到的结果显然与我们定义的==不一致,因此它不满足要求2

如果存在唯一一种逻样可靠的<定义,则应该考虑为这个类定义<运算符。如果类同时还包含==,则当且仅当<的定义和==产生的结果一致时才定义<运算符

赋值运算符

之前已经介绍过拷贝赋值和移动赋值运算符, 它们可以把类的一个对象赋值给该类的另一个对象。此外, 类还可以定义其他赋值运算符以使用别的类型作为右侧运算对象

举个例子, 在拷贝赋值和移动赋值运算符之外,标准库vector类还定义了第三种赋值运算符, 该运算符接受花括号内的元素列表作为参数:

vector<string> v;
v = {
    
    "a", "an", "the"};

同样, 也可以把这个运算符添加到StrVec类中:

class StrVec {
    
    
public:
	StrVec &operator= (std::initializer_list<std::string>);
	// ...
StrVec &StrVec::operator=(initializer_list<string> i1)
{
    
    
	// alloc_n_copy 分配内存空间并从给定范围内拷贝元素
	auto data = alloc_n_copy(i1.begin(), i1.end());
	free(); // 销毁对象中的元素并释放内存空间
	elements = data.first; //更新数据成员使其指向新空间
	first_free = cap = data.second;
	return *this;
}

和拷贝赋值及移动赋值运算符一样,其他重载的赋值运算符也必须先释放当前内存空间,再创建一片新空间。不同之处是,这个运算符无须检查对象向自身的赋值

我们可以重载赋值运算符。不论形参的类型是什么,赋值运算符都必须定义为成员函数

复合赋值运算符

复合赋值运算符不非得是类的成员,不过我们还是倾向于把包括复合赋值在内的所有赋值运算都定义在类的内部

例如:

// 作为成员的二元运算符: 左侧运算对象绑定到隐式的this指针
// 假定两个对象表示的是同一本书
Sales_data& Sales_data::operator+=(const Sales_data &rhs)
{
    
    
	units_sold += rhs.units_sold;
	revenue += rhs.revenue;
	return *this;
}

下标运算符

表示容器的类通常可以通过元素在容器中的位置访问元素,这些类一般会定义下标运算符operator[]

下标运算符必须是成员函数

  • 下标运算符通常以所访问元素的引用作为返回值
  • 最好同时定义下标运算符的常量版本和非常量版本, 当作用于一个常量对象时, 下标运算符返回常量引用以确保我们不会给返回的对象赋值

例如:

class StrVec {
    
    
public:
	std::string& operator[] (std::size_t n)
		{
    
     return elements[n]; }
	const std::string& operator[] (std::size_t n) const
		{
    
     return elements[n]; }
	// ...
private:
	std::string *elements; // 指向数组首元素的指针
} ;

递增和递减运算符

在迭代器类中通常会实现递增运算符和递减运算符使得类可以在元素的序列中前后移动。C++语言并不要求递增和递减运算符必须是类的成员,但是因为它们改变的正好是所操作对象的状态, 所以建议将其设定为成员函数

对于内置类型来说, 递增和递减运算符既有前置版本也有后置版本。同样, 我们也应该为类定义两个版本的递增和递减运算符

定义前置递增/递减运算符

class StrBlobPtr {
    
    
public:
	// 递增和递减运算符
	StrBlobPtr& operator++();		// 前置运算符
	StrBlobPtr& operator--();
	// ...
};

为了与内置版本保持一致, 前置运算符应该返回递增或递减后对象的引用

StrBlobPtr& StrBlobPtr::operator++()
{
    
    
	// 如果curr已经指向了容器的尾后位置, 则无法递增它
	check(curr, 'inncrement past end of StrBlobPtr");	// 调用check 函数检验StrBlobPtr是否有效, 如果是, 接着检查给定的索引值是否有效。如果check函数没有抛出异常, 则运算符返回对象的引用
	++curr; // 将curr在当前状态下向前移动一个元素
	return *this;
}

StrBlobPtr& StrBlobPtr::operator--()
{
    
    
	// 如果curr是0, 则继续递减它将产生一个无效下标
	--curr;   	// 将curr在当前状态下向后移动一个元素。如果curr ( 一个无符号数)已经是0了, 那么我们传递给check的值将是一个表示无效下标的非常大的正数值
	check(curr, "decrement past begin of StrBlobPtr");
	return *this;
}

区分前置和后置运算符

前置和后置版本使用的是同一个符号, 意味着其重载版本所用的名字将是相同的, 并且运算对象的数量和类型也相同

为了解决这个问题, 后置版本接受一个额外的(不被使用) int类型的形参。当我们使用后置运算符时, 编译器为这个形参提供一个值为 0 的实参

class StrBlobPtr {
    
    
public:
	// 递增和递减运算符
	StrBlobPtr operator++(int);		// 后置运算符
	StrBlobPtr operator--(int);
	// ...
};

为了与内置版本保持一致, 后置运算符应该返回对象的原值

对于后置版本来说, 在递增对象之前需要首先记录对象的状态:

StrBlobPtr StrBlobPtr::operator++(int)
{
    
    
	// 此处无须检查有效性, 调用前置递增运算时才需要检查
	StrBlobPtr ret = *this;		// 记录当前的值
	++*this; 					// 向前移动一个元素,前置++需要检查递增的有效性
	return ret;
}

StrBlobPtr StrBlobPtr::operator--(int)
{
    
    
	// 此处无须检查有效性, 调用前置递减运算时才需要检查
	StrBlobPtr ret = *this; 	// 记录当前的值
	--*this; 					// 向后移动一个元素,前置--需要检查递减的有效性
	return ret; 
}

因为我们不会用到int形参,所以无须为其命名

显式地调用后置运算符

StrBlobPtr p(a1);
p.operator++(0);	// 后置版本
p.operator++();		// 前置版本

成员访问运算符

在迭代器类及智能指针类中常常用到解引用运算符(*)和箭头运算符( ->)。我们以如下形式向类中添加这两种运算符:

class StrBlobPtr {
    
    
public:
	std::string& operator*() const
		{
    
     auto p = check(curr, "dereference past end");
			return (*p)[curr];} 	// (*p)是对象所指的vector
	std::string* operator->() const
		{
    
    // 将实际工作委托给解引用运算符
		return &this->operator*();// ...

箭头运算符不执行任何自己的操作,而是调用解引用运算符并返回解引用结果元素的地址。

箭头运算符必须是类的成员。解引用运算符通常也是类的成员,尽管并非必须
如此

值得注意的是, 我们将这两个运算符定义成了const成员。同时, 它们的返回值分别是非常量string的引用或指针, 因为一个StrBlobPtr 只能绑定到非常量的StrBlob对象

这两个运算符的用法与指针或者vector迭代器的对应操作完全一致:

StrBlob a1 = {
    
    "hi", "bye", "now"};
StrBlobPtr p(a1); 					// p指向a1中的vector
*p = "okay"; 						// 给a1的首元素赋值
cout << p->size() << endl; 			
cout << (*p).size() << endl;
cout << p.operator()->size() << endl;

和大多数其他运算符一样(尽管这么做不太好),我们能令operator* 完成任何我们指定的操作。换句话说, 我们可以让operator*返回一个固定值, 或者打印对象的内容, 或者其他。箭头运算符则不是这样, 它永远不能丢掉成员访问这个最基本的含义。重载箭头时, 可以改变的是箭头从哪个对象当中获取成员, 而箭头获取成员这一事实则永远不变

point->mem 的执行过程如下所示:

  1. 如果point是指针,则我们应用内置的箭头运算符,表达式等价于(*point) .mem。首先解引用该指针, 然后从所得的对象中获取指定的成员
  2. 如果point是定义了operator->的类的一个对象,则我们使用point.operator->()的结果来获取mem。其中, 如果该结果是一个指针, 则执行第1步:如果该结果本身含有重载的operator->(), 则重复调用当前步骤

重载的箭头运算符必须返回类的指针或者自定义了箭头运算符的某个类的对象

函数调用运算符

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

举个简单的例子:

struct absInt {
    
    
	int operator() (int val) const {
    
    
		return val < 0 ? -val : val;
	}
};
int i = -42;
absInt absObj;
int ui = absObj(i);

一个类可以定义多个不同版本的调用运算符

如果类定义了调用运算符, 则该类的对象称作函数对象(function object)

函数对象类通常含有一些数据成员用于定制调用运算符中的操作。举个例子,我们将定义一个打印string实参内容的类。默认情况下, 我们的类会将内容写入到cout中, 每个string之间以空格隔开。同时也允许类的用户提供其他可写入的流及其他分隔符:

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;
};

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

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

lambda 是函数对象

当我们编写了一个lambda后, 编译器将该表达式翻译成一个未命名类的未命名对象。在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 &sl, const string &s2) const
		( return s1.size() < s2.size(); }
};

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

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

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

当一个lambda表达式通过引用捕获变量时, 将由程序负责确保lambda执行时引用所引的对象确实存在。因此, 编译器可以直接使用该引用而无须在lambda产生的类中将其存储为数据成员。相反, 通过值捕获的变量被拷贝到lambda中。因此, 这种lambda 产生的类必须为每个值捕获的变量建立对应的数据成员, 同时创建构造函数,令其使用捕获的变量的值来初始化数据成员。例如:

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

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

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

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

auto we = find_if(words.begin(), words.end(), SizeComp(sz));

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

标准库定义的函数对象

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

这些类都被定义成模板的形式, 我们可以为其指定调用运算符的形参类型。例如, plus<string>string加法运算符作用于string对象:

plus<int> intAdd; 		// 加法
negate<int> intNegate; 	// 取反
// 使用intAdd::operator(int, int)求10和20的和
int sum = intAdd(10, 20); 
sum = intNegate(intAdd(10, 20)); 

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

在这里插入图片描述

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

表示运算符的函数对象类常用来替换算法中的默认运算符。例如:

// sort 默认使用 < 进行升序排列
// 传入一个临时的函数对象用于执行两个string对象的比较运算
sort(svec.begin(), svec.end(), greater<string>());

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

vector<string *> nameTable; 
// 错误: 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

可调用对象与function

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

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

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

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 denorninator / divisor;
	};
};

上面这些可调用对象尽管它们的类型各不相同, 但是共享同一种调用形式:

int (int, int)

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

函数表很容易通过map来实现。对于此例来说, 我们使用一个表示运算符符号的string对象作为关键字;使用实现运算符的函数作为值

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

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

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

binops.insert({
    
    "+", add}); 

但是我们不能将mod或者divide存入binops。问题在于mod是个lambda表达式, 而每个lambda有它自己的类类型, 该类型与存储在binops中的值的类型不匹配。

标准库function类型

我们可以使用一个名为function的新的标准库模板类来解决上述问题, function定义在functional头文件中

在这里插入图片描述

function<int(int, int)>

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

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

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

map<string, function<int(int, int)>> binops;

我们能把所有可调用对象都添加到这个map中:

map<string, function<int(int, int)>> binops = {
    
    
	{
    
    "+", add}, 				// 函数指针
	{
    
     "-", std::minus<int>()},	// 标准库函数对象
	{
    
    "I", divide()},			// 用户定义的函数对象
	{
    
    "*", [](int i, int j) {
    
     return i * j; }},	// 未命名的lambda
	{
    
    "%", mod}};				// 命名了的lambda对象
binops["+"](10, 5); //调用add(10, 5)
binops["-"](10, 5); //使用minus<int>对象的调用运算符
binops["/"](10, 5); //使用divide对象的调用运算符
binops["*"](10, 5); //调用lambda函数对象
binops["%"](10, 5); //调用lambda函数对象
int a, b;
string op;
cin >> a >> op >> b;
cout << binops[op](a, b) << endl;

重载的函数与function

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

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?

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

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

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

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

lambda内部的函数调用传入了两个int, 因此该调用只能匹配接受两个intadd版本

重载、类型转换与运算符

我们能通过定义类型转换运算符来定义类类型的类型转换。转换构造函数和类型转换运算符共同定义了类类型转换(class-type conversions)

类型转换运算符

类型转换运算符(conversion operator)是类的一种特殊成员函数, 它负责将一个类类型的值转换成其他类型:

operator type() const;

t y p e type type 可以为 const int, int, double

其中type表示某种类型。类型转换运算符可以面向任意类型(除了void之外)进行定义,只要该类型能作为函数的返回类型。因此, 我们不允许转换成数组或者函数类型, 但允许转换成指针(包括数组指针及函数指针)或者引用类型

类型转换运算符既没有显式的返回类型,也没有形参,而且必须定义成类的成员函数。类型转换运算符通常不应该改变待转换对象的内容, 因此, 类型转换运算符一般被定义成const成员

尽管没有显式的返回类型,函数体内还是应该返回一个 t y p e type type 类型的值

定义含有类型转换运算符的类

举个例子, 我们定义一个比较简单的类, 令其表示0到255之间的一个整数:

class SmallInt {
    
    
public:
	SmallInt(int i = 0): val(i)
	{
    
    
		if (i < 0 || i > 255)
			throw std::out_of_range("Bad Smallint value");
	}
	
	operator int() const {
    
     return val; }
private:
	std::size_t val;
};

SmallInt 类的构造函数将算术类型的值转换成SmallInt对象, 而类型转换运算符将SmallInt对象转换成int

SmallInt si;
si = 4; 	// 首先将4隐式地转换成SmallInt, 然后调用SmallInt::operator=
si + 3;		// 首先将si隐式地转换成int, 然后执行整数的加法

尽管编译器一次只能执行一个用户定义的类型转换, 但是隐式的用户定义类型转换可以置于一个标准(内置)类型转换之前或之后, 并与其一起使用。因此, 我们可以将任何算术类型传递给SmallInt 的构造函数。类似的, 我们也能使用类型转换运算符将一个SmallInt 对象转换成int,然后再将所得的int转换成任何其他算术类型:

// 内置类型转换将double实参转换成int
Smallint si = 3.14; // 调用Smallint(int)构造函数
//  Smallint的类型转换运算符将si转换成int
si + 3.14; // 内置类型转换将所得的int继续转换成double

提示:避免过度使用类型转换函数
如果在类类型和转换类型之间不存在明显的映射关系, 则这样的类型转换可能具有误导性
例如, 假设某个类表示Date, 我们也许会为它添加一个从Dateint的转换。然而,类型转换函数的返回值应该是什么? 一种可能的解释是,函数返回一个十进制数依次表示年、月、日, 例如,July 30, 1989可能转换为int值19890730。同时还存在另外一种合理的解释, 即类型转换运算符返回的int表示的是从某个时间节点(比如January 1, 1970)开始经过的天数。显然这两种理解都合情合理。
问题在于Date类型的对象和int类型的值之间不存在明确的一对一映射关系。因此在此例中, 不定义该类型转换运算符也许会更好。作为替代的手段, 类可以定义一个或多个普通的成员函数以从各种不同形式中提取所需的信息

类型转换运算符可能产生意外结果

在实践中, 类很少提供类型转换运算符。在大多数情况下, 如果类型转换自动发生,用户可能会感觉比较意外,而不是感觉受到了帮助。然而这条经验法则存在一种例外情况:对于类来说, 定义向bool的类型转换还是比较普遍的现象

在C++标准的早期版本中, 如果类想定义一个向bool的类型转换,则它常常遇到一个问题: 因为bool是一种算术类型, 所以类类型的对象转换成bool后就能被用在任何需要算术类型的上下文中。这样的类型转换可能引发意想不到的结果,特别是当istream含有向bool的类型转换时, 下面的代码仍将编译通过:

int i = 42;
cin << i; //如果向bool的类型转换不是显式的, 则该代码在编译器看来将是合法的!

这段程序试图将输出运算符作用于输入流。因为istream本身并没有定义<<, 所以本来代码应该产生错误。然而, 该代码能使用istreambool类型转换运算符将cin 转换成bool, 而这个bool值接着会被提升成int并用作内置的左移运算符的左侧运算对象。这样一来, 提升后的bool值最终会被左移42个位置。这一结果显然与我们的预期大相径庭

显式的类型转换运算符

为了防止这样的异常情况发生,C++11新标准引入了显式的类型转换运算符(explicit conversion operator):

class Smallint {
    
    
public:
	// 编译器不会自动执行这一类型转换
	explicit operator int() const {
    
     return val; }
	// 其他成员与之前的版本一致
};

和显式的构造函数一样, 编译器(通常)也不会将一个显式的类型转换运算符用于隐式类型转换:

Smallint si = 3; 	//正确: Smallint的构造函数不是显式的
si + 3; 			// 错误: 此处需要隐式的类型转换, 但类的运算符是显式的
static_cast<int>(si) + 3; // 正确: 显式地请求类型转换

该规定存在一个例外, 即如果表达式被用作条件, 则编译器会将显式的类型转换自动应用于它

转换为bool

在标准库的早期版本中, IO 类型定义了向void*的转换规则, 以求避免上面提到的问题。在C++11新标准下,IO 标准库通过定义一个向bool的显式类型转换实现同样的目的

无论我们什么时候在条件中使用流对象, 都会使用为IO 类型定义的operator bool

while (std::cin >> value)

bool的类型转换通常用在条件部分, 因此operator bool 一般定义成explicit

避免有二义性的类型转换

如果类中包含一个或多个类型转换,则必须确保在类类型和目标类型之间只存在唯一一种转换方式。否则的话, 我们编写的代码将很可能会具有二义性

在两种情况下可能产生多重转换路径

  1. 两个类提供相同的类型转换:例如, 当 A A A 类定义了一个接受 B B B 类对象的转换构造函数, 同时 B B B 类定义了一个转换目标是 A A A 类的类型转换运算符时, 我们就说它们提供了相同的类型转换
  2. 类定义了多个转换规则,而这些转换涉及的类型本身可以通过其他类型转换联系在一起。最典型的例子是算术运算符,对某个给定的类来说, 最好只定义最多一个与算术类型有关的转换规则

通常情况下,不要为类定义相同的类型转换,也不要在类中定义两个及两个以上转换源或转换目标是算术类型的转换

实参匹配和相同的类型转换

// 最好不要在两个类之间构建相同的类型转换
struct B;
struct A {
    
    
	A() = defaultA(const B&);	// 把一个B转换成A
	// ...
};
struct B {
    
    
	operator A() const; // 也是把一个B 转换成A
	// ...
};
A f(const A&);
B b;
A a = f(b); // 二义性错误:含义是f(B::operator A()) 还是 f(A::A(const B&))?

如果我们确实想执行上述的调用,就不得不显式地调用类型转换运算符或者转换构造函数:

A a1 = f(b.operator A());
A a2 = f(A(b)) ;

值得注意的是, 我们无法使用强制类型转换来解决二义性问题, 因为强制类型转换本身也面临二义性

二义性与转换目标为内置类型的多重类型转换

如果类定义了一组类型转换, 它们的转换源(或者转换目标)类型本身可以通过其他类型转换联系在一起, 则同样会产生二义性的问题

最简单也足最困扰我们的例子就是类当中定义了多个参数都是算术类型的构造函数, 或者转换目标都是算术类型的类型转换运算符

struct A (
	A(int = 0);	// 最好不要创健两个转换源都是算术类型的类型转换
	A(double);
	
	operator int() const; 	//最好不要创建两个转换对象都是算术类型的类型转换
	operator double() const;
	// 
};

void f2(long double);
A a;
f2(a); //二义性错误:含义是f(A::operator int()) 还是 f(A::operator double())?
long lg;
A a2(lg); // 二义性错误:含义是A :A(int)还是A::A(double)?

short s= 42;
// 把short 提升成int 优于把short 转换成double
A a3(s); // 使用A::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))

调用者可以显式地构造正确的类型从而消除二义性:

manip(C(10)); // 正确: 调用manip(const C&)

如果在调用重载函数时我们需要使用构造函数或者强制类型转换来改变实参的类型, 则这通常意味着程序的设计存在不足

重载函数与用户定义的类型转换

当调用重载函数时, 如果两个(或多个)用户定义的类型转换都提供了可行匹配, 则我们认为这些类型转换一样好。在这个过程中,我们不会考虑任何可能出现的标准类型转换的级别。只有当重载函数能通过同一个类型转换函数得到匹配时, 我们才会考虑其中出现的标准类型转换

struct E {
    
    
	E(double);
	// ...
};

void manip2(const C&);
void manip2(const E&);
// 二义性错误: 两个不同的用户定义的类型转换都能用在此处
// 即使其中一个调用需要额外的标准类型转换而另一个调用能精确匹配,
// 编译器也会将该调用标示为错误
manip2(10); // 含义是manip2(C(10))还是manip2(E(double(10)))

编译器对重载函数进行函数匹配时,实参类型转换的优先级在这篇博客的函数重载小节

函数匹配与重载运算符

重载的运算符也是重载的函数。因此,通用的函数匹配规则同样适用于判断在给定的表达式中到底应该使用内置运算符还是重载的运算符。不过当运算符函数出现在表达式中时,候选函数集的规模要比我们使用调用运算符调用函数时更大

如果a是一种类类型,则表达式a sym b可能是

a.operatorsym(b); 		// a有一个operatorsym成员函数
operatorsym(a, b);		// operatorsym是一个普通函数

和普通函数调用不同,我们不能通过调用的形式来区分当前调用的是成员函数还是非成员函数

当我们使用重载运算符作用千类类型的运算对象时,候选函数中包含该运算符的普通非成员版本和内置版本。除此之外,如果左侧运算对象是类类型,则定义在该类中的运算符的重载版本也包含在候选函数内

例如:

class SmallInt {
    
    
	friend
	SmallInt operator+(const SmallInt&, const SmallInt&);
public:
	SmallInt(int = 0); 		//转换源为int的类型转换
	operator int() const {
    
     return val; } //转换目标为int的类型转换
private:
	std::size_t val;
};

可以使用这个类将两个SmallInt对象相加,但如果我们试图执行混合模式的算术运算,就将遇到二义性的问题:

SmallInt sl, s2;
SmallInt s3 = s1 + s2;	// 使用重载的operator+
int i = s3 + 0;			// 二义性错误:
						// 我们可以把0转换成SmallInt, 然后使用Smallint的+
						// 或者把s3转换成int, 然后对两个int执行内置的加法运算

如果我们对同一个类既提供了转换目标是算术类型的类型转换,也提供了重载的运算符,则将会遇到重载运算符与内置运算符的二义性问题

猜你喜欢

转载自blog.csdn.net/weixin_42437114/article/details/108865727