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

基本概念

重载的运算符是具有特殊名字的函数:它们的名字由关键字 operator 和其后要定义的运算符号共同组成。和其他函数一样,重载的运算符也包含返回类型、参数列表以及函数体。

重载运算符函数的参数数量与该运算符作用的运算对象数量一样多。

当一个重载的运算符是成员函数时,this 绑定到左侧运算对象。成员运算符函数的(显式)参数数量比运算符的数量少一个

对于一个运算符函数来说,它或者是类的成员,或者至少含有一个类类型的参数:

	// 错误:不能为 int 重定义内置的运算符
	int operator + (int, int);
  • 我们可以重载大多数(但不是全部)运算符。
  • 我们只能重载已有的运算符
  • 有四个符号(+、-、*、&)既是一元运算符也是二元运算符,所有这些运算符都能被重载,从参数的数量我们可以推断到底定义的是哪种运算符。
  • 对于一个重载的运算符来说,其优先级和结合律与其对应的内置运算符保持一致。不考虑运算对象类型的话,x == y + z 永远等价于 x == (y + z)
可以被重载的运算符
+	-	*	/	%	^
&	|	~	!	,	=
<	>	<=	>=	++	--
<<	>>	==	!=	&&	||
+=	-=	/=	%=	^=	&=
|=	*=	<<=	>>=	[]	()
->	->*	new	new[]	delete	delete[]
不能被重载的运算符
::	.*	.	?:

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

通常情况下,我们将运算符作用于类型正确的实参,从而以这种间接方式“调用”重载的运算符函数。然而,我们也能像调用普通函数一样直接调用运算符函数,先指定函数名字,然后传入数量正确、类型适当的实参:

	// 一个非成员运算符函数的等价调用
	data1 + data2; // 普通的表达式
	operator + (data1, data2); // 等价的函数调用

我们像调用其他成员函数一样显式地调用成员运算符函数

	data1 += data2; // 基于“调用”的表达式
	data1.operator += (data2); // 对成员运算符函数的等价调用

这两条语句都调用了成员函数 operator +=,将 this 绑定到 data1 的地址、将 data2 作为实参传入了函数。

某些运算符不应该被重载

因为使用重载的运算符本质上是一次函数调用,所以这些关于运算对象求值顺序的规则无法应用到重载的运算符上。
通常情况下,不应该重载逗号、取地址、逻辑与和逻辑或运算符

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

当你开始设计一个类时,首先应该考虑的是这个类将提供哪些操作。在确定类需要哪些操作之后,才能思考到底应该把每个类操作设成普通函数还是重载的运算符。如果某些操作在逻辑上与运算符相关,则它们适合于定义成重载的运算符:

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

赋值和复合赋值运算符

赋值运算符的行为与复合版本的类似:赋值之后,左侧运算对象和右侧运算对象的值相等,并且运算符应该返回它左侧运算对象的一个引用。重载的赋值运算应该继承而非违背其内置版本的含义。

如果类含有算术运算符或者位运算符,则最好也提供对应的复合赋值运算符。无须赘言,+= 运算符的行为显然应该与其内置版本一致,即先执行 +,再执行 =。

选择作为成员或者非成员

下面的准则有卒于我们在将运算符定义为陈冠函数还是普通的非成员函数做出抉择:

  • 赋值(=)、下标([])、调用(())和成员范文箭头(->)运算符必须是成员。
  • 复合赋值运算符一般来说应该是成员,但并非必须,这一点与赋值运算符略有不同。
  • 改变对象状态的运算符或者与给定类型密切相关的运算符,如递增、递减和解引用运算符,通常应该是成员。
  • 具有对称性的运算符可能转换任意一端的运算对象,例如算术、相等性、关系和位运算等,因此它们通常应该是普通的非成员函数。

输入和输出运算符

重载输出运算符 <<

通常情况下,输出运算符的第一个形参是一个非常量 ostream 对象的引用。之所以 ostream 是非常量是因为向流写入内容会改变其状态;而该形参是引用是因为我们无法直接复制一个 ostream 对象。

