More Effective C++ Item3的思考:不要对数组使用多态

本文探讨了C++中使用基类指针或引用操作子类数组的风险及对象切片问题,分析了运行时错误的原因,并提出了避免这些问题的方法。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

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的检查和排除。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值