【C++ Primer Plus】第十一章:使用类

第11章:使用类

11.1 运算符重载

运算符重载是一种形式的C++多态。要重载运算符,需使用被称为运算符函数的特殊函数形式。运算符函数的格式如下:

operator op(argument list);

例如,operator +( )重载+运算符,operator ( )重载运算符。op必须是有效的C++运算符,不能虚构一个新的符号。例如,不能有operator@( )这样的函数,因为C++中没有@运算符。

district2 = sid + sara;
district2 = sid.operator+(sara);
//operator +( )函数的名称使得可以使用函数表示法或运算符表示法来调用它。编译器将根据操作数的类型来确定如何做

该函数将隐式地使用sid(因为它调用了方法),而显式地使用sara对象(因为它被作为参数传递),来计算总和,并返回这个值。当然最重要的是,可以使用简便的+运算符表示法,而不必使用笨拙的函数表示法。

11.1.1 重载的限制

  1. 重载后的运算符必须至少有一个操作数是用户定义的类型,这将防止用户为标准类型重载运算符。因此,不能将减法运算符(−)重载为计算两个double值的和,而不是它们的差。虽然这种限制将对创造性有所影响,但可以确保程序正常运行。
  2. 使用运算符时不能违反运算符原来的句法规则。例如,不能将求模运算符(%)重载成只使用一个操作数。同样,不能修改运算符的优先级。因此,如果将加号运算符重载成将两个类相加,则新的运算符与原来的加号具有相同的优先级。
  3. 不能创建新运算符。例如,不能定义operator **( )函数来表示求幂。
  4. 不能重载下面的运算符。
sizeof             :sizeof运算符。
.                  :成员运算符。
. *                :成员指针运算符。
::                 :作用域解析运算符。
?:                 :条件运算符。
typeid             :一个RTTI运算符。
const_cast         :强制类型转换运算符。
dynamic_cast       :强制类型转换运算符。
reinterpret_cast   :强制类型转换运算符。
static_cast        :强制类型转换运算符。
  1. 表11.1中的大多数运算符都可以通过成员或非成员函数进行重载,但下面的运算符只能通过成员函数进行重载。
=  :赋值运算符。
( ):函数调用运算符。
[ ]:下标运算符。
-> :通过指针访问类成员的运算符。

11.1.2 可重载的运算符

+ - * / % ^
& | ~= ! = <
> += -= *= /= %=
^= &= |= << >> >>=
<<= == != <= >= &&
|| ++ , ->* ->
() [] new delete new[] delete[]

除了这些正式限制之外,还应在重载运算符时遵循一些明智的限制,尽量符合常识,见闻知意。

这一块知识书里论述的没有侯捷的教学视频全面。

11.2 友元

C++控制对类对象私有部分的访问。通常,公有类方法提供唯一的访问途径,但是有时候这种限制太严格,以致于不适合特定的编程问题。在这种情况下,C++提供了另外一种形式的访问权限:友元。友元有3种:

  • 友元函数;
  • 友元类;
  • 友元成员函数。

通过让函数成为类的友元,可以赋予该函数与类的成员函数相同的访问权限。

为什么需要友元?

例如,我们有一个时间类,并且运用上面的知识,实现了对运算符的重载:

扫描二维码关注公众号,回复: 14678707 查看本文章
A = B * 2.75;//相当于 A = B.operator*(2.75);

但是这种调用就不适合:

A = 2.75 * B;

从概念上说,2.75 * B应与B *2.75相同,但代码实现并不符合这种常识。

解决方案

一,告诉用户只能使用第一种,其他的均不适用。显然这并不友好。

二,非成员函数。非成员函数不是由对象调用的,它使用的所有值(包括对象)都是显式参数。

Time operator*(double m, const Time& t);

对于非成员重载运算符函数来说,运算符表达式左边的操作数对应于运算符函数的第一个参数,运算符表达式右边的操作数对应于运算符函数的第二个参数。而原来的成员函数则按相反的顺序处理操作数,至于为什么可以参考侯捷视频中关于这一点的探讨。

我们通过非成员函数确实可以实现我们的目的,但引发了一个新问题:非成员函数不能直接访问类的私有数
据,至少常规非成员函数不能访问。然而,有一类特殊的非成员函数可以访问类的私有成员,它们被称为友元函数。

11.2.1 创建友元

创建友元函数的第一步是将其原型放在类声明中,并在原型声明前加上关键字friend:

friend Time operator* (double m, const Time & t);

该原型意味着以下两点:

  • 虽然operator *( )函数是在类声明中声明的,但它不是成员函数,因此不能使用成员运算符来调用;
  • 虽然operator *( )函数不是成员函数,但它与成员函数的访问权限相同。

