пятница, 19 ноября 2010 г.

Проектирование классов

После создания функциональной модели, можно приступать к проектированию классов.

Классический объектно-ориентированный подход даёт проектировщику чёткое руководство, как это делать.

Прежде всего, в предметной области следует найти ключевые абстракции. Различные учебники по ООП пишут, что для графического редактора ключевой абстракцией является фигура.

Затем следует установить взаимосвязи между ключевыми абстракциями. Если взаимосвязь можно выразить отношением is-a, то такие абстракции следует связать при помощи  наследования. Если взаимосвязь подходит под отношение has-a, то такие абстракции следует связать при помощи агрегации.

Для графического редактора абстрактную фигуру можно связать отношением наследования с конкретными фигурами: прямоугольником, эллипсом, треугольником и т.п.

Получается стройная и логичная картина:

class Figure;
class Rectangle : public Figure;
class Ellipse   : public Figure;
class Triangle  : public Figure;

К сожалению, она не позволяет создать редактор, который хоть в какой-то мере удовлетворяет потребностям пользователя.


Чтобы убедится в этом, посмотрим на функциональную модель модуля редактирования. Наибольшее количество операций связано с преобразованием формы фигуры.

Графический редактор должен позволять преобразовывать:

  • эллипс – в последовательность кривых;
  • прямоугольник – в многоугольник;
  • отрезок – в кривую;
  • последовательность кривых – в кривую;
  • кривую – в последовательность кривых:

а также – находить:

  • общий контур для группы фигур;
  • пересечение двух фигур;
  • разницу двух фигур;
  • отрицание фигуры.

Классическая иерархия фигур в объектно-ориентированном стиле позволяет абстрагироваться от формы фигуры при помощи полиморфизма. Это означает, что проектировщик может установить для всех фигур одинаковый интерфейс, а конкретную реализацию каждой формы – отдать на откуп специфичному классу, выведенному из базового Figure.

Такое решение кажется разумным лишь в учебном примере. Его использование в серьёзном приложении порождает ряд критичных проблем:

  1. Правильно ли представлять прямоугольник и квадрат отдельными классами?
  2. Что из чего правильнее выводить – прямоугольник из квадрата или квадрат из прямоугольника?
  3. Как представить пересечение прямоугольника с эллипсом?
  4. Следует ли для пересечения создать отдельный класс?
  5. Что будет, если полученное пересечение мы пересечём ещё один раз?
  6. Какой класс будем использовать для представления результата?
  7. Как добавлять в программу новые фигуры?
  8. Если нам потребуется 115 фигур, то означает ли это, что мы должны написать 115 классов?

Эти проблемы возникли, потому что мы искуственно решили поделить фигуры на классы. Основанием для деления выступила форма контура фигуры. Вместо того, чтобы задуматься над тем, как представить различные контура одинаковой структурой данных, мы бросились искать абстракции в предметной области и устанавливать между ними взаимосвязи.

Но абстракции абстракциям – рознь. Полезность объекта определяется не его названием, а его функциональным наполнением. Важно смотреть не на то, как объект называется, а на то, какие он выполняет функции. Класс должен браться не из предметной области, а выводиться из функциональной модели на основе общности функций.

Можно дать такое определение:

Класс – это группа похожих функций.

Или ещё короче:

1 класс == 1 обязанность

Мы допустили ошибку, потому что стали искать абстракции в отрыве от функциональной модели. Мы анализировали предметную область по названиям объектов, а не по их предназначению. И невольно использовали классификацию, взятую из школьного курса геометрии. Между тем, при проектировании важно сроить свою классификацию, которая будет приспособлена для решения наших задач.

Правильное направление проектирования может быть задано формулой:

Функциональная модель --> Группа похожих функций --> Структура данных

Анализ функцинальной модели графического редактора показывает, что ключевой абстракцией является не фигура, а форма (или контур).

Контура можно:

  1. создавать;
  2. объединять;
  3. пересекать;
  4. вычитать;
  5. отрицать;
  6. и т.д.

Проектировщику важно вовремя осознать этот факт, отказаться от проектирования иерархии фигур и подумать над структурой данных для представления контура.

