Как избежать нарезки объектов - PullRequest
0 голосов
/ 05 июня 2019

Так что я обновляюсь на C ++, и, честно говоря, это было какое-то время.Я сделал консольную игру понг как своего рода задачу по повышению квалификации и получил некоторые сведения об использовании полиморфизма для своих классов для получения базового «GameObject» (в котором есть несколько базовых методов для рисования объектов на экране).

Одна из частей ввода была (и я впоследствии спрашивал о ней) о том, как работает память при выводе из базовых классов.Поскольку я на самом деле не проделал много продвинутого C ++.

Например, допустим, у нас есть базовый класс, а сейчас у него просто есть метод "рисования" (кстати, почему мы должны сказать virtual для него?), поскольку все остальные производные объекты на самом деле имеют только один общий метод, и он рисуется:

class GameObject
{
public:

    virtual void Draw( ) = 0;
};

у нас также есть класс ball, например:

class Ball : public GameObject

Вход IПолучается, что в правильной игре они, вероятно, будут храниться в каком-то векторе указателей GameObject.Примерно так: std::vector<GameObject*> _gameObjects;

(То есть вектор указателей на GameObjects) (кстати, зачем нам здесь указатели? Почему бы не просто GameObjects?).Мы создали бы один из этих gameObjects с чем-то вроде:

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

(new возвращает указатель на объект правильно? IIRC).Из моего понимания, если бы я попытался сделать что-то вроде:

Ball b;
GameObject g = b;

Это могло бы испортить вещи (как видно здесь: Что такое нарезка объектов? )

Однако... я не просто создаю производные объекты самостоятельно, когда делаю new Ball( -1, 1, boardWidth / 2, boardHeight / 2 );, или это тоже автоматически назначает его как GameObject?Я не могу понять, почему один работает, а другой нет.Связано ли это с созданием объекта с помощью new против Ball ball, например?

Извините, если вопрос не имеет смысла, я просто пытаюсь понять, как будет происходить нарезка объекта.

Ответы [ 6 ]

2 голосов
/ 05 июня 2019

Разрезание объектов происходит, когда вы непосредственно сохраняете объекты в контейнере. При хранении указателей (или, что лучше, умных указателей) на объекты не может происходить нарезка. Таким образом, если вы храните Ball в vector<GameObject>, он будет разрезан, но если вы сохраните Ball * в vector<GameObject *>, все будет хорошо.

1 голос
/ 05 июня 2019

