Композиция или наследование: как выбрать?
В начале.
… не было ни композиции, ни наследования, только код.
И был код неповоротливым, повторяющимся, нераздельным, несчастным, избыточным и измученным.
Основным инструментом для повторного использования кода была копипаста. Процедуры и функции были редкостью, подозрительными новомодными штучками. Вызов процедур был дорогим удовольствием. Части кода, отделенные от основной логики, вызывали недоумение!
Мрачные были времена.
И вот тут ООП взлетел. Было написано множество 4 книг, расплодились бесчисленные 5 статьи. Так что сегодня-то каждый может в объектно-ориентированное программирование, так?
Увы, код (и интернет) говорит, что не так
Самые жаркие споры и наибольшее непонимание, похоже, вызывает выбор между композицией и наследованием, зачастую выраженный мантрой «предпочитайте композицию наследованию». Вот об этом и поговорим.
Когда мантры вредят
В житейском плане «предпочитать композицию наследованию» в целом нормально, хоть я и не любитель мантр. Несмотря на то, что они зачастую и несут зерно истины, слишком легко поддаться соблазну и бездумно следовать лозунгу, не понимая, что за ним скрывается. А это всегда выходит боком.
Желтушные статьи с заголовками вроде «Наследование — зло» 6 тоже не по мне, особенно если автор пытается обосновать свои набросы, сначала неправильно применяя наследование, а потом делая вывод, что оно во всем виновато. Ну типа «молотки — отстой, потому что ими нельзя завинтить шуруп.»
Определения
Далее в статье я буду понимать под ООП «классический» объектный язык, который поддерживает классы со свойствами, методами и простое (одиночное) наследование. Никаких вам интерфейсов, примесей, аспектов, множественного наследования, делегатов, замыканий, лямбд, — ничего, кроме самых простых вещей:
Наследование фундаментально
Наследование — это фундаментальное понятие ООП. В языке программирования могут быть объекты и сообщения, но без наследования он не будет объектно-ориентированным (только основанным на объектах, но все еще полиморфным).
… как и композиция
Композиция это тоже фундаментальное свойство, причем любого языка. Даже если язык не поддерживает композицию (что редкость в наши дни), люди все равно будут мыслить категориями частей и компонентов. Без композиции было бы невозможно решить сложные задачи по частям.
(Инкапсуляция тоже вещь фундаментальная, но сейчас речь не о ней)
Так от чего весь сыр-бор?
Ну хорошо, и композиция, и наследование фундаментальны, в чем дело-то?
А дело в том, что можно подумать, что одно всегда может заменить другое, или что первое лучше или хуже второго. Разработка ПО — это всегда выбор разумного баланса, компромисс.
С композицией все более-менее просто, мы с ней постоянно сталкиваемся в жизни: у стула есть ножки, стена состоит из кирпичей и цемента и тому подобное. А вот наследование, несмотря на свое простое определение, может все усложнить и запутать, если хорошенько не поразмыслить над тем, как его применять. Наследование это весьма абстрактная штука, о нем можно рассуждать, но так просто его не потрогаешь. Мы, конечно, можем сымитировать наследование, используя композицию, но это, как правило, слишком много возни. Для чего нужна композиция — очевидно: из частей собрать целое. А вот с наследованием сложнее, потому что оно сразу о двух вещах: о смысле и о механике.
Наследование смысловое
Как в биологии классификация таксонов организует их в иерархии, так наследование отражает иерархию понятий из предметной области. Упорядочивает их от общего к частному, собирает родственные идеи в ветви иерархического древа. Смысл (семантика) класса по большей части выражен в его интерфейсе — наборе сообщений, которые класс способен понять, но также определяется и теми сообщениями, которыми класс отвечает. Унаследовался от предка — будь добр не только понять все сообщения, которые мог понять предок, но также и уметь ответить как он (сохранить поведение предка — прим. пер.) И поэтому наследование связывает наследника с предком гораздо сильнее, чем если бы мы взяли просто экземпляр предка как компонент. Обратите внимание, даже если класс делает что-то совсем простое, почти не имеет логики, его имя несет существенную смысловую нагрузку, разработчик делает из него важные выводы о предметной области.
Наследование механическое
Говоря о наследовании в механическом плане, мы имеем в виду, что наследование берет данные (поля) и поведение (методы) базового класса и позволяет использовать их повторно или же дополнить в наследниках. С точки зрения механики, если потомок унаследует реализацию (код) предка, то неизбежно получит и его интерфейс.
Я уверен, что в недопонимании виновата именно эта двойственная природа наследования 7 в большинстве ОО-языков. Многие считают, что наследование — это чтобы повторно использовать код, хотя оно не только для этого. Если придавать повторному использованию чрезмерное значение — жди беды в архитектуре. Вот пара примеров.
Как не надо наследовать. Пример 1
Можно было бы переопределить все нежелательные методы, а некоторые (например, clear() ) даже и адаптировать под наши нужды, но не многовато ли работы из-за одной ошибки в дизайне? На самом деле трех: одной смысловой, одной механической и одной комбинированной:
Последний пункт — незначительная на первый взгляд, но важная вещь. Посмотрим на нее пристальнее.
Как не надо наследовать. Пример 2
Не тут-то было. Поступив так мы опять спутаем две предметные области. Старайтесь избегать этого:
Слой предметной области не должен знать, как у нас там все внутри сделано. Рассуждая о том, что делает наша программа, мы оперируем понятиями из предметной области, и мы не хотим отвлекаться на нюансы внутреннего устройства. Если видеть в наследовании только инструмент повторного использования кода, мы раз за разом будем попадаться в эту ловушку.
Дело не в одиночном наследовании
Одиночное наследование пока остается самой популярной моделью ООП. Оно неизбежно влечет наследование реализации, которое приводит к сильному зацеплению (coupling — прим. пер.) между классами. Может показаться, что беда в том, что ветка наследования у нас только одна на обе потребности: и смысловую и механическую. Если использовали для одного, то для другого уже нельзя. А раз так, может быть множественное наследование все исправит?
Нет. Отношение наследования не должно пересекать границы между предметными областями: инструментальной (структуры данных, алгоритмы, сети) и прикладной (бизнес-логика). Если CustomerGroup будет наследовать ArrayList и одновременно, скажем, DemographicSegment, то две предметные области переплетутся между собой, а «видовая принадлежность» объектов станет неочевидна.
Предпочтительно (по крайней мере, с моей точки зрения) делать так. Наследуемся от имеющихся в языке инструментальных классов по минимуму, ровно настолько, чтобы реализовать «механическую» часть вашей логики. Потом соединяем получившиеся части композицией, но не наследованием. Иными словами:
От инструментов можно наследовать только другие инструменты.
Это очень частая ошибка новичков. Что не удивительно, ведь так просто взять и унаследовать. Редко где встретишь обсуждения, почему именно это неправильно. Еще раз: бизнес-сущности должны пользоваться инструментами, а не быть ими. Мухи (инструменты) — отдельно, котлеты (бизнес-модели) — отдельно.
Так когда же нужно наследование?
Наследуемся как надо
Чаще всего — и при этом с наибольшей отдачей — наследование применяют для описания объектов, незначительно отличающихся друг от друга (в оригинале используется термин «differential programming» — прим. пер.) Например, нам нужна особенная кнопка с небольшими дополнениями. Нормально, наследуемся от существующего класса Кнопка. Потому что наш новый класс, это все еще кнопка, а мы полностью наследуем API класса Кнопка, его поведение и реализацию. Новая функциональность только добавляется к существующему. А вот если в наследнике часть функциональности убирается, это повод задуматься, а нужно ли наследование.
Наследование полезнее всего для группировки сходных сущностей и понятий, определения семейств классов, и вообще для организации терминов и понятий, описывающих предметную область. Зачастую, когда значительная часть предметной логики уже реализована, исходно выбранные иерархии наследования перестают работать. Если всё к тому идет, не бойтесь разобрать и заново сложить эти иерархии 9 так, чтобы они лучше соответствовали и работали друг с другом.
Композиция или наследование: что выбрать?
В ситуации, когда вроде бы подходит и то и другое, взгляните на дизайн в двух плоскостях:
Пока наследование остается внутри одной плоскости, все нормально. Но если иерархия проходит через две плоскости сразу, это плохой симптом.
Например, у вас есть один объект внутри другого. Внутренний объект реализует значительную часть поведения внешнего. У внешнего объекта куча прокси-методов, которые тупо пробрасывают параметры во внутренний объект и возвращают от него результат. В этом случае посмотрите, а не стоит ли унаследоваться от внутреннего объекта, хотя бы частично.
Разумеется, никакие инструкции не заменят голову на плечах. Когда строишь объектную модель, вообще полезно думать. Но если вам хочется конкретных правил, то пожалуйста.
Иногда все эти условия выполняются одновременно:
Если это не ваш случай, то и наследование вам, скорее всего, будет нужно не часто. Но не потому, что надо «предпочитать» композицию наследованию, и не потому что она «лучше». Выбирайте то, что подходит наилучшим образом для конкретно вашей задачи.
Надеюсь, эти правила помогут вам понять разницу между двумя подходами.
Послесловие
Отдельная благодарность сотрудникам ThoughtWorks за их ценный вклад и замечания: Питу Хогсону, Тиму Брауну, Скотту Робинсону, Мартину Фаулеру, Минди Ор, Шону Ньюхэму, Сэму Гибсону и Махендре Кария.
Первый официальный ОО-язык, SIMULA 67, появился в 1967 году.
Системные и прикладные программисты приняли на вооружение C++ в середине 1980-х, но перед тем, как ООП стал общепринятым, прошел еще десяток лет.
Я намеренно упрощаю, не говорю про паб/саб, делегатов и тому подобное, чтобы не раздувать статью.
На момент написание этого текста Амазон предлагает 24777 книг по ООП.
Поиск в гугле по фразе «объектно-ориентированное программирование» дает 8 млн результатов.
Поиск в гугле выдает 37600 результатов по запросу «наследование это зло».
Смысл (интерфейс) и механику (исполнение) можно разделить за счет усложнения языка. См. пример из спецификации языка D.
Проектирование для повторного использования через наследования выходит за рамки темы статьи. Просто имейте в виду, что ваш дизайн должен удовлетворить потребности и тех, кто пользуется базовым классом, и тех, кому нужен наследник.
Переводчик выражает благодарность ООП-чату в Telegram, без которого этот текст не смог бы появиться.
Наследование, композиция, агрегация
Нередко случается, что решив разобраться с какой-то новой темой, понятием, инструментом программирования, я читаю одну за другой статьи на различных сайтах в интернете. И, если тема сложная, то эти статьи могут не на шаг не приблизить меня к понимаю. И вдруг встречается статья, которая моментально дает озарение и все паззлы складываются воедино. Трудно определить, что отличает такую статью от других. Правильно подобранные слова, оптимальная логика изложения или же просто более релевантный пример. Я не претендую на то, что моя статься окажется новым словом в C# или же лучшей обучающей статьей. Но, возможно для кого-то она станет именно той, которая позволит разобраться, запомнить и начать правильно применять те понятия, о которых пойдет речь.
В объектно-ориентированных языках программирования существует три способа организации взаимодействия между классами. Наследование — это когда класс-наследник имеет все поля и методы родительского класса, и, как правило, добавляет какой-то новый функционал или/и поля. Наследование описывается словом «является». Легковой автомобиль является автомобилем. Вполне естественно, если он будет его наследником.
Ассоциация – это когда один класс включает в себя другой класс в качестве одного из полей. Ассоциация описывается словом «имеет». Автомобиль имеет двигатель. Вполне естественно, что он не будет являться наследником двигателя (хотя такая архитектура тоже возможна в некоторых ситуациях).
Выделяют два частных случая ассоциации: композицию и агрегацию.
Композиция – это когда двигатель не существует отдельно от автомобиля. Он создается при создании автомобиля и полностью управляется автомобилем. В типичном примере, экземпляр двигателя будет создаваться в конструкторе автомобиля.
Агрегация – это когда экземпляр двигателя создается где-то в другом месте кода, и передается в конструктор автомобиля в качестве параметра.
Хотя ведутся дискуссии о преимуществах того или иного способа организации взаимодействия между классами, какого-либо абстрактного правила не существует. Разработчик выбирает тот или иной путь основываясь на элементарной логике (“является” или “имеет”), но также принимает во внимание возможности и ограничения, которые дают и накладывают эти способы. Для того, чтобы увидеть эти возможности и ограничения, я попытался написать пример. Достаточно простой, чтобы код оставался компактным, но и достаточно развитый, чтобы в рамках одной программы можно было применить все три способа. И, главное, я попытался сделать этот пример как можно менее абстрактным – все объекты и экземпляры понятны и осязаемы.
Напишем простенькую игру – танковый бой. Играют два танка. Они поочередно стреляют и проигрывает тот, здоровье которого упало до нуля. В игре будут различные типы снарядов и брони. Для того, чтобы нанести урон необходимо во-первых, попасть по танку противника, во-вторых, пробить его броню. Если броня не пробита, урон не наносится. Логика игры построена на принципе «камень-ножницы-бумага»: то есть броня одного типа хорошо противостоит снарядам определенного типа, но плохо держит другие снаряды. Кроме того, снаряды, которые хорошо пробивают броню, наносят малый «заброневой» урон, и, напротив, наиболее «летальные» снаряды имеют меньше шансов пробить броню.
Создадим простенький класс для пушки. Он будет иметь два приватных поля: калибр и длину ствола. От калибра зависит урон, и, частично, способность к пробитию брони. От длины ствола – точность стрельбы.
Сделаем также конструктор для пушки:
Сделаем метод для получения калибра из других классов:
Помните, что для поражения цели должно произойти две вещи: попадание в цель и пробитие брони? Так вот, пушка будет отвечать за первую из них: попадание. Поэтому делаем булевый метод IsOnTarget, который принимает случайную величину (dice) и возвращает результат: попали или нет:
Целиком класс пушки выглядит следующим образом:
Здесь мы применили агрегацию. Где-то будет создана пушка. Потом к этой пушке будут создаваться снаряды, которые имеют указатель на пушку.
Теперь сделаем разные типы снарядов, которые будут наследовать абстрактный снаряд: фугасный, кумулятивный, подкалиберный. Фугасный наносит самый большой урон, кумулятивный – меньше, подкалиберный – еще меньше. Дочерние классы не имеют полей и вызывают конструктор базового снаряда, передавая ему пушку, и строковый тип. В дочернем классе переопределяется метод GetDamage() – вносятся коэффициенты, которые увеличат или уменьшат урон по сравнению с дефолтным.
Фугасный (дефолтный урон):
Кумулятивный (дефолтный урон х 0.6):
Подкалиберный (дефолтный урон х 0.3):
Обратите внимание, что в переопределенном методе GetDamage вызывается и метод базового класса. То есть, переопределив метод, мы также сохраняем возможность обратиться к дефолтному методу, использовав ключевое слово base).
Итак, для снарядов мы применили и агрегацию (пушка в базовом классе), и наследование.
Создадим теперь броню для танка. Здесь применим только наследование. Любая броня имеет толщину. Поэтому абстрактный класс брони будет иметь поле thickness, и строковое поле type, которое будет определятся при создании дочерних классов.
Броня будет в нашей игре определять пробита они или нет. Поэтому, у нее будет лишь один метод, который будет переопределяться в дочерних, в зависимости от типа брони.
Для того, чтобы конструктор танка остался более-менее компактным, сделаем два вспомогательных приватных метода, которые добавляют три типа брони соответствующей толщины, и наполняют боеукладку 10 снарядами каждого из трех типов:
Теперь конструктор танка выглядит вот таким образом:
Пользовательский интерфейс танка состоит из трех методов: выбрать броню, зарядить пушку, выстрелить.
Как я упомянул в начале, в этом примере я старался максимально уйти от абстрактных понятий, которые нужно все время держать в голове. Поэтому каждый экземпляр снаряда у нас равен физическому снаряду, который положили в боеукладку перед боем. Следовательно, снаряды могут закончится в самый неподходящий момент!
Этот интерфейс требует реализации метода Clone(). Вот она:
Теперь все супер реалистично: при выстреле генерируется dice, пушка рассчитывает попадание своим методом IsOnTarget, и, если попадание есть, то метод Shoot вернет экземпляр снаряда, а если промах – то вернет null.
Последний метод танка – его поведение при попадании вражеского снаряда:
Все готово. Остается только написать консольный (или неконсольный) вывод, в котором будет обеспечен пользовательский интерфейс и в цикле реализованы поочередные ходы игроков.
Подведем итоги. Мы написали программу, в которой использовали наследование, композицию и агрегацию, надеюсь, поняли и запомнили различия. Активно задействовали возможности полиморфизма, во-первых, когда любые экземпляры дочерних классов можно сложить в список, имеющий тип данных родительского, а во-вторых, создавая методы, которые принимают в качестве параметра родительский экземпляр, но внутри которых вызываются методы дочернего. По ходу текста я упоминал возможные альтернативные реализации – замену наследования на агрегацию, и, универсального рецепта тут нет. В нашей реализации наследование дало нам легкость добавления новых деталей в игру. Например, чтобы добавить новый тип снаряда нам нужно лишь:
Ниже – приведена диаграмма наших классов.
В финальном коде игры все «магические числа», которые использовались в тексте, вынесены в отдельный статический класс Config. К публичным полям статического класса мы можем обратиться из любого фрагмента нашего кода и его экземпляр не нужно (и невозможно) создавать. Вот так он выглядит:
Композиция
Еще одной особенностью объектно-ориентированного программирования является возможность реализовывать так называемый композиционный подход. Заключается он в том, что есть класс-контейнер, он же агрегатор, который включает в себя вызовы других классов. В результате получается, что при создании объекта класса-контейнера, также создаются объекты других классов.
Чтобы понять, зачем нужна композиция в программировании, проведем аналогию с реальным миром. Большинство биологических и технических объектов состоят из более простых частей, также являющихся объектами. Например, животное состоит из различный органов (сердце, желудок), компьютер — из различного «железа» (процессор, память).
Не следует путать композицию с наследованием, в том числе множественным. Наследование предполагает принадлежность к какой-то общности (похожесть), а композиция — формирование целого из частей. Наследуются атрибуты, т. е. возможности, другого класса, при этом объектов непосредственно родительского класса не создается. При композиции же класс-агрегатор создает объекты других классов.
Рассмотрим на примере реализацию композиции в Python. Пусть, требуется написать программу, которая вычисляет площадь обоев для оклеивания помещения. При этом окна, двери, пол и потолок оклеивать не надо.
Прежде, чем писать программу, займемся объектно-ориентированным проектированием. То есть разберемся, что к чему. Комната – это прямоугольный параллелепипед, состоящий из шести прямоугольников. Его площадь представляет собой сумму площадей составляющих его прямоугольников. Площадь прямоугольника равна произведению его длины на ширину.
По условию задачи обои клеятся только на стены, следовательно площади верхнего и нижнего прямоугольников нам не нужны. Из рисунка видно, что площадь одной стены равна xz, второй – уz. Противоположные прямоугольники равны, значит общая площадь четырех прямоугольников равна S = 2xz + 2уz = 2z(x+y). Потом из этой площади надо будет вычесть общую площадь дверей и окон, поскольку они не оклеиваются.
Можно выделить три типа объектов – окна, двери и комнаты. Получается три класса. Окна и двери являются частями комнаты, поэтому пусть они входят в состав объекта-помещения.
Для данной задачи существенное значение имеют только два свойства – длина и ширина. Поэтому классы «окна» и «двери» можно объединить в один. Если бы были важны другие свойства (например, толщина стекла, материал двери), то следовало бы для окон создать один класс, а для дверей – другой. Пока обойдемся одним, и все что нам нужно от него – площадь объекта:
Класс «комната» – это класс-контейнер для окон и дверей. Он должен содержать вызовы класса «окно_дверь».
Хотя помещение не может быть совсем без окон и дверей, но может быть чуланом, дверь которого также оклеивается обоями. Поэтому имеет смысл в конструктор класса вынести только размеры самого помещения, без учета элементов «дизайна», а последние добавлять вызовом специально предназначенного для этого метода, который будет добавлять объекты-компоненты в список.
Практическая работа
Приведенная выше программа имеет ряд недочетов и недоработок. Требуется исправить и доработать, согласно следующему плану.
При вычислении оклеиваемой поверхности мы не «портим» поле self.square. В нем так и остается полная площадь стен. Ведь она может понадобиться, если состав списка wd изменится, и придется заново вычислять оклеиваемую площадь.
Однако в классе не предусмотрено сохранение длин сторон, хотя они тоже могут понадобиться. Например, если потребуется изменить одну из величин у уже существующего объекта. Площадь же помещения всегда можно вычислить, если хранить исходные параметры. Поэтому сохранять саму площадь в поле не обязательно.
Исправьте код так, чтобы у объектов Room были только четыре поля – width, lenght, height и wd. Площади (полная и оклеиваемая) должны вычислять лишь при необходимости путем вызова методов.
Программа вычисляет площадь под оклейку, но ничего не говорит о том, сколько потребуется рулонов обоев. Добавьте метод, который принимает в качестве аргументов длину и ширину одного рулона, а возвращает количество необходимых, исходя из оклеиваемой площади.
Разработайте интерфейс программы. Пусть она запрашивает у пользователя данные и выдает ему площадь оклеиваемой поверхности и количество необходимых рулонов.
Курс с примерами решений практических работ:
android-приложение, pdf-версия
С. Шапошникова © 2021
Объектно-ориентированное программирование на Python
Композиция программного обеспечения: Вступление
Композиция — это составление целого из частей.
На моём первом уроке программирования в старших классах мне рассказывали, что программирование — это разделение сложной задачи на несколько небольших, и объединение простых результатов для получения конечного решения сложной проблемы.
Одним из моих главных сожалений в жизни является то, что я не смог рано понять значение того урока. Я узнал суть проектирования программного обеспечения слишком поздно.
Я проводил собеседования с сотнями разработчиков. Из этих встреч я узнал, что я не одинок. Очень немногие разработчики имели хорошее понимание сути разработки программ. Большинство не знали о самых важных инструментах, которые имеются в наличии, или как их использовать. 100% пытались ответить на один или на оба самых важных вопроса в области разработки программного обеспечения:
Проблема заключается в том, что вы не можете избежать композиции, только потому что не знаете о ней. Вы все-равно делаете это — но делаете плохо. Вы пишете код с большим количеством ошибок и делаете его сложным для понимания другим разработчикам и это большая проблема. Мы тратим больше времени на поддержку программного обеспечения, чем на создание его с нуля, и наши ошибки влияют на миллиарды людей по всему миру.
Во всем мире используют различное программное обеспечение. Каждый автомобиль это мини-суперкомпьютер на колесах, и проблемы с разработкой его программного обеспечения может создают реальные проблемы и стоят человеческих жизней. В 2013 году комитет признал команду разработчиков программного обеспечения Toyota виновной в грубом нарушении после того, как расследование аварии выявило спагетти-код с 10000 глобальными переменными.
Хакеры и правительственные агенты собирают ошибки, чтобы шпионить за людьми, красть кредитные карты, использовать вычислительные ресурсы для запуска распределенных атак типа “отказ в обслуживании” (DDoS), взламывать пароли и даже манипулировать выборами.
Мы должны добиваться лучшего.
Вы составляете программы каждый день
Если вы разработчик, вы составляете функции и структуры данных каждый день, знаете вы это или нет. Вы можете делать это сознательно (что лучше), или случайно, используя клейкую ленту и суперклей.
Процесс разработки это разделение больших проблем на более мелкие, создание компонентов, которые решают эти маленькие проблемы, и затем составление всех этих частей вместе, чтобы получить конечное приложение.
Компонуем функции
Всякий раз когда вы пишите подобный код, вы компонуете функции:
Всякий раз когда вы пишите цепочку промисов, вы компонуете функции:
Точно так же, каждый раз, когда вы делаете цепочку вызовов методов массива, методов lodash, observables (RxJS, и т.д.), вы компонуете функции. Если вы используете цепочки вызовов, вы компонуете функции. Если вы передаете возвращаемые значения в другие функции, вы компонуете функции. Если вы последовательно вызываете два метода вы компонуете их, используя this в качестве входных данных.
Если вы используете цепочки вызовов, вы компонуете функции.
Когда вы используете композицию функций намеренно, то делаете это лучше.
Специально скомпоновав функции, мы можем улучшить наш doStuff() до одной строчки.
Главное замечание к такой форме написания заключается в том, что её сложнее отлаживать. Например как бы вы написали следующий код, используя композицию функций?
Во-первых, давайте вынесем логирование “after f”, “after g” в отдельную вспомогательну функцию с именем trace() :
Теперь мы можем использовать её:
Популярные библиотеки функционального программирования, такие как Lodash и Ramda, уже включают в себя утилиты для упрощения композиции функций. Вы можете переписать функцию выше, следующим образом:
Если хотите попробовать этот код без импорта чего-либо, то можете определить функцию pipe таким образом:
Не беспокойтесь, если не смогли еще уследить, как это работает. Позже мы рассмотрим функциональную композицию более подробно. На самом деле это настолько важно, потому вы увидите ее определение и демонстрацию множество раз в этом тексте. Цель в том, чтобы помочь вам понять настолько, чтобы сделать использование этого автоматическим. Будьте едины с композицией.
pipe() создает конвейер из функций, где результат одной функции является аргументом для следующей. Когда вы используете pipe() (или его аналог compose() ), то не используете промежуточные переменные. Описание функций без указания аргументов называется бесточечной нотацией. Для этого вы вызываете функцию, которая возвращает новую функцию, без явного её объявления. Это означает, что вам не нужно использовать ключевое слово function или стрелочный синтаксис ( => ).
Бесточечную нотацию можно использовать гораздо дальше, и это хорошо, потому то промежуточные переменные создают ненужную сложность вашим функциям.
Несколько преимуществ уменьшения сложности:
Работа памяти
Средний человеческий мозг имеет только несколько общих ресурсов для дискретных квантов в рабочей памяти, и каждая переменная потенциально потребляет один из этих квантов. Когда вы добавляете больше переменных, то наша способность точно вспомнить значение каждой переменной уменьшается. Модель рабочей памяти включают 4–7 дискретных квантов, превысив эти значения коэффициент ошибок резко возрастают.
Отношение сигнал/шум
Короткий код увеличивает отношение сигнал/шум в вашем коде. Это как слушать радио — когда оно не настроено должным образом, вы будете получать множество помех, из-за чего труднее услышать музыку. Но стоит настроить на правильную станцию, как шум уходит и вы получаете отличный музыкальный сигнал.
Писать код — это тоже самое, короткое выражение ведет к лучшему пониманию. Какой-то код несет полезную информацию, а какой-то просто занимает место. Если вы сможете уменьшить объем кода, не уменьшая его значение, которое передается, то облегчите анализ и понимание кода для других людей, которым нужно его прочитать.
Место для ошибок
Взгляните на функции до и после. Похоже, что эта функция пошла на диету и похудела. Это важно, потому что дополнительный код создает дополнительное место для ошибок, что означает больше ошибок будут скрываться в нем.
Меньше кода = меньше места для ошибок = меньше ошибок
Компонуем объекты
“Предпочитайте композицию наследованию класса” — «Банда Четырех», «Приёмы объектно-ориентированного проектирования. Паттерны проектирования» (прим. пер. the Gang of Four, “Design Patterns: Elements of Reusable Object Oriented Software”)
“В информатике составные или сложные типы данных — это тип, который может быть получен в программе, используя примитивные типы языка программирования и другие составные типы. […] Построение составного типа является композицией.”
А это составной тип
Кроме того, все массивы, Sets, Maps, WeakMaps, TypedArrays и т. д. являются составными типами данных. Всякий раз когда вы создаете любую структуру данных, не являющейся примитивом, вы производите композицию в каком-то роде.
Обратите внимание, что “Банда Четырех” определяет паттерн, называемый “Компоновщик”, который является особым типом рекурсивной композиции объектов, что позволяет одинаково обрабатывать отдельные компоненты и агрегированные составные типы. Некоторые разработчики путаются, думая, что паттерн компоновщик единственное определение композиции объектов. Не дайте себя запутать, существует множество различных типов композиции объектов.
“Банда Четырех” продолжает, — “вы увидите как композиция объектов используется снова и снова в паттернах проектирования”, и затем они показывают три вида связей между скомпонованным объектами, в которые включены делегирование (используется в паттернах состояние, стратегия и посетитель), осведомленность (когда объекту известно о другом объекте посредством ссылки, обычно переданной как параметр: Uses-A отношение, например в обработчик запроса можно передать ссылку на логгер, который будет выводить запрос — запрос использует логгер), и агрегирование (когда дочерние объекты являются частью родительского: Has-a отношение, например дочерние DOM элементы являются составными частями DOM-узла — у DOM-узла имеются дети).
Наследование классов может использоваться для создания составных объектов, но это ограниченный и хрупкий способ делать это. Когда Банда Четырех говорит “преподчитайте композицию объектов наследованию”, они советуют вам использовать гибкие подходы для построения составных объектов, а не жесткий, тесно связанный подход наследования классов.
Мы будем использовать более общее определение композиции объектов из книги “Categorical Methods in Computer Science: With Aspects from Topology” (1989):
Составные объекты формируются путем объединения объектов таким образом, что каждый из них является “частью” первого.
Еще одна хорошая отсылка — “Reliable Software Through Composite Design”, Glenford J Myers, 1975. Обе книги давно вышли из печати, но вы все еще можете найти продавцов на Amazon или eBay, если вы хотите глубже изучить тему композиции объектов с технической точки зрения.
Наследование классов — это только один из видов построения составного объекта. Все классы дают в результате составные объекты, но не все сложные объекты созданы классами или наследованием классов. “Предпочитайте композицию объектов наследованию” означает, что следует создавать составные объекты из мелких частей, а не наследовать все свойства от предка в иерархии классов. Последнее вызывает большое разнообразие известных проблем в объектно-ориентированном проектировании:
Наиболее распространенная форма объектной композиции в JavaScript известна как объединение объектов (или примесь). Это работает как мороженное, вы начинаете с объекта (допустим ванильное мороженное), и затем подмешиваете дополнительные функции, которые хотите. Добавьте орехи, карамель, шоколад, и в итоге получите орехово-карамельно-шоколадное мороженое.
Создание составных объектов через наследование классов:
Создание составных объектов через примеси:
Позже мы рассмотрим другие виды композиции объектов более подробно. На данный момент вы можете понять, что:
Заключение
Эта статья не о функциональном программировании (ФП) против объектно-ориентированного (ООП), или один язык против другого. Компонентами могут быть функции, структуры данных, классы и т.д. Различные языки программирования, как правило, предоставляют разные базовые элементы для компонентов — Java предлагает классы, Haskell предлагает функции и т.д. Но независимо от того, какой язык и какую парадигму вы предпочитаете, вам никуда не деться от составления функций и структур данных. В конце концов, это то, к чему все сводится.
Мы будем много говорить о функциональном программировании, потому что функции в JavaScript очень просто компонуются, и сообщество функционального программирования вложило много времени и усилий для формирования техник композиции функций.
Чего мы не будем делать, так это говорить, что функциональное программирование лучше объектно-ориентированного программирования, или что вы должны выбрать одно вместо другого. ООП против ФП — неправильное противопоставление. Каждое реальное приложение на Javascript, которое я видел в последние годы, активно смешивает ФП и ООП.
Мы будем использовать композицию объектов, чтобы создавать типы данных для функционального программирования, а функциональное программирование — чтобы создавать объекты для ООП.
Независимо от того как вы пишите программное обеспечение, вы должно хорошо составить его.
Суть разработки программного обеспечения — это композиция.
Разработчик, который не понимает композицию, похож на строителя дома, который не знает о болтах или гвоздях. Построение программы без знания композиции похоже на устаноку стен с помощью клейкой ленты и клея.
Пришло время упростить и лучший для этого способ — добраться до сути. Проблема в том, что почти никто в отрасли не имеет хорошего знания сути. Мы, как отрасль, подвели вас, разработчиков. Наша обязанность как отрасли — это лучше обучать разработчиков. Мы должны совершенствоваться, должны взять на себя ответственность. На программном обеспечении работает все в мире, от экономики до медицинского оборудования. На этой планете нет буквально ни одного уголка, где обитает человек, на который бы не повлияло бы качество наших программ. Нам нужно осознавать, что мы делаем.
Теперь время изучить как составлять программы.
Узнайте больше на EricElliottJS.com
Видеоуроки по композиции функций и объектов доступны для пользоваетелей EricElliottJS.com. Если вы не еще не являетесь им, регистрируйтесь сегодня
Он работает удаленно из любой точки мира с самой красивой женщиной в мире.



