Проектирование и программирование пользовательского интерфейса на С++. Часть 2. Главное меню

В прошлой части статьи (см. КГ №45) я рассказал, почему так важно уделять особое внимание программированию GUI, приведя в качестве примера стандартную задачу — разработку главного меню приложения. При более-менее серьезном взгляде на эту задачу выяснилось, что она не так-то проста. Элементы главного меню должны дублироваться элементами контекстного меню, панелей инструментов и, возможно, другими виджетами. У них у всех должно быть одно состояние, они должны вызывать одну и ту же функциональность. Главное меню должно быть построено таким образом, чтобы его можно было сериализовать. Кроме того, код главного меню должен быть максимально компактен, локализован и удобен для понимания и редактирования. Были спроектированы следующие классы. MyMenuSerializer реализует функциональность по сериализации состояния меню. Здесь мы будем рассматривать самый простой случай, когда файл с состоянием не может быть прочитан, и меню строится по default'ной схеме. MyMenuBuilder строит меню, т.е. создает по очереди все элементы меню и добавляет их в класс MyMenu. А MyMenu их только содержит и отрисовывает. Элементам меню присвоено имя MyMenuItem. Каждый элемент меню содержит свое название, иконку, подсказку, состояние и метод вызова функциональности.

А теперь давайте посмотрим, какие возможности предоставляет библиотека Qt4 для реализации описанного проектного решения. Начнем с класса MyMenuItem. Идеальным воплощением этого класса в Qt4 является класс QAction. Он содержит в себе текст названия и подсказки, иконку, состояние и прочие фишки. Когда пользователь так или иначе активизирует объект QAction (например, кликает мышкой), QAction посылает сигнал activated(). Таким образом нам практически не нужно реализовывать свой класс MyMenuItem, а достаточно воспользоваться стандартным библиотечным. Чтобы собрать из объектов QAction главное меню, нужно получить у конструируемого окна объект QMenuBar, затем создать объект QMenu, добавить туда все QAction, затем QMenu добавить в QMenuBar. Точно таким же образом создаются и объекты контекстного меню и панелей инструментов. Вот как я релизовал это в конструкторе формы:

MyForm::MyForm(QWidget *parent, const char *name) {
_Menu = menuBar();
MyMenuSerializer sr;
sr.loadMenu(_Menu);
}

Соответственно, вызов дефолтной схемы построения меню может выглядеть так:

void MyMenuSerializer::loadMenu(QMenuBar *menuBar){
// TODO:
// проверка наличия файла состояния меню,
// попытка его прочитать,
// продолжение по схеме
// "файл состояния отсутствует"
MyMenuBuilder b;
b.build(menuBar);
}

Таким образом получается, что обязанности класса MyMenu, который должен был содержать и отрисовывать объекты MyMenuItem, распределяются сразу по двум классам: QMenuBar и QMenu. Но в указанном решении есть один недостаток: содержать-то QAction'ы они содержат. Но если пользователь будет динамически настраивать меню и, например, удалит из него некоторые ненужные ему элементы, то они просто "потеряются". В случае повторного добавления их в меню их придется создавать заново. Кроме того, QAction'ы очень неудобно получать из QMenu для других классов: Qt4 не предоставляет для этого стандартных способов. Получается, что QMenu не столько содержит свои элементы, сколько их отрисовывает. Значит, должен быть некоторый объект, который их содержит. Кроме того, возникает следующая проблема: какой класс будет создавать QAction'ы? В задачу MyMenuSerializer входит только прочитать/записать состояние меню и указать классу MyMenuBuilder, из каких QAction'ов оно состоит. А в задачу класса MyMenuBuilder входит только правильное построение готовых QAction'ов в правильном порядке, а также добавление их в QMenu. Среди девяти шаблонов GRASP есть один, который описывает решение проблемы создания объектов — Creator (Создатель). Шаблон Создатель предлагает назначить обязанность классу Б по созданию объекта класса А в случае, если:

1. Класс Б агрегирует объекты А (отношения часть-целое).
2. Класс Б содержит объекты А (контейнер хранит объект).
3. Класс Б записывает экземпляры класса А (сериализация).
4. Класс Б активно использует объекты А.
5. Класс Б обладает данными инициализации класса А.

