为什么在这种情况下需要使用指针?

15

可能是重复问题:
学习C++:多态和切片

这是基于我之前提出的一个问题。 类看起来是这样的:

class Enemy
{
    public:
        void sayHere()
        {
            cout<<"Here"<<endl;
        }
        virtual void attack()
        {
        }
};

class Monster: public Enemy
{

    public:
        void attack()
        {
            cout<<"RAWR"<<endl;
        }

};
class Ninja: public Enemy
{

    public:
        void attack()
        {

            cout<<"Hiya!"<<endl;
        }
};

我是C++的新手,对于为什么只能使用指针才能使这个程序工作感到困惑(Ninja和monster都是从Enemy派生而来):

int main()
{
    Ninja ninja;
    Monster monster;

    Enemy *enemies[2];

    enemies[0] = &monster;
    enemies[1] = &ninja;

    for (int i = 0; i < 2; i++)
    {
        enemies[i]->attack();
    }

    return 0;
}

为什么我不能这样做呢?:
int main()
{
    Ninja ninja;
    Monster monster;

    Enemy enemies[2];

    enemies[0] = monster;
    enemies[1] = ninja;

    for (int i = 0; i < 2; i++)
    {
        enemies[i].attack();
    }

    return 0;
}

1
+1,这个问题其实非常有趣。当然我知道为什么第二个例子不起作用,但如果有人知道为什么C++标准根本不支持第二个例子,我会很感兴趣的(毕竟,从派生类型“自动”复制所有所需值在理论上不应该那么难)。 - TravisG
1
@heishe,这被称为切片,你可以谷歌一下,它有点糟糕。 - unkulunkulu
4
奖励分数给“公敌” ;) - drxzcl
2
我喜欢忍者在攻击时友好地向你问候的方式。 - cheeken
10个回答

23
这是一个很好的问题,它涉及到C++继承中一些更棘手的部分。混淆是由于C++为对象分配存储空间的方式以及静态类型和动态类型之间的差异引起的。
首先,让我们讨论静态类型和动态类型之间的区别。C++中每个对象都有一个静态类型,这是源代码描述的对象类型。例如,如果你尝试写下:
Base* b = new Derived;

那么b的静态类型就是Base*,因为在源代码中这是你为它声明的类型。同样地,如果你写下

Base myBases[5];

myBases的静态类型是Base[5],一个由五个Base组成的数组。

对象的动态类型是对象在运行时实际具有的类型。例如,如果您编写以下内容:

Base* b = new Derived;

那么,b 的动态类型是 Derived*,因为它实际上指向了一个 Derived 对象。

C++ 中静态类型和动态类型的区别有两个重要的原因:

  1. 对象的赋值始终基于静态类型,而不是动态类型。
  2. 只有在静态类型为指针或引用时,才会分派到动态类型调用虚函数。

让我们依次解决这些问题。

首先,第二个代码版本中的一个问题是您执行以下操作:

Ninja ninja;
Monster monster;

Enemy enemies[2];

enemies[0] = monster;
enemies[1] = ninja;

让我们来跟踪这里发生了什么。首先创建一个新的NinjaMonster对象,然后创建一个Enemy对象数组,最后将ninjamonster的值分配给enemies数组。

这段代码的问题在于当你写下

enemies[0] = monster;
lhs的静态类型是Enemy,rhs的静态类型是Monster。在决定如何进行赋值时,C++只看对象的静态类型,而不看动态类型。这意味着因为enemies[0]被静态地声明为Enemy,它必须仅仅持有Enemy类型的对象,而不能是任何派生类型。这也就意味着当你执行上面的赋值操作时,C++会解释为“取出monster对象中仅仅属于Enemy部分,然后将该部分复制到enemies[0]中”。换句话说,尽管Monster是带有一些额外添加内容的Enemy,但是通过这行代码只会将MonsterEnemy部分复制到enemies [0]中。这被称为切片,因为你将对象的一部分切下来,只留下了基类Enemy

在你发布的第一段代码中,有这样一段:

Ninja ninja;
Monster monster;

Enemy *enemies[2];

enemies[0] = &monster;
enemies[1] = &ninja;

这是完全安全的,因为在这行代码中:

enemies[0] = &monster;
lhs具有静态类型Enemy*,rhs具有类型Monster*。在C++中,你可以合法地将指向派生类型的指针转换为指向基础类型的指针而不会出现任何问题。因此,rhs的monster指针可以无损地转换为lhs类型Enemy*,因此对象的顶部不会被切掉。
更一般地讲,在将派生对象分配给基对象时,你有可能会切掉对象。将派生对象的指针存储在指向基对象类型的指针中总是更安全和更可取的,因为不会进行切片操作。
这里还有第二个要点。在C++中,每当调用虚函数时,只有在接收器是指针或引用类型时,该函数才仅在对象的动态类型上调用(运行时实际上是对象的类型)。也就是说,如果你有原始代码:
Ninja ninja;
Monster monster;

Enemy enemies[2];

enemies[0] = monster;
enemies[1] = ninja;

并写下来

enemies[0].attack();

然后,由于enemies[0]的静态类型为Enemy,编译器不会使用动态分派来确定要调用哪个版本的attack函数。原因是,如果对象的静态类型是Enemy,那么在运行时它始终引用Enemy而不是其他任何类型。然而,在代码的第二个版本中:

Ninja ninja;
Monster monster;

Enemy *enemies[2];

enemies[0] = &monster;
enemies[1] = &ninja;

当你编写代码时

enemies[0]->attack();

