C++Primer 5th:第十四章 重载运算与类型转换

本文最后更新于:2020年7月19日 上午

第十四章 重载运算与类型转换

当运算符作用于类类型的运算对象时,可以通过运算符重载重新定义该运算符的含义。

14.1 基本概念

  1. 重载的运算符是具有特殊名字的函数,由关键字operator和其后要定义的运算符号组成。
  2. 除了重载的函数调用运算符operator()外,其他重载运算符不能含有默认参数。
  3. 当一个重载的运算符是成员函数this绑定到左侧运算对象,成员运算符函数的(显式)参数数量比运算对象的数量少一个。

1. 直接调用一个重载的运算符函数

1
2
3
4
5
6
//一个非成员运算符函数的等价调用
data1 + data2; //普通表达式
operator+(data1,data2); //等价的函数调用
//成员运算符函数
data1 += data2;
data1.operator+=(data2);

2. 某些运算符不应该被重载

  • 通常情况下,不应该重载逗号、取地址、逻辑与和逻辑或运算符。

3. 使用与内置类型一致的含义

  • 当在内置的运算符和我们自己的操作之间存在逻辑映射关系时,运算符重载的效果最好。
  • 只有当操作的含义对于用户来说清晰明了时才使用运算符

4. 赋值和复合赋值运算符

  • 如果类含有算数运算符或者位运算符,最好也提供对应的复合赋值运算符+=

5. 选择作为成员或者非成员

  • 赋值=,下标[], 调用(), 成员访问箭头->运算符必须是成员
  • 复合赋值运算符一般是成员,但非必须
  • 改变对象状态的运算符或者与给定类型密切相关的运算符,如递增、递减和解引用运算符,通常是成员
  • 具有对称性的运算符可能转换任意一端的运算对象,例如算数、相等性、关系、位运算符等,通常是非成员函数

  • 如果想提供含有类对象的混合类型表达式,则运算符必须定义为非成员函数

14.2 输入和输出运算符

14.2.1 重载输出运算符 <<

  1. 输出运算符的第一个形参是一个非常量 ostream 对象的引用
    • 非常量:因为向流写入内容会改变其状态
    • 引用:因为我们无法直接复制一个 ostream 对象
  2. 第二个形参一般是一个常量的引用,该常量是我们想要打印的类类型

1. Sales_data 的输出运算符

1
2
3
4
5
6
ostream &operator<<(ostream &os, const Sales_data &item)
{
os << item.isbn() << " " << item.units_sold << " "
<< item.revenue << " " << item.avg_price();
return os;
}

2. 输出运算符尽量减少格式化操作

3. 输入输出运算符必须是非成员函数

IO运算符通常需要读写类的非公有数据成员,所以 IO 运算符一般被声明为友元

14.2.2 重载输入运算符 >>

输入运算符的一个形参是运算符将要读取的流的引用,第二个形参是将要读入到的(非常量)对象的引用

1. Sales_data 的输入运算符

1
2
3
4
5
6
7
8
9
10
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. 输入时的错误

  • 当流含有错误类型的数据时读取操作可能失败
  • 当读取操作到达文件末尾或者遇到输入流的其他错误时也会失败

3. 标识错误

  • 通常情况下,输入运算符只设置 failbit 。除此之外,设置 eofbit 表示文件耗尽,而设置 badbit 表示流被破坏。

  • 最好的方式是由 IO 标准库自己来标示这些错误

14.3 算数和关系运算符

  • 通常把算数和关系运算符定义为非成员函数以允许对左侧或右侧的运算对象进行转换

  • 如果类同时定义了算术运算符和相关的复合赋值运算符,通常应该使用复合赋值来实现算数运算符

1
2
3
4
5
6
7
Sales_data
operator+(const Sales_data &lhs, const Sales_data &rhs)
{
Sales_data sum = lhs;
sum += rhs;
return sum;
}

14.3.1 相等运算符

设计原则:

  • 如果某个类在逻辑上有相等性的含义,则该类应该定义 operator==,这样做可以使得用户更容易使用标准库算法来处理这个类
  • 相等运算符应该具有传递性
  • 如果类定义了operator==,也应该定义operator!=
  • 相等运算符和不相等运算符中的一个应该把工作委托给另外一个
1
2
3
4
5
6
7
8
9
10
bool operator==(cosnt Sales_data &lhs, const Sales_data &rhs)
{
return lhs.isbn() == rhs.isbn() &&
lhs.units_sold == rhs.units_sold &&
lhs.revenue == rhs.revenue;
}
bool operator!=(cosnt Sales_data &lhs, cosnt Sales_data &rhs)
{
return !(lhs == rhs);
}