第二个形参一般来说是一个常量的引用,该常量是我们想要打印的类类型。第二个形参是引用的原因是我们希望避免复制实参;而之所以该形参可以是常量是因为(通常情况下)打印对象不会改变对象的内容。

为了与其他输出运算符保持一致,operator << 一般要返回它的 ostream 形参。

Sales_data 的输出运算符

std::ostream &operator << (std::ostream& os, const Sales_data &rhs) {
    os << rhs.isbn() << ' ' << rhs.units_sold << ' ' << rhs.revenue << ' ' << rhs.avg_price();
    return os;
}

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

通常,输出运算符应该主要负责打印对象的内容而非控制格式,输出运算符不应该打印换行符

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

与 iostream 标准库兼容的输入输出运算符必须是普通的非成员函数,而不能是类的成员函数。否则,它们的左侧运算对象将是我们的类的一个对象:

	Sales_data data; 
	data << cout; // 如果 operator << 是 Sales_data 的成员

重载输入运算符 >>

通常情况下,输入运算符的第一个形参是运算符将要读取的流的引用,第二个形参是将要读入到的(非常量)对象的引用。该运算符通常会返回某个给定流的引用。第二个形参之所以必须是个非常量是因为输入运算符本身的目的就是将数据读到这个对象中。

Sales_data 的输入运算符

std::istream &operator >> (std::istream& is, Sales_data &rhs) {
    double price;
    is >> rhs.bookNo >> rhs.units_sold >> price;
    if (is)
        rhs.revenue = rhs.units_sold * price;
    else
        rhs = Sales_data();
    return is;
}

输入运算符必须处理输入可能失败的情况,而输出运算符不需要

输入时的错误

当读取操作发生错误时,输入运算符应该负责从错误中恢复

标示错误

通常情况下,输入运算符只设置 failbit。除此之外,设置 eofbit 表示文件耗尽,而设置 badbit 表示流被破坏。最好的方式是由 IO 标准库自己来标示这些错误。

算术和关系运算符

如果类定义了算术运算符,则它一般也会定义一个对应的复合赋值运算符。此时,最有效的方式是使用复合赋值来定义算术运算符。

// 等价于原来的 add 函数。
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 &&
           lhs.revenue == rhs.revenue;
}

bool operator != (const Sales_data &lhs, const Sales_data &rhs) {
    return !(lhs == rhs);
}

从这些函数中体现出来的设计准则

  • 如果一个类含有判断两个对象是否相等的操作,则它显然应该把函数定义成 operator== 而非一个普通的命名函数:因为用户肯定希望能使用 == 比较对象,所以提供了 == 就意味着用户无需再费时费力地学习并记忆一个全新的函数名字。此外,类定义了 == 运算符之后也更容易使用标准库容器和算法。
  • 如果类定义了 operator==,则该运算符应该能判断一组给定的对象中是否含有重复数据。
  • 通常情况下,相等运算符应该具有传递性,换句话说,如果 a == b 和 b == c 都为真,则 a == c 也应该为真。
  • 如果类定义了 operator==,则这个类也应该定义 operator !=。对于用户来说,当他们能使用 == 时肯定也希望能使用 != ,反之亦然。
  • 相等运算符和不相等运算符中的一个应该把工作委托给另外一个,这意味着其中一个运算符应该负责实际比较对象的工作,而另一个运算符则只是调用那个真正工作的运算符。
    如果某个类在逻辑上有相等性的含义,则该类应该定义 operator==,这样做可以使用户更容易使用标准库算法来处理这个类。

关系运算符

通常情况下关系运算符应该

  • 定义顺序关系,令其与关联容器中对关键字的要求一致;并且
  • 如果类同时也含有 == 运算符的话,则定义一种关系令其与 == 保持一致。特别是,如果两个对象是 != 的,那么一个对象应该 < 另外一个。

如果存在唯一一种逻辑可靠的 < 定义,则应该考虑为这个类定义 < 运算符。如果类同时还包含 ==,则当且仅当 < 的定义和 == 产生的结果一致时才定义 < 运算符。