因为enemies[0] 的静态类型是Enemy*,所以它可以指向EnemyEnemy的子类型。因此,C++会根据对象的动态类型分派函数调用。

希望这可以帮助你!


1
特别说明这段代码中,“因为 enemies[0] 的静态类型是 Enemy,所以编译器不会使用动态分派来决定调用哪个版本的 attack 函数”。 - Paul Manta
1
+1. 这是我读过的有关切片的最好的回答之一。 - Izza

16
没有指针的情况下,您的enemies[]数组表示栈上足以存储两个“Enemy”对象的空间,这意味着存储它们的所有字段(加上可能为vtable指针和对齐而产生的开销)。 Enemy的派生类可能具有附加字段,因此可能更大,因此不能将派生对象存储在为实际Enemy对象保留的空间中。当您执行像示例中那样的赋值时,它使用赋值运算符(在本例中是隐式定义的) - 该赋值运算符将左侧对象的字段的值设置为右侧对象相应字段的值,同时保持左侧对象的类型(因此vtable指针)不变。 这称为“对象切片”,通常应避免使用。
指针的大小都相同,因此可以在指向Enemy指针的空间中放置一个指向Enemy派生对象的指针,并且可以像指向普通enemy对象的指针一样使用它。由于指向派生对象的指针指向实际的派生对象实例,在指针上调用虚函数将使用派生对象的vtable并提供所需的行为。

这被称为切片问题:https://dev59.com/YHVC5IYBdhLWcg3wfxQ8,http://en.wikipedia.org/wiki/Object_slicing - Seth Carnegie
是的,这被称为“切片”对象,而且这是不好的! - rodrigo
但是虚函数表不是存储在与Enemy对象相同的内存“块”中吗?那么,当我执行new Derived时,vtable中的指针被更改以指向Derived::attack而不是Enemy::attack。难道vtable存储在其他地方吗? - Paul Manta
@Mat - 我已经扩展并澄清了我的回答,以反映您(和其他人)的评论。 - antlersoft

3

在C++中,这被称为切片。

Enemy()创建了一个敌人对象。如果你调用 Enemy().attack(),它不会打印任何内容,因为该方法为空。

在C++中,唯一可以获得多态行为的方式是使用指针或引用。


1

在C++中,使用指针实现多态性(请参见此处)。如果您尝试将monsterninja对象放入enemies数组中,则会出现类型不匹配错误。但是,“派生类的指针与其基类的指针兼容”。


1

这将给你完全不同的结果。

在使用指针的第一个场景中,您将拥有指向您的NinjaMonster对象的Enemy指针。对象将完好无损,并且在运行时,attack()调用将调用对象的attack()方法。

在另一种情况下,您有实际的Enemy对象。当您将分配NinjaMonster对象时,只会复制共同成员(不属于Enemy的其余成员将丢失)。
然后当您调用attack()时,它将是一个Enemyattack()(因为它们是Enemy对象)


1

Enemy enemies[2]; 创建了一个具体类型(Enemy)的对象数组。这意味着,该数组的所有元素都有已知大小。

对于派生类可能包含其他数据的情况,它不适用。

另一方面,给定指针,这根本无关紧要。指针将指向“某个东西”(虚表加数据),虚拟继承机制会以某种方式弄清楚哪里是什么,并且在哪里是什么。可能存在相同的函数、重载的函数和附加数据字段,但它仍然能够工作。


1
将怪物和忍者分配到敌人数组中可以工作,但是当您在每个对象上调用攻击函数时,它会调用基类的攻击函数。为什么?首先,当您将对象分配到敌人数组中时,实际上是将这些类进行类型转换,因此当您与其对象交互时,它们就像敌人一样行事,而不是它们最初的形式。
如果您注意到了,您在Enemy中声明了攻击函数为虚拟的。这允许多态性的本质。通过将该函数声明为虚拟函数,您允许Enemy的子类对象(例如Monster和Ninja)在运行时确定使用哪个版本的攻击函数,如果使用Enemy指针,则可以使用通用的Enemy指针来访问不同的子类对象,并仍然正确地使用正确的函数:
Enemy * ptr;
Enemy copy;
Monster m;

copy = (Enemy)m;
ptr = &m;

copy.attack(); // Calls Enemy's definition of attack, which is undefined.
ptr->attack(); // Even though this is an Enemy pointer, the Monster's definition of attack is used.

1

它不被支持,因为当你将子类实例的值分配给超类实例时,超类中不包含的子类信息会被剔除。因此,一些方法 - 即使是多态的方法 - 在所有情况下都无法在子类依赖的情况下工作。唯一通用的在编译时保证类型安全的方法是使用父类的实现。

简短版:父类的实例可能比子类实例少状态,因此对父类实例的操作必须假定它们是为父类定义的操作。指针可以避免这种情况,因为具有完整状态的子类实例确实存在。


1

因为实现这样的功能是不可能的(也许非常困难,也许可以使用指针完成)。主要原因是基类和派生类对象的大小可能不同(sizeof(Enemy) != sizeof(Monster)),将怪物存储在敌人中会丢失一些数据。


1

通过编写

enemies[0] = monster;

你正在将你的Monster对象转换为Enemy对象。每个派生类对象都可以自动转换为基类对象。这被称为对象切片。一旦发生了这种转换,Enemy对象就不再有任何记忆它曾经是Monster对象的方式,它只是一个普通的Enemy对象,像其他任何对象一样。因此,当你调用attack时,你调用的是Enemy::attack。

在Java中不存在这个问题,因为在Java中一切都是自动指针。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接