第04章 C++语言专题(一.07)操作重载与类型转换

声明:仅为个人学习总结,还请批判性查看,如有不同观点,欢迎交流。

摘要

内容主要包括:运算符重载的基本概念、8 种运算符的重载说明、类类型转换及其与重载运算符的关联。


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

1 基本概念

重载的运算符也是函数,只是具有特殊的名字:关键字 operator + 运算符号

  • 参数数量与运算符作用的运算对象数量一样多:
    • 一元运算符有一个参数;
    • 二元运算符有两个参数,左侧运算对象传递给第一个参数,右侧运算对象传递给第二个参数。
  • 如果是成员函数,第一个(左侧)运算对象绑定到隐式 this 指针:
    • (显式)参数数量比运算对象的数量少一个。
  • 除了重载的函数调用运算符 operator() 以外,其他重载运算符不能含有默认实参;
  • 要么是类的成员,要么至少含有一个类类型的参数。
    • 当运算符作用于内置类型的运算对象时,无法改变该运算符的含义。

可以重载大多数(但不是全部)运算符

  • 只能重载已有的运算符,不能发明新的运算符号;
  • 重载运算符的运算对象数量、结合律、优先级与对应的内置运算符保持一致。
可以被重载
1 不建议重载,&&&||
2 输入和输出<<>>
3 算术和关系+2-2*/%&|^<<>>~
==!=<><=>=!
4 赋值=
+=-=*=/=%=&=|=^=<<=>>=
5 下标[]
6 递增和递减++--
7 成员访问*->->*
8 函数调用()
9 类型转换
10 new 和 deletenewnew[]deletedelete[]
不可被重载
::.*.? :

重载运算符调用方式:

// 非成员运算符函数的等价调用:
// 通常情况下,将运算符作用于类型正确的实参,以间接的方式 “调用” 重载的运算符函数
data1 + data2;           // 间接调用,普通的表达式
// 也可以像调用普通函数一样直接调用运算符函数,首先指定函数名字,然后传入数量正确、类型适当的实参
operator+(data1, data2); // 直接调用,等价的函数调用

// 成员运算符函数的等价调用:
// 像调用其他成员函数一样显式地调用成员运算符函数,将 this 绑定到 datal 的地址,将 data2 作为实参
data1 += data2;          // 间接调用
data1.operator+=(data2); // 直接调用

1.1 某些运算符不应该被重载

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

  • &&||, 运算符的重载版本,无法保留运算对象求值顺序规则;
  • &&|| 运算符的重载版本,无法保留内置运算符的短路求值属性;
  • ,&(取地址)运算符用于类类型对象时,C++ 语言已经为其定义了特殊含义。

1.2 与内置类型含义保持一致

如果类的某个操作在逻辑上与运算符相关,可以考虑将其定义为重载的运算符:

  • 如果类执行 IO 操作,则定义移位运算符,使其与内置类型的 IO 保持一致;
  • 如果类的某个操作是检查相等性,则定义 operator==;如果类含有 operator==,通常也应该含有 operator!=
  • 如果类包含一个内在的单序比较操作,则定义 operator<;如果类含有 operator<,通常也应该含有其它关系运算符;
  • 如果类含有算术运算符或位运算符,则最好也提供对应的复合赋值运算符;
  • 重载运算符的返回类型通常应该与其内置版本的返回类型兼容:
    • 逻辑运算符和关系运算符应该返回 bool
    • 算术运算符应该返回一个类类型的值;
    • 赋值运算符和复合赋值运算符应该返回左侧运算对象的一个引用。

1.3 选择作为成员或者非成员

可以参考以下准则,来决定将重载运算符定义为类的成员函数还是普通的非成员函数。

  • 赋值 =、下标 []、函数调用 () 和成员访问箭头 -> 运算符,必须是成员;
  • 复合赋值运算符,一般来说应该是成员,但并非必须;
  • 改变对象状态的运算符,或者与给定类型密切相关的运算符,如递增、递减和解引用运算符,通常应该是成员;
  • iostream 标准库兼容的输入输出运算符,必须是普通的非成员函数;
    • 一般被声明为友元,便于读写类的非公有数据成员。
  • 具有对称性的运算符,可能转换任意一端的运算对象,例如算术、相等性、关系和位运算符等,通常应该是普通的非成员函数:
    • 如果提供含有类对象的混合类型表达式,则运算符必须定义为非成员函数;
    • 如果将运算符定义为成员函数,则左侧运算对象必须是该运算符所属类的一个对象。
string s = "world";

// 如果 + 是 string 类的成员,等价于 s.operator+("!")
string t = s + "!"; // 正确: 能把一个 const char* 加到一个 string 对象中

// 如果 + 是 string 类的成员,等价于 "hi".operator+(s),"hi" 是内置类型 const char*,没有成员函数
// 因为 string 将 + 定义成了普通的非成员函数,
// 所以 等价于 operator+("hi", s),和任何其他函数调用一样,每个实参都能被转换成形参类型
string u = "hi" + s; // 如果 + 是 string 的成员,则产生错误

2 输入和输出运算符

对于内置类型,IO 标准库分别使用 >><< 运算符来实现输入和输出操作;
对于类类型,需要自定义适合其对象的运算符的重载版本,来支持 IO 操作。

2.1 重载输出运算符 <<

