Pull to refresh

Comments 66

Вот такова особенность C++. Чёрт возьми! — я это выяснил на своей шкуре, разбирая, в чём баг (до этого много программировал на Delphi). Это связано с жизненным циклом объекта. Например, при создании чеширского кота всё пройдёт в таком порядке:
1. Вызвать конструктор кота.
1а. Провести автоинициализацию полей кота.
1б. Вызвать какие-то методы кота. К полям чеширского кота обращаться запрещено, т.к. их автоинициализации не было. Поэтому все методы кота вызываются статически.
2. Вызвать конструктор чеширского кота.
2а. Провести автоинициализацию полей чеширского кота.
2б. Вызвать какие-то методы чеширского кота.

Уничтожение — в обратном порядке.
1. Вызвать деструктор чеширского кота.
1а. Вызвать какие-то методы чеширского кота.
1б. Провести автодеструкторы полей чеширского кота.
2. Вызвать деструктор кота.
2а. Вызвать какие-то методы кота. К полям чеширского кота обращаться запрещено, т.к. они уже уничтожены. Поэтому все методы кота вызываются статически.
2б. Провести автодеструкторы полей кота.

Есть некоторые языки (Object Pascal), в которых это можно. Но и жизненный цикл чеширского кота в паскале другой.
Старт…
1. Очистить все поля чеширского кота. Инициализировать такие поля, как строки и динамические массивы.
2. Вызвать конструктор кота (вызывается вручную как inherited Create). К полям чеширского кота обращаться можно, т.к. они уже очищены. Автоинициализации в Object Pascal просто нет, программист должен лишь помнить, что его метод будет вызван до конструктора и все поля занулены.
3. Вызвать конструктор чеширского кота.

Финиш…
1. Вызвать деструктор чеширского кота.
2. Вызвать деструктор кота (вызывается вручную как inherited Destroy). К полям чеширского кота обращаться можно, т.к. они ещё не очищены. Автодеструкторы (в Delphi таковые есть, хоть и не в таком объёме, как в C++) будут вызваны потом, программист должен лишь помнить, что его метод будет вызван после деструктора и указатели, связанные с чеширским котом, смотрят «в никуда».
3. Разрушить такие поля, как строки, динамические массивы и интерфейсы.
delete cats[]; delete cats[1]; парсер лох? вроде нулик должен быть
Я, конечно, не Страуструп, но, вполне возможно, что некоторые комприляторы такое пропускают… Visual C++ мне вот сказал только что, что, цитирую: «error C2059: syntax error: ']»: )

P.S.: Уважаемый GooRoo, огромная просьба: в следующий раз таких вот мелочей не допускать, читать тяжелее: )
P.P.S.: очень хорошая статья! Понравилась: ) Спасибо: )
Там вообще-то стоит нолик и вряд ли Visual C++ пропустила бы такое. Парсер и правда лох =)
Происходит это потому, что удаление производится через указатель на базовый класс и для вызова деструктора компилятор использует раннее связывание. Деструктор базового класса не может вызвать деструктор производного, потому что он о нем ничего не знает. В итоге часть памяти, выделенная под производный класс, безвозвратно теряется.


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

Например:

class B: public A {
  char m_str[1000];
};

— не будет утечек.

class B: public A {
  std::string m_str;
};

— будут утечки.
но по закону гор деструктор в базовом классе действительно всегда делается виртуальным… По крайней мере, у нас по Code Policy никто даже не думает, выделяет ли там что-то где-то ресурсы или нет…
Если в классе нет ни одной виртуальной функции — нет смысла делать деструктор виртуальным и увеличивать таким образом размер объекта.
Хмм… А если в базовом классе есть динамически выделяемая память?
Базовый класс сам ее освободит.
Память освобождается в деструкторе базового класса. И как он её освободит, если этот деструктор не будет вызываться?
Уточню: мы говорим о разрушении оператором delete объекта класса B через указатель на его базовый подобъект класса A, в случае если деструкторы A и B — невиртуальные.

Деструктор класса A будет вызван в любом случае. Вне зависимости от его виртуальности. И деструкторы всех предков A, если бы таковые были. Компилятор просто знает что их нужно вызвать, видя что delete применяется к A*. (Вас же не удивляет, что будь тут написано не

A * pA = new B; delete pA;

а

B * pB = new B; delete pB;

деструкторы будут вызваны сначала для B а потом и для A?)