14.3.2 关系运算符

  1. 定义顺序关系,令其与关联容器中对关键字的要求一致
  2. 如果类同时含有 == 运算符,则定义一种关系令其与 == 保持一致,特别是如果两个对象是 != 的,那么一个对象应该 < 另外一个
  3. 如果存在唯一一种逻辑可靠的 < 定义,则应该考虑为这个类定义 < 运算符

14.4 赋值运算符

  • 标准库vector类除了拷贝赋值和移动赋值运算符之外,定义了第三种赋值运算符,该运算符接受花括号的元素列表作为参数
  • 可以重载赋值运算符,不论形参的类型是什么,赋值运算符都必须定义为成员函数
1
2
vector<string> v;
v = {"a", "an", "the"};
1
2
3
4
5
6
7
8
StrVec &STrVec::operator=(initializer_list<string> il)
{
auto data = alloc_n_copy(il.begin(), il.end());
free(); //销毁对象中的元素并释放内存空间
elements = data.first;
first_free = cap = data.second;
return *this;
}

1. 复合赋值运算符

  • 赋值运算符必须定义为类的成员
  • 复合赋值运算符通常也应该这样做
  • 两类运算符都应该返回左侧运算对象的引用
1
2
3
4
5
6
Sales_data& Sales_data::operator+=(const Sales_data &rhs)
{
units_sold += rhs.unit_sold;
revenue += rhs.revenue;
return *this;
}

14.5 下标运算符

  • operator[]
  • 下标运算符必须是成员函数
  • 如果一个类包含下标运算符,通常定义两个版本:一个返回普通引用,另一个是类的常量成员并且返回常量引用
1
2
3
4
5
6
7
8
9
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;
};
1
2
3
4
5
6
7
//假设 svec 是一个 StrVec 对象
const StrVec cvec = svec;
//如果 svec 中含有元素,对第一个元素运行 string 的 empty 函数
if(svec.size() && svec[0].empty()){
svec[0] = "zero"; //正确:下标运算符返回string的引用
//cvec[0] = "Zip"; //错误:对 cvec 取下标返回的是常量引用
}

14.6 递增和递减运算符

定义递增和递减运算符的类应该同时定义前置版本和后置版本,这些运算符通常应该被定为成类的成员

1. 定义前置递增/递减运算符

  • 为了与内置版本保持一致,前置运算符应该返回递增或递减后对象的引用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class StrBlobPtr{
public:
StrBlobPtr& operator++();
StrBlobPtr& operator--();
};

//前置版本
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;
}

2. 区分前置和后置运算符

  • 后置版本接受一个额外的(不被使用)int 类型的形参
  • 为了与内置版本保持一致,后置运算符应该返回对象的原值(递增或递减之前的值),返回的形式是一个值而非引用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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;
}

3. 显式的调用后置运算符

如果想通过函数调用的方式调用后置版本,则必须为他的整型参数传递一个值

1
2
3
StrBlobPtr p(a1);
p.operator++(0); //调用后置版本
p.operator++(); //调用前置版本

14.7 成员访问运算符

解引用运算符(*)和箭头访问运算符(->)

  • 箭头运算符必须是类的成员
  • 解引用运算符通常是类的成员,尽管并非必须如此
1
2
3
4
5
6
7
8
9
10
11
12
class StrBlobPtr{
public:
std::string& operator*() const
{
auto p = check(curr,"dereference past end");
return (*p)[curr];
}
std::string* operator->() const
{
return & this->operator*();
}
};

1. 对箭头运算符返回值的限定

  • 我们能令 operator* 完成任何我们指定的操作
  • 箭头运算符可以改变的是箭头从哪个对象中获取成员,而箭头获取成员这一事实永远不变
  • 重载的箭头运算符必须返回类的指针或者自定义了箭头运算符的某个类的对象

14.8 函数调用运算符

  • 函数调用运算符必须是成员函数
  • 一个类可以定义多个不同版本的函数调用运算符,相互之间应该在参数数量或类型上有区别
  • 如果类定义了调用运算符,则该类的对象称作函数对象
1
2
3
4
5
6
7
8
9
struct absInt{
int operator()(int val) const{
return val < 0 ? -val : val ;
}
};

int i = -42;
absInt absObj;
int ui = absObj(i);

1. 含有状态的函数对象类

一个打印 string 实参内容的类

1
2
3
4
5
6
7
8
9
10
11
12
13
class PrintString{
public:
PrintString(ostream &o = cout, char c = ' '): os(o), sep(c){}
void operator(){const string &s} const {os << s << sep;}
private:
ostream &os; //用于写入目的的流
char sep; //用于将不同输出隔开的字符
};

PrintString printer;
printer(s);
PrintString errors(cerr, '\n');
errors(s);
  • 函数对象常常作为泛型算法的实参
1
for_each(vs.begin(), vs.end(), PrintString(cerr, '\n'));

