Почему не следует выводить из класса C++ std string?


Я хотел спросить о конкретном моменте, сделанном в эффективном C++.

Он говорит:

деструктор должен быть виртуальным, если класс должен действовать как полиморфный класс. Он далее добавляет, что с std::string не имеет виртуального деструктора, никогда не следует выводить из него. Также std::string даже не предназначен для базового класса, забудьте полиморфный базовый класс.

Я не понимаю, что конкретно требуется В класса иметь право быть базовым классом (не полиморфным)?

это единственная причина, по которой я не должен выводить из std::string класс не имеет виртуального деструктора? Для целей повторного использования можно определить базовый класс и наследовать от него несколько производных классов. Так что же делает std::string даже не подходит в качестве базового класса?

кроме того, если существует базовый класс, чисто определенный для целей повторного использования, и есть много производных типов, есть ли способ предотвратить клиент от этого Base* p = new Derived() потому что классы не предназначены для использования полиморфно?

8 58

8 ответов:

Я думаю, что это утверждение отражает путаницу здесь (акцент мой):

Я не понимаю, что конкретно требуется в классе, чтобы иметь право на базовый клас (не полиморфный)?

в идиоматическом C++ существует два способа получения из класса:

  • частная наследование, используется для миксинов и аспектно-ориентированного программирования с использованием шаблоны.
  • общественные наследование, используемый для только полиморфные ситуации. EDIT: хорошо, я думаю, это может быть использовано в нескольких сценариях mixin тоже - например,boost::iterator_facade -- которые появляются, когда CRTP находится в использовании.

нет абсолютно никакой причины публично выводить класс В C++ , если вы не пытаетесь сделать что-то полиморфное. Язык поставляется со свободными функциями в качестве стандартной функции язык и свободные функции-это то, что вы должны использовать здесь.

подумайте об этом таким образом -- вы действительно хотите заставить клиентов вашего кода конвертировать в использование некоторого проприетарного класса string просто потому, что вы хотите использовать несколько методов? Потому что в отличие от Java или C# (или большинства подобных объектно-ориентированных языков), когда вы производите класс В C++, большинство пользователей базового класса должны знать об этом изменении. В Java/C# классы обычно доступны через ссылки, которые похожи на указатели C++. Поэтому существует уровень косвенности, который отделяет клиентов вашего класса, позволяя вам заменять производный класс без ведома других клиентов.

однако в C++ классы типы значений -- в отличие от большинства других языков ОО. Самый простой способ увидеть это то, что известно как нарезки проблема. В принципе, рассмотрим:

int StringToNumber(std::string copyMeByValue)
{
    std::istringstream converter(copyMeByValue);
    int result;
    if (converter >> result)
    {
        return result;
    }
    throw std::logic_error("That is not a number.");
}

если вы передадите свою собственную строку в этот метод, то конструктор копирования для std::string будет вызван, чтобы сделать копию,не конструктор копирования для производного объекта -- независимо от того, какой дочерний класс - это. Это может привести к несогласованности между вашими методами и всем, что связано со строкой. Функция StringToNumber нельзя просто взять любой производный объект и скопировать его, просто потому, что ваш производный объект, вероятно, имеет другой размер, чем std::string -- но эта функция была скомпилирована для резервирования только пространства для std::string в автоматическом хранения. В Java и C# это не проблема, потому что единственное, что связано с автоматическим хранением, - это ссылочные типы, и ссылки всегда имеют одинаковый размер. Не так в C++.

короче говоря -- не используйте наследование для привязки методов в C++. Это не идиоматично и приводит к проблемам с языком. Используйте функции, не являющиеся друзьями, не являющиеся членами, где это возможно, а затем композицию. Не используйте наследование, если вы не шаблон метапрограммирования или хотят полиморфного поведения. Для получения дополнительной информации см. Scott Meyers'Эффективный C++ пункт 23: предпочитайте функции-члены, не являющиеся членами, функциям-членам.

EDIT: вот более полный пример, показывающий проблему нарезки. Вы можете видеть, что это выход на codepad.org

