《C++ Primer》笔记 第14章 重载运算与类型转换
-
当运算符作用于类类型的运算对象时,可以通过运算符重载重新定义该运算符的含义。明智地使用运算符重载能令我们的程序更易于编写和阅读。
-
重载的运算符是具有特殊名字的函数:它们的名字由关键字operator和其后要定义的运算符号共同组成。
-
重载运算符函数的参数数量与该运算符作用的运算对象数量一样多。一元运算符有一个参数,二元运算符有两个。对于运算符来说,左侧运算符传递给第一个参数,而右侧运算对象传递给第二个参数。除了重载的函数调用运算符operator()之外,其他重载运算符不能含有默认实参。
-
当一个重载的运算符是成员函数时,this绑定到左侧运算对象。成员运算符函数的(显式)参数数量比运算对象的数量少一个。
-
对于一个运算符函数来说,它或者是类的成员,或者至少含有一个类类型的参数:
// 错误:不能为int重定义内置的运算符 int operator+(int, int);
-
有四个符号(+、-、*、&)既是一元运算符也是二元运算符,所有这些运算符都能被重载,从参数的数量我们可以推断到底定义的是哪种运算符。
-
对于一个重载的运算符来说,其优先级和结合律与对应的内置运算符保持一致。
-
不能被重载的运算符:
不能被重载的运算符 :: .* . ?: -
通常情况下,我们将运算符作用于类型正确的实参,从而以这种间接方式“调用”重载的运算符函数。然而,我们也能像调用普通函数一样直接调用运算符函数:
// 一个非成员运算符函数的等价调用 data1 + data2; // 普通的表达式 operator+(data1, data2); // 等价的函数调用
我们可以像调用其他成员函数一样显式地调用成员运算符函数。
data1 += data2; // 基于“调用”的表达式 data1.operator+=(data2); // 对成员运算符函数的等价调用
-
因为使用重载的运算符本质上是一次函数调用,所以这些关于运算对象求值顺序的规则无法应用到重载的运算符上。
-
因为运算符的重载版本无法保留求值顺序和/或短路求值属性,因此不建议重载逻辑与运算符、逻辑或运算符和逗号运算符。
-
C++语言已经定义了逗号运算符和取地址运算符用于类类型对象时的特殊含义,所以一般来说它们不应该被重载。
-
如果某些操作在逻辑上与运算符相关,则它们适合于定义成重载的运算符:
- 如果类执行IO操作,则定义移位运算符使其与内置类型的IO保持一致。
- 如果类的某个操作是检查相等性,则定义operator==;如果类有了operator==,意味着它通常也应该有operator!=。
- 如果类包含一个内在的单序比较操作,则定义operator<;如果类有了operator<,则它也应该含有其他关系操作。
- 重载运算符的返回类型通常情况下应该与其内置版本的返回类型兼容:逻辑运算符和关系运算符应该返回bool,算术运算符应该返回一个类类型的值,赋值运算符和复合赋值运算符则应该返回左侧运算对象的一个引用。
-
只有当操作的含义对于用户来说清晰明了时才使用运算符。如果用户对运算符可能有几种不同的理解,则使用这样的运算符将产生二义性。
-
如果类含有算术运算符或者位运算符,则最好也提供对应的复合赋值运算符。
-
下面的准则有助于我们在将运算符定义为成员函数还是普通的非成员函数做出抉择:
- 赋值(=)、下标([])、调用(())和成员访问箭头(->)运算符必须是成员。
- 复合赋值运算符一般来说应该是成员,但并非必须,这一点与赋值运算符略有不同。
- 改变对象状态的运算符或者与给定类型密切相关的运算符,如递增、递减和解引用运算符,通常应该是成员。
- 具有对称性的运算符可能转换任意一端的运算对象,例如算术、相等性、关系和位运算符等,因此它们通常应该是普通的非成员函数。
-
当我们把运算符定义成成员函数时,它的左侧运算对象必须是运算符所属类的一个对象。
-
当我们把运算符定义非成成员函数时,唯一的要求是至少有一个运算对象是类类型,并且两个运算对象都能准确无误地转换成形参类型:
string s = "world"; string t = s + "!"; // 正确:我们能把一个const char *加到一个string对象中 string u = "hi" + s; // 如果+是string的成员,则产生错误 // 因为string将+定义成了普通的非成员函数,所以`"hi" + s`等价于`operator+("hi", s)`。 // 和任何其他函数调用一样,每个实参都能被转换成形参类型。
-
重载输出运算符<<:通常情况下,输出运算符的第一个形参是一个非常量
ostream
对象的引用。第二个形参一般来说是一个常量的引用,该常量是我们想要打印的类类型。operator<<
一般要返回它的ostream
形参。ostream &operator<<(ostream &os, const Sales_data &item) { os << item.isbn() << " " << item.units_sold << " " << item.revenue << " " << item.avg_price(); return os; }
-
通常,输出运算符应该主要负责打印对象的内容而非控制格式,输出运算符不应该打印换行符。
-
如果我们希望为类自定义IO运算符,则必须将其定义成非成员函数。当然,IO运算符通常需要读写类的非公有数据成员,所以IO运算符一般被声明为友元。
-
重载输入运算符>>:通常情况下,输入运算符的第一个形参是运算符将要读取的流的引用,第二个形参是将要读入到的(非常量)对象的引用。该运算符通常会返回某个给定流的引用。
istream &operator>>(istream &is, Sales_data &item) { double price; // 不需要初始化,因为我们将先读入数据到price,之后才使用它 is >> item.bookNo >> item.units_sold >> price; if (is) // 检查输入是否成功 item.revenue = item.units_sold * price; else item = Sales_data(); // 输入失败:对象被赋予默认的状态 return is; }
-
输入运算符必须处理输入可能失败的情况,而输出运算符不需要。
-
在执行运算符时可能发生下列错误:
- 当流含有错误类型的数据时读取操作可能失败。
- 当读取操作到达文件末尾或者遇到输入流的其他错误时也会失败。
-
当读取操作发生错误时,输入运算符应该负责从错误中恢复。
-
即使从技术上来看IO是成功的,输入运算符也应该设置流的条件以标示失败信息。通常情况下,输入运算符只设置
failbit
。除此之外,设置eofbit
表示文件耗尽,而设置badbit
表示流被破坏。最好的方式是由IO标准库自己来标示这些错误。 -
算术和关系运算符:通常情况下,我们把算术和关系运算符定义成非成员函数以允许对左侧或右侧的运算对象进行转换。因为这些运算符一般不需要改变运算对象的状态,所以形参都是常量的引用。
-
如果类定义了算术运算符,则它一般也会定义一个对应的复合赋值运算符。此时,最有效的方式是使用复合赋值来定义算术运算符:
// 假设两个对象指向同一本书 Sales_data operator+(const Sales_data &lsh, const Sales_data &rhs) { Sales_data sum = lhs; // 把lhs的数据成员拷贝给sum sum += rhs; // 将rhs加到sum中 return sum; }
-
相等运算符的一些设计准则:
- 如果一个类含有判断两个对象是否相等的操作,则它显然应该把函数定义成operator==而非一个普通的命名函数。
- 如果类定义了operator==,则该运算符应该能判断一组给定的对象中是否含有重复数据。
- 通常情况下,相等运算符应该具有传递性,换句话说,如果a==b和b==c都为真,则a==c也应该为真。
- 如果类定义了operator==,则这个类也应该定义operator!=。对于用户来说,当他们能使用==时肯定也希望能使用!=,反之亦然。
- 相等运算符和不相等运算符中的一个应该把工作委托给另外一个,这意味着其中一个运算符应该负责实际比较对象的工作,而另一个运算符则只是调用哪个真正工作的运算符。
-
如果某个类在逻辑上有相等性的含义,则该类应该定义operator==,这样做可以使得用户更容易使用标准库算法来处理这个类。
-
通常情况下关系运算符应该
- 定义顺序关系,令其与关联容器中对关键字的要求一致;并且
- 如果类同时也含有==运算符的话,则定义一种关系令其与==保持一致。特别是,如果两个对象是!=的,那么一个对象应该<另外一个。
-
如果存在唯一一种逻辑可靠的<定义,则应该考虑为这个类定义<运算符。如果类同时还包含==,则当且仅当<的定义和==产生的结果一致时才定义<运算符。
-
赋值运算符必须定义成类的成员,复合赋值运算符通常情况下也应该这样做。这两类运算符都应该返回左侧运算对象的引用。
// 作为成员的二元运算符:左侧运算对象绑定到隐式的this指针 // 假定两个对象表示的是同一本书 Sales_data& Sales_data::operator+=(const Sales_data &rhs) { units_sold += rhs.units_sold; revenue += rhs.revenue; return *this; }
-
如果一个类包含下标运算符,则它通常会定义两个版本:一个返回普通引用,另一个是类的常量成员并返回常量引用。
-
定义递增和递减运算符的类应该同时定义前置版本和后置版本。这些运算符通常应该被定义成类的成员。
-
为了与内置版本保持一致,前置运算符应该返回递增或递减后对象的引用。
// check函数检验StrBlobPtr是否有效,如果是,接着检查给定的索引值是否有效 inline shared_ptr<vector<string>> StrBlobPtr::check(size_t i, const string &msg) const { auto ret = wptr.lock(); // vector还存在吗? if (!ret) { throw runtime_error("unbound StrBlobPtr"); } if (i >= ret->size()) { throw out_of_range(msg); } return ret; // 否则,返回指向vector的shared_ptr } // 前置版本:返回递增/递减对象的引用 StrBlobPtr& StrBlobPtr::operator++() { // 如果curr已经指向了容器的尾后位置,则无法递增它 check(curr, "increment past end of StrBlobPtr"); ++curr; // 将curr在当前状态下向前移动一个元素 return *this; } StrBlobPtr& StrBlobPtr::StrBlobPtr--() { // 如果curr是0,则继续递减它将产生一个无效下标 --curr; // 将curr在当前状态下向后移动一个元素 check(curr, "decrement past begin of StrBlobPtr"); return *this; }
-
后置版本接受一个额外的(不被使用)int类型的形参。当我们使用后置运算符时,编译器为这个形参提供一个值为0的实参。这个形参的唯一作用就是区分前置版本和后置版本。
-
为了与内置版本保持一致,后置运算符应该返回对象的原值(递增或递增之前的值),返回的形式是一个值而非引用。
// 后置版本:递增/递减对象的值但是返回原值 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]; // (*p)是对象所指的vector } std::string* operator->() const { // 将实际工作委托给解引用运算符 return & this->operator*(); } // 其他成员与之前的版本一致 };
值得注意的是,我们将这两个运算符定义成了
const
成员,这是因为与递增和递减运算符不一样,获取一个元素并不会改变StrBlobPtr
对象的状态。同时,它们的返回值分别是非常量string的引用或指针,因为一个StrBlobPtr
只能绑定到非常量的StrBlob
对象。 -
和大多数其他运算符一样(尽管这么做不太好),我们能令
operator*
完成任何我们指定的操作。换句话说,我们可以让operator*
返回一个固定值42,或者打印对象的内容,或者其他。箭头运算符则不是这样,它永远不能丢掉成员访问这个最基本的含义。当我们重载箭头时,可以改变的是箭头从哪个对象当中获取成员,而箭头获取成员这一事实则永远不变。 -
point->mem
的执行过程如下所示:// 根据point类型的不同,point->mem分别等价于 (*point).mem; // point是一个内置的指针类型 point.operator->()->mem; // point是类的一个对象
- 如果
point
是指针,则我们应用内置的箭头运算符,表达式等价于(*point).mem
。首先解引用该指针,然后从所得的对象中获取指定的成员。如果point
所指的类型没有名为mem
的成员,程序会发生错误。 - 如果
point
是定义了operator->
的类的一个对象,则我们使用point.operator->()
的结果来获取mem
。其中,如果该结果是一个指针,则执行第1步;如果该结果本身含有重载的operator->()
,则重复调用当前步骤。最终,当这一过程结束时程序或者返回了所需的内容,或者返回一些表示程序错误的信息。
- 如果
-
重载的箭头运算符必须返回类的指针或者自定义了箭头运算符的某个类的对象。
-
如果类重载了函数调用运算符,则我们可以像使用函数一样使用该类的对象。因为这样的类同时也能存储状态,所以与普通函数相比它们更加灵活。
-
调用对象实际上是在运行重载的调用运算符。
-
函数调用运算符必须是成员函数。一个类可以定义多个不同版本的调用运算符,相互之间应该在参数数量或类型上有所区别。
-
如果类定义了调用运算符,则该类的对象称作函数对象。因为可以调用这种对象,所以我们说这些对象的“行为像函数一样”。
-
函数对象常常作为泛型算法的实参。
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; // 用于将不同输出隔开的字符 }; for_each(vs.begin(), vs.end(), PrintString(cerr, '\n'));
-
当我们编写了一个lambda后,编译器将该表达式翻译成一个未命名类的未命名对象
// 根据单词的长度对其进行排序,对于长度相同的单词按照字母表顺序排序 stable_sort(words.begin(), words.end(), [](const string &a, const string &b) {return a.size() < b.size(); } ); // 其行为类似于下面这个类的一个未命名对象 class ShortString { public: bool operator()(const string &s1, const string &s2) const { return s1.size() < s2.size(); } };
-
默认情况下lambda不能改变它捕获的变量。因此在默认情况下,由lambda产生的类当中的函数调用运算符是一个const成员函数。如果lambda被声明为可变的,则调用运算符就不是const的了(参见mutable)。
-
当一个lambda表达式通过引用捕获变量时,将由程序负责确保lambda执行时引用所引的对象确实存在。因此,编译器可以直接使用该引用而无须在lambda产生的类中将其存储为数据成员。相反,通过值捕获的变量被拷贝到lambda中。因此,这种lambda产生的类必须为每个值捕获的变量建立对应的数据成员,同时创建构造函数,令其使用捕获的变量的值来初始化数据成员
// 获得第一个指向满足条件元素的迭代器,该元素满足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) { } // 该形参对应捕获的变量 // 该调用运算符的返回类型、形参和函数体都与lambda一致 bool operator()(const string &s) const { return s.size() >= sz; } private: size_t sz; // 该数据成员对应通过值捕获的变量 };
-
lambda表达式产生的类不含默认构造函数、赋值运算符及默认析构函数;它是否含有默认的拷贝/移动构造函数则通常要视捕获的数据成员类型而定。
-
标准库定义的函数对象:标准库定义了一组表示算术运算符、关系运算符和逻辑运算符的类,每个类分别定义了一个执行命名操作的调用运算符。它们定义在
functional
头文件中。这些类都被定义成模板的形式,我们可以为其指定具体的应用类型,这里的类型即调用运算符的形参类型。例如,plus令string加法运算符作用于string对象。
算术 | 关系 | 逻辑 |
---|---|---|
plus |
equal_to |
logical_and |
minus |
not_equal_to |
logical_or |
multiplies |
greater |
logical_not |
divides |
greater_equal |
logical_not |
modulus |
less |
|
negate |
less_equal |
-
表示运算符的函数对象类通常用来替换算法中的默认运算符。
// 传入一个临时的函数对象用于执行两个string对象的>比较运算 sort(svec.begin(), svec.end(), greater<string>);
-
标准库规定其函数对象对于指针同样适用。通常,比较两个无关指针将(可能)产生未定义的行为,有时我们想通过比较指针的内存地址来sort指针的vector,那么我们可以使用一个标准库函数对象来实现该目的。
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<key_type>对元素排序,因此我们可以定义一个指针的set或者在map中使用指针作为关键值而无须直接声明less。
-
C++语言中有几种可调用的对象:函数、函数指针、lambda表达式、bind创建的对象以及重载了函数调用运算符的类。
-
和其他对象一样,可调用的对象也有类型。例如,每个lambda有它自己唯一的(未命名的)类类型;函数及函数指针的类型则由其返回值类型和实参类型决定,等等。
-
两个不同类型的可调用对象却可能共享同一种调用方式。调用形式指明了调用返回的类型以及传递给调用的实参类型。一种调用形式对应一个函数类型,例如:
int(int, int) // 是一个函数类型,它接受两个int、返回一个int
-
函数表用于存储指向可调用对象的“指针”。
-
不同类型可能具有相同的调用形式。我们使用function来统一它们的类型。function定义在
functional
头文件中
function的操作 | 解释 |
---|---|
function |
f是一个用来存储可调用对象的空function,这些可调用对象的调用形式应该与函数类型T相同(即T是retType(args)) |
function |
显式地构造一个空function |
function |
在f中存储可调用对象obj的副本 |
f | 将f作为条件:当f含有一个可调用对象时为真;否则为假 |
f(args) | 调用f中的对象,参数是args |
—— | 定义为function |
result_type | 该function类型的可调用对象返回的类型 |
argument_type first_argument_type second_argument_type | 当T有一个或两个实参时定义的类型。如果T只有一个实参,则argument_type是该类型的同义词;如果T有两个实参,则first_argument_type和second_argument_type分别代表两个实参的类型 |
-
如果我们索引
binops
,将得到function对象的引用。function类型重载了调用运算符,该运算符接受它自己的实参然后将其传递给存好的可调用对象:// 普通函数 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; } }; map<string, function<int(int, int)>> binops = { {"+", add}, // 函数指针 {"-", std::minus<int>()}, // 标准库函数对象 {"/", divide()}, // 用户定义的函数对象 {"*", [](int i, int j){ return i * j; }}, // 未命名的lambda {"%", mod} // 命名了的lambda对象 }; binops["+"](10, 5); // 调用add(10, 5) binops["-"](10, 5); // 使用minus<int>对象的调用运算符 binops["/"](10, 5); // 使用divide对象的调用运算符 binops["*"](10, 5); // 调用lambda函数对象 binops["%"](10, 5); // 调用lambda函数对象
-
我们不能(直接)将重载函数的名字存入function类型的对象中:
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}); // 错误:哪个add?
解决上述二义性问题的一条途径是存储函数指针而非函数的名字:
int (*fp)(int, int) = add; // 指针所指的add是接受两个int的版本 binops.insert({"+", fp}); // 正确:fp指向一个正确的add版本
同样,我们也能使用lambda来消除二义性:
// 正确:使用lambda来指定我们希望使用的add版本 binops.insert({"+", [](int a, int b){ return add(a, b); }});
-
转换构造函数和类型转换运算符共同定义了类类型转换,这样的转换有时也被称作用户定义的类型转换。
-
类型转换运算符是类的一种特殊成员函数,它负责将一个类类型的值转换成其他类型。
operator type() const;
其中type表示某种类型。类型转换运算符可以面向任意类型(除了void之外)进行定义,只要该类型能作为函数的返回类型。因此,我们不允许转换成数组或者函数类型,但允许转换成指针(包括数组指针及函数指针)或者引用类型。
-
一个类型转换函数必须是类的成员函数;它不能声明返回类型,形参列表也必须为空。类型转换函数通常应该是const。
-
构造函数将算术类型的值转换成
SmallInt
对象,而类型转换运算符将SmallInt
对象转换成int: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 si; si = 4; // 首先将4隐式地转换成SmallInt,然后调用SmallInt::operator= si + 3; // 首先将si隐式地转换成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不是一个指针 };
-
避免过度使用类型转换函数:和使用重载运算符的经验一样,明智地使用类型转换运算符也能极大地简化类设计者的工作,同时使得使用类更加容易。然而,如果在类类型和转换类型之间不存在明显的映射关系,则这样的类型转换可能具有误导性。
-
编译器(通常)不会将一个显式的类型转换运算符用于隐式类型转换:
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的类型转换通常用在条件部分,因此
operator bool
一般定义成explicit的。 -
如果类中包含一个或多个类型转换,则必须确保在类类型和目标类型之间只存在唯一一种转换方式。否则的话,我们编写的代码将很可能具有二义性。
-
在两种情况下可能产生多重转换路径:
-
(实参匹配和相同的类型转换)两个类提供相同的类型转换:例如,当A类定义了一个接受B类对象的转换构造函数,同时B类定义了一个转换目标是A类的类型转换运算符时,我们就说它们提供了相同的类型转换。
// 最好不要在两个类之间构建相同的类型转换 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的过程之所以会产生二义性,根本原因是它们所需的标准类型转换级别一致,编译器无法确定最佳匹配。
-
-
通常情况下,不要为类定义相同的类型转换,也不要在类中定义两个及两个以上转换源或转换目标是算术类型的转换。
-
经验规则:
- 不要令两个类执行相同的类型转换
- 避免转换目标是内置算术类型的类型转换。特别是当你已经定义了一个转换成算术类型的类型转换时,接下来
- 不要再定义接受算术类型的重载运算符。
- 不要定义转换到多种算术类型的类型转换。
-
建议:除了显式地向bool类型的转换之外,我们应该尽量避免定义类型转换函数并尽可能地限制那些“显然正确”的非显式构造函数。
-
如果在调用重载函数时我们需要使用构造函数或者强制类型转换来改变实参的类型,则这通常意味着程序的设计存在不足。
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 C { C(int); // 其他成员 }; struct E { E(double); // 其他成员 }; void manip2(const C&); void manip2(const E&); // 二义性错误:两个不同的用户定义的类型转换都能用在此处 manip2(10); // 含义是manip2(C(10))还是manip2(E(double(10)))
-
重载的运算符也是重载的函数。因此,通用的函数匹配规则同样适用于判断在给定的表达式中到底应该使用内置运算符还是重载的运算符。
-
和普通函数调用不同,我们不能通过调用的形式来区分当前调用的是成员函数还是非成员函数。
// 如果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+ int i = s3 + 0; // 二义性错误