现在的位置: 首页 > 综合 > 正文

C++ Primer学习笔记——$14 操作符重载、函数对象及类类型转换

2018年03月30日 ⁄ 综合 ⁄ 共 7828字 ⁄ 字号 评论关闭
题记:本系列学习笔记(C++ Primer学习笔记)主要目的是讨论一些容易被大家忽略或者容易形成错误认识的内容。只适合于有了一定的C++基础的读者(至少学完一本C++教程)。
 
作者: tyc611, 2007-01-30


   本文主要讨论C++中操作符重载、函数对象和类类型转换。
   如果文中有错误或遗漏之处,敬请指出,谢谢!


   重载操作符是具有特殊名称的函数:保留字operator后接需要定义的操作符符号。
 
   大多数操作符是可重载的,但有几个操作符是不可重载的。不可重载的操作符有四个:

 ::  .  .*  ?:

并且,只能重载C++中已有操作符,不可以连接其他合法符号创建新的操作符。例如:定义一个operator**以提供求幂运算是非法的。

 
   重载操作符必须具有至少一个类类型或枚举类型的操作数,即重载操作符不能重新定义用于内置类型对象的操作符的含义。
   操作符的优先级、结合性或操作数数目不能改变。有四个符号(+, -, *, &)既可以作一元操作符又可作二元操作符。
   除了函数调用操作符operator()之外,重载操作符时使用默认实参是非法的。
   重载操作符并不保证操作数的计算顺序,尤其是不会保证内置逻辑AND、逻辑OR和逗号操作符的操作数的计算顺序。在&&和||的重载版本中,两个操作数都要求进行计算,而不再遵循短路计算规则。
 


重载操作符的设计
 
不要重载具有内置含义的操作符
 
   赋值操作符、取地址操作符和逗号操作符对类类型操作数有默认含义。如果没有特定重载版本,编译器就会自动定义以下这些操作符:
   > 合成赋值操作符:逐个成员进行赋值,分别使用自己的赋值操作符。
   > 默认情况下,取地址操作符(,)和逗号操作符(,)在类类型对象上的执行,与在内置类型对象上的执行一样。取地址操作符返回对象的内存地址,逗号操作符从左至右计算每个表达式的值,并返回最右边操作数的值。注意,如果重载了自己的逗号操作符版本,则不再保证操作数的计算顺序。
   > 内置逻辑与(&&)和逻辑或(||)操作符使用短路计算规则。如果重新定义了该操作符,将失去操作符的短路计算特性。
 注:逗号操作符(,)、取地址操作符(&)、逻辑与操作符(&&)和逻辑或操作符(||)具有有用的内置含义,通常不应该重载它们。因为重载之后,就不再具有这些内置含义。
 
相等和关系操作符
 
   在关联容器中,键类型应定义<操作符,因为关联容默认使用键类型的<操作符;在顺序容器中,通常应该定义相等(==)操作符和小于(<)操作符,因为标准库里的许多算法默认使用这些操作符。例如sort算法使用<操作符,而find算法使用==操作符。
 
   如果类定义了相等操作符,它也应该定义不相等操作符!=。因为类用户通常会假设如果可以进行相等比较,则也可以进行不相等比较。同样,对于其它关系操作符也用这个规则。当然,!=通常由==实现,而>, <=, >=也通常由<实现。
 
成员函数还是非成员函数(友元)的选择
 
   下面是一些经验法则,可以指导我们进行选择:
   > 赋值(=)、下标([])、调用(())和成员访问箭头(->)等操作符必须定义为成员,把这些操作符定义为非成员函数将在编译时标记为错误。
   > 像赋值一样,复合赋值操作符通常应定义为类的成员。与赋值不同的是,不一定非得这么做,如果定义非成员复合赋值操作符,不会出现编译错误。
   > 改变对象状态或与给定类型紧密联系的其他一些操作符,如自增、自减和解引用,通常应定义为类成员。
   > 对称的操作符,如算术操作符、相等操作符、关系操作符和位操作符,最好定义为普通非成员函数。
 


重载操作符的实现
 
输出操作符<<
 
   一般而言,输出操作符应输出对象的内容,进行最小限度的格式化,它们不应该输出换行符。这样,让用户自己去控制输出细节,给用户更多的选择。I/O操作符应该为非成员函数,这样才能实现与标准库I/O一致的行为。
 