#include <ostream>
#include <iomanip>

struct Base
{
    int aMemberForASize;
    Base() { std::cout << "Constructing a base." << std::endl; }
    Base(const Base&) { std::cout << "Copying a base." << std::endl; }
    ~Base() { std::cout << "Destroying a base." << std::endl; }
};

struct Derived : public Base
{
    int aMemberThatMakesMeBiggerThanBase;
    Derived() { std::cout << "Constructing a derived." << std::endl; }
    Derived(const Derived&) : Base() { std::cout << "Copying a derived." << std::endl; }
    ~Derived() { std::cout << "Destroying a derived." << std::endl; }
};

int SomeThirdPartyMethod(Base /* SomeBase */)
{
    return 42;
}

int main()
{
    Derived derivedObject;
    {
        //Scope to show the copy behavior of copying a derived.
        Derived aCopy(derivedObject);
    }
    SomeThirdPartyMethod(derivedObject);
}

предложить встречную сторону к общему совету (который Ядров когда никакие определенные вопросы многословности/урожайности очевидные)...

сценарий для разумного использования

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

  • вы хотите получить некоторые преимущества безопасности типов и читаемости кода, предоставляемые выделенными пользовательскими типами (классами)
  • an существующая база идеально подходит для хранения данных и позволяет выполнять низкоуровневые операции, которые клиентский код также хотел бы использовать
  • вы хотите удобство повторного использования функций, поддерживающих этот базовый класс
  • вы понимаете, что любые дополнительные инварианты, которые логически необходимы вашим данным, могут быть применены только в коде, явно обращающемся к данным как к производному типу, и в зависимости от того, насколько это "естественно" произойдет в вашем дизайне, и насколько вы можете доверять клиенту чтобы понять и сотрудничать с логически идеальными инвариантами, вы можете захотеть, чтобы функции-члены производного класса превозносили ожидания (и бросали или что-то еще)
  • производный класс добавляет некоторые высокоспецифичные функции удобства, работающие над данными, такие как пользовательский поиск, фильтрация / модификация данных, потоковая передача, статистический анализ, (альтернативные) итераторы
  • соединение кода клиента к основанию более соотвествующее чем соединение к производный класс (поскольку база либо стабильна, либо изменения в ней отражают улучшения функциональности, а также ядро производного класса)
    • иными словами: вы хотите, чтобы производный класс продолжал предоставлять тот же API, что и базовый класс, даже если это означает, что клиентский код вынужден меняться, а не изолировать его каким-то образом, что позволяет базовым и производным API расти из синхронизации
  • вы не собираетесь смешивать указатели на базу и производные объекты в частях кода, ответственных за их удаление

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

Предыстория обсуждения: относительные достоинства

программирование - это компромиссы. Прежде чем писать более концептуально "правильную" программу:

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

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

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

скажем, у вас есть класс D, публично производный от B. без каких-либо усилий операции над B возможны на D (за исключением конструкции, но даже если есть много конструкторов, вы часто можете обеспечить эффективную пересылку, имея один шаблон для каждого отдельного числа аргументов конструктора: например template <typename T1, typename T2> D(const T1& x1, const T2& t2) : B(t1, t2) { }. Лучше обобщенные решения в C++0х шаблоны с переменным числом аргументов.)

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

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

это часто не то, что вы хотите, но иногда это идеально, а в других случаях не проблема (см. следующий абзац). Изменения в базе заставляют больше клиентских изменений кода в местах, распределенных по всей базе кода, и иногда люди, меняющие базу, могут даже не иметь доступа к клиенту код для просмотра или обновления его соответственно. Иногда это лучше, хотя: если вы, как поставщик производного класса - "человек в середине" - хотите, чтобы изменения базового класса передавались клиентам, и вы обычно хотите, чтобы клиенты могли - иногда принудительно-обновлять свой код, когда базовый класс изменяется без необходимости постоянного участия, то публичное выведение может быть идеальным. Это распространено, когда ваш класс не столько самостоятельная сущность в своем собственном праве, но тонкая добавленная стоимость на базу.

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