赋值运算符

之前已经介绍过拷贝赋值和移动赋值运算符,它们可以把类的一个对象赋值给该类的另一个对象。此外,类还可以定义其他赋值运算符以使用别的类型作为右侧运算对象。

    vector<string> v;
    v = {"a", "an", "the"};

同样,也可以把这个运算符添加到 StrVec 类中。

class StrVec {
public:
    StrVec &operator = (std::initializer_list<std::string>);
    // 其他成员与之前一致
};

StrVec &StrVec::operator = (std::initializer_list<std::string> il) {
	// alloc_n_copy 分配内存空间并从给定范围内拷贝元素
	auto data = alloc_n_copy(il.begin(), il.end());
	free(); // 销毁对象中的元素并释放空间
	elements = data.first; // 更新数据成员使其指向新空间
	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;
}

赋值运算符必须定义成类的成员,复合赋值运算符通常情况下也应该这样做。这两类运算符都应该返回左侧运算对象的引用

下标运算符

表示容器的类通常可以通过元素在容器中的位置访问元素,这些类一般会定义下标运算符 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]; }
	// ...
private:
	std::string *elements;		// 指向数组首元素的指针
};

// 假设 svec 是一个 StrVec 对象
const StrVec cvec = svec; // 把 svec 的元素拷贝到 cvec 中
// 如果 svec 中含有元素,对第一个元素运行 string 的 empty 函数
if (svec.size() && svec[0].empty()) {
	svec[0] = "zero"; // 正确:下标运算符返回 string 的引用
	cvec[0] = "Zip"; // 错误:对 cvec 取下标返回的是常量引用
}

递增和递减运算符

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

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

为了与内置版本保持一致,前置运算符应该返回递增或递减后对象的引用

class StrBlobPtr {
public:
	// 递增和递减运算符
	StrBlobPtr &operator ++ (); // 前置运算符
	StrBlobPtr &operator -- ();
};
// 前置版本:返回递增/递减对象的引用
StrBlobPtr &StrBlobPtr::operator ++ () {
	// 如果 curr 已经指向了容器的尾后位置,则无法递增它
	check(curr, "increment past end of StrBlobPtr");
	++ curr; // 将 curr 在当前状态下向前移动一个元素
	return *this;
}

StrBlobPtr &StrBlobPtr::operator -- () {
	// 如果 curr 是 0,则继续递减它将产生一个无效下标
	-- curr; // 将 curr 在当前状态下向后移动一个元素
	check(curr, "decrement past begin of StrBlobPtr");
	return *this;
}

区分前置和后置运算符

后置版本接受一个额外的(不被使用)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; // 返回之前记录的状态
}

因为我们不会用到 int 形参,所以无须为其命名

显式地调用后置运算符

    StrBlobPtr p(a1); // p 指向 a1 中的 vector
    p.operator ++ (0); // 调用后置版本的 operator ++
    p.operator ++ (); // 调用前置版本的 operator ++

尽管传入的值通常会被运算符函数忽略,但却必不可少,因为编译器只有通过它才能知道应该使用后置版本。

成员访问运算符

箭头运算符必须是类的成员。解引用运算符通常也是类的成员,尽管并非必须如此

class StrBlobPtr {
public:
	std::string &operator * () const {
		auto p = check(curr, "dereference past end");
		return (*p)[curr];
	}
	std::string *operator -> () const {
		// 将实际工作委托给解引用运算符
		return & this->operator*();
	}
};

int main(int argc, char *argv[]) {
    
    StrBlob a1 = {"hi", "bye", "now"};
    StrBlobPtr p(a1); // p 指向 a1 中的 vector
    *p = "okay"; // 给 a1 的首元素赋值
    cout << p->size() << endl; // 打印 4,这是 a1 首元素的大小
    cout << (*p).size() << endl; // 等价于 p->size()

    return 0;
}

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

重载的箭头运算符必须返回类的指针或者自定义了箭头运算符的某个类的对象

函数调用运算符

