Статья
Версия для печати
Обсудить на форуме (4)
Философия ООП



2 Категории сложности, универсальности и гибкости


2.2 Категория универсальности

Под степенью универсальности программного обеспечения мы понимаем степень его применимости в различных вычислительных средах для решения различных задач.
Разработка первых программ для первых компьютеров обычно характеризуется латинским выражением "ad hoc", обозначающим в программировании полную привязку решения задачи к конкретной вычислительной машине (или, как теперь говорят, к конкретной платформе). Подобный метод разработки имеет свои положительные и отрицательные стороны. С одной стороны, программы, разработанные с помощью такого подхода, наиболее полно используют возможности машины (или платформы), для которых они были созданы, часто наиболее экономно расходуют вычислительные ресурсы. С другой стороны, в случае смены платформы приходится переписывать (нередко полностью) все созданные ранее решения, что весьма трудоёмко. Таким образом, "ad hoc" программирование обладает наименьшей степенью универсальности по сравнению с другими методами.
Со временем, по мере увеличения возможностей вычислительных машин требование о наиболее полном использовании возможностей машин или платформ для решения задач перешло из разряда необходимого в разряд желательного. С другой стороны, стало возможным требование о снижении трудозатрат на разработку программного обеспечения за счёт переносимости разработок между машинами и платформами, чему способствовали унификация машин, удачные архитектурные решения (например, проект IBM 360). Начало появляться программное обеспечение, работающее не оптимальным образом на каждой конкретной машине, зато могущее работать с минимальными модификациями на множестве различных машин.
Возможность создавать первые универсальные с точки зрения аппаратной платформы программы возникла с появлением языков программирования среднего уровня, в которых особенности устройства вычислительных машин (такие как адресация памяти, регистры процессора, системы машинных команд) были более-менее скрыты от программиста. Например, компиляторы языка Fortran были реализованы на самых разнообразных машинах, и вычислительные алгоритмы, запрограммированные на этом языке, требовали незначительных модификаций (а то и не требовали их вовсе) для исполнения на различных машинах.
Развитием идеи разработки независимого от машины программного кода явилась концепция виртуальных машин, ранее часто применявшаяся на аппаратном уровне, а в последнее время (например, платформа Java) на программном уровне. Виртуальные машины создаются для перевода некоторого общего для всех вычислительных сред языка в язык конкретной вычислительной среды.
Описанное выше - это лишь одна ипостась категории универсальности, связанная с универсальностью работы программного кода в разных вычислительных средах, это лишь универсальность программ по форме. Другой ипостасью является универсальность программ по содержанию - пригодность программы к решению широкого класса задач.
С точки зрения алгоритмического подхода к программированию, универсальность программы зависит от универсальности того алгоритма, который реализуется программой. Универсальные алгоритмы позволяют решать более широкие классы задач, и обычно это свойство алгоритма приобретается за счёт отказа от оптимизации алгоритма для каждого специфического подкласса задач.
Алгоритм обычно тесно связан со структурами данных. При программировании на старых языках часто возникала потребность дублировать одни и те же алгоритмы для различных структур данных. Например, алгоритм обхода элементов такой структуры данных, как дерево, требовал многократного программирования для каждого типа узлов дерева. Такой метод программирования нельзя признать удачным: во-первых, он крайне трудоёмок, во-вторых, каждую реализацию алгоритма в коде нужно независимо проверять на наличие ошибок, в-третьих, модификация алгоритма требует переписывания всех его реализаций в программном коде. Проблему решали путём отказа от строгой типизации структур данных - использовали нетипизированные указатели на хранящиеся в узлах структуры данные. Но и такой подход неудобен, потому что не может уберечь программиста от ошибок неправильной интерпретации данных, скрытых за нетипизированными указателями.
Развитие абстрактных типов данных в языках программирования привело к созданию параметризированных типов данных и целых программных модулей (например, в языке программирования Ada). При программировании алгоритмов разработчик может воспользоваться неопределённым в момент программирования типом данных, являющимся параметром модуля или структуры данных. Работать с параметром можно, лишь абстрагируясь от его содержания. Например, можно разработать модуль для операций над стеком неизвестных элементов. Модуль работы с неизвестными элементами и элементы конкретных типов для этого модуля между собой разделены "стеной" абстракции - хорошо инкапсулированы. Чтобы модуль стал полезным для использующего его программиста, необходимо выполнить специализацию модуля - назначить параметрам модуля конкретные типы данных. Тогда, взятый нами в качестве примера модуль для работы со стеком может превратиться в модуль работы со стеком, например, целых чисел или строк, или определённых пользователем структур данных. Компиляторы языков, поддерживающих подобные конструкции, обрабатывают специализированные модули как различные варианты исходного модуля, пытаясь применить все операции, выполняемые над данными типов-параметров или параметризированных типов к конкретным типам, являющимся значениями параметров. Если это удаётся сделать - специализация считается успешной, иначе компилятор сообщит о синтаксической ошибке. Например, операция деления двух переменных неизвестного типа-параметра успешно специализируется числовыми типами переменных (числа можно делить) и приведёт к синтаксической ошибке в случае попытки задать в качестве значения параметра строковый тип (операция деления строк обычно не определена).
Параметризация модулей и структур данных в целом решает проблему контроля типов при разработке программ, избавляет программиста от необходимости многократно программировать одни и те же алгоритмы. Такое решение не приводит к неуправляемому росту сложности программ. При разработке параметризированного модуля программист не может (или не должен) делать предположений о вариантах специализации модуля. При специализации модуля программист не может (или не должен) делать предположений об особенностях реализации предоставленного ему параметризированного модуля. Тем не менее, часто возникают задачи, когда такие предположения делать необходимо. Решить проблему можно введением иерархии последовательно уточняемых структур данных и модулей, где каждая новая структура или каждый новый модуль на пути от наиболее абстрактных к конкретным решениям содержат в себе частичную специализацию лежащих выше в иерархии решений. Однако такая организация кода часто связана с необходимостью задавать большое количество параметров. Также существует проблема эффективности решений. Наиболее универсальные алгоритмы обработки данных не являются наиболее эффективными в конкретных случаях. Часто требуется для конкретных специализаций типов данных использовать конкретные же варианты алгоритмов их обработки. Например, сортировку в случае структур данных с произвольным доступом к элементам эффективнее реализовывать при помощи алгоритмов Хоара или Шелла, однако эти алгоритмы невозможно эффективно использовать для структур данных с последовательным доступом к элементам.
В ООП существует свой способ выражения абстракций в языках программирования. Между классами может быть установлено отношения обобщения, при котором из нескольких классов, называемых потомками, общие части выделяются в отдельный класс, называемый предком. При этом потомки наследуют от предка целиком или частично его внутреннюю структуру данных и существующие реализации его методов. Благодаря механизму наследования программист избавляется от необходимости дублировать код алгоритмов, сокращая трудозатраты и повышая надёжность создаваемых решений. В ряде языков программирования встречается множественное наследование - общий потомок объединяет в себе свойства множества предков. Однако свойства наследования не ограничиваются автоматическим отнесением свойств классов-предков к классам-потомкам. Важно то, что вся цепочка наследуемых классов сохраняет свойство инкапсуляции своих внутренних структур и реализации своих методов в целом - скрыта от не входящих в эту цепочку классов. Нет необходимости предоставлять общий доступ к тем частям классов-предков, которые будут использоваться классами-потомками. Сохранение свойства инкапсуляции классов при вынесении общих частей в отдельные классы отсутствует в модульном подходе и не может быть реализовано при помощи механизма параметризации. Такое свойство объектных классов позволяет лучше управлять сложностью разработки при достижении некоторого уровня универсальности создаваемых решений, нежели это возможно в модульном программировании.
Механизм наследования в ООП тесно связан с ещё одним свойством - полиморфизмом типов и функций. И полиморфизм типов, и полиморфизм функций не ограничиваются рамками ООП. Полиморфизм типов хорошо реализован, например, в языке Ada: все типы данных выстроены в иерархию по уровням абстракции, и программист может, к примеру, на основе целочисленного типа данных определить собственный тип с более ограниченным интервалом значений, нежели в исходном типе. Полиморфизм процедур и функций известен под названием их "перегрузки": программист может реализовать несколько вариантов одной и той же функции с разными наборами параметров, типами параметров и возвращаемых функцией значений, либо определить альтернативные варианты реализации процедуры или функции в разных модулях. В ООП свойство полиморфизма выведено на качественно более высокий уровень. Поскольку классы объединяют в себе и данные и методы их обработки, постольку свойство полиморфизма классов объединяет в себе и полиморфизм типов, и полиморфизм функций. С точки зрения полиморфизма типов некоторый объект одновременно является экземпляром как своего собственного класса, так и всех предков этого класса любого уровня обобщения. Благодаря этому один и тот же объект можно использовать в работе различных по уровню абстракции решаемой задачи алгоритмов. Благодаря инкапсуляции особенностей объекта при разработке каждого алгоритма можно абстрагироваться от несущественных для него деталей класса объекта и не заботиться о конкретных классах объектов во время исполнения программы. Вместе инкапсуляция, наследование и полиморфизм классов позволяют программировать решения с такой же степенью абстракции, какая предоставляется механизмом параметризации модулей и структур данных. Однако полиморфизм классов позволяет удобно решить проблему эффективной специализации алгоритмов обработки абстрактных данных, что затруднительно сделать лишь при помощи механизма параметризации. Каждый класс-потомок может переопределять методы предка с учётом особенностей своей собственной реализации. При этом использование механизма виртуальных функций гарантирует то, что при работе программы будет вызван метод конкретного класса объекта, а не метод того класса, типом которого представлен конкретный объект в каком-либо месте программы. Таким способом в ООП разводятся понятия посылки сообщения объекту и вызова метода объекта. Некоторому объекту, каким бы типом он не был представлен в конкретной точке программы, посылается сообщение, обрабатывать которое будет одна из переопределяемых в иерархии наследования реализаций метода объекта. Выбор конкретной реализации происходит автоматически и зависит от конкретного класса объекта. Наиболее активно такой подход используется в интерпретируемых или динамически компилируемых нетипизированных языках объектно-ориентированного программирования.
Введение механизмов явного выражения абстракций в языках программирования позволило создавать программные решения нужной степени универсальности, значительно понизив трудозатраты в программировании и повысив качество работы программистов. Наиболее приспособлены к программированию решений разной степени абстрактности объектно-ориентированные языки программирования.


