Что такое композиция в программировании
Композиция
Еще одной особенностью объектно-ориентированного программирования является возможность реализовывать так называемый композиционный подход. Заключается он в том, что есть класс-контейнер, он же агрегатор, который включает в себя вызовы других классов. В результате получается, что при создании объекта класса-контейнера, также создаются объекты других классов.
Чтобы понять, зачем нужна композиция в программировании, проведем аналогию с реальным миром. Большинство биологических и технических объектов состоят из более простых частей, также являющихся объектами. Например, животное состоит из различный органов (сердце, желудок), компьютер — из различного «железа» (процессор, память).
Не следует путать композицию с наследованием, в том числе множественным. Наследование предполагает принадлежность к какой-то общности (похожесть), а композиция — формирование целого из частей. Наследуются атрибуты, т. е. возможности, другого класса, при этом объектов непосредственно родительского класса не создается. При композиции же класс-агрегатор создает объекты других классов.
Рассмотрим на примере реализацию композиции в Python. Пусть, требуется написать программу, которая вычисляет площадь обоев для оклеивания помещения. При этом окна, двери, пол и потолок оклеивать не надо.
Прежде, чем писать программу, займемся объектно-ориентированным проектированием. То есть разберемся, что к чему. Комната – это прямоугольный параллелепипед, состоящий из шести прямоугольников. Его площадь представляет собой сумму площадей составляющих его прямоугольников. Площадь прямоугольника равна произведению его длины на ширину.
По условию задачи обои клеятся только на стены, следовательно площади верхнего и нижнего прямоугольников нам не нужны. Из рисунка видно, что площадь одной стены равна xz, второй – уz. Противоположные прямоугольники равны, значит общая площадь четырех прямоугольников равна S = 2xz + 2уz = 2z(x+y). Потом из этой площади надо будет вычесть общую площадь дверей и окон, поскольку они не оклеиваются.
Можно выделить три типа объектов – окна, двери и комнаты. Получается три класса. Окна и двери являются частями комнаты, поэтому пусть они входят в состав объекта-помещения.
Для данной задачи существенное значение имеют только два свойства – длина и ширина. Поэтому классы «окна» и «двери» можно объединить в один. Если бы были важны другие свойства (например, толщина стекла, материал двери), то следовало бы для окон создать один класс, а для дверей – другой. Пока обойдемся одним, и все что нам нужно от него – площадь объекта:
Класс «комната» – это класс-контейнер для окон и дверей. Он должен содержать вызовы класса «окно_дверь».
Хотя помещение не может быть совсем без окон и дверей, но может быть чуланом, дверь которого также оклеивается обоями. Поэтому имеет смысл в конструктор класса вынести только размеры самого помещения, без учета элементов «дизайна», а последние добавлять вызовом специально предназначенного для этого метода, который будет добавлять объекты-компоненты в список.
Практическая работа
Приведенная выше программа имеет ряд недочетов и недоработок. Требуется исправить и доработать, согласно следующему плану.
При вычислении оклеиваемой поверхности мы не «портим» поле self.square. В нем так и остается полная площадь стен. Ведь она может понадобиться, если состав списка wd изменится, и придется заново вычислять оклеиваемую площадь.
Однако в классе не предусмотрено сохранение длин сторон, хотя они тоже могут понадобиться. Например, если потребуется изменить одну из величин у уже существующего объекта. Площадь же помещения всегда можно вычислить, если хранить исходные параметры. Поэтому сохранять саму площадь в поле не обязательно.
Исправьте код так, чтобы у объектов Room были только четыре поля – width, lenght, height и wd. Площади (полная и оклеиваемая) должны вычислять лишь при необходимости путем вызова методов.
Программа вычисляет площадь под оклейку, но ничего не говорит о том, сколько потребуется рулонов обоев. Добавьте метод, который принимает в качестве аргументов длину и ширину одного рулона, а возвращает количество необходимых, исходя из оклеиваемой площади.
Разработайте интерфейс программы. Пусть она запрашивает у пользователя данные и выдает ему площадь оклеиваемой поверхности и количество необходимых рулонов.
Курс с примерами решений практических работ:
android-приложение, pdf-версия
С. Шапошникова © 2021
Объектно-ориентированное программирование на Python
Языки программирования. Композиционный взгляд
Здравствуй, Хабр! Сегодня хотел бы поднять вопрос об использовании композиций и их роли в программировании. Те, кто сталкивался с функциональными языками, скорее всего слышали о них, а те, кто не сталкивался, возможно, узнают что-нибудь новое. Надеюсь на интересную дискуссию в конце статьи об их применении. Эшер для привлечения внимания.
Начнем с понятия программы. С математической точки зрения программа — это функция. Иногда это может показаться не очевидным. Если программа — функция, тогда следует решить что есть ее аргументами и что она возвращает. В случае с простыми утилитами вроде bc всё понятно: аргументом выражение из stdin, а результатом — результат в stdout. В более сложных случаях тоже можно прийти к тому, что программа — это функция. Рассмотрим, пример, СУБД, в этом случае программа тоже функция, аргументом она принимает память, выделенную ей в user-space, ее же и возвращает, при этом непрерывно выполняясь. Может, довольно неоптимальный пример, но он доступен для понимания.
Если программа — функция, то следует рассмотреть, какие средства получения этой функции могут быть. Тут уже всё зависит от языков программирования, именно они являются такими средствами. И независимо от того функциональный это язык, процедурный, императивный, декларативный и т.д., всем им свойственно использование композиций, явное или неявное. Пришло время определить понятие композиции. Пусть даны две функции и , из них можно построить функцию применив к ним функцию : . Такая функция F, которая принимает как аргумент другие функции и будет называться композицией. А способов построения может быть множество, каждый способ — композиция. Рассмотрим пару примеров композиций: композиция ветвления и композиция суперпозиции.
Композиция ветвления. Пусть заданы две функции и , а также один предикат (та же функция, но возвращающая не элемент множества, на котором определена, а значения «истина» либо «ложь»). Существует такая функция , которая . В таком случае говорят, что функция получается операцией ветвления, обозначим эту операцию как , тогда . Применив композицию к функциям, можно получить новую функцию, которую далее использовать.
Композиция суперпозиции. Пусть задана функция , где — -арная функция и существует такая функция , которая определяется как . В этом случае будет считаться полученной с помощью операции суперпозиции : .
Рассмотрим же прообразы этих операций в языках программирования. Где-то они более явные, где-то менее. В Языке Си операция ветвления, очевидно, сокрыта в управляющей структуре : либо тернарном операторе: В первом случае был рассмотрен statement, а не expression, но это ведь не мешает представить как, например, все множество переменных в области видимости. В LISP прообраз данной композиции выглядел бы так: А в Haskell это выглядит как либо
Если говорить об операции суперпозиции, то она еще менее заметна во многих языках программирования, но тем не менее, вездесуща. Операция суперпозиции в языке Си позволяет сделать следующую подстановку На LISP это выглядело бы как На Haskell же как
Сразу следует отметить, что арность всех предложенных функций должна быть единой, иначе примение к ним механизма композиций может быть значительно затруднено, т.е. X в самом деле это что-то вроде .
Очевидно, что композиции глубоко «похоронены» в синтаксисе языков программирования, неявно присутствуя в них. Впрочем, ничего нового, как может верно заметить искушенный в этом деле читатель. Еще Эдсгар Дейкстра это заметил в своей книге «Дисциплина программирования».
Использование композиций
В связи с вышесказанным, появляется множество вопросов. В частности то, а почему композиции не используются явным образом? Ведь использование композиций явным образом могло бы принести много преимуществ. Особенно то, что станет возможным рассмотрение самого процесса программирования, а следовательно — оптимизация разработки сложных проектов. Ведь как сейчас программирует программист? Он делает это как художник, пишущий картину. Иными словами, конструкторские решения принимаются на основании его опыта, предчувствия, советов коллег, каких-то личных эстетических убеждений. Но этот ход размышления нельзя выложить просто так в файл вместе с программой. Т.е. программа есть, а хода мыслей, приведшего к ней, нет. Это первый вопрос. Почему у языков такой синтаксис как есть, почему создатели языка заложили именно такой набор управляющих структур? Почему их набор догматизирован? Что делать если необходимо добавить другие управляющие структуры.
Эти вопросы помогли бы решить средства применения и порождения композиций. Так как решение любой сколь угодно сложной задачи человеком сводится к анализу/декомпозиции, а потом синтезу, этот процесс может быть органично поддержан механизмом композиций. Ведь композиции будут именно тем самым связующим элементом, который будет склеивать части, на которые разбита задача. Ну а задачу разбивать так или иначе придется человеку, именно это его прерогатива, а не синтаксическое оформление задачи. К сожалению пока ни одного языка, который был бы заточен под понятие композиции, не замечено.
Для лучшего понимания рассмотрим пример с нахождением наибольшего общего делителя (НОД) с применением композиций. Вычислитель может находить остаток от деления (), осуществлять целочисленное деление (), вычислять предикат неравенства (), генерировать ноль (). Все функции определены на множестве натуральных чисел без нуля. Таким образом имеем следующие функции в предложенной алгебре = < , , , , >. Читатель может заметить еще одну функцию — , так называемую селекторную функцию, которая из аргументов возвращает без изменений -й, она необходима, так как мы работаем с -арными функциями. Кроме этого доступна композиция циклирования, назовем ее . Приведем ее определение. Пусть даны -арных функций , а также -арный предикат . Итерационно каждая функция выполняет вычисление одного из аргументов для следующей итерации, так вычисляет , вычисляет и т.д. Итерационный процесс продолжается до тех пор, пока значение предиката «истина». Аргументы операции циклирования передаются в следующем порядке . Значение , полученное на последней итерации будет значением функции, полученной в результате композиции. Кроме того доступна, описанная ранее, композиция суперпозиции. Тогда алгоритм вычисления НОД можно записать как . Схематически это будет выглядеть следующим образом:
Таким образом становится возможным формализовать сам алгоритм «языком математики», а значит становится возможным его исследование математическими средствами, что позволяет уменьшить долю необоснованных или несознательно принятых конструкторских решений из-за того, что человек думает в терминах того языка программирования, на котором пишет, вместо того, чтобы размышлять композициями, которые, собственно, и лежат в основе различных языков.
Сам композиционный подход к решению начали исследовать А.И. Мальцев (мальцевские алгебры), академик Глушков (алгоритмические алгебры). Но все они работают на уровне применения композиций, догматизируя набор композиций, с которым ведется работа. Важным улучшением композиционного подхода была бы возможность получать новые композиции из существующих, применив к ним некоторые мета-композиции. Тема интересная, не знаю чтобы кто-то ее затрагивал, но об этом в следующий раз. Надеюсь я вас не утомил.
Вышеизложенное — предмет моих исследований. Сразу сделаю ремарку, что композиционный подход не предлагается использовать для тривиальных вычислений, как тот же самый алгоритм НОД, или в проектах, где доля вычислений ничтожно мала. Хочется очень знать что считает сообщество. Имеет ли композиционный подход будущее? Хотели бы вы на практике применять композиции? Нужны ли вам для этого инструменты?
Композиция или наследование: как выбрать?
В начале.
… не было ни композиции, ни наследования, только код.
И был код неповоротливым, повторяющимся, нераздельным, несчастным, избыточным и измученным.
Основным инструментом для повторного использования кода была копипаста. Процедуры и функции были редкостью, подозрительными новомодными штучками. Вызов процедур был дорогим удовольствием. Части кода, отделенные от основной логики, вызывали недоумение!
Мрачные были времена.
И вот тут ООП взлетел. Было написано множество 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, без которого этот текст не смог бы появиться.