К списку

Абстрактные классы и интерфейсы. Часть 1

17 сентября 2019

Много уже написано и сломано копий по поводу использования абстрактных классов и интерфейсов, их различия и границ применимости. В ряде языков программирования, например С++, вообще нет четкой границы между абстрактным классом и интерфейсом, правильнее сказать, что там интерфейсы реализуются посредством абстрактных классов. В языках подобных С# и Java интерфейсы — это отдельные выделенные сущности, что иногда привносит дополнительную путаницу в мозг неокрепшего джуна, только начинающего свой славный путь в сфере ИТ.  Попробуем разобраться, кто есть кто, и, главное, зачем. Начнем с определения (cогласно википедии):

Абстрактный класс в объектно-ориентированном программировании — базовый класс, который не предполагает создания экземпляров. Абстрактные классы реализуют на практике один из принципов ООП — полиморфизм. Абстрактный класс может содержать (и не содержать) абстрактные методы и свойства. Абстрактный метод не реализуется для класса, в котором описан, однако должен быть реализован для его неабстрактных потомков. Абстрактные классы представляют собой наиболее общие абстракции, то есть имеющие наибольший объём и наименьшее содержание.

Вроде все ясно, четко и понятно, но понятно, в первую очередь, матерым бородатым  разработчикам, которые за чашечкой кофе могут (и любят) обсуждать преимущества того или иного архитектурного дизайна для создаваемого приложения параллельно с автомобильным и строительным холиваром. Однако возникает вопрос: зачем нужен класс, экземпляр которого нельзя создать, и который нежизнеспособен без потомков, своего рода «сферический конь в вакууме». И можно ли вообще обойтись без абстрактных классов, чтобы не плодить сущности и чтобы не разрастался проект? Ответ простой — МОЖНО, как можно обойтись вообще без ООП, и вообще весь функционал приложения свалить в одну функцию main(), взять в руку копье и гоняться за мамонтом. Однако если одни умные люди, задающие тон в развитии языков и парадигм программирования, придумали и реализовали некоторый подход, то, наверное, остальным умным людям стоит, как минимум, попытаться понять и использовать его, разумеется в границах применимости и, конечно, без фанатизма.

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