Фундаментальная проблема - это копирование объекта (что не является проблемой в языках, где классы являются «ссылочными типами», но в 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;

&obj и ptr имеют разные типы (Derived * и Base * соответственно), но C ++ в любом случае разрешает этот код. Поскольку Derived объекты содержат все элементы Base, можно разрешить указателю Base указывать на экземпляр Derived.

По сути, это сокращенный интерфейс до obj. При доступе через ptr он имеет только методы, объявленные в Base. Но поскольку копирование не было выполнено, все данные (включая Derived определенные части) все еще там и могут использоваться для внутреннего использования.

Что касается virtual: Обычно, когда вы вызываете метод foo через объект типа Base, он вызывает ровно 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, которого не существует. В зависимости от того, как именно вы его кодируете, он либо не будет компилироваться в первую очередь, либо прерываться во время выполнения.

1 голос
/ 05 июня 2019

Я попытаюсь ответить на различные вопросы, которые вы задавали, хотя у других может быть более техническое объяснение в их ответах.

virtual void Draw( ) = 0;

Почему для этого нужно сказать виртуальный?

Проще говоря, ключевое слово virtual сообщает компилятору C ++, что функция может быть переопределена в дочернем классе.Когда вы вызываете ball.Draw(), компилятор знает, что Ball::Draw() должен выполняться, если он существует в классе Ball вместо GameObject::Draw().


std::vector<GameObject*> _gameObjects;

Почему бымы используем указатели здесь?

Это хорошая идея, потому что разделение объектов происходит, когда контейнер должен выделять пространство для самих объектов и содержать их.Помните, что указатель имеет постоянный размер, независимо от того, на что он указывает.Когда вам нужно изменить размер контейнера или переместить элементы, указатели намного легче и быстрее перемещаться.И вы всегда можете привести указатель к GameObject обратно в указатель на Ball, если вы уверены, что это правильно.


new возвращает указатель направильный объект?

Да, new делает создание экземпляра этого класса в куче и затем возвращает указатель на этот экземпляр.
Я настоятельно рекомендую вам узнать, какиспользовать умные указатели хотя.Они могут автоматически удалять объекты, когда на них больше нет ссылок.Вроде того, что делает сборщик мусора на таких языках, как Java или C #.


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

... или это тоже автоматически назначает его как GameObject?

Да, если Ball наследует класс GameObject, то указатель на Ball также будет действительным указателем на GameObject.Как и следовало ожидать, вы не можете получить доступ к элементам Ball из указателя на GameObject.


Имеет ли это отношение к созданию объекта с помощью новогоvs просто 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.Вот почему умные указатели полезны, потому что они внутренне отслеживают, есть ли где-нибудь еще ссылки на экземпляр.

1 голос
/ 05 июня 2019

Кстати, почему мы должны сказать 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;
1 голос
/ 05 июня 2019

Быстрый ответ на ваш вопрос заключается в том, что разделение объектов не является проблемой, когда вы делаете _gameObjects.push_back( new Ball( ... )), потому что new выделяет достаточно памяти для объекта размером Ball.

Вот объяснение.Разделение объектов - это проблема, при которой компилятор считает, что объект меньше, чем он есть на самом деле.Итак, в вашем примере кода:

Ball b;
GameObject g = b;

Компилятор зарезервировал достаточно места для GameObject с именем g, и все же вы пытаетесь поместить туда Ball (b).Но Ball может быть больше, чем GameObject, и тогда данные будут потеряны, и плохие вещи, вероятно, начнут происходить.

Однако, когда вы делаете new Ball(...) или new GameObject(...), компиляторточно знает, сколько места нужно выделить, потому что он знает истинный тип объекта.Тогда то, что вы храните, на самом деле Ball* или GameObject*.И вы можете безопасно хранить Ball* в типе GameObject*, потому что указатели имеют одинаковый размер, поэтому разделение объектов не происходит.Память, на которую указывает память, может иметь любое количество разных размеров, но указатели всегда будут одинакового размера.

0 голосов
/ 05 июня 2019

Это связано со значениями.

Ball b;
GameObject g;

Значение b - это разные значения его переменных.

Значение g - также разные значенияего переменных.

Когда b назначается g, переменные "подобъекта" b (унаследованные от GameObject) назначаются переменным g.Это нарезка.

Теперь о функциях.

Для компилятора функция-член класса является указателем на память, в которой находится код функции.

Невиртуальная функция - это всегда постоянное значение указателя.

Но виртуальная функция может иметь разные значения в зависимости от того, в каком классе они были объявлены.

Таким образом, чтобы сообщить компилятору, что он должен создатьв качестве заполнителя для указателя на функцию используется ключевое слово virtual.

Теперь вернемся к присвоению значений.

Мы знаем, что присвоение различных типов переменных друг другу может вызвать нарезку.Таким образом, для решения этой проблемы используется косвенное указатель - указатель на объект.

Указателю всегда требуется одинаковое количество строк для любого указателя типа.И когда указатель назначен, базовая структура остается неизменной, копируется только указатель, который переопределяет предыдущий указатель.

Когда мы вызываем виртуальную функцию на g, которая была разрезана, мы можем вызыватьправильный function из b, но нарезанный объект g не имеет всех полей, необходимых для функции b, поэтому может произойти ошибка.

Но вызов с использованием указателя наобъект, оригинальный объект b, который имеет все обязательные поля, используемые виртуальной функцией b.

...