一、基本概念
使用与内置类型一致的含义
根据一个类提供的操作去考虑把哪些类操作设计成普通函数或者重载运算符,那些在逻辑上与运算符有关的操作就适合被定义成重载的运算符。
- 类执行IO操作,则定义移位运算符使其与内置类型的IO保持一致
- 类执行相等操作,适合定义
operator==
、operator==
- 类执行单序比较操作,适合定于
<
等比较操作符 - 重载运算符的返回类型通常情况下应该与其内置版本的返回类型兼容
选择作为成员或者非成员
=、[]、()以及->
运算符必须定义为成员- 复合赋值运算符一般俩说应该是成员
- 改变对象状态的运算符或者与给定类型密切相关的运算符,比如递增、递减和解引用运算符,一般都定义为成员
- 具有对称性的运算符可能转换任意一端的运算对象,例如算术、相等性、关系和位运算符,一般为普通的非成员函数
当把运算符定义为成员函数时,它的左侧运算对象必须时运算符所属类的一个对象。
string s = "world";
string t = s + "!";
string u = "hi" + s; // 错误,hi是一个const char* 类型,使用"hi" + s相当于"hi.operator+(s)",很明显作为内置类型根本没有成员函数
重载运算符与内置运算符的区别
- 不同点:
- 重载操作符必须具有至少一个class或枚举类型的操作数
- 重载操作符不保证操作数的求值顺序,例如对于&&和||的重载版本不再具有短路求值特性,两个操作数都要进行求值,而且不规定操作数的求值顺序
- 相同点:
- 对于优先级和结合性及操作数的数目都不变
二、输入和输出运算符
2.1重载输出运算符<<
通常输出运算符的第一个形参是一个非容量ostream
对象的引用,因为向流写入内容会该表其状态,引用的方式是因为无法直接复制一个ostream
对象。
第二个形参一般是对一个常量的引用,该常量是想要打印的类类型,采用引用方式是为了避免复制实参。返回的输出为ostream
形参。
ostream &operator<<(ostream &os, const Sales_data &item) {
os << item.isbn() << " " << item.units_sold << " "
<< item.revenue << " " << item.avg_price();
return os;
}
在重载输出运算符时,应该减少格式化的操作,让用户自行选择输入的格式。
输入输出运算符必须时非成员函数
如果设计为类的成员函数,左侧运算对象就会是类的一个对象:(所以会带来什么问题?,代码下面的一段话)
Sales_data data;
data << cout;
假设输入输出运算符是某个类的成员,则它们也必须是istream或者ostream
的成员,但是这两个类属于标准库,无法为其中的类添加任何成员。==所以只要为类定义IO运算符,就必须将其定义为非成员函数。==由于IO运算符有时需要读取类中私有的数据成员,所以还要被声明友元。
2.2重载输入运算符>>
第一个形参是运算符将要读取的流的引用,第二个形参是将要读入到(非常量)对象的引用。返回输出为某个给定流的引用。
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;
}
三、算术和关系运算符
算术和关系运算符定义成非成员函数以允许对左侧或右侧的运算对象进行转换。如果定义了算术运算符,一般也会定义一个对应的复合赋值运算符,最有效的方式是使用复合赋值来定义算术运算符。
加法运算符
Sales_data operator+(const Sales_data &lhs, const Sales_data &rhs) {
Sales_data sum = lhs;
sum += rhs;
return sum;
}
相等运算符
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);
}
关系运算符
定义了相等运算符的类也常常(不总是)包含关系运算符。一般来说,关系运算符要满足下列两点:
- 定义顺序关系,令其与关联容器中对关键字的要求一致
- 如果类同时含有==运算符,则定义一种关系令其保持一致(不太清楚)。
但本书的Sales_data
类就是一个不需要关系运算符的例子。一个Sales_data
包含3个成员:a,b,c
(简写)。如果定义一个<
符号去判断两个a
相同的对象,但b,c
不同的对象,那么结果时不相等,但是这两个对象却都不比对方小(又不是相等的)。当然我们可以按照规则决定当a
相同,就比较b
之类的操作,但有时我们的需要时一直在变化的,所以就不存在一种适合任何情况下的关系运算符函数来满足要求,因此对于Sales_data
类不定义关系运算符会更好。
四、赋值运算符
除了上一章谈到的拷贝赋值和移动赋值运算符,标准库vector
还定义了第三种赋值运算符,采用花括号形式。
vector<string> v;
v = {"a", "an", "the"};
class StrVec {
public:
StrVec &operator=(std::initializer_list<std::string>);
}
StrVec &StrVec::operator=(initializer_list<string> il) {
auto dta = alloc_n_copy(il.begin(), il,end());
free();
elements = data.first;
first_free = cap = data.second;
return *this;
}
和其它赋值运算符一样,在赋值前需要释放当前的内存空间然后创建一片新空间,但是花括号列表形式赋值不要检查对象是否是字符值,因为它能够保证il
与this
所指的不是一个对象。赋值运算符都必须定义为成员函数。
复合赋值运算符
不必须是类的成员,为了与其它赋值运算保持一致一般定义在类的内部
Sales_data &Sale_data::operator+=(const Sales_data &rhs) {
units_sold += rhs.units_sold;
revenue += rhs.revenue;
return *this;
}
五、下标运算符
下标运算符必须是成员函数。下标运算符通常以所访问元素的引用作为返回值,这样的好处是下标可以出现在赋值运算符的任意一端。通常最好同时定义下标运算符的常量版本和非常量版本,当作用一个常量对象时,下标运算符返回常量引用以确保不会给返回的对象赋值。
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; //指向数组首元素的指针
}
六、递增和递减运算符
这两个运算符会改变操作对象的状态,所以建议设定为成员函数,定义时还需要考虑前置版本和后置版本
class StrBlobPtr {
public:
StrBlobPtr &operator++();
StrBlobPtr &operator--();
}
// 前置
StrBlobPtr &StrBlobPtr::operator++() {
check(curr, "超过容器尾部"); // 检查元素位置
++curr;
return *this;
}
StrBlobPtr &StrBlobPtr::operator--() {
--curr;
check(curr,"无效下标");
return *this;
}
区分前置和后置运算符
普通的函数重载不能区分这两种运算形式,因为它们不管时名字还是参数的类型数量都是一致的,为了解决这个问题,后置版本接受一个额外的(不使用)int
类型的形参。
class StrBlodPtr {
public:
StrBlobPtr &operator++(int);
StrBlobPtr &operator--(int);
}
// 后置
StrBlodPtr StrBlodPtr::operator++(int) {
StrBlodPtr ret = *this; // 记录当前值
++*this;
return ret; // 返回之前记录的值
}
StrBlodPtr StrBlodPtr::operator--(int) {
StrBlodPtr ret = *this; // 记录当前值
--*this;
return ret; // 返回之前记录的值
}
七、成员访问运算符
在迭代器及智能指针类中常用解引用运算符和箭头运算符
class StrBlobPtr {
public:
std::string &operator*() const { auto p = check(curr, "无效下标"); return (*p)[curr]; }
std::string *operator->() const { return &this->operator*(); } // 箭头函数实际上还是调用的解引用函数来获取元素地址
}
StrBlob a1 = {"hi", "bye", "now"};
StrBlobPtr p(a1); // p指向a1中的vector
*p = "okay"; // 给a1的首元素赋值
cout << p->size() << endl; //打印4,a1首元素的大小
cout << (*p).size() << endl; //等价于p->size()
对箭头运算符返回值的限定
使用operator*
可以完成任何指定的操作,但是箭头运算符就不行,它永远不能丢掉成员访问这个 最基本的含义。对于形如point->mem
的表达式来说,point
必须指向类对象的指针或者时一个重载了operator->
的类的对象。根据point
类型的不同,point->mem
分别等价于:
(*point).mem; // point是一个内置的指针类型
point.operator()->mem; // point是类的一个对象
point->mem执行过程
- 如果
point
是指针,则应用内置的箭头运算符,表达式等价于(*point).mem
,首先解引用该指针,然后从所得的对象中获取指定的成员,如果point
指的对象没有mem
成员,出现就会发生错误。 - 如果是定义了
operator->()
的类的一个对象,则使用point.operator->()
的结果来获取mem
,如果结果是一个指针,则执行1中的步骤;如果该结果本身含有重载的operator->()
,则重复调用当前步骤。
八、函数调用运算符
如果类重载了函数调用运算符,则可以像使用函数一样使用该类的对象,并且还能存储状态,所以于普通函数相比它们更加灵活。
struct absInt {
int operator()(int val) const {
return val < 0 ? -val : val;
}
}
上面的类只定义了一种操作:函数调用运算符,使用的方式就是令一个absInt
对象作用于一个实参列表
int i = -42;
absInt absObj;
int ui = absObj(i); // 即使absobj只是一个对象而非函数,也能“调用”该对象,实际上是在运行重载的调用运算符。
函数调用符必须是成员函数
含有状态的函数对象类
函数对象类除了operator()
之外也可以包含其他成员。一般会包含一些数据成员,用于定制调用运算符中的操作:比如下面代码定义了一个用于输出的类,默认情况下输出会按照空格进行隔开,当然用户也能自定义想要的方式。
class PrintString {
public:
PrintString(ostream &o = cout, char c = ' ') : os(o), seq(c) { }
void operator() (const string &s) const { os << s << seq; }
private:
ostream &os; // 写入目的流
char seq; // 用于将不同输出隔开的字符
}
函数对象常常作为泛型算法的实参,比如上面的代码,或者说标准库的for_each
算法for_each(vs.begin(), vs.end(), PrintString(cerr, '\n'));
。
8.1lamba函数对象
实际上在定义了一个lambda
后,编译器会将表达式翻译成一个未命名类的未命名对象
sort(vec.begin(), vec.end(), [] (const string &a, const string &b) { return a.size() < b.size();});
// 相当于下列代码
class sort {
public:
bool operator() { const string &s1, const string &s2} const { return s1.size() < s2.size(); }
}
表示lambda及相应捕获行为的类
当一个lambda表达式通过引用捕获变量时,将由程序负责确保lambda执行时引用所引的对象存在,因此编译器可以直接使用该引用而无须在lambda产生的类中将其存储为数据成员,相反,通过值捕获的变量被拷贝到lambda
中。这种lambda
产生的类必须为每个值捕获的变量建立对应的数据成员,同时创建构造函数。
auto wc = find_if(vec.begin(), vec.end(), [sz](const string &a) {return a.size() >= sz}); // sz通过值捕获
// 相当于下列代码
class SizeComp {
SizeComp(size_t n) : sz(n) { } // 形参对应捕获的变量
bool operator() (const string &s) const { return s.size() >= sz;}
private:
size_t sz; // 对应通过值捕获的变量
}
可以看到,与上面的sort
比较,SizeComp
类包含了一个数据成员,并且注意两个类都不含有默认构造函数,lambda表达式产生的类不含默认构造函数、赋值运算符及默认析构函数
8.2标准库定义的函数对象
简单的说就是如8.1一样对于某些标准库的函数我们可以自定义其中的一些实参改变该参数调用的默认对象。标准库规定其函数对象对于指针同样适用,比较两个无关指针将产生未定义的行为,如果想要是要这样的操作,比如根据指针的内存地址来sort
,直接做肯定是不行的。可以使用一个标准库函数对象来实现该目的:
vector<string *> nameTable;
sort(nameTable.begin(), nameTable.end(), [](string *a, string *b { return a < b; }); // 错误,nameTable里面的指针彼此之间并没有关系
sort(nameTable.begin(), nameTable.end(), less<string *>()); // 采用标准库定义的函数对象可以满足要求
8.3可调用对象与function
两个不同类型的可调用对象可以共享一种调用形式(调用形式指明了调用返回的类型以及传递给调用的实参类型,一种调用形式对应一个函数类型)。
对于下列三种不同的对象:
int add(int i, int j) { return i + j; }
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, int(*)(int,int)> binops
// 使用
binops.insert({"+",add}); //注意传入的是一个pair
但是上面的函数表不能把mod
或者divide
存入binops
,因为mod
不是一个函数指针,它是一个lambda
表达式,而每个lambda
有它自己的类类型,该类型与存储在binops
中的值的类型不匹配。
标准库function类型
对于上面存在的问题,可以使用新的标准库function
解决。它是一个模板,所以我们创建一个具体的类型时必须提供额外的信息,针对上面问题,使用function<int(int, int)>
,我们就可以表示上面任意的三个类型
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
// 定义函数表
map<string, function<int(int, int)>> binops;
binops = {
{"+", add},
{"-", std::minus<int>()},
{"/", divide()},
{"*", [](int i, int j) { return i * j; }}, // 未命名的lambda
{"%", mod} }; // 命名了的lambda对象
}
// 使用
binops["+"](10,5) ;
重载的函数与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}); // 歧义
解决上面二义性问题可以存储函数指针
int (*fp)(int, int) = add;
binops.insert({"+", fp});
或者采用lambda
binops.insert( {"+", [](int a, int b) { return add(a,b); }});
九、重载、类型转换与运算符
9.1类型转换运算符
类型转换运算符时类的一种特殊成员函数,负责将一个类类型的值转换成其他类型,一般形式:operator type() const;
类型转换运算符可以面向任意类型(除了void之外),只要该类型能够作为函数的返回类型,因此不允许转换成数组或者函数类型,但是允许转换成指针或者引用类型。类型转换运算符既没有显式的返回类型,也没有形参,必须定义成类的成员函数。
总结:
- 类型转换函数必须是类的成员函数
- 不能声明返回类型,形参列表必须为空
- 通常定义为
const
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; }
operator int(int = 0) const; // 错误,参数列表不为空
int operator int() const; // 错误,指定了返回类型
private:
std::size_t val;
}
上面代码既定义了向类类型的转换,也定义了从类类型向其他类型的转换。
SmallInt si;
si = 4; // 4隐式转换成Smallint,然后调用SmallInt::operator=
si + 3; // si隐式转换成int,然后执行整数的加法
编译器一次只能执行一个用户定义的类型转换,但是隐式的用户定义类型转换可以置于一个标准(内置)类型转换之前或之后,并与其一起使用。平时少用类型转换函数
类型转换运算符可能产生意外结果
实际上类很少提供类型转换运算符,一般向bool
的类型转换还是比较普遍。早期C++
标准有一个问题,如果类向定义一个向bool
的类型转换,作为一种算术类型,蕾蕾的对象转换成bool
后就能被用在任何需要算术类型的上下文中。在某些时候就可能带来问题:
int i = 42;
cin << i;
上面代码试图将输出运算符作用于输入流,但是istream
本身没有定义<<
,所以本来代码应该报错,但是istream
的bool
类型转换运算符将cin
转换成bool
,而这个bool
值接着会被提升成int
并用作内置的左移运算符的左侧运算符对象,结果肯定表示预期的。
显示的类型转换运算符
class SmallInt{
public:
// 编译器不会自动执行这一类型转换
explicit operator int() const { return val; }
...
}
SmallInt si = 3; // 正确,SmallInt的构造函数不是显式的
si + 3; // 错误,此处需要隐式类型转换
static_cast<int>(si) + 3; // 正确,显式转换
9.2 避免有二义性的类型转换
如果类中包含一个或多个类型转换,则必须确保在类类型和目标类型之间只存在唯一一种转换方式。
实参匹配和相同的类型转换
struct B;
struct A {
A() = default;
A(const B&); // 把一个B转换成A
...
};
strcut 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&))
由于上面代码同时存在两种由B获得A的方法,所以编译器无法判断应该允许哪个类型转换。当然,显式的调用也可以解决该问题。
二义性与转换目标为内置类型的多重类型转换
strcut A {
A(int i = 0);
A(double);
operator int() const;
operator double() const;
}
void f2(long double);
A a;
f2(a); // 二义性
上面的二义性问题其实就是转换级别的问题,对于同级的转换肯定不行,如果像下面代码这样使用可以成功允许,因为short
提升成int
的操作优于short
转换成double
的操作。
short s = 42;
A a3(s);
重载函数与转换构造函数
对于若干定义的重载函数,如果里面使用到的类对象定义相同类型的构造函数,就会造成二义性错误,同样,显式使用可以消除问题。
struct C {
C(int);
}
struct D {
D(int);
}
void manip(const C&);
void manip(const D&);
manip(10);
假如类对象定义的构造函数参数类型不同(上面例子都是int),那么它们也视为同级别的匹配,也会造成二义性错误。
9.3函数匹配与重载运算符
重载的运算符也是重载的函数,通用的函数匹配规则(第六章)同样适用于判断在给定的表达式中到底应该使用内置运算符还是重载的运算符。==与普通函数调用不同,不能通过调用的形式来区分当前调用的是成员函数非成员函数。==使用重载运算符作用于类类型的运算对象时,候选函数中包含该运算符的普通非成员版本和内置版本。调用命名函数时,具有该名字的成员函数和非成员函数不会彼此重载,因为调用命名函数的语法形式对于成员函数和非成员函数来说是不相同的。通过类类型的对象(或者该对象的指针及引用)进行函数调用时,只考虑该类的成员函数。
class SmallInt {
friend
SmallInt operator+(const SmallInt&, const SmallInt&);
public:
SmallInt(int = 0);
operator int() const { return val };
private:
std::size_t val;
};
SmallInt s1, s2;
SmallInt s3 = s1 + s2; // 使用重载的operator +
int i = s3 + 0; // 二义性 可以把0转换成SmallInt,反之也可以