输入操作符>>
 
   输入操作符应当检查输入过程中可能出现的错误,如类型不匹配错误、遇到文件结束符。如果是输入过程中发生流错误,则应当保证对象处于可用和一致的状态,用户可以通过检查输入后的流状态来判断是否正常输入;如果输入正常,但数据不合某种附加的限制,则可以设置流状态标志位为failbit表示输入失败。
 
算术操作符和复合赋值运算符
 
   为了保持与内置操作符一致,算术操作符(如加法)应返回一个右值,而不是一个引用。即定义了算术操作符又定义了相应的复合赋值操作符的类,一般应使用复合赋值实现算术操作符。例如,operator+由operator+=来实现。
 
赋值操作符
 
   赋值操作符必须定义为成员函数,并且赋值操作符与复合赋值操作符应返回左操作数的引用(返回*this)。
 
下标操作符
 
   下标操作符必须定义为类成员函数,其结果一般同时用作赋值操作符的左右操作数。所以,一般定义两个版本:一个非const成员并返回引用,另一个为const成员并返回const引用。
  
成员访问操作符
 
   成员访问操作符有解引用操作符(*)和箭头操作符(->)。箭头操作符必须定义为类成员函数;解引用操作符不要求定义为成员,但将它作为成员一般也是正确的。成员访问操作符与下标操作符一样,一般应该定义const版本和非const版本。
 
   箭头操作符表现得比较特殊,因为它表现得像一个二元操作符:接受一个对象和一个成员名,但事实上它不接受显式形参。例如:Ptr *operator->() {return ptr;}。这里没有形参,因为->的右操作数不是表达式,它是对应着类成员的一个标识符。没有明显可靠的途径将一个标识符作为形参传递给函数,相反,由编译器处理获取成员的工作。当编写程序:ptr->action();时,实际上等价于:(ptr->action)();。而编译器按如下规则对ptr->action求值:
   1)如果ptr是一个指针,指向具有名为action的成员的类对象,则编译器将代码编译为调用该对象的action成员。
   2)否则,如果action是定义了operator->操作符的类的一个对象,则ptr->action与ptr.operator->()->action相同。即,执行ptr的operator->(),然后使用该结果重复这三步。
   3)否则代码出错。
 
   由此可见,箭头操作符必须返回指向类类型的指针,或者返回定义了自己的箭头操作符的类类型对象。
 
自增操作符和自减操作符
 
   一般情况下,我们都把自增和自减操作符定义为类的成员函数。这两种操作符都有前缀和后缀两种形式。
 
   前缀形式的自增和自减操作符与普通操作符定义形式一样。为了保持与内置类型一致,前缀形式的自增和自减操作符应该返回对象的引用。
 
   为了区分前缀和后缀两种形式,后缀形式的自增和自减操作符接受一个额外的int型形参。使用后缀形式的操作符时,编译器提供0作为这个形参的实参,在实现操作符时可以不对这个形参命名。为了与内置操作符一致,后缀形式的操作符应返回旧值,并且,作为值返回,而不是返回引用。通常,后缀形式的实现是调用前缀形式实现的。例如:
   Demo& Demo::operator++() {    // prefix form
      // do something here
      return *this;
   }
   Demo Demo::operator++(int) {  // postfix form
      Demo ret(*this);
      ++*this;
      return ret;
   }
   如果需要显示地以函数形式调用后缀操作符,则应该给出一个整型实参值。例如:
   obj.operator++();        // call prefix operator++
   obj.operator++(0);       // call postfix operator++
 
调用操作符
 
   可以为类类型的对象重载函数调用操作符。函数调用操作符必须声明为成员函数,它的形式看上下有点怪异(见下面的例子)。
   例如,下面的结构封装一个计算绝对值的操作:
   struct AbsInt {
      int operator() (int val) {
         return val < 0 ? -val : val;
      }
   };
   int i = -10;
   AbsInt absObj;
   unsigned int ui = absOjb(i);
 