第二步是编写函数定义。因为它不是成员函数,所以不要使用Time::限定符。另外,不要在定义中使用关键字friend。

有了以上步骤,下面的语句才成立:

A = 2.75 * B;//相当于 A = operator*(2.75,B);

总之,类的友元函数是非成员函数,其访问权限与成员函数相同。

友元是否有悖于OOP?

乍一看,你可能会认为友元违反了OOP数据隐藏的原则,因为友元机制允许非成员函数访问私有数据。然而,这个观点太片面了。相反,应将友元函数看作类的扩展接口的组成部分。例如,从概念上看,double乘以Time和Time乘以double是完全相同的。也就是说,前一个要求有友元函数,后一个使用成员函数,这是C++句法的结果,而不是概念上的差别。通过使用友元函数和类方法,可以用同一个用户接口表达这两种操作。另外请记住,只有类声明可以决定哪一个函数是友元,因此类声明仍然控制了哪些函数可以访问私有数据。总之,类方法和友元只是表达类接口的两种不同机制。

实际上,按下面的定义进行修改,可以将友元函数编写为非友元函数:

Time operator*(double m,const Time & t){
    return t*m;//use t.operator*(m)
}

如果要为类重载运算符,并将非类的项作为其第一个操作数,则可以用友元函数来反转操作数的顺序。

11.2.2 常用的友元:重载<<运算符

cout是一个ostream对象,能够识别所有的C++基本类型。这是因为对于每种基本类型,ostream类声明中都包含了相应的重载的operator<<( )定义。因此,要使cout能够识别Time对象,一种方法是将一个新的函数运算符定义添加到ostream类声明中。但修改iostream文件是个危险的主意,这样做会在标准接口上浪费时间。相反,通过Time类声明来让Time类知道如何使用cout。

<<的第一种重载版本

要使Time类知道使用cout,必须使用友元函数。调用cout << trip应使用cout对象本身,而不是它的拷贝,因此该函数按引用(而不是按值)来传递该对象。这样,表达式cout << trip将导致os成为cout的一个别名;而表达式cerr << trip将导致os成为cerr的一个别名。Time对象可以按值或按引用来传递,因为这两种形式都使函数能够使用对象的值。按引用传递使用的内存和时间都比按值传递少。

<<的第二种重载版本

ostream& operator<< (ostream& os,const Time & t){return os;}
cout<<trip;//实际上就相当于
operator<<(cout,trip);

这样做的结果就是:函数的返回值就是传递给它的对象。有趣的是,这个operator版本还可以将输出写入到文件中。

#include<fstream>
...
ofstream fout;
fout.open("savetime.txt");
Time trip(12,40);
fout<<trip;//这条语句将会被转换为这样:operator<<(fout,trip);

类继承属性让ostream引用能够指向ostream对象和ofstream对象。

一般来说,要重载<<运算符来显示c_name的对象,可使用一个友元函数,其定义如下:

ostream & operator << (ostream & os , sonst c_name & obj){
    os << ...;
    return os;
}

只有在类声明中的原型中才能使用friend关键字。除非函数定义也是原型,否则不能在函数定义中使用该关键字。

11.3 重载运算符:作为成员函数还是非成员函数

对于很多运算符来说,可以选择使用成员函数或非成员函数来实现运算符重载。一般来说,非成员函数应是友元函数,这样它才能直接访问类的私有数据。

Time operator+(const Time & T) const;//成员函数版本 T1 = T2.operator+(T3);
friend Time operator+(const Time & t1 , const Time & t2);//非成员函数版本 T1 = operator+(T2,T3);
//加法运算符需要两个操作数。对于成员函数版本来说,一个操作数通过this指针隐式地传递,另一个操作数作为函数参数显式地传递;对于友元版本来说,两个操作数都作为参数来传递。

在定义运算符时,必须选择其中的一种格式,而不能同时选择这两种格式。因为这两种格式都与同一个表达式匹配,同时定义这两种格式将被视为二义性错误,导致编译错误。

11.4 再谈重载:一个矢量类

有大小,有方向的量叫做矢量;关于矢量的表示方法:

  • 可以用大小(长度)和方向(角度)描述矢量;
  • 可以用分量x和y表示矢量。

11.5 类的自动转换和强制类型转换

lone count = 8;     //int 8  被强制转换成了long
double time = 11;   //int 11 被强制转换成了double
int side = 3.3      //double 被强转换成了int

C++不自动转换不兼容的类型,但可以强制转换。

int*p = 10; //不允许
int*p = (int*) 10;//允许,将指针设置为地址10,但这种赋值是否有意义是另外一回事。