в целом, public derivation-это быстрый способ получить или приблизить идеальный, знакомый интерфейс базового класса для производного класса-таким образом, чтобы он был кратким и самоочевидно правильным как для сопровождающего, так и для клиентского кодера - с дополнительной функциональностью, доступной в качестве функций-членов (которые IMHO-который, очевидно, отличается от Sutter, Alexandrescu и т. д.-может помочь в использовании, удобочитаемости и помочь повысить производительность инструментов, включая IDE)

стандарты кодирования C++ - Sutter & Alexandrescu-минусы рассмотрены

пункт 35 Стандарты Кодирования C++ перечисляет проблемы со сценарием получения от std::string. По мере развития сценариев хорошо, что он иллюстрирует бремя предоставления большого, но полезного API, но как хорошего, так и плохого, как базовый API удивительно стабильна-будучи частью стандартной библиотеки. Стабильная база-это обычная ситуация, но не более распространенная, чем нестабильная, и хороший анализ должен относиться к обоим случаям. Рассматривая список вопросов книги, я специально сопоставлю применимость вопросов к случаям say:

а) class Issue_Id : public std::string { ...handy stuff... }; б) class Issue_Id : public string_with_virtual_destructor { ...handy stuff... }; в)class Issue_Id { public: ...handy stuff... private: std::string id_; }; d) использование std::string везде, с автономными функциями поддержки

(надеюсь, мы можем согласиться с тем, что состав является приемлемой практикой, поскольку он обеспечивает инкапсуляцию, безопасность типа, а также потенциально обогащенный API поверх и выше std::string.)

Итак, скажем, вы пишете какой-то новый код и начинаете думать о концептуальных сущностях в смысле OO. Может быть, в системе отслеживания ошибок (я думаю о JIRA), один из них, скажем, Issue_Id. Содержание данных является текстовым - состоит из алфавитного идентификатора проекта, дефиса и увеличивающегося номера выпуска: например, "MYAPP-1234". Идентификаторы проблемы могут храниться в std::string, и там будет много суетливо мало текстовых поисков и манипуляций операций, необходимых для выдачи идентификаторов-большое подмножество тех, которые уже предоставлены на std::string и еще несколько для хорошей меры (например, получение компонента идентификатора проекта, предоставление следующего возможного идентификатора проблемы (MYAPP-1235)).

на список Саттера и Александреску проблемы...

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

используя открытый вывод, существующий код может неявно обращаться к базовому классу string как string, и продолжайте вести себя как всегда. Нет особых причин думать, что существующий код может использовать любой дополнительная функциональность от super_string (в нашем случае Issue_Id)... на самом деле это часто код поддержки более низкого уровня, уже существующий в приложении, для которого вы создаете super_string, и, следовательно, не обращая внимания на потребности, предусмотренные расширенными функциями. Например, скажем, что есть функция, не являющаяся членом to_upper(std::string&, std::string::size_type from, std::string::size_type to) - это все еще может быть применен к Issue_Id.

так, если функция поддержки non-члена не очищается вверх или не удлиняется на нарочитой цене плотно соединять это к новому коду, тогда его трогать не нужно. Если это и будучи переработан для поддержки идентификаторов проблем (например, используя понимание формата содержимого данных в верхнем регистре только ведущие Альфа-символы), то это, вероятно, хорошо, чтобы убедиться, что он действительно передается Issue_Id путем создания перегрузки ala to_upper(Issue_Id&) и придерживаться либо деривационных, либо композиционных подходов, позволяющих обеспечить безопасность типа. Будь super_string или состав используется не имеет никакого значения для усилие или ремонтопригодность. А to_upper_leading_alpha_only(std::string&) многоразовая отдельно стоящая функция поддержки вряд ли будет иметь много пользы - я не могу вспомнить, когда в последний раз я хотел такую функцию.

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