函数对象及其函数适配器
   定义了调用操作符的类,其对象常称作函数对象(function object),即它们的行为表现出类似于函数的行为。
 
   函数对象通常用作泛型算法的实参,如标准库中大量泛型算法有需要函数对象的版本。函数对象使用起来可以比函数灵活。标准库在头文件<functional>头文件中定义了一组算术、关系与逻辑函数对象类,还定义了一组函数适配器,使我们能够特化或者扩展标准库所定义的以及自定义的函数对象类。
 
 算术函数对象类型  plus<Type>   +
   minus<Type>  -
   multiplies<Type>  *
   divides<Type>  /
   modulus<Type>  %
   negate<Type>  -
 关系函数对象类型  equal_to<Type>  ==
   not_equal_to<Type>  !=
   greater<Type>  >
   greater_equal<Type>  >=
   less<Type>  <
   less_equal<Type>  <=
 逻辑函数对象类型  logical_and<Type>  &&
   logical_or<Type>  ||
   logical_not<Type>  !
 
   每个标准库函数对象类表示一个操作符,即,每个类都定义了应用命名操作的调用操作符。上面只有两个一元函数对象(unary function-object):negate<Type>和<logical_not<Type>,其它都是二元函数对象(binary function-object)。
 
   函数对象常用于覆盖算法中使用的默认操作符。在使用标准库的函数对象时,需要生成该模板类的一个实例对象,然后将生成的函数对象传递给算法。例如,sort默认使用operator<按升序对容器进行排序。为了按降序对容器进行排序,可以传递函数对象greater:
   sort (svec.begin(), svec.end(), greater<string>());
其中,greater<string>()表示生成一个比较元素类型为string类的greater函数对象。
 
   标准库提供了一组函数适配器(function adapter),用于特化和扩展一元和二元函数对象。函数适配器分为如下两类:
   1)绑定器(binder):它通过将一个操作数绑定到给定值而将二元函数对象转换为一元函数对象。
   2)求反器(negator):它将谓词函数对象的真值求反。
 
   标准库定义了两个绑定器适配器:bind1st和bind2nd。每个绑定器接受一个函数对象和一个值。bind1st将给定值绑定到二元函数对象的第一个实参,bind2nd将给定值绑定到二元函数对象的第二个实参。例如,为了计算一个容器中所有小于或等于10的元素的个数,可以这样给count_if传递值:
   count_if (vec.begin(), vec.end(), bind2nd(less_equal<int>(), 10));
 
   标准库还定义了两个求反器:not1和not2。not1将一元函数对象的真值求反,not2将二元函数对象的真值求反。例如:
   count_if (vec.begin(), vec.end(), not1(bind2nd(less_equal<int>(), 10)));
其效果是对不<=10的那些元素计数,即对>10的元素计数。
 

类类型转换
为什么需要类类型转换
 
   我们已经知道,对于没有explicit声明的单参构造函数,可以实现从参数类型到类类型的自动转换。C++也提供了这样一种机制:一个类可以定义自己到其它类型的转换。
 
   假如我们需要定义一个类与内置int类型进行混合算术、关系等运算,并且考虑到左右操作数的次序可以交换,若要设计该类则需要定义大量的操作符。此时,我们就可以定义一个从该类到int类型的转换。如果定义了该转换,则无须再定义任何算术、关系等操作符,就可以把该对象应用于任何可用于int类型的地方。
 
转换操作符
 
   转换操作符(conversion operator)是一种特殊的类成员函数,其形式为:
      operator type();
其中,type表示内置类型名、类类型名或由类型别名所定义的名字。对任何可作为函数返回类型的类型(void除外)都可以定义转换函数。一般而言,不允许转换为数组或函数类型,转换为指针类型(数据和函数指针)以及引用类型是可以的。
 
   转换函数必须是成员函数,不能指定返回类型,并且形参表必须为空。虽然转换函数不能指定返回类型(因为返回类型已经在转换目标类型中指定),但是每个转换函数必须显式返回一个指定类型的值。通常,转换操作符应定义为const成员。
 
使用类类型转换
 
   只要存在转换,编译器将在可以使用转换的地方自动调用它。使用转换函数时,被转换的类型不必与所需类型完全匹配。必要时可在类类型转换之后跟上标准转换以获得想要的类型。例如,假设SmallInt类定义了到int的转换,则:
   SmallInt si;
   double dval;
   si >= dval      // si converted to int and then convert to double
   if (si)         // si converted to int and then convert to bool
   int func(int);
   func(si);       // si converted to int and then call func
 
   // instruct compiler to cast si to int
   int ival = static_cast<int>(si) + 3;
 
   只能应用一个类类型转换:类类型转换之后不能再用另一个类类型转换而得到需要的类型。语言只允许一次类类型转换,否则将出错。
 