Если для класса Б выполняется хоть одно из этих условий, это значит, что он связан с классом А, и назначение обязанности создания объектов А не повысит связанности класса Б. Но у нас почти все классы как-то связаны с QAction: и MyMenuSerializer, и MyMenuBuilder, и QMenu, и даже еще не спроектированный класс, который будет хранить объекты QAction. А раз так, нужно выбрать среди них такой класс, назначение которому обязанности создания QAction'ов не понизит степени его функционального зацепления (сфокусированности обязанностей на некоторой проблеме). Информация, необходимая для создания объекта QAction (иконка, текст названия и подсказки, состояние), не может храниться в файле состояния меню. Она должна быть достаточно жестко "зашита" в коде программы, чтобы не подвергнуть риску безопасность приложения. В файле состояния меню хранится только порядок следования объектов QAction, поэтому вполне возможно убрать связь MyMenuSerializer с QAction и тем самым понизить его связанность и повысить зацепление. Может ли создавать объекты QAction объект MyMenuBuilder? Вполне, тем более, что в этом, можно сказать, и есть его предназначение — построить меню. Может ли создавать объекты QAction объект класса, который их содержит? Конечно, и тогда этот класс будет помесью паттерна фабрика с паттерном синглтон. Я выбрал второй вариант по следующей причине. Если создавать объекты QAction будет MyMenuBuilder, то в его действиях будет избыток: сначала нужно объект создать, затем добавить его в список для хранения в объект-хранитель, а после этого добавить в меню QMenu. Во втором случае объект MyMenuBuilder будет только запрашивать у фабрики объект QAction, а фабрика уже самостоятельно проверит, существует ли в списке такой объект, и если нет, создаст его и добавит в список, а затем вернет в MyMenuBuilder. Этим способом также гарантируется уникальность объектов QAction, отсутствие "утечки памяти" и еще более высокая степень зацепления обоих классов: как
MyMenuBuilder'а, так и фабрики. Кроме того, абсолютно тем же способом можно получать уже созданные элементы QAction при формировании объектов панели инструментов и контекстного меню. Вот как может при этом выглядеть дефолтовый метод создания меню:

void MyMenuBuilder::build(QMenuBar *menuBar) {
QMenu *menu = new QMenu("File");
menuBar->addMenu(menu);
MyMenuFactory* f = MyMenuFactoryCtrl::Instance();
menu->addAction(f->menuItem<FileNew>());
menu->addAction(f->menuItem<FileOpen>());
menu->addAction(f->menuItem<FileExit>());
}

В целях экономии места в примере приводится последовательность для создания только лишь трех элементов (New, Open и Exit) только для одного подменю File. Как видите, для класса MyMenuBuilder создание меню при помощи фабрики MyMenuFactory выглядит тоже очень просто. Вопросы читателя может вызвать шаблонный метод фабрики MyMenuFactory::menuItem. Его я опишу ниже, а пока только оцените простоту его использования: достаточно всего лишь указать тип создаваемого QAction, и фабрика его создаст. При этом, поскольку метод menuItem шаблонный, нет необходимости писать его отдельную специализацию для каждого объекта QAction. Это сильно экономит время, сокращает код и количество ошибок. Кроме того, выполняется одно из требований, выдвинутых к реализации главного меню в предыдущей части: если меню некоторым образом изменится, и добавятся новые элементы меню либо уберутся старые, то не придется дополнительно изменять код фабрики. Осталось решить еще одну интересную задачу: какой объект будет хранить данные инициализации для классов QAction? Если бы они должны были храниться в файле, то однозначно — MyMenuSerializer. В нашем случае данные жестко зашиты в текст программы. Поэтому из уже спроектированных классов придется выбирать между MyMenuBuilder и MyMenuFactory. Однако, если хранить данные в одном из этих объектов, это здорово понизит степень их зацепления: они предназначены для работы исключительно с QAction, а не с кучей других данных. Кроме того, любое изменение в составе меню обязательно повлечет за собой изменение в классе — хранителе данных. Поэтому, на мой взгляд, наилучшим образом было бы локализовать данные инициализации QAction в самом QAction, т.е. в его подклассе. Пример для элемента File- >New:

FileNew::FileNew()
:QAction(0L)
{
setText(tr("&New"));
setIcon(QIcon(":/img/filenew.png"));
setStatusTip(tr("Create a new file"));
setShortcut(tr("Ctrl+N"));
connect(this,SIGNAL(activated()),FileMenuCtrl::Instance(),SLOT(New()));
}

Дальнейшее описание классов фабрики и элементов меню ниже. Сейчас только оцените саму схему: добавление нового элемента меню повлечет за собой добавление только лишь нового класса. И, возможно — только возможно, а вовсе не обязательно, — изменение MyMenuBuilder::build(). При этом не надо думать, где хранятся данные инициализации QAction, как его правильно создать и т.д., что повышает степень зацепления каждого класса в отдельности.