输出运算符(operator<<):

  • 第一个形参,通常是一个 非const ostream 对象的引用:
    • 之所以 非const,是因为 向流写入内容会改变其状态;
    • 之所以 是引用,是因为 无法直接复制一个 ostream 对象。
  • 第二个形参,一般是一个 将要打印的类类型的 const 对象的引用:
    • 之所以 是引用,是因为 希望避免复制实参;
    • 之所以 是const,是因为 (通常情况下)打印对象不会改变对象的内容。
  • 一般返回其 ostream 形参,与其他输出运算符保持一致(以支持链式输出)。
// 与 iostream 标准库兼容的输入输出运算符必须是普通的非成员函数
// 否则,它们的左侧运算对象需要是将要打印/读入的类的一个对象
ostream &operator<<(ostream &os, const Sales_data &item)
{
  // 通常,输出运算符应该主要负责打印对象的内容而非控制格式,比如打印换行符(交由用户)
  os << item.isbn() << " " << item.units_sold << " "
     << item.revenue << " " << item.avg_price();
  return os;
}

2.2 重载输入运算符 >>

输入运算符(operator>>):

  • 第一个形参,通常是运算符将要读取的流的引用;
  • 第二个形参,一般是将要保存读入数据的 非const 对象的引用;
  • 一般返回其 istream 形参。
istream &operator>>(istream &is, Sales_data &item)
{
  double price; // no need to initialize; we'll read into price before we use it
  is >> item.bookNo >> item.units_sold >> price;
  if (is) // check that the inputs succeeded
    item.revenue = item.units_sold * price;
  else
    item = Sales_data(); // input failed: give the object the default state
  return is;
}

输入运算符必须处理输入可能失败的情况(输出运算符不需要),可能发生的错误包括:

  • 当流含有错误类型的数据时,读取操作可能失败;
  • 当读取操作到达文件末尾,或者遇到输入流的其他错误时,也会失败;
  • 数据读入成功,但合法性验证失败。
    • 需要设置流的条件状态以标示失败信息。

在发生错误时,输入运算符需要负责从错误中恢复。通过将对象置为合法的状态,能(略微)减轻使用者受到输入错误的影响。

3 算术和关系运算符

3.1 算术运算符

算术运算符通常会对它的两个运算对象进行计算,得到一个新的对象,并返回新对象的副本作为其结果。
如果类定义了算术运算符,它一般也会定义对应的复合赋值运算符,此时,最有效的方式是使用复合赋值来实现算术运算符。

// 具有对称性的运算符,可能转换任意一端的运算对象,通常定义为普通的非成员函数
Sales_data operator+(const Sales_data &lhs, const Sales_data &rhs)
{
  Sales_data sum = lhs; // copy data members from lhs into sum
  sum += rhs;           // add rhs into sum
  return sum;
}

3.2 相等运算符

相等运算符设计准则:

  1. 如果一个类含有判断两个对象是否相等的操作,需要定义 operator==
    • 用户无须再学习并记忆一个全新的函数名字;
    • 更容易使用标准库容器和算法。
  2. 如果类定义了 operator==,则该运算符需要能够判断一组给定对象中是否含有重复数据;
  3. 通常情况下,相等运算符需要具有传递性,即,如果 a==bb==c 都为真,那么 a==c 也应该为真;
  4. 如果类定义了 operator==,那么也需要定义 operator!=
  5. 相等运算符和不相等运算符中的一个,应该把工作委托给另一个:
    • 一个运算符负责实际比较对象的工作,另一个运算符只是调用那个真正工作的运算符。
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);
}

3.3 关系运算符

定义了相等运算符的类也常常(但不总是)定义关系运算符。特别是,关联容器和一些算法需要使用的 operator< 运算符。

通常情况下,关系运算符需要:

  1. 定义顺序关系,使其与关联容器中对关键字的要求一致;
    • 即,如果存在唯一一种逻辑可靠的 < 定义,则应该考虑为这个类定义 < 运算符。
  2. 如果类同时也含有 == 运算符,则定义一种关系使其与 == 保持一致。特别是,如果两个对象是 != 的,则一个对象应该 < 另一个。
    • 即,当且仅当 < 的定义和 == 产生的结果一致时,才定义 < 运算符。

4 赋值运算符

通过拷贝赋值和移动赋值运算符,可以把类的一个对象赋值给"该类"的另一个对象。
通过定义其他赋值运算符,还可以使用"其他类型"作为右侧运算对象。

// 库 vector 的一个赋值运算符,接受一个使用花括号括起来的元素列表
vector<string> v;
v = {"a", "an", "the"};

// 同样,也可以在自定义的 StrVec 类中定义这个运算符
class StrVec
{
public:
  // 赋值运算符,必须是成员函数
  StrVec &operator=(std::initializer_list<std::string>);
  // ...
};

StrVec &StrVec::operator=(initializer_list<string> il)
{
  // 无须检查对象向自身的赋值,因为形参 initializer_list<string> 确保 il 与 this 所指的不是同一个对象
  // alloc_n_copy allocates space and copies elements from the given range
  auto data = alloc_n_copy(il.begin(), il.end());
  free();                // destroy the elements in this object and free the space
  elements = data.first; // update data members to point to the new space
  first_free = cap = data.second;
  return *this;
}

复合赋值运算符不必须是类的成员,但是,建议把包括复合赋值运算符在内的所有赋值运算符都定义在类的内部。

// 作为成员的二元运算符,左侧运算对象绑定到隐式的 this 指针
Sales_data &Sales_data::operator+=(const Sales_data &rhs)
{
  units_sold += rhs.units_sold;
  revenue += rhs.revenue;
  return *this;
}

5 下标运算符

表示容器的类通常可以通过元素在容器中的位置访问元素,这些类一般会定义下标运算符 operator[]

