ООП, «святая троица» и SOLID: некоторый минимум знаний о них
Необходимое вступление
Я не гарантирую, что изложенные здесь трактовки общепринятых терминов и принципов совпадают с тем, что изложили в солидных научных статьях калифорнийские профессора во второй половине прошлого века. Я не гарантирую, что мои трактовки полностью разделялись или разделяются большинством IT-профессионалов в отрасли или научной среде. Я даже не гарантирую, что мои трактовки помогут вам на собеседовании, хоть и предполагаю, что будут небесполезны.
Но я гарантирую, что если отсутствие всякого понимания заменить моими трактовками и начать их применять, то код вами написанный будет проще сопровождать и изменять. Так же я прекрасно понимаю, что в комментариях мной написанное будут яростно дополнять, что позволит выправить совсем уж вопиющие упущения и нестыковки.
Столь малые гарантии поднимают вопросы о причинах, по которым статья пишется. Я считаю, что этим вещам должны учить везде, где учат программированию, вплоть до уроков информатики в школах с углублённым её изучением. Тем не менее, для меня стала пугающе нормальной ситуация, когда я узнаю, что собеседник мой коллега, причём работающий уже не первый год, но про инкапсуляцию «что-то там слышал». Необходимость собрать всё это в одном месте и давать ссылку при возникновении вопросов зрела давно. А тут ещё и мой «pet-project» дал мне изрядно пищи для размышлений.
Тут мне могут возразить, что учить эти вещи в школе рановато, и вообще на ООП свет клином не сошёлся. Во-первых, это смотря как учить. Во-вторых, 70% материала этой статьи применимо не только к ООП. Что я буду отмечать отдельно.
ООП вкратце
Это, наверное, самый сложный для меня раздел. Но всё-таки надо установить базу и описать совсем вкратце, в чём самая суть ООП, чтобы было понятно, почему именно инкапсуляция, полиморфизм, наследование и принципы SOLID усилили её. И сделаю я это, рассказав о том, как вообще можно было до такого додуматься.
Началось с Дейкстры, который доказал, что любой алгоритм можно выразить через три способа выбора следующей команды: линейное выполнение (по порядку), ветвление по условию, выполнение цикла пока выполняется условие. С помощью этих трёх соединений можно сконструировать любой алгоритм. Более того, было рекомендовано писать программы, ограничиваясь линейным расположением команд друг за другом, ветвлением и циклами. Это было названо «процедурным программированием».
Так же здесь отметим, что последовательности команд необходимо объединять в подпрограммы и каждая подпрограмма может выполняться с помощью одной команды.
Кроме порядка действий для нас важно и то, над чем выполняются действия. А выполняются они над данными, которые стало принято хранить в переменных. В переменных хранятся данные. Интерпретируются они исходя из их типа. Очевидно до зубовного скрежета, но потерпите чуть-чуть, пожалуйста.
С самого начала сформировался более-менее общий набор примитивных типов данных. Целые числа, вещественные числа, булевы переменные, массивы, строки. Алгоритмы + структуры данных = программы, как завещал Никлаус Вирт.
Так же с самого начала времён был в разных ипостасях такой тип данных как подпрограмма. Или кусочек кода, если хотите. Кто-то может сказать, что использование подпрограмм в качестве переменных — прерогатива функционального программирования. Даже если так, то возможность делать кусочек кода переменной есть даже в ассемблере. Пусть эта возможность и сводится к «ну, вот тебе номер байта в оперативной памяти, где эта подпрограмма живёт, а дальше команду CALL со стеком вызова в зубы и крутись, как умеешь».
Само собой, нескольких типов данных было мало и люди начали думать над тем, чтобы добавить в различные ЯП возможность создавать свои типы. Одной из вариаций такой возможности были так называемые записи. Ниже два примера записи на несуществующем языке программирования (далее NEPL — Non-Existing Programming Language):
type Name: record consisting of
FirstName: String,
MiddleName: String,
LastName: String.
type Point: record consisting of
X: Double,
Y: Double.
То есть, вместо того, чтобы таскать за собой две-три связанные по смыслу переменные, вы группируете их в одну структуру с поименованными полями. Потом вы объявляете переменную типа Name и обращаетесь к полю FirstName, например.
Что такого ценного в этой «усиленной» переменной для нашей темы? То, что отсюда остаётся только один шажок до ООП. Я не просто так выделил целый жирный абзац на то, чтобы обозначить, что в переменные можно помещать и кусочки кода. Смотрите же, как переменные превращаются в объекты:
type Name: class consisting of
FirstName: String,
MiddleName: String,
LastName: String,
GetFullName: subprogram with no parameters returns String.
type Point: class consisting of
X: Double,
Y: Double,
ScalarMultiply: subprogram with (Double) parameters returns Point.
N.B. NEPL активно развивается и уже заменил ключевое слово record на class.
То есть, мы можем обратиться к полю «GetFullName» и вызвать его. А переменная содержит в себе не только данные, описывающие её состояние, но и поведение. Таким образом, переменная обращается в объект, который обладает некоторыми умениями и состоянием. И мы уже работаем не просто с переменными, а с маленькими системами, которым можно отдавать команды.
В юности эта идея меня восхищала. Просто вдумайтесь, можно создать любой тип данных. И вы работаете не с какими-то цифрами, а с объектами создаваемого вами мира. Никаких мучений со скучными массивами или хитросплетёнными наборами цифр. Работаем напрямую с объектами типов Player, Enemy, Bullet, Boss! Да, мне в юности хотелось делать видеоигры.
В реальности всё оказалось не так просто. И без некоторых «усиливающий» идей ООП превратит жизнь программиста в ад. Но перед тем, как двинуться дальше, дадим ещё несколько терминов:
- «Усиленные» своим поведением типы данных в ООП называются классами.
- Переменные этих типов называются объектами.
- А подпрограммы, которые задают поведение объектов называют методами. Как правило, набор методов свой у каждого класса, а не у каждого объекта. Чтобы каждый объект определённого класса вёл себя как и другие объекты того же класса. Буду рад узнать из комментариев о языках, где дело обстоит иначе.
«Святая троица»
Так уж исторически сложилось, что об этих вещах спрашивают на собеседованиях. О них пишут в любом учебнике по ООП-языку. Почему? Потому что если спроектировать ООП-программу без всякой оглядки на инкапсуляцию и полиморфизм, то получим «гроб, гроб, кладбище, несопровождаемость». Наследование уже не столь строго обязательно, но эта концепция позволяет понять ООП лучше и является одним из основных инструментов при проектировании с использованием ООП.
Инкапсуляция
Ну что, давайте со старта определение с википедии: «упаковка данных и функций в единый компонент». Определение кажется ясным, но в то же время слишком обобщённым. Поэтому давайте поговорим о том, зачем это вообще надо, что нам это даст, и как именно надо упаковывать данные и функции в единый компонент.
Я уже писал статью, которая касалась инкапсуляции. И там меня справедливо попрекнули тем, что я свёл инкапсуляцию к сокрытию информации, а это несколько разные вещи. В частности, EngineerSpock выдал такую изящную формулировку, как защита инварианта. Свою ошибку признаю, и тут объясню почему я её совершил.
А пока моё, предварительное определение принципа инкапсуляции, или если хотите, процесса инкапсулирования, которое даёт описание не только принципа инкапсуляции, но и того, что с его помощью предполагается достичь:
Любая программная сущность, обладающая нетривиальным состоянием, должна быть превращена в замкнутую систему, которую можно только перевести из одного корректного состояния в другое.
Про ту часть, где «любая программная сущность, обладающая нетривиальным состоянием» давайте чуть позже. Пока речь будем вести исключительно об объектах. И о второй части моего определения. Зачем нам это?
Тут всё просто: то, что можно только перевести из одного корректного состояния в другое, нельзя сломать. То есть, нам надо сделать так, чтобы любой объект нельзя было сломать. Звучит, мягко говоря, амбициозно. Как же этого добиться?
В-нулевых, всё, что касается объекта, должно лежать в одном месте, внутри одной архитектурной границы, скажем так. На случай, если получилось очень заумно, повторю определение из википедии: «упаковка данных и функций в единый компонент».
Во-первых, чётко разделить интерфейс и его реализацию. Думаю, всем моим коллегам знакома абревиатура API. Так вот, у каждого объекта должно быть своё API, или PI, если уж быть дотошным. То, ради чего его создают, и то, чем другие будут пользоваться, что именно будут вызывать. Каким он должен быть? Таким, чтобы никому даже в голову не пришло лезть внутрь объекта и использовать его неподходящим образом. Но не более того.
В какой-то книге, увы, не помню в какой, это объяснялось на примере микроволновки. На ней есть кнопки. Ручки. Они позволяют разогреть еду. Вам не надо раскручивать микроволновку, и что-то там паять, чтобы разогреть вчерашний суп. У вас есть интерфейс, кнопки. Поставь тарелку, нажми пару кнопок, подожди минуту и будь счастлив.
Вот подумайте, какие кнопки пользователю вашего объекта надо нажать, и отделите их от внутренних потрохов. И ни в коем случае не добавляйте лишних кнопок! Это было во-первых.
Во-вторых, уважайте границу между интерфейсом и реализацией и заставляйте других её уважать. В принципе, эта мысль интуитивно понятна и витает среди народных мудростей во множестве форм. Взять хотя бы «если вы воспользовались чем-то недокументированным, а у вас потом что-то сломалось, сами виноваты». Думаю, с «не раскручивай микроволновку, пока она работает так, как тебе надо», всё понятно. Теперь о том, как заставить других уважать пресловутую границу.
Тут-то и приходит на помощь то самое сокрытие информации. Да, всегда можно договориться, попросить, установить code conventions, указывать на код-ревью, что так нельзя. Но сама возможность залезть за эту границу-то останется. Тут-то то самое сокрытие информации и приходит на помощь.
Мы не можем пересечь пресловутую границу, если наш код не может узнать о её существовании и о том, что за ней лежит. А даже если и узнает, то компилятор сделает вид, что такого поля или метода нет, а даже если и есть, то трогать его не положено, компилироваться я отказываюсь, и вообще кто вы такие, мы вас не звали, пользуйтесь интерфейсной частью.
Вот тут-то и вступают в игру всякие разные public, private и прочие модификаторы доступа из вашего любимого языка. То самое «сокрытие информации» является самым надёжным способом не пустить по ветру выгоды инкапсуляции. Как ни крути, нет смысла группировать всё, что касается одного класса, в одном месте, если код использует что захочет и откуда захочет. А вот с сокрытием информации такая ситуация уже не должна возникать в принципе. И способ это настолько надёжный, что в сознании тысяч и тысяч программистов (включая меня, чего уж там) разница между инкапсуляцией и сокрытием информации как-то изглаживается.
Что делать, если ваш любимый ЯП не позволяет скрывать информацию вот вообще никак? На эту тему можно весело поперекидываться комментариями. Я же вижу, следующий выход. По нарастающей:
- Документировать только интерфейсную часть и считать, всё, что не документировано, реализацией.
- Отделять реализацию от интерфейса через code-convention (пример — в python есть переменная __all__, которая указывает, что именно будет импортировано из модуля, когда попросишь импортировать всё).
- Сделать эти самые code-conventions достаточно строгими, чтобы их можно было проверять автоматически, после чего любое их нарушение приравнять к ошибке компиляции и упавшему билду.
Ещё разок:
- Всё, что касается одного класса, пакуется в один модуль.
- Между классами проводятся строгие архитектурные границы.
- У любого класса отделяется интерфейсная часть от реализации этой самой интерфейсной части.
- Границы между классами надо уважать самому и заставлять их уважать других!
Закончу примером на NEPL, который всё ещё очень активно развивается и уже спустя десять абзацев обзавёлся модификаторами доступа:
type Microwave: class consisting of
private fancyInnerChips: List of Chip,
private foodWarmingThing: FoodWarmerController,
private buttonsPanel: ButtonsPanel,
public GetAccessToControlPanel: subprogram with no parameters returns ButtonsPanel,
public OpenDoor: subprogram with no parameters returns nothing,
public Put: subprogram with (Food) parameters return nothing,
public CloseDoor: subprogram with no parameters returns nothing.
type ButtonsPanel: class consisting of
private buttons: List of ButtonState,
public PressOn: subprogram with no parameters returns nothing,
public PressOff: subprogram with no parameters returns nothing,
public PressIncreaseTime: subprogram with no parameters returns nothing,
public PressDecreaseTime: subprogram with no parameters returns nothing,
public PressStart: subprogram with no parameters returns nothing,
public PressStop: subprogram with no parameters returns nothing.
Я надеюсь, что из кода ясно, в чём суть примера. Уточню только один момент: GetAccessToControlPanel проверяет, можно ли нам вообще микроволновку трогать. А вдруг она сломана? Тогда нажимать ничего нельзя. Можно только получить сообщение об ошибке.
Ну и тот факт, что ButtonsPanel стало отдельным классом плавно подводит нас к важному вопросу:, а что такое «единый компонент» из определения инкапсуляции по википедии? Где и как должны пролегать границы между классами? Мы обязательно вернёмся к этому вопросу чуть позже.
Single Responsibility Principle
Использование за пределами ООП
Очень много программистов узнало об инкапсуляции из учебника по Java/C++/C#/подставьте ваш первый ООП-язык. Поэтому инкапсуляция в массовом сознании как-то связалась с ООП. Но давайте вернёмся к двум определениям инкапсуляции.
Упаковка данных и функций в единый компонент.
Любая программная сущность, обладающая нетривиальным состоянием, должна быть превращена в замкнутую систему, которую можно только перевести из одного корректного состояния в другое.
Заметили? Там вообще ничего о классах и объектах!
Итак, пример. Вы DBA. Ваша работа — присматривать за какой-то реляционной базой данных. Пускай она будет, например, на MySQL. Вашей драгоценной базой данных пользуется несколько программ. Над некоторыми из них вы не имеете контроля. Что делать?
Создаём набор хранимых процедур. Компонуем их в одну схему, которую назовём interface. Создаём для наших программ по одному пользователю без всяких прав. Это команда CREATE USER. Затем с помощью команды GRANT даёт пользователям только право на выполнение этих хранимых процедур из схемы interface.
Всё. У нас есть база данных, та самая программная сущность с нетривиальным состоянием, которую сломать достаточно легко. И мы, чтобы её не ломали, создаём интерфейс из хранимых процедур. И после средствами самого MySQL делаем так, чтобы сторонние сущности могли использовать только этот интерфейс.
Заметьте, пресловутая инкапсуляция, как она есть, и как она описывалась, используется во весь рост. А ведь между реляционным представлением данных и объектами пропасть такая, что её приходится закрывать громоздкими ORM-фреймворками.
Именно поэтому в определении инкапсуляции не фигурируют классы и объекты. Идея куда шире, чем ООП. И она приносит слишком много пользы, чтобы говорить в ней только в учебниках по ООП-языкам.
Полиморфизм
Полиморфизм имеет много форм и определений. Достаточно, чтобы меня хватил Кондратий, когда я открыл википедию. Здесь я буду говорить о полиморфизме, как его сформулировал Страуструп: один интерфейс — множество реализаций.
В таком виде идея полиморфизма может очень сильно усилить позиции пишущих программы с оглядкой на инкапсуляции. Ведь если мы отделилили интерфейс от реализации, то тому, кто пользуется интерфейсом необязательно знать о том, что в реализации что-то поменялось. Тому, кто пользуется интерфейсом (в идеале) необязательно даже знать о том, что реализация вообще поменялась! И это открывает безграничные возможности для расширения и модификации. Ваш предшественник решил, что еду лучше всего греть военным радаром? Если этот безумный гений отделил интерфейс от реализации и чётко его формализовал, то военный радар можно приспособить под иные нужды, а его интерфейс для разогрева еды реализовать с помощью микроволновки.
NEPL стремительно развивается и под влиянием C# обзаводится (осторожно, не споткнитесь об формулировку) таким типом типов данных, как интерфейс.
type FoodWarmer: interface consisting of
GetAccessToControlPanel: no parameters returns FoodWarmerControlPanel,
OpenDoor: no parameters returns nothing,
Put: have (Food) parameters returns nothing,
CloseDoor: no parameters returns nothing.
type FoodWarmerControlPanel: interface consisting of
PressOn: no parameters returns nothing,
PressOff: no parameters returns nothing,
PressIncreaseTime: no parameters returns nothing,
PressDecreaseTime: no parameters returns nothing,
PressStart: no parameters returns nothing,
PressStop: no parameters returns nothing.
type EnemyFinder: interface consisting of
FindEnemies: no parameters returns List of Enemy.
type Radar: class implementing FoodWarmer, EnemyFinder and consisting of
private secretMilitaryChips: List of Chip,
private giantMicrowavesGenerator: FoodWarmerController,
private strangeControlPanel: AlarmClock,
public GetAccessToControlPanel: subprogram with no parameters returns FoodWarmerControlPanel,
public OpenDoor: subprogram with no parameters returns nothing,
public Put: subprogram with (Food) parameters return nothing,
public CloseDoor: subprogram with no parameters returns nothing,
public FindEnemies: subprogram with no parameters returns List of Enemy.
type AlarmClock: class implementing FoodWarmerControlPanel and consisting of
private mechanics: List of MechanicPart,
public PressOn: subprogram with no parameters returns nothing,
public PressOff: subprogram with no parameters returns nothing,
public PressIncreaseTime: subprogram with no parameters returns nothing,
public PressDecreaseTime: subprogram with no parameters returns nothing,
public PressStart: subprogram with no parameters returns nothing,
public PressStop: subprogram with no parameters returns nothing.
type Microwave: class implementing FoodWarmer and consisting of
private fancyInnerChips: List of Chip,
private foodWarmingThing: FoodWarmerController,
private buttonsPanel: ButtonsPanel,
public GetAccessToControlPanel: subprogram with no parameters returns FoodWarmerControlPanel,
public OpenDoor: subprogram with no parameters returns nothing,
public Put: subprogram with (Food) parameters return nothing,
public CloseDoor: subprogram with no parameters returns nothing.
type ButtonsPanel: class implementing FoodWarmerControlPanel and consisting of
private buttons: List of ButtonState,
public PressOn: subprogram with no parameters returns nothing,
public PressOff: subprogram with no parameters returns nothing,
public PressIncreaseTime: subprogram with no parameters returns nothing,
public PressDecreaseTime: subprogram with no parameters returns nothing,
public PressStart: subprogram with no parameters returns nothing,
public PressStop: subprogram with no parameters returns nothing.
Итак, если класс объявлен, как реализующий интерфейс, то он обязан реализовать все методы из этого интерфейса. Иначе компилятор скажет нам «фи». И у нас есть два интерфейса: FoodWarmer и FoodWarmerControlPanel. Посмотрите на них внимательно, а потом давайте разберём реализации.
В наследство от тяжкого советского прошлого мы получили класс двойного назначения Radar, которым можно и еду разогреть, и врага найти. А вместо панели управления используется будильник, потому что план перевыполнен, а их надо куда-то девать. Но, к счастью, безымянный МНС из НИИ Химии, Удобрений и Ядов, на которого это спихнули, реализовал интерфейсы FoodWarmer для радара и FoodWarmerControlPanel для будильника.
Спустя поколение кому-то пришло в голову, что еду лучше греть микроволновкой, а управлять микроволновкой лучше кнопочками. И вот созданые класс Microwave и ButtonsPanel. И они реализуют те же интерфейсы. FoodWarmer и FoodWarmerControl. Что нам это даёт?
Если мы везде в своём коде использовали для разогрева еды переменную типа FoodWarmer, то мы можем просто заменить реализацию на более современную, и никто ничего не заметит. То есть, коду, использующему интерфейс нет никакого дела до деталей реализации. Или до того факта, что она поменялась целиком и полностью. Мы можем даже сделать класс FoodWarmerFactory, который выдаёт разные реализации FoodWarmer в зависимости от конфигурации вашего приложения.
Ещё посмотрите на закрытые поля в класс Microwave и Radar. Там у нас будильник и панель с кнопочками. Но наружу мы отдаём переменную типа FoodWarmerControlPanel.
Где-то на Пикабу была история о том, как некий кандидат объяснял принцип полиморфизма следующим образом:
Вот у меня есть ручка. Я могу ей написать свое имя, а могу воткнуть ее вам в глаз. Это и есть принцип полиморфизма.
Картинка смешная, ситуация страшная, а объяснение принципа полиморфизма никудышное.
Принцип полиморфизма не в том, что класс ручки с какого-то перепугу реализует интерфейсы канцелярского изделия и холодного оружия одновременно. Принцип полиморфизма в том, что всё, что может колоть, можно воткнуть в глаз. Потому что этим можно колоть. И результат будет отличаться, но в идеале должен выдавать лишение зрения. И метод полиморфизма позволяет отразить этот факт в модели, которую вы строите для своего мира.
Использование за пределами ООП
Есть такой весёлый и забавный во всех смыслах этих слов язык как Erlang. И в нём есть такая фича как behaviour. Следите за руками:
Код там делится на модули. В качестве переменной можно использовать имя модуля. То есть, можно написать вызов функции из модуля так:
%option 1
foobar:function(),
%option 2
Module = foobar,
Module:function().
Для того, чтобы быть уверенным в том, что модуль имеет определённые функции, есть такая фича языка, как behaviour. В модуле, который использует другие модули, вы задаёте с помощью декларации behaviour_info требования к модулям-переменным. А потом модули, который ваш батька-модуль, задавший behaviour_info, будет использовать, с помощью декларации behaviour говорят компилятору: «мы обязуемся реализовать это поведение, чтобы батька-модуль мог нас использовать».
Например, модуль gen_server позволяет создать сервер, который синхронно или асинхронно в отдельном процессе (в Erlang нет никаких потоков, только тысячи маленьких параллельных процессов), выполняет запросы других процессов. И в gen_server собрана вся логика, касающаяся запросов других процессов. А вот непосредственно обработка этих запросов делается теми, кто реализует поведение gen_server. И пока другие модули его реализуют правильно (пусть там и пустые заглушки), gen_server-у вообще плевать, как эти запросы обрабатываются. Более того, обрабатывающий модуль можно сменить «на лету».
Один интефейс — множество реализаций. Как Страуструп нам завещал. Как пишется в умных книжках по ООП. А теперь цитата из википедии в студию:
Erlang — функциональный язык программирования с сильной динамической типизацией, предназначенный для создания распределённых вычислительных систем.
Просто идея «один интерфейс — множество реализаций» слишком красивая и базовая, скажем так, чтобы ограничить её применение исключительно ООП.
Пример номер два. Есть такой фреймворк .NET. И он позволяет взаимодействовать множеству программ на разных языках между собой. За это отвечает CLR. И этот самый CLR Microsoft не стали запирать на миллиард юридических замков, а описали, что оно должно уметь, в достаточно суровом техническом стандарте (гуглить ECMA-335).
.NET и программы, на нём написанные работали на Windows, Windows Phone, XBox (гуглить XNA, и всё, что с ним связано), на любом платформе. Если её держит Microsoft. Но так как есть открытый стандарт, есть интерфейс, там описанный. то нашлись люди, которые его реализовали в виде Mono Project. Их реализация работала на линуксах и позволяла запускать программы на .NET. на линуксе.
Казалось бы, на этом всё. Но потом ребята из Microsoft решили, что тоже хотят сделать .NET для линукса. И сделали. С открытым кодом, оптимизированный по самое не могу, весь из себя красивый .NET Core. И в будущем, этот самый .NET Core станет пятой версией .NET Framework, а всё древнее легаси, которое писалось, начиная с начала нулевых отправится в утиль. Да, пример с новой микроволновкой в реальной жизни.
Я описал тут эту ситуацию, потому что это выход идеи «один интерфейс — множество реализаций» на какой-то космический уровень. Полиморфизм в масштабе целой программной платформы, откуда объекты и классы даже в телескоп не видны.
Наследование
Красивая концепция, которая позволяет объединить переиспользование кода и силу полиморфизма. Но, само собой, это не панацея, а один из инструментов. Не всегда уместный, но иногда весьма и весьма полезный.
Суть в том, что можно создать новый класс на основе уже существующего и дополнить его, или частично изменить его поведение. Окей, мне тяжело дальше двигаться без примера, поэтому продолжим развивать NEPL. Сначала возникшая проблема. Помните класс Name? Вот улучшенная версия, которая сообщает нам имя в вежливой форме. Класс EtiquetteInfo находится где-то в другом месте.
import class EtiquetteInfo from Diplomacy.
type PoliteName: class consisting of
private FirstName: String,
private MiddleName: String,
private LastName: String,
for descendants GetPoliteFirstName: subprogram with (EtiquetteInfo) parameters returns String,
for descendants GetPoliteMiddleName: subprogram with (EtiquetteInfo) parameters returns String,
for descendants GetPoliteLastName: subprogram with (EtiquetteInfo) parameters returns String,
public GetFullName: subprogram with (EtiquetteInfo) parameters returns String.
subprogram GetPoliteFirstName.PoliteName with (EtiquetteInfo _EtiquetteInfo) parameters returning String implemented as
return _EtiquetteInfo.PoliteFirstName(FirstName).
subprogram GetPoliteMiddleName.PoliteName with (EtiquetteInfo _EtiquetteInfo) parameters returning String implemented as
return _EtiquetteInfo.PoliteMiddleName(MiddleName).
subprogram GetPoliteLastName with (EtiquetteInfo _EtiquetteInfo) parameters returning String implemented as
return _EtiquetteInfo.PoliteLastName(LastName).
subprogram GetFullName with (EtiquetteInfo _EtiquetteInfo) parameters returning String implemented as
return GetPoliteFirstName(_EtiquetteInfo) + GetPoliteMiddleName(_EtiquetteInfo)
+ GetPoliteLastName(_EtiquetteInfo).
Допустим, в методе GetFullName у нас была какая-то достаточно сложная логика, касающаяся обработки имени (сделаем вид, что она сложная, хорошо?). Мы пользовались этим классом, были сравнительно счастливы, но потом у нас случился клиент из каких-то дальних краёв. Где с именами всё, мягко говоря, сложно. Есть те же имя и фамилия, к ним применяются те же, назовём их так, модификаторы вежливости, но вот добавочных имён там может быть много. Возможно, даже очень много. Наш класс PoliteName становится неудобным. Написать отдельный класс ExoticPoliteName с общим интерфейсом — это создавать кучу повторяющегося кода. То, сколько приносит это боли и страданий при сопровождении, я тут рассказывать не буду.
Тут-то наследование и вступает в игру. Мы создаём класс ExoticPoliteName, который расширяет класс PoliteName, и использует его реализацию. Ниже реализация класса PoliteExoticName. Будем считать, что он в одном модуле с PoliteName.
import class EtiquetteInfo from Diplomacy.
type PoliteExoticName: class extending PoliteName and consisting of
private MoreMiddleNames: List of String,
for descendants overridden GetPoliteMiddleName: subprogram with (EtiquetteInfo) parameters returns String,
public overriden GetFullName: subprogram with (EtiquetteInfo) parameters returns String.
subprogram GetPoliteMiddleName.PoliteExoticName with (EtiquetteInfo _EtiquetteInfo) parameters returning String implemented as
String AggregatedMiddleName = String.Join(" ", MoreMiddleNames),
return base.GetPoliteMiddleName(_EtiquetteInfo + AggregatedMiddleName).
subprogram GetFullName with (EtiquetteInfo _EtiquetteInfo) parameters returning String implemented as
String Prefix = "",
String FirstName = GetFirstName(_EtiquetteInfo),
if _EtiquetteInfo.ComplimentIsAppropriate(FirstName) then
Prefix = "Oh, joy of my heart, dear ",
return Prefix + base.GetFullName(_EtiquetteInfo).
На всякий случай: класс PoliteName в отношении наследования называется базовым. А класс PoliteExoticName является классом-наследником.
Переиспользование кода у нас проявляется в том, что мы используем логику из базового класса там, где не задано никакой логики в классе наследнике. То есть, нам не надо писать заново методы GetPoliteFirstName и GetPoliteLastName. Они у нас уже есть. И когда мы хотим добавить немножко логики к методу GetFullName, мы её добавляем, а не воссоздаём заново.
Полиморфность наследования же в том, что мы можем там, где от нас просят класс PoliteName, дать объект типа PoliteExoticName, и спокойно дёрнуть метод GetFullName. Компилятор поймёт, что нам подсунули наследника PoliteName, и посмотрит, нет ли у него своей реализации этого метода. Обратите внутри реализации на такую конструкцию, как base.GetFullName (etiquetteInfo). Это означает, что мы вызываем реализацию этого метода из базового класса, чтобы не дублировать логику оттуда.
Тут надо сказать, что между базовым классом и его наследником таким образом устанавливается отношение »является». Вежливое имя остаётся вежливым именем, даже если оно экзотическое. Классический пример: квадрат является фигурой, как и круг. И их можно нарисовать. Но по разному.
ООП считается настоящим, если всё в программе является объектом. Вообще всё. Да, переменная типа Boolean, которой хватает на существования одного бита, тоже. Наследование позволяет нам зафиксировать это в языке, сделав все классы наследниками одного класса Object. Во многих языках это наследование неявно. То есть, если вы не укажете, от какого класса вы наследуетесь, то компилятор решит, что от Object, и разрешит вызывать его методы.
Давайте будем считать, что в NEPL такая система тоже действует. Тогда класс PoliteName является наследником класса Object, а PoliteExoticName являетя наследником класса PoliteName и наследником класса Object одновременно. Это значит, что эти строчки на NEPL допустимы:
subprogram Foo.Bar with no parameters returning nothing implemented as
PoliteExoticName _PoliteExoticName = GetSomePoliteExoticName(),
PoliteName _PoliteName = _PoliteExoticName,
Object _Object = _PoliteExoticName.
Мы не можем, правда, после этого написать _Object.GetFullName, так как там может быть всё, что угодно. Но если PoliteName или PoliteExoticName переопределит что-то из интерфейса класса Object, и мы потом дёрнем это что-то у переменной _Object, компилятор сначала начнёт искать реализацию этого метода у наследников.
К чему я это подводил? К тому, что наследование может быть многоуровневым. И число уровней ограничивается только здравым смыслом. Который обычно подсказывает, что если три уровня (не считая неявный Object) вам ещё простят, то за четыре-пять уже могут побить после код-ревью.
Ну, и естественно, от одного класса может отнаследоваться несколько классов, что даёт нам уже не просто цепочку, а дерево наследования. А что может пойти не так с деревом наследования? То, что от одного класса зависит множество. И если мы в базовом классе что-то сломаем или изменим, это повлияет на всех. Это известно как проблема хрупких базовых классов. Из-за этого иногда говорят, что наследование нарушает инкапсуляцию.
Так ли это? Мне кажется, что не совсем. Наследование добавляет соблазна и делает разрушение архитектурных границ лёгким и незаметным. Но наследование не обязательно нарушает инкапсуляцию. Главное помнить, что несмотря на отношение «является» между базовым классом и его наследником, между ними должна пролегать архитектурная граница. Что я имею в виду?
Что даже наследникам не положено копаться в потрохах базового класса без всяких ограничений. Обратите внимание на то, что в NEPL появился новый модификатор доступа for descendants.
type PoliteName: class consisting of
private FirstName: String,
private MiddleName: String,
private LastName: String,
for descendants GetPoliteFirstName: subprogram with (EtiquetteInfo) parameters returns String,
for descendants GetPoliteMiddleName: subprogram with (EtiquetteInfo) parameters returns String,
for descendants GetPoliteLastName: subprogram with (EtiquetteInfo) parameters returns String,
public GetFullName: subprogram with (EtiquetteInfo) parameters returns String.
Если класс PoliteExoticName попытается получить доступ к переменной FirstName, компилятор скажет «нельзя, эта переменная слишком важная, и от правильного доступа к ней зависит работоспособность класса, не трогай, пожалуйста». А вот метод GetPoliteFirstName создан специально для защищённого доступа к FirstName.
Да, кажется разумным, что если Square это Shape, то и полный доступ к Shape для Square проблемой не будет. Пока оба этих класса просты, не будет. Но как только Shape станет достаточно сложным, то есть станет программной сущностью с нетривиальным состоянием, его придётся отделить от остальных классов. Даже от Square, который дополняет класс Shape. Зачем? Чтобы он дополнял, а не изменял класс Shape, то есть не мог просто так его сломать.
Тут может возникнуть вопрос. Если забор слишком высок, то в использовании наследования смысла будет мало? Во-первых, высота забора между базовым классом и не наследниками будет ещё больше. Во-вторых, да, действительно мало, и возможно, наследование в этом случае использовать не стоит. Сформированное эмпирически правило гласит, что если можно переиспользовать код без наследования, то лучше так и сделать.
Наследование следует использовать только тогда, когда оно делает вашу программу проще. Это случается не слишком часто, если честно. Почему тогда наследование является одним из «трёх китов»? Потому что там где оно применимо, оно очень сильно упростит вашу программную систему. Но с большой силой приходит и большая ответственность. В нашем случае, большой гемморой при неправильном использовании.
Напоследок буквально пара абзацев о множественном наследовании. Так называется практика, когда у нас несколько базовых классов. В реализации нескольких интерфейсов, где не предполагается никакого поведения по умолчанию, никаких проблем нет. Там мы имеем просто набор контрактов без заданной реализации. И даже если они пересекутся, проблем будет мало. В крайнем случае, в язык добавляется конструкция, которая позволяет сделать две реализации одного и того же метода для двух разных интерфейсов.
Но когда мы наследуемся от нескольких классов, то дело резко осложняется тем, что дерево наследования превращается в полноценный граф. И искать в нём правильную реализацию того или иного метода становится очень тяжело. Если с деревом всё однозначно (двигаемся к корню, пока не найдём реализацию), то с графом, включающим множественное наследование становятся возможны разные варианты. Несколько корней, и не вполне понятно, куда надо двигаться. Плюс к тому, если наследование реализовано через вложение объектов друг в друга, повторяющеся базовые классы добавляют неприятных вопросов. Просто погуглите про ромбовидное наследование.
Я не буду в это углубляться дальше потому, что а) статья и так уже разрослась, б) множественное наследование редко позволяет упрощать программы, так что если вы решите, что оно вам здесь не нужно, в 999 случаях из 1000 не ошибётесь. О том, насколько этот инструмент полезен красноречиво говорит тот факт, что во многих ЯП множественное наследование запрещено.
(Не)использование за пределами ООП
Если инкапсуляция и полиморфизм не предполагают наличия объектов и классов, то концепция наследования вертится именно вокруг них. Я бы мог притянуть чт