14.8.1 lambda 是函数对象

  • 当编写了一个 lambda 后,编译器将该表达式翻译成一个未命名类的未命名对象
  • 在 lambda 表达式产生的类中含有一个重载的函数调用运算符
  • 默认情况下 lambda 不能改变它捕获的变量
1
2
3
4
5
6
7
8
9
10
11
12
//根据单词的长度对其进行排序
stable_sort(words.begin(), words.end(),
[](const string &a, const string &b)
{return a.size() < b.size();});
//其行为类似于下面这个类的未命名对象
class ShorterString{
public:
bool operator()(const string &s1, const string &s2) const
{return s1.size() < s2.size();}
};
//用这个类替代 lambda 表达式后
stable_sort(words.begin(), words.end(), ShorterString());

1. 表示 lambda 及相应捕获行为的类

  • 当 lambda 表达式通过引用捕获变量时,将由程序负责确保 lambda 执行时引用所引的对象确实存在,编译器直接使用该引用而无须在 lambda 产生的类中将其存储为数据成员
  • 通过值捕获的变量将拷贝到 lambda 中,这种 lambda 产生的类必须为每个值捕获的变量建立对应的数据成员,同时创建构造函数,令其使用捕获的变量的值初始化数据成员
  • lambda 表达式产生的类不含默认构造函数、赋值运算符及默认析构函数;它是否含有默认的拷贝/移动构造函数则通常要视捕获的数据成员类型而定
1
2
3
4
5
6
7
8
9
10
11
12
13
14
//获得第一个指向满足条件元素的迭代器,该元素满足 size() is > sz
auto wc = find_if(words.begin(), words.end(),
[sz](const string &a)
{return a.size() >= sz;});
//该 lambda 表达式产生的类
class SizeComp{
SizeComp(size_t n):sz(n){ }
bool operator()(const string &s) const
{return s.size() >= sz;}
private:
size_t sz;
}

auto wc = find_if(words.begin(), words.eng(), SizeCopm(sz));

14.8.2 标准库定义的函数对象

标准库定义了一组表示算数运算符、关系运算符和逻辑运算符的类,每个类分别定义了一个执行命令操作的调用运算符

  • 这些类被定义成模板的形式
  • 定义在 functional 头文件中
1
2
3
4
5
plus<int> intAdd;	
negate<int> intNegate;
int sum = intAdd(10, 20);
sum = intNegate(intAdd(10, 20));
sum = intAdd(10, intNegate(10));

1. 在算法中使用标准库函数对象

  • 默认情况排序算法使用 operator< 将序列按照升序排列
  • 如果要执行降序排列,可以传入一个 greater 类型的对象
1
sort(svec.begin(), svec.end(), greater<string>());

通过比较指针的内存地址来 sort 指针的 vector

1
2
3
4
5
vector<string *> nameTable;
//错误:nameTable 中的指针彼此之间没有关系,所以 < 将产生未定义的行为
//sort(nameTable.begin(), nameTable.end(), [](string *a, string *b){return a < b;});
//正确:标准库规定指针的 less 是定义良好的
sort(nameTable.begin(), nameTable.end(), less<string *>());

14.8.3 可调用对象与 function

  • c++语言中有几种可调用的对象:函数、函数指针、lambda 表达式、bind创建的对象以及重载了函数调用运算符的类
  • 两个不同类型的可调用对象可能共享同一种调用形式,调用形式指明了调用返回的类型以及传递给调用的实参类型
  • 一种调用形式对应一个函数类型
1
2
int(int, int)
//是一个函数类型,接受两个 int、 返回一个 int

1. 不同类型可能具有相同的调用形式

1
2
3
4
5
6
7
8
9
10
//普通函数
int add(int i, int j) {return i + j;}
//lambda,其产生一个未命名的函数对象类
auto mod = [](int i, int j){return i % j;}
//函数对象类
struct divide {
int operator()(int denominator, int divisot=r){
return denominator / divisor;
}
};
  • 定义一个函数表用于存储指向这些可调用对象的“指针”
  • 在c++中,函数表很容易通过 map 实现
  • 此处我们使用一个表示运算符符号的string 对象作为关键字;使用实现运算符的函数作为值
1
2
3
4
5
6
map<string, int(*)(int, int)> binops;
//正确:add 是一个指向正确类型函数的指针
binops.insert({"+", add});
//错误:mod 不是一个函数指针
// mod 是个 lambda 表达式,每个 lambda 有它自己的类类型,该类型与存储在 binops 中的值的类型不匹配
//binops.insert({"%", mod});

2. 标准库 function 类型

1
2
3
4
5
6
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
cout << f1(4, 2) << endl;
cout << f2(4, 2) << endl;
cout << f3(4, 2) << endl;

使用这个 function 类型重新定义map

