1. 基本概念

重载的运算符是具有特殊名字的函数:它们的名字由关键字operator和其后要定义的运算符号共同组成。和其他函数一样,重载的运算符也包含返回类型、参数列表以及函数体。

重载运算符函数的参数数量与该运算符作用的运算对象数量一样多。一元运算符有一个参数,二元运算符有两个。对于二元运算符来说,左侧运算对象传递给第一个参数,而右侧运算对象传递给第二个参数。除了重载的函数调用运算符operator()之外,其他重载运算符不能含有默认实参。

如果一个运算符函数是成员函数,则它的第一个(左侧)运算对象绑定到隐式的this指针上,因此,成员运算符函数的(显式)参数数量比运算符的运算对象总数少一个。

  • 对于一个运算符函数来说,它或者是类的成员,或者至少含有一个类类型的参数:

    int operator+(int, int);  // 错误,不能为int重定义内置的运算符
    
  • 只能重载已有的运算符,而无权发明新的运算符号

  • 对于一个重载的运算符来说,其优先级和结合律与对应的内置运算符保持一致。不考虑运算对象类型的话,

    x == y + z;
    // 永远等价于
    x == (y + z);
    
  • 可以重载大多数(但不是全部)运算符

    image-20230403162249048

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

对于重载的运算符我们即可先调用函数一样调用,也可以像使用普通的运算符一样使用

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

对于成员函数的第一个(左侧)运算对象绑定到隐式的this指针上

data1 += data2;  // 基于调用的表达式
data1.operator+=(data2);	// 对成员运算函数的等价调用

重载原则

  • 某些运算符不应该被重载,通常情况下,不应该重载逗号、取地址、逻辑与和逻辑或运算符。
  • 使用与内置类型一致的含义 ,我们可以将+ 定义成 - 的含义,但是不建议如此操作。
  • 赋值和复合赋值运算符, 赋值运算符的行为与复合版本的类似:赋值之后,左侧运算对象和右侧运算对象的值相等,并且运算符应该返回它左侧运算对象的一个引用。重载的赋值运算应该继承而非违背其内置版本的含义。

选择作为成员或者非成员

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

  • 赋值(=)、下标([])、调用(())和成员访问箭头(->)运算符必须是成员。
  • 复合赋值运算符一般来说应该是成员,但并非必须,这一点与赋值运算符略有不同。
  • 改变对象状态的运算符或者与给定类型密切相关的运算符,如递增、递减和解引用运算符,通常应该是成员。
  • 具有对称性的运算符可能转换任意一端的运算对象,例如算术、相等性、关系和位运算符等,因此它们通常应该是普通的非成员函数。

2. 输入和输出运算符

2.1 重载输出运算符 <<

通常情况下,输出运算符的第一个形参是一个非常量 ostream 对象的引用。之所以ostream是非常量是因为向流写入内容会改变其状态;而该形参是引用是因为我们无法直接复制一个ostream对象。第二个形参一般来说是一个常量的引用,该常量是我们想要打印的类类型。第二个形参是引用的原因是我们希望避免复制实参;而之所以该形参可以是常量是因为(通常情况下)打印对象不会改变对象的内容。为了与其他输出运算符保持一致,perator<<一般要返回它的ostream形参。

Sales_data的输出运算符

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的成员

假设输入输出运算符是某个类的成员,则它们也必须是istream或ostream的成员。然而,这两个类属于标准库,且我们无法给标准库中的类添加任何成员。

2.2 重载输入运算符>>

通常情况下,输入运算符的第一个形参是运算符将要读取的流的引用,第二个形参是将要读入到的(非常量)对象的引用。该运算符通常会返回某个给定流的引用。第二个形参之所以必须是个非常量是因为输入运算符本身的目的就是将数据读入到这个对象中。

istream &operator>>(istream &is, Sales_data &item)
{
	double price;//不需要初始化,因为我们将先读入数据到price,之后才使用它
	is >> item.bookNo>>item.units_sold>>price;
	if(is)//检查输入是否成功
		item.revenue=item.units_sold*price;
	else
		item=Sales_data();//输入失败:对象被赋予默认的状态
	return is;
}

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

