[ <<< | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | Источники | >>> ]
© 2001 А.И. Легалов
Ниже рассмотрены отличительные особенности технических решений, определяющие специфику использования обобщений. Этот материал ни в коем случае не является полноценным анализом достоинств и недостатков процедурного и объектно-ориентированного программирования.
На мой взгляд, именно обобщение с правой повергло процедурное программирование в глубокий нокаут, после которого ему, в основном, приходится выступать лишь на любительских и студенческих подмостках. Основным неоспоримым достоинством объектного обобщения является поддержка быстрого и безболезненного, для уже написанного кода, добавления новых альтернатив в существующие обобщающие процедуры. Там, где процедурный подход ведет к поиску и редактированию фрагментов программы, ООП довольствуется только созданием и легкой притиркой новых классов.
Рассмотрим добавление круга в уже написанную программу, обеспечивающую работу с геометрическими фигурами. При процедурном подходе подобное расширение ведет к изменению обобщения и всех процедур, непосредственно обрабатывающих его. Это связано с тем, что именно в телах процедур осуществляется выбор альтернатив и их последующее использование. Измененное вариантное обобщение, построенное с использованием общего ресурса, примет следующий вид:
//---------------------------------------------------------------- // структура, обобщающая все имеющиеся фигуры // Изменилась, в связи с добавлением круга struct shape { // значения ключей для каждой из фигур enum key {RECTANGLE, TRIANGLE, CIRCLE}; // добавился признак круга key k; // ключ // используемые альтернативы union { // используем простейшую реализацию rectangle r; triangle t; circle c; // добавился круг }; }; //---------------------------------------------------------------- |
Для нашего небольшого примера добавление круга всего лишь к одному обобщению ведет к модификации все трех процедур, осуществляющих обработку обобщенной геометрической фигуры. Привожу их.
//---------------------------------------------------------------- // Ввод параметров обобщенной фигуры из стандартного потока ввода // Изменилась из-за добавления круга shape* In() { shape *sp; // Изменилась подсказка из-за добавления круга cout << "Input key: for Rectangle is 1, for Triangle is 2," " for Circle is 3 else break: "; int k; cin >> k; switch(k) { case 1: sp = new shape; sp->k = shape::key::RECTANGLE; In(sp->r); return sp; case 2: sp = new shape; sp->k = shape::key::TRIANGLE; In(sp->t); return sp; case 3: // добавился ввод круга sp = new shape; sp->k = shape::key::CIRCLE; In(sp->c); return sp; default: return 0; } } //---------------------------------------------------------------- // Вывод параметров текущей фигуры в стандартный поток вывода // Изменилась из-за добавления вывода круга void Out(shape &s) { switch(s.k) { case shape::key::RECTANGLE: Out(s.r); break; case shape::key::TRIANGLE: Out(s.t); break; case shape::key::CIRCLE: // добавился вывод круга Out(s.c); break; default: cout << "Incorrect figure!" << endl; } } //---------------------------------------------------------------- // Нахождение площади обобщенной фигуры // Изменилась в связи с добавлением круга double Area(shape &s) { switch(s.k) { case shape::key::RECTANGLE: return Area(s.r); case shape::key::TRIANGLE: return Area(s.t); case shape::key::CIRCLE: // добавился круг return Area(s.c); default: return 0.0; } } //---------------------------------------------------------------- |
При разработке же больших программных систем обработка обобщений может осуществляться не одной сотней процедур, каждую из которых потребуется изменить. А изменение написанного кода всегда связано с риском внести дополнительные ошибки. Кроме этого, изменения, связанные с добавлением сведений о новой специализаций могут затронуть различные единицы компиляции (без изменения располагаемых в них программных объектов), что тоже ведет к дополнительным затратам. На всякий случай я дописал и откомпилировал этот пример, чтобы убедиться в правильности его работы. Файлы проекта находятся в архиве pp_examp1_1.zip.
ОО подход, в аналогичной ситуации, позволяет провести добавление круга практически без изменений уже написанного кода. При этом, все добавления, связанные с новой фигурой могут осуществляться во вновь создаваемых единицах компиляции. Процесс добавления новой фигуры полностью аналогичен разработке уже созданных и не требует специальных комментариев. В рассматриваемом случае необходимо только изменить процедуру, обеспечивающую ввод новой фигуры. Хотя, и здесь можно было бы поступить более тонко.
//---------------------------------------------------------------- // Ввод параметров обобщенной фигуры из стандартного потока ввода // Изменяется в связи с добавлением круга shape* In() { shape *sp; // Изменяется подсказка cout << "Input key: for Rectangle is 1, for Triangle is 2," " for Circle is 3, else break: "; int k; cin >> k; switch(k) { case 1: sp = new rectangle; sp->In(); return sp; case 2: sp = new triangle; sp->In(); return sp; case 3: // добавлен ввод круга sp = new circle; sp->In(); return sp; default: return 0; } } //---------------------------------------------------------------- |
Файлы этого проекта находятся в архиве oop_examp1_1.zip.
Естественно, такое впечатляющее техническое преимущество ОО подхода, при создании альтернатив, не могло остаться незамеченным, и привело к его плодотворной раскрутке, Помпезное воспевание оставило за кадром слабые стороны объектного и сильные стороны процедурного программирования.
Кроме наращивания альтернатив, ОО обобщение прекрасно поддерживает эволюционное расширение агрегативных способностей объекта, при котором используется только одна производная альтернатива, заменяющая ту, которая эксплуатировалась до нее. Эта возможность вытекает из того, что обобщение - агрегат. А добавляемые или изменяемые в производных классах виртуальные методы можно трактовать как методы агрегата. Таким образом, применяя наследование, мы можем легко расширять внутреннюю структуру и функциональность объекта, предоставляемого клиентам. А сохранение интерфейса обеспечивает прозрачную для клиента подмену одного объекта другим. Методы любого производного класса могут использовать тот же интерфейс, что и методы ранее используемых базовых классов. Главное для клиента - это отсутствие изменений в интерфейсе связываемого объекта.
Использование процедурного подхода не позволяет осуществлять такую подмену, так как создание нового агрегата сопровождается и созданием для его обработки новых процедур. В связи с тем, что обращение к агрегату осуществляется через формальный параметр определенного типа, приходится менять интерфейс клиента. Можно, конечно, обратиться к подключаемому объекту через указатель, поддерживающий образное восприятие, но все равно, придется изменить тела процедур, вызываемых клиентом, чтобы они были "заточены" на обработку новых структур данных. Но и в этом случае встают дополнительные проблемы, если предполагаются дальнейшие, не предусмотренные регламентом, изменения подключаемого объекта (как нового, так и хорошо зарекомендовавшего себя старого).
Вместе с тем, и у объектных альтернатив есть свои узкие места, которые затрудняют их эффективное применение.
Снова вернемся к исходной постановке задачи (чтобы не расширять до бесконечности пример). Предположим, что нам необходимо добавить в программу вычисление периметров геометрических фигур. И здесь процедурное программирование слегка отыгрывается на ниве эволюционного расширения программы. Добавление новой обобщающей процедуры некоим образом не связано с изменением уже написанного кода. Создаются специализированные процедуры, обеспечивающие получение периметров для прямоугольников и треугольников. После этого формируется обобщающая процедура, использующая их результаты после анализа признака текущей альтернативы. Да и агрегат (уже рассмотренный нами контейнер), при необходимости вычислить суммарный периметр, изменять не надо. Исходные тексты примера лежат в архиве pp_examp1_2.zip. Процедуры, обеспечивающие вывод периметров отдельных фигур, обобщения и суммарный периметр для фигур, расположенных в контейнере, выглядят следующим образом:
//---------------------------------------------------------------- // Вычисление периметра прямоугольника double Perimetr(rectangle &r) { return (r.x + r.y) * 2.0; } //---------------------------------------------------------------- // Вычисление периметра треугольника double Perimetr(triangle &t) { return t.a + t.b + t.c; } //---------------------------------------------------------------- // Нахождение периметра обобщенной фигуры double Perimetr(shape &s) { switch(s.k) { case shape::key::RECTANGLE: return Perimetr(s.r); case shape::key::TRIANGLE: return Perimetr(s.t); default: return 0.0; } } //---------------------------------------------------------------- // Вычисление суммарного периметра для фигур, // размещенных в контейнере double Perimetr(container &c) { double a = 0; for(int i = 0; i < c.len; i++) { a += Perimetr(*(c.cont[i])); } return a; } //---------------------------------------------------------------- |
Что происходит при ОО подходе? Необходимо включить в базовый класс новую виртуальную процедуру, расширяющую исходный интерфейс. Далее требуется вставить во все производные классы методы, осуществляющие непосредственное вычисление периметров.
//---------------------------------------------------------------- // Класс, обобщающает все имеющиеся фигуры. // Изменился в связи с добавлением метода вычисления периметра class shape { public: virtual void In() = 0; // ввод данных из стандартного потока virtual void Out() = 0; // вывод данных в стандартный поток virtual double Area() = 0; // вычисление площади фигуры // добавлено вычисление периметра фигуры virtual double Perimetr() = 0; protected: shape() {}; }; //---------------------------------------------------------------- // Измененный прямоугольник (вычисляет периметр) class rectangle: public shape { int x, y; // ширина, высота public: // переопределяем интерфейс класса void In(); // ввод данных из стандартного потока void Out(); // вывод данных в стандартный поток double Area(); // вычисление площади фигуры double Perimetr(); // добавлено вычисление периметра rectangle(int _x, int _y); // создание с инициализацией. rectangle() {} // создание без инициализации. }; //---------------------------------------------------------------- // Измененный треугольник class triangle: public shape { int a, b, c; // стороны public: // переопределяем интерфейс класса void In(); // ввод данных из стандартного потока void Out(); // вывод данных в стандартный поток double Area(); // вычисление площади фигуры double Perimetr(); // добавлено вычисление периметра фигуры triangle(int _a, int _b, int _c); // создание с инициализацией triangle() {} // создание без инициализации. }; //---------------------------------------------------------------- // Вычисление периметра прямоугольника double rectangle::Perimetr() { return (x + y) * 2.0; } //---------------------------------------------------------------- // Вычисление периметра треугольника double triangle::Perimetr() { return a + b + c; } //---------------------------------------------------------------- |
После этого, из-за изменения множества объектов необходимо перекомпилировать практически всю программу. Исходные тексты проделанной мною работы положены в архив oop_examp1_2.zip. Если эволюция программной системы зашла достаточно далеко и насчитывает несколько сотен альтернатив (чего мелочиться: будем манипулировать числами такого же порядка, как и при критике процедурного подхода), то объем проделанных изменений является достаточно впечатляющим. Если же вводимая в базовый класс процедура не является чистой, то можно легко упустить вставку реального кода в один из производных классов.
Однако, этот недостаток объектного подхода обычно считается не столь существенным. Во-первых, его сторонники не видят особой разницы между добавлением отдельных методов в классы и централизованным объединением множества новых независимых процедур в один огромный переключатель. И в том и в другом случаях можно легко что-то напутать. Ведь процедурный переключатель тоже займет не одну страницу. К тому же, последовательный анализ вариантов замедляет время выбора альтернативы. Во-вторых, модификация классов является более контролируемой во время трансляции, если добавлять чистый метод, ориентируясь на наследование интерфейса. Тогда сам компилятор будет сигнализировать о том, что тот или иной производный класс не содержит переопределения требуемой процедуры. Ну а если перекомпиляция и сборка проекта, после проделанной работы и займет пару суток, то освободившееся время всегда можно с пользой потратить на пару ящиков пива.
Другим методом решения этой проблемы, предлагаемым Бучем [Буч98], является более тщательное проектирование интерфейсов классов, осуществляемое до начала кодирования. По его мнению, именно проектированию интерфейсов надо уделять большее внимание, и тогда поставленная проблема почти отпадет. Метод, конечно разумный, хотя бы тем, что предлагает подумать и минимизировать дальнейшие затраты, прежде давить на клавиатуру. Однако, вряд ли он будет сильно полезен при экстремальном программировании [Beck]. Да и при любом методе проектирования больших систем всегда существует вероятность того, что ряд возможных функциональных расширений останутся неучтенными.
Предположим, что нам надо выводить все прямоугольники, расположенные в контейнере. Соответствующая процедура должна "выявлять" прямоугольник из множества фигур всех видов и запускать специализированную процедуру вывода. Использование процедурного подхода позволяет добавить новый метод без каких-либо проблем, так как его отличие от других обработчиков альтернатив заключается только в анализе одного признака. Ее легко сформировать путем вырезания лишнего кода из уже существующей процедуры вывода произвольной альтернативы.
//---------------------------------------------------------------- // Вывод только обобщенного прямоугольника // Процедура добавлена без изменений других объектов bool Out_rectangle(shape &s) { switch(s.k) { case shape::key::RECTANGLE: Out(s.r); return true; default: return false; } } //---------------------------------------------------------------- |
Что из этого получилось, можно посмотреть в архиве pp_examp1_3.zip.
Объектно-ориентированный подход не позволяет, в данном случае, получить элегантное решение. Это связано с тем, что основной спецификой его использования является ориентация на массовое применение полиморфизма: когда множество всех объектов или один из этого множества объектов обрабатываются внешне одинаковым методом. Когда же требуется выделить специфический экземпляр производного класса, приходится прибегать к дополнительным ухищрениям.
Одним из наиболее эффективных и простых вариантов является использование динамического анализа типа объекта. Однако, как уже отмечалось выше, это процедурные штучки! Следовательно, однорукого программирования явно не хватает. Приходится, наряду с объектным обобщением, использовать и элементы вариантного обобщения, что ведет к смешению стилей и появлению признаков, сопровождающих классы по всей программе (даже тогда, когда эти признаки не используются). А раз так, то возможны проблемы, связанные с внутренними изменениями ряда таких процедур при добавлении новых производных классов. Эти проблемы, возможно, не столь критически, когда процедура обрабатывает только одну специализацию, что, скорее всего, является наиболее типичным случаем. Хотя, исключать возможность появления внутри таких процедур нескольких различных специализаций не стоит.
Менее изящным, но чисто объектным решением является перенос интерфейса специализированного обработчика в базовый класс и закрепления за ним функций ничего неделания, используя для этого, например, пустое тело метода (в нашем случае возвращается булевский признак, указывающий на отсутствие вывода прямоугольника). Такой подход используется даже в образцах проектирования [Гамма]. Переопределение метода только в нужном производном классе позволяет решить проблемы специализации. Ниже показано, как переопределяется вывод только прямоугольника.
//---------------------------------------------------------------- // Класс, обобщающает все имеющиеся фигуры. class shape { public: virtual void In() = 0; // ввод данных из стандартного потока virtual void Out() = 0; // вывод данных в стандартный поток // Добавлен вывод только прямоугольника как заглушка // Метод не является чистым и вызывается там где не переопределен virtual bool Out_rectangle() { return false; }; virtual double Area() = 0; // вычисление площади фигуры protected: shape() {}; }; //---------------------------------------------------------------- // Прямоугольник переопределяет вывод себя class rectangle: public shape { int x, y; // ширина, высота public: // переопределяем интерфейс класса void In(); // ввод данных из стандартного потока void Out(); // вывод данных в стандартный поток // Переопределен вывод только прямоугольника bool Out_rectangle(); // вывод только прямоугольника double Area(); // вычисление площади фигуры rectangle(int _x, int _y); // создание с инициализацией. rectangle() {} // создание без инициализации. }; //---------------------------------------------------------------- // Вывод только прямоугольника bool rectangle::Out_rectangle() { Out(); return true; }; //---------------------------------------------------------------- |
В архиве oop_examp1_3.zip показано, как такой подход обеспечивает вывод всех прямоугольников. Основным недостатком примененного технического решения является "разбухание" интерфейсов базового и производных классов. Ему приходится поддерживать множество методов, полезных только в специфичных ситуациях. Наряду с этим возникают проблемы, связанные модификацией базового класса и полной перекомпиляции всех зависимостей при добавлении каждого нового специализированного метода.
В ряде случаев проблему можно решать и чисто программистскими методами, связанными с добавлениями в программу новых объектов. Например, для поддержки вывода только прямоугольников, можно ввести специальный контейнер, предназначенный для их хранения. Но в каждом из таких случаев следует внимательно учитывать специфику решаемой задачи.
[ <<< | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | Источники | >>> ]