避免对象切片

时间:2019-06-05 16:38:41

标签: c++ object-slicing

所以我正在刷新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 );时不是简单地自己创建派生对象,还是自动将其分配为GameObject吗?我真的无法弄清楚为什么一个有效而另一个无效。它与通过new而不是仅通过Ball ball创建对象有关吗?

很抱歉,如果这个问题没有意义,我只是想了解如何进行此对象切片。

6 个答案:

答案 0 :(得分:2)

将对象直接存储在容器中时会发生对象切片。当您存储指向对象的指针(或更好的智能指针)时,不会发生切片。因此,如果您将Ball存储在vector<GameObject>中,它将被切片,但是如果您将Ball *存储在vector<GameObject *>中,一切都会好的。

答案 1 :(得分:1)

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

这是解释。对象切片是编译器认为对象小于实际大小的问题。因此,在您的代码示例中:

Ball b;
GameObject g = b;

编译器已经为名为GameObject的{​​{1}}保留了足够的空间,但是您正在尝试在其中放置gBall)。但是b可能比Ball大,然后数据将会丢失,坏的东西很可能开始发生。

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

答案 2 :(得分:1)

  

为什么我们要为此说virtual

如果未将函数声明为虚拟函数,则无法使用虚拟调度调用该函数。当通过指针或对基类的引用虚拟调用函数时,该调用将分派到最派生类(如果存在)中的重写。换句话说,virtual允许运行时多态。

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

  

顺便说一句,为什么我们在这里使用指针?为什么不只是纯GameObjects?

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

  

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

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

顺便说一句,如果在丢失指针值之前未能在指针上调用delete,则可能会导致内存泄漏。哦,如果您尝试两次delete或不是delete的某事new,则程序的行为将不确定。内存分配很困难,您应该始终使用智能指针进行管理。像您的示例中那样,拥有所有权指针的向量是一个非常糟糕的主意。

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

  

避免对象切片

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

例如:

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;

答案 3 :(得分:1)

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

virtual void Draw( ) = 0;
  

我们为什么要说虚拟的?

简单来说,virtual关键字告诉C ++编译器该函数可以在子类中重新定义。当您调用ball.Draw()时,如果Ball::Draw()类中的Ball而不是GameObject::Draw()中存在std::vector<GameObject*> _gameObjects; ,编译器就会知道应该执行。


GameObject
  

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

这是一个好主意,因为当容器必须为其分配空间并包含对象本身时,确实会发生对象切片。请记住,无论指针指向什么,指针都是恒定大小。当您必须调整容器的大小或移动元素时,指针的移动变得更加容易和快捷。而且,如果您确定这样做是正确的话,则始终可以将指向Ball的指针转换回指向new的指针。


  

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

是的,new Ball( -1, 1, boardWidth / 2, boardHeight / 2 ); 的工作是在堆上构造该类的实例,然后返回指向该实例的指针。
但我强烈建议您学习如何使用smart pointers。当不再引用对象时,它们可以自动删除对象。就像垃圾收集器在Java或C#这样的语言中所做的一样。


Ball
  

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

是的,如果GameObject继承了Ball类,则指向GameObject的指针也将是指向Ball的有效指针。如您所料,您无法通过指向GameObject的指针访问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的范围时,指针将被销毁,但指向的实例将保留在堆中。如果您没有在其他地方保存该指针的值,则将无法释放该wheel实例使用的内存。这就是智能指针之所以有用的原因,因为它们在内部跟踪实例是否仍在任何地方被引用。

答案 4 :(得分: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;

&objptr具有不同的类型(分别为Derived *Base *),但是C ++仍然允许该代码。因为Derived对象包含Base的所有成员,所以可以让Base指针指向Derived实例。

这实际上为您提供了obj的简化接口。通过ptr访问时,它只有在Base中声明的方法。但是因为没有进行复制,所以所有数据(包括Derived特定部分)仍然存在并且可以在内部使用。

对于virtual:通常,当您通过类型为foo的对象调用方法Base时,它将完全调用Base::foo(即{ {1}})。即使通过使用方法的不同实现实际指向派生对象的指针进行调用(如上所述),也会发生这种情况:

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。根据您编码的精确程度,它要么不会首先编译,要么在运行时中止。

答案 5 :(得分:0)

这与值有关。

Ball b;
GameObject g;

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

g的值同样是其变量的不同值。

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

现在有关功能。

对于编译器,类的成员函数是指向该函数代码所驻留的内存的指针。

非虚函数始终是恒定的指针值。

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

因此,为了告诉编译器应该为函数指针创建一个占位符,使用了关键字virtual

现在返回值的分配。

我们知道彼此分配不同类型的变量会导致切片。因此,要解决此问题,可以使用间接寻址-指向对象的指针。

对于任何类型的指针,指针始终需要相同数量的strorage。并且当分配了一个指针时,其底层结构保持不变,仅复制该指针,该指针将覆盖前一个指针。

当我们在已切片的g上调用虚拟函数时,我们可能正在从function调用正确的b,但是切片的g对象却没有具有b函数所需的所有字段,因此可能会发生错误。

但是使用原始对象b进行指针调用,该对象具有b的虚函数使用的所有必填字段。