1. Введение
§ 1. Императивное и декларативное программирование. Во введении мы попытаемся дать концептуальное определение объектно-ориентированной парадигмы программирования.
Начнем с определения термина «парадигма». С некоторой точки зрения разработка программного обеспечения – это всегда процесс структурирования и формализации неструктурированных и неформализованных знаний. В конечном счете при разработке программы мы стремимся что-то «объяснить» вычислительной машине, понимающей только некоторый формальный язык. Так, в ходе анализа требований нечеткие цели, задачи, идеи, пожелания пользователей (заказчиков) уточняются и структурируются в технические задания (требования, проекты и другие документы), содержащие относительно внятные цели, сценарии использования, структурированное описание предметной области, упорядоченные функциональные требования и тому подобные сведения. В ходе проектирования и программирования более или менее структурированная задача транслируется программистами с естественного языка на формальный (в математическом смысле) язык программирования1. Естественный внутренний язык мышления человека может принципиально отличаться от языка программирования, соответственно, в ходе этой трансляции может полностью измениться способ описания задачи. Насколько разными при этом могут быть типы языков программирования?
В самой общей классификации таких типов, называемых парадигмами программирования, может быть два. Рассмотрим их на примере следующей задачи. Имеется массив чисел – показаний датчика давления. Требуется найти среднее арифметическое отклонений от граничной величины 50 для всех значений, превышающих эту величину. Это вполне четкая формулировка задачи на естественном языке. Рассмотрим следующее решение на языке программирования C#:
// Объявляем и инициализируем массив вещественных чисел,
// хранящий показания датчика.
float[] data = new float[] { 35, 52, 44, 58, 59, 38, 33 };
// Граничная величина, по условию задачи равна 50.
float threshold = 50;
// Переменная для хранения суммы отклонений показаний граничной величины,
// для показаний, ее превышающих. Начальное значение – 0.
float sum = 0;
// Количество показаний, превышающих граничную величину.
// Начальное значение – 0.
int count = 0;
// Перебираем все показания.
for (int i = 0; i < data.Length; i++)
{
// Если i-е показание превышает граничную величину...
if (data[i] > threshold)
{
// ...сохраняем отклонение в сумму.
sum += data[i] – threshold;
// ...и увеличиваем счетчик отклонений.
count++;
}
}
// Искомое среднее арифметическое отклонений.
float avg = sum / count;
Читателю, знакомому с основами программирования, этот код, конечно, покажется вполне типичным. Однако рассмотрим другой вариант решения той же задачи:
float[] data = new float[] { 35, 52, 44, 58, 59, 38, 33 };
float threshold = 50;
float avg = data
// Выбрать только те элементы i2 последовательности,
// которые удовлетворяют условию i > threshold
.Where (i => i > threshold)
// Вместо каждого элемента i получить значение,
// рассчитанное по формуле i – threshold.
.Select(i => i - threshold)
// Получить среднее значение элементов последовательности.
.Avg();
В чем принципиальная разницам между эти двумя решениями? В первом случае мы пошагово описываем алгоритм, то есть инструктируем компьютер, как достичь результата. Во втором же случае мы описываем сам результат, то есть что нужно достичь, но не описываем, как это сделать. Мы пишем: найди среднее значение (avg) отклонений (i - threshold) для элементов, удовлетворяющих заданному условию (i > threshold). При этом последовательность выполнения действий может быть другой: компилятор сам решит, как именно лучше получить искомое значение. Например, вероятно, он не будет обходить последовательность три раза (один раз – чтобы отфильтровать ненужные, второй – чтобы найти отклонения, третий – чтобы посчитать среднее значение), а так или иначе совместит эти задачи. Можно сказать, что использованные методы Where, Select, Avg являются не инструкциями, а требованиями3.
Приведенные примеры – это примеры императивного и декларативного программирования соответственно.
Императивное программирование – программирование вычислительной машины путем описания последовательности инструкций, выполняемых одна за другой; программист однозначно определяет последовательность шагов, которые необходимо выполнить для достижения результата. Соответствующие языки, программы на которых представляют собой последовательность инструкций, называются императивными языками программирования.
Декларативное программирование – программирование вычислительной машины путем описания искомого результата; программист определяет только требования, которым должен удовлетворять результат, но не последовательность шагов для его достижения. Соответствующие языки, программы на которых представляют собой описание требований к результату, называются декларативными языками программирования.
Конечно, мы понимаем, что декларативный код в итоге преобразуется в императивный, так как архитектура современных вычислительных систем по сути императивна. Но вопрос в том, что это преобразование выполняется не программистом, а компилятором (или средой исполнения, или СУБД, или каким-либо другим механизмом). Более того, многие современные императивные языки поддерживают некоторые возможности декларативных языков. Так, оба рассмотренных выше примера написаны на C#.
Тип языка диктует способ мышления о программе. Процитируем главного разработчика языка C# [17]: «Язык влияет на то, как мы думаем. Работа программиста состоит, если хотите, в том, чтобы думать. Это сырье, чистая энергия, участвующие в процессе. Язык – это то, что определяет ваше мышление; его задача – помочь вам думать продуктивным образом. Например, языки с поддержкой объектов заставляют вас думать над задачей так. Функциональные языки заставляют вас думать над задачей иначе. Динамические языки заставят вас думать еще как-то. Выбрав язык, вы выбираете особый способ мышления.» Язык программирования определяет не только процесс программирования в узком смысле как написание кода (coding), но также обуславливает подходы к проектированию и анализу системы. Именно в этом смысле обычно используют термин «парадигма»:
Парадигма программирования4 (в широком смысле слова) – совокупность используемых при разработке программного обеспечения подходов к анализу, проектированию и программированию, обусловленных типом используемого языка программирования (императивного или декларативного).
Мы намеренно подчеркнули, что деление языков на императивные и декларативные определяют парадигму программирования в широком смысле слова. Возможно ли выделить еще какие-либо парадигмы? В силу некоторой нечеткости термина иногда к базовым парадигмам относят и функциональное программирование, однако мы будем придерживаться распространенной классификации, выделяющей только два типа. Также отметим, что сегодня как альтернативную, «неклассическую» парадигму часто определяют машинное обучение. «В классическом программировании… люди вводят правила (программу) и данные для обработки в соответствии с этими правилами и получают ответы. В машинном обучении люди вводят данные и ответы, соответствующие этим данным, а на выходе получают правила. Эти правила затем можно применить к новым данным для получения оригинальных ответов. В машинном обучении система обучается, а не программируется явно. Ей передаются многочисленные примеры, имеющие отношение к решаемой задаче, а она находит в этих примерах статистическую структуру, которая позволяет системе выработать правила для автоматического решения задачи.»5
Однако вернемся к классическому программированию, которое мы будем рассматривать в настоящей книге. В более узком смысле выделяют парадигмы программирования на основе более частных классификаций типов языков. Так, в рамках императивной парадигмы выделяют три базовых типа: (1) структурное, или процедурное6, (2) объектно-ориентированное и (3) функциональное [Мартин 11]. Далее мы рассмотрим понятие объектно-ориентированного программирования в сравнении с, вероятно, известным читателю процедурным программированием.
§ 2. Алгоритмическая декомпозиция. В процедурном программировании мы представляем программу как иерархию процедур, то есть алгоритмов. Таким образом, при решении задач, мы выполняем алгоритмическую декомпозицию, а основная используемая абстракция, то есть элемент языка, с которым работает программист – алгоритм. Такое структурирование кода диктует архитектуру приложений и подходы к проектированию и анализу. При этом, конечно, процедурное программирование является императивным. Рассмотрим пример: положим, мы создаем систему мониторинга автотранспорта, отображающую на экране карту с текущим положением автобусов. Определим какие процедуры (алгоритмы) нам следует реализовать для решения задачи. Разобьем их на три группы: относящиеся к автобусу, относящиеся к маршруту и относящиеся к карте.
Во-первых, основной алгоритм: (1) «перерисовать положение автобуса на карте с указанием гос. номера, номера маршрута, уровня топлива, вместимости и статуса положения на маршруте («на маршруте» или «сошел с маршрута»)». Он относится к автобусу. Чтобы его реализовать нам потребуется несколько вспомогательных процедур: (2) «определить гос. номер», (3) «определить местоположение», (4) «определить уровень топлива», (5) «определить вместимость», (6) «определить номер маршрута». Все эти вспомогательные алгоритмы будут обращаться к базе данных и получать соответствующую информацию.
Во-вторых, чтобы определить, находиться ли автобус на маршруте или нет, нам потребуется два алгоритма, относящихся к маршруту: (7) «определить координаты границ маршрута (многоугольника)», (8) «определить, находится ли точка с указанными координатами внутри указанного многоугольника (маршрута)».
Наконец, нам потребуются алгоритмы рисования, относящиеся к карте: (9) «нарисовать точку заданного цвета и размера», (10) «добавить на карту заданный текст заданного цвета и размера» (для подписи точки-автобуса).
На следующей диаграмме показано, как перечисленные алгоритмы группируются в иерархию, в которой каждый алгоритм имеет один или несколько подалгоритмов:
При написании кода эта модель преобразуется в иерархию вызовов процедур в коде – каждому блоку соответствует процедура, использующая (вызывающая) процедуры, соответствующие подчиненным блокам7.
Для запуска программы мы вызываем корневой метод для некоторого автобуса:
int busId = 123;
DrawBus (busId);
Мы сейчас разбираем развернутый пример процедурного решения задачи, чтобы далее сопоставить его с объектно-ориентированным решением той же задачи. Попутно укажем, что в C# процедуры называются методами, подробнее вопрос терминологии будет рассматриваться в последующих главах.
Обратим внимание, что, давая краткое описание процедурного программирования в начале параграфа, мы указали три характеристики: 1) используемые способы декомпозиции (алгоритмическая), 2) используемые абстракции (процедуры-алгоритмы) и 3) используемые иерархии, упорядочивающие эти абстракции (иерархия вызовов процедур). Дело в том, что декомпозиция, абстракция и иерархия – базовые мыслительные операции на пути от неструктурированных и неформализованных к структурированным и формализованным знаниям. Человек использует их не только в программировании, но и вообще при решении сложных задач: разделяет задачу на части (декомпозиция), выделяет только существенные аспекты (абстракция) и структурирует элементы в иерархии. Читатель может найти подробный анализ этих вопросов применительно к разработке программного обеспечения к книге [Буч 3], где этому посвящен отдельный раздел. Можно сказать, что именно используемые виды этих операций определяют подходы и к кодированию, и к проектированию, и к анализу, то есть определяют парадигму программирования. Дадим следующее уточненное определение:
Парадигма программирования – это совокупность используемых при разработке программного обеспечения способов декомпозиции, видов абстракций и видов иерархий, упорядочивающих используемые абстракции; или: совокупность используемых при разработке программного обеспечения подходов к анализу, проектированию и программированию, обусловленных способами декомпозиции, видами абстракций и видами иерархий, упорядочивающих используемые абстракции.
Такую формулировку вполне можно интерпретировать как частный случай предыдущей. Так, в императивном программировании используются абстракции – инструкции, в декларативном – требования.
§ 3. Объектная декомпозиция. Еще в 70-х годах отмечалось, что основное концептуальное ограничение процедурного программирования – семантический (смысловой) разрыв между моделью, поддерживаемой языком, и моделью в голове у программиста. Хотя мы и говорим, что язык обуславливает мышление, однако разные языки могут в разной мере соответствовать естественному процессу мышления. В отличие от процедурного подхода основная идея объектно-ориентированного программирования заключается в представлении программы как набора взаимодействующих объектов подобно тому, как мы воспринимаем окружающий мир. Мы видим вокруг себя людей, здания, автомобили, растения – тысячи различных объектов. Каждый из объектов состоит из других объектов (иерархия «часть – целое»). В то же время каждый из объектов относится к некоторому классу (виду, типу) объектов (иерархия «частное – общее»). Сколь угодно сложный объект может быть физически или мысленно разделен на множество более простых. Таким образом, в объектной модели используется: 1) декомпозиция – объектная; 2) абстракция – объект и класс объектов; 3) иерархия – «часть – целое» и «частное – общее».
Покажем это на рассмотренном в предыдущем параграфе примере системы мониторинга автотранспорта. Вместо того, чтобы выполнять алгоритмическую декомпозицию, определяя необходимые нам процедуры, выполним объектную декомпозицию. Во-первых, это, конечно, автобус. Объект «автобус» должен «уметь» перерисовать себя на объекте «карта», что соответствует процедуре 1, выделенной нами в ходе алгоритмической декомпозиции. Также автобус должен уметь определить номер своего маршрута (соответствует процедуре 6). Должен ли автобус уметь определить свою вместимость (процедура 5)? С одной стороны – да. Но мы можем развить объектную модель, указав, что автобус – это разновидность пассажирского транспортного средства (ТС), то есть автобус – «частное» по отношению к пассажирскому ТС в иерархии «частное – общее». Как любое частное, автобус «умеет» все то, что умеет «общее». Поэтому мы можем сказать, что пассажирское ТС умеет определять вместимость (процедура 5), а автобус тоже это умеет, так как он является разновидностью пассажирского ТС. Рассмотрим следующую диаграмму:
Блоки диаграммы показывают классы объектов. Мы пока говорим о «классах» в обычном «не программистском» значении как о выделенных в результате классификации группах однотипных объектов. Блок «Автобус» обозначает класс автобусов, каждый из объектов-автобусов умеет себе перерисовывать и определять номер маршрута. Стрелка в виде не закрашенного треугольника обозначает, что класс объектов «Автобус» является разновидностью класса объектов «Пассажирское ТС», что есть каждый объект-автобус – разновидность объекта-пассажирское ТС и, значит, каждый автобус умеет все то, что умеет пассажирское ТС, здесь: умеет определять свою вместимость.
В последующих разделах мы будем детально разбирать и вопросы иерархии, и используемые на диаграмме условные обозначения. Сейчас мы хотим только обозначить общую идею, поэтому не столь важно, если какие-то детали останутся не вполне понятыми.
Итак, продолжим построение нашей объектной модели. Если вместимость – характеристика пассажирских ТС, то уровень топлива – характеристика любых ТС. Но характеристика ли это именно ТС? Мы можем еще более детализировать модель, указав, что транспортное средство включает в себя как составную часть топливный бак, а топливный бак включает в себя как составную часть датчик уровня. И уже датчик уровня умеет определять уровень. Покажем это на диаграмме:
Стрелка в виде закрашенного ромба обозначает иерархическое отношение «часть – целое». Отметим, что у нас на диаграмме три промежуточных блока между классом «Автобус» и классом «Датчик уровня», однако датчик уровня – часть автобуса. Покажем это на модели: автобус – разновидность пассажирского ТС, пассажирское ТС – разновидность ТС, ТС включает в себя топливный бак, топливный бак включает в себя датчик давления. Значит автобус также включает в себя и топливный бак, и датчик давления. Эта диаграмма отражает вполне естественные для мышления человека отношения между объектами предметной области.
На следующем рисунке представлена полная диаграмма объектной декомпозиции решаемой задачи:
Обычными стрелками на диаграмме мы показываем отношение зависимости. То есть любой автобус зависит от маршрута в том смысле, что он использует объекты-маршруты для выполнения тех или иных задач (здесь – для выполнения метода перерисовки), при этом маршрут не является частью автобуса, а автобус не является разновидностью маршрута. Они только взаимодействуют, как могут взаимодействовать различные объекты реального мира (предметной области).
Код перерисовки автобуса выглядит следующим образом8:
int busId = 123;
// Создаем в программе объект bus. bus – переменная, тип данных которой – Bus.
Bus bus = new Bus (123);
// «Говорим» этому объему, чтобы он перерисовал себя.
bus.Draw();
Что нам дает такая декомпозиция по сравнению с алгоритмической? Ведь обратим внимание, что все процедуры, которые мы выделили при алгоритмической декомпозиции присутствуют и на объектной модели (их номера указаны в скобках). Дело в том, что код программы в объектно-ориентированном программировании оказывается ближе к естественному представлению о предметной области в мышлении разработчика. Это упрощает понимание программы. Проще – значит лучше (быстрее, надежнее, качественнее, дешевле). Это безусловный тезис в сфере разработки программного обеспечения.
Обозначим две базовых идеи, лежащие в основе объектно-ориентированного программирования: 1) абстрактные типы данных и 2) иерархические типы данных.
Абстрактные типы данных – определенные программистом типы данных (в отличие от встроенных типов данных языка, например, int, float), представляющие (моделирующие в коде) некоторую абстракцию. Такие типы позволяют отвлечься от ненужных для решаемой задачи частностей и писать код с использованием языка предметной области. Например, автобус в нашем коде – абстракция реального автобуса. Реальный автобус имеет значительно более сложную структуру и обладает большими возможностями, но для нашей задачи нужно только то, что мы выделили. Таким образом, мы, во-первых, абстрагируемся от ненужных деталей предметной области, создавая при этом модель на языке этой предметной области. Во-вторых, мы абстрагируемся от реализации автобуса и его возможностей в коде. Это значит, что код, вызывающий методы автобуса, ничего не знает, о том, как автобус реализован, в каких типах данных он хранит те или иные характеристики. Все эти детали коду, вызывающему перерисовку, не нужны.
Вторая базовая идея ООП – иерархические типы. Это определенные программистом типы данных, расширяющие возможности других типов данных. В нашем примере автобус – частный случай транспортного средства. Иерархические типы позволяют отвлечься от деталей частных классов, работая с общим поведением и, наоборот, не дублировать общее поведение, работая с частным, полагаясь на класс, являющийся общим. Так, разрабатывая код для класса «датчик давления» мы можем не думать о том, что этот датчик давления топливного бака именно автобуса. Мы разрабатываем отдельный датчик давления, который сможем использовать и в других транспортных средствах. С другой стороны, разрабатывая код автобуса, мы может не отвлекаться на возможности, реализуемые пассажирским ТС, ТС и датчиками – все это доступно в виде готовых абстракций, реализация которых от автобуса также скрыта. В обоих случая мы работаем только над своей текущей задачей, не отвлекаясь на ненужные частности и зависимости.
Конечно, приведенное описание понятий абстрактных и иерархических типов не претендует на точность и полноту. Каждую из этих идей мы будем подробно рассматривать в соответствующем разделе. Однако, интуитивно понятно, что обе идеи могут существенно упрощать код. Соединение концепции абстрактных типов данных и иерархических типов данных в объектно-ориентированное программирование сформулировано еще на заре развития информационных технологий, «обе концепции действительно являются достижением в искусстве программирования» [Брукс 2].
Приведем классическое определение ООП [Буч 3]:
Объектно-ориентированное программирование (object-oriented programming) – парадигма программирования, основанная на представлении программы в виде совокупности объектов, каждый из которых является экземпляром класса, а классы образуют иерархию наследования.
По сути это определение формулирует три уже рассмотренных нами характеристики парадигмы программирования: 1) способ декомпозиции (здесь – объектная); 2) виды абстракций (объекты и классы); 3) виды иерархии («часть – целое» и «частное – общее»).
Мы рассмотрели концептуальное определение ООП. С технической точки зрения объектно-ориентированное программирование – набор определенных возможностей языка: классы, наследование, виртуальные и абстрактные методы, полиморфизм и другие. Их детальному обсуждение посвящены последующие разделы.
Вопросы и задания
Дайте определение следующим терминам: парадигма программирования, императивное программирование, декларативное программирование, декомпозиция, абстракция, иерархия, процедурное программирование, структурное программирование.
Найдите несколько разных определений объектно-ориентированного программирования и попытайтесь свести их к приведенному в тексте.
Вспомните языки программирования, с которыми вы сталкивались. Они являются императивными или декларативными?
** Вспомните какую-либо задачу, которую вы недавно решали, и попытайтесь выполнить ее объектную декомпозицию.
Примечания:
1. При этом необходимо понимать, что последовательность «анализ – проектирование – программирование» – это не описание организации разработки. Мы лишь обозначили условную классификацию видов процессов. На практике все они встречаются на любом этапе создания программного обеспечения.
2. Здесь i – сокращение от item (элемент), а не от index (индекс).
3. Читатель, знакомый с SQL, увидит, что приведенный код похож на SQL-запрос select avg (i – 50) from data where i > 50. Действительно, SQL также реализует подход к программированию, основанный на описании требований к результату, а не способа достижения этого результата. Для сравнения: если бы мы решали задачу выборки данных в терминах инструкций, то вместо приведенного запроса, нам бы пришлось описывать алгоритм построчного выбора данных из таблицы, проверки значений, вычисления средних значений, а также работать с индексами, страницами памяти и т. п. Используя же SQL мы возлагаем все эти задачи на СУБД, концентрируясь на сути решаемой проблемы: найти такие-то данные, удовлетворяющие таким-то условиями.
4. Также, часто как синонимы «парадигме программирования» используются какие термины как подход (approach) или стиль.
5. Шолле, Ф. Глубокое обучение на Python.
6. Иногда между этими терминами проводят различия, однако мы будем использовать их как синонимы. Обычно, говоря о процедурном программировании, акцентируют внимание на идее процедуры (метода, функции) и, соответственно, на идее алгоритмической декомпозиции. Говоря о структурном программировании, акцентируют внимание на запрете использования оператора goto (перехода к любой указанной строке кода). Запрет на использование этого оператора как следствие приводит к структурированию программы с использованием условных выражений, циклов и процедур [Мартин 11].
7. Подчеркнем (повторно), что речь не идет об организации процесса разработки: говорить, что сначала составляется диаграмма, подобная приведенной, а потом программист по ней пишет код – огромное упрощение. Однако, концептуально происходит именно так: иерархия алгоритмов в мысленном представлении (более ими менее полном, явном и формализованном) воплощается в иерархии процедур в коде.
8. Как и для алгоритмической декомпозиции, подчеркнем (в третий раз), что речь не идет об организации процесса разработки: говорить, что сначала составляется диаграмма, подобная приведенной, а потом программист по ней пишет код – огромное упрощение.