下标运算符通常以所访问元素的引用作为返回值,这样可以使下标出现在赋值运算符的任意一端。
如果一个类包含下标运算符,则它通常会定义两个版本:一个返回普通引用,另一个是类的 const 成员并且返回 const 引用。

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; // pointer to the first element in the array
};

// assume svec is a StrVec
const StrVec cvec = svec; // copy elements from svec into cvec
// if svec has any elements, run the string empty function on the first one
if (svec.size() && svec[0].empty())
{
  svec[0] = "zero"; // ok: subscript returns a reference to a string
  cvec[0] = "Zip";  // error: subscripting cvec returns a reference to const
}

6 递增和递减运算符

在迭代器类中通常会实现递增运算符 ++ 和递减运算符 --,这两种运算符使得类可以在元素的序列中前后移动。C++ 语言并不要求递增和递减运算符必须是类的成员,但是因为它们改变的是所操作对象的状态,所以建议将其设定为成员函数。

定义递增和递减运算符的类应该同时定义前置版本和后置版本。

6.1 定义前置递增/递减运算符

class StrBlobPtr
{
public:
  // increment and decrement
  StrBlobPtr &operator++(); // prefix operators
  StrBlobPtr &operator--();
  // ...
};

// 为了与内置运算符一致,前置运算符应该返回指向递增或递减后对象的引用
StrBlobPtr &StrBlobPtr::operator++()
{
  // if curr already points past the end of the container, can't increment it
  check(curr, "increment past end of StrBlobPtr");
  ++curr; // advance the current state
  return *this;
}

StrBlobPtr &StrBlobPtr::operator--()
{
  // if curr is zero, decrementing it will yield an invalid subscript
  --curr; // move the current state back one element
  check(-1, "decrement past begin of StrBlobPtr");
  return *this;
}

6.2 区分前置与后置运算符

为了与前置版本区分,后置版本接受一个额外的(不被使用的)int 类型的形参。当使用后置运算符时,编译器为这个形参提供一个值为 0 的实参。

class StrBlobPtr
{
public:
  // increment and decrement
  StrBlobPtr operator++(int); // postfix operators
  StrBlobPtr operator--(int);
  // ...
};

// 为了与内置运算符保持一致,后置运算符应该返回对象的原值(递增或递减之前的值)
// 注:返回的形式是一个值,不同于前置版本返回的引用
StrBlobPtr StrBlobPtr::operator++(int)
{
  // no check needed here; the call to prefix increment will do the check
  StrBlobPtr ret = *this; // save the current value
  // 后置运算符调用各自的前置版本来完成实际的工作
  ++*this;                // advance one element; prefix ++ checks the increment
  return ret;             // return the saved state
}

// 因为不会用到 int 形参,所以不需要为它命名
StrBlobPtr StrBlobPtr::operator--(int)
{
  // no check needed here; the call to prefix decrement will do the check
  StrBlobPtr ret = *this; // save the current value
  --*this;                // move backward one element; prefix -- checks the decrement
  return ret;             // return the saved state
}

6.3 显式调用递增/递减运算符

StrBlobPtr p(a1); // p 指向 a1 中的 vector
p.operator++(0);  // 调用后置版本的 operator++,编译器通过传入的值判断
p.operator++();   // 调用前置版本的 operator++

7 成员访问运算符

在迭代器类及智能指针类中,常常用到解引用运算符 * 和箭头运算符 ->

class StrBlobPtr
{
public:
  // 解引用运算符并不必须是类的成员,但通常定义为类的成员
  // 返回非常量的引用
  std::string &operator*() const // 定义为 const 成员
  {
    auto p = check(curr, "dereference past end");
    return (*p)[curr]; // (*p) 是对象所指的 vector
  }
  // 箭头运算符必须是类的成员
  // 返回非常量的指针
  std::string *operator->() const // 定义为 const 成员
  {
    // 箭头运算符调用解引用运算符,返回解引用结果元素的地址
    return &this->operator*();
  }
  // ...
};

StrBlob a1 = {"hi", "bye", "now"};
StrBlobPtr p(a1);            // p points to the vector inside a1
*p = "okay";                 // assigns to the first element in a1
cout << p->size() << endl;   // prints 4, the size of the first element in a1
cout << (*p).size() << endl; // equivalent to p->size()

对箭头运算符返回值的约束

和大多数其他运算符一样,可以让 operator* 完成任何指定的操作,尽管这么做不太好。但对于 operator->,则不同:

  • 箭头运算符只能用于访问成员;
  • 当重载箭头运算符时,可以改变的只是箭头从哪个对象当中获取成员,并且:
    • 要么返回一个指向类的对象的指针;
    • 要么返回一个自定义了箭头运算符的类的对象。

对于形如 point->mem 的表达式来说,point 必须是指向类对象的指针,或者是一个重载了 operator-> 的类的对象。根据 point 类型的不同,point->mem 分别等价于:

(*point).mem;            // point 是一个内置的指针类型
point.operator->()->mem; // point 是类的一个对象

除上述形式以外,代码都将发生错误。point->mem 的执行过程如下所示:

  1. 如果 point 是指针,则应用内置的箭头运算符,表达式等价于 (*pont).mem
    • 首先解引用该指针,然后从得到的对象中获取指定的成员;
    • 如果 point 所指的类型没有名为 mem 的成员,程序会发生错误。
  2. 如果 point 是定义了 operator-> 的类的一个对象,则使用 point.operator->() 的结果来获取 mem
    • 如果该结果是一个指针,则执行第 1 步;
    • 如果该结果本身含有重载的 operator->(),则重复调用当前步骤;
    • 最终,当这一过程结束时,程序或者返回了所需的内容,或者返回一些表示程序错误的信息。