Создадим класс Vehicle (например, на языке C#), в полях (свойствах) и методах которого прописываем все характеристики и функциональность, свойственные всем  транспортным средствам, построенных на данной платформе.

[c] class VehicleEngine
    {
        public void Start() { }
    }
    
    class Vehicle
    {
        public VehicleEngine Engine { get; set; }
        // Other properties
        public void StartEngine() { }
        public void Move() { }
        // Other methods
    }
[/c]

Достаточно ли данного кода для  создания объектов типа Vehicle и совершения поездок? Нет. Объект-то мы создать можем, однако методы StartEngine() и Move() имеют пока «пустую» реализацию, и соответственно автомобиль никуда не сдвинется своим ходом. Можно ли методы этого класса наполнить функционалом и наконец-то поехать куда-то? Почему нет, можно, если не интересует удобство, топливная эффективность, скорость, комфорт и, в конечном итоге, надежность объекта. Дело в том, что существует большое количество двигателей с разными видами топлива, типов и производителей коробок передач, вариантов кузова, не все варианты сочетания которых в рамках одного изделия возможны. Даже реализация кажется простейшего метода StartEngine()

[c] public void StartEngine()
{
    Engine.Start();
}
[/c]

в рамках данного класса может привести к програмерскому «аду» и вылиться в сотни строчек не поддерживаемого кода с обилием if else, вложенных циклов и т.д. Разные двигатели могут запускаться по-разному, причем ответственность за это лежит не только на самом объекте «двигатель», но и тесно интегрирована, как минимум, в электрическую схему всего автомобиля, которая при необходимости позволяет прогреть свечи, проконтролировать наличие и качество топлива, состояние аккумулятора, климатической системы, положение коробки передач, ручного тормоза и т.д. Алгоритм запуска даже одного и того же двигателя для транспортных средств разного типа может отличаться, вплоть до того, что рекомендуется его запускать от внешнего ручного стартера (шутка). Даже если удастся «разрулить» все эти вопросы в одном монстроподобном методе, дальше все может стать еще более интересным и катастрофическим. Допустим, что по результатам эксплуатации производитель решил немного изменить алгоритм запуска для одной единственной модели автомобиля, и, разумеется, сделает это в общем методе StartEngine(), после чего конкретная модель эффективно и отлично заводится, а вот все остальные модели на конвейере после обновления прошивки из-за незначительной ошибки реализации перестали заводиться вообще.

Какой выход? Отправить талантливого инженера, который предложил такую архитектуру проекта, на другую высокооплачиваемую работу или почетную пенсию, и сделать абсолютно логичную вещь — делегировать ответственность за реализацию алгоритма запуска двигателя на уровень ниже: на инженеров, знакомых с деталями реализации конкретных типов и моделями автомобилей. Благо парадигма ООП это позволяет сделать очень элегантным способом. Как? Правильно — через наследование и реализацию виртуальных методов.

   [c] class Vehicle
    {
        ………………………………………………….     
 public virtual void StartEngine()
        {
            Engine.Start();
        }
        ……………………………………………
    }

    class AirConditioner
    {
        public void SwitchOn() { }
        public void SwitchOff() { }
    }

    class LuxurySedanCar: Vehicle
    {
        public AirConditioner Conditioner { get; set; }
        public LuxurySedanCar(VehicleEngine engine)
        {
            Engine = engine;
            Conditioner = new AirConditioner();
        }

        public override void StartEngine()
        {
            Conditioner.SwitchOff();
            base.Start();
            Conditioner.SwitchOn();
        }
    }
[/c]

Как видно из приведенного примера, перед запуском двигателя в автомобиле, оборудованном кондиционером, необходимо его отключить (чтобы обеспечить максимальную отдачу энергии от аккумулятора, а заодно и поберечь дорогостоящее оборудования от перепадов напряжения в бортовой сети), запустить двигатель, а затем можно и включить его обратно.  Для каждой модели будет своя реализация метода StartEngine(), учитывающая особенности ее конструкции, в конечном итоге метод базового класса становится максимально вырожденным и вряд ли сможет успешно переиспользоваться в производных классах. Кроме того, виртуальный метод StartEngine() становится «вредным», поскольку метод, объявленный как виртуальный, ДОПУСКАЕТ, но не ТРЕБУЕТ его переопределения. В результате некий талантливый инженер забудет переопределить данный метод и автомобиль сойдет с конвейера с базовой реализацией запуска двигателя, что со временем практически ГАРАНТИРОВАНО приведет к поломке дорогостоящего агрегата, поскольку в ней как минимум отсутствует проверка уровня охлаждающей жидкости в системе, которую другой талантливый инженер или автовладелец забудет залить. Реализовать такую базовую проверку в базовом классе тоже нецелесообразно, поскольку двигатель может быть и воздушного охлаждения, и перекладывание логики этой проверки опять на StartEngine() из Vehicle приведет к новому витку «чернухи» в данном методе.

Поскольку решили, что метод базового класса практически нежизнеспособен и даже «вреден»,  то если кто-то забыл или не хочет его переопределить, то нужно его ЗАСТАВИТЬ это сделать, благо рычаги давления язык предоставляет более чем достаточные – объявим его абстрактным, а соответственно и весь класс.

   [c]  abstract class Vehicle
    {
	……………………………..
     
   public abstract void StartEngine();
…………………………….
     
    }

    class LuxurySedanCar : Vehicle
    {
        public override void StartEngine()
        {
    		………………….
            Engine.Start();
……………………….
        }
    }
[/c]

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

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

И в заключение первой части. Следование всех экземпляров автопрома базовому абстрактному классу снимает много головной боли и у руководителей автопарков. Несмотря на особенности эксплуатации, все автотранспортные средства могут использоваться как элемент некоторого списка List<Vehicle> autos, и отправляться на маршрут унифицированным способом auto.Move(), в идеальном случае без учета индивидуальных особенностей конкретного объекта. Однако хорошо, когда все унифицировано, но в парке могут содержаться и принципиально иные транспортные средства, которые тоже могут перемещаться и выполнять возложенные функции по перевозке пассажиров и грузов (мопеды, самокаты и т.д. ), однако никак не вписываются в концепцию автомобиля Vehicle.

Как быть в этом случае? Об этом поговорим в следующий раз.

Автор материала – Игорь Хейдоров, преподаватель Тренинг центра ISsoft.

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

Опыт работы: C++/C# разработчик с 1997 года.