避免对象切片
Avoiding object slicing
所以我正在刷新 C++,老实说已经有一段时间了。我制作了一个控制台乒乓球游戏作为一种复习任务,并获得了一些关于使用多态性为我的 classes 从基础 "GameObject" 派生的输入(它有一些用于将对象绘制到屏幕的基本方法) .
其中一个输入是(我随后询问过)从基 classes 派生时内存是如何工作的。因为我没有真正做过很多高级 C++。
例如,假设我们有一个基础 class,现在它只有一个 "draw" 方法(顺便说一句,为什么我们需要为它说 virtual
?),因为所有其他派生对象实际上只共享一个通用方法,并且正在绘制:
class GameObject
{
public:
virtual void Draw( ) = 0;
};
我们也有一个球class例如:
class Ball : public GameObject
我收到的信息是,在适当的游戏中,这些可能会保存在某种游戏对象指针向量中。像这样:std::vector<GameObject*> _gameObjects;
(所以是指向游戏对象的指针向量)(顺便说一句,为什么我们要在这里使用指针?为什么不只是纯游戏对象?)。我们将使用以下内容实例化这些游戏对象之一:
_gameObjects.push_back( new Ball( -1, 1, boardWidth / 2, boardHeight / 2 ); );
(new
returns 指向对象的指针是否正确?IIRC)。根据我的理解,如果我尝试做类似的事情:
Ball b;
GameObject g = b;
事情会变得一团糟(如此处所示:What is object slicing?)
但是...我在执行 new Ball( -1, 1, boardWidth / 2, boardHeight / 2 );
时不是简单地自己创建 Derived 对象,还是自动将其分配为 GameObject?我无法真正弄清楚为什么一个有效而另一个无效。它是否与通过 new
与仅 Ball ball
创建对象有关?
抱歉,如果这个问题没有意义,我只是想了解这个对象切片是如何发生的。
当您直接将对象存储在容器中时,就会发生对象切片。当您存储指向对象的指针(或更好的智能指针)时,不会发生切片。因此,如果您将 Ball
存储在 vector<GameObject>
中,它将被切片,但如果您将 Ball *
存储在 vector<GameObject *>
中,一切都会很好。
您的问题的快速回答是当您执行 _gameObjects.push_back( new Ball( ... ))
时对象切片不是问题,因为 new
为 Ball
大小的对象分配了足够的内存。
这是解释。对象切片是编译器认为对象比实际小的问题。所以在你的代码示例中:
Ball b;
GameObject g = b;
编译器已经为名为 g
的 GameObject
保留了足够的 space,但您却试图将 Ball
(b
) 放在那里.但是 Ball
可能比 GameObject
大,然后数据就会丢失,并且可能会开始发生坏事。
但是,当您执行 new Ball(...)
或 new GameObject(...)
时,编译器确切地知道要分配多少 space,因为它知道对象的真实类型。那么,你存储的其实是一个Ball*
或者GameObject*
。您可以安全地将 Ball*
存储在 GameObject*
类型中,因为指针大小相同,因此不会发生对象切片。指向的内存可以是任意数量的不同大小,但指针将始终具有相同的大小。
Btw why do we need to say virtual
for it?
如果你不声明一个函数为虚函数,那么你就不能用虚分派调用这个函数。当通过指针或对基 class 的引用虚拟调用函数时,调用将被分派到最派生的 class(如果存在)中的覆盖。换句话说,virtual
允许运行时多态性。
如果函数是non-virtual,那么函数只能静态调度。当一个函数被静态调用时,编译时类型的函数被调用。因此,如果通过基指针静态调用函数,则调用基函数,而不是派生覆盖。
BTW Why would we use pointers here? why not just pure GameObjects?
GameObject
是抽象的 class,因此您不能拥有该类型的具体对象。由于您不能拥有具体的 GameObject
,因此您也不能拥有它们的数组(或向量)。 GameObject
个实例只能作为派生类型的基础 class 子对象存在。
new
returns a pointer to the object correct?
new
在动态存储中创建一个对象,returns 指向该对象的指针。
顺便说一句,如果您在丢失指针值之前未能在指针上调用 delete
,就会发生内存泄漏。哦,如果您尝试 delete
某事两次,或 delete
并非源自 new
的某事,您的程序的行为将是未定义的。内存分配很困难,您应该始终使用智能指针来管理它。像您的示例中这样的裸指针向量是一个非常糟糕的主意。
此外,通过基对象指针删除对象具有未定义的行为,除非基 class 的析构函数是虚拟的。 GameObject
的析构函数不是虚拟的,因此您的程序无法避免 UB 或内存泄漏。两种选择都不好。解决方案是使 GameObject
的析构函数成为虚拟的。
Avoiding object slicing
您可以通过使基础 class 抽象来避免意外的对象切片。由于不能有抽象的具体实例 class,您不能意外地 "slice off" 派生对象的基础。
例如:
Ball b;
GameObject g = b;
是 ill-formed 因为 GameObject
是一个抽象 class。编译器可能会这样说:
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;
我将尝试回答您提出的各种问题,尽管其他人可能会在他们的回答中进行更技术性的解释。
virtual void Draw( ) = 0;
Why do we need to say virtual for it?
简单来说,virtual
关键字告诉C++编译器该函数可以在子class中重新定义。当你调用 ball.Draw()
时,编译器知道 Ball::Draw()
应该被执行,如果它存在于 Ball
class 而不是 GameObject::Draw()
.
std::vector<GameObject*> _gameObjects;
Why would we use pointers here?
这是个好主意,因为当容器必须为对象本身分配 space 并包含对象时,确实会发生对象切片。请记住,无论指针指向什么,指针的大小都是恒定的。当您必须调整容器大小或四处移动元素时,指针的移动会更容易、更快。如果您确定这样做是有效的,则始终可以将指向 GameObject
的指针转换回指向 Ball
的指针。
new
returns a pointer to the object correct?
是的,new
正在做的是在堆上构建 class 的实例,然后返回指向该实例的指针。
不过,我强烈建议您学习如何使用 smart pointers。这些可以在不再引用对象时自动删除对象。有点像垃圾收集器在 Java 或 C#.
等语言中所做的事情
new Ball( -1, 1, boardWidth / 2, boardHeight / 2 );
...or is that automatically assigning it as a GameObject too?
是的,如果 Ball
继承了 GameObject
class,那么指向 Ball
的指针也将是指向 GameObject
的有效指针。正如您所期望的,您不能从指向 GameObject
的指针访问 Ball
的成员。
Does it have to do with creating an object via new vs just Ball ball for example?
我会解释两种实例化方法的区别Ball
:
Ball ballA = Ball();
Ball* ballB = new Ball();
对于 ballA
我们声明 ballA
变量是 Ball
的一个实例,它将 "live" 在堆栈内存中。我们使用 Ball()
构造函数将 ballA
变量初始化为 Ball
的实例。由于这是一个堆栈变量,一旦程序退出声明它的范围,ballA
实例将被销毁。
对于ballB
,我们声明ballB
变量是指向Ball
实例的指针,该实例将驻留在堆内存中。我们使用 new Ball()
语句首先为 Ball
分配堆内存,然后使用 Ball()
构造函数构造它。最后,new
语句的计算结果为一个分配给 ballB
的指针。
现在当程序退出声明 ballB
的范围时,指针被销毁但它指向的实例留在堆上。如果您没有将该指针的值保存在其他地方,您将无法释放该 Ball
实例使用的内存。这就是智能指针有用的原因,因为它们在内部跟踪实例是否仍在任何地方被引用。
这与价值观有关。
Ball b;
GameObject g;
b
的值是它的变量的不同值。
g
的值同样是它的变量的不同值。
当b
赋值给g
时,b
的一个"subobject"的变量(继承自GameObject
)赋值给g
。这是切片。
现在介绍函数。
对于编译器来说,class 的成员函数是指向函数代码所在内存的指针。
一个non-virtual函数总是一个常量指针值。
但是虚函数可以有不同的值,这取决于它们在哪个 class 中声明。
所以要告诉编译器它应该为函数指针创建一个占位符,使用关键字 virtual
。
现在回到值的分配。
我们知道将不同类型的变量相互赋值会导致切片。所以为了解决这个问题,使用了间接 - 指向对象的指针。
对于任何类型的指针,指针总是需要相同数量的存储空间。并且在分配指针时,底层结构保持不变,仅复制覆盖先前指针的指针。
当我们在 g
上调用已切片的虚函数时,我们可能会从 b
调用正确的 function
,但切片的 g
对象不会'具有 b
函数所需的所有字段,因此可能会发生错误。
但是使用指向对象的指针调用,使用的是原始对象b
,它具有b
的虚函数所需要的所有字段。
基本问题是复制对象(这在 classes 是 "reference types" 的语言中不是问题,但在 C++ 中默认是按值传递事物,即制作副本). "Slicing" 表示将较大对象(类型 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
.
同样的事情发生在 classes:
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
中声明的所有成员都被忽略。仅复制在基 class Base
中声明的数据成员,因为那是 v[0]
的全部空间。
指针给你的是避免复制的能力。当你这样做时
T x;
T *ptr = &x;
, ptr
不是 x
的副本,它只是指向 x
.
同样的,你可以做
Derived obj;
Base *ptr = &obj;
&obj
和 ptr
有不同的类型(分别是 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
的不同子 class 时,所有这些都实现了自己的自定义 draw
方法,您希望代码注意对象的实际类型,因此在每种情况下都会调用正确的方法。如果 draw
不是虚拟的,您的代码将尝试调用不存在的 GameObject::draw
。根据您编码的精确程度,这要么不会首先编译,要么在运行时中止。
所以我正在刷新 C++,老实说已经有一段时间了。我制作了一个控制台乒乓球游戏作为一种复习任务,并获得了一些关于使用多态性为我的 classes 从基础 "GameObject" 派生的输入(它有一些用于将对象绘制到屏幕的基本方法) .
其中一个输入是(我随后询问过)从基 classes 派生时内存是如何工作的。因为我没有真正做过很多高级 C++。
例如,假设我们有一个基础 class,现在它只有一个 "draw" 方法(顺便说一句,为什么我们需要为它说 virtual
?),因为所有其他派生对象实际上只共享一个通用方法,并且正在绘制:
class GameObject
{
public:
virtual void Draw( ) = 0;
};
我们也有一个球class例如:
class Ball : public GameObject
我收到的信息是,在适当的游戏中,这些可能会保存在某种游戏对象指针向量中。像这样:std::vector<GameObject*> _gameObjects;
(所以是指向游戏对象的指针向量)(顺便说一句,为什么我们要在这里使用指针?为什么不只是纯游戏对象?)。我们将使用以下内容实例化这些游戏对象之一:
_gameObjects.push_back( new Ball( -1, 1, boardWidth / 2, boardHeight / 2 ); );
(new
returns 指向对象的指针是否正确?IIRC)。根据我的理解,如果我尝试做类似的事情:
Ball b;
GameObject g = b;
事情会变得一团糟(如此处所示:What is object slicing?)
但是...我在执行 new Ball( -1, 1, boardWidth / 2, boardHeight / 2 );
时不是简单地自己创建 Derived 对象,还是自动将其分配为 GameObject?我无法真正弄清楚为什么一个有效而另一个无效。它是否与通过 new
与仅 Ball ball
创建对象有关?
抱歉,如果这个问题没有意义,我只是想了解这个对象切片是如何发生的。
当您直接将对象存储在容器中时,就会发生对象切片。当您存储指向对象的指针(或更好的智能指针)时,不会发生切片。因此,如果您将 Ball
存储在 vector<GameObject>
中,它将被切片,但如果您将 Ball *
存储在 vector<GameObject *>
中,一切都会很好。
您的问题的快速回答是当您执行 _gameObjects.push_back( new Ball( ... ))
时对象切片不是问题,因为 new
为 Ball
大小的对象分配了足够的内存。
这是解释。对象切片是编译器认为对象比实际小的问题。所以在你的代码示例中:
Ball b;
GameObject g = b;
编译器已经为名为 g
的 GameObject
保留了足够的 space,但您却试图将 Ball
(b
) 放在那里.但是 Ball
可能比 GameObject
大,然后数据就会丢失,并且可能会开始发生坏事。
但是,当您执行 new Ball(...)
或 new GameObject(...)
时,编译器确切地知道要分配多少 space,因为它知道对象的真实类型。那么,你存储的其实是一个Ball*
或者GameObject*
。您可以安全地将 Ball*
存储在 GameObject*
类型中,因为指针大小相同,因此不会发生对象切片。指向的内存可以是任意数量的不同大小,但指针将始终具有相同的大小。
Btw why do we need to say
virtual
for it?
如果你不声明一个函数为虚函数,那么你就不能用虚分派调用这个函数。当通过指针或对基 class 的引用虚拟调用函数时,调用将被分派到最派生的 class(如果存在)中的覆盖。换句话说,virtual
允许运行时多态性。
如果函数是non-virtual,那么函数只能静态调度。当一个函数被静态调用时,编译时类型的函数被调用。因此,如果通过基指针静态调用函数,则调用基函数,而不是派生覆盖。
BTW Why would we use pointers here? why not just pure GameObjects?
GameObject
是抽象的 class,因此您不能拥有该类型的具体对象。由于您不能拥有具体的 GameObject
,因此您也不能拥有它们的数组(或向量)。 GameObject
个实例只能作为派生类型的基础 class 子对象存在。
new
returns a pointer to the object correct?
new
在动态存储中创建一个对象,returns 指向该对象的指针。
顺便说一句,如果您在丢失指针值之前未能在指针上调用 delete
,就会发生内存泄漏。哦,如果您尝试 delete
某事两次,或 delete
并非源自 new
的某事,您的程序的行为将是未定义的。内存分配很困难,您应该始终使用智能指针来管理它。像您的示例中这样的裸指针向量是一个非常糟糕的主意。
此外,通过基对象指针删除对象具有未定义的行为,除非基 class 的析构函数是虚拟的。 GameObject
的析构函数不是虚拟的,因此您的程序无法避免 UB 或内存泄漏。两种选择都不好。解决方案是使 GameObject
的析构函数成为虚拟的。
Avoiding object slicing
您可以通过使基础 class 抽象来避免意外的对象切片。由于不能有抽象的具体实例 class,您不能意外地 "slice off" 派生对象的基础。
例如:
Ball b;
GameObject g = b;
是 ill-formed 因为 GameObject
是一个抽象 class。编译器可能会这样说:
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;
我将尝试回答您提出的各种问题,尽管其他人可能会在他们的回答中进行更技术性的解释。
virtual void Draw( ) = 0;
Why do we need to say virtual for it?
简单来说,virtual
关键字告诉C++编译器该函数可以在子class中重新定义。当你调用 ball.Draw()
时,编译器知道 Ball::Draw()
应该被执行,如果它存在于 Ball
class 而不是 GameObject::Draw()
.
std::vector<GameObject*> _gameObjects;
Why would we use pointers here?
这是个好主意,因为当容器必须为对象本身分配 space 并包含对象时,确实会发生对象切片。请记住,无论指针指向什么,指针的大小都是恒定的。当您必须调整容器大小或四处移动元素时,指针的移动会更容易、更快。如果您确定这样做是有效的,则始终可以将指向 GameObject
的指针转换回指向 Ball
的指针。
new
returns a pointer to the object correct?
是的,new
正在做的是在堆上构建 class 的实例,然后返回指向该实例的指针。
不过,我强烈建议您学习如何使用 smart pointers。这些可以在不再引用对象时自动删除对象。有点像垃圾收集器在 Java 或 C#.
new Ball( -1, 1, boardWidth / 2, boardHeight / 2 );
...or is that automatically assigning it as a GameObject too?
是的,如果 Ball
继承了 GameObject
class,那么指向 Ball
的指针也将是指向 GameObject
的有效指针。正如您所期望的,您不能从指向 GameObject
的指针访问 Ball
的成员。
Does it have to do with creating an object via new vs just Ball ball for example?
我会解释两种实例化方法的区别Ball
:
Ball ballA = Ball();
Ball* ballB = new Ball();
对于 ballA
我们声明 ballA
变量是 Ball
的一个实例,它将 "live" 在堆栈内存中。我们使用 Ball()
构造函数将 ballA
变量初始化为 Ball
的实例。由于这是一个堆栈变量,一旦程序退出声明它的范围,ballA
实例将被销毁。
对于ballB
,我们声明ballB
变量是指向Ball
实例的指针,该实例将驻留在堆内存中。我们使用 new Ball()
语句首先为 Ball
分配堆内存,然后使用 Ball()
构造函数构造它。最后,new
语句的计算结果为一个分配给 ballB
的指针。
现在当程序退出声明 ballB
的范围时,指针被销毁但它指向的实例留在堆上。如果您没有将该指针的值保存在其他地方,您将无法释放该 Ball
实例使用的内存。这就是智能指针有用的原因,因为它们在内部跟踪实例是否仍在任何地方被引用。
这与价值观有关。
Ball b;
GameObject g;
b
的值是它的变量的不同值。
g
的值同样是它的变量的不同值。
当b
赋值给g
时,b
的一个"subobject"的变量(继承自GameObject
)赋值给g
。这是切片。
现在介绍函数。
对于编译器来说,class 的成员函数是指向函数代码所在内存的指针。
一个non-virtual函数总是一个常量指针值。
但是虚函数可以有不同的值,这取决于它们在哪个 class 中声明。
所以要告诉编译器它应该为函数指针创建一个占位符,使用关键字 virtual
。
现在回到值的分配。
我们知道将不同类型的变量相互赋值会导致切片。所以为了解决这个问题,使用了间接 - 指向对象的指针。
对于任何类型的指针,指针总是需要相同数量的存储空间。并且在分配指针时,底层结构保持不变,仅复制覆盖先前指针的指针。
当我们在 g
上调用已切片的虚函数时,我们可能会从 b
调用正确的 function
,但切片的 g
对象不会'具有 b
函数所需的所有字段,因此可能会发生错误。
但是使用指向对象的指针调用,使用的是原始对象b
,它具有b
的虚函数所需要的所有字段。
基本问题是复制对象(这在 classes 是 "reference types" 的语言中不是问题,但在 C++ 中默认是按值传递事物,即制作副本). "Slicing" 表示将较大对象(类型 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
.
同样的事情发生在 classes:
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
中声明的所有成员都被忽略。仅复制在基 class Base
中声明的数据成员,因为那是 v[0]
的全部空间。
指针给你的是避免复制的能力。当你这样做时
T x;
T *ptr = &x;
, ptr
不是 x
的副本,它只是指向 x
.
同样的,你可以做
Derived obj;
Base *ptr = &obj;
&obj
和 ptr
有不同的类型(分别是 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
的不同子 class 时,所有这些都实现了自己的自定义 draw
方法,您希望代码注意对象的实际类型,因此在每种情况下都会调用正确的方法。如果 draw
不是虚拟的,您的代码将尝试调用不存在的 GameObject::draw
。根据您编码的精确程度,这要么不会首先编译,要么在运行时中止。