目录
重载运算符是具有特殊名字的函数: 它们的名字由关键字operator和其后要定义的运算符号共同组成.
重载运算符函数的参数数量与该运算符作用的运算对象数量一样多.
对于二元运算符来说, 左侧运算对象传递给第一个参数, 而右侧运算对象传递给第二个参数.
除了重载的函数调用运算符operator()除外, 其他重载运算符不能含有默认实参.
如果一个运算符函数是成员函数, 则它的第一个(左侧)运算对象绑定到隐式的this指针上, 因此成员运算符函数的显式参数数量比运算符的运算对象总数少一个.
对于一个运算符函数来说, 它或者是类的成员, 或者至少有一个类类型的参数. 如:
int operator+(int, int); // 错误, 不能为int重定义内置的运算符这意味着当运算符作用于内置类型时, 我们无法改变运算符的含义.
只能重载已有的运算符, 而无权发明新的运算符号, 对于重载的运算符来说, 其优先级和结合律与对应的内置运算符保持一致.
不能被重载的运算符: "::" ".*" "." "? :"
一般情况下不应该被重载的运算符: 逗号, 取地址, 逻辑与和逻辑或运算符.
非成员运算符函数的调用:
data1 + data2; // 普通的表达式
operator+(data1, data2); // 等价的函数调用
成员运算符函数的调用:
data1 += data2; // 基于“调用”的形式
data1.operator+=(data2); // 对成员运算符函数的等价调用
选择作为成员函数或者非成员函数
有些运算符必须作为成员函数, 另一些情况下, 运算符作为普通函数比作为成员好.
赋值(=), 下标([ ]), 调用(())和成员访问箭头(->)运算符必须是成员函数.
复合赋值符运算符一般来说应该是成员, 但这并非必须, 这一点与赋值运算符略有不同.
改变对象状态的运算符或者与给定类型密切相关的运算符, 如递增, 递减和解引用运算符, 通常应该是成员函数.
具有对称性的运算符可能转换任意一端的运算对象, 如算术, 相等性, 关系和位运算符等, 它们通常应该是普通的非成员函数.
必须为成员函数的运算符函数有4个: =, [], (), ->.
建议为成员函数的运算符函数: 复合赋值运算符(+=, -=, *=, /=), 自增, 自减, 解引用.
必须为非成员: 流操作运算符(<<, >>).
建议非成员: 算术, 关系, 位操作.
如果我们想提供含有类对象的混合类型表达式, 则运算符必须定义成非成员函数. 因为我们把运算符定义成成员函数时, 它的左侧运算对象必须是运算符所属类的一个对象. 如:
string = "world";
string t = s + "!"; // 正确, 能够把一个const char * 加到一个string对象中.
string u = "hi" + s; // 如果+是string的成员, 则产生错误.
如果operator+是string类的成员, 则上面的第一个加法等价于s.operator("!"). 同样的, "hi"+s等价于"hs".operator(s). 显然, "hi"的类型是const char *, 这是一种内置类型, 根本没有成员函数.
因为string将+定义成了普通的非成员函数, 所以"hi"+s等价于operator("hi", s), 每个实参都能被转换成形参类型. 唯一的要求是至少有一个运算对象是类类型, 且两个运算对象都能转换成string.
赋值运算符应该返回它左侧运算对象的一个引用.
1 输入输出运算符
1.1 重载输出运算符
通常情况下, 输出运算符的第一个形参是非常量ostream对象的引用. 之所以ostream是非常量是因为向流写入内容会改变其状态, 形参是引用是因为我们无法拷贝ostream.
第二个形参一般来说是一个常量引用, 该常量是我们想要打印的类类型. 是引用的原因是因为我们希望避免赋值实参, 为常量是因为打印通常不会改变对象的内容.
为了与其他输出运算符保持一致, operator<<一般要返回它的ostream形参.
ostream &operator<<(ostream &os, const Sales_data &item) // 返回引用
{
os << item.isbn() << " " << item.units_sold << " "
<< item.revenue << " " << item.avg_price();
return os;
}
通常输出运算符只负责打印内容而非控制格式, 输出运算符不应该打印换行符.
输入输出运算符必须是普通的非成员函数, 而不能是类的成员函数. 否则它的左侧运算对象将是我们的类的一个对象.
如果是类的成员的话, 则第一个参数就必须是类的对象, 但是输入输出运算符的左侧运算对象(第一个参数)是ostream对象.
IO运算符通常需要读写类的非公有数据成员, 所以IO运算符一般被声明为友元.
1.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;
}
输入运算符可能发生如下错误:
流含有错误类型的数据时读取操作可能失败.
读取操作到达文件结尾或者输入流遇到其他错误时也会失败.
2 算术和关系运算符
通常情况下把算术和关系运算符定义成非成员函数以允许对左侧或右侧的运算对象进行转化, 因为这些运算符一般不需要改变运算对象的状态, 所以形参都是常量引用.
Sales_data operator+(const Sales_data &lhs, const Sales_data &rhs)
{
Sales_data sum = lhs;
sum += rhs; // 用复合赋值运算符来实现算术运算符
return sum;
}
如果类同时定义了复合赋值运算符, 则通常情况下应该使用复合赋值运算符来实现算术运算符.
2.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 !(lhs == rhs); // 具体工作交由==来完成. }
如果类定义了==操作, 则通常也应该定义!=操作.
相等运算符和不相等运算符中的一个应该把工作委托给另外一个.
2.2 关系运算符
定义了相等运算符的类常常(但不总是)包含关系运算符.
通常情况下, 关系运算符应该:
定义顺序关系, 令其与关联容器中对关键字的要求一致.
如果类同时也含有==运算符的话, 则定义一种关系令其与==保持一致, 特别是, 如果两个对象是!=的, 那么一个对象应该<另外一个.
Sales_data类的相等比较是逐个比较成员, 但是由于有多个成员, <操作的逻辑不明确. Sales_data类不存在一种逻辑可靠的<定义, <和==产生的结果不一致, 这个类不定义<运算也许会更好.
如果存在唯一一种逻辑可靠的<定义, 则应该考虑为这个类定义<运算符. 如果类同时还包含==, 则当且仅当<的定义和==产生的结果一致时才定义<运算符.
3 赋值运算符
除拷贝赋值运算符外, 类还可以定义其他赋值运算符以使用其他类型作为右侧运算对象.
赋值运算符返回左侧运算对象的引用.
赋值运算符必须定义为成员函数.
与拷贝赋值和移动赋值运算符一样, 其他重载的赋值运算符也必须先释放当前的内存空间, 在创建一片新空间. 但是无需检查自赋值的情况, 因为其形参不是自身类型, 这确保形参与自身不是同一个对象.
StrVec &StrVec::operator=(initalizer_list<string> il) { auto data = alloc_n_copy(il.begin(), il.end()); // 无需检查自赋值情况. free(); elements = data.first; first_free = cap = data.second; return *this; }
4 复合赋值运算符
复合赋值运算符不非得是类的成员, 不过我们还是倾向于把包括复合赋值在内的所有赋值运算都定义在类的内部. 这两类运算符都应该返回左侧运算对象的引用.
Sales_data &Sales_data::operator+=(const Sales_data &rhs) { units_sold += rhs.units_sold; revenue += rhs.revenue; return *this; }
5 下标运算符
下标运算符必须是成员函数.
为了与下标的原始定义相兼容, 下标运算符以所访问元素的引用作为返回值, 这样做的好处是下标可以出现在赋值运算符的任意一端.
最好同时定义下标运算的常量版本和非常量版本, 当作用于常量对象时, 下标运算符返回常量引用以确保我们不会给返回的对象赋值.
class StrVec{ public: std::string &operator[](std::siez_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的引用类型 StrBlobPtr &operator--(); }; StrBlobPtr &StrBlobPtr::operator++() { check(curr, "incremet past end of StrBlobPtr"); ++curr; return *this; } StrBlobPtr &StrBlobPtr::operator--() { --curr; check(curr, "incremet past end of StrBlobPtr"); return *this; }
区分前置和后置版本
前置版本和后置版本使用的是同一个符号, 意味着其重载版本所用的名字将是相同的, 并且运算对象的数量和类型也相同.
为了将二者区分开来, 后置版本接受一个额外的(但并不使用)int类型的形参, 当我们使用后置版本运算符时, 编译器为这个形参提供一个值为0的实参. 这个形参的唯一作用就是区分前置版本和后置版本的函数, 而不是真正的要在实现后置版本时参与运算.
class StrBlobPtr{ public: StrBlobPtr operator++(int);// 后置版本, 返回类型为值类型, 而非引用, 多了一个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); // 调用后置版本的operator++,p++ p.operator++(); // 调用前置版本的operator++,++p
7 成员访问运算符
class StrBlobPtr{ public: std::string& operator*() const // 返回引用 { auto p = check(curr, "dereference past end"); return (*p)[curr]; } std::string* operator->() const // 返回指针 { return &this->operator*(); // 将实际工作委托给解引用运算符 } };
箭头运算符必须是类成员, 解引用运算符通常也是类的成员, 尽管并非必须如此.
重载的箭头运算符必须返回类的指针或者自定义了箭头运算符的某个类的对象.
对于形如point->mem的表达式来说, point必须是指向类对象的指针或者是一个重载了operator->类的对象, 其执行过程如下:
如果point是指针, 则我们应用内置的箭头运算符, 表达式等价于(*point).mem, 首先解引用该指针, 然后从所得的对象中获取指定的成员.
如果point是定义了operator->的类的一个对象, 则我们使用point.operator->()的结果来获取mem. 其中, 如果该结果是一个指针, 则执行第1步, 如果该结果本身含有重载的operator->(), 则重复调用当前步骤.
重载的箭头运算符必须返回类的指针或者自定义了箭头运算符的某个类的对象.
8 函数调用运算符
如果类重载了函数调用运算符, 则我们可以像使用函数一样使用该类的对象. 因为这样的类同时也能存储状态, 所以与普通函数相比他们更加灵活.
struct absInt { int operator()(int val) const { return val < 0 ? -val : val; } }; int i = 42; absInt absObj; // 含有函数调用运算符的对象 int ui = absObj(i); // 将i传递给absObj.operator()
函数调用运算符必须是成员函数, 一个类可以定义多个不同版本的调用运算符, 相互之间应该在参数或类型上有所区别.
如果类定义了调用运算符, 则该类的对象称作函数对象. 因为可以调用这种对象, 所以我们说这些对象的行为像函数一样.
含有状态的函数对象类
和其他类一样, 函数对象类除了operator()之外也可以包含其他成员. 函数对象类通常含有一些数据成员, 这些成员被用于定制调用运算符中的操作. 如
// 该类用于定制打印字符串的操作, 每一个字符串之后打印一个sep字符. class PrintString{ public: PrintString(std::ostream &o = std::cout, char c = ' ') : os(o), sep(c) { }; void operator()(const std::string &s) const { os << s << sep; }; private: std::ostream &os; char sep; }; std::string s{"Hello"}; PrintString ps; // 使用默认值, 打印到cout ps(s); PrintString ps2(std::cout, '\n'); ps2(s); // 函数对象常常作为泛型算法的实参 std::for_each(svec.begin(), svec.end(), PrintString(std::cerr, '\n'));
8.1 lambda是函数对象
编译器将lambda表达式翻译成一个未命名类的未命名对象. 所以必须要用auto来获取lambda的类型. lambda有他自己唯一的类类型, 只不过是未命名的.
在lambda表达式产生的类中含有一个重载的函数调用运算符. 如
stable_sort(svec.begin(), svec.end(), [](const string &a, const string &b){ return a.size() < b.size(); }); // 与lambda表达式的等价操作 class ShorterString { public: bool operator()(const string &a, const string &b) const { return a.size() < b.size(); } }; stable_sort(svec.begin(), svec.end(), ShorterString());
表示lambda及相应捕获行为的类
当一个lambda表达式通过引用捕获变量时, 将由程序确保lambda执行时引用所引的对象确实存在. 因此, 编译器可以直接使用该引用而无需再lambda产生的类中将其存储为数据成员.
通过值捕获变量时, 由于变量是被拷贝到lambda中的, 因此这种lambda产生的类必须为每个值捕获的变量建立对应的数据成员, 同时创建构造函数, 令其使用捕获的变量的值来初始化数据成员.
// 返回第一个指向满足条件元素的迭代器, 该元素满足 size > sz; auto wc = find_if(svec.begin(), svec.end(), [sz](const string &a){ return a.size() > sz; }); // 该lambda表达式产生的类形如 class SizeComp { public: SizeComp(size_t size): sz(size) { } // 该形参对用捕获的变量 // 调用该运算符的返回类型, 形参和函数体都与lambda保持一致 bool operator()(const string &a) { return a.size() > sz; } private: size_t sz; // 该数据成员对应通过值捕获的变量 }; auto wc = find_if(svec.begin(), svec.end(), SizeComp(sz));
lambda表达式产生的类不含有默认构造函数, 赋值运算符及默认析构函数. 是否含有默认拷贝/移动函数则要视捕获的数据成员类型而定.
8.2 标准库定义的函数对象
标准库定义了一组表示算术运算符, 关系运算符和逻辑运算符的类, 每个类分别定义了一个执行命名操作的调用运算符. 这些类型都定义在functional头文件中.
算术 |
plus<Type> |
minus<Type> |
multiplies<Type> |
divides<Type> |
modulus<Type> |
negate<Type> |
plus<int> intAdd; // 执行int加法操作 negate<int> intNegate; // 执行int取反操作 // 使用 intAdd::operator(int, int) int sum = intAdd(10, 24); // 10 + 24 // 使用 intNegate::operator(int) sum = intNegate(intAdd(10, 20)); // -(10 + 20) // 在算法中使用标准库函数对象 vector<int> ivec{2, 3, 1, 7, 6}; sort(ivec.begin(), ivec.end(), greater<int>()); // 按 > 符号排序 for_each(ivec.begin(), ivec.end(), [](int i){ cout << i << " "; });
8.3 可调用对象与function
C++语言中有几种可调用对象: 函数, 函数指针, lambda表达式, bind创建的对象以及重载了函数调用运算符的类.
然而, 两个不同类型的可调用对象却可能共享同一种调用形式. 调用形式指明了调用返回类型以及传递个调用的实参类型. 一种调用形式对应一个函数类型.
不同类型可能具有相同的调用形式.
按 Ctrl+C 复制代码
按 Ctrl+C 复制代码
函数表: 用于存储指针指向可调用对象的“指针”. 在C++语言中, 函数表很容易通过map来实现.
按 Ctrl+C 复制代码
按 Ctrl+C 复制代码
但是我们不能将mod或者divide存入binops. 问题在于mod是个lambda表达式, 而每个lambda有它自己的类类型, 该类型与binops中的值得类型不匹配.
可以使用function的标准库类型来解决上述问题, function定义在functional头文件中.
function的操作:
function<T> f;
function<T> f(nullptr);
function<T> f(obj);
f
f(args)
定义为function<T>的成员的类型
result_type
argument_type
first_argument_type
second_argument_type
function是一个模板, 创建一个具体的function类型时我们必须提供额外的信息, 及对象的调用形式. 如
按 Ctrl+C 复制代码
按 Ctrl+C 复制代码
8.4 重载函数与function
不能直接将重载函数的名字存入function类型的对象中:
按 Ctrl+C 复制代码
按 Ctrl+C 复制代码
9 重载, 类型转换与运算符
一个实参调用的非显式构造函数定义了种隐式的类型转换, 这种构造函数将实参类型的对象转换成类类型. 也可以通过类型转换运算符来定义对于类类型的转换.
转换构造函数和类型转换运算符共同定义了类类型转换, 这样的转换有时也被称为用户定义的类型转换.
转换构造函数: 其他类型----------->类类型
类型转换运算符: 类类型----------->其它类型
9.1 类型转换运算符
类型转换运算符是一种特殊的成员函数, 它负责将一个类类型的值转换成其他类型. 类型转换函数的一般形式如下:
operator type() const;
其中type表示某种类型. 类型转换运算符可以面向任意类型(除void之外)进行定义, 只要该类型能够作为函数的返回类型. 因此不允许转换成函数类型或数组, 但允许转换成指针或者引用类型.
一个类型转换函数必须是类的成员函数, 它不能声明返回类型, 形参列表也必须为空, 类型转换函数通常应该是const的.
按 Ctrl+C 复制代码
按 Ctrl+C 复制代码
尽管编译器一次只能执行一个用户定义的类型转换, 但是隐式的用户定义类型转换可以置于一个标准(内置)类型转换之前或之后并与其一起使用. 如
SmallInt si = 3.14; // 首先内置类型double转换为int, 然后执行隐式的转换 si + 3.14; // SmallInt类型转换运算符将si转换成int, 内置类型将所得的int继续转换成double
因为类型转换运算符是隐式执行的, 所以无法给这些函数传递实参, 当然也就不能在类型转换运算符的定义中使用任何形参.
尽管类型转换函数不负责制定返回类型, 但实际上每个类型转换函数都会返回一个对应类型的值.
按 Ctrl+C 复制代码
按 Ctrl+C 复制代码
类型转换运算符可能产生意外结果
int i = 42; cin << i; // 如果istream含有向bool的类型转换且该转换不是显式的时, 则该代码在编译器看来是合法的!!!!
istream的bool类型转换运算符将cin转换成bool, 这个bool接着被提升为int并作用于内置的左移运算符的左侧运算对象. 这样一来, 提升后的bool值被左移42个位置.
显式的类型转换运算符
在类型转换函数之前加上explicit修饰符. 如
按 Ctrl+C 复制代码
按 Ctrl+C 复制代码
该规定存在一个例外, 即如果表达式被用作条件, 则编译器会将显式的类型转换自动应用于它, 显式的类型转换将被隐式地执行.
无论在什么时候在条件中使用流对象, 都会使用为IO类定义的operator bool, 如:
while (std::cin >> value)
while语句条件执行输入运算符, 它负责将数据读入到value并且返回cin. cin被istream operator bool类型转换函数隐式执行了转换.
向bool类型的转换通常用在条件部分, 因此operator bool一般定义成explicit的, 使得在条件部分可以隐式执行, 而在其他部分则需要显式请求.
9.2 避免有二义性的类型转换
如果类中包含一个或多个类型转换, 则必须确保在类类型和目标类型之间只存在唯一一种转换方式. 否则很可能引发二义性.
在两种情况下会产生多重转换路径:
两个类提供了相同的类型转换. 如类A定义了一个接受B类对象的转换构造函数, 同时B类定义了一个转换目标是A类的类型转换运算符, 我们就说它们提供了相同的类型转换.
类定义了多个转换规则, 而这些转换涉及的类型本身可以通过其他类型转换联系在一起. 最典型的是算术运算符, 对一个给定的类来说, 最好只定义最多一个与算术类型有关的转换规则.
实参匹配和相同类型的转换
下面的例子定义了两种将B转换成A的方法: 一种用B的类型转换运算符, 一种用A的以B为参数的转换构造函数.
class A{ A() = default; A(const &B); // 隐式转换构造函数, 将一个B转换为A //其他成员 }; class 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&))? // 要想执行上述调用, 就不得不显式地调用类型转换运算符或者转换构造函数 A a1 = f(b.operator A()) // 使用B的类型转换运算符 A a2 = f(A(b)) // 使用A的构造函数
二义性与转换目标为内置类型的多重类型转换
如果转换源(或者转换目标)类型本身可以通过其他类型转换联系在一起, 则同样会产生二义性的问题. 比如定义了多个算术类型的类型转换运算符.
举例: 略
重载函数与转换构造函数
如果两个或多个类型提供了同一种可行匹配, 则这些类型转换一样好.
struct C{ C(int); }; struct D{ D(int); }; void manip(const C&); void manip(const D&); manip(10); // 二义性错误, 含义是manip(C(10))还是manip(D(10)). manip(C(10)); // 正确
重载函数与用户定义的类型转换
当调用重载函数时, 如果两个(或多个)用户定义的类型转换都提供了可行的匹配, 则认为这些类型转换一样好.
struct E{ E(double); }; void manip2(const C&); void manip2(const E&); manip2(10); // 二义性错误, 两个不同的用户定义的类型转换都能在此处用.
9.3 函数匹配与重载运算符
当运算符函数出现在表达式中时, 候选函数集的规模要比我们使用调用运算符调用函数时更大. 如果a是一种类类型, 则表达式a sym b可能是
a.operatorsym(b) // a有一个operatorsym成员函数 operatorsym(a, b) // operatorsym是一个普通函数
与普通函数不同, 我们不能通过调用的形式来区分当前调用的是成员函数还是非成员函数.
表达式中运算符的候选函数集既应该包括成员函数, 也应该包括非成员函数.
如果我们对同一个类既提供转换目标是算术类型的类型转换, 也提供了重载的运算符, 则将会遇到重载运算符与内置运算符的二义性问题.