Теперь рассмотрим интересную проблему. Представим себе, что нам необходимо одним махом заблокировать сразу несколько пунктов меню, связанных общей концепцией. Например, если документ в редакторе не создан, то должны быть заблокированы все пункты подменю Edit. Идеальный вариант — назначить каждому объекту меню некоторый численный идентификатор, по которому его можно было бы легко выбрать из списка. Тогда, например, зная идентификаторы первого и последнего элементов меню Edit, можно в цикле заблокировать их все сразу. Этот способ удобен тем, что изменения меню Edit (добавление/удаление элементов) не повлекут за собой изменения в алгоритме. Единственная сложность — добавление/удаление элементов меню может потребовать пересчитывания этих идентификаторов вручную. Пример: были элементы с числами 1, 2 и 3. Между 2 и 3 нужно вставить еще 2 элемента. Тогда получится ряд 1, 2, 4, 5, 3 — числа идут не по порядку. А для их упорядочивания придется изменять код вручную. Неужели нету способа, как автоматизировать такую элементарную задачу? Есть, но он крайне нетривиальный. Слабонервных попрошу сразу запастись валерьянкой.

Общий план решения задачи автоматического наращивания идентификатора типа

Раз уж мы договорились, что для каждого элемента меню будем создавать отдельный класс, в конструкторе которого и проходит вся инициализация, удобно будет применить список типов. При создании нового класса он добавляется в список типов, а номер в этом списке и будет его численным идентификатором. На словах выглядит просто. Я не буду здесь показывать альтернативные варианты без применения списка типов: все они обладают серьезными недостатками. И еще я не буду детально описывать механизмы языка С++, которые позволяют реализовать такую сложную конструкцию. Цель статьи — применение языка С++ и шаблонов проектирования, а вовсе не изучение его тонкостей. И еще не стану подробно описывать каждый примененный шаблон: по этой тематике и так достаточно литературы. Поэтому, если кому-то материал покажется слишком сложным, советую почитать книжки, которые я привел в первой части статьи. Итак, как выглядит элемент списка типов:

template <class T, class U>
struct Typelist
{
typedef T head;
typedef U tail;
};

Применять его нужно так:

typedef Typelist<MyType1,MyType2> MyTypeList2;

Собственно, это даже и не список, а просто определение двух типов в структуре. Но применение шаблона в этом коде позволяет продолжить эволюцию — например, так:

typedef Typelist<MyTypeList2, MyType3> MyTypeList3;

Разумеется, создание динамических списков типов невозможно, т.к. они формируются на этапе компиляции. Поэтому размеры списка должны быть известны заранее. Кроме того, создавать списки таким образом неудобно. Проще написать набор #define'ов примерно такого вида:

#define TYPELIST_1(T1) Typelist <T1, NullType>
#define TYPELIST_2(T1,T2) Typelist <T1, TYPELIST_1(T2)> и т.д.

Здесь NullType — просто пустой тип, который играет роль нулевого байта в строке и означает "конец списка". Объявлен он так: class NullType {}; Длину списка типов можно вычислить следующими определениями:

template <class TList> struct Length;
template <> struct Length<NullType> {
enum { value = 0 };
};
template <class T, class U>
struct Length< Typelist<T,U> > {
enum { value = 1 + Length<U>::value };
};

Этот "статический" алгоритм значит следующее: "Длина нулевого списка типов равна 0. Длина любого другого списка типов равна 1 плюс длина его оставшейся части". При реализации алгоритма применена частичная специализация шаблонов, которая хорошо описана в книгах Андрея Александреску и Bruce Eckel'а. Итак, чтобы вычислить длину списка MyTypeList3, нужно написать следующее:

Lehgth<MyTypeList3>::value;

Для добавления в список нового типа нам понадобятся следующие определения:

template <class TList, class T> struct Append;
template <> struct Append<NullType, NullType> {
typedef NullType Result;
};
template <class T> struct Append<NullType, T> {
typedef Typelist<T,NullType> Result;
};
template <class Head, class Tail>
struct Append<NullType, Typelist<Head, Tail> > {
typedef Typelist<Head, Tail> Result;
};
template <class Head, class Tail, class T>
struct Append<Typelist<Head, Tail>, T> {
typedef Typelist<Head,
typename Append<Tail, T>::Result> Result;
};