3. 算术和关系运算符

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

3.1 相等运算符

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!(1hs==rhs);
}

关于相等运算符的设计准则

  • 如果一个类含有判断两个对象是否相等的操作,则它显然应该把函数定义成operator==而非一个普通的命名函数:因为用户肯定希望能使用 == 比较对象,所以提供了==意味着用户无须再费时费力地学习并记忆一个全新的函数名字。
  • 如果类定义了operator==,则该运算符应该能判断一组给定的对象中是否含有重复数据。
  • 通常情况下,相等运算符应该具有传递性,换句话说,如果a==b==c都为真,则a==c应该为真。
  • 如果类定义了operator==,则这个类也应该定义operator!=
  • 相等运算符和不相等运算符中的一个应该把工作委托给另外一个,这意味着其中一个运算符应该负责实际比较对象的工作,而另一个运算符则只是调用那个真正工作的运算符。

3.2 关系运算符

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

4. 赋值运算符

参考13章的拷贝赋值和移动赋值。

5. 下标运算符

表示容器的类通常可以通过元素在容器中的位置访问元素,这些类一般会定义下标运算符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;//指向数组首元素的指针
};

6. 递增和递减运算符

定义递增和递减运算符的类应该同时定义前置版本和后置版本。这些运算符通常应该被定义成类的成员。

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

class StrBlobPtr {
public:
    //递增和递减运算符
    StrBlobPtr& operator++();//前置运算符
    StrBlobPtr& operator--();
    //...
};
//前置版本:返回递增/递减对象的引用
StrBlobPtr& StrBlobPtr::operator++()
{
    //如果curr已经指向了容器的尾后位置,则无法递增它
    check(curr,"increment past end of StrBlobPtr");
    ++curr;    //将curr在当前状态下向前移动一个元素
    return *this;
}

StrBlobPtr& StrBlobPtr:: operator--() {
    //如果curr是0,则继续递减它将产生一个无效下标
    --curr;		 //将curr在当前状态下向后移动一个元素
    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;  		 //返回之前记录的状态
}

显式地调用后置运算符

StrBlobPtr p(a1);		//p指向a1中的vector
p.operator++(0);		//调用后置版本的operator++
p.operator++();			//调用前置版本的operator++

尽管传入的值通常会被运算符函数忽略,但却必不可少,因为编译器只有通过它才能知道应该使用后置版本。

7. 成员访问运算符

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

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*();
    }
    //...
}

8. 函数调用运算符

如果类重载了函数调用运算符(),则我们可以像使用函数一样使用该类的对象。因为这样的类同时也能存储状态,所以与普通函数相比它们更加灵活。如果类定义了调用运算符,则该类的对象称作函数对象(function object)。因为可以调用这种对象,所以我们说这些对象的“行为像函数一样”。

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

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

8.1lambda 是函数对象

编译器会将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 &s1, const string &s2) const
    { return s1.size()<s2.size(); }
};

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

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

第三个参数是新构建的 ShorterString 对象。

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

lambda表达式通过引用捕获变量时,编译器直接使用引用调用该变量,自身无须存储数据成员。

而使用置捕获变量时,需要将变量拷贝到lambda中,所以需要定义成员变量保存拷贝的值同时还要生成对应的构造函数

//获得第一个指向满足条件元素的迭代器,该元素满足size()is>=sz
auto wc = 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;//该数据成员对应通过值捕获的变量
};

8.2 标准库定义的函数对象

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

image-20230404101825375

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

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

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

8.3 可调用对象与 function

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

和其他对象一样,可调用的对象也有类型,然而,不同类型的可调用对象却可能共享同一种调用形式。调用形式志明了调用返回的类型以及传递给调用的实参类型

int(int, 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)

假如我们用这些可调用对象构建一个简单的计算器,我们可以定义一个函数表用于存储指向这些可调用对象的“指针”。

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