В чем теперь ваш вопрос?
Уточню: мы говорим о разрушении оператором delete объекта класса B через указатель на его базовый подобъект класса A, в случае если деструкторы A и B — невиртуальные.


В случае если деструкторы A и B невиртуальные следующий код невалиден
A * pA = new B;
delete pA; // undefined behaviour

Вы же об этом сами писали ниже.
Отправилось раньше времени. Добавить в цитату
Деструктор класса A будет вызван в любом случае.
Ну в таком случае вопрос вообще смысла не имеет.
Но если не обращать внимания на эту мелочь (:
Например, заменив delete pA на pA->A::~A(); а delete pB; на pb->B::~B();
Вопросов нет, спасибо.
Я просто перепутал удаление объекта (когда деструкторы предков будут вызываться по-любому) и удаление по указателю на «чужой» тип.
Это не имеет значения. Деструктор базового класса всегда вызывается после деструктора наследника. А виртуальным его можно не делать потому, что если виртуальных функций нет, то никто не будет использовать класс наследник через указатель на базовый:
class Base {};
class Derived : public Base {};

Base* p = new Derived; // в этом нет никакого смысла
delete p; // так, соответственно, тоже никто делать не будет => виртуальный деструктор не нужен
а если

class Derived: public Base{
  void stop_thread() {...}
public:
  Derived() {pthread_create(...);}
  ~Derived() {stop_thread();pthread_join(...);}
};


И кроме того, такой delete — это неопределенное поведение, как сказано ниже.
Так я и говорю, что никто не будет использовать объект Derived через указатель на Base.
Да, красиво. Спасибо, буду умнее: )
Очень уж я отвык от таких вещей за пол года работы на C#…
По-моему, критерием для объявления виртуального деструктора должно быть не наличие виртуальных функций (кстати, они могут появиться позже, когда никто не подумает о виртуальности деструктора), а сама возможность порождения дочерних классов от данного класса.
Если вы будете наследоваться от класса и использовать его через указатель на базовый класс — то вам нужны в базовом классе виртуальные функции, которые вы будете в наследнике переопределять, и, соответственно, виртуальный деструктор, чтобы можно было корректно удалить объект через указатель на базовый класс.

Если вы будете наследоваться от класса и использовать его через указатель или ссылку на наследника, то есть неполиморфно, то ни виртуальные функции ни виртуальный деструктор вам не нужен.
В момент разработки нового класса не всегда заранее известно, будут ли у него потомки, и тем более — каким образом они будут использоваться.

Поэтому, когда я предполагаю, что у нового класса могут в будущем появиться полиморфные потомки, я обычно сразу делаю деструктор виртуальным.
Я писал эту статейку для достаточно сознательных людей, которые поймут, что утечка произойдет, если в классе будут какие-то данные… Естественно, если там одни методы, то освобождать особо нечего. А вообще, как сказано выше, действительно хорошее правило: ставить виртуальный деструктор всегда, независимо от того, выделяется ли что-то.
В моих примерах не одни только методы. Я к тому, что данные бывают разными.
А кроме того, будь это правило действительно хорошим, оно было бы частью стандарта, вы не находите?
Не нахожу. Именно за счет этого, С++ гибкий и мощный язык, хоть и сложный.

В случае
class A {};
class B: public A {
char m_str[1000];
}

int main() {
A * pA = new B;
delete pA;
return EXIT_SUCCESS;
}