Чтобы создать новый список типов, состоящий из старого плюс новый тип, нужно написать следующее:

typedef Append<MyTypeList3,MyType4>::Result MyTypeList4;

Впрочем, частичная специализация шаблонов позволяет здесь создавать список с нуля — например:

typedef Append<NullType,MyType1>::Result MyTypeList1;

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

1. Создается список типов нулевой длины с заранее определенным именем.
2. Каждый новый элемент меню добавляет себя в список при помощи определения Append, при этом определяя новый список.
3. В новом типе определяется статическая численная константа, равная длине списка типов.
4. Последний определенный список при помощи typedef переименовывается в окончательный список со стандартным именем.

Привожу макроопределения, которые помогают это сделать:
1) #define NUMBER(tpl) public: static const int Index = Length<tpl>::value; — позволяет объявить в классе статическую переменную Index с номером типа;
2) #define NUMBER1(tpl) public: static const int Index = Length<tpl>::value+1;
3) #define APPEND(tpl,tp,ntp) typedef Append<tpl,tp>::Result ntp; — добавляет тип tp в список tpl и регистрирует новый список ntp;
4) #define fitem_class(tp,tl1,tl2) class tp:public QAction { public: tp(); NUMBER(tl1) public: typedef tl1 ListType; }; APPEND(tl1,tp,tl2) — создает первый элемент подменю с типом tp, добавляет его в список tl1 и регистрирует новый список — tl2;
5) #define item_class(tp,prev,tl) class tp; APPEND(prev::ListType,tp,tl) class tp:public QAction { public: tp(); NUMBER1(prev::ListType) public: typedef tl ListType; }; — то же самое, только для всех остальных элементов подменю.

С применением этих макроопределений объявления классов элементов меню могут выглядеть так:

typedef Typelist<NullType, NullType> FileTL;
fitem_class(FileNew,FileTL,fl_mn_a);
item_class(FileOpen,FileNew,fl_mn_b);
item_class(FileExit,FileOpen,fl_mn_c);
typedef fl_mn_c FileTypeLst;

Здесь список FileTL — самый первый, пустой, список. А FileTypeList — окончательный список, который можно применять в других классах. Соответственно, добавление/удаление любого элемента меню повлечет за собой изменения только в двух соседних строчках файла. То есть любые изменения хорошо локализованы и вряд ли повлекут за собой ошибки. А для реализации каждого класса достаточно написать конструктор элемента меню, приведенный в начале статьи. Поскольку список типов — статическое определение, длина его будет известна заранее. То есть еще на этапе компиляции можно определить, каких размеров нужен массив, чтобы вместить в себя все элементы подменю. Это свойство очень пригодится в фабрике, т.к. она использует индексированный доступ к элементам меню. Вот часть заголовочного файла фабрики MyMenuFactory:

class MyMenuFactory {
public:
MyMenuFactory() {
for (int i = 0; i < _FileMenuLength; i++)
_FileMenu[i] = 0L;
}
template <typename T>
QAction* menuItem() {
int n = T::Index — 1;
if (!_FileMenu[n])
_FileMenu[n] = new T();
return _FileMenu[n];
}
protected:
QAction* _FileMenu[Length<FileTypeLst>::value];
static const int _FileMenuLength = Length<FileTypeLst>::value;
};

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

template <class Client>
class SingletoneHolder {
public:
static Client* Instance() {
static Client* h = 0L;
if (!h)
h = new Client();
return h;
}
};

С применением этого класса объявление уникальной фабрики будет выглядеть так:

typedef SingletoneHolder<MyMenuFactory> MyMenuFactoryCtrl;

Доступ к экземпляру фабрики:

MyMenuFactory* f = MyMenuFactoryCtrl::Instance();

Резюме

Применение шаблонов проектирования позволило создать меню с заданными свойствами. Разработанный код устойчив к изменениям; изменения в коде, связанные с добавлением/удалением элементов меню, всегда носят локальный характер. Высока степень повторного использования кода. Классы обладают сильным зацеплением и слабым связыванием, что значительно упрощает их понимание и поддержку. Единственный сложный момент реализации — списки типов — могут быть оформлены в библиотеку и использованы в дальнейшем и в других решениях. Код реализации меню может быть оформлен в компонент для дальнейшего повторного использования. Окончательная диаграмма взаимодействия объектов показана на рисунке.

Дмитрий Бушенко, nop@list.ru


Компьютерная газета. Статья была опубликована в номере 46 за 2005 год в рубрике программирование :: разное

©1997-2024 Компьютерная газета