15 комментариев:

  1. страшные вещи пишите...

    ОтветитьУдалить
  2. В чём заключается "страшность вещей"? )

    ОтветитьУдалить
  3. Что то как то бредово...

    Особенно бредовы 8 критичных проблем. Особенно второй пункт. :)

    ОтветитьУдалить
  4. По проблемам 1-2 на RSDN.RU регулярно возникают обсуждения. Да и Буч в своих "датчиках" допустил такую же ошибку.

    P.S.: По поводу "бредовости" - нужны аргументы.

    ОтветитьУдалить
  5. А не лучше ли будет модель классов, состоящая из фигур, а каждая фигура (их базовый класс) будет агрегировать в себя объект класса линия, подклассами которого будут прямая и кривая? Тут у нас и будет принцип единственной обязанности и black-box reuse(т.к. мы делегируем всю логику по конторам на класс линии, предоставляя ему возможность реализации специфичной логики).

    ОтветитьУдалить
  6. А точно в таком случае интерфейс "примитивов" (линий) будет совпадать с интерфейсом класса фигура?

    ОтветитьУдалить
  7. Не понимаю, зачем контур разбивать на линии, а те, в свою очередь, на прямые и кривые. Такое разбиение лишь усложнит написание алгоритмов растеризации. Эти алгоритмы учитывают не только текущую линию, но и её соседей, например, для того, чтобы сделать плавным стык двух кривых.

    ИМХО, контур выгоднее представить в виде набора точек и отдельного набора их атрибутов.

    Чтобы получить первую контрольную точку следующей кривой Безье, в Вашем варианте программисту придётся написать такой код:

    Line * pLine = GetLine(i + 1);
    Curve * pCurve = dynamic_cast(pLine);

    Point p;

    if (pCurve)
    p = pCurve->GetPoint(1);

    В моём варианте контрольная точка следующей кривой может быть получена при помощи одной строчки:

    Point p = GetPoint(i + 2);

    Но об этом более детально я, видимо, напишу позже. Замечу только, что, на мой взгляд, выгоднее подходить к проектированию структур данных, исходя не из принципов красивости, а - из удобства написания и сопровождения кода.

    Спасибо,

    ОтветитьУдалить
  8. gandjustas

    Будет. Просто класс фигуры будет делегировать всю логику, связанную с примитивами классу этого самого примитива.

    askofen

    Разбиения на прямые и кривые позволит по возможности использовать более простую логику там, где это возможно. Возьмём для примера нахождение точки пересечения. Я не могу быть уверен, но предполагаю, что найти точку пересечения двух прямых будет проще ( алгоритмически) нежели найти точку пересечения двух кривых.

    Зачем разбивать контур на линии. Наш графический редактор должен поддерживать размытие границ. В таком случае мы дружны иметь возможность определить ту грань на которую необходимо наложить некий фильтр. В данном случае обход ро точкам будет более трудоёмким. Кроме того, такую фигуру как прямая или просто не замкнутая кривая мы сможем обрабатывать как и любую другую фигуру и экономить на ресурсах подсчета того же пересечения.

    Сугубо ИМХО.

    ОтветитьУдалить
  9. Рисование размытых (или каких-то иных) границ происходит на этапе растеризации фигуры. Растеризация включает в себя две под-операции:

    1) заливка;
    2) рисование абриса.

    Чтобы нарисовать абрис, на основе имеющегося контура создаётся другой контур - для линии. Это происходит, если толщина линии больше 1-го пикселя.

    Затем полученный контур преобразуется в многоугольник и заливается с нужным эффектом.

    К операциям с контурами толщина абриса и эффект не имеет никакого отношения.

    Спасибо,

    ОтветитьУдалить
  10. > Графический редактор должен позволять преобразовывать:
    > эллипс – в последовательность кривых;
    (прочий бред опущен)

    Такое ощущение, что вы работаете в каких-то совсем других редакторах. Я использовал Фотошоп и ПайнтНЕТ - ни в одном из них таким "специфичным" редактированием не занимаются. Я _допускаю_, что на каких-то специфических операциях нужны кривые, но чтобы на них строить ВСЮ иерархию - увольте, это перепложение сущностей.

    ОтветитьУдалить
  11. Существуют два разных вида графических редакторов: растровые и векторные. Первые работают с растровыми изображениями, а вторые - с векторными.

    В растровых редакторах после того, как Вы нарисуете примитив, он добавляется к растровому изображению. Вы можете изменить любой пиксел этого примитива (это плюс), но при масштабировании картинка у Вас ухудшится. В векторных редакторах Вы можете произвольно менять форму примитива, преобразуя его в кривые и изменяя их кривизну, но при изменении масштаба качество изображения не ухудшится.

    Вы работаете с растровыми редакторами, а в этой и предыдущих статьях описывается подход к проектированию векторного графического редактора. Там же приводятся ссылки на примеры таких редакторов и уроки по работе с ними.

    ОтветитьУдалить
  12. "1) Правильно ли представлять прямоугольник и квадрат отдельными классами?"
    Теперь приходит заказчик и говорит, если вы за 10$ добавите в свой редактор хитровывернутую кривую, я куплю у вас софт.
    Голова программиста взорвется наверное от того как и что представлять. Каждая фигура должна рисовать себя сама, либо использовать какой либо отрисовщик. Имея базовый класс с защищенными методами по рисованию примитивов (линия, окружность, точка), дело фигуры как себя рисовать. Потому что рисование может производиться как и на графическом ускорителе, так и на растре. Но объектам будет по сути побарабаную. Простой пример(есть функции окружность линия и точка) нарисуем дугу типа буквы С(считаем что у нее радиус R): рисуем окружность и потом в нужных местах закрашиваем окружность фоновым цветом. Может это будет не оптимально для всех платформ, но это уже вопрос производительности, результат такой, что у нас есть ДУГА и как она отрисована не имеет значения, для клиента типа который использует класс "ФИГУРА".

    ОтветитьУдалить
  13. Если буква С не будет преобразовываться в кривую, то заказчику такая фигура будет не нужна, потому что он не сможет воспользоваться возможностями редактирования. Он не сможет ни сделать скос этой фигуры, ни вырезать её в полигоне, ни изменить её кривизну в заданном месте. И т.д. - существует много разных операций над формами. :)

    Если же фигуру представить в виде пути - последовательности прямолинейных и криволинейных сегментов, то пользователь получит возможность менять её так, как захочет. Более того, для добавления поддержки новой фигуры программисту вообще не нужно будет писать и перекомпилировать код. Нужно будет только добавить описание этой фигуры в векторном виде в файле данных.

    ОтветитьУдалить
  14. Не пойму обвинений в бредовости. Иерархию классов и/или интерфейсы можно задать десятью способами... и все будут правильными. Если автор способен этот дизайн реализовать, то для выбранных им целей все нормально.

    Не в обиду, но такие негативные комментарии напоминают ситуацию, когда подкованный в IT заказчик поручил сделать дизайн, а потом пошел этот сделанный дизайн громить - и классы не такие, и аггрегация не через интерфейсы, и паттернов не видно...

    ОтветитьУдалить