// 添加可调用对象到函数表
binops.insert({"+", add});		// 正确
binops.insert({"%", mod});		// 错误, mod 不是一个函数指针

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

标准库function类型

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

image-20230404104035160

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

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

// 将不同的可调用对象添加到map
map<string,function<int(int,int)>>binops={
    
    {"+",add},							//函数指针
    {"-",std::minus<int>()},			//标准库函数对象
    {"/",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函数对象

重载的函数与 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来指定我们希望使用的add版本
binops . insert ( { " + " , [ ] ( int a , int b ) { return add ( a , b ) ; } } ) ;

9. 重载、类型转换与运算符

在第7章中我们看到由一个实参调用的非显式构造函数定义了一种隐式的类型转换,这种构造函数将实参类型的对象转换成类类型。我们同样能定义对于类类型的类型转换,通过定义类型转换运算符可以做到这一点。转换构造函数类型转换运算符共同定义了类类型转换 ,这样的转换有时也被称作用户定义的类型转换

9.1 类型转换运算符

类型转换运算符是类的一种特殊成员函数,它负责将一个类类型的值转换成其他类型。类型转换函数的一般形式如下所示:

operator type() const ;

其中 type 表示某种类型。

一个类型转换函数必须是类的成员函数;它不能声明返回类型,形参列表也必须为空。类型转换函数通常应该是const。

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

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

class SmallInt {
public:
    SmallInt(int i=0):val(i)
    {
        if (i < 0 11 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,然后执行整数的加法

编译器一次只能执行一个用户定义的类型转换,但是隐式的用户定义类型转换可以置于一个标准(内置)类型转换之前或之后,并与其一起使用,实现两次的类型转换。

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

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

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

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

上面这段代码中,cin 中定义了向bool类型转换的运算符, 但是没有 << 运算符的定义,所以 cin 就会被转换为 bool 类型,bool 类型在经过一次内置的类型转换成整型,最终 cin << i 表达式的意义就是将bool值(1或0)左移42位。

显示的类型转换运算符

C++11 为了防止这样的异常情况发生,C++11新标准引入了显式的类型转换运算符,使用 explicit 关键字,这个我们在第7章中有提到。

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

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

隐式转换通常会出现意外的结果,对于可能隐式调用的成员方法(类型转换,转换构造函数)尽量加上 explicit

explicit 声明的方法存在一个例外,即如果表达式被用作条件,则编译器会将显式的类型转换自动应用于它。换句话说,当表达式出现在下列位置时,显式的类型转换将被隐式地执行:

  • ifwhiledo语句的条件部分
  • for语句头的条件表达式
  • 逻辑非运算符(!)、逻辑或运算符(||)、逻辑与运算符(&&)的运算对象
  • 条件运算符(? : )的条件表达式。
while(std::cin >> value)

std::cin >> value 返回的是 cin , 但是由于定义了 istream operator boolcin 被转换成了 bool。尽管被定义为explicit 显示调用,但它被作为条件表达式是可以进行隐式转换的。

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

标准库中的类型转换方法

#if __cplusplus >= 201103L
      explicit
#endif
      operator bool() const
      { return _M_ok; }
    };

9.2 避免二义性的类型转换

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

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

在下面的例子中,我们定义了两种将B转换成A的方法:一种使用B的类型转换运算符、另一种使用A的以B为参数的构造函数:

//最好不要在两个类之间构建相同的类型转换
struct B;
struct A {
    A()=default;
    A(const B&);//把一个B转换成A
    //...
    };
struct B {
    operatorA()const;//也是把一个B转换成A
    //...
};
A f(const A&);
Bb;
Aa=f(b);//二义性错误:含义是f(B::operator A())
		//还是f(A:A(const B&))?
// 显式调用
A a1 = f (b.operator A());//正确:使用B的类型转换运算符
Aa2=f(A(b));//正确:使用A的构造函数
  • 二义性与转换目标为内置类型的多重类型转换

  • 重载函数与构造函数

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

9.3 函数匹配与重载运算符

  • TODO