C++重载运算符
当定义重载运算符时,首先要决定是将其声明为类的成员函数,还是声明为一个普通的非成员函数。
下面的准则有助于我们在将运算符定义为成员函数还是普通的非成员函数做出抉择:
- 赋值(
=
),下标([]
),调用(()
)和成员访问箭头运算符(->
)必须是成员。 - 复合赋值运算符一般来说应该是成员,但并非必须,这一点与赋值运算符略有不同。
- 改变对象状态的运算符或者与给定类型密切相关的运算符,如递增、递减和解引用运算符,通常应该是成员。
- 具有对称性的运算符可能转换任意一端的运算对象,例如算术、相等性、关系和位运算符等,因此它们通常应该普通的非成员函数。
当我们把运算符定义成成员函数时,它的左侧运算对象必须是运算符所属类的一个对象,例如:
string s = "world";
string t = s + "!";
string u = "hi" + s; //如果+是string的成员,则产生错误。
如果将+定义为成员,那么"hi" + s
等价于"hi".operator+(s)
,显然"hi"
的类型是const char*
,这是一种内置类型,根本没有成员函数。
而string
将+定义了普通的非成员函数,所有"hi"+s
等价于operator+("hi", s)
,和普通函数调用一样,每个实参都被转换成形参类型,唯一要求是至少有一个运算对象是类类型,并且都能转换成string
。
重载输出运算符<<
-
<<
运算符的第一个形参是一个非常量的ostream
对象的引用。- 常量是因为向流写入内容会改变其状态
- 引用是因为我们无法拷贝一个
ostream
对象。
-
第二个形参一般是一个常量引用,该常量是我们想要打印的类类型。
- 引用是避免复制实参;
- 常量是打印对象通常无需改变它。
-
为了与其他输出运算符保持一致,
operator<<
一般要返回它的ostream
形参。 -
输入输出运算符必须是非成员函数。
- 与
iostream
标准库兼容的输入输出运算符必须是普通的非成员函数,而不能是类的成员函数。否则,它们的左侧运算对象将是我们的类的一个对象:
Sales_data data; data << cout; //如果operator<<是Sales_data的成员 。
假设输入输出运算符是某个类的成员,则它们也必须是
istream
或ostream
的成员。然而,这两个类属于标准库,并且我们无法给标准库中的类添加任何成员。-
上面的这句话不大好理解,解释一下:
ostream& Sales_data::operator<<(ostream &os, const Sales_data &item);
将<<重载运算符作为Sales_data的成员函数,使用时就是这样:(这里应了前半句“假设输入输出运算符是某个类的成员”)
Sales_data data; data << cout;
这意味着
data.operator<<(cout)
,从data调用,合理(因为operator<<是成员函数)。而不能是
Sales_data data; cout << data;
因为cout << data意味着cout.operator<<(data),错误。因为cout没有重载针对Sales_data类型的operator<<。你想这样用就必须在ostream里声明针对Sales_data的重载运算符operator<<。
书上的意思应该是隐藏了一句话,补全应该是:
假设输入输出运算符是某个类的成员,那么要以正常形式使用(流对象在左),则它们也必须是istream或ostream的成员。然而,这两个类属于标准库,并且我们无法给标准库中的类添加任何成员。
- 与
-
由上面一条可知,如果希望为类自定义IO运算符,则必须将定义为非成员函数。当然,IO运算符通常需要读写类的非公有数据成员,所以IO运算符一般被声明为友元。
所以operator
的格式就是这样
ostream &operator<<(ostream &os, const Sales_data &item)
{
os << item.isbn() " " << item.revene;
return os;
}
重载输入运算符>>
- 通常情况下,第一个形参是运算符将要读取的流的引用。
- 第二个形参是将要读入到的对象(非常量)的引用。
- 返回某个给定流的引用。
基本格式:
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;
}
if语句检查读取操作是否成功,如果发生了IO错误,则运算符将给定的对象重置为空Sales_data,这样可以确保对象处于正确的状态。
输入运算符必须处理输入可能失败的情况,输出运算符不需要。
在执行输入运算符时可能发生下列错误:
- 当流含有错误类型的数据时,读取操作可能失败。
- 当读取操作到达文件末尾,或者遇到输入流的其他错误时也会失败。
如果在发生错误前对象已经有一部分被改变,则适时地将对象置为合法状态显得尤为重要。
通过将对象置为合法状态,我们能(略为)保护使用者免于受到输入错误的影响。此时的对象处于可用的状态,即它的成员都是被正确定义的。而且该对象也不会产生误导性的结果,因为它的数据在本质上确实是一体的。
标识错误
一些输入运算符需要做更多的数据验证工作。
例如,我们的输入运算符可能需要检查bookNo
是否符合规范的格式。在这样的例子中,即使从技术上来看IO是成功的,输入运算符也应该设置流的条件状态以标识出失败信息。
通常情况下,输入运算符只设置failbit
,设置eofbit
表示文件耗尽,而设置badbit
表示流被破坏。最好的方式是由IO标注库自己来标识这些错误。
算术运算符
- 通常,将算术运算符定义成非成员函数,以允许对左侧或右侧的运算对象进行转换。
- 因为这些运算符一般不需要改变运算对象的状态,所以形参都是常量的引用。
如果类定义了算术运算符,则它一般也会定义一个对应的复合赋值运算符,此时,最有效的方式是使用复合赋值来定义算术运算符。
//假设两个对象指向同一本书
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;
}
bool operator!=(const Sales_data &lhs, const Sales_data &rhs)
{
return !(lhs == rhs);
}
一般定义了==
的,也需要定义!=
,将其中一个委托给另一个即可。
如果一个类在逻辑上有相等性的含义,则该类应该定义
operator==
,这样做可以使得用户更容易使用标准库算法来处理这个类。
关系运算符
定义了相等运算符的类常常(但不总是)包含关系运算符。特别是,因为关联容器和一些算法要用到小于运算符,所以定义operator<
会比较有用。
对于有的类,如果不存在一种逻辑可靠的<
定义,这个类不定义<
运算符也许会更好。
赋值运算符
赋值运算符必须是类的成员函数。
除了拷贝赋值和移动赋值运算符,还可以定义其他赋值运算符以使用别的类型作为右侧运算对象。
例如,标准库vector
类还定义了第三种赋值运算符,该运算符接受花括号内的元素列表作为参数:
vector<string> v;
v = { "a", "b", "c" };
和赋值及移动运算符一样,其他重载的赋值运算符也必须先释放当前内存空间,再创建一片新空间。
不同的是,如果能确保两个对象不相同,无需检查自赋值情况。
复合赋值运算符
- 复合赋值运算符不非得是类的成员,不过还是倾向于把包括复合赋值在内的所有赋值运算符都定义在类的内部。
- 应该返回左侧运算对象的引用。
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]; }
}
上面的两个下标运算符的用法类似于vector
或者数组中的下标。因为下标运算符返回的是元素的引用,所以当StrVec
是非常量时,我们可以给元素赋值;而当我们对常量对象取下标时,不能为其赋值:
//假设svec是一个StrVec对象
const StrVec cvec = svec;//拷贝
if(svec.size() && svec[0].empty()){
svec[0] = "zero"; //正确,下标运算符返回string的引用
cvec[0] = "zip"; //错误,对cvec取小白哦返回的是常量引用
}
递增和递减运算符
- C++并不要求这两种运算符必须是类的成员,但是因为它们改变的正好是所操作对象的状态,所以建议将其设定为成员函数。
递增和递减运算符一个同时定义前置版本和后置版本,且通常作为类的成员。
定义前置递增/递减运算符
class StrBlobPtr{
public:
//前置递增和递减运算符
StrBlobPtr& operator++();
StrBlobPtr& operator--();
}
为了与内置版本保持一致,前置运算符应该返回递增或递减后对象的引用。
递增和递减运算符的工作机理非常相似:它们首先函数调用check
函数检验StrBlobPtr
是否有效,如果是,接着检查给定的索引值是否有效。如果check
函数没有抛出异常,则运算符返回对象的引用。
在递增运算符的例子中,我们把curr
的当前值传递给check
函数。如果这个值小于vector
的大小,则check
正常返回。否则,如果curr
已经到达了vector
的末尾,check
将抛出异常。
//前置版本
StrBlobPtr& StrBlobPtr::operator++()
{
//如果curr已经指向了容器的尾后位置,则无法递增它
check(curr, "increment past end of StrBlobPtr");
++curr;
return *this;
}
StrBlobPtr& StrBlobPtr::operator--()
{
//如果curr是0,则继续递减它产生一个无效下标
--curr;
check(curr, "decrement past begin of StrBlobPtr");
return *this;
}
递减运算符先递减curr
,然后调用check
函数。此时,如果curr
(一个无符号数)已经是0了,那么我们传递给check
的值将是一个表示无效下标的非常大的正数值。
区分前置和后置运算符
通过额外的一个int
类型形参(不被使用的)来区分前置和后置的重载。
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.operator++(0); //调用后置的
p.operator++(); //调用前置的
成员访问运算符
- 箭头运算符必须是类的成员。解引用运算符通常也是类的成员,尽管并非必须如此。
*待办
函数调用运算符
*待办