7 Отцы и дети (о правильном наследовании)

Главная Форумы Программирование Программирование на С++ Заметки о С++ 7 Отцы и дети (о правильном наследовании)

В этой теме 0 ответов, 1 участник, последнее обновление  Васильев Владимир Сергеевич 7 мес., 2 нед. назад.

  • Автор
    Сообщения
  • #3580

    Хорошие цитаты о наследовании:
    — Предпочитайте композицию наследованию
    «Стандарты программирования на C++», Герб Саттер, Андрей Александреску.

    — Даже в нынешнюю эпоху шаблонов во многих проектах все еще делается неоптимальный выбор при использовании полиморфизма
    «Шаблоны C++», Дэвид Вандевурд, Николаи М. Джосаттис.

    Где истоки этой болезни — я не знаю. Может быть дело в том, как преподаватели в учебных заведениях преподают программирование, может быть — что-то еще. Однако факт остается фактом — 98% программистов в 98% случаев используют наследование и динамический полиморфизм типов там, где этого делать не стоит.

    От простого — к сложному

    Возьмем базовый класс «Кнопка» и отнаследуем от него два класса — «Зеленая кнопка» и «Красная кнопка»… Согласитесь — уже отдает какой-то бредятиной. Любой здравомыслящий человек напишет единственный класс «кнопка», мембером которого будет «цвет».

    Пойдем дальше. Рассмотрим общее понятие «Фигура» и его более конкретные сущности — «Круг», «Квадрат» и «Треугольник». Выразить эти понятия на языке C++ можно довольно большим числом способов. Рассмотрим самые распространенные. Как это бывает обычно:

    class shape
    {
    public:
        virtual void draw() = 0;
    };
    
    class cicrle : public shape
    {
    public:
        virtual void draw();
    };
    
    class square : public shape
    {
    public:
        virtual void draw();
    };
    
    class triangle : public shape
    {
    public:
        virtual void draw();
    };

    Да, да, да. Базовый класс «Фигура» и его дочерние классы «Круг», «Квадрат», «Треугольник», и десяток виртуальных функций для манипулирования дочерними объектами. К сожалению, сейчас так учат и всегда так учили во всех отечественных учебных заведениях, от сельского ПТУ, до самого блатного столичного университета.

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

    class shape
    {
    public:
        enum concrete
        {
            circle,
            square,
            triangle
        };
    
        void draw()
        {
            switch(type)
            {
            case circle:
                // ...
                break;
            case square:
                // ...
                break;
            case triangle:
                // ...
                break;
            };
        }
    private:
        concrete type;
    };

    На всех собеседованиях, на которых меня спрашивали, как бы я реализовал управление схожими объектами через единый интерфейс (пытаясь тем самым проверить, знаю ли я что такое виртуальные функции), я именно так и отвечал — «Вы наверно подразумеваете использование виртуальных функций? Наследование в даннм случае является плохим решением». И что же вы думаете? На такой ответ я всегда получал двадцатисекундную озадаченность, после которой обязательно следовал вопрос: «А, тоесть вы предлагаете ввести enum для хранения типа и внутри мембер-функций делать по нему switch?».

    Ну и наконец посмотрим на решение, свойственное качественному промышленному продукту:

    typedef float real;
    
    struct point
    {
        real x;
        real y;
    };
    
    class shape
    {
    public:
        // ...
    private:
        typedef std::vector<point> container;
    
        container outline;
    };
    
    typedef boost::shared_ptr<shape> shape_ptr;
    
    int main()
    {
        shape_ptr circle = load_shape("circle.xml");
        shape_ptr square = load_shape("square.xml");
        shape_ptr triangle = load_shape("triangle.xml");
    
        return 0;
    }

    Одни скажут — переусложнено. Другие — непонятно. Третьи — слишком запутанно. Однако, факт останется фактом — подобный подход наиболее жизнеспособен в крупных коммерческих продуктах. К сожалению, большинство программистов не может понять одной простой вещи — первый пример служит лишь иллюстрацией, наглядным пособием на тему «как работает динамический полиморфизм», и не имеет ничего общего с реальной жизнью. Неправильное, необоснованное или необдуманное использование наследования и динамического полиморфизма будут ежедневно приносить все новые и новые проблемы в разрабатываемый продукт. Я попытаюсь классифицировать эти проблемы и, по возможности, дать комментарий.

    Излишняя свобода

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

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

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

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

    Требования к типу

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

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

    Подобные требования к типам создают определенные проблемы. Во-первых, они вносят определенные ограничения в реализацию. Во-вторых, они завязывают тип на способе взаимодействия с механизмом. Изменился интерфейс базового класса — придется ползать по всем исходникам и рефакторить все реализации. В третьих, данным требованиям скорее всего не будут соответствовать типы данных, взятые из сторонних библиотек (в том числе STL и boost) — для них придется писать искусственные «соединители».

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

    Чем больше механизм предъявляет требований к типу, тем этот механизм хуже. В идеале, к которому всегда стоит стремиться, механизм вообще не должен предъявлять к типам никаких требований.

    Сильные связи

    Эти проблемы относятся к системам, основанным на полностью или частично открытом многоэтажном наследовании (те же .NET, JRE или выкидыш-MFC). В таких системах любой класс имеет доступ ко всем членам всех своих предков, не объявленных как private. Потомок может вызвать функцию своего предка, находящегося на пять этажей выше в иерархии классов. Такая возможность вносит сквозную зависимость между типами по всему дереву иерархии. Следствием этого является монолитность и неповоротливость системы в целом. Любой рефакторинг такой системы превратится в каторгу.

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

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

    Место под солнцем

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

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

    Во-вторых, наследование и динамический полиморфизм являются наиболее естественным инструментом, позволяющим объединять функциональности сторонних независимых систем в единое целое. Например — имея на руках WinSocks и OpenSSL вполне логично ввести базовый класс «socket» и его реализации «tcp_socket» и «ssl_socket». В данном случае любая другая интеграционная альтернатива будет не столь лаконичной и слишком громоздкой.

    В-третьих, наследование может решать ряд задач внутри реализации каких-то инструментов. Однако, при этом следует обезопасить пользователей этих инструментов от неприятностей, связанных с использованием наследования. Например, совершенно очевидно, что инструмент boost::function невозможно реализовать без динамического полиморфизма. Однако, пользователи этого инструмента об этом даже не догадываются. Все что требуется от пользователя это подставить свой тип в качестве шаблонного параметра. И все. Вся динамика скрыта подальше от глаз программиста. Его не заставляют от чего-то там отнаследоваться и переопределить десять виртуальных функций.

    Бывают ситуации, когда сторонняя библиотека вынуждает вас пользоваться наследованием. Например, когда dll экспортирует абстрактный класс. В этом случае не следует позволять пользователю напрямую работать с этим интерфейсом. Локализуйте его и спрячьте куда-нибудь подальше в каком-нибудь адаптере. Если пользователь завяжется напрямую на такой интерфейс, то он рискует получить множество проблем, если этот интерфейс изменится в следующей версии библиотеки. А виноваты будете вы.

    Резюме

    Не выпускайте динамику типов и, по возможности, само наследование, наружу там, где оно дейcтвительно не нужно пользователю. Не завязывайте свою архитектуру на парадигме наследования — это устаревшая и давно изжившая себя модель. Не заставляйте (или хотя бы попытайтесь не заставлять) пользователя от чего-то наследоваться и переопределять виртуальные функции. Вообще, постарайтесь не предъявлять пользователю каких-то особых требований для того, чтобы он мог работать с вашей подсистемой. Учитесь правильно выполнять декомпозицию. Классифицируйте функциональность и сосредотачивайте ее в компонентах. Позвольте вашим пользователям использовать только ту функциональность, которая им действительно нужна. Для расширения функциональности вашей системы следите за тем, чтобы пользователю приходилось выполнять только ту работу, которую действительно необходимо выполнить. Не заставляйте пользователя выполнять ненужную работу.

    Как говорил мой дипломный руководитель — «Любой процесс проектирования начинается с классификации и декомпозиции». Уделяйте этим двум замечательным вещам особое внимание.

Для ответа в этой теме необходимо авторизоваться.