8 函数调用运算符

如果类重载了函数调用运算符 operator(),则可以像使用函数一样使用该类的对象,这样的对象被称作函数对象(function object)。

struct absInt
{
  // 函数调用运算符必须是成员函数,一个类可以重载定义多个不同版本的调用运算符
  // 函数调用运算符示例:接受一个 int 类型的实参,返回该实参的绝对值
  int operator()(int val) const {
    return val < 0 ? -val : val;
  }
};

int i = -42;
absInt absObj;      // object that has a function-call operator
int ui = absObj(i); // passes i to absObj.operator()
// 即使 absObj 只是一个对象而非函数,也能 “调用” 该对象,即运行重载的调用运算符

函数对象类通常含有一些数据成员,这些成员可以被用于定制调用运算符中的操作。

// 定义一个打印 string 实参内容的类
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; // stream on which to write
  char sep;    // character to print after each output
};

PrintString printer; // uses the defaults; prints to cout
printer(s);          // prints s followed by a space on cout
PrintString errors(cerr, '\n');
errors(s); // prints s followed by a newline on cerr

// 函数对象常常作为泛型算法的实参
// 例如,使用标准库 for_each 算法和 PrintString 类来打印容器的内容
for_each(vs.begin(), vs.end(), PrintString(cerr, '\n'));

8.1 lambda 是函数对象

当编写了一个 lambda 表达式后,编译器将该表达式翻译成一个未命名类的未命名对象。在 lambda 表达式产生的类中含有一个重载的函数调用运算符。

// 对于传递给 stable_sort 作为其最后一个实参的 lambda 表达式来说
stable_sort(words.begin(), words.end(),
            [](const string &a, const string &b) { return a.size() < b.size(); });

// 其行为类似于下面这个类的一个未命名对象
class ShorterString
{
public:
  // 默认情况下,lambda 不能改变它捕获的变量,由 lambda 产生的类中的函数调用运算符是一个 const 成员
  // 如果 lambda 被声明为可变的,则调用运算符就不是 const
  bool operator()(const string &s1, const string &s2) const { return s1.size() < s2.size(); }
};

// 将 ShorterString 对象作为调用 stable_sort 的实参,类似于使用 lambda 表达式的程序
stable_sort(words.begin(), words.end(), ShorterString());
  • 当一个 lambda 表达式通过引用捕获变量时:
    • 将由程序负责确保 lambda 执行时所引的对象确实存在;
    • 编译器可以直接使用该引用,无须在 lambda 产生的类中将其存储为数据成员。
  • 当一个 lambda 表达式通过值捕获变量时:
    • 捕获的变量被拷贝到 lambda 中;
    • 这种 lambda 产生的类,必须为每个值捕获的变量建立对应的数据成员,同时创建构造函数,令其使用捕获的变量的值来初始化数据成员。
// 获得第一个指向满足条件元素的迭代器,该元素满足 size() >= sz
auto wc = find_if(words.begin(), words.end(),
                  [sz](const string &a) { return a.size() >= sz; });

// 该 lambda 表达式产生的类示例:
class SizeComp
{
public:
  SizeComp(size_t n) : sz(n) {} // 该形参对应捕获的变量
  // 该调用运算符的返回类型、形参和函数体都与 lambda 一致
  bool operator()(const string &s) const { return s.size() >= sz; }
private:
  size_t sz; // 该数据成员对应通过值捕获的变量
};

// 获得第一个指向满足条件元素的迭代器,该元素满足 size() >= sz
auto wc2 = find_if(words.begin(), words.end(), SizeComp(sz));

lambda 表达式产生的类不含默认构造函数、赋值运算符以及默认析构函数;是否含有默认的拷贝/移动构造函数则通常要视捕获的数据成员类型而定。

8.2 标准库定义的函数对象

标准库定义了一组表示算术运算符、关系运算符和逻辑运算符的类。每个类都定义了一个执行命名操作的调用运算符。这些类都被定义成模板的形式,可以为其指定具体的应用类型,即调用运算符的形参类型。

plus<int> intAdd;      // function object that can add two int values
negate<int> intNegate; // function object that can negate an int value
// uses intAdd::operator(int, int) to add 10 and 20
int sum = intAdd(10, 20);        // equivalent to sum = 30
sum = intNegate(intAdd(10, 20)); // equivalent to sum = -30
// uses intNegate::operator(int) to generate -10 as the second parameter
// to intAdd::operator(int, int)
sum = intAdd(10, intNegate(10)); // sum = 0

标准库函数对象(定义在 functional 头文件中):

算术关系逻辑
plus<Type>equal_to<Type>logical_and<Type>
minus<Type>not_equal_to<Type>logical_or<Type>
multiplies<Type>greater<Type>logical_not<Type>
divides<Type>greater_equal<Type>
modulus<Type>less<Type>
negate<Type>less_equal<Type>

表示运算符的函数对象类常用来替换算法中的默认运算符。

// 在默认情况下,排序算法使用 operator< 将序列按照升序排列
sort(svec.begin(), svec.end());

// 传入一个临时的函数对象用于执行两个 string 对象的 > 比较运算,将序列按照降序排列
sort(svec.begin(), svec.end(), greater<string>());

// 标准库规定其函数对象对于指针同样适用
vector<string *> nameTable; // 指针的 vector
// 错误: nameTable 中的指针彼此之间没有关系,所以直接使用 < 将产生未定义的行为(?)
sort(nameTable.begin(), nameTable.end(),
      [](string *a, string *b) { return a < b; });