二义性(ambiguous)问题
 
   对于设计不好的程序,在实参匹配和转换过程中可能出现二义性问题。一般情况下,当有多个侯选匹配时,编译器一般会选择最佳匹配。并且在出现二义性问题时,一般可以通过强制类型转换消除二义性(虽然这是程序设计拙劣的表现)。但在某些情况下,那些看上去可以区分的情况编译器仍会标记为错误,还有些情况,无法通过强制转换消除二义性问题。
 
情景一:编译器将不会试图区别两个不同的类类型转换。具体而言,即使一个调用需要在类类型转换之后跟一个标准转换,而另一个是完全匹配,编译器仍会将该调用标记为错误。例如:
 
void compute(int );
void compute(long double);
class SmallInt
{
public:
   operator int() const {return val;}
   operator double() const {return val;}
private:
   std::size_t val;
};
SmallInt si;
compute(si);    // error: ambiguous
 
当然,这个二义性问题可以通过强制转换来消除。例如:compute(static_cast<int>(si));
 
情景二:当有多个类具有相同类型的单参构造函数时,可能会引起二义性问题。这种情况下,即使一个版本之前需要一个标准转换,而另一个版本不需要,编译器也会认为具有二义性。例如:
 
class SmallInt {
public:
   SmallInt(int);
};
class Integral {
   Integral(short);
};
void manip(const Integral&);
void manip(const SmallInt&);
manip(10);     // error: ambiguous
 
此时,可以通过显式调用构造函数消除二义性:manip(SmallInt(10));
 
情景三:当两个类定义了相互转换时,很可能存在二义性。例如:
 
class Integral;
class SmallInt {
public:
   SmallInt(Integral);
};
class Integral {
public:
   operator SmallInt() const;
};
void compute(SmallInt);
Integral val;
compute(val);     // error: ambigugous
 
上面这个例子来自《C++ Primer,中文E4,P460》。书上说,这种情况下的二义性问题不能通过显示转换消除,因为显式类型转换本身既可以使用转换操作符也可以使用构造函数。此时,需要显式调用转换操作符或构造函数:
   compute(val.operator SmallInt());      // ok: use conversion operator
   compute(SmallInt(val));                // ok: use SmallInt constructor
但事实上,我在MinGW2.05(GCC3.4.2)和GCC4.1里编译都没有二义性错误,这两个编译器都是优先调用构造函数完成转换。目前没有找到相关资料,先做个标记。
 
重载、转换和操作符
 
   操作符的重载确认遵循常见的三个步骤:
    1)选择候选函数;
    2)选择可行函数,包括识别每个实参的潜在转换序列;
    3)选择最佳匹配的函数。
 
   一般而言,候选函数集由所有与被使用的函数同名的函数构成,被使用的函数可以从函数调用处看到。对于操作符用在表达式中的情况,候选函数包括操作符的内置版本以及该操作符的普通非成员版本。另外,如果左操作数具有类类型,而且该类定义了该操作符的重载版本,则候选集将包含操作符的重载版本。
 
   正确设计类的重载操作符、转换函数和转换构造函数需要多加小心。尤其是,如果类既定义了转换操作符又定义了重载操作符,容易产生二义性。下面是一些经验法则供参考:
    1)不要定义相互转换的类,即如果类Foo具有接受类Bar的对象的构造函数,不要再为类Bar定义到类型Foo的转换操作符。
    2)避免到内置算术类型的转换。具体而言主,如果定义了到算术类型的转换,则:
      > 不要定义接受算术类型的操作符的重载版本。如果用户需要使用这些操作符,转换操作符将转换你所定义的类型的对象,然后可以使用内置操作符。
      > 不要定义转换到一个以上算术类型的转换。让标准转换提供到其他算术类型的转换。
 

   如果文中有错误或遗漏之处,敬请指出,谢谢!


 

参考文献:
[1] C++ Primer(Edition 4)
[2] Thinking in C++(Volume Two, Edition 2)
[3] International Standard:ISO/IEC 14882:1998

抱歉!评论已关闭.