1.类的多态:
对于这个话题,可以先说说类的多态的实现。类继承的最重要的性质是可以通过基类的指针或引用来操作派生类,在动态绑定技术的基础上,在程序运行时期确定指针或对象的真正指向的对象,调用该对象所对应的方法体执行。
我们可以使用基类的指针或引用来操作子类的对象,但是却不能使用基类的指针或引用来操作子类的数组。尽管我们可以看到C++的编译器很宽容地通过了我们的代码,但却会承担运行时期程序崩溃的风险。
2.使用基类指针或引用操作子类数组的风险:
我们可通过C++的程序来理解使用基类指针或引用操作子类数组的风险:
class BST
{
public:
BST(int id = 0)
{
this->id = id;
}
friend ostream& operator<<(ostream& os, const BST & bst);
protected:
int id;
};
ostream& operator<<(ostream& os, const BST& bst)
{
os << bst.id << endl;
return os;
}
class BalancedBST : public BST
{
public:
BalancedBST(int id = 0, int id2 = 10) :BST(id), id2(id2) {}
friend ostream& operator<<(ostream& os, const BalancedBST & bbst);
private:
double id2;
};
ostream& operator<<(ostream& os, const BalancedBST& bbst)
{
os << bbst.id << "\t" << bbst.id2 << endl;
return os;
}
void printBSTArray(ostream& os, BST* bstArray, int arraysize)
{
for (int i = 0;i<arraysize;i++)
{
os << bstArray[i];
}
}
int main()
{
BalancedBST bbstArray[4] = { BalancedBST(0),BalancedBST(1),BalancedBST(2),BalancedBST(3) };
printBSTArray(cout, bbstArray, 4);
return 0;
}
在上述的程序中,子类BalancedBST继承基类BST,在两个类中,分别重载了<<运算符,最后,在主函数中调用了全局的函数printBSTArray,注意这个函数的参数是BST类型的指针,而main函数中传入了BalancedBST类型的数组,这时,程序顺利通过了编译,但在运行时却出现了这样的结果:
我们使用了基类的指针来操作子类的数组,却出现了这样不能理解的运行结果。如果将全局的函数printBSTArray中的参数类型改成BalancedBST,此时的运行结果就正确了:
有了现象,我们需要分析原因。在全局函数的循环体中:
void printBSTArray(ostream& os, BST* bstArray, int arraysize)
{
for (int i = 0;i<arraysize;i++)
{
os << bstArray[i];
}
}
bstArray[I]只是一个指针算法的缩写:它所代表的是*(bstArray)。我们知道bstArray是一个指向数组起始地址的指针,但是bstArray中各元素内存地址与数组的起始地址的间隔究竟有多大呢?它们的间隔是i*sizeof(一个在数组里的对象),因为在bstArray数组[0]到[I]间有I个对象。编译器为了建立正确遍历数组的执行代码,它必须能够确定数组中对象的大小,这对编译器来说是很容易做到的。参数bstArray被声明为BST类型,所以bstArray数组中每一个元素都是BST类型,因此每个元素与数组起始地址的间隔是i*sizeof(BST)。
至少你的编译器是这么认为的。但是如果你把一个含有BalancedBST对象的数组变量传递给printBSTArray函数,你的编译器就会犯错误。在这种情况下,编译器原先已经假设数组中元素与BST对象的大小一致,但是现在数组中每一个对象大小却与BalancedBST一致。派生类的长度通常都比基类要长。我们料想BalancedBST对象长度的比BST长。如果如此的话,printBSTArray函数生成的指针算法将是错误的,如果用BalancedBST数组来执行printBSTArray函数将会发生不可估计的后果。
所以,使用基类的指针或引用操作子类的数组时,就出现了上述的错误。
同样的,当我们使用基类的指针或引用来删除子类的数组时,也会出现错误。我们可以定义一个全局的删除数组的函数如下:
void deleteArray(ostream& logStream, BST array[])
{
logStream << "Deleting array at address "<< static_cast<void*>(array) << '\n';
delete[] array;
}
在main函数中做如下的调用:
int main()
{
BalancedBST bbstArray[4] = { BalancedBST(0),BalancedBST(1),BalancedBST(2),BalancedBST(3) };
//printBSTArray(cout, bbstArray, 4);
deleteArray(cout, bbstArray);
return 0;
}
这个过程中隐藏了基类的指针操作子类数组的过程:当一个数组被删除时,每一个数组元素的析构函数也会被调用。
当编译器遇到这样的代码:
delete [] array;
会这样生成代码:
// 以与构造顺序相反的顺序来析构array数组里的对象
for (int i = 数组元素的个数; i >= 0; --i)array[i].BST::~BST();//调用array[i]的析构函数
此时程序的运行也会出错,因为我们使用了基类的指针来操作子类的数组。
引用和指针有着类似的作用,都让你间接引用其他的对象,用基类的应用操作子类的数组同样也会遇到上述的问题,综上,我们不能使用基类的指针或引用来操作子类的数组。
3.对象切片问题:
上述场景中,我们将子类的数组最为参数传递给基类的引用或指针,会出现错误。而与之类似的一个现象叫“对象切片”。当我们通过传值方式将子类的对象传递给基类对象,会出错。
同样,我们通过代码来说明“对象切片”的概念。在ThingKing in C++中,有这样的代码来说明“对象切片”的概念:
class Pet {
string pname;
public:
Pet(const string& name) : pname(name) {}
virtual string name() const { return pname; }
virtual string description() const
{
return "This is " + pname;
}
};
class Dog : public Pet {
string favoriteActivity;
public:
Dog(const string& name, const string& activity)
: Pet(name), favoriteActivity(activity) {}
string description() const {
return Pet::name() + " likes to " +
favoriteActivity;
}
};
void describe(Pet p) { // Slices the object
cout << p.description() << endl;
}
在主函数main中执行如下代码:
int main()
{
Pet p("Alfred");
Dog d("Fluffy", "sleep");
describe(p); //正常调用基类函数 This is Alfred
describe(d); //对象切片 This is Fluffy
return 0;
}
得到的结果如下:
我们可以看到Dog子类的对象d调用全局的describe函数,并没有执行子类的description函数,而是调用了基类Pet的description函数。出现这个现象的原因,在于函数传参处理多态性时,如果一个派生类对象在UpCasting时,用的是传值的方式,而不是指针和引用,那么,这个派生类对象在UpCasting以后,将会被slice(切分)成基类对象,也就是说,派生类中独有的成员变量和方法都被slice掉了,仅剩下和基类相同的成员变量和属性。这个派生类对象被切成了一个基类对象。
我们把全局函数中的基类传参方式改为引用或指针,此时,的结果运行如下:
可以看到此时程序的运行结果如我们所期待的那样,子类的对象调用全局的describe函数时,真正执行的也是子类的函数,此时没有发生对象切片的现象。
为了避免对象切片,也是纯虚函数的一个重要意义,我们可以将基类Pet中的description函数声明为纯虚函数,那样,在全局函数describe中,不能通过拷贝构造函数实例化一个抽象基类的对象,编译器会直接报错,从而避免了对象切片的问题。这样,我们把对象切片问题的检查提前到编译期发现,减少了不必要的错误和程序bug的检查和排除。