【问题标题】:Avoiding object slicing避免对象切片
【发布时间】:2019-06-05 16:38:41
【问题描述】:

所以我对 C++ 感到耳目一新,老实说,这已经有一段时间了。我制作了一个控制台乒乓球游戏作为一种复习任务,并获得了一些关于使用多态性为我的类派生自基本“GameObject”(它具有一些用于将对象绘制到屏幕上的基本方法)的输入。

其中一个输入是(我随后问过)是从基类派生时内存是如何工作的。因为我并没有真正做过很多高级 C++。

例如,假设我们有一个基类,现在它只有一个“draw”方法(顺便说一句,为什么我们需要为它说virtual?),因为所有其他派生对象实际上只共享一个公共方法,并且正在绘制:

class GameObject
{
public:

    virtual void Draw( ) = 0;
};

例如,我们还有一个球类:

class Ball : public GameObject

我收到的输入是,在适当的游戏中,这些可能会保存在某种 GameObject 指针向量中。像这样的东西:std::vector<GameObject*> _gameObjects;

(所以是指向 GameObjects 的指针向量)(顺便说一句,我们为什么要在这里使用指针?为什么不只是纯 GameObjects?)。我们将使用以下内容实例化其中一个游戏对象:

_gameObjects.push_back( new Ball( -1, 1, boardWidth / 2, boardHeight / 2 ); ); 

new 返回指向对象的指针正确吗?IIRC)。根据我的理解,如果我尝试做类似的事情:

Ball b;
GameObject g = b;

