目录
通过操作符重载,能让用户定义的类类型表现的和内置类型一样,简化使用而且容易理解,但是操作符重载也有很多陷阱,本节的目的就是介绍如何使用以及怎么规避陷阱。
1.如何重载操作符
操作符重载是通过定义特殊的函数,函数名以operator开头,后面跟对应的操作符,比如operator+是定义+操作符。重构操作符有两种方式:
我们以一个Fruit类为例,假设我们定义了这样一个类,有两个数据成员,水果的名称以及重量。没有默认构造函数,但有合成复制构造函数
#include <string>
#include <iostream>
#include <sstream>
class Fruit {
public:
Fruit(const std::string &s, unsigned w) : name(s), weight(w) {}
std::string str() {
std::ostringstream os;
os << "name:" << name << ",weight:" << weight ;
return os.str();
}
private:
std::string name;
unsigned weight;
};
1.非成员函数重载
我们可以使用非成员函数重载操作符+运算,函数标识符operator+(Fruit &, Fruit &),为了能访问Fruit的数据成员,将该函数设为Fruit的友元。
Fruit operator+(Fruit& f1, Fruit& f2) {
Fruit f3(f1);
f3.weight += f2.weight;
return f3;
}
class Fruit {
friend Fruit operator+(Fruit& f1, Fruit& f2);
public:
... // 节省篇幅,后续代码前面给的类定义一致
};
int main() {
Fruit f1("Apple", 1);
Fruit f2("Orange", 2);
Fruit f3 = f1 + f2;
std::cout << "f1:" << f1.str() << std::endl; // print f1:name:Apple,weight:1
std::cout << "f2:" << f2.str() << std::endl; // print f2:name:Orange,weight:2
std::cout << "f3:" << f3.str() << std::endl; // print f3:name:Apple,weight:3
}
2.成员函数重载
使用成员函数重载时,不需要显示的提供左操作数,也不许再定义友元。对相同类型的操作符重载不能定义多次,因为编译器不能确定调用哪个
Fruit Fruit::operator+(Fruit& f2) {
Fruit f3(*this);
f3.weight += f2.weight;
return f3;
}
2.支持重载的操作符
支持重载的运算符,我把它分为5大类,常见的算术运算、位运算、逻辑运算、赋值,还用C++里特有的调用,包括下标、调用、箭头操作符、new和delete。
3.重载限制
1.必须有一个类类型参数
比如重新定义两个int的操作符+是不允许的,定义非成员内置函数时,必须有一个参数是类类型的。
int operator+(int i1, int i2)
对于成员函数,操作符+的成员函数重载,第一个参数默认为this指向的对象,所以必定存在一个类类型参数。
2.没有短路的特性
内置的逻辑运算,&&和||都有短路的特性,按顺序从左到右计算操作数的值,一旦能确定整个表达式的值,就不会再计算剩余部分。重载操作符后,将不保证运算符的操作数的求值顺序,逻辑运算符的短路特性。
3.优先级和结合性
重载操作符保留原始操作符的参数个数、优先级和结合性。比如下面的表达式,总是先计算b + c,然后再拿计算结果和a做==操作。
a == b + c
4.不支持默认实参
除了调用操作符operator()以外,所有的操作符重载,不支持默认实参。
4.重载原则
1.不重定义有内置含义的操作符
逗号、赋值、取地址、逻辑与、逻辑或这类函数有默认含义,程序员对这些默认含义都了然于胸,贸然的修改定义会严重的违反使用者直觉
2.谨慎使用操作符重载
多数操作符重载都是无意义的,设计操作符重载之前,先定义类的接口,接口确认之后再来判断接口中哪些操作定义为操作符重载能简化使用,又不违反直觉,只有逻辑上复合操作符行为的操作,才应该考虑重载。
3.保持定义的一致性
如果定义了算法运算或者位运算的操作符,同时定义对应的复合赋值操作数是比较明智的做法,比如你定义了+操作符,同时也应该定义+=操作符,而且+=操作符的行为,应该和先对左右操作数用+操作符,然后将结果用=操作符,赋值给左操作数一样。
关联容器的键,需要支持<操作符。很多的泛型算法,要求类型支持==操作,比如泛型算法sort要求元素支持<操作符,find要求元素支持==操作符。如果类重新定义了==操作符,那么类也应该重新定义!=操作符。如果类定义<操作符,那么也要定义 <=、>、>=这些关联的操作符,同时如果两个操作符 a < b 是false,b < a 也是false,应该让 a == b。
4.选择成员或非成员实现
- 赋值=、调用()、下标[]、箭头->等操作符,必须选择成员实现,否则会提示编译错误
- 复合赋值,推荐选择成员实现,但定义为非成员实现并不会导致编译问题
- 改变对象状态的操作符,如自增、自减等等,推荐选择成员实现
- 和给定类型紧密连续的,如解引用,推荐选择成员实现
- 对称的操作符,如算术运算、位运算、关系运算、相等操作符,推荐选择非成员实现
5.使用案例
1.输入输出
输入输出操作符必须定义位非成员函数,因为左操作数是iostream,而我们无法修改iostream的类定义。
class Person {
friend std::ostream& operator<<(std::ostream& s, Person& p);
friend std::istream& operator>>(std::istream& s, Person& p);
public:
Person() :name("-"), age(0) {}
Person(const std::string n, unsigned a) :name(n), age(a) {}
private:
std::string name;
unsigned age;
};
std::ostream& operator<<(std::ostream& s, Person& p) {
s << p.name << " " << p.age;
if (!s) {
p = Person();
}
return s;
}
std::istream& operator>>(std::istream& s, Person& p) {
s >> p.name >> p.age;
return s;
}
int main() {
Person p1("randy", 18);
std::cout << "p1:" << p1 << std::endl;
std::istringstream is("henry 8");
Person p2;
is >> p2;
std::cout << "p2:" << p2 << std::endl;
}
定义了输出操作符后,确实大大的简化了对象状态的输出。案例中对iostream状态的检查还不够严谨,尤其是输入错误时,比如读取的数据格式不是我们预期的、读取到EOF等等。这类错误可以通过设置iostream的状态,failbit、badbit、eofbit等等,输入输出结束后,有应用程序自行检查iostream的状态。
2. 加法操作
算术操作符术语对称操作符,一般而言,选择非成员函数实现。比如+操作符,一般需要同时实现+=操作符。按照之前学的重载原则,复合赋值操作应该定义为成员函数。为了让+=和=的赋值逻辑一致,我们选择在=操作内部调用+=操作符。先提供+=操作符的定义,这里省略其他代码,只流+=操作符的定义
class Person {
...
Person& operator+=(Person& p2) {
name += p2.name;
age += p2.age;
return *this;
}
...
};
然后我们来定义+操作符,+操作不修改左右操作数,新生成一个结果对象,注意这里的返回值并不是引用
Person operator+(Person& p1, Person& p2) {
Person p(p1);
p += p2;
return p;
}
3.等于和不等于
==操作符的含义是两个对象等价,有相同的数据,如果定义了operator==,那也应该同时定义operator!=,并且==操作符和!=操作的定义应该是一致的,其中一个函数实现逻辑,另一个只负责调用。==拿比较所有成员的结果
bool operator==(Person& p1, Person& p2) {
return p1.name == p2.name && p1.age == p2.age;
}
bool operator!=(Person& p1, Person& p2) {
return !(p1 == p2);
}
4.赋值操作符
在1. 复制控制的章节里提到过,如果没有显示的定义赋值操作符,编译器会自动合成一个,默认是依次复制每个数据成员,指针类型采用的浅拷贝。可以为一个类定义重载多个赋值操作符,只要函数的形参不同。
根据重载操作符的原则,赋值操作符使用成员函数实现。比如我们定义两个赋值函数
Person& Person::operator=(Person& p1) {
name = p1.name;
age = p1.age;
return *this;
}
Person& Person::operator=(std::string& n1) {
name = n1;
return *this;
}
通过如下程序进行测试,赋值操作符定义必须返回*this的引用,如果是引用类型时,不能将右值作为入参
int main() {
Person p0;
Person p1("randy", 18);
std::cout << "p0:" << p0 << std::endl << "p1:" << p1 << std::endl;
//输出 p0:- 0 p1:randy 18
p0 = p1;
std::cout << "p0:" << p0 << std::endl << "p1:" << p1 << std::endl;
//输出 p0:randy 18 p1:randy 18
std::string s = "henry";
p0 = s;
std::cout << "p0:" << p0 << std::endl << "p1:" << p1 << std::endl;
//输出 p0:henry 18 p1:randy 18
}
5.下标操作符
下标操作符通过operator[]定义,必须用成员函数实现,难度在于下标操作符可以作为左操作数,也可以作为右操作数。下标操作数一般会定义两个版本,一个返回非const引用,一个返回const引用
char& Person::operator[](std::string::size_type idx) {
if (idx < 0 || idx >= name.size()) {
throw std::out_of_range("out of range");
}
else {
return name[idx];
}
}
const char& Person::operator[](std::string::size_type idx) const {
return operator[](idx);
}
6.解引用和箭头操作符
解引用操作返回的对象的引用,可以是const或非const的,非const可以作为左操作数。箭头操作符返回的指针,正常情况箭头操作应该有两个操作数,因为C++没有类型来表示成员函数,所以箭头操作只给一个操作者,箭头的右操作数有编译器使用,自动调用箭头操作符定义函数返回的指针对应的对象的成员函数。
class Person {
friend std::ostream& operator<<(std::ostream& s, Person& p);
public:
Person() : name(0), age(0) {}
Person(std::string* n, unsigned a) :name(n), age(a) {}
std::string& operator*() {
return *name;
}
std::string* operator->() {
return name;
}
private:
std::string* name;
unsigned age;
};
std::string* s = new std::string("randy");
Person p1(s, 18);
std::cout << (*p1)[0] << (*p1)[1] << std::endl; // *p返回的是数据成员name的引用
std::string::size_type size = p1->size();
// 调用name指针指向对象的成员函数
std::cout << "string size:" << size << std::endl;
p1->size()实际调用的是p1.operator->()->size(),因为p1.operator()返回的是数据成员name的指针。->操作符返回的必须是类类型对象的指针、或者重定义->操作的类类型对象。
7.自增自减
自增/自减操作符会修改对象状态,一般都定义为成员函数,自增/自减操作返回当前对象的引用。自增/自减操作只有一个操作数,无法区分前置/后置操作,通过额外的添加一个无用的int形参来区分,没有形参表示前置操作,有形参表示后置操作。我们来看一个简单的例子,为Person定义自增和自减操作,前置自增对age+1,后置指针对age+2
class Person {
friend std::ostream& operator<<(std::ostream& s, Person& p);
public:
Person() : name(0), age(0) {}
Person(std::string* n, unsigned a) :name(n), age(a) {}
Person& operator++() {
age++;
return *this;
}
Person operator++(int i) {
Person copy = Person(*this);
age += 2;
return copy;
}
private:
std::string* name;
unsigned age;
};
这里需要比较注意的是,前置自增返回的是Person对象的引用,因为是this指向的对象,在函数外部对象依然有效。后置自增,因为要保存修改之前的状态,所以新建了局部对象,因为局部对象在函数外部无效了,不能再返回指针或引用。
std::string* s = new std::string("randy");
Person p1(s, 18);
std::cout << (p1++) << std::endl; // 返回18,实际值已经是20
std::cout << p1 << std::endl; // 20
std::cout << (++p1) << std::endl; // 21
std::cout << p1 << std::endl; // 21
8.调用操作符和函数对象
重载调用操作符的类的对象,可以和函数一样被调用,也被称为函数对象。
struct OP {
public:
int operator() (int i) {
std::cout << "op_call:" << i << std::endl;
return i + 100;
}
};
OP fn = OP();
std::cout << "OP: " << fn(10) << std::endl;
函数对象属于std::function模板定义的类型,定义函数对象的形参时,我们可以这么做
std::function<int(int)> fp = OP();
std::cout << "FP:" << fp(99) << std::endl;