интерфейсные функции, которые принимают строку теперь нужно: a) держаться подальше от super_stringдобавлена функциональность (бесполезно); b) скопируйте их аргумент в super_string (расточительно); или c) приведите ссылку на строку к ссылке super_string (неудобно и потенциально незаконно).

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

функции-члены super_string не делают иметь больше доступа к внутренним элементам string, чем функции, не являющиеся членами, потому что string, вероятно, не имеет защищенных членов (помните, что он не должен был быть получен из в первую очередь)

верно, но иногда это хорошо. Многие базовые классы не имеют защищенных данных. Публика string интерфейс-это все, что нужно для управления содержимым и полезной функциональностью (например,get_project_id() постулировано выше) можно элегантно выразить в терминах тех оперативный. Концептуально, много раз я получал от стандартных контейнеров, я не хотел расширять или настраивать их функциональность по существующим линиям - они уже "идеальные" контейнеры - скорее я хотел добавить другое измерение поведения, которое специфично для моего приложения и не требует частного доступа. Это потому, что они уже хорошие контейнеры, которые они хороши для повторного использования.

если super_string скрывает часть stringфункции (и переопределение a невиртуальная функция в производном классе не переопределяет, она просто скрывается), что может вызвать широко распространенную путаницу в коде, который манипулирует strings, которые начали свою жизнь автоматически преобразованы из super_string s.

True для композиции тоже - и, скорее всего, произойдет, поскольку код по умолчанию не пропускает вещи и, следовательно, остается в синхронизации, а также true в некоторых ситуациях с полиморфными иерархиями времени выполнения. Samed именованные функции, которые ведут себя по-разному в классах, которые изначально кажутся взаимозаменяемыми-просто противно. Это фактически обычная осторожность для правильного программирования OO, и опять же не является достаточной причиной для отказа от преимуществ в безопасности типа и т. д..

, что если super_string хочет наследовать от string добавить больше state [объяснение нарезка]

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

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

ну, конечно соглашусь насчет скуки....

рекомендации для успешного вывода без виртуального деструктора

  • в идеале избегайте добавления элементов данных в производный класс: варианты нарезки могут случайно удалить элементы данных, повредить их, не инициализировать их...
  • тем более-избегайте не-POD членов данных: удаление через указатель базового класса в любом случае является технически неопределенным поведением, но с типами без POD, которые не запускают свои деструкторы, скорее всего, будут иметь нетеоретические проблемы с утечками ресурсов, плохими ссылками и т. д.
  • соблюдайте принцип подстановки Лискова / вы не можете надежно поддерживать новые инварианты
    • например, в Определении от std::string вы не можете перехватить несколько функций и ожидать, что ваши объекты останутся прописными: любой код, который обращается к ним через a std::string& или ...* можно использовать std::stringоригинальные реализации функций для изменения значения)
    • derive для моделирования объекта более высокого уровня в вашем приложении, чтобы расширить унаследованную функциональность с некоторой функциональностью, которая использует, но не конфликтует с базой; не ожидайте и не пытайтесь изменить основные операции - и доступ к этим операциям - предоставленный базовым типом
  • имейте в виду соединение: базовый класс не может быть удален без влияние на клиентский код, даже если базовый класс развивается, чтобы иметь несоответствующую функциональность, т. е. удобство использования вашего производного класса зависит от текущей уместности базы
    • иногда, даже если вы используете композицию, вам нужно будет предоставить элемент данных из - за производительности, проблем с потоковой безопасностью или отсутствия семантики значений-поэтому потеря инкапсуляции из публичного вывода не заметно хуже
  • более вероятные люди, использующие потенциально-производный класс будет не знать о своих компромиссах реализации, тем меньше вы можете позволить себе сделать их опасными
    • поэтому низкоуровневые широко развернутые библиотеки со многими специальными случайными пользователями должны быть более осторожными в опасном выводе, чем локализованное использование программистами, обычно использующими функциональность на уровне приложения и / или в" частной " реализации / библиотеках

резюме

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

личный опыт