如果类重载了函数调用运算符,则我们可以像使用函数一样使用该类的对象。因为这样的类同时也能存储状态,所以与普通函数相比它们更加灵活。
举例

struct absInt {
    int operator () (int val) const {
        return val < 0 ? -val : val;
    }
};

int main(int argc, char *argv[]) {
    
    int i = -42;
    absInt absObj; // 含有函数调用运算符的对象
    int ui = absObj(i); // 将 i 传递给 absObj.operator()

    return 0;
}

即使 absObj 只是一个对象而非函数,我们也能“调用”该对象。调用对象实际上是在运行重载的调用运算符。在此例中,该运算符接受一个 int 值并返回其绝对值。

函数调用运算符必须是成员函数。一个类可以定义多个不同版本的调用运算符,相互之间应该在参数数量或类型上有所区别

如果类定义了调用运算符,则该类的对象称作函数对象。因为可以调用这种对象,所以我们说这些对象的“行为像函数一样”。

含有状态的函数对象类

函数对象类除了 operator() 之外也可以包含其他成员。函数对象类通常含有一些数据成员,这些成员被用于定制调用运算符中的操作。

class PrintString {
public:
    PrintString(std::ostream &o = cout, char c = ' ') : os(o), sep(c) { }
    void operator () (const string &s) const { os << s << sep; }
private:
    std::ostream &os; // 用于写入的目的流
    char sep; // 用于将不同输出隔开的字符
};

int main(int argc, char *argv[]) {
    string s = "xxxxxx";

    PrintString printer; // 使用默认值打印到 cout
    printer(s); // 在 cout 中打印 s,后面跟一个空格
    PrintString errors(cerr, '\n');
    errors(s); // 在 cerr 中打印 s,后面跟一个换行符

    return 0;
}

函数对象常常作为泛型算法的实参。例如可以使用标准库 for_each 算法和我们自己的 PrintString 类来打印容器中的内容。

    vector<string> vs = {"asd", "qwe", "zxc"};
    for_each(vs.begin(), vs.end(), PrintString(cerr, '\n'));

lambda 是函数对象

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

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 及相应捕获行为的类

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; // 该数据成员对应通过值捕获的变量
};

auto wc = find_if(words.begin(), words.end(), SizeComp(sz));

标准库定义的函数对象

标准库定义了一组表示算术运算符、关系运算符和逻辑运算符的类,每个类分别定义了一个执行命名操作的调用运算符。例如,plus 类定义了一个函数调用运算符用于对一对运算对象执行 + 的操作;modulus 类定义了一个调用运算符执行二元的 % 操作;equal_to 类执行 ==,等。

这些类都被定义成模板的形式,我们可以为其指定具体的应用类型,这里的类型即调用运算符的形参类型。

int main(int argc, char *argv[]) {
    
    std::plus<int> intAdd; // 加法
    std::negate<int> intNegate; // 取反

    int sum = intAdd(10, 20); // 30

    sum = intNegate(intAdd(10, 20)); // -30

    sum = intAdd(10, intNegate(10)); // 0

    return 0;
}

定义在 functional 头文件中

标准库函数对象
算术                 关系                     逻辑
plus<Type>           equal_to<Type>          logical_and<Type>
minus<Type>          not_equal<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>

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

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

sort(svec.begin(), sevc.end(), greater<string>());

需要特别注意的是,标准库规定其函数对象对于指针同样适用。

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

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

可调用对象与 function

两个不同类型的可调用对象可能共享同一种调用形式。调用形式指明了调用返回的类型以及传递给调用的实参类型。一种调用形式对应一个函数类型,例如:

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;
        }
    };

上面这些可调用对象分别对其参数执行了不同的算术运算,尽管它们的类型各不相同,但是共享同一种调用形式:

int(int, int)

我们可能希望使用这些可调用对象构建一个简单的桌面计算器。为了实现这一目的,需要定义一个函数表用于存储指向这些可调用对象的“指针”。当程序需要执行某个特定的操作时,从表中查找调用的函数。

    // 构建从运算符到函数指针的映射关系,其中函数接受两个 int、返回一个 int
    map<string, int(*)(int, int)> binops;

    // 正确:add 是一个指向正确类型函数的指针
    binops.insert({"+", add});

    // 错误:mod 不是一个函数指针,是个 lambda 表达式,每个 lambda 有它自己的类类型,该类型与存储在 binops 中的值的类型不匹配
    binops.insert({"%", mod});

