Проектирование библиотек
13.7 Каркас области приложения
Мы перечислили виды классов, из которых можно создать библиотеки, нацеленные на проектирование и повторное использование прикладных программ. Они предоставляют определенные "строительные блоки" и объясняют как из них строить. Разработчик прикладного обеспечения создает каркас, в который должны вписаться универсальные строительные блоки. Задача проектирования прикладных программ может иметь иное, более обязывающее решение: написать программу, которая сама будет создавать общий каркас области приложения. Разработчик прикладного обеспечения в качестве строительных блоков будет встраивать в этот каркас прикладные программы. Классы, которые образуют каркас области приложения, имеют настолько обширный интерфейс, что их трудно назвать типами в обычном смысле слова. Они приближаются к тому пределу, когда становятся чисто прикладными классами, но при этом в них фактически есть только описания, а все действия задаются функциями, написанными прикладными программистами.
Для примера рассмотрим фильтр, т.е. программу, которая может выполнять следующие действия: читать входной поток, производить над ним некоторые операции, выдавать выходной поток и определять конечный результат. Примитивный каркас для фильтра будет состоять из определения множества операций, которые должен реализовать прикладной программист:
class filter { public: class Retry { public: virtual const char* message() { return 0; } }; virtual void start() { } virtual int retry() { return 2; } virtual int read() = 0; virtual void write() { } virtual void compute() { } virtual int result() = 0; };
Нужные для производных классов функции описаны как чистые виртуальные, остальные функции просто пустые. Каркас содержит основной цикл обработки и зачаточные средства обработки ошибок:
int main_loop(filter* p) { for (;;) { try { p->start(); while (p->read()) { p->compute(); p->write(); } return p->result(); } catch (filter::Retry& m) { cout << m.message() << '\n'; int i = p->retry(); if (i) return i; } catch (...) { cout << "Fatal filter error\n"; return 1; } } }
Теперь прикладную программу можно написать так:
class myfilter : public filter { istream& is; ostream& os; char c; int nchar; public: int read() { is.get(c); return is.good(); } void compute() { nchar++; }; int result() { os << nchar << "characters read\n"; return 0; } myfilter(istream& ii, ostream& oo) : is(ii), os(oo), nchar(0) { } };
и вызывать ее следующим образом:
int main() { myfilter f(cin,cout); return main_loop(&f); }
Настоящий каркас, чтобы рассчитывать на применение в реальных задачах, должен создавать более развитые структуры и предоставлять больше полезных функций, чем в нашем простом примере. Как правило, каркас образует дерево узловых классов. Прикладной программист поставляет только классы, служащие листьями в этом многоуровневом дереве, благодаря чему достигается общность между различными прикладными программами и упрощается повторное использование полезных функций, предоставляемых каркасом. Созданию каркаса могут способствовать библиотеки, в которых определяются некоторые полезные классы, например, такие как scrollbar ( 12.2.5) и dialog_box ( 13.4). После определения своих прикладных классов программист может использовать эти классы.
13.8 Интерфейсные классы
Про один из самых важных видов классов обычно забывают - это "скромные" интерфейсные классы. Такой класс не выполняет какой-то большой работы, ведь иначе, его не называли бы интерфейсным. Задача интерфейсного класса приспособить некоторую полезную функцию к определенному контексту. Достоинство интерфейсных классов в том, что они позволяют совместно использовать полезную функцию, не загоняя ее в жесткие рамки. Действительно, невозможно рассчитывать, что функция сможет сама по себе одинаково хорошо удовлетворить самые разные запросы.
Интерфейсный класс в чистом виде даже не требует генерации кода. Вспомним описание шаблона типа Splist из 8.3.2:
template<class T> class Splist : private Slist<void*> { public: void insert(T* p) { Slist<void*>::insert(p); } void append(T* p) { Slist<void*>::append(p); } T* get() { return (T*) Slist<void*>::get(); } };
Класс Splist преобразует список ненадежных обобщенных указателей типа void* в более удобное семейство надежных классов, представляющих списки. Чтобы применение интерфейсных классов не было слишком накладно, нужно использовать функции-подстановки. В примерах, подобных приведенному, где задача функций-подстановок только подогнать тип, накладные расходы в памяти и скорости выполнения программы не возникают.
Естественно, можно считать интерфейсным абстрактный базовый класс, который представляет абстрактный тип, реализуемый конкретными типами ( 13.3), также как и управляющие классы из раздела 13.9. Но здесь мы рассматриваем классы, у которых нет иных назначений - только задача адаптации интерфейса.
Рассмотрим задачу слияния двух иерархий классов с помощью множественного наследования. Как быть в случае коллизии имен, т.е. ситуации, когда в двух классах используются виртуальные функции с одним именем, производящие совершенно разные операции? Пусть есть видеоигра под названием "Дикий запад", в которой диалог с пользователем организуется с помощью окна общего вида (класс Window):
class Window { // ... virtual void draw(); }; class Cowboy { // ... virtual void draw(); }; class CowboyWindow : public Cowboy, public Window { // ... };
В этой игре класс CowboyWindow представляет движение ковбоя на экране и управляет взаимодействием игрока с ковбоем. Очевидно, появится много полезных функций, определенных в классе Window и Cowboy, поэтому предпочтительнее использовать множественное наследование, чем описывать Window или Cowboy как члены. Хотелось бы передавать этим функциям в качестве параметра объект типа CowboyWindow, не требуя от программиста указания каких-то спецификаций объекта. Здесь как раз и возникает вопрос, какую функции выбрать для CowboyWindow: Cowboy::draw() или Window::draw().
В классе CowboyWindow может быть только одна функция с именем draw(), но поскольку полезная функция работает с объектами Cowboy или Window и ничего не знает о CowboyWindow, в классе CowboyWindow должны подавляться (переопределяться) и функция Cowboy::draw(), и функция Window_draw(). Подавлять обе функции с помощью одной - draw() неправильно, поскольку, хотя используется одно имя, все же все функции draw() различны и не могут переопределяться одной.
Наконец, желательно, чтобы в классе CowboyWindow наследуемые функции Cowboy::draw() и Window::draw() имели различные однозначно заданные имена.
Для решения этой задачи нужно ввести дополнительные классы для Cowboy и Window. Вводится два новых имени для функций draw() и гарантируется, что их вызов в классах Cowboy и Window приведет к вызову функций с новыми именами:
class CCowboy : public Cowboy { virtual int cow_draw(int) = 0; void draw() { cow_draw(i); } // переопределение Cowboy::draw }; class WWindow : public Window { virtual int win_draw() = 0; void draw() { win_draw(); } // переопределение Window::draw };
Теперь с помощью интерфейсных классов CCowboy и WWindow можно определить класс CowboyWindow и сделать требуемые переопределения функций cow_draw() и win_draw:
class CowboyWindow : public CCowboy, public WWindow { // ... void cow_draw(); void win_draw(); };
Отметим, что в действительности трудность возникла лишь потому, что у обеих функций draw() одинаковый тип параметров. Если бы типы параметров различались, то обычные правила разрешения неоднозначности при перегрузке гарантировали бы, что трудностей не возникнет, несмотря на наличие различных функций с одним именем.
Для каждого случая использования интерфейсного класса можно предложить такое расширение языка, чтобы требуемая адаптация проходила более эффективно или задавалась более элегантным способом. Но такие случаи являются достаточно редкими, и нет смысла чрезмерно перегружать язык, предоставляя специальные средства для каждого отдельного случая. В частности, случай коллизии имен при слиянии иерархий классов довольно редки, особенно если сравнивать с тем, насколько часто программист создает классы. Такие случаи могут возникать при слиянии иерархий классов из разных областей (как в нашем примере: игры и операционные системы). Слияние таких разнородных структур классов всегда непростая задача, и разрешение коллизии имен является в ней далеко не самой трудной частью. Здесь возникают проблемы из-за разных стратегий обработки ошибок, инициализации, управления памятью. Пример, связанный с коллизией имен, был приведен потому, что предложенное решение: введение интерфейсных классов с функциями-переходниками, - имеет много других применений. Например, с их помощью можно менять не только имена, но и типы параметров и возвращаемых значений, вставлять определенные динамические проверки и т.д.
Функции-переходники CCowboy::draw() и WWindow_draw являются виртуальными, и простая оптимизация с помощью подстановки невозможна. Однако, есть возможность, что транслятор распознает такие функции и удалит их из цепочки вызовов.
Интерфейсные функции служат для приспособления интерфейса к запросам пользователя. Благодаря им в интерфейсе собираются операции, разбросанные по всей программе. Обратимся к классу vector из 1.4.
Для таких векторов, как и для массивов, индекс отсчитывается от нуля. Если пользователь хочет работать с диапазоном индексов, отличным от диапазона 0..size-1, нужно сделать соответствующие приспособления, например, такие:
void f() { vector v(10); // диапазон [0:9] // как будто v в диапазоне [1:10]: for (int i = 1; i<=10; i++) { v[i-1] = ... // не забыть пересчитать индекс } // ... }
Лучшее решение дает класс vec c произвольными границами индекса:
class vec : public vector { int lb; public: vec(int low, int high) : vector(high-low+1) { lb=low; } int& operator[](int i) { return vector::operator[](i-lb); } int low() { return lb; } int high() { return lb+size() - 1; } };
Класс vec можно использовать без дополнительных операций, необходимых в первом примере:
void g() { vec v(1,10); // диапазон [1:10] for (int i = 1; i<=10; i++) { v[i] = ... } // ... }
Очевидно, вариант с классом vec нагляднее и безопаснее.
Интерфейсные классы имеют и другие важные области применения, например, интерфейс между программами на С++ и программами на другом языке ( 12.1.4) или интерфейс с особыми библиотеками С++.