// 正确: 标准库规定指针的 less 是定义良好的
sort(nameTable.begin(), nameTable.end(), less<string *>());

// 关联容器使用 less<ket_type> 对元素排序,
// 因此,可以定义一个指针的 set 或者在 map 中使用指针作为关键值,而无须直接声明 less

8.3 可调用对象与 function(C++11)

C++ 语言中有几种可调用的对象:函数、函数指针、lambda 表达式、bind 创建的对象、重载了函数调用运算符的类。
和其他对象一样,可调用的对象也有类型。例如,每个 lambda 有它自己唯一的(未命名 )类类型;函数及函数指针的类型则由其返回值类型和实参类型决定。

不同类型的可调用对象可能共享同一种调用形式(call signature):

  • 调用形式指明了:调用返回的类型、传递给调用的实参类型;
  • 一种调用形式对应一个函数类型(function type)。
// 以下是不同类型的可调用对象,分别对其参数执行了不同的算术运算
// 但是共享同一种调用形式/函数类型:int(int, int),即接受两个 int、返回一个 int

// 普通函数
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 divisor) {
    return denominator / divisor;
  }
};

对于共享同一种调用形式的几个可调用对象,有时会需要定义一个函数表(function table),用于存储这些可调用对象的 function 类型的对象。这样,当程序需要执行某个特定的操作时,可以从表中查找该调用的函数。

8.3.1 标准库 function 类型

function 是一个模板,当创建一个具体的 function 类型时,必须提供该 function 类型能够表示的对象的调用形式。

function 的操作(定义在 functional 头文件中):

操作说明
function<T> ff 是一个用来存储可调用对象的空 function,这些可调用对象的调用形式应该与函数类型 T 相同(即 TretType(args)
function<T> f(nullptr)显式地构造一个空 function
function<T> f(obj)f 中存储可调用对象 obj 的副本
ff 作为条件:当 f 含有一个可调用对象时为真,否则为假
f(args)调用 f 中的对象,参数是 args

定义为 function<T> 的成员的类型:

类型说明
result_typefunction 类型的可调用对象返回的类型
argument_type
first_argument_type
second_argument_type
T 有一个或两个实参时定义的类型。如果 T 只有一个实参,则 argument_type 是该类型的同义词; 如果 T 有两个实参,则 first_argument_typesecond_argument_type 分别代表两个实参的类型
// 列举可调用对象与二元运算符对应关系的表格:
// 所有可调用对象都必须接受两个 int、返回一个 int
// 其中的元素可以是函数指针、函数对象或者 lambda
map<string, function<int(int, int)>> binops = {
    {"+", add},               // 函数指针
    {"-", std::minus<int>()}, // 标准库函数对象
    {"/", divide()},          // 用户定义的函数对象
    {"*", [](int i, int j) { return i * j; }}, // 未命名的 lambda
    {"%", mod}};              // 命名了的 lambda 对象

// 索引 map 时将得到关联值的一个引用,即 function 对象引的用
// function 类型重载了调用运算符,该运算符接受它自己的实参,然后将其传递给保存的可调用对象
binops["+"](10, 5); // calls add(10, 5)
binops["-"](10, 5); // uses the call operator of the minus<int> object
binops["/"](10, 5); // uses the call operator of the div object
binops["*"](10, 5); // calls the lambda function object
binops["%"](10, 5); // calls the lambda function object

8.3.2 重载的函数与 function

将重载函数存入 function 类型的对象中,需要:

  • 存储函数指针,而非函数的名字;
  • 使用 lambda。
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;

// 不能(直接)将重载函数的名字存入 function 类型的对象中,存在二义性问题
binops.insert({"+", add}); // 错误:哪个 add ?

// 方式1:存储函数指针,而非函数的名字,来消除二义性
int (*fp)(int, int) = add; // 指针所指的 add 是接受两个 int 的版本
binops.insert({"+", fp});  // 正确:fp 指向一个正确的 add 版本

// 方式2:使用 lambda 也可以消除二义性
// 正确:使用 lambda 来指定希望使用的 add 版本
binops.insert({"+", [](int a, int b) { return add(a, b); }});

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

和内置类型的转换一样,类类型转换(class-type conversions,也称作 用户定义的类型转换 user-defined conversions),可以将一种类型的对象转换成另一种所需类型的对象。定义方式包括:

9.1 类型转换运算符

类型转换运算符(conversion operator),是类的一种特殊成员函数,负责将一个类类型的值转换成其他类型。其一般形式为:operator type() const;type 为某种目标类型)

  • 可以面向任意类型(除 void)进行定义,只要该类型能作为函数的返回类型;
    • 也就是,不允许转换成数组或者函数类型,但允许转换成指针或者引用类型。
  • 不能声明返回类型(函数会返回一个转换类型的值);
  • 形参列表必须为空(隐式执行时,无法给函数传递实参);
  • 必须定义成类的成员函数;
  • 一般被定义成 const 成员(通常不应该改变待转换对象的内容)。

9.1.1 含有类型转换的类

class SmallInt
{
public:
  // 转换构造函数,定义了向类类型的转换:将算术类型的值转换成 SmallInt 对象
  SmallInt(int i = 0) : val(i) {
    if (i < 0 || i > 255)
      throw std::out_of_range("Bad SmallInt value");
  }
  // 类型转换运算符,定义了从类类型向其他类型的转换:将 SmallInt 对象转换成 int 值
  operator int() const { return val; }

private:
  std::size_t val;
};

