第十一章:多态
多态基础
多态(Polymorphism)是面向对象语言的一种特征,让您能够以类似的方式处理不同类似的对象。
有一句话总结的很好:多态其实就是父类型引用指向子类型对象。
为什么需要多态
#include <iostream>
using namespace std;
class Fish {
public:
void Swim() {
cout << "Fish swims!" << endl;
}
};
class Tuna : public Fish {
public:
void Swim() {
cout << "Tuna swims!" << endl;
}
};
void MakeFishSwim(Fish& inputFish) {
inputFish.Swim();
}
int main() {
Tuna myDinner;
MakeFishSwim(myDinner);
return 0;
}
从结果可以看出,Tuna 从 Fish 那里继承了方法 Swim( )。然而,Tuna 提供了自己的 Swim( )方法,以定制其游泳方式,但鉴于它是 Fish,如果将 Tuna 实例作为实参传递给 Fish 参数,并通过该参数调用 Swim( ),最终执行的将是 Fish::Swim( ),而不是Tuna::Swim( )。
如果希望调用 inputFish.Swim( ) 时执行的是 Tuna::Swim( ),那么这就是一种多态技术。
要实现这种多态行为—让 Fish 参数表现出其实际类型(派生类 Tuna)的行为,可将 Fish::Swim( )声明为虚函数。
使用虚函数实现多态行为
可通过 Fish 指针或 Fish 引用来访问 Fish 对象,这种指针或引用可指向 Fish、Tuna 或 Carp 对象,但您不知道也不关心它们指向的是哪种对象。
您希望通过这种指针或引用调用 Swim( )时,如果它们指向的是 Tuna 对象,则像 Tuna 那样游泳,如果指向的是 Carp 对象,则像 Carp 那样游泳,如果指向的是 Fish,则像 Fish 那样游泳。为此,可在基类 Fish 中将 Swim( )声明为虚函数:
class Base
{
//使用关键字 virtual 来声明一个虚函数以实现多态
virtual ReturnType FunctionName (Parameter List);
};
class Derived
{
ReturnType FunctionName (Parameter List);
};
示例程序如下:
#include <iostream>
using namespace std;
class Fish {
public:
virtual void Swim() {
cout << "Fish swims!" << endl;
}
};
class Tuna : public Fish {
public:
//重写了父类中的 Swim 虚函数
void Swim() {
cout << "Tuna swims!" << endl;
}
};
void MakeFishSwim(Fish& inputFish) {
//此时将会调用虚函数 Swim
inputFish.Swim();
}
int main() {
Tuna myDinner;
MakeFishSwim(myDinner);
return 0;
}
为何需要虚构造函数
将派生类对象传递给基类参数时,并通过该参数调用函数时,将执行基类的函数。
然而,还存在一个问题:如果基类指针指向的是派生类对象,通过该指针调用运算符 delete 时,结果将如何呢?将调用哪个析构函数呢?看示例代码:
#include <iostream>
using namespace std;
class Fish {
public:
Fish() {
cout << "Fish 构造函数" << endl;
}
~Fish() {
cout << "Fish 析构函数" << endl;
}
};
class Tuna : public Fish {
public:
Tuna() {
cout << "Tuna 构造函数" << endl;
}
~Tuna() {
cout << "Tuna 析构函数" << endl;
}
};
void DeleteFishMemory(Fish* pFish) {
delete pFish;
}
int main() {
cout << "在堆空间申请资源创建 Tuna 子类对象" << endl;
Tuna* tuna = new Tuna;
cout << "销毁堆空间上的 Tuna 子类对象" << endl;
DeleteFishMemory(tuna);
cout << "在栈空间上创建一个 Tuna 子类对象" << endl;
Tuna myTuna;
cout << "当 main 函数结束后 Tuna 子类对象自动销毁" << endl;
return 0;
}
容易发现,在堆空间上申请的内存,只析构了父类 Fish 的内存空间,却没析构子类 Tuna 的内存空间,这就造成了内存泄漏,与之产生鲜明对比的则是在栈空间上 Fish 和 Tuna 都被析构掉了。
这个程序表明,对于使用 new 在自由存储区中实例化的派生类对象,如果将其赋给基类指针,并通过该指针调用 delete,将不会调用派生类的析构函数。这可能导致资源未释放、内存泄露等问题,必须引起重视。
要避免这种问题,可以将父类的析构函数声明为虚函数,将父类的析构函数声明为虚函数,确保通过基类指针调用 delete 时,将调用派生类的析构函数。
因此上面的程序只改动一行即可:
virtual ~Fish() {
cout << "Fish 析构函数" << endl;
}
运行结果如图:
输出还表明,无论 Tuna 对象是使用 new 在自由存储区中实例化的,还是以局部变量的方式在栈中实例化的,构造函数和析构函数的调用顺序都相同。
虚函数工作原理
一段之前的代码:
#include <iostream>
using namespace std;
class Fish {
public:
virtual void Swim() {
cout << "Fish swims!" << endl;
}
};
class Tuna : public Fish {
public:
//重写了父类中的 Swim 虚函数
void Swim() {
cout << "Tuna swims!" << endl;
}
};
void MakeFishSwim(Fish& inputFish) {
//此时将会调用虚函数 Swim
inputFish.Swim();
}
class Carp:public Fish {
public:
// 重写了父类中的 Swim 虚函数
void Swim() {
cout << "Carp swims!" << endl;
}
};
int main() {
Tuna myDinner;
MakeFishSwim(myDinner);
return 0;
}
在上面代码的函数 MakeFishSwim( )中,虽然程序员通过 Fish 引用调用 Swim( ),但实际调用的却是方法 Carp::Swim( )或 Tuna::Swim( )。
显然,在编译阶段,编译器并不知道将传递给该函数的是哪种对象,无法确保在不同的情况下执行不同的 Swim( )方法。该调用哪个 Swim( )方法显然是在运行阶段决定的,这是使用实现多态的逻辑完成的,而这种逻辑是编译器在编译阶段提供的。
请看下面的 Base 类,它声明了 N 个虚函数:
class Base {
public:
virtual void Func1() {
// Func1 implementation
}
virtual void Func2() {
// Func2 implementation
}
// .. so on and so forth
virtual void FuncN() {
// FuncN implementation
}
};
下面的 Derived 类继承了 Base 类,并覆盖了除 Base::Func2( ) 外的其他所有虚函数:
class Derived: public Base {
public:
virtual void Func1() {
// Func2 overrides Base::Func2()
}
// no implementation for Func2()
virtual void FuncN() {
// FuncN implementation
}
};
编译器见到这种继承层次结构后,知道 Base 定义了一些虚函数,并在 Derived 中覆盖了它们。在这种情况下,编译器将为实现了虚函数的基类和覆盖了虚函数的派生类分别创建一个虚函数表(Virtual Function Table,VFT)。
换句话说,Base 和 Derived 类都将有自己的虚函数表。
实例化这些类的对象时,将创建一个隐藏的指针(我们称之为 VFT*),它指向相应的 VFT。可将 VFT 视为一个包含函数指针的静态数组,其中每个指针都指向相应的虚函数,如图 11.1 所示。
每个虚函数表都由函数指针组成,其中每个指针都指向相应虚函数的实现。在类 Derived 的虚函数表中,除一个函数指针外,其他所有函数指针都指向 Derived 本地的虚函数实现。Derived 没有覆盖Base::Func2( ),因此相应的函数指针指向 Base 类的 Func2( )实现。
这意味着遇到下述代码时,编译器将查找 Derived 类的 VFT,确保调用 Base::Func2( )的实现:
CDerived objDerived;
objDerived.Func2();
调用被覆盖的方法时,也将如此:
void DoSomething(Base& objBase) {
objBase.Func1(); // invoke Derived::Func1
}
int main() {
Derived objDerived;
DoSomething(objDerived);
};
在这种情况下,虽然将 objDerived 传递给了 objBase,进而被解读为一个 Base 实例,但该实例的VFT 指针仍指向 Derived 类的虚函数表,因此通过该 VTF 执行的是 Derived::Func1( )。
虚函数表就是这样帮助实现 C++多态的。
下面的代码将 sizeof 用于两个相同的类(一个包含虚函数,另一个不包含),并对结果进行比较,从而证明了确实存在隐藏的虚函数表指针。
#include <iostream>
using namespace std;
// 没有虚函数的类
class SimpleClass {
int a, b;
public:
void DoSomething(){}
};
// 有虚函数的类
class Base {
int a, b;
public:
virtual void DoSomething(){}
};
int main() {
cout << "Sizeof(SimpleClass) = " << sizeof(SimpleClass) << endl;
cout << "Sizeof(Base) = " << sizeof(Base) << endl;
return 0;
}
使用 32 位编译器的输出运行结果如下:
使用 64 位编译器的输出运行结果如下:
上述代码最大程度地简化了这个示例。其中有两个类—SimpleClass 和 Base,它们包含的成员数量和类型都相同,但在 Base 中,将 DoSomething( )声明成了虚函数,而在 SimpleClass 中没有这样做。添加关键字 virtual 带来的影响是,编译器将为 Base 类生成一个虚函数表,并为其虚函数表指针(一个隐藏成员)预留空间。在 32 位系统中,Base 类占用的内存空间多了 4 字节,这证明确实存在这样的指针。
最后,相信从上面的代码中也发现了,在C++中,当子类重写(也称为覆盖或覆盖实现)父类中的虚方法时,virtual 关键字在子类的实现中是可选的。一旦父类中的方法被声明为 virtual,那么该方法的所有子类实现(只要它们具有相同的函数签名,包括返回类型、函数名和参数列表)都将自动被视为虚函数,无论子类中的方法声明是否显式地包含了 virtual 关键字。
抽象基类和纯虚函数
不能实例化的基类被称为抽象基类,这样的基类只有一个用途,那就是从它派生出其他类。在 C++ 中,要创建抽象基类,可声明纯虚函数。
以下述方式声明的虚函数被称为纯虚函数:
class AbstractBase{
public:
virtual void DoSomething() = 0; // pure virtual method
}
该声明告诉编译器,AbstractBase 的派生类必须实现方法 DoSomething( ):
class Derived: public AbstractBase{
public:
// 纯虚函数必须被实现
void DoSomething(){
cout << "Implemented virtual function" << endl;
}
}
AbstractBase 类要求 Derived 类必须提供虚方法 DoSomething( )的实现。这让基类可指定派生类中方法的名称和特征标,即指定派生类的接口。
虽然不能实例化抽象基类,但可将指针或引用的类型指定为抽象基类。
抽象基类提供了一种非常好的机制,让您能够声明所有派生类都必须实现的函数。
使用虚继承解决菱形问题
来看下面的继承脉络:
这样的多继承就是菱形继承,那么这种情况下实例化 Platypus 时,结果将如何呢?对于每个 Platypus 实例,将实例化多少个 Animal 实例呢?下面的程序帮助回答了这个问题。
#include <iostream>
using namespace std;
// 父类
class Animal {
public:
Animal() {
cout << "Animal constructor" << endl;
}
int age;
};
// 公有继承自 Animal
class Mammal :public Animal {
};
// 公有继承自 Animal
class Bird :public Animal {
};
// 公有继承自 Animal
class Reptile :public Animal {
};
// 菱形多继承
class Platypus : public Mammal, public Bird, public Reptile {
public:
Platypus() {
cout << "Platpus constructor" << endl;
}
};
int main() {
Platypus duckBilledP;
//下面这行代码编译错误,因为其自动创建了三个 Animal 实例
//duckBilledP.age = 25;
return 0;
}
输出结果:
输出表明,由于采用了多继承,且 Platypus 的全部三个基类都是从 Animal 类派生而来的,因此创建 Platypus 实例时,自动创建了三个 Animal 实例。存在多个 Animal 实例带来的问题并非仅限于会占用更多内存。Animal 有一个整型成员—Animal::age,为了方便说明问题,将其声明成了公有的。如果您试图通过 Platypus 实例访问 Animal::age(如第 42 行所示),将导致编译错误,因为编译器不知道您要设置 Mammal::Animal::age、Bird::Animal::age 还是 Reptile::Animal::age。
离谱的是,如果我们愿意,甚至可以分别设置这三个属性:
duckBilledP.Mammal::Animal::age = 25;
duckBilledP.Bird::Animal::age = 32;
duckBilledP.Reptile::Animal::age = 22;
这样的代码并不报错,是正确的。
显然,鸭嘴兽 Platypus 应该只有一个age属性,但您希望Platypus类以公有方式继承Mammal、Bird和Reptile。解决方案是使用虚继承。
因此,如果派生类可能被用作基类,派生它是最好使用关键字 virtual:
class Derived1: public virtual Base{
// ...
};
class Derived2: public virtual Base{
// ...
};
因此下面的程序是更健壮的程序,在继承层次结构中使用关键字 virtual,将基类 Animal 的实例个数限定为 1:
#include <iostream>
using namespace std;
class Animal {
public:
Animal() {
cout << "Animal constructor" << endl;
}
int age;
};
class Mammal :public virtual Animal {
};
class Bird :public virtual Animal {
};
class Reptile :public virtual Animal {
};
// 多继承
class Platypus final: public Mammal, public Bird, public Reptile {
public:
Platypus() {
cout << "Platpus constructor" << endl;
}
};
int main() {
Platypus duckBilledP;
duckBilledP.age = 25;
return 0;
}
输出结果如下:
与之前的程序相比,将发现构造的 Animal 实例数减少到了 1 个,这表明只构造了一个 Platypus。这是因为从 Animal 类派生 Mammal、Bird 和 Reptile 类时,使用了关键字 virtual,这样 Platypus 继承这些类时,每个 Platypus 实例只包含一个 Animal 实例。这解决了很多问题,其中之一是第 41 行能够通过编译,不再像程序之前那样存在二义性。
另外,注意到第 27 行使用了关键字 final 以禁止将 Platypus 类用作基类。
表明覆盖意图的限定符 override
从 C++11 起,程序员可使用限定符 override 来核实被覆盖的函数在基类中是否被声明为虚的。
以之前的代码为例, Tuna 重写 Fish 父类中的 Swim() 方法时不小心加了 const,此时就没办法重写 Fish 中的方法了,因为二者的特征标志不同,不是重写关系。
这时候就容易让程序员误以为是重写了的,就会发生错误,此时就可以使用 override 关键字让编译器进行检查:
class Tuna:public Fish{
public:
//此时就会报错,Fish 父类中不存在下面这样的函数
void Swim() const override {
cout << "Tuna swims" << endl;
}
}
换而言之,override 提供了一种强大的途径,让程序员能够明确地表达对基类的虚函数进行覆盖的意图,进而让编译器做如下检查:
• 基类函数是否是虚函数?
• 基类中相应虚函数的特征标是否与派生类中被声明为 override 的函数完全相同?
使用 final 来禁止覆盖函数
C++11 引入了限定符 final,这在第 10 章介绍过。被声明为 final 的类不能用作基类,同样,对于被声明为 final 的虚函数,不能在派生类中进行覆盖。
可将复制构造函数声明为虚函数吗
从技术上说,C++不支持虚复制构造函数。
根本就不可能实现虚复制构造函数,因为在基类方法声明中使用关键字 virtual 时,表示它将被派生类的实现覆盖,这种多态行为是在运行阶段实现的。而构造函数只能创建固定类型的对象,不具备多态性,因此 C++不允许使用虚复制构造函数。
虽然如此,但我们可以自己定义克隆函数来实现上述目的。
小结
在本章中,您学习了如何使用多态,以充分发挥 C++继承的威力。您学习了如何声明和编写虚函数。通过基类指针或引用调用虚方法时,如果它指向的是派生类对象,将调用派生类的方法实现。纯虚函数是一种特殊的虚函数,确保基类不能被实例化,让这种基类非常适合用于定义派生类必须实现的接口。最后,您学习了多继承导致的菱形问题以及如何使用虚继承解决这种问题。
第十二章:运算符类型和运算符重载
除封装数据和方法外,类还能封装运算符,以简化对实例执行的操作。通过使用这些运算符,可以像第 5 章处理整数那样,对对象执行赋值或加法运算。与函数一样,运算符也可以重载。
C++运算符
从语法层面看,除使用关键字 operator 外,运算符与函数几乎没有差别。运算符声明看起来与函数声明极其相似:
return_type operator operator_symbol (...parameter list...);
要实现相关运算符,需要做额外的工作,但类使用起来将更容易,因此值得这样做。
C++运算符分两大类:单目运算符 与 双目运算符。
单目运算符
单目运算符只对一个操作数进行操作。
实现为全局函数或静态成员函数的单目运算符的典型定义如下:
return_type operator operator_type (parameter_type){
// ... implementation
}
作为类成员(非静态函数)的单目运算符没有参数,因为它们使用的唯一参数是当前类实例(*this),如下所示:
return_type operator opertator_type(){
// ... implementation
}
单目运算符的类型
可重载(或重新定义)的单目运算符如表 12.1 所示。
单目递增与单目递减运算符
下面的程序是一个简单的 Date 类,让您能够使用运算符++对日期进行递增。
#include <iostream>
using namespace std;
class Date {
private:
int day, month, year;
public:
Date(int inMonth, int inDay, int inYear)
: month(inMonth), day(inDay), year(inYear) {};
// 前置++
Date& operator ++() {
++day;
return *this;
}
//后置++
//后置++与前置++不同的地方在于 返回类型 和 参数
Date operator ++(int) {
//先复制当前对象,copy不是一个函数,我服了,它是一个对象
Date copy(month, day, year);
//再将对当前对象执行递增或递减运算
++day;
//最后返回复制的对象
return copy;
}
//后置--
Date operator -- (int) {
Date copy(month, day, year);
--day;
return copy;
}
//前置--
Date& operator --() {
--day;
return *this;
}
void DisplayDate() {
cout << month << " / " << day << " / " << year << endl;
}
};
int main() {
Date hoilday(12, 25, 2016);
cout << "The day object is initialized to: ";
hoilday.DisplayDate();
++hoilday;
cout << "Date after prefix-increment is: ";
hoilday.DisplayDate();
--hoilday;
cout << "Date after prefix-decrement is: ";
hoilday.DisplayDate();
hoilday++;
cout << "Date after postfix-increment is: ";
hoilday.DisplayDate();
hoilday--;
cout << "Date after postfix-decrement is: ";
hoilday.DisplayDate();
return 0;
}
输出如下:
转换运算符
我们试着直接输出 holiday,会发现其是有问题的:
cout << holiday; // error in absence of conversion operator
因为 cout 不知道如何解读 Date 实例,因为 Date类不支持这样的运算符。因此我们需要将 Date 对象的内容转换成 cout 能够接受的类型。
显然,cout 能够很好地显示 const char *:
std::cout << "Hello world"; // const char* works!
因此,要让 cout 能够显示 Date 对象,只需添加一个返回 const char*的运算符:
operator const char*() {
// operator implementation that returns a char*
}
示例代码如下所示:
#include <iostream>
#include <string>
#include <sstream>
using namespace std;
class Date {
private:
int day, month, year;
string dateInString;
public:
Date(int inMonth, int inDay, int inYear)
: month(inMonth), day(inDay), year(inYear) {};
operator const char* () {
ostringstream formattedDate;
formattedDate << month << " / " << day << " / " << year;
dateInString = formattedDate.str();
return dateInString.c_str();
}
};
int main() {
Date Holiday(12, 25, 2016);
cout << "Holiday is on: " << Holiday << endl;
//string strHoliday(Holiday); OK
//strHoliday = Date(11, 11, 2016); OK
return 0;
}
解除引用运算符(*)和成员选择运算符(->)
解除引用运算符(*)和成员选择运算符(->)在智能指针类编程中应用最广。
智能指针是封装常规指针的类,旨在通过管理所有权和复制问题简化内存管理。这将在后面的章节进行详细介绍。
下面的代码简单介绍了解除引用运算符(*)和成员选择运算符(->)是怎么使用的:
#include <iostream>
// 假设我们有一个简单的类,用于演示
class MyClass {
public:
void print() const {
std::cout << "Hello, World!" << std::endl;
}
};
// 实现一个智能指针类
class MySmartPointer {
private:
MyClass* ptr; // 内部指针
public:
// 构造函数
MySmartPointer(MyClass* p) : ptr(p) {}
// 解除引用运算符重载
MyClass& operator*() {
return *ptr;
}
// 成员选择运算符重载
MyClass* operator->() {
return ptr;
}
};
int main() {
// 创建一个 MyClass 的对象
MyClass obj;
// 创建一个智能指针对象,指向 MyClass 对象
MySmartPointer sptr(&obj);
// 使用智能指针进行解引用和成员选择
(*sptr).print(); // 等同于 obj.print();
sptr->print(); // 等同于 obj.print();
return 0;
}
输出如下:
双目运算符
双目运算符的类型
双目加法与双目减法运算符
比较简单,直接给出示例代码:
#include <iostream>
#include <string>
#include <sstream>
using namespace std;
class Date {
private:
int day, month, year;
string dateInString;
public:
Date(int inMonth, int inDay, int inYear)
: month(inMonth), day(inDay), year(inYear) {};
operator const char* () {
ostringstream formattedDate;
formattedDate << month << " / " << day << " / " << year;
dateInString = formattedDate.str();
return dateInString.c_str();
}
//加法双目运算符
Date operator + (int dayToAdd) {
Date newDate(month, day + dayToAdd, year);
return newDate;
}
//减法双目运算符
Date operator -(int dayToSub) {
return Date(month, day - dayToSub, year);
}
};
int main() {
Date Holiday(12, 25, 2016);
cout << "Holiday on: ";
cout << Holiday << endl;
Date PreviousHoliday(Holiday - 19);
cout << "Previous holiday on: ";
cout << Holiday << endl;
Date NextHoliday(Holiday + 6);
cout << "Next holiday on: ";
cout << Holiday << endl;
return 0;
}
实现运算符+=与−=
示例代码如下:
#include <iostream>
#include <string>
#include <sstream>
using namespace std;
class Date {
private:
int day, month, year;
string dateInString;
public:
Date(int inMonth, int inDay, int inYear)
: month(inMonth), day(inDay), year(inYear) {};
operator const char* () {
ostringstream formattedDate;
formattedDate << month << " / " << day << " / " << year;
dateInString = formattedDate.str();
return dateInString.c_str();
}
void operator += (int daysToAdd) {
day += daysToAdd;
}
void operator -=(int daysToSub) {
day -= daysToSub;
}
};
int main() {
Date holiday(12, 25, 2016);
cout << "holiday is on: " << holiday << endl;
holiday -= 19;
cout << "holiday -= 19 gives: " << holiday << endl;
holiday += 25;
cout << "holiday += 25 gives: " << holiday << endl;
return 0;
}
输出如下:
重载等于运算符(==)和不等运算符(!=)
如果像下面这样将两个 Date 对象进行比较,结果将如何呢?
if (date1 == date2) {
// Do something
}
else {
// Do something else
}
由于还没有定义等于运算符(==),编译器将对这两个对象进行二进制比较,并仅当它们完全相同时才返回 true。对于包含简单数据类型的类(如现在的 Date 类),这种二进制比较是可行的。然而,如果类有一个非静态字符串成员,它包含字符串值(char *),则比较结果可能不符合预期。在这种情况下,对成员属性进行二进制比较时,实际上将比较字符串指针(MyString::buffer),而字符串指针并不相等(即使指向的内容相同),因此总是返回 false。为了解决这
种问题,可定义比较运算符。
示例代码如下:
#include <iostream>
#include <string>
#include <sstream>
using namespace std;
class Date {
private:
int day, month, year;
string dateInString;
public:
Date(int inMonth, int inDay, int inYear)
: month(inMonth), day(inDay), year(inYear) {};
operator const char* () {
ostringstream formattedDate;
formattedDate << month << " / " << day << " / " << year;
dateInString = formattedDate.str();
return dateInString.c_str();
}
bool operator ==(const Date& compareTo) {
return ((day == compareTo.day) && (month == compareTo.month) && (year == compareTo.year));
}
bool operator != (const Date& compareTo){
return !(this->operator==(compareTo));
}
};
int main() {
Date holiday1(12, 25, 2016);
Date holiday2(12, 31, 2016);
cout << "holiday 1 is: ";
cout << holiday1 << endl;
cout << "holiday 2 is: ";
cout << holiday2 << endl;
if (holiday1 == holiday2)
cout << "Equality operator: The two are on the same day" << endl;
else
cout << "Equality operator: The two are on different days" << endl;
if (holiday1 != holiday2)
cout << "Inequality operator: The two are on different days" << endl;
else
cout << "Inequality operator: The two are on the same day" << endl;
return 0;
}
输出如下:
重载运算符<、>、<=和>=
基本都差不多,就略了。
重载复制赋值运算符(=)
有时候,需要将一个类实例的内容赋给另一个类实例,如下所示:
Date holiday(12, 25, 2016);
Date anotherHoliday(1, 1, 2017);
anotherHoliday = holiday; // 使用的拷贝赋值运算符
如果没有提供复制赋值运算符,这将调用编译器自动给类添加的默认复制赋值运算符。根据类的特征,默认复制赋值运算符可能不可行,具体地说是它不复制类管理的资源,默认复制赋值运算符存在的这种问题与第 9 章讨论的默认复制构造函数存在的问题类似。与复制构造函数一样,为确保进行深复制,您需要提供复制赋值运算符:
ClassType& operator = (const ClassType& copySource) {
// 避免自复制
if(this != ©Source){
// copy assignment operator implementation
}
return *this;
}
如果类封装了原始指针,则确保进行深复制很重要。为确保赋值时进行深复制,应定义复制赋值运算符,如下面程序所示。
#define _CRT_SECURE_NO_WARNINGS
#include <iostream>
#include <string.h>
using namespace std;
class MyString {
private:
char* buffer;
public:
MyString(const char* initialInput) {
if (initialInput != NULL) {
buffer = new char[strlen(initialInput) + 1];
strcpy(buffer, initialInput);
}
else {
buffer = NULL;
}
}
//拷贝赋值运算符
MyString& operator=(const MyString& copySource) {
//防止自复制
if ((this != ©Source) && (copySource.buffer != NULL)) {
if (buffer != NULL) {
delete[] buffer;
}
buffer = new char[strlen(copySource.buffer) + 1];
strcpy(buffer, copySource.buffer);
}
return *this;
}
operator const char* () {
return buffer;
}
~MyString() {
delete[] buffer;
}
};
int main() {
MyString string1("Hello ");
MyString string2(" World");
cout << "Before assignment: " << endl;
cout << string1 << string2 << endl;
string2 = string1;
cout << "After assignment string2 = string1: " << endl;
cout << string1 << string2 << endl;
return 0;
}
输出如下:
在示例代码中省略了复制构造函数,旨在减少代码行(但您编写这样的类时,应添加它)。复制赋值运算符是在第 22~32 行实现的,其功能与复制构造函数很像。它首先检查源和目标是否同一个对象。如果不是,则释放成员 buffer 占用的内存,再重新给它分配足以存储复制源中文本的内存,然后使用 strcpy( )进行复制。
下标运算符
下标运算符让您能够像访问数组那样访问类,其典型语法如下:
return_type& operator [] (subscript_type& subscript);
编写封装了动态数组的类(如封装了 char* buffer 的 MyString)时,通过实现下标运算符,可轻松地随机访问缓冲区中的各个字符:
class MyString {
// ... other class members
public:
/*const*/ char& operator [] (int index) /*const*/ {
// return the char at position index in buffer
}
};
最后,不要啥运算符都写全,应根据类的目标和用途重载运算符或实现新的运算符。
函数运算符 operator()
operator()让对象像函数,被称为函数运算符。函数运算符用于标准模板库(STL)中,通常是 STL 算法中,其用途包括决策。根据使用的操作数数量,这样的函数对象通常称为单目谓词或双目谓词。下面分析一个非简单的函数对象,如下面的代码所示,以便理解使用如此有意思的名称的原因!
#define _CRT_SECURE_NO_WARNINGS
#include <iostream>
#include <string>
using namespace std;
class Display {
public:
void operator () (string input) const {
cout << "input" << endl;
}
};
int main() {
Display displayFuncObj;
// 等价于 displayFuncObj.operator() ("Display this string!")
displayFuncObj("Display this string!");
return 0;
}
输出如下:
注意,之所以能够将对象 displayFuncObj 用作函数,是因为编译器隐式地将它转换为对函数 operator()的调用。因此,这个运算符也称为 operator()函数,而 Display 对象也称为函数对象或 functor。第 21 章将详尽地讨论这个主题。
用于高性能编程的移动构造函数和移动赋值运算符
移动构造函数和移动赋值运算符乃性能优化功能,属于 C++11 标准的一部分,旨在避免复制不必要的临时值(当前语句执行完毕后就不再存在的右值)。对于那些管理动态分配资源的类,如动态数组类或字符串类,这很有用。
不必要的复制带来的问题
还记得之前实现过的 MyString 代码吗?
下述简单的代码让您能够使用双目加法运算符(+)轻松地将三个字符串拼接起来:
MyString operator+ (const MyString& addThis) {
MyString newStr;
if (addThis.buffer != NULL) {
// copy into newStr
}
return newStr; // 返回值拷贝,会执行拷贝构造函数
}
这个加法运算符(+)让您能够式轻松地拼接字符串,但也可能导致性能问题。来看下面的代码:
MyString Hello("Hello ");
MyString World("World");
MyString CPP(" of C++");
MyString sayHello(Hello + World + CPP); // operator+, copy constructor
MyString sayHelloAgain ("overwrite this");
// operator+, copy constructor, copy assignment operator=
sayHelloAgain = Hello + World + CPP;
创建 sayHello 时,需要执行加法运算符两次,而每次都将创建一个按值返回的临时拷贝,导致执行复制构造函数。复制构造函数执行深复制,而生成的临时拷贝在该表达式执行完毕后就不再存在。总之,该表达式导致生成一些临时拷贝(准确地说是右值),而它们在当前语句执行完毕后就不再需要。这一直是 C++带来的性能瓶颈,直到最近才得以解决。
C++11 解决了这个问题:编译器意识到需要创建临时拷贝时,将转而使用移动构造函数和移动赋值运算符—如果您提供了它们。
声明移动构造函数和移动赋值运算符
移动构造函数的声明语法如下:
class Sample {
private:
Type* ptrResource;
public:
// 移动构造函数,注意 && 符号
Sample(Sample&& moveSource)
{
ptrResource = moveSource.ptrResource; // take ownership, start move
moveSource.ptrResource = NULL;
}
// 移动赋值运算符,注意 && 符号
Sample& operator= (Sample&& moveSource)
{
if(this != &moveSource)
{
delete [] ptrResource; // free own resource
ptrResource = moveSource.ptrResource; // take ownership, start move
moveSource.ptrResource = NULL; // free move source of ownership
}
}
Sample(); // default constructor
Sample(const Sample& copySource); // copy constructor
Sample& operator= (const Sample& copySource); // copy assignment
};
从上述代码可知,相比于常规赋值构造函数和复制赋值运算符的声明,移动构造函数和移动赋值运算符的不同之处在于,输入参数的类型为 Sample&&。另外,由于输入参数是要移动的源对象,因此不能使用 const 进行限定,因为它将被修改。返回类型没有变,因为它们分别是构造函数和赋值运算符的重载版本。
在需要创建临时右值时,遵循 C++的编译器将使用移动构造函数(而不是复制构造函数)和移动赋值运算符(而不是复制赋值运算符)。移动构造函数和移动赋值运算符的实现中,只是将资源从源移到目的地,而没有进行复制。下面的代码演示了如何使用这两项新功能对 MyString 类进行优化。
除复制构造函数和复制赋值运算符外,还包含移动构造函数和移动赋值运算符的 MyString 类:
#define _CRT_SECURE_NO_WARNINGS
#include <iostream>
#include <string>
using namespace std;
class MyString {
private:
char* buffer;
//私有化构造器
MyString() :buffer(NULL) {
cout << "Default constructor called" << endl;
}
public:
//构造器
MyString(const char* initialInput) {
cout << "Constructor called for: " << initialInput << endl;
if (initialInput != NULL) {
buffer = new char[strlen(initialInput) + 1];
strcpy(buffer, initialInput);
}
else {
buffer = NULL;
}
}
//移动构造
MyString(MyString&& moveSrc) {
cout << "Move constructor moves: " << moveSrc.buffer << endl;
if (moveSrc.buffer != NULL) {
buffer = moveSrc.buffer; //'移动'资源
moveSrc.buffer = NULL; //释放移动源资源
}
}
//移动赋值运算符
MyString& operator=(MyString&& moveSrc) {
cout << "Move assignment op.moves: " << moveSrc.buffer << endl;
if ((moveSrc.buffer != NULL) && (this != &moveSrc)) {
delete[] buffer;
buffer = moveSrc.buffer;
moveSrc.buffer = NULL;
}
return *this;
}
//拷贝构造
MyString(const MyString& copySrc) {
cout << "Copy constructor copies: " << copySrc.buffer << endl;
if (copySrc.buffer != NULL) {
buffer = new char[strlen(copySrc.buffer) + 1];
strcpy(buffer, copySrc.buffer);
}
else {
buffer = NULL;
}
}
//拷贝赋值运算符
MyString& operator= (const MyString& copySrc) {
cout << "Copy assignment op. copies: " << copySrc.buffer << endl;
if ((this != ©Src) && (copySrc.buffer != NULL)) {
if (buffer != NULL)
delete[] buffer;
buffer = new char[strlen(copySrc.buffer) + 1];
strcpy(buffer, copySrc.buffer);
}
return *this;
}
//析构函数
~MyString() {
if (buffer != NULL)
delete[] buffer;
}
int GetLength(){
return strlen(buffer);
}
//重载转换运算符
operator const char* (){
return buffer;
}
//重载加号运算符
MyString operator+ (const MyString& addThis){
cout << "operator+ called: " << endl;
MyString newStr;
if (addThis.buffer != NULL){
newStr.buffer = new char[GetLength() + strlen(addThis.buffer) + 1];
strcpy(newStr.buffer, buffer);
strcat(newStr.buffer, addThis.buffer);
}
return newStr;
}
};
int main() {
MyString Hello("Hello ");
MyString World("World");
MyString CPP(" of C++");
MyString sayHelloAgain("overwrite this");
sayHelloAgain = Hello + World + CPP;
return 0;
}
输出如下所示:
C++11 标准引入了返回值优化(Return Value Optimization, RVO),它允许编译器优化返回对象的构造过程,避免不必要的拷贝或移动操作。在我的编译环境下,编译器可能会执行返回值优化,直接将 newStr 对象构造在调用者的位置,而不是在函数内部构造然后再移动。因此这里的输出没有调用移动构造函数。
移动构造函数和移动赋值运算符是可选的。不同于复制构造函数和复制赋值运算符,如果您没有提供移动构造函数和移动赋值运算符,编译器并不会添加默认实现。
对于管理动态分配资源的类,可使用这项功能对其进行优化,避免在只需临时拷贝的情况下进行深复制。
用户自定义的字面量
C++增大了对字面量的支持力度,让您能够自定义字面量。例如,编写热力学方面的科学应用程序时,对于所有的温度,您都可能想以卡尔文为单位来存储和操作它们。为此,您可使用类似于下面的语法来声明所有的温度:
Temperature k1 = 32.15_F;
Temperature k2 = 0.0_C;
通过使用自定义的字面量_F 和 _C,可以让应用程序更容易理解和维护得多。
要自定义字面量,可像下面这样定义 operator “”:
ReturnType operator "" YourLiteral(ValueType value) {
// conversion code here
}
下列代码演示了一个进行类型转换的用户定义字面量,将华氏温度和摄氏温度转换为开尔文温度:
#define _CRT_SECURE_NO_WARNINGS
#include <iostream>
#include <string>
using namespace std;
struct Temperature {
double Kelvin;
Temperature(long double kelvin) : Kelvin(kelvin){}
};
Temperature operator"" _C(long double celcius) {
return Temperature(celcius + 273);
}
Temperature operator"" _F(long double fahrenheit) {
return Temperature((fahrenheit + 459.67) * 5 / 9);
}
int main() {
Temperature k1 = 31.73_F;
Temperature k2 = 0.0_C;
cout << "k1 is " << k1.Kelvin << " Kelvin" << endl;
cout << "k2 is " << k2.Kelvin << " Kelvin" << endl;
return 0;
}
输出:
在上面的代码中,用户定义字面量 _F 以声明一个华氏温度值,定义字面量 _C 声明了一个摄氏温度值。这两个字面量是在第 12~18 行定义的,它们分别将华氏温度和摄氏温度转换为开尔文温度,并返回一个 Temperature 实例。
请注意,对于 k2,有意识地将其初始化为 0.0_C,而不是 0_C,这是因为字面量_C 被定义成接受一个long double 输入值,因此要求输入值必须是这种类型,而 0 将被解读为整型。
另外 _C 和 _F 不是固定的,名字可以随便取嗷。
不能重载的运算符
虽然 C++提供了很大的灵活性,让程序员能够自定义运算符的行为,让类更易于使用,但 C++ 也有所保留,不允许程序员改变有些运算符的行为。表 12.3 列出了不能重新定义的运算符。
总结
本章介绍了如何重载各种运算符,让类更易于使用。编写管理资源(如动态数组或字符串)的类时,除析构函数外,还需至少提供复制构造函数和复制赋值运算符。对于管理动态数组的实用类,如果包含移动构造函数和移动赋值运算符,就可避免将包含的资源深复制给临时对象。最后,您学习了.、.*、::、?: 和 sizeof 等不能重新定义的运算符。