я иногда получаю от std::map<>,std::vector<>,std::string etc-я никогда не был сожжен с помощью нарезки или удаления-через-базовый-класс-указатель проблем, и я сэкономил много времени и энергии для более важных вещи. Я не храню такие объекты в гетерогенных полиморфных контейнерах. Но вам нужно рассмотреть, знают ли все программисты, использующие объект, о проблемах и, вероятно, будут программировать соответственно. Мне лично нравится писать свой код для использования кучи и полиморфизма времени выполнения только тогда, когда это необходимо, в то время как некоторые люди (из-за фона Java, их предпочтительный подход к управлению зависимостями перекомпиляции или переключению между поведением во время выполнения, средствами тестирования и т. д.) используйте их привычно и поэтому нужно больше беспокоиться о безопасных операциях с помощью указателей базового класса.

деструктор не только не является виртуальным, std:: string не содержит виртуальных функций на всех, и нет защищенных членов. Что делает его очень трудно для производного класса, чтобы изменить его функциональность.

тогда почему вы выводите из него?

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

если вы действительно хотите извлечь из него (не обсуждая, почему вы хотите это сделать) я думаю, что вы можете предотвратить Derived класс прямой экземпляр кучи, сделав это operator new private:

class StringDerived : public std::string {
//...
private:
  static void* operator new(size_t size);
  static void operator delete(void *ptr);
}; 

но таким образом вы ограничиваете себя от любой динамической StringDerived объекты.

почему не следует выводить из класса строк C++ std?

потому что это не надо. Если вы хотите использовать DerivedString для расширения функциональности; я не вижу никаких проблем в получении std::string. Единственное, вы не должны взаимодействовать между обоими классами (т. е. не использовать string как приемник для DerivedString).

есть ли способ, чтобы предотвратить клиентов от этого Base* p = new Derived()

да. Убедитесь, что вы предоставляете inline фантики вокруг Base методы внутри Derived класса. например,

class Derived : protected Base { // 'protected' to avoid Base* p = new Derived
  const char* c_str () const { return Base::c_str(); }
//...
};

есть две простые причины, не вытекающие из не-полиморфный класс:

  • технические: он вводит ошибки нарезки (потому что в C++ мы передаем значение, если не указано иное)
  • функциональное: если он не является полиморфным, вы можете достичь того же эффекта с композицией и некоторой функцией переадресации

Если вы хотите добавить новые функции в std::string, то сначала рассмотрим использование свободных функций (возможно, шаблоны), как Повысить Строковый Алгоритм библиотека.

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

EDIT:

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

кроме того, композиция дает вам возможность красиво упаковать метод класса оригинала. Это невозможно, если вы выбираете наследование (public) и методы не являются виртуальными (что имеет место здесь).

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

C++ стандартный раздел 5.3.5/3:

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

чтобы было понятно не-полиморфный класс & нужен виртуальный деструктор
Цель создания виртуального деструктора состоит в том, чтобы облегчить полиморфное удаление объектов через delete-expression. Если нет полиморфного удаления объектов, то вам не нужен виртуальный деструктор.

Почему бы не наследовать от класса String?
Как правило, следует избегать вывода из любого стандартного контейнерного класса по той самой причине, что у них нет виртуальных деструкторов, которые делают невозможным удаление объектов полиморфно.
Что касается класса string, класс string не имеет никаких виртуальных функций, поэтому нет ничего, что вы можете переопределить. Лучшее, что вы можете сделать, это скрыть что-то.

Если вы вообще хотите иметь строку, подобную функциональности, вы должны написать свой собственный класс, а не наследовать от std::string.

Как только вы добавите какой-либо член (переменную) в свой производный класс std::string, вы будете систематически завинчивать стек, если вы попытаетесь использовать std-лакомства с экземпляром вашего производного класса std::string? Поскольку функции/члены stdc++ имеют свои указатели стека[индексы] фиксированные [и настроенные] на размер/границу размера экземпляра (base std::string).

верно?

пожалуйста, поправьте меня, если я ошибаюсь.