утечка, полагаю, все-таки произойдет, потому деструктор класса B вызван не будет. А если он не будет вызван, то кто должен освобождать память, занятую данными объекта B?
Хотя, возможно, я не прав. Я кажется понял, о чем Вы.
Как бы там ни было, стандарт, в пункте 5.3.5:3 говорит о том, что если статический тип аргумента delete отличается от динамического типа объекта, а деструктор аргумента не является виртуальным, то поведение не определено.
Неопределенное поведение не намного лучше утечки памяти (а может и хуже).
~~ Попытка самоубийства ненамного лучше гемофилии (а может и хуже).
Если бы в стандарт вшили этот момент, то даже в классах, от которых никто и никогда не будет наследоваться надо было бы делать деструктор виртуальным. А, например, gcc (g++), на сколько я помню (давно им не пользовался), кидает warning, если в классе есть виртуальные методы, но деструктор не виртуальный.
return 0 — в фукнции main по стандарту можно и писать. Компилятор сам его добавит.
зы. а если что-то можно не писать то обычно лучше это не писать
Если что-то можно не писать, то лучше это все-таки написать, за исключением случаев, когда эта особенность настолько известна, что её знают все как дважды два.
Более того, вызывая виртуальную функцию из деструктора можно случайно (ну или специально) организовать pure virtual function call :)
Основное правило: если у вас в классе присутствует хотя бы одна виртуальная функция, деструктор также следует сделать виртуальным.

В первом примере ни одной виртуальной функции нет, но деструктор все равно решено сделать виртуальным.
То есть, нужно всегда виртуалить деструктор, а не только при наличии в классе виртуальных функций? Ведь всегда кто-то может пронаследоваться и создавать свои объекты в динамической памяти. Странно как-то, объясните)
Если вам непонятно, могу добавить какую-нибудь левую виртуальную функцию в базовый класс. Я сделал виртуальный деструктор для того чтобы объяснить, как пойдут дела в таком случае.

А правило такое потому, что если в базовом классе нет виртуальных функций, то вряд ли есть особый смысл обращаться к производным классам через указатель на базовый, и наоборот — если в базовом виртуальные функции есть, то практически наверняка вы будете обращаться к нему и его потомкам через указатель.
Ага, мысль понял.
Просто формулировка «если у вас в классе присутствует хотя бы одна виртуальная функция, деструктор также следует сделать виртуальным» не только меня ввела в заблуждение, судя по всему.
Да, ниже меня уже поправили) Имеется в виду второй пример ( создание в динамической памяти). Вопрос тот же)
Непонятный вывод «Основное правило: если у вас в классе присутствует хотя бы одна виртуальная функция, деструктор также следует сделать виртуальным. » Во втором примере как раз виртуальных функций нет, а деструктор все равно должен быть виртуальным.
автор видимо не читал мейерса с сотоварищами…
Хорошо. Но, допустим, я таки _действительно_ хочу создать код зачистки и выделить его куда-нибудь в отдельный метод. Как можно удобно бороться с данной особенностью языка? Наверняка ведь умные дяди уже придумали обходные маневры =)

(сейчас как раз изучаю плюсы, и поэтому может очень пригодиться)
Просто не нужно рассчитывать на виртуальный вызов метода из деструктора.
Хорошо, допустим у меня есть объект. Его я использую в рамках паттерна Стратегия. И конечно после выполнения будет какой-то хлам, который надо убить.

Сейчас такие мысли:

