ООП. Наследование

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

Просмотр 1 сообщения - с 1 по 1 (всего 1)
  • Автор
    Сообщения
  • #4025

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

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

    Механизм наследования. Сильные стороны

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

    Например, у нас есть класс Часы (Clock). Он обладает возможностью установки и получения текущего времени. Установка значения времени перегружена, позволяя передавать нужную величину через параметр.

    class Clock
    {
       DateTime current_Time;
       //установка времени(отталкиваясь от текущего)
       public void SetTime()
       {
          current_Time = DateTime.Now;
       }
         //установка времени(поправка)
       public void SetTime(DateTime dt)
       {
         //...
       }
       //получение значения времени (с учётом поправки)
       public DateTime CheckCurrentTime()
      {
         //... 
      }
       //распечатка текущего времени
       public void PrintCurrentTime()
       {
         //...
       }
    }
    

    Тестирование работы наших часов выглядит так:

    class Program
    {
        static void Main()
       { 
          Clock clock = new Clock();
          clock.SetTime();
          Console.WriteLine( clock.CheckCurrentTime().ToString("T"));
       }
    }

    Мы создаём экземпляр класса Clock, задаём время и отображаем значение времени у данного экземпляра часов в определённом формате.

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

    Решением будет создание на базе класса Clock класса AlarmClock (Будильник), добавить в него недостающий функционал и использовать в тех случаях, когда нужен именно будильник, а не просто часы.

    Примерно это будет выглядеть вот так:

    class AlarmClock: Clock
    {
       //время сигнала
       DateTime alarmTime;
       bool is_active;
       //установка времени сигнала
       public void SetAlarmTime(DateTime dt)
       {
          //...
          alarmTime = dt;
       }
       //подача сигнала будильника
       void DoAlarm()
       {
         //...
         StopAlarm();
       }
       //начало работы будильника
       public void StartAlarm()
       {
         is_active = true;
         Console.WriteLine("AlarmClock started");
         //...
      }
       //остановка работы будильника
       void StopAlarm()
       {
         //...
         is_active = false;
          Console.WriteLine("AlarmClock stopped");
       }
    }

    Тестируем его работу:

    class Program
    {
       static void Main()
       {
         AlarmClock aclock = new AlarmClock();
         //вызов метода базового класса
         //установка текущего времени
         aclock.SetTime();
         //проверка текущего времени
         Console.WriteLine(aclock.CheckCurrentTime().ToString("T"));
         //вызов метода производного класса
         //установка времени подачи сигнала
         aclock.SetAlarmTime(DateTime.Now.AddMinutes(10));
         //начало работы будильника
         aclock.StartAlarm();
         Console.ReadKey();
       }
    }

    Теперь у нас есть 2 класса. Базовый – Clock и производный от него – AlarmClock. Преимущество наследования в том, что производный тип обладает функционалом и данными своего базового типа, а также своими собственными, и при этом нет дублирования в коде. Мы получили 2 полноценных типа данных, каждый из которых можем использовать в подходящей ситуации.

    В итоге класс Alarm НАСЛЕДУЕТ часть функциональности от класс Clock.

    Правильное использование механизма наследования

    Для начала, пара хороших цитат авторитетных людей:

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

Просмотр 1 сообщения - с 1 по 1 (всего 1)

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