标准库 function 类型

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

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

result_type                    该 function 类型的可调用对象返回的类型
argument_type                  当 T 有一个或两个实参时定义的类型。如果 T 只有一个实参,
first_argument_type            则 argument_type 是该类型的同义词:如果 T 有两个实参,
second_argument_type           则 first_argument_type 和 second_argument_type
                               分别代表两个实参的类型

function 是一个模板,和我们使用过的其他模板一样,当创建一个具体的 function 类型时我们必须提供额外的信息。

    std::function<int(int, int)> f1 = Add; // 函数指针
    std::function<int(int, int)> f2 = Divide(); // 函数对象类的对象
    std::function<int(int, int)> f3 = [] (int i, int j) { return i * j; }; // lambda

    cout << f1(4, 2) << endl; // 6
    cout << f2(4, 2) << endl; // 2
    cout << f3(4, 2) << endl; // 8

使用这个 function 类型我们可以重新定义 map

    // 列举了可调用对象与二元运算符对应关系的表格
    // 所有可调用对象都必须接受两个 int、返回一个 int
    // 其中的元素可以是函数指针、函数对象或者 lambda
    map<string, std::function<int(int, int)> > binops = {
        {"+", Add},
        {"-", std::minus<int>()},
        {"/", Divide()},
        {"*", [](int i, int j) { return i * j; }},
        {"%", Mod}  };

    binops["+"](10, 5);
    binops["-"](10, 5);
    binops["/"](10, 5);
    binops["*"](10, 5);
    binops["%"](10, 5);

重载的函数与 function

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

    int add(int i, int j) { return i + j; }
    Sales_data add(const Sales_data&, const Sales_data&);
    map<string, std::function<int(int, int)>> binops;
    binops.insert({"+", add}); // 错误:哪个 add?

解决上述二义性问题的一条途径是存储函数指针而非函数的名字

    int (*fp)(int, int) = add; // 指针所指的 add 是接受两个 int 的版本
    binops.insert( {"+", add} ); // 正确:fp 指向一个正确的 add 版本

同样,我们也能使用 lambda 来消除二义性:

    // 正确:使用 lambda 来指定我们希望使用的 add 版本
    binops.insert( {"+", [](int a, int b) { return add(a, b);} } );

lambda 内部的函数调用传入了两个 int,因此该调用只能匹配接受两个 int 的 add 版本,而这也正是执行 lambda 时真正调用的函数。

新版本标准库中的 function 类与旧版本中的 unary_function 和 binary_function 没有关联,后两个类已经被更通用的 bind 函数替代了

重载、类型转换与运算符

转换构造函数和类型转换运算符共同定义了类类型转换,这样的转换有时也被称作用户定义的类型转换

类型转换运算符

类型转换运算符是类的一种特殊成员函数,它负责将一个类类型的值转换成其他类型。类型转换函数的一般形式如下所示:

operator type() const;

一个类型转换函数必须是类的成员函数;它不能声明返回类型,形参列表也必须为空。类型转换函数通常应该是 const

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

#include <cstddef>
#include <stdexcept>

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 对象,而类型转换运算符将 SmallInt 对象转换成 int:

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

尽管编译器一次只能执行一个用户定义的类型转换,但是隐式的用户定义类型转换可以置于一个标准(内置)类型转换之前或之后,并与其一起使用。因此,我们可以将任何算术类型传递给 SmallInt 的构造函数。类似的,我们也能使用类型转换运算符将一个 SmallInt 对象转换成 int,然后再将所得的 int 转换成任何其他算术类型。

    // 内置类型转换将 double 实参转换成 int
    SmallInt si = 3.14; // 调用 SmallInt(int) 构造函数
    // SmallInt 的类型转换运算符将 si 转换成 int
    si + 3.14; // 内置类型转换将所得的 int 继续转换成 double

