Как безопасно разрушить объект. И другие мысли

    Недавно разглядывал вакансии одной известной конторы, задумывался над вопросам (которые, кстати, на всех их вакансиях одинаковые). И решил написать заметку по самому интересному (на мой взгляд) аспекту первого же вопроса. Может быть доберусь и до других, а пока предлагаю задуматься, надо ли делать деструкторы виртуальными?

    Ответ не так уж однозначен, и чтобы заманить вас под кат скажу, что в реализации STL вы обнаружите всего несколько виртуальных деструкторов.

    Каким же должен быть полный ответ на вопрос про деструкторы?


    Суть проблемы для тех, кто не очень в курсе



    Итак, привожу, всем уже надоевший пример, представляющий неправильный деструктор:

    #include <iostream>
    
    class A {
    public:
        A() {std::cout << "A()" << std::endl;}
        ~A() {std::cout << "~A()" << std::endl;} // 6
    };
    
    class B : public A {
    public:
        B() {std::cout << "B()" << std::endl;}
        ~B() {std::cout << "~B()" << std::endl;}
    };
    
    int main() {
        A *a = new B; // 16
        delete a;     // 17
        return 0;
    }
    

    Результат таков:

    A()
    B()
    ~A()
    

    То есть, конструктор B в строке 16 честно вызвал оба конструктора (A и B), а деструктор в строке 17 вызвал только деструктор класса A, что полностью согласуется с типом переданной ему ссылки.

    Всё отработало правильно, но деструктор B вызван не был, что могло привести к утечкам памяти, дескрипторов и других полезных ресурсов.

    Как с этим бороться?


    Общепринятый ответ — виртуальный деструктор



    Если в строке 6 мы добавим слово virtual:

    virtual ~A() {std::cout << "~A()" << std::endl;} // 6
    

    то всем будет счастье

    A()
    B()
    ~B()
    ~A()
    

    Это обсуждалось на хабре много раз. Были очень хорошие статьи. Но я хотел бы обсудить не то, как работают виртуальные методы, а более философские вопросы.


    Хорошо ли делать публичными виртуальные методы?



    Давайте вспомним, для кого создаются публичные методы. Они определяют интерфейс класса и создаются для тех, кто будет использовать класс.

    А для чего существуют виртуальные методы? Правильно — для настройки поведения класса. То есть для тех, кто будет расширять функциональность класса.

    Наверно любой здравомыслящий человек скажет, что использование класса и разработка класса — это разные задачи. Смешивать их не нужно. Поэтому и придуманы подходы NVI (Non-Virtual Interface), поведенческие патерны типа bridge и другие уловки, позволяющие разделить абстракцию и реализацию. Полезность такого разделения уже не вызывает ни у кого сомнений. Мы не будем здесь подробно описывать все детали и варианты, а лишь сделаем вывод.

    Вывод: делать виртуальные методы публичными не очень хорошо.

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

    Второй вопрос:


    Хорошо ли полиморфно уничтожать объекты?



    Как известно, всё, что было создано, надо бы удалить. Чтобы не запутаться, не ошибиться, ничего не забыть и не пропустить надо либо автоматизировать процесс удаления объектов (автоматические переменные, auto_ptr и множество подобных средств), либо следует удалять объекты где-то не слишком далеко от точки их создания.

    Скажем, если вы создаёте объект (в куче) в конструкторе некого контейнера, то уместно удалить этот объект в деструкторе того же контейнера. Обратите внимание, что в данном случае в полиморфном удалении нет никакой необходимости.

    Вывод: полиморфное удаление — подозрительная штука.

    Если вы испытываете острую необходимость в полиморфном удалении — это повод задуматься над дизайном. На самом деле полиморфный деструктор нужен не так часто. Возможно следует чуть пересмотреть дизайн, отказаться от полиморфного удаления, приблизить друг другу операции создания и удаления и таким образом сделать код более простым, стройным. Пусть объект живёт полиморфной судьбой, но создание и удаление — это парные операции. Объект создаётся вполне определённого типа и логично и удалить его не забывая про этот тип. Это возможно не всегда, но очень часто.


    Кроме того, виртуальные методы сразу создают некоторые ограничения



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

    Вывод: прежде, чем сделать первый виртуальный метод, можно на минуту задуматься: а оно нам точно надо?


    Так каким же должен быть идеальный деструктор базового класса?



    Иносказательно.

    Делать деструкторы не виртуальными и публичными (как в первом примере) — всё равно, что ездить на роликах по МКАД. Одно не ловкое движение, не успели увернуться и вот уже ролики отдельно, а вы отдельно…

    Делать деструктор публичным и виртуальным — это как ездить по МКАД на асфальтоукладочном катке. Вам ничего не грозит, вы как в танке. Можете нарезать круги, не опасаясь за свою жизнь. Но это не самый быстрый способ; к тому же, ваше движение вступает в некоторое противоречие с общим потоком.

    Делать деструктор защищённым и не виртуальным, это как двигаться по МКАД на бронированной иномарке с кондиционером, личным водителем за рулём и бокалом шампанского в руках. Это не только безопасно и удобно для вас, вы ещё и не создаёте неудобств для других. Хотя (обратите внимание) на вашей иномарке вы не сможете проехать везде, где проехал бы каток.

    Защищённый не виртуальный деструктор хорош почти всем. Он не виртуален и он не позволяет пользователям класса создавать аварийные ситуации на подобии той, что была показана в первом примере. Он не позволяет использовать базовый класс напрямую, что тоже часто бывает полезно. Одним словом, он защищает вас от ошибок и вынуждает писать более качественный код.

    Асфальтоукладчики — хорошая штука. Иногда для работы нужны действительно именно они. Но это не единственный вид транспорта и, более того, часто удобнее пользоваться не ими.

    Итак ответ (полный):

    Если вы пишите что-то маленькое и не на долго — делайте деструкторы публичными и виртуальными и ни о чём не думайте. Ещё не было случаев, чтобы виртуальные деструкторы кого-то подвели. Это надёжно, и в этом нет ничего плохого.

    Если вы создаёте софт на годы с перспективой развития (совершенствования, переноса на другие платформы), то следует серьёзно подумать о защищённых деструкторах. Чёткое раздение абстракции и реализации — полезная штука.


    Всем счастливых праздников и успехов в новом году!



    Спасибо известной компании за интересные вопросы. Любопытно было бы узнать, а какой ответ ожидали они? Уж очень маленькое поле ввода для ответа. А ведь я коснулся лишь одной части ответа на их вопрос.

    upd: появился ответ на эту статью. Отвечать наверно ничего не буду. Диагноз мне там уже поставили. Авторитетов автор, очевидно, не признаёт. Книжки для него не аргумент. Но в конструктивной части заметки есть очень правильные мысли о вреде наследования вообще. А что касается фабрики кодеков, о которой пишет автор в конце, то он может надеяться почитать рассказ и про это, если я соберусь написать про вопрос 4 из всё того же наборчика вопросов.

    upd2: Почему-то находятся люди, которые воспринимают этот пост как нападку на Яндекс. Уверяю вас! В этом посте нет скрытого смысла и нет нападок. Я просто почитал их вопросы, они мне показались достойными обсуждения, я написал заметочку. И всё.
    Поделиться публикацией

    Комментарии 51

      0
      Хотелось бы уточнить по поводу:
      > Вывод: делать виртуальные методы публичными не очень хорошо.
      Это вы имеете ввиду конечные классы, которыми пользуется пользователь, или вообще всегда? Ведь абстрактные классы никто не запрещал вроде.
        –1
        <gramar_nazi>
        У Вас в заголовке запятая пропущена
        </gramar_nazi>
          0
          gramar пишется через две m
          0
          Честно говоря, в любой разработке один из самых неуловимых, но важных моментов — соблюдать меру. Если вы пишете корпоративное приложение, которое и поддерживаете — то да, подход вашей «иномарки» оправдан, т.к. позволяет вам иметь контроль над приложением и держать архитектуру чистой.

          Если же, например, такой код встречается в публичных библиотеках, то подход вряд ли будет оправдан. Не все приложения, использующие ее, захотят следовать всем вашим правилам. Плюс к тому, если используется несколько библиотек, то следование правилам каждой из них может превратить жизнь в ад, с учетом что они нередко противоречат друг другу.

          Так что… Да, есть минусы, есть плюсы. Но конкретный выбор далеко не столь однозначен и прост.
            +9
            > Любопытно было бы узнать, а какой ответ ожидали они?

            Вероятно, достаточно бы было ответа про:
            1) В C++ main должен возвращать int
            2) В списках инициализаторов Bar должен быть Foo(что-то там), поскольку у Foo нет конструктора без обязательных параметров.
            3) В Foo должен быть конструктор копирования
            4) В Bar должен быть конструктор неявного преобразования в Foo (привет valgrind)
            5) Для реализации пункта 4 скорее всего понадобится указать спецификатор доступа к родительскому классу class Bar: public Foo
            6) В ~Foo и ~Bar должны быть delete[], а не delete
            7) Проверки на успешность выделения памяти? fputs("Доставьте ваш комп в музей", stderr); Тут почти наверняка они хотели увидеть что-то про auto_ptr и SFINAE
            8) В качестве параметров конструкторов должно быть size_t, а не int (привет статические анализаторы)
            9) Виртуальные деструкторы: см. пост.

            Хотя, быть может, они хотели увидеть переделанный код на std::vector и отсутствие элементов-данных с одинаковыми именами. Чёрт их разберёт.
              –2
              Даже по первому пункту можно сказать много больше. В общем, по однму только первому вопросу можно докторскую защищать :-) Такую добротную, с историей вопроса, обзором литературы и стандартов, описанием существующих решений и тенденций.
                0
                > fputs(«Доставьте ваш комп в музей», stderr);

                никогда не вылезали за 2Gb в 32-битном процессе? бывают такие задачи.
                • НЛО прилетело и опубликовало эту надпись здесь
                    0
                    Для выполнения предложенной задачи необходимо и достаточно 500 байт на классической 32-битной платформе. Так что точно в музей =).

                    Ещё: к пункту 8 скорее всего стоит дописать замену int *i на int32_t *i.

                    Задача-минимум состоит в том, чтобы код компилировался компиляторами, работающими по стандарту, не производил утечек и обеспечивал одинаковую работу на разных платформах/архитектурах.
                      0
                      Мастеров пихающих int32_t везде без разбора надо пороть.
                    0
                    3: в Foo по той же причине должен быть ещё и оператор присваивания. И так же конструктор копирования и оператор присваивания должны быть в Bar.

                    7: operator new кидает исключение при невозможности выделить память.
                    0
                    На картинке явно видно, что строение не жилым, не промышленным и никаким небыло, построили и сразу поломали?

                    — Что это за семиэтажная херня?
                    — Дак вы же сами…
                    — Экскаватор на нее поставили и чтоб я через 3 дня ее не видел!
                      0
                      Это снос какого-то небоскрёба в Китае. Был ли он жилим — не знаю.
                        0
                        Это глубокий рефакторинг на раннем этапе развития проекта :)
                        0
                        В общем RAII во все поля…
                          +4
                          Не соглашусь с таким шаблонным разделением: «маленькое и ненадолго — публичный виртуальный» против «большое и всерьёз — защищённый невиртуальный». Есть паттерны, опирающиеся на полиморфный деструктор, state например. И Герб Саттер в своей заметке не так категоричен. Нужно просто понимать что виртуальный и невиртуальный деструктор — предназначены для двух разных вариантов использования базового класса.
                            +13
                            Как-то уж очень много натяжек
                            Вывод: полиморфное удаление — подозрительная штука.
                            Вывод сделан на основании того, что владеющий класс всегда контролирует процесс создания объектов — то есть что не бывает сложных объектов, созданных на классе-фабрике и переданных во владение в нашу подсистему. Ага.

                            Вывод: делать виртуальные методы публичными не очень хорошо.
                            Замечательно — особенно если мы реализуем COM-интерфейс с какими-нибудь тривиальными свойствами. Что там надо городить вместо простой реализации
                            virtual int GetTrivialField() const = 0
                            ?

                            Вывод: прежде, чем сделать первый виртуальный метод, можно на минуту задуматься: а оно нам точно надо?
                            Ограничение по поводу union'ов конечно нас пугает до смерти, а уж использование виртуальных вызовов если боремся за скорость проконтролировать никак нельзя. Ага.

                            Предложенное переусложнение, IMHO, не решает никаких проблем. Так что лучше пользоваться виртуальными деструкторами и не мучиться бессонницей на эту тему.
                              +6
                              > автоматизировать процесс удаления объектов (автоматические переменные, auto_ptr и множество подобных средств),

                              Давайте, расскажите мне как использовать auto_ptr на интерфейс без полиморфного удаления

                              > Вывод: полиморфное удаление — подозрительная штука.

                              Вывод высосан из пальца, аргументов нет. NVI, brige — это все хорошо, но только не надо забывать что деструктор — это чисто техническая вещь, к обязанностям интерфейса класса он как правило не имеет ни малейшего отношения.

                              > приблизить друг другу операции создания и удаления и таким образом сделать код более простым, стройным.

                              Да что вы говорите. Каким образом код станет проще и стройнее? Создание и удаление в современном C++ — это RAII, new и delete действительно рядом написаны в коде, но вот только в большом количестве случаев — удаление полиморфное: shared_ptr(new Service); при этом создание и удаление производится как правило в разных контекстах

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

                              Еще есть ограничения, кроме непереносимого юниона? дальше вроде о переносе на другие платформы упоминается.

                              Итого: стоящих аргументов не видно, примеров плохого кода с полиморфным удалением нет.

                                0
                                тьфу *shared_ptr(new Service);
                                  0
                                  ааа, парсер жрет угловые скобки. Еще раз: shared_ptr < IService > (new Service)
                                +4
                                Вывод: делать виртуальные методы публичными не очень хорошо.


                                Насчет обычных методов спорить не буду, а вот деструктор практически всегда должен быть виртуальным, так как теоретически у вас нету 100-процентной уверенности в том, что кто-нибудь не захочет наследовать от вашего класса.

                                На мой взгляд, вывод из статьи должен быть таким — если в классе есть хотя бы один виртуальный метод, то и деструктор должен быть обязательно виртуальным. Если же в классе виртуальных методов нету, то необходимо в комментарии пояснить, что он не предназначен для наследования. Если же все-таки может возникнуть необходимость наследовать от класса, деструктор должен быть виртуальным. Это покрывает случаи использования класса другими людьми или вами же, но в другом проекте или контексте.

                                Если класс будет использоваться только в пределах одного проекта, тогда правила устанавливаются из соображений целесообразности для данного конкретного проекта.
                                  0
                                  > если в классе есть хотя бы один виртуальный метод, то и деструктор должен быть обязательно виртуальным

                                  Тогда уж лучше так: еcли у наследника есть деструктор, то у базового класса деструктор должен быть виртуальным. Только вот беда — базовый класс заранее не знает, какие от него отнаследуются наслденики.

                                  > необходимо в комментарии пояснить, что он не предназначен для наследования

                                  Во-первых, класс не предназначен для полиморфного удаления. А наследовать можно и даже нужно.

                                  Во-вторых, обратите внимание, что если вы делаете деструктор защищённым, то никаких комментариев не понадобится. Код из первого примера просто не скомпилируется.
                                  +4
                                  в java все методы виртуальные
                                  и ничего — живут люди
                                    +1
                                    С таким же успехом можно сказать: «в Perl все методы публичные, и ничего — живут люди».
                                    Java — это другой язык, в нём другие средства. Ваши люди (которые «живут же») ведь не брезгуют использованием интерфейсов?
                                    0
                                    либо следует удалять объекты где-то не слишком далеко от точки их здания

                                    исправьте здания на создания
                                      0
                                      Один из случаев, необходимо использование виртуальных деструкторов — придание виртуальности оператору delete, определенному локально. Язык C++ не является полным, поэтому приходится так неочевидно добиваться virtual operator delete.
                                        +1
                                        По поводу вопроса в вакансии Яндекса — тип подкласса из-за private наследования(т.е. наследование реализации) не является полиморфным и не приводится к базовому классу, соответственно то что мы обсуждаем немного не то.

                                        В STL практически нет виртуальных функций и деструкторов потому что это часто не нужно. И зачем использовать контейнер STL как полиморфный тип?

                                        «Деструкторы необходимо делать виртуальными.» — так говорят всегда всем студентам и школьникам при первом знакомстве с С++. В этом контексте, я полностью согласен с этим утверждением — если сделаешь деструктор виртуальным, то хуже никогда не будет. Понимая как он работает, ты вряд ли ошибешься.

                                        Но отсутствие виртуальности деструктора позволяет:
                                        — сделать базовый класс POD-типом
                                        — сделать С-совместимый тип (иногда)
                                        — уменьшить растраты на VMT(иногда, с увеличением производительности)
                                        — использовать объект без его удаления в callback-ах

                                        Если забыть про разные С-подобные штуки, то вопрос точнее должен стоять так — А должны ли объекты класса быть полиморфно удаляемыми?
                                        Для COM-объектов это необходимо, вспомните про delete this в Release().
                                          0
                                          Вот этот тонкий момент: «сделать базовый класс POD-типом» + «сделать С-совместимый тип» + уменьшить растраты на VMT меня всегда волновал. А оно надо? Если идёт борьба за байты и миллисекунды, может просто написать критически важные участки кода на чистом с, или вообще на асме?

                                          А последний пункт («использовать объект без его удаления в callback-ах») не понял, объясните, пожалуйста.
                                            0
                                            но ведь С++ удобнее, и в нем есть много бесплатных удобств…
                                            а некоторые удобства еще и несут дополнительную производительность, шаблоны например…
                                              0
                                              Ну а пайтон (руби, js...) ещё удобнее. Вопрос то не в удобстве, а в производительности. Вот критические куски можно переписывать на чистом си.

                                              А что вы имели ввиду под дополнительной производительностью при использовании шаблонов?
                                          0
                                          > Скажем, если вы создаёте объект (в куче) в конструкторе некого контейнера,
                                          > то уместно удалить этот объект в деструкторе того же контейнера.
                                          > Обратите внимание, что в данном случае в полиморфном удалении нет никакой необходимости.

                                          Обращаю внимание, что из первого предложения следует отрицание второго. Если то, что создаётся в конструкторе логично освободить в деструкторе, то если в потомках что-то будет создаваться (что довольно ожидаемо), то деструкторы потомков должны вызываться и деструктор должен быть виртуальным.

                                          Более логичный вывод — деструктор не должен быть виртуальным только в тех случаях когда потомки будут отличаться только поведением.
                                            0
                                            Давайте вспомним, для кого создаются публичные методы. Они определяют интерфейс класса и создаются для тех, кто будет использовать класс.

                                            А для чего существуют виртуальные методы? Правильно — для настройки поведения класса. То есть для тех, кто будет расширять функциональность класса.

                                            Виртуальные методы служат в основном для декларации внешних интерфейсов классов. А они как раз публичные. А вот непубличные виртуальные методы как правило говорят о использовании наследования реализации, а не наследовании интерфейсов. Что, как правило, есть зло, т.к. пораждает сильную связность.
                                              +1
                                              Нет. Виртуальные методы могут использоваться для декларации интерфейсов. Они ещё много для чего могут использоваться. Но служат они всё же не для этого.

                                              И совершенно не понятно, как наличие не публичных виртуальных методов может помешать наследовать интерфейс?

                                              Я всё же призываю не смешивать публичность и виртуальность. Это вещи не связанные, хотя и могут использоваться одновременно.
                                              +1
                                              На мой взгляд, для описания интерфейса и существуют интерфейсы, т.е. абстрактные виртуальные классы, в которых деструктор не описывается, он там просто не нужен. А вот если уж в системе требуется полиморфность, временами без неё можно такого нагородить, что будет только хуже. Не вижу в этом приступления, ну да, будет работать чуть медленее и что в этом такого? Да и ранняя оптимизация редко приводит к хорошим результатам (путь то качество оптимизации или затраченное на неё время).

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

                                              Если перейти именно к архитектуре, то хотелось бы задать вопрос, как реализовать работу плагинов без полиморфизма, как реализовать работу с различными объектами на карте (если конкретно по вакансии)?
                                              Использовать case?

                                              Высказывание относительно того, что использовать открытый виртуальный метод, это на мой взгляд тоже самое, что и зачем использовать руль в машине, машина нужна для того, чтобы ехать, а не для того, чтобы рулить.
                                                0
                                                По поводу заголовка, кажысь правильнее было бы написать «Как безопасно разрушить объект» и другие мысли.
                                                  0
                                                  typo: не правильный деструктор -> неправильный деструктор
                                                  • НЛО прилетело и опубликовало эту надпись здесь
                                                      0
                                                      По поводу shared_ptr — это не так в случае полиморфизма, поскольку тогда он объявляется для абстрактного класса, а ссылается на конкретные объекты, и остаются все те же проблемы, что и с обычным указателем.
                                                      • НЛО прилетело и опубликовало эту надпись здесь
                                                          0
                                                          Да, вы правы.
                                                          Спасибо, не знал.
                                                        0
                                                        A *get_a() { return new B; }
                                                        ...
                                                            std::shared_ptr<A> a(get_a());

                                                      • НЛО прилетело и опубликовало эту надпись здесь
                                                          +7
                                                          Хорошая статья — удачное жонглирование псевдологикой. Элементарные и корректные примеры… не относящиеся к теме. Все остальное — метафоры, «не вызывает ни у кого сомнений», «не есть хорошо и может выйти вам боком» и прочие откровения британских ученых.
                                                          Единственный корректный аргумент во всей статье — не может использоваться в объединениях. Ну ради объединений не грех отказаться от ООП.
                                                            0
                                                            Юзать union'ы в 2010(11) году! Это мягко говоря моветон
                                                            +3
                                                            у меня от статьи когнитивный диссонанс какой-то, простите.
                                                            А может это вброс такой?
                                                              0
                                                              Каждый год, 31-го декабря, мы с друзьями красим вентилятор… ;)
                                                              +1
                                                              По поводу деструкторов моя мысль проста.
                                                              Есть хоть один виртуальный метод — сразу же обзаводись виртуальным деструктором!
                                                              Если нет — надо думать, на что класс годится; обычно не нужно.
                                                                0
                                                                +1, в GCC можно включить warning для таких случаев…
                                                                0
                                                                Про книжки. Может быть я невнимательно прочитал, но ссылок на книги не заметил.

                                                                Вот одна на тему:
                                                                Scott Meyers — Effective C++ Third Edition 55 Specific Ways to Improve Your Programs and Designs
                                                                Chapter 2, Item 7.
                                                                  0
                                                                  Есть мнение, что стоит вообще отказаться от выделения и освобождения ресурсов в конструкторахи деструкторах объектов в пользу явных вызовов и специализированных интерфейсов и аллокаторов по причине трудностей, возникающих при обработке исключительных ситуаций.
                                                                    0
                                                                    Кстати, не стоит еще забывать и о том, что использование виртуальных методов необходимо для обеспечения ABI (Application Binary Interface) совместимости, при разработки динамических (so/dll) библиотек.

                                                                    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                                                                    Самое читаемое