事情会变得一团糟(如下所示:What is object slicing?

但是...当我执行new Ball( -1, 1, boardWidth / 2, boardHeight / 2 ); 时,我不是简单地自行创建派生对象,还是自动将其分配为游戏对象?我真的无法弄清楚为什么一个有效而一个无效。例如,它是否与通过 new 与仅通过 Ball ball 创建对象有关?

对不起,如果这个问题没有意义,我只是想了解这个对象切片是如何发生的。

【问题讨论】:

  • _gameObjects 很好。指针或引用避免了对象切片。 Gameobject g = b; 不行。
  • ^ ... 甚至更好地使用 智能指针std::unique_ptr<GameObject>std::shared_ptr<GameObject> 来存储在容器中。
  • GameObject g 甚至不会编译,因为GameObject 包含一个“纯虚拟”方法(即需要由子类实现的方法)。所以你不能创建GameObject类型的对象。

标签: c++ object-slicing


【解决方案1】:

基本问题是复制对象(这在类是“引用类型”的语言中不是问题,但在 C++ 中默认是按值传递,即制作副本)。 “切片”意味着将较大对象(B 类型,派生自A)的值复制到较小对象(A 类型)中。因为A 更小,所以只制作了部分副本。

当您创建一个容器时,它的元素就是它们自己的完整对象。例如:

std::vector<int> v(3);  // define a vector of 3 integers
int i = 42;
v[0] = i;  // copy 42 into v[0]

v[0] 是一个int 变量,就像i

类也会发生同样的事情:

class Base { ... };
std::vector<Base> v(3);  // instantiates 3 Base objects
Base x(42);
v[0] = x;

最后一行将x 对象的内容复制到v[0] 对象中。

如果我们像这样改变x的类型:

class Derived : public Base { ... };
std::vector<Base> v(3);
Derived x(42, "hello");
v[0] = x;

...然后v[0] = x 尝试将Derived 对象的内容复制到Base 对象中。在这种情况下,Derived 中声明的所有成员都会被忽略。只复制基类Base 中声明的数据成员,因为v[0] 有空间。

指针给你的是避免复制的能力。当你这样做时

T x;
T *ptr = &x;

ptr 不是x 的副本,它只是指向x

同样,你可以这样做

Derived obj;
Base *ptr = &obj;

&amp;objptr 具有不同的类型(分别为 Derived *Base *),但 C++ 无论如何都允许使用此代码。因为Derived 对象包含Base 的所有成员,所以可以让Base 指针指向Derived 实例。

这为您提供的基本上是obj 的简化界面。通过ptr访问时,只有Base中声明的方法。但是因为没有复制,所以所有数据(包括Derived具体部分)都还在,可以内部使用。

至于virtual:通常,当您通过Base类型的对象调用方法foo时,它会准确调用Base::foo(即Base中定义的方法)。即使调用是通过一个实际指向派生对象(如上所述)的指针进行的,这种情况也会发生,但该方法的实现不同:

class Base {
    public:
    void foo() const { std::cout << "hello from Base::foo\n"; }
};

class Derived : public Base {
    public:
    void foo() const { std::cout << "hello from Derived::foo\n"; }
};

Derived obj;
Base *ptr = &obj;
obj.foo();  // calls Derived::foo
ptr->foo();  // calls Base::foo, even though ptr actually points to a Derived object

通过将foo 标记为virtual,我们强制方法调用使用对象的实际类型,而不是调用所通过的指针的声明类型:

class Base {
    public:
    virtual void foo() const { std::cout << "hello from Base::foo\n"; }
};

class Derived : public Base {
    public:
    void foo() const { std::cout << "hello from Derived::foo\n"; }
};

Derived obj;
Base *ptr = &obj;
obj.foo();  // calls Derived::foo
ptr->foo();  // also calls Derived::foo

virtual 对普通对象没有影响,因为声明的类型和实际的类型总是相同的。它只影响通过指向对象的指针(和引用)进行的方法调用,因为它们能够引用其他对象(可能具有不同的类型)。

这也是存储指针集合的另一个原因:当您有多个GameObject 的不同子类时,它们都实现了自己的自定义draw 方法,您希望代码注意实际类型对象,因此在每种情况下都会调用正确的方法。如果draw 不是虚拟的,您的代码将尝试调用不存在的GameObject::draw。根据您的编码方式,它要么一开始就无法编译,要么在运行时中止。

【讨论】:

  • 对文字墙感到抱歉,但您的问题有点……含糊不清且没有重点。我希望我至少能解决一些问题。
【解决方案2】:

当您将对象直接存储在容器中时,就会发生对象切片。当您将指针(或更好的智能指针)存储到对象时,不会发生切片。因此,如果您将 Ball 存储在 vector&lt;GameObject&gt; 中,它将被切片,但如果您将 Ball * 存储在 vector&lt;GameObject *&gt; 中,一切都会好起来的。

【讨论】:

    【解决方案3】:

    我将尝试回答您提出的各种问题,尽管其他人的回答中可能会有更技术性的解释。

    virtual void Draw( ) = 0;
    

    为什么我们需要为它说虚拟?

    简单来说,virtual 关键字告诉 C++ 编译器该函数可以在子类中重新定义。当你去调用ball.Draw() 时,编译器知道如果Ball::Draw() 存在于Ball 类而不是GameObject::Draw() 中,则应该执行它。


    std::vector<GameObject*> _gameObjects;
    

    我们为什么要在这里使用指针?

    这是一个好主意,因为当容器必须为对象本身分配空间并包含对象时,就会发生对象切片。请记住,指针的大小是恒定的,无论它指向什么。当您必须调整容器大小或四处移动元素时,移动指针会更容易和更快。如果您确定这样做是有效的,您可以随时将指向 GameObject 的指针转换回指向 Ball 的指针。


    new 返回指向对象的指针对吗?

    是的,new 正在做的是在堆上构造该类的实例,然后返回指向该实例的指针。
    我强烈建议你学习如何使用smart pointers。这些可以在不再引用时自动删除对象。有点像垃圾收集器在 Java 或 C# 等语言中所做的事情。


    new Ball( -1, 1, boardWidth / 2, boardHeight / 2 );
    

    ...还是自动将其分配为 GameObject?

    是的,如果Ball 继承了GameObject 类,那么指向Ball 的指针也将是指向GameObject 的有效指针。正如您所料,您无法通过指向 GameObject 的指针访问 Ball 的成员。


    例如,它是否与通过 new 与仅 Ball ball 创建对象有关?

    我将解释实例化Ball 的两种方式之间的区别:

    Ball ballA = Ball();
    Ball* ballB = new Ball();
    

    对于ballA,我们声明ballA 变量是Ball 的一个实例,它将“存在”在堆栈内存中。我们使用Ball() 构造函数将ballA 变量初始化为Ball 的实例。由于这是一个堆栈变量,因此一旦程序退出声明它的范围,ballA 实例就会被销毁。

    对于ballB,我们声明ballB 变量是指向Ball 实例的指针,该实例将存在于堆内存中。我们使用new Ball() 语句首先为Ball 分配堆内存,然后使用Ball() 构造函数构造它。最后,new 语句计算为分配给ballB 的指针。 现在,当程序退出声明 ballB 的范围时,指针被销毁,但它指向的实例留在堆上。如果您没有将该指针的值保存在其他地方,您将无法释放该Ball 实例使用的内存。这就是智能指针有用的原因,因为它们在内部跟踪实例是否仍被任何地方引用。

    【讨论】:

      【解决方案4】:

      对您的问题的快速回答是,当您执行_gameObjects.push_back( new Ball( ... )) 时,对象切片不是问题,因为newBall 大小的对象分配了足够的内存。

      这里是解释。对象切片是编译器认为对象小于实际大小的问题。所以在你的代码示例中:

      Ball b;
      GameObject g = b;
      

      编译器已为名为@9​​87654326@ 的GameObject 保留了足够的空间,但您却试图将Ball (b) 放在那里。但是Ball 可能比GameObject 大,然后数据会丢失,坏事可能会开始发生。

      但是,当您执行new Ball(...)new GameObject(...) 时,编译器确切地知道要分配多少空间,因为它知道对象的真实类型。然后,您存储的实际上是Ball*GameObject*。您可以安全地将Ball* 存储在GameObject* 类型中,因为指针大小相同,因此不会发生对象切片。指向的内存可以是任意数量的不同大小,但指针始终是相同大小。

      【讨论】:

        【解决方案5】:

        顺便说一句,为什么我们需要说virtual

        如果你不声明一个函数是虚拟的,那么你就不能用虚拟调度来调用这个函数。当通过指针或对基类的引用虚拟调用函数时,调用将分派到最派生类(如果存在)中的覆盖。换句话说,virtual 允许运行时多态。

        如果函数是非虚拟的,那么函数只能静态调度。静态调用函数时,调用的是编译时类型的函数。因此,如果通过基指针静态调用函数,则调用基函数,而不是派生覆盖。

        顺便说一句,我们为什么要在这里使用指针?为什么不只是纯游戏对象?

        GameObject 是一个抽象类,因此您不能拥有该类型的具体对象。由于您不能拥有具体的GameObject,因此您也不能拥有它们的数组(或向量)。 GameObject 实例只能作为派生类型的基类子对象存在。

        new 返回指向对象的指针正确吗?

        new 在动态存储中创建一个对象,并返回指向该对象的指针。

        顺便说一句,如果在丢失指针值之前未能在指针上调用delete,则存在内存泄漏。哦,如果你尝试delete 两次,或者delete 不是源自new 的东西,你的程序的行为将是未定义的。内存分配很困难,您应该始终使用智能指针来管理它。如您的示例中的裸拥有指针向量是一个非常糟糕的主意。

        此外,通过基对象指针删除对象具有未定义的行为,除非基类的析构函数是虚拟的。 GameObject 的析构函数不是虚拟的,因此您的程序无法避免 UB 或内存泄漏。两种选择都很糟糕。解决办法是把GameObject的析构函数设为virtual。

        避免对象切片

        您可以通过将基类抽象化来避免意外的对象切片。由于不能有抽象类的具体实例,因此您不能意外地“切掉”派生对象的基础。

        例如:

        Ball b;
        GameObject g = b;
        

        格式不正确,因为GameObject 是一个抽象类。编译器可能会这样说:

        main.cpp: In function 'int main()':
        main.cpp:16:20: error: cannot allocate an object of abstract type 'GameObject'
         GameObject g = b;
                        ^
        main.cpp:3:7: note:   because the following virtual functions are pure within 'GameObject':
         class GameObject
               ^~~~~~~~~~
        main.cpp:7:18: note:    'virtual void GameObject::Draw()'
             virtual void Draw( ) = 0;
                          ^~~~
        main.cpp:16:16: error: cannot declare variable 'g' to be of abstract type 'GameObject'
             GameObject g = b;
        

        【讨论】:

          【解决方案6】:

          这与价值观有关。

          Ball b;
          GameObject g;
          

          b 的值是它的变量的不同值。

          g 的值也是它的变量的不同值。

          b 分配给g 时,b(继承自GameObject)的“子对象”的变量被分配给g 的变量。这是切片。

          现在关于功能。

          对于编译器来说,类的成员函数是指向函数代码所在内存的指针。

          非虚函数始终是一个常量指针值。

          但是虚函数可以有不同的值,具体取决于它们在哪个类中声明。

          所以要告诉编译器它应该为函数指针创建一个占位符,使用关键字virtual

          现在回到值的分配。

          我们知道将不同类型的变量相互分配会导致切片。所以为了解决这个问题,使用了间接——指向对象的指针。

          对于任何类型的指针,指针总是需要相同数量的存储空间。并且当分配一个指针时,底层结构保持不变,只复制覆盖前一个指针的指针。

          当我们在被切片的g 上调用虚函数时,我们可能会从b 调用正确的function,但是切片的g 对象没有@ 所需的所有字段987654334@函数,所以会出错。

          但是使用指向对象的指针调用,使用的是原始对象b,它具有b的虚函数使用的所有必需字段。

          【讨论】:

            猜你喜欢
            • 1970-01-01
            • 2019-03-28
            • 1970-01-01
            • 1970-01-01
            • 1970-01-01
            • 1970-01-01
            • 1970-01-01
            • 1970-01-01
            • 1970-01-01
            相关资源
            最近更新 更多