可以将类定义成与基本类型或另一个类相关,使得从一种类型转换为另一种类型是有意义的。在这种情况下,程序员可以指示C++如何自动进行转换,或通过强制类型转换来完成。但只有接受一个参数的构造函数才能作为转换函数。(注意:这里指的是接收一个参数,不是只有一个参数)

Stonewt(double  lbs);//这样一个构造函数将有能力实现double到Stonewt的强转
Stonewt myCat;
myCat = 19.6;
Stonewt(int stn , double  lbs = 0);//如果给第二个参数提供默认值,将有能力转换int。

如何关闭这种特性?

将构造函数用作自动类型转换函数似乎是一项不错的特性。然而,当程序员拥有更丰富的C++经验时,将发现这种自动特性并非总是合乎需要的,因为这会导致意外的类型转换。因此,C++新增了关键字explicit,用于关闭这种自动特性。也就是说,可以这样声明构造函数:

explicit Stonewt(double lbs);//这将关闭上述示例中介绍的隐式转换,但仍然允许显式转换,即显式强制类型转换.
Stonewt myCat;  //定义一个对象
myCat = 19.6;   //not ok
mycat = Stonewt(19.6);  //ok
myCat = (Stonewt)19.6;  //ok

函数原型化提供的参数匹配过程:

//下面两条语句都首先将int转换为double,然后使用Stonewt(double)构造函数。
Stonewt Jumbo(7000);
Jumbo = 7300;

然而,当且仅当转换不存在二义性时,才会进行这种二步转换。也就是说,如果这个类还定义了构造函数Stonewt(long),则编译器将拒绝这些语句,可能指出:int可被转换为long或double,因此调用存在二义性。

11.6 转换函数

是否可以将Stonewt对象转换为double值?

Stonewt wolfe(285.7);
double host = wolfe;

可以这样做,但不是使用构造函数。构造函数只用于从某种类型到类类型的转换。要进行相反的转换,必须使用特殊的C++运算符函数——转换函数。转换函数是用户定义的强制类型转换,可以像使用强制类型转换那样使用它们。

Stonewt wolfe(285.7);
double host = double(wolfe);
double thinker = (double)wolfe;
double thinker = wolfe;

那么,如何创建转换函数呢?要转换为typeName类型,需要使用这种形式的转换函数:

operator typeName();
//例如,转换为double类型的函数的原型如下:
operator double();

typeName(这里为double)指出了要转换成的类型,因此不需要指定返回类型。转换函数是类方法意味着:它需要通过类对象来调用,从而告知函数要转换的值。因此,函数不需要参数。

例如:

operator int() const;
operator double() const;

Stonewt::operator int() const{
    return int(pounds+0.5);
}
Stonewt::operator double() const{
    return pounds;
}

注意,虽然没有声明返回类型,这两个函数也将返回所需的值。另外,int转换将待转换的值四舍五入为最接近的整数,而不是去掉小数部分。例如,如果pounds为114.4,则pounds +0.5等于114.9,int(114.9)等于114。但是如果pounds为114.6,则pounds + 0.5是115.1,而int(115.1)为115。

原则上说,最好使用显式转换,而避免隐式转换。C++11中关键字explicit也同样适用于转换函数。

explicit operator int() const;
explicit operator double() const;

有了这些声明后,需要强制转换时将调用这些运算符。

另一种方法是,用一个功能相同的非转换函数替换该转换函数即可,但仅在被显式地调用时,该函数才会执行。

Stonewt::operator int(){return int (pounds + 0.5);}//可以替换为
int Stonewt::Stone_to_int(){return int(pounds+0.5);}
int plb = poppins;//Not ok
int plb = poppins.Stone_to_int();//ok

总之,C++为类提供了下面的类型转换:

  • 只有一个参数的类构造函数用于将类型与该参数相同的值转换为类类型。例如,将int值赋给Stonewt对象时,接受int参数的Stonewt类构造函数将自动被调用。然而,在构造函数声明中使用explicit可防止隐式转换,而只允许显式转换。
  • 被称为转换函数的特殊类成员运算符函数,用于将类对象转换为其他类型。转换函数是类成员,没有返回类型、没有参数、名为operator typeName( ),其中,typeName是对象将被转换成的类型。将类对象赋给typeName变量或将其强制转换为typeName类型时,该转换函数将自动被调用。

11.7 转换函数与友元函数

使用成员函数与友元函数都可以实现操作符的重载,如果是用成员函数实现的函数重载,则其第一个操作数必定是函数对象;如果是用友元函数实现的操作符重载,如果提供了Stonewt(double)构造函数,则其第一个参数可以为double类型。因为会首先将第一个double值强制转换为Stonewt对象。

