Чи нормально успадковувати реалізацію з контейнерів STL, а не делегувати?


79

У мене є клас, який адаптує std :: vector для моделювання контейнера доменних об'єктів. Я хочу відкрити для користувача більшість API std :: vector, щоб вони могли використовувати звичні методи (розмір, очищення, at тощо ...) та стандартні алгоритми на контейнері. Здається, це повторюваний шаблон для мене в моїх проектах:

class MyContainer : public std::vector<MyObject>
{
public:
   // Redeclare all container traits: value_type, iterator, etc...

   // Domain-specific constructors
   // (more useful to the user than std::vector ones...)

   // Add a few domain-specific helper methods...

   // Perhaps modify or hide a few methods (domain-related)
};

Мені відомо про практику віддання переваги композиції, а не успадкуванню при повторному використанні класу для реалізації - але тут має бути обмеження! Якби я делегував усе на std :: vector, було б (за моїми підрахунками) 32 функції пересилання!

Тож мої запитання: Чи справді так погано успадковувати реалізацію у таких випадках? Які ризики? Чи є більш безпечний спосіб я можу реалізувати це, не надто багато друкуючи? Я єретик для використання успадкування реалізації? :)

Редагувати:

А як щодо того, щоб дати зрозуміти, що користувач не повинен використовувати MyContainer через покажчик std :: vector <>:

// non_api_header_file.h
namespace detail
{
   typedef std::vector<MyObject> MyObjectBase;
}

// api_header_file.h
class MyContainer : public detail::MyObjectBase
{
   // ...
};

Бібліотеки, що працюють над підвищенням, здається, постійно роблять це.

Редагувати 2:

Однією з пропозицій було використання безкоштовних функцій. Я покажу його тут як псевдокод:

typedef std::vector<MyObject> MyCollection;
void specialCollectionInitializer(MyCollection& c, arguments...);
result specialCollectionFunction(const MyCollection& c);
etc...

Більш зручний спосіб зробити це:

typedef std::vector<MyObject> MyCollection;
class MyCollectionWrapper
{
public:
   // Constructor
   MyCollectionWrapper(arguments...) {construct coll_}

   // Access collection directly
   MyCollection& collection() {return coll_;} 
   const MyCollection& collection() const {return coll_;}

   // Special domain-related methods
   result mySpecialMethod(arguments...);

private:
   MyCollection coll_;
   // Other domain-specific member variables used
   // in conjunction with the collection.
}

6
Ой милий! Ще один шанс підштовхнути мій блог на punchlet.wordpress.com - в основному, писати безкоштовні функції та забути про підхід до обгортки "більше ОО". Це не більше ОО - якби він використовував би спадщину, чого ви, мабуть, не мали б у цьому випадку. Запам’ятайте OO! = Клас.

1
@ Ніл: Але, але .. глобальні функції - це зло !!! Все - предмет! ;)
Еміль Корм'є

4
Вони не будуть глобальними, якщо ви помістите їх у простір імен.

1
Якщо ви дійсно хочете виставити весь інтерфейс вектора, то, мабуть, краще в C ++ використовувати композицію та виставити посилання на вектор за допомогою геттера (з версіями const та non-const). У Java ви просто успадковуєте, але тоді в Java деякі numpty не з'являться, ігнорують вашу документацію, видаляють ваш об'єкт через неправильний вказівник (або знову успадковують і псують його), а потім скаржаться. Можливо, для обмеженої аудиторії, але якщо користувачі можуть бути виродками з динамічним поліморфізмом або нещодавно колишніми програмістами Java, ви розробляєте інтерфейс, в якому ви можете бути впевнені, що вони неправильно зрозуміють.
Steve Jessop

1
Ви не можете захиститися від людей, повністю ігноруючих документацію. Я не був би здивований, коли дізнався, що таке зловживання спричиняє стільки ж проблем у Java, що і в C ++.

Відповіді:


75

Ризик полягає у звільненні звільнення через покажчик на базовий клас ( видалення , видалення [] та потенційно інші методи звільнення). Оскільки ці класи ( deque , map , string тощо) не мають віртуальних dtors, неможливо очистити їх належним чином лише за допомогою вказівника на ці класи:

