C++ Primer
操作重载与类型转换
当运算符作用于类类型的运算对象时,通过运算符重载重新定义该运算符的含义
基本概念
重载的运算符是具有特殊名字的函数,由关键字operator 和其后要定义的运算符号共同组成,包括返回类型,参数列表以及函数体,重载的运算符必须是类的成员或者至少有一个类类型的参数
- 重载运算符的参数数量与该运算符作用的运算对象数量一样多,对于二元运算符来说,左侧运算对象传递给第一个参数,右侧运算对象传递给第二个参数,除了重载的函数调用运算符之外,其他重载运算符不能含有默认实参,如果一个重载运算符是成员函数,则第一个运算对象绑定到隐式的this
- 不能对内置类型重载运算符,且不能发明新的运算符号。
- 通常情况下,我们将运算符作用于实参从而调用重载的运算符函数,然而其实可以用普通的函数调用方式调用运算符函数,
- 某些运算符与求值顺序有关,这些规则无法保留,所以一般不进行重载,否则用户会不习惯(求值顺序失效了)
- 应当使重载运算符和内置类型的对应运算符含义一致:(1) 如果类执行IO操作,则定义移位运算符 (2) 检查相等性 (3) 单序比较操作< 和> (4) 返回类型,逻辑运算符和关系运算符应当返回bool 算术运算符应当返回一个类类型的值,赋值运算符和复合赋值运算符返回左侧对象的一个引用。
- 赋值运算符的行为:赋值后左侧运算对象和右侧运算对象的值相等,且返回左侧对象的一个引用,重载的赋值运算应当继承其内置版本的含义,
- 当我们定义重载的运算符时,必须决定是否声明为类的成员函数:
- 赋值 下标 调用和 ->必须作为成员函数,
- 复合赋值运算符一般是成员,
- 改变对象状态或者与给定类型密切相关的运算符应当是成员
- 具有对称性的运算符如算术 相等 关系和位运算应当是非成员函数
输入输出运算符
重载输出运算符
通常情况下第一个形参是非常量对象ostream
的引用,之所以是非常量是因为向流写入对象会改变其状态,第二个参数是常量的引用,返回ostream对象
1.输出运算不要考虑格式化操作,尤其不会打印换行符,应当主要负责打印对象的内容。
2. 与iostream兼容的输入输出运算符必须是普通的非成员函数,而不能是类的成员函数,否则左侧的运算对象是我们类的一个对象data<<cout
假设输入输出运算符是某个类的成员,则它们必须也是iostream里类的成员,但是标准库不允许这么做,因此如果想为自定义类重载IO运算符就要定义成非成员函数,但是为了读取私有数据成员,常声明为友元
重载输入运算符
输入运算符的第一个形参是将要读取的流的引用,第二个形参是将要读取到的对象的引用,返回流的引用
- 输入运算符负担从流中读取数据到对象的工作,需要注意的是,应当处理输入可能失败的状态:
istream& operator>>(istream& is, const 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;
}
相等运算符
C++中的相等运算符应当比较每一个数据成员,当对应的成员都相等时才认为两个对象相等,所以我们的相等运算符不但应当比较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!=
并且可以利用已经重载的运算符来实现另一个运算符(如上例)
关系运算符
-
关系运算符应当定义顺序关系,令其与关联容器中对关键字的要求一致 , 如果类同时含有==运算符的话,应当定义关系与其保持一致,如果两个对象是
!=
的,那么一个对象应当<另外一个 -
需要指出的是,Sales_data是不存在逻辑可靠的<定义的,首先,我们不能只比较ISBN,如果ISBN相同但revenue 和 units_sold是不相等的,但一个对象units_sold大,一个revenue大,所以一个对象并不比另一个小(任意对象不比另一个小,按道理讲这两个对象是相等的),但对象其实又不是相等的,所以逻辑会出现问题
赋值运算符
将类的一个对象赋值给另一个对象,类也可以定义其他赋值运算符来使用别的类型作为右侧运算对象,必须定义为成员函数
class StrVec
{
public:
StrVec& operator=(std::initializer_list<std::string> il)
{
auto data=alloc_n_copy(il.begin(),il.end());
free()
elements=data.first;
first_free=cap=data.end;
return *this;
}
}
就可以以v={"a","b","c"};
的方式来使用这个赋值运算符了,该运算符返回了左侧对象的引用
- 复合赋值运算符通常是成员,也返回其左侧运算对象的引用
Sales_data& Sales_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];
}
}
递增和递减运算符
迭代器类中常实现了递增递减运算符,使得迭代器可以在序列中前后移动,因为该运算符改变了操作对象的状态,所以建议将其设定为成员函数
- 定义前置和后置的的递增递减运算符,应当返回递增或递减后对象的引用
class StrBlobPtr
{
public:
StrBlobPtr& operator++();
StrBlobPtr& operator--();
StrBlobPtr& operator++(int);//后置
StrBlobPtr& operator--(int);//后置
}
StrBlobPtr& StrBlobPtr::operator++()
{
check(curr,"increment past end of StrBlobPtr");//如果已经指向了尾后位置,则无法递增它
++curr;
return *this;
}
StrBlobPtr& StrBlobPtr::operator--()
{ --curr;
check(curr,"decrement past begin of StrBlobPtr");
return *this;
}
先检查对象是否有效,接着检查给定值的索引是否有效,若未抛出异常,则返回对象的引用
- 前置后置运算符使用同一个符号,意味着其重载版本所用的名字是相同的,并且运算对象的数量和类型也相同,为了解决这个问题,后置版本接受一个额外的int类型的形参,这个形参的唯一作用就是区分前置版本和后置版本的函数,而不是参加运算
StrBlobPtr StrBlobPtr::operator++(int)
{
StrBlobPtr ret = *this;
++*this;//前置运算符帮检查
return ret;
}
StrBlobPtr StrBlobPtr::operator--(int)
{
StrBlobPtr ret = *this;
--*this;//前置运算符帮检查
return ret;
}
我们可以显式地调用一个重载的运算符,但如果想调用后置版本,需要为它的整形参数传递一个值p.operator++(0)
传入的值尽管被编译器忽略,但是能够告知编译器可以使用后置版本。后置版本应当返回对象的原值,返回的形式是一个值而非引用。
成员访问运算符
解引用运算符和箭头运算符 常用在迭代器类和智能指针类。
class StrBlobPtr
{
public:
std::String& operator*() const;
{
auto p = check(curr, "dereference past end");
return (*p)[curr];
}
std::string* operator->() const
{
return &this->operator*();//将实际工作委托给解引用运算符
}
}
首先检查curr
是否仍在作用范围内,如果是则返回curr所指元素的一个引用,箭头运算符不执行任何自己的操作,而是调用解引用运算符并返回解引结果元素的地址,这两个运算符不会改变对象的状态所以被定义成了const
成员
- 我们重载箭头运算符时,可以改变箭头从哪个对象中获得成员,但不能改变箭头获取成员这一行为,例如
point->mem
根据point
的类型不同,分别等价于(*point).mem;
或者point.operator->()mem;
函数调用运算符
类重载了函数调用运算符后,我们可以像使用函数一样使用类的对象,
struct absInt
{
int operator()(int val) const
{
return val<0?-val:val;
}
}
该函数调用运算符接受一个int类型的实参,返回实参的绝对值,调用方法:
int i=-42;
absInt absObj;
int ui = absObj(i);//ui=42
如果类定义了调用运算符,则该类的对象被称为函数对象
- 函数对象类也含有其他的数据成员,这些成员通常被用于定制调用运算符中的操作,如一个打印string实参内容的类
class StringPrint
{
public:
StringPrint(ostream& o=cout, char c=' '):os(o),sep(c){}
void opearator()(const std::string& s ){os<<s<<sep;}
private:
ostream &os;
char sep;
}
类的构造函数接受一个输出流的引用和用于分割的字符,表明我们在构造对象的时候可以使用默认值也可以使用自己定义的值,函数对象常作为泛型算法的实参,for_each(v.begin(),v.end(),StringPrint(cerr,'\n'));
lambda是函数对象
编写一个lambda后,编译器将表达式翻译成一个未命名类的未命名对象,这个类中有一个重载的函数调用运算符。
stable_sort(vec.begin(),vec.end(),[](const string &a, const string &b){return a.size()<b.size();})
转换为下面类对象:
class shorter
{
public:
bool operator()(const string &a, const string &b) const
{
return a.size()<b.size();
}
}
只有一个函数调用运算符成员,接受两个string并比较长度stable_sort(vec.begin(),vec.end(),shorter());
由于默认情况下lambda不能改变它捕获的变量,因此在默认情况下lambda生成的类当中的函数调用运算符是const成员函数
- 当lambda表达式通过引用捕获变量时,程序确保lambda执行引用时所引用的对象确实存在,编译器可以直接使用该引用而无需再lambda产生的类中将其存储,但是通过值捕获时,在lambda生成的类中需要为值捕获的变量生成数据成员,创建构造函数。
auto wc = find_if(words.begin().words.end(),[sz](const string &a){return a.size()>=sz;});
class SizeComp
{
SizeComp(size_t n):sz(n){}
bool operator()(const string &s) const
{
return s.size()>=sz;
}
private:
size_t sz;
}
标准库定义的函数对象
标准库定义了一组表示算术、关系、逻辑运算符的类,都被定义成模板的形式,可以为其指定具体的应用类型即调用运算符的形参类型。
plus<int> intAdd;
int sum=intAdd(10,20);
- 表示运算符的函数对象类常用来替换算法中的默认运算符,如
sort(vec.begin(),vec.end(),greater<string>);
第三个实参是greater<string>
类型的一个未命名的对象,需要注意的是,标准库定义的函数对象也适用于指针,但不能用大于小于号而是用less函数对象
可调用对象与function
- C++的可调用对象有:函数、函数指针、lambda表达式、bind创建的对象、重载了函数调用运算符的类,其实可调用对象也有类型,每个lambda有它自己唯一的类类型,函数及函数指针的类型由其返回值和实参类型决定
- 不同类型的可调用对象可能共享同一种调用形式,指明了返回类型和传递给调用的实参类型。例如普通函数加、lambda表达式减和函数对象类除法的调用方法都为:
mod(a,b)
为了利用这些可调用对象,可以定义一个函数表来存储指向这些可调用对象的指针。 - 用运算符符号的string对象作为关键字,用实现运算符的函数作为值构建运算符到函数指针的映射
map<string, function<int(*)(int,int)>> binops;
- 再利用
function<T> f
来创建可放入容器中的类型,无论函数指针,lambda表达式,函数对象类的对象都可保存,但也不能把重载函数的名字直接存入map中,而是要利用函数指针或者lambda表达式:
int add(int i,int j){return i+j;}
map<string,function<int(int,int)>> bin;
int (*fp)(int,int)=add;
bin.insert({"+",fp});
重载、类型转换与运算符
转换构造函数和类型转换运算符共同定义了类类型转换(用户定义的类型转换)
类型转换运算符
- 是类的一种特殊成员函数,负责将一个类类型的值转换成其他类型
operator type()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;}
private:
std::sizt_t val;
}
定义了内置类型向类型的转换,也定义了类类型向内置类型的转换,SmallInt si; si+3.14
此时发生隐式的类型转换,si=4
此时发生了4向类类型的转换,隐式的类型转换可以置于一个标准类型转换之前或者之后,这意味着可以将任何算术类型传递给构造函数。
- 类型转换运算符可能有意外结果,因为如果类型转换自动发生,用户可能感觉意外。我们经常会定义向bool的转换,但是类类型的对象转换为bool后就能被用在任何需要算术类型的地方
int i=42; cin<<i;
会造成cin
转换为bool类型,然后左移位i个位置。为防止这样的情况发生,C++11=-引入了显式类型转换运算符
class SmallInt
{
public:
explicit operator int() const {return val;}
}
这种情况下当显式地请求转换,才会执行类类型到内置类型的转换。但是如果表达式被用作条件,编译器将会执行显式的类型转换。即如果表达式出现在:if while do的条件部分,for语句头的条件表达式,逻辑与或非,显式类型转换将会被隐式执行。
避免二义性的类型转换
需要确保在类类型和目标类型之间只存在唯一一种转换方式,否则代码会有二义性
- 比如两个类提供了相同的类型转换方式,或者一个类定义了多个转换规则。存在二义性,就必须显式地调用类型转换运算符或者转换构造函数。
struct B;
struct A
{
A() = defauly;
A(const B&);
};
struct B
{
operator A() const;
};
A F(const A&);
B b;
A a = f(b);//存在二义性,是形参A转换为B还是实参b转换为A类?
- 如果定义了多个参数都为算术类型的构造函数,有可能会产生二义性,原因是在隐式类型转换时,标准类型转换级别一致,这决定了编译器选择最佳匹配的过程。如果转换级别有一个更高,则不会出现二义性错误
struct A
{
A(int = 0);
A(double);
operator int() const;
operator double() const;
};
void f2(long double);
A a;
F2(a); //二义性,无法确定A是转换为int 还是double
long lg;
A a2(lg);//都要求类型转换,long到int或者double
有这么几个规则:不要令两个类执行相同的类型转换;避免转换目标是内置算术类型的类型转换;总之,除了显式的向bool类型的转换之外,避免定义类型转换函数。
- 调用重载函数,从多个类型转转换中进行选择将变得复杂,特别是转换构造函数的二义性,如果出现了,说明程序的设计是存在不足的。
struct E
{
E(int);
};
struct C
{
C(int);
};
void manip2(const C&);
void manip(const E&);
manip2(10);
- 如果调用重载函数时,多个用户定义的类型转换提供了可行匹配则认为级别一致,在这个过程中不考虑可能出现的标准类型转换,
struct E
{
E(double);
};
struct C
{
C(int);
};
void manip2(const C&);
void manip(const E&);
manip2(10);
这两个manip2都会隐式转换,C接受int 转换为C类型,E接受double转换为E类型,尽管10直接转换为int,而到double需要额外的标准类型转换,编译器也会认为这样是具有二义性的。
函数匹配与重载运算符
重载的运算符也是重载的函数,因此适用于重载函数的规则同样适用于在给定表达式中判断到底适用内置运算符还是重载运算符,我们用重载运算符作用于类类型的运算对象,候选函数中包含该运算符的非成员版本和内置版本,如果左侧对象是类类型,则定义在该类中运算符重载版本也包含在候选函数中。与此不同的是,调用一个命名的函数,具有该名字的成员函数和非成员函数不会彼此重载。
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;
int i = s3+0;
第三条语句有二义性,因为其重载运算符的非成员函数也在候选函数列表中,所以一方面可以将SmalInt 利用成员函数的重载的类型转换运算符转换为int 另一方面可以利用非成员函数版本的重载的加法运算符将int转换为SmallInt进而执行内置加法运算。