但是在这种情况下,如果定义了operator double()成员函数,将造成混乱,因为该函数将提供另一种解释方式。编译器不是将kennyD转换为double并执行Stonewt加法,而是将jennySt转换为double并执行double加法。过多的转换函数将导致二义性。

total = jennySt + KennyD;//将转换为
total = operator+(jennySt, KennyD);

实现加法时的选择

要将double量和Stonewt量相加,有两种选择。第一种方法是将下面的函数定义为友元函数,让Stonewt(double)构造函数将double类型的参数转换为Stonewt类型的参数:

operator+(const Stonewt & , const Stonewt &);

第二种方法是,将加法运算符重载为一个显式使用double类型参数的函数:

Stonewt operator + (double x);//成员函数
friend Stonewt operator + (double x, Stonewt& t);

这样,下面的语句将与成员函数operator + (double x)完全匹配:

total = jennySt + kennyD;

而下面的语句将与友元函数operator + (double x, Stonewt &s)完全匹配:

total = pennyD + jennySt;

每一种方法都有其优点。第一种方法(依赖于隐式转换)使程序更简短,因为定义的函数较少。这也意味程序员需要完成的工作较少,出错的机会较小。这种方法的缺点是,每次需要转换时,都将调用转换构造函数,这增加时间和内存开销。第二种方法(增加一个显式地匹配类型的函数)则正好相反。它使程序较长,程序员需要完成的工作更多,但运行速度较快。

如果程序经常需要将double值与Stonewt对象相加,则重载加法更合适;如果程序只是偶尔使用这种加法,则依赖于自动转换更简单,但为了更保险,可以使用显式转换。

总结

一般来说,访问私有类成员的唯一方法是使用类方法。C++使用友元函数来避开这种限制。要让函数成为友元,需要在类声明中声明该函数,并在声明前加上关键字friend。

C++扩展了对运算符的重载,允许自定义特殊的运算符函数,这种函数描述了特定的运算符与类之间的关系。运算符函数可以是类成员函数,也可以是友元函数(有一些运算符函数只能是类成员函数)。要调用运算符函数,可以直接调用该函数,也可以以通常的句法使用被重载的运算符。对于运算符op,其运算符函数的格式如下:

operator op (argument list)

argument-list表示该运算符的操作数。如果运算符函数是类成员函数,则第一个操作数是调用对象,它不在argument-list中。例如,本章通过为Vector类定义operator +( )成员函数重载了加法。如果up、right和result都是Vector对象,则可以使用下面的任何一条语句来调用矢量加法:

result = up.operator + right;
result = up + right;

在第二条语句中,由于操作数up和right的类型都是Vector,因此C++将使用Vector的加法定义。

当运算符函数是成员函数时,则第一个操作数将是调用该函数的对象。例如,在前面的语句中,up对象是调用函数的对象。定义运算符函数时,如果要使其第一个操作数不是类对象,则必须使用友元函数。这样就可以将操作数按所需的顺序传递给函数了。

最常见的运算符重载任务之一是定义<<运算符,使之可与cout一起使用,来显示对象的内容。要让ostream对象成为第一个操作数,需要将运算符函数定义为友元;要使重新定义的运算符能与其自身拼接,需要将返回类型声明为ostream &。下面的通用格式能够满足这种要求:

ostream & operator << (ostream & os , sonst c_name & obj){
    os << ...;
    return os;
}

然而,如果类包含这样的方法,它返回需要显示的数据成员的值,则可以使用这些方法,无需在operator<<( )中直接访问这些成员。在这种情况下,函数不必(也不应当)是友元。

C++允许指定在类和基本类型之间进行转换的方式。首先,任何接受唯一一个参数的构造函数都可被用作转换函数,将类型与该参数相同的值转换为类。如果将类型与该参数相同的值赋给对象,则C++将自动调用该构造函数。例如,假设有一个String类,它包含一个将char *值作为其唯一参数的构造函数,那么如果bean是String对象,则可以使用下面的语句:

bean = "pinto";

然而,如果在该构造函数的声明前加上了关键字explicit,则该构造函数将只能用于显式转换:

bean = String("pinto");

要将类对象转换为其他类型,必须定义转换函数,指出如何进行这种转换。转换函数必须是成员函数。将类对象转换为typeName类型的转换函数的原型如下:

operator typeName();

注意,转换函数没有返回类型、没有参数,但必须返回转换后的值(虽然没有声明返回类型)。例如,下面是将Vector转换为double类型的函数:

Vector::operator double(){
    ...
        return a_double_value;
}

经验表明,最好不要依赖于这种隐式转换函数。

猜你喜欢

转载自blog.csdn.net/weixin_43717839/article/details/129939812