1
2
3
4
5
6
7
8
9
map<string, function<int(int, int)>> binops = {
{"+", add},
{"-", std::minus<int>()},
{"/", divide()}.
{"*", [](int i, int j){return i * j;}},
{"%", mod};
}

binops["+"](10, 5); //调用 add(10, 5)

3. 重载的函数与 funciton

不能(直接)将重载函数的名字存入 function 类型的对象中

1
2
3
4
5
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});

解决上述二义性问题的方法

  • 存储函数指针而非函数的名字
1
2
int (*fp)(int, int) = add;
binops.insert( {"+", fp});
  • 使用 lambda 类消除二义性
1
binops.insert( {"+", [](int a, int b){return add(a, b);} } );

14.9 重载、类型转换与运算符

转换构造函数和类型转换运算符共同定义了类类型转换

14.9.1 类型转换运算符

  • 一个类型转换函数必须是类的成员函数
  • 它不声明返回类型,形参列表也必须为空
  • 类型转换函数通常是const
  • 不允许转换数组或函数类型,但允许转换成指针(包括数组指针和函数指针)或者引用类型
1
2
// type 是某种类型
operator type() const;

1. 定义含有类型转换运算符的类

1
2
3
4
5
6
7
8
9
10
11
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::size_t val;
};
  • 构造函数将算术类型的值转换成 SmallInt 对象
  • 类型转换运算符将 SmallInt 对象转换成 int
  • 尽管类型转换函数不负责指定返回类型,但实际上每个类型转换函数都会返回一个对应类型的值
1
2
3
4
5
6
7
8
class SmallInt;
//operator int(SmallInt&); //错误:不是成员函数
class SmallInt{
public:
//int operator int() const; //错误:指定了返回类型
//operator int(int = 0) const; //错误:参数列表不为空
//operator int*(){return 42;} //错误:42不是一个指针
};
  • 如果在类类型和转换类型之间不存在明显的映射关系,则这样的类型转换可能具有误导性

2. 类型转换运算符可能产生意外的结果

1
2
int i = 42;
cin << i ;

该代码使用 istream 的 bool 类型转换运算符将 cin 转换成 bool,而这个 bool 值接着会被提升为 int 并用作内置的左移运算符的左侧运算对象,提升后的 bool 值最终会被左移 42个位置

3. 显式的类型转换运算符

为了防止上述异常发生,c++11 新标准引入了显示的类型转换运算符

1
2
3
4
5
6
7
8
9
class SmallInt{
public:
//编译器不会自动执行这一类型转换
explicit operator int() const {return val;}
};

SmallInt si = 3; //正确:SmallInt 的构造函数不是显式的
//si + 3; //错误:此处需要隐式的类型转换,当类的运算符是显式的
static_cast<int>(si) +3; //正确:显式的请求类型转换

当表达式出现在下列位置时,显式的类型转换将被隐式的执行

  • if、while、do语句的条件部分
  • for 语句头的条件表达式
  • 逻辑非运算符(!)、逻辑或运算符(||)、逻辑与运算符(&&) 的运算对象
  • 条件运算符(? :)的条件表达式

4. 转换为 bool

1
while(std::cin >> value)

向 bool 的类型转换通常用在条件部分,因此 operator bool 一般定义成 explicit 的

14.9.2 避免有二义性的类型转换

通常情况下,不要为类定义相同的类型转换,也不要在类中定义两个及两个以上转换源或转换目标是算数类型的转换

1. 实参匹配相同的类型转换

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//最好不要在两个类之间构建相同的类型转换
struct B;
struct A{
A() = default;
A(const B&); //把一个 B 转换为A
};
struct 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());
A a2 = f(A(b));

2. 二义性与转换目标为内置类型的多重类型转换

  • 当我们使用两个用户定义的类型转换时,如果转换函数之前或之后存在标准类型转换,则标准类型转换将决定最佳匹配是哪个
  • 除了显式地向 bool 类型的转换之外,应该尽量避免定义类型转化函数并尽可能地限制那些”显然正确“的非显式构造函数

3. 重载函数与转换构造函数

  • 如果在调用重载函数时我们需要使用构造函数或者强制类型转换来改变实参的类型,则通常意味着程序的设计存在不足

4. 重载函数与用户定义的类型转换

在调用重载函数时,如果需要额外的标准类型转换,则该转换的级别只有当所有可行函数都请求同一个用户定义的类型转换时才有用。如果所需的用户定义的类型转换不止一个,则该调用具有二义性

14.9.3 函数匹配与重载运算符

  • 表达式中运算符的候选函数集即应该包括成员函数,也应该包括非成员函数
  • 如果我们对一个类即提供了转换目标是算术类型的类型转换,也提供了重载的运算符,则将会得到重载运算符与内置运算符的二义性问题

本博客所有文章除特别声明外,均采用 CC BY-SA 3.0协议 。转载请注明出处!