int main()
{
  SmallInt si;
  si = 4;         // 首先将 4 隐式地转换成 SmallInt,然后调用 SmallInt::operator=
  int i = si + 3; // 首先将 si 隐式地转换成 int,然后执行整数的加法

  // 尽管编译器一次只能执行一个用户定义的类型转换,
  // 但是隐式的用户定义类型转换可以置于一个标准(内置)类型转换之前或之后,并与其一起使用。

  // 因此,可以将任何算术类型传递给 SmallInt 的构造函数。
  // the double argument is converted to int using the built-in conversion
  SmallInt si2 = 3.14; // calls the SmallInt(int) constructor

  // 也可以使用类型转换运算符将 SmallInt 对象转换为 int,然后再将所得的 int 转换成任何其他算术类型。
  // the SmallInt conversion operator converts si to int;
  double d = si2 + 3.14; // that int is converted to double using the built-in conversion

  return 0;
}

9.1.2 可能产生意外结果

在实践中,类很少提供类型转换运算符:

  • 在大多数情况下,如果类型转换自动发生,用户可能会感到比较意外,而不是感觉受到了帮助。

但是,对于类来说,定义向 bool 的类型转换比较普遍:

  • 可以直接用在各种条件表达式中;
  • 但是,在 C++ 标准的早期版本中,如果类定义一个向 bool 的类型转换,那么它常常遇到一个问题:因为 bool 是一种算术类型,所以类类型的对象转换成 bool 后,就能被用在任何需要算术类型的上下文中。这样的类型转换可能引发意想不到的结果,例如:
// istream 本身没有定义 <<,所以下面的代码本来应该产生错误。
// 但是,当 istream 含有向 bool 的类型转换时,该代码能将 cin 转换为 bool,
// 然后,这个 bool 值会被提升为 int,并用作内置的左移运算符的左侧运算对象。

int i = 42;
cin << i; // this code would be legal if the conversion to bool were not explicit!
          // the promoted bool value (either 1 or 0) would be shifted left 42 positions.

9.1.3 显式类型转换(C++ 11)

显式的类型转换运算符(explicit conversion operator),和显式的构造函数一样,编译器(通常)不会将一个显式的类型转换运算符用于隐式类型转换。

class SmallInt
{
public:
  // 编译器不会自动执行这一类型转换
  explicit operator int() const { return val; }

  // 向 bool 的类型转换通常用在条件部分,因此 operator bool 一般应定义成 explicit 的
  explicit operator bool() const { return val; }

  // 为避免二义性,通常不要在类中定义多个转换源或转换目标是算术类型的转换 ↑

  SmallInt(int i = 0) : val(i) {
    if (i < 0 || i > 255)
      throw std::out_of_range("Bad SmallInt value");
  }

private:
  std::size_t val;
};

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

if (si) {                 // 正确:当类型转换被用作条件时,会隐式地执行类型转换
  cout << "implicit conversion is applied" << endl;
}

当类型转换运算符是 explicit 时:

  • 通常,必须通过显式的强制类型转换,才能执行类型转换;
  • 但是,当类型转换表达式被用作条件时,即出现在下列位置时,编译器会隐式地执行类型转换:
    • ifwhiledo 语句的条件部分;
    • for 语句头的条件表达式;
    • 逻辑非运算符 !、或运算符 ||、与运算符 && 的运算对象;
    • 条件运算符 ? : 的条件表达式。

在 C++11 标准下,IO 标准库通过定义一个向 bool 类型的 explicit 转换,避免上面提到的问题。
无论什么时候在条件中使用流对象,都会使用为 IO 类型定义的 operator bool,例如:

while (std::cin >> value)

// while 语句的条件执行输入运算符,它负责将数据读入到 value 并返回 cin。
// 为了对条件求值,cin 被 istream operator bool 类型转换函数隐式地执行了转换。
// 如果 cin 的条件状态是 good,则该函数返回为真,否则该函数返回为假。

9.2 避免二义性类型转换

如果类中包含一个或多个类型转换,必须确保从类类型转换为目标类型只存在唯一一种转换方式,否则编写的代码将很可能会具有二义性。

在两种情况下可能产生多重转换路径:

  • 两个类提供相同的类型转换:

    • 例如,对于类 A 和类 B, 类 A 存在转换构造函数 A(B),类 B 存在转换运算符 operator A()
  • 类定义了多个转换规则,而这些转换涉及的类型本身,可以通过其他类型转换联系在一起:

    • 例如,多个算术运算符、算术类型转换运算符涉及的算术类型本身可以进行转换。

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

9.2.1 相同的类型转换

// 最好不要在两个类之间构建相同的类型转换,例如:
// 定义两种将 B 转换成 A 的方法:一种使用 B 的类型转换运算符、另一种使用 A 的以 B 为参数的构造函数
struct B;
struct A {
  A() = default;
  A(const B &); // converts a B to an A
  // ...
};
struct B {
  operator A() const; // also converts a B to an 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()); // ok: use B's conversion operator
A a2 = f(A(b));           // ok: use A's constructor

9.2.2 多重类型转换

如果类定义了一组类型转换,它们的转换源(或者转换目标)类型本身可以通过其他类型转换联系在一起,则同样会产生二义性的问题。例如,类当中定义了多个参数都是算术类型的构造函数,或者转换目标都是算术类型的类型转换运算符。

struct A
{
  A(int = 0); // 最好不要创建多个转换源都是算术类型的类型转换
  A(double);
  operator int() const; // 最好不要创建多个转换对象都是算术类型的类型转换
  operator double() const;
  // ...
};

void f2(long double);
A a;
f2(a); // 二义性错误:含义是 f(A::operator int()),还是 f(A::operator double())

long lg;
A a2(lg); // 二义性错误:含义是 A::A(int),还是 A::A(double)

// 当使用用户定义的类型转换时,如果转换过程包含标准类型转换,
// 那么,标准类型转换的级别将决定编译器选择最佳匹配的过程
short s = 42;
// promoting short to int is better than converting short to double
A a3(s); // uses A::A(int)

调用 f2 及初始化 a2 的过程之所以会产生二义性,根本原因是它们所需的标准类型转换级别一致。复习回顾:C++ 语言专题(一.06)函数重载 => 3.3 寻找最佳匹配

提示:类的重载运算符、转换构造函数、类型转换函数,设计经验规则:

  • 不要令两个类执行相同的类型转换:
    • 如果 Foo 类有一个接受 Bar 类对象的构造函数,则不要在 Bar 类中再定义转换目标是 Foo 类的类型转换运算符。
  • 避免转换目标是内置算术类型的类型转换。特别是在已经定义了一个转换成算术类型的类型转换时,接下来:
    • 不要再定义接受算术类型的重载运算符。否则,当用户使用这样的运算符时,可能会导致类类型的对象被转换为算术类型,然后使用内置的运算符。
    • 不要再定义转换到其他算术类型的类型转换。让标准类型转换完成向其他算术类型转换的工作。

一言以蔽之:除了向 bool 类型的 explicit 转换之外,应该尽量避免定义类型转换函数,并尽可能地限制那些“显然正确”的非 explicit 构造函数。

9.2.3 重载函数与转换构造函数

在调用重载的函数时,如果两个或多个类型转换都提供了同一种可行匹配,则这些类型转换具有相同的转换级别。
例如,当几个重载函数的参数分属不同的类类型时,如果这些类恰好定义了同样的转换构造函数,那么就可能产生二义性问题。

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)); // 正确:调用 manip(const C&)

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

9.2.4 重载函数与(类)类型转换

在调用重载函数时,如果两个(或多个)用户定义的类型转换都提供了可行匹配:

  • 则这些类型转换具有相同的转换级别,即该调用具有二义性(同上节 9.2.3)。
  • 在这个过程中,不会考虑任何可能出现的标准类型转换的级别。

只有当所有可行函数都请求同一个用户定义的类型转换时:

  • 才会考虑其中可能出现的标准类型转换的级别。
struct C {
  C(int);
  // ...
};
struct E {
  E(double);
  // ...
};
void manip2(const C &); // C 有一个转换源为 int 的类型转换
void manip2(const E &); // E 有一个转换源为 double 的类型转换

// 二义性错误:两个不同的用户定义的类型转换都能用在此处
// 即便其中一个转换调用 E(double(10)) 需要额外的标准类型转换,而另一个调用 C(10) 能精确匹配
manip2(10); // 含义是 manip2(C(10)),还是 manip2(E(double(10)))

9.3 函数匹配与重载运算符

重载的运算符也是重载的函数:

  • 通用的函数匹配规则同样适用于判断:在给定的表达式中,应该使用内置运算符,还是使用重载的运算符。复习回顾:C++ 语言专题(一.06)函数重载 => 3 调用重载的函数

  • 当重载运算符函数出现在表达式中时,候选函数集的规模要比使用调用运算符调用函数时更大。例如:

// 对于重载运算符,不能通过调用的形式来区分当前调用的是成员函数还是非成员函数
// 如果 a 是一种类类型,则表达式 a sym b 可能如下,

a.operatorsym(b);  // a 有一个 operatorsym 成员函数
operatorsym(a, b); // operatorsym 是一个普通函数

// 在调用一个命名的函数时,具有该名字的成员函数和非成员函数不会彼此重载。
// 当通过类类型的对象(或者该对象的指针及引用)进行函数调用时,只需考虑该类的成员函数。

当使用重载运算符作用于类类型的运算对象时,候选函数中包含该运算符的:

  • 普通非成员版本
  • 内置版本
  • 定义在类中的重载版本(如果左侧运算对象是类类型)

警告 :对于同一个类,如果既提供了转换目标是算术类型的类型转换,又提供了重载的运算符,则可能会导致重载运算符和内置运算符之间的二义性问题。

class SmallInt
{
  friend SmallInt operator+(const SmallInt &, const SmallInt &);

public:
  SmallInt(int = 0);                   // 转换源为 int 的类型转换
  operator int() const { return val; } // 转换目标为 int 的类型转换
private:
  std::size_t val;
};

SmallInt s1, s2;
SmallInt s3 = s1 + s2; // 使用重载的 operator+

// 可以把 0 转换成 SmallInt,然后使用 SmallInt 的 +
// 也可以把 s3 转换成 int,然后使用内置的加法运算
int i = s3 + 0; // 二义性错误

10 重载 newdelete

某些应用程序需要自定义内存分配的细节,比如将对象放置在特定的内存空间中。
为了实现这一目的,应用程序需要重载 new 运算符和 delete 运算符,以控制内存分配的过程。

10.1 newdelete 的工作机理

string *sp = new string("a value"); // 分配并初始化一个 string 对象
string *arr = new string[10];       // 分配 10 个默认初始化的 string 对象

一条 new 表达式,实际执行了三步操作:

  1. 调用一个名为 operator new (或者 operator new[] )的标准库函数,分配一块足够大的、原始的、未命名的内存空间,以便存储特定类型的对象(或者对象的数组);
  2. 在得到的内存空间中,运行相应的构造函数以构造这些对象,并为其传入初始值;
  3. 对象被分配空间并构造完成,返回一个指向该对象的指针。
delete sp;    // 销毁 *sp,然后释放 sp 指向的内存空间
delete[] arr; // 销毁数组中的元素,然后释放对应的内存空间

一条 delete 表达式,实际执行了两步操作:

  1. sp 所指的对象或 arr 所指的数组中的元素,执行对应的析构函数;
  2. 调用名为 operator delete(或者 operator delete[])的标准库函数,释放内存空间。

实际上,应用程序根本无法自定义 new 表达式或 delete 表达式的行为。只能通过自定义 operator new 函数和 operator delete 函数,改变上述步骤中内存分配和释放的方式。

10.2 自定义 operator newoperator delete

即使在标准库中已经存在 operator new 函数和 operator delete 函数的定义,应用程序仍然可以定义自己的版本,可以定义在全局作用域中,也可以定义为类的成员函数。

当编译器发现一条 new 表达式或 delete 表达式后,将在程序中查找可供调用的 operator 函数:

  1. 如果被分配(释放)的对象是类类型,则编译器首先在类及其基类的作用域中查找:
    • 如果该类含有 operator new 成员或 operator delete 成员,则相应的表达式将调用这些成员;
    • 可以使用作用域运算符,通过 ::new::delete 限定只在全局作用域中查找,从而忽略定义在类中的函数。
  2. 否则,编译器在全局作用域查找匹配的函数:
    • 如果找到了自定义的版本,则将使用该版本。
  3. 如果没有找到自定义的版本,编译器将使用标准库定义的版本。

10.2.1 operator newoperator delete 接口

标准库定义了 operator new 函数和 operator delete 函数的多个重载版本。

void *operator new(size_t);              // 分配一个对象
void *operator new[](size_t);            // 分配一个数组
// 与析构函数类似,operator delete 也不允许抛出异常
void operator delete(void *) noexcept;   // 释放一个对象
void operator delete[](void *) noexcept; // 释放一个数组

void *operator new(size_t, nothrow_t &) noexcept;
void *operator new[](size_t, nothrow_t &) noexcept;
void operator delete(void *, nothrow_t &) noexcept;
void operator delete[](void *, nothrow_t &) noexcept;

应用程序可以自定义上面函数版本中的任意一个:

  • 自定义的版本必须位于全局作用域或者类作用域;

  • 当定义为类的成员时,它们是隐式静态的,无须显式地声明 static。因为 operator new 用在对象构造之前,而 operator delete 用在对象销毁之后。它们也不能操作类的任何数据成员。

  • 如果自定义 operator new 函数,可以为它提供额外的形参:

    • 用到这些自定义函数的 new 表达式必须使用 new 的定位形式将实参传给新增的形参;
    • 尽管在一般情况下可以自定义具有任何形参的 operator new,但是下面的函数形式只供标准库使用,不能够被用户重载:
void *operator new(size_t, void *); // 不允许重新定义这个版本
  • operator deleteoperator delete[] 定义成类成员时,该函数可以包含另外一个类型为 size_t 的形参,其初始值是第一个形参所指对象的字节数。

10.2.2 mallocfree 函数

在自定义 operator newoperator delete 时,这两个函数必须以某种方式执行分配内存与释放内存的操作。为此,可以使用 C++ 从 C 语言中继承的 malocfree 的函数:

void *operator new(size_t size)
{
  if (void *mem = malloc(size))
    return mem;
  else
    throw bad_alloc();
}

void operator delete(void *mem) noexcept { free(mem); }

10.3 定位 new 表达式

在 C++ 的早期版本中,a11ocator 类还不是标准库的部分。应用程序如果想把内存分配与初始化分离开,需要调用 operator newoperator delete 来分配和释放内存空间,使用 new定位 new 形式来构造对象,并显式调用析构函数来销毁对象。

10.3.1 只传一个指针实参的定位 new

new 的 定位 new(placement new)形式为分配函数提供了额外的信息。可以使用定位 new 传递一个地址,此时定位 new 的形式如下方代码所示,其中:

  • place_address,必须是一个指针;
    • 指针不必须指向 operator new 分配的内存,甚至不需要指向动态内存。
  • initializers,提供一个(可能为空的)以逗号分隔的初始值列表,用于构造新分配的对象。
new (place_address) type
new (place_address) type (initializers)
new (place_address) type [size]
new (place_address) type [size] { braced initializer list }

当仅通过一个地址值调用时:

  • 首先,定位 new 使用 operator new(size_t, void*) “分配” 它的内存:
    • 这是一个无法自定义的 operator new 版本;
    • 该函数不分配任何内存,它只是简单地返回指针实参。
  • 然后,由 new 表达式负责在指定的地址初始化对象并完成后续工作。

也就是,定位 new 允许应用程序在一个特定的、预先分配的内存地址上构造对象。

10.3.2 显式的析构函数调用

调用析构函数会销毁指定的对象,但是不会释放该对象所在的内存空间。如果需要的话,可以重新使用该空间。

// 可以通过对象、对象的指针或引用调用析构函数:
string *sp = new string("a value"); // 分配并初始化一个 string 对象
sp->~string();                      // 直接调用析构函数销毁对象

参考

  1. [美] Stanley B.Lippman著.C++ Primer 中文版(第5版).电子工业出版社.2013.
  2. [美] Stephen Prata著.C++ Primer Plus(第6版)中文版.人民邮电出版社.2012.

宁静以致远,感谢 Vico 老师。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值