因为类型转换运算符是隐式执行的,所以无法给这些函数传递实参,当然也就不能在类型转换运算符的定义中使用任何形参。同时,尽管类型转换函数不负责指定返回类型,但实际上每个类型转换函数都会返回一个对应类型的值。

class SmallInt;
operator int(SmallInt&); // 错误:不是成员函数
class SmallInt {
public:
    int operator int() const; // 错误:指定了返回类型
    operator int(int = 0) const; // 错误:参数列表不为空
    operator int*() const { return 42; } // 错误:42 不是一个指针
};

提示:避免过度使用类型转换函数

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

    int i = 42;
    cin << i; // 在早期 C++ 中,如果向 bool 的类型转换不是显式的,则该代码在编译器看来将是合法的!

istream 的 bool 类型转换运算符将 cin -> bool -> int -> (1 << 42)

显式的类型转换运算符

为了防止这样的异常情况发生,C++11 新标准引入了显式的类型转换运算符

class SmallInt {
public:
    // 编译器不会自动执行这一类型转换
    explicit operator int() const { return val; }
    // 其他成员与之前的版本一致
};
SmallInt si = 3; // 正确:SmallInt 的构造函数不是隐式的
si + 3; // 错误:此处需要隐式的类型转换,但类的运算符是显式的
static_cast<int>(si) + 3; // 正确:显式地请求类型转换

当类型转换运算符是显式的时,我们也能执行类型转换,不过必须通过显式的强制类型转换才可以。

该规定有一个例外,即如果表达式被用作条件,则编译器会将显式的类型转换自动应用于它。换句话说,当表达式出现在下列位置时,显式的类型转换将被隐式地执行:

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

转换为 bool

在标准库的早期版本中,IO 类型定义了向 void* 的转换规则,以求避免上面提到的问题。在 C++11 新标准下,IO 标准库通过定义一个向 bool 的显式类型转换实现同样的目的。

无论我们什么时候在条件中使用流对象,都会使用为 IO 类型定义的 operator bool。例如:

    while (std::cin >> value)

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

避免有二义性的类型转换

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

实参匹配和相同的类型转换

// 最好不要在两个类之间构建相同的类型转换
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()); // 正确:使用 B 的类型转换运算符
A a2 = f(A(b)); // 正确:使用 A 的构造函数

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

另外如果类定义了一组类型转换,它们的转换源(或者转换目标)类型本身可以通过其他类型转换联系在一起,则同样会产生二义性的问题。

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)?

调用 f2 及初始化 a2 的过程之所以会产生二义性,根本原因是它们所需的标准类型转换级别一致。当我们使用用户定义的类型转换时,如果转换过程包含标准类型转换,则标准类型转换的级别将决定编译器选择最佳匹配的过程:

    short s = 42;
    // 把 short 提升成 int 优于把 short 转换成 double
    A a3(s); // 使用 A::A(int)

当我们使用两个用户定义的类型转换时,如果转换函数之前或之后存在标准类型转换,则标准类型转换将决定最佳匹配到底是哪个

重载函数与转换构造函数

当我们调用重载的函数时,从多个类型转换中进行选择将变得更加复杂。

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&)

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

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

struct E {
    E(double);
    // 其他成员
};

void manip2(const C&);
void manip2(const E&);
// 二义性错误:两个不同的用户定义的类型转换都能用在此处
manip2(10); // 含义是 manip2(C(10)) 还是 manip2(E(double(10)))

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

函数匹配与重载运算符

表达式中运算符的候选函数集既应该包括成员函数,也应该包括非成员函数。
举例:

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 + 
int i = s3 + 0; // 二义性错误 0 -> SmallInt -> operator + / SmallInt -> int -> 内置加法运算

如果我们对同一个类既提供了转换目标是算术类型的类型转换,也提供了重载的运算符,则将会遇到重载运算符与内置运算符的二义性问题。

posted @ 2023-03-25 10:33  HuiPuKui  阅读(32)  评论(0)    收藏  举报