1. Не использовать локальных «не подчищаемых» данных вообще. Все методы сами удаляют то, что наделали. Грустно это.
2. Вынести локальные данные во внешнее хранилище, в другой объект. Это «оттянет» невозможность вызова виртуальных функций на одну ступень абстракции (в самом «хранилище» ведь тоже будет деструктор, в котором тоже будет нельзя вызывать виртуальные функции).
3. Вынести код(ы) зачистки в другой объект, и передавать этим «внешним методам зачистки» локальные данные в виде параметов. Некрасиво ((( Получается что логика работы размазана по куче объектов.
4. Как компромисс между 2 и 3. Все классы первого уровня должны содержать только указатель на массив локальных данных и указатель на массив виртуальных функций. При этом все локальные данные должны быть объектами под паттерном Value-Object, а все методы — объектами под паттерном Command. После чего вручную реализовать полиморфизм и наследование и потом реализовать нормальное поведение деструктора. Но это же жуть какая-то (((
Непонятно в чем проблема. Деструктор класса должен чистить ресурсы, выделенные классом, а не ресурсы предка или наследников. А лучше использовать RAII и перестать задумываться об освобождении ресурсов вообще.
Проблема в том, что при вызове «delete A» хотелось бы иметь не только удаление самого A, но также удаление всех занятых им ресурсов, а не только указателей на эти рерсурсы, ну вы понимаете :) А удаление этих самых ресурсов будет очень сильно зависеть от более чем 9000 параметров, и наверняка не влезет в один метод, поэтому метод подчистки желательно иметь свой в зависимости от класса.

IStorage* storage = new FileStorageImpl(ляляляля) (NetworkFileStorage? OracleStorage?)
IConnector* connector = storage->getConnector()
delete storage;

или если уж совсем жестко, то

IStorage* storage = new Oracle11StorageImpl(ляляляля);
while(null != storage) {
storage = storage->getNextVersion();
}
delete storage;

Конечно можно так:

//Все классы в обязаловку наследуются от этого СуперБазовогоКласса!
class SuperBaseClass {
virtual void** cleanupResources(int argNum, ...)
}

//чтобы потом можно было не заботясь о том,
//что такого метода нет вовсе, делать вот так:

Foo* foo = new foo();
foo->cleanupResources(0);
delete foo;

Но короче это всё некрасиво и здорово смахивает на эмуляцию недостающей возможности…

Или я что-то в упор не понимаю?!!!
Если следовать RAII, то каждый класс в иерархии должен в своем деструкторе освободить занятые именно этим классом ресурсы. Можно сделать это в отдельных функциях, вызываемых деструктором. Очевидно что таким функциям не нужно быть виртуальными. Виртуальным должен быть только деструктор того класса, к которому применяется delete.
а, то есть смысл в том, чтобы ВСЕ классы следовали RAII. Займусь встраиванием этой идеи в моск. Спасибо )
Великолепно! Больше бы таких статей!
У меня вопрос — зачем в языке нужны невиртуальные функции и особенно невиртуальные деструкторы?
Невиртуальные функции вызываются быстрее и могут быть проинлайнены.
Вот за это я и 'люблю' C++ — кучу времени приходится тратить не на программирование, а на попытки подстроится под этот C++. Как говорится: 'С++ is to C as lung cancer is to lung' — The UNIX haters handbook.
Так говорится теми, кто «ниасилил» понять логику языка. Всё ведь на самом деле логично, но для тех, кто не в теме выглядит дико, и они позволяют себе такие высказывания.
А с чего вы взяли, что я 'ниасилил'? Осилил, при чём до такой степени, что могу на чистом ассемблере написать реализацию класса со множественным наследованием. Но сил у меня хватило не только на C++ но и ещё на несколько других языков. И сравнение с этими языками вполне себе показывает то, насколько C++ коряв, и то, как много в нём ad-hoc решений, плохо стыкующихся между собой. Логика-то в нём, конечно есть, иначе, не компилировалось бы ничего, но по той же самой причине логика есть в любом работающем языке программирования, в brainfuck, например. Но нужно ли считать brainfuck удобным языком программирования?
А с чего вы взяли, что я 'ниасилил'?

Вот с этого:
кучу времени приходится тратить не на программирование, а на попытки подстроится под этот C++
А. Ну… Тогда, наверное, не осилил. Не получается у меня размышлять над программами в терминах абстракций C++. Проблема в том, что я не могу свободно думать в терминах ООП для C++, потому что C++ не даёт этой свободы, требуя постоянно каких-то уточнений и учёта огромной кучи тонкостей при использовании объектов (тут надо коснтруктор копирования, тут оператор =, тут ссылку, тут указатель, тут простой деструктор, тут виртуальный, тут надо менять список инициализации, тут не надо, здеся нужен reinterpret_cast и т.д. и т.п). На самом деле, пока я обо всех этих тонкостях не знал, я тоже был ярым сторонником C++, ибо ощущение того, что знаешь столь нетривиальный язык повышает самооценку. Но когда начинаешь C++ использовать в действительно сложных проектах, приходит понимание того, насколько это всё вредно для психического здоровья :). Наверное, поэтому в большинстве сложных проектов используется только около четверти возможностей C++.

Торвальдс вот, кстати, тоже не осилил, если по этому параметру измерять: thread.gmane.org/gmane.comp.version-control.git/57643/focus=57918
Торвальс либо тролль, и сознательно гонит пургу, либо реально не осилил и неосознанно гонит пургу. С++ — сложный язык, тут никто не спорит, но он предоставляет намного больше способов писать безопасно, чем Си. Одно только управление памятью и ресурсами чего стоит (в С++ если грамотно писать этим можно не заморачиваться, благодаря RAII).

Посмотрите на его аргументы:
infinite amounts of pain when they don't work (and anybody who tells me that STL and especially Boost are stable and portable is just so full of BS that it's not even funny)
Переводим на русский: Я не понимаю как работают шаблоны, поэтому когда я что-то написал неправильно и мне вываливается одна ошибка на 2 страницы я паникую. Вывод: STL и Boost — BS.

inefficient abstracted programming models where two years down the road you notice that some abstraction wasn't very efficient, but now all your code depends on all the nice object models around it, and you cannot fix it without rewriting your app.
Переводим на русский: Я не в курсе, что С++ по эффективности ничем не отличается от Си, если не использовать виртуальные функции и RTTI. Я себе так представляю, что введение новых абстракций ужасно неэффективно, а поскольку я не умею мыслить в ОО-стиле, код у меня получается такой, что для того, чтобы что-то починить/изменить приходится переписывать всё приложение. Вывод: С++ — BS.

Линус конечно молодец, он очень много сделал полезного, но это не значит, что любые его высказывания должны восприниматься как неоспоримые истины.
Вы как-то слишком вольно переводите его высказывания :) И в ОО-стиле он вполне мыслить умеет, посмотрите на исходники Linux, на то, как пишутся драйверы. Это чистый объектно-ориентированный дизайн. Да и вообще модульное ядро спроектировать иным способом, скорее всего, невозможно. Но это всё сделано на Си. Вот Линус и спрашивает: а на кой нам C++, если всё можно сделать на Си без лишних заморочек со сложностями C++?

Если же эти сложности созданы, чтобы дать возможность работать с виртуальными функциями и RTTI, которые замедляют исполнение кода, то тут уже можно смотреть на более высокоуровневые языки, в которых это всё сделано удобнее.

Си++ проигрывает Си по своей черезмерной сложности, когда речь заходит о необходимости создавать эффективный код. И Си++ проигрывает D, например, когда нужно удобно и быстро программировать. В итоге, imho, популярность Си++ объясняется только его черезмерной крутостью, а не полезностью в реальном программировании.
Скажите, а как правильно объявить деструкторы Ваших классов, чтобы при уничтожении получить:
Meow-meow!
Bye-bye! (:

Сделайте это прямо в деструкторе производного класса.
То есть в классе CheshireCat нужно добавить:
~CheshireCat() { sayGoodbye(); }
или
virtual ~CheshireCat() { sayGoodbye(); }
?

А тогда в этом же классе сточка
virtual void sayGoodbye()
обязательно должна быть со словом «virtual»?

То есть, я не могу понять, для чего «virtual» ставится в методах производного класса? В первом примере деструктор только базового класса нуждался в «virtual» для правильной роботы.
Прочитайте мою предыдущую статью. Там я писал, что если метод в базовом классе объявлен как виртуальный, то в производных он тоже будет виртуальным независимо от того, поставите ли Вы в производном классе слово «virtual». А ставят virtual в производных классах только для того, чтобы программист, который не видел код базового класса, знал, что этот метод виртуальный.

Поэтому в данном случае нет разницы, как Вы напишете: ~CheshireCat() или virtual ~CheshireCat() — в классе Cat деструктор все равно виртуальный.
За статью спасибо. Теперь я на много спокойнее смотрю на это страшное слово «virtual» :)
Спасибо за то, что прочитали ;)
Ещё более интересные штуки происходят в случае, когда Ваш код многопоточный и в деструкторе базового класса вы делаете Wait/Join потока, который вызывает методы экземпляра дочернего класса.
При этом во «втором» потоке из-за data race на указателе vfptr в одном и том же месте когда в зависимости от scheduling могут быть вызваны как методы дочернего класса, так и родительского.

Если описанные Вами проблемы воспроизводятся в 100% случаев (во всяком случае, WTF-поведение очевидно и может быть проанализировано под дебаггером), то из-за data race «неправильное» поведение может происходить редко — в 1% случаев! :)

Подробнее
code.google.com/p/data-race-test/wiki/PopularDataRaces#Data_race_on_vptr
Sign up to leave a comment.

Articles