struct BadExample : vector<int> {};
int main() {
  vector<int>* p = new BadExample();
  delete p; // this is Undefined Behavior
  return 0;
}

Тим не менш, якщо ви готові переконатись, що ніколи випадково цього не зробите, у їх успадкування немає великих недоліків, але в деяких випадках це дуже важливо. Інші недоліки включають зіткнення зі специфікою реалізації та розширеннями (деякі з яких можуть не використовувати зарезервовані ідентифікатори) та справу з роздутими інтерфейсами ( зокрема, рядком ). Однак успадкування призначене в деяких випадках, оскільки контейнерні адаптери, такі як стек, мають захищений член c (основний контейнер, який вони адаптують), і він майже доступний лише з похідного екземпляра класу.

Замість того, щоб успадковувати або складати , розгляньте можливість написання безкоштовних функцій, які приймають або пару ітераторів, або посилання на контейнер, і працюють над цим. Практично весь <algorithm> є прикладом цього; і make_heap , pop_heap та push_heap , зокрема, є прикладом використання безкоштовних функцій замість контейнера для конкретного домену.

Отже, використовуйте класи контейнерів для своїх типів даних, і все одно викликайте безкоштовні функції для логіки вашого домену. Але ви все одно можете досягти певної модульності, використовуючи typedef, який дозволяє спростити їх оголошення та надає єдину точку, якщо частина з них повинна змінитися:

typedef std::deque<int, MyAllocator> Example;
// ...
Example c (42);
example_algorithm(c);
example_algorithm2(c.begin() + 5, c.end() - 5);
Example::iterator i; // nested types are especially easier

Зверніть увагу, що значення_типу та розподільник можуть змінюватися, не впливаючи на пізніше код, використовуючи typedef, і навіть контейнер може перейти з деке в вектор .


35

Ви можете поєднати приватне успадкування та ключове слово 'using', щоб обійти більшість згаданих вище проблем: Приватне успадкування 'є-реалізоване-в-умовах-о', і оскільки воно є приватним, ви не можете тримати вказівник на базовий клас

#include <string>
#include <iostream>

class MyString : private std::string
{
public:
    MyString(std::string s) : std::string(s) {}
    using std::string::size;
    std::string fooMe(){ return std::string("Foo: ") + *this; }
};

int main()
{
    MyString s("Hi");
    std::cout << "MyString.size(): " << s.size() << std::endl;
    std::cout << "MyString.fooMe(): " << s.fooMe() << std::endl;
}

2
Я не можу не згадати, що privateспадкування все ще є спадщиною і, отже, міцнішими відносинами, ніж склад. Зокрема, це означає, що зміна реалізації вашого класу обов’язково призведе до порушення двійкової сумісності.
Matthieu M.

8
Приватні члени успадкування та приватні дані порушують двійкову сумісність, коли вони змінюються, і, за винятком друзів (яких повинно бути небагато), як правило, не важко переключатися між ними --- що використовується, часто диктується деталями реалізації. Також дивіться "ідіому від члена".

Для допитливих - Ідіома від учасника: en.wikibooks.org/wiki/More_C%2B%2B_Idioms/Base-from-Member
Еміль Корм'є

1
@MatthieuM. Порушення ABI взагалі не є проблемою для більшості програм. Навіть деякі бібліотеки живуть без Pimpl для кращої роботи.
doc

15

Як уже всі заявляли, контейнери STL не мають віртуальних деструкторів, тому успадкування від них у кращому випадку небезпечно. Я завжди розглядав загальне програмування з шаблонами як інший стиль ОО - такий без успадкування. Алгоритми визначають потрібний їм інтерфейс. Це наближається до Duck Typing, наскільки ви можете статичною мовою.

У будь-якому разі, мені є що додати до обговорення. Спосіб, яким я раніше створив власні спеціалізації на шаблонах, полягає у визначенні класів, як показано нижче, для використання в якості базових класів.

template <typename Container>
class readonly_container_facade {
public:
    typedef typename Container::size_type size_type;
    typedef typename Container::const_iterator const_iterator;

    virtual ~readonly_container_facade() {}
    inline bool empty() const { return container.empty(); }
    inline const_iterator begin() const { return container.begin(); }
    inline const_iterator end() const { return container.end(); }
    inline size_type size() const { return container.size(); }
protected: // hide to force inherited usage only
    readonly_container_facade() {}
protected: // hide assignment by default
    readonly_container_facade(readonly_container_facade const& other):
        : container(other.container) {}
    readonly_container_facade& operator=(readonly_container_facade& other) {
        container = other.container;
        return *this;
    }
protected:
    Container container;
};

template <typename Container>
class writable_container_facade: public readable_container_facade<Container> {
public:
    typedef typename Container::iterator iterator;
    writable_container_facade(writable_container_facade& other)
        readonly_container_facade(other) {}
    virtual ~writable_container_facade() {}
    inline iterator begin() { return container.begin(); }
    inline iterator end() { return container.end(); }
    writable_container_facade& operator=(writable_container_facade& other) {
        readable_container_facade<Container>::operator=(other);
        return *this;
    }
};

Ці класи мають той самий інтерфейс, що і контейнер STL. Мені сподобався ефект розділення модифікуючих та немодифікуючих операцій на різні базові класи. Це по-справжньому добре впливає на правильність const. Єдиним недоліком є ​​те, що вам потрібно розширити інтерфейс, якщо ви хочете використовувати їх з асоціативними контейнерами. Але я не стикався з потребою.


Приємно! Я міг би просто використати це. Але інші переосмислили ідею адаптації контейнерів, тож, можливо, я не буду цим користуватися. :)
Еміль Корм'є

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

5

У цьому випадку успадкування є поганою ідеєю: контейнери STL не мають віртуальних деструкторів, тому ви можете зіткнутися з витоками пам’яті (крім того, це свідчить про те, що контейнери STL не призначені для успадкування в першу чергу).

Якщо вам просто потрібно додати якусь функціональність, ви можете оголосити це за допомогою глобальних методів або полегшеного класу за допомогою покажчика / посилання на елемент контейнера. Це, звичайно, не дозволяє приховувати методи: якщо це насправді те, чим ви займаєтесь, тоді немає іншого варіанту, а потім перевизначити всю реалізацію.


Ви все ще можете приховувати методи, не оголошуючи їх у заголовку, а натомість лише у реалізації, роблячи їх непублічними статичними методами в фіктивному класі (з якого ви можете подружитися, і це працює для шаблонів, які повинні бути лише заголовком ), або розмістивши їх у "подробицях" або просто так названому просторі імен. (Усі троє працюють так само, як і звичайні приватні методи.)

Я не розумію, як, на вашу думку, можна приховати метод "вектора", не оголошуючи його у своєму заголовку. Це вже оголошено у векторі.
Jherico

Йеріко: Ти розмовляєш зі мною чи зі штейном? У будь-якому випадку, я думаю, ви неправильно зрозуміли когось із нас.

@roger Я відстежую Джеріко і не думаю, що я тебе розумію: ти говориш про приховування методів від std :: vector чи від чогось іншого? Також як розміщення методу в іншому просторі імен робить його прихованим? Поки заявлено в заголовку, до якого хтось має доступ, це насправді не приховано так, як приховується приватне ключове слово?
stijn

stijn: Це те, на що я вказував щодо приватного доступу, він насправді теж не прихований, оскільки кожен, хто має доступ до заголовка, може прочитати джерело або використовувати його -Dprivate=publicв командному рядку компілятора. Специфікатори доступу, такі як приватні, в основному є документацією, яка просто виконується.

4

Якщо не брати до уваги віртуальних dtors, рішення про успадкування проти вмісту повинно бути дизайнерським рішенням, заснованим на класі, який ви створюєте. Ви ніколи не повинні успадковувати функціональність контейнера лише тому, що це простіше, ніж містити контейнер і додавати кілька функцій додавання та видалення, які здаються спрощеними обгортками, якщо ви не можете остаточно сказати, що клас, який ви створюєте, є свого роду контейнером. Наприклад, клас у класі часто міститиме об’єкти студентів, але клас для більшості не є своєрідним списком учнів, тому вам не слід успадковувати його зі списку.



1

У будь-якому випадку способи пересилання будуть вбудовані. Таким чином, ви не отримаєте кращих показників. Насправді, швидше за все, ви отримаєте гірші показники.

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.