2.3 Категория гибкости

В настоящее время, во многом благодаря использованию ООП, накопились такие объёмы уже разработанных программных систем, библиотек, компонентов, из них собраны такие крупные и сложные программные комплексы, что во многих случаях ставить задачу разработки принципиально новых программных решений экономически нецелесообразно. Часто ставятся задачи приспособления имеющихся наработок к решению новых проблем, задачи адаптации имеющихся программных систем к решению дополнительных задач.
Классическая задача написания компьютерной программы превратилась в основном в задачу сборки и переделки имеющихся компонентов, готовых библиотечных модулей, объектов, функций, соединения вместе различных компонентов и сервисов для решения какой-либо прикладной проблемы.
Учитывая смену содержания задачи программирования, разработчику становится необходимым пересмотреть свои взгляды на качественные характеристики разрабатываемых им программных решений, уделить внимание гибкости программы.
Под гибкостью программного обеспечения понимается организация такой внутренней структуры программы, которая позволяет модифицировать программу с минимальными трудозатратами. Следует обратить внимание, что гибкость является не одномоментной, а постоянной характеристикой программного решения. Во-первых, гибкое решение обладает такой структурой, которая обеспечивает минимизацию трудозатрат при любом (в том числе заранее не известном) изменении программы. Во-вторых, всякое изменение программы должно проводиться таким образом, чтобы не уничтожить гибкости программы.
Гибкое программное решение является долговечным решением. Если в процессе модификации существующей программы разработчик понимает, что дешевле всё переписать "с нуля", нежели разбирать существующие решения, можно констатировать окончание жизненного цикла этой программы. Назовём такую программу морально устаревшей - не соответствующей новым требованиям к ней, возникшим в процессе эксплуатации. Отныне удовлетворить новые требования может лишь новое программное решение. Если же всякое новое требование к некой программной системе применимо с незначительными её преобразованиями, жизненный цикл такой программной системы продолжается, вложенные в неё усилия и труд её предыдущих разработчиков не пропадают даром.
Определяющими важность гибкости программного решения для разработчика являются условия эксплуатации этого решения, а также содержание потока новых требований к эксплуатируемому решению. Таким образом, если пишется "одноразовая" программа, не предполагающая многократных запусков, длительной эксплуатации, или если пишется очень специфическая программа, сильно привязанная к каким-либо аппаратным решениям, не предполагающая повторного использования её частей в других проектах, гибкость такой программы для разработчика не является важной характеристикой. Для программных систем, предназначенных для активной работы с пользователями-людьми, для массовых продуктов гибкость зачастую является столь важной характеристикой, что становится приоритетней даже вопросов алгоритмической эффективности, экономичности затрат вычислительных ресурсов.
Средством обеспечения гибкости программной системы является использование операций абстрагирования и применения (апплицирования) при синтезе и анализе проектных решений. Достаточно гибкой является структура программного решения, состоящая из тщательно разделенных на разных уровнях абстракции общих и специальных решений. Такие уровни образуют архитектуру программной системы. В подавляющем большинстве случаев речь идёт о функциональных уровнях архитектуры, однако их функциональность можно понимать двояко. В одном случае наблюдается подчинение неких подсистем общей для них надсистеме; в этом случае надсистема выступает в качестве регламентирующего программного элемента, определяющего перечень и функциональный состав своих подсистем. В другом случае наблюдается лишь инкапсуляция реализации возложенных на некую систему функций; такие системы обычно являются самостоятельными компонентами и сервисами или целыми библиотеками компонентов и сервисов, включаемыми в различные комплексы и выполняющими разнообразные функции. Фактически эти две точки зрения определяют два различных подхода к построению архитектур. Один из подходов является проектированием сверху вниз - от концептуальных проблем, разрешаемых на верхних уровнях абстракции с последовательным раскрытием содержания решений в более специальных и конкретных подсистемах. Другой подход является проектированием снизу вверх - на базе имеющихся компонентов создаются программные системы, решающие прикладные задачи. Оба подхода обладают различными преимуществами и недостатками с точки зрения гибкости получаемых решений.
Первый подход обладает тем преимуществом, что получаемое с его помощью содержание архитектурных уровней ограничено лишь самым необходимым для решаемых программной системой задач. Это положительно сказывается как на сроках разработки, так и на объёме полученного решения. Недостатком подхода является риск быстрой потери гибкости программного решения в силу преждевременного применения создаваемых программных элементов к конкретной текущей задаче, стоящей перед разработчиком.
У второго подхода достоинства и недостатки зеркально противоположны. Преимуществом является активное повторное использование ранее разработанного и отлаженного программного кода. При этом разработанный код в большой степени абстрагирован от решения конкретной задачи, стоящей перед разработчиком - универсален. Недостаток состоит в том, что часто разработчики компонентов перегружают их функциональными возможностями на все мыслимые и немыслимые случаи жизни, поскольку заранее не знают задач применения своих компонентов, что значительно увеличивает сроки разработки и стоимость компонентов с некоторым ущербом для качества предлагаемых решений.
Особо следует сказать о процедуре стандартизации программных библиотек, компонентов и методов их применения - того, что сейчас обычно называют технологиями программирования. Очевидное преимущество стандартизации во взаимозаменяемости стандартизованных компонентов, в унификации интерфейсов и методов их сопряжения. Недостаток же, опасность стандартизации заключается в подмене решаемой задачи: вместо попыток адаптации стандартных методов решения к стоящим задачам зачастую пытаются адаптировать задачи к стандартным методам решения. В результате процесс разработки может полностью деградировать: разработчики вместо действительного решения стоящих перед ними задач объявляют о невозможности решения, поскольку ограничены стандартными методами решения, либо переформулируют задачи так, чтобы "подогнать" их к имеющимся стандартам, в результате чего получают решение не исходной задачи, а какой-то другой. В противном случае может полностью деградировать стандарт: разработчики перестают ему следовать в решении стоящих перед ними задач.
Оба подхода являют собой крайности парадоксального содержания категории гибкости программной системы. Первый подход уменьшает суммарные трудозатраты разработки отдельно взятой программной системы, но за счёт более быстрой деградации гибкости создаваемой системы в процессе адаптации её к новым требованиям. Второй подход акцентирует внимание на высокой адаптивности отдельно взятой компоненты к различным областям её применения, но за счёт больших трудозатрат на создание такой компоненты.
Решить этот парадокс можно, придерживаясь срединного пути. С одной стороны, необходимо отказаться от практики разработки "тяжёлых" компонентов, перегруженных различными функциями, из которых большая часть обычно не используется в конкретном решении. С другой стороны, необходимо отказаться от практики преждевременного частного решения конкретных, зато более очевидных задач вместо решения задач в их обобщённых, абстрагированных от частностей формулировках, если предполагается длительный жизненный цикл разрабатываемой программной системы. В результате активно и в полной мере повторно используемыми окажутся наиболее абстрактные "полуфабрикаты" программных компонентов - скелет программной системы, а вот адаптация этих "полуфабрикатов" к конкретной задаче, их доводка до потребительского вида как раз и будет являться содержанием конкретной работы. Таким образом, воедино сливаются оба подхода. С одной стороны разработчик с помощью операции апплицирования повторно использует компоненты, содержание которых абстрагировано от его конкретной задачи. С другой стороны всю специфику своей конкретной задачи разработчик реализует сам согласно требованиям своей задачи - он не ограничен заранее ему навязанными конкретными решениями и методами. С третьей стороны ни разработчик прикладной программной системы, ни разработчик компонентов не выполняют лишнюю работу, которой, возможно, никто повторно не воспользуется.
Если разработчик прикладной программной системы ощутит, что используемые им абстрактные "полуфабрикаты" компонентов не позволяют ему решить стоящие перед ним задачи, ему следует отказаться от некоторых компонентов, перейдя к решению проблем на более абстрактном уровне. Когда на верхних уровнях абстракции возникшие проблемы успешно решены, на более низких уровнях становится возможным создать решение прикладной задачи. Если, отказавшись от одних компонентов, разработчик не нашёл их более подходящих к решению задачи альтернатив, он может реализовать их самостоятельно. На этом этапе открываются преимущества обобщённого подхода: не требуется создавать трудоёмкие законченные компоненты и в то же время не нужно сразу создавать конкретные решения - нужно разработать "полуфабрикат" компонента и только затем приступить к его применению к конкретной прикладной задаче. Разделение общего и частного решений проходит по линии, отделяющей "полуфабрикат" компонента от кода его применения к решению конкретной задачи.
Можно обнаружить примеры подобной практики. Гениальность изобретения такого класса прикладных программ, как табличный процессор, подтверждается широчайшим применением этих программ в самых различных прикладных областях. Гениальность состоит ещё и в понятности принципов работы табличного процессора даже для не очень хорошо подготовленных пользователей. Лежащая в основе табличного процессора абстракция с точки зрения программирования чрезвычайно проста, в то же время применение (апплицирование) этой абстракции самое разнообразное. Пользователь сам выполняет операции абстрагирования и апплицирования при работе с таблицей: он формирует структуру конкретной таблицы на основе обобщённого решения, он включает в эту таблицу те данные и формулы, которые ему нужны. С точки зрения программы (с точки зрения программируемой разработчиком абстракции) всё вводимое пользователем при создании любой конкретной таблицы есть не более чем матрица объектов различных достаточно простых типов (строк, чисел, дат, формул и т.п.). С точки зрения пользователя каждая конкретная таблица есть очевидный для пользователя результат апплицирования абстракции к конкретной прикладной задаче.
Чем более обобщена абстракция, тем больше существует возможностей её апплицирования, тем выше вероятность повторности её использования в будущем. Чем выше возможность апплицирования абстракции, тем большее количество разнообразных требований способна она удовлетворить, следовательно, тем более она устойчива в процессе эволюции конкретной программной системы. Овладение методом обнаружения и явного выделения уровней абстракций даст возможность разработчику выбирать наиболее гибкие программные решения из имеющихся альтернатив.
Из имеющихся парадигм программирования лишь объектно-ориентированный подход предоставляет удобные и "естественные" средства для явного описания на уровне программного кода структур различной степени абстрактности на основе иерархий наследуемых объектов и поведения различной степени абстрактности на основе полиморфизма объектов.

Dimka
30 сентября 2006 года
Версия для печати
Обсудить на форуме (4)