Абстракция данных
Модульное программирование предполагает группировку всех данных одного типа вокруг одного модуля, управляющего этим типом. Если потребуются стеки двух разных видов, можно определить управляющий ими модуль с таким интерфейсом:
class stack_id { /* ... */ }; // stack_id только тип
// никакой информации о стеках
// здесь не содержится
stack_id create_stack ( int size ); // создать стек и возвратить
// его идентификатор
void push ( stack_id, char );
char pop ( stack_id );
destroy_stack ( stack_id ); // уничтожение стека
Конечно такое решение намного лучше, чем хаос, свойственный традиционным, неструктурированным решениям, но моделируемые таким способом типы совершенно очевидно отличаются от "настоящих", встроенных. Каждый управляющий типом модуль должен определять свой собственный алгоритм создания "переменных" этого типа. Не существует универсальных правил присваивания идентификаторов, обозначающих объекты такого типа. У "переменных" таких типов не существует имен, которые были бы известны транслятору или другим системным программам, и эти "переменные" не подчиняются обычным правилам областей видимости и передачи параметров.
Тип, реализуемый управляющим им модулем, по многим важным аспектам существенно отличается от встроенных типов. Такие типы не получают той поддержки со стороны транслятора (разного вида контроль), которая обеспечивается для встроенных типов. Проблема здесь в том, что программа формулируется в терминах небольших (одно-два слова) дескрипторов объектов, а не в терминах самих объектов ( stack_id может служить примером такого дескриптора). Это означает, что транслятор не сможет отловить глупые, очевидные ошибки, вроде тех, что допущены в приведенной ниже функции:
void f ()
{
stack_id s1;
stack_id s2;
s1 = create_stack ( 200 );
// ошибка: забыли создать s2
push ( s1,'a' );
char c1 = pop ( s1 );
destroy_stack ( s2 ); // неприятная ошибка
// ошибка: забыли уничтожить s1
s1 = s2; // это присваивание является по сути
// присваиванием указателей,
// но здесь s2 используется после уничтожения
}
Иными словами, концепция модульности, поддерживающая парадигму упрятывания данных, не запрещает такой стиль программирования, но и не способствует ему.
В языках Ада, Clu, С++ и подобных им эта трудность преодолевается благодаря тому, что пользователю разрешается определять свои типы, которые трактуются в языке практически так же, как встроенные. Такие типы обычно называют абстрактными типами данных, хотя лучше, пожалуй, их называть просто пользовательскими. Более строгим определением абстрактных типов данных было бы их математическое определение. Если бы удалось его дать, то, что мы называем в программировании типами, было бы конкретным представлением действительно абстрактных сущностей. Как определить "более абстрактные" типы, показано в $$4.6. Парадигму же программирования можно выразить теперь так:
Определите, какие типы вам нужны; предоставьте полный набор операций для каждого типа.
Если нет необходимости в разных объектах одного типа, то стиль программирования, суть которого сводится к упрятыванию данных, и следование которому обеспечивается с помощью концепции модульности, вполне адекватен этой парадигме.
Арифметические типы, подобные типам рациональных и комплексных чисел, являются типичными примерами пользовательских типов:
class complex
{
double re, im;
public:
complex(double r, double i) { re=r; im=i; }
complex(double r) // преобразование float->complex
{ re=r; im=0; }
friend complex operator+(complex, complex);
friend complex operator-(complex, complex); // вычитание
friend complex operator-(complex) // унарный минус
friend complex operator*(complex, complex);
friend complex operator/(complex, complex);
// ...
};
Описание класса (т.е. определяемого пользователем типа) complex задает представление комплексного числа и набор операций с комплексными числами. Представление является частным (private): re и im доступны только для функций, указанных в описании класса complex. Подобные функции могут быть определены так:
complex operator + ( complex a1, complex a2 )
{
return complex ( a1.re + a2.re, a1.im + a2.im );
}
и использоваться следующим образом:
void f ()
{
complex a = 2.3;
complex b = 1 / a;
complex c = a + b * complex ( 1, 2.3 );
// ...
c = - ( a / b ) + 2;
}
Большинство модулей (хотя и не все) лучше определять как пользовательские типы.
Абстрактные классы
Многие классы сходны с классом employee тем, что в них можно дать разумное определение виртуальным функциям. Однако, есть и другие классы. Некоторые, например, класс shape, представляют абстрактное понятие (фигура), для которого нельзя создать объекты. Класс shape приобретает смысл только как базовый класс в некотором производном классе. Причиной является то, что невозможно дать осмысленное определение виртуальных функций класса shape:
class shape {
// ...
public:
virtual void rotate(int) { error("shape::rotate"); }
virtual void draw() { error("shape::draw"): }
// нельзя ни вращать, ни рисовать абстрактную фигуру
// ...
};
Создание объекта типа shape (абстрактной фигуры) законная, хотя совершенно бессмысленная операция:
shape s; // бессмыслица: ``фигура вообще''
Она бессмысленна потому, что любая операция с объектом s приведет к ошибке.
Лучше виртуальные функции класса shape описать как чисто виртуальные. Сделать виртуальную функцию чисто виртуальной можно, добавив инициализатор = 0:
class shape {
// ...
public:
virtual void rotate(int) = 0; // чисто виртуальная функция
virtual void draw() = 0; // чисто виртуальная функция
};
Класс, в котором есть виртуальные функции, называется абстрактным. Объекты такого класса создать нельзя:
shape s; // ошибка: переменная абстрактного класса shape
Абстрактный класс можно использовать только в качестве базового для другого класса:
class circle : public shape {
int radius;
public:
void rotate(int) { } // нормально:
// переопределение shape::rotate
void draw(); // нормально:
// переопределение shape::draw
circle(point p, int r);
};
Если чисто виртуальная функция не определяется в производном классе, то она и остается таковой, а значит производный класс тоже является абстрактным. При таком подходе можно реализовывать классы поэтапно:
class X {
public:
virtual void f() = 0;
virtual void g() = 0;
};
X b; // ошибка: описание объекта абстрактного класса X
class Y : public X {
void f(); // переопределение X::f
};
Y b; // ошибка: описание объекта абстрактного класса Y
class Z : public Y {
void g(); // переопределение X::g
};
Z c; // нормально
Абстрактные классы нужны для задания интерфейса без уточнения каких-либо конкретных деталей реализации. Например, в операционной системе детали реализации драйвера устройства можно скрыть таким абстрактным классом:
class character_device {
public:
virtual int open() = 0;
virtual int close(const char*) = 0;
virtual int read(const char*, int) =0;
virtual int write(const char*, int) = 0;
virtual int ioctl(int ...) = 0;
// ...
};
Настоящие драйверы будут определяться как производные от класса character_device.
После введения абстрактного класса у нас есть все основные средства для того, чтобы написать законченную программу.
Абстрактные типы
Самый простой способ ослабить связь между пользователем класса и его создателем, а также между программами, в которых объекты создаются, и программами, в которых они используются, состоит в введении понятия абстрактных базовых классов. Эти классы представляют интерфейс со множеством реализаций одного понятия. Рассмотрим класс set, содержащий множество объектов типа T:
class set {
public:
virtual void insert(T*) = 0;
virtual void remove(T*) = 0;
virtual int is_member(T*) = 0;
virtual T* first() = 0;
virtual T* next() = 0;
virtual ~set() { }
};
Этот класс определяет интерфейс с произвольным множеством (set), опираясь на встроенное понятие итерации по элементам множества. Здесь типично отсутствие конструктора и наличие виртуального деструктора, см. также $$6.7. Рассмотрим пример:
class slist_set : public set, private slist {
slink* current_elem;
public:
void insert(T*);
void remove(T*);
int is_member(T*);
virtual T* first();
virtual T* next();
slist_set() : slist(), current_elem(0) { }
};
class vector_set : public set, private vector {
int current_index;
public:
void insert(T*);
void remove(T*);
int is_member(T*);
T* first() { current_index = 0; return next(); }
T* next();
vector_set(int initial_size)
: array(initial_size), current_index(0) { }
};
Реализация конкретного типа используется как частный базовый класс, а не член класса. Это сделано и для удобства записи, и потому, что некоторые конкретные типы могут иметь защищенный интерфейс с целью предоставить более прямой доступ к своим членам из производных классов. Кроме того, подобным образом в реализации могут использоваться некоторые классы, которые имеют виртуальные функции и не являются конкретными типами. Только с помощью образования производных классов можно в новом классе изящно переопределить (подавить) виртуальную функцию класса реализации. Интерфейс определяется абстрактным классом.
Теперь пользователь может записать свои функции из $$13.2 таким образом:
void my(set& s)
{
for (T* p = s.first(); p; p = s.next())
{
// мой код
}
// ...
}
void your(set& s)
{
for (T* p = s.first(); p; p = s.next())
{
// ваш код
}
// ...
}
Стало очевидным сходство между двумя функциями, и теперь достаточно иметь только одну версию для каждой из функций my() или your(), поскольку для общения с slist_set и vector_set обе версии используют интерфейс, определяемый классом set:
void user()
{
slist_set sl;
vector_set v(100);
my(sl);
your(v);
my(v);
your(sl);
}
Более того, создатели функций my() и your() не обязаны знать описаний классов slist_set и vector_set, и функции my() и your() никоим образом не зависят от этих описаний. Их не надо перетранслировать или как-то изменять, ни если изменились классы slist_set или vector_set ни даже, если предложена новая реализация этих классов. Изменения отражаются лишь на функциях, которые непосредственно используют эти классы, допустим vector_set. В частности, можно воспользоваться традиционным применением заголовочных файлов и включить в программы с функциями my() или your() файл определений set.h, а не файлы slist_set.h или vector_set.h.
В обычной ситуации операции абстрактного класса задаются как чистые виртуальные функции, и такой класс не имеет членов, представляющих данные (не считая скрытого указателя на таблицу виртуальных функций). Это объясняется тем, что добавление невиртуальной функции или члена, представляющего данные, потребует определенных допущений о классе, которые будут ограничивать возможные реализации. Изложенный здесь подход к абстрактным классам близок по духу традиционным методам, основанным на строгом разделении интерфейса и его реализаций. Абстрактный тип служит в качестве интерфейса, а конкретные типы представляют его реализации.
Такое разделение интерфейса и его реализаций предполагает недоступность операций, являющихся "естественными" для какой-то одной реализации, но не достаточно общими, чтобы войти в интерфейс. Например, поскольку в произвольном множестве нет упорядоченности, в интерфейс set нельзя включать операцию индексирования, даже если для реализации конкретного множества используется массив. Это приводит к ухудшению характеристик программы из-за отсутствия ручной оптимизации. Далее, становится как правило невозможной реализация функций подстановкой (если не считать каких-то конкретных ситуаций, когда настоящий тип известен транслятору), поэтому все полезные операции интерфейса, задаются как вызовы виртуальных функций. Как и для конкретных типов здесь плата за абстрактные типы иногда приемлема, иногда слишком высока.
Подводя итог, перечислим каким целям должен служить абстрактный тип:
[1] определять некоторое понятие таким образом, что в программе могут сосуществовать для него несколько реализаций;
[2] применяя виртуальные функции, обеспечивать достаточно высокую степень компактности и эффективности выполнения программы;
[3] сводить к минимуму зависимость любой реализации от других классов;
[4] представлять само по себе осмысленное понятие.
Нельзя сказать, что абстрактные типы лучше конкретных типов, это просто другие типы. Какие из них предпочесть - это, как правило, трудный и важный вопрос для пользователя. Создатель библиотеки может уклониться от ответа на него и предоставить варианты с обеими типами, тем самым выбор перекладывается на пользователя. Но здесь важно ясно понимать, с классом какого вида имеешь дело. Обычно неудачей заканчивается попытка ограничить общность абстрактного типа, чтобы скорость программ, работающих с ним, приблизилась к скорости программ, рассчитанных на конкретный тип. В этом случае нельзя использовать взаимозаменяемые реализации без большой перетрансляции программы после внесения изменений. Столь же неудачна бывает попытка дать "общность" в конкретных типах, чтобы они могли по мощности понятий приблизиться к абстрактным типам. Это снижает эффективность и применимость простых классов. Классы этих двух видов могут сосуществовать, и они должны мирно сосуществовать в программе. Конкретный класс воплощает реализацию абстрактного типа, и смешивать его с абстрактным классом не следует.
Отметим, что ни конкретные, ни абстрактные типы не создаются изначально как базовые классы для построения в дальнейшем производных классов. Построение производных к абстрактным типам классов скорее нужно для задания реализаций, чем для развития самого понятия интерфейса. Всякий конкретный или абстрактный тип предназначен для четкого и эффективного представления в программе отдельного онятия. Классы, которым это удается, редко бывают хорошими кандидатами для создания на их базе новых, но связанных с ними, классов. Действительно, попытки построить производные, "более развитые" классы на базе конкретных или абстрактных типов, таких как, строки, комплексные числа, списки или ассоциативные массивы приводят обычно к громоздким конструкциям. Как правило эти классы следует использовать как члены или частные базовые классы, тогда их можно эффективно применять, не вызывая путаницы и противоречий в интерфейсах и реализациях этих и новых классов.
Когда создается конкретный или абстрактный тип, акцент следует сделать на том, чтобы предложить простой, реализующий хорошо продуманное понятие, интерфейс. Попытки расширить область приложения класса, нагружая его описание всевозможными "полезными" свойствами, приводят только к беспорядку и неэффективности. Этим же кончаются напрасные усилия гарантировать повторное использование класса, когда каждую функцию-член объявляют виртуальной, не подумав зачем и как эти функции будут переопределяться.
Почему мы не стали определять классы slist и vector как прямые производные от класса set, обойдясь тем самым без классов slist_set и vector_set? Другими словами зачем нужны конкретные типы, когда уже определены абстрактные типы? Можно предложить три ответа:
[1] Эффективность: такие типы, как vector или slist надо создавать без накладных расходов, вызванных отдалением реализацийот интерфейсов (разделения интерфейса и реализации требует концепция абстрактного типа).
[2] Множественный интерфейс: часто разные понятия лучше всего реализовать как производные от одного класса.
[3] Повторное использование: нужен механизм, который позволит приспособить для нашей библиотеки типы, разработанные "где-то в другом месте".
Конечно, все эти ответы связаны. В качестве примера [2] рассмотрим понятие генератора итераций. Требуется определить генератор итераций (в дальнейшем итератор) для любого типа так, чтобы с его помощью можно было порождать последовательность объектов этого типа. Естественно для этого нужно использовать уже упоминавшийся класс slist. Однако, нельзя просто определить общий итератор над slist, или даже над set, поскольку общий итератор должен допускать итерации и более сложных объектов, не являющихся множествами, например, входные потоки или функции, которые при очередном вызове дают следующее значение итерации. Значит нам нужны и множество и итератор, и в тоже время нежелательно дублировать конкретные типы, которые являются очевидными реализациями различных видов множеств и итераторов. Можно графически представить желательную структуру классов так:
Здесь классы set и iter предоставляют интерфейсы, а slist и stream являются частными классами и представляют реализации. Очевидно, нельзя перевернуть эту иерархию классов и, предоставляя общие интерфейсы, строить производные конкретные типы от абстрактных классов. В такой иерархии каждая полезная операция над каждым полезным абстрактным понятием должна представляться в общем абстрактном базовом классе. Дальнейшее обсуждение этой темы содержится в $$13.6.
Приведем пример простого абстрактного типа, являющегося итератором объектов типа T:
class iter {
virtual T* first() = 0;
virtual T* next() = 0;
virtual ~iter() { }
};
class slist_iter : public iter, private slist {
slink* current_elem;
public:
T* first();
T* next();
slist_iter() : current_elem(0) { }
};
class input_iter : public iter {
isstream& is;
public:
T* first();
T* next();
input_iter(istream& r) : is(r) { }
};
Можно таким образом использовать определенные нами типы:
void user(const iter& it)
{
for (T* p = it.first(); p; p = it.next()) {
// ...
}
}
void caller()
{
slist_iter sli;
input_iter ii(cin);
// заполнение sli
user(sli);
user(ii);
}
Мы применили конкретный тип для реализации абстрактного типа, но можно использовать его и независимо от абстрактных типов или просто вводить такие типы для повышения эффективности программы, см. также $$13.5. Кроме того, можно использовать один конкретный тип для реализации нескольких абстрактных типов.
В разделе $$13.9 описывается более гибкий итератор. Для него зависимость от реализации, которая поставляет подлежащие итерации объекты, определяется в момент инициализации и может изменяться в ходе выполнения программы.
Альтернативные реализации
Пока описание общей части класса и функций-членов остается неизменным, можно, не влияя на пользователей класса, менять его реализацию. В подтверждение этого рассмотрим таблицу имен из программы калькулятора, приведенной в главе 3. Структура ее такова:
struct name {
char* string;
name* next;
double value;
};
А вот вариант класса table (таблица имен):
// файл table.h
class table {
name* tbl;
public:
table() { tbl = 0; }
name* look(char*, int = 0);
name* insert(char* s) { return look(s,1); }
};
Эта таблица отличается от определенной в главе 3 тем, что это настоящий тип. Можно описать несколько таблиц, завести указатель на таблицу и т.д. Например:
#include "table.h"
table globals;
table keywords;
table* locals;
main()
{
locals = new table;
// ...
}
Приведем реализацию функции table::look(), в которой используется линейный поиск в списке имен таблицы:
#include <string.h>
name* table::look(char* p, int ins)
{
for (name* n = tbl; n; n=n->next)
if (strcmp(p,n->string) == 0) return n;
if (ins == 0) error("имя не найдено");
name* nn = new name;
nn->string = new char[strlen(p)+1];
strcpy(nn->string,p);
nn->value = 1;
nn->next = tbl;
tbl = nn;
return nn;
}
Теперь усовершенствуем класс table так, чтобы поиск имени шел по ключу (хэш-функции от имени), как это и было сделано в примере с калькулятором. Сделать это труднее, если соблюдать ограничение, требующее, чтобы не все программы, использующие приведенную версию класса table, надо было изменять:
class table {
name** tbl;
int size;
public:
table(int sz = 15);
~table();
name* look(char*, int = 0);
name* insert(char* s) { return look(s,1); }
};
Изменения в структуре данных и конструкторе произошли потому, что для хэширования таблица должна иметь определенный размер. Задание конструктора со стандартным значением параметра гарантирует, что старые программы, в которых не использовался размер таблицы, останутся верными. Стандартные значения параметров полезны в таких случаях, когда нужно изменить класс, не влияя на программы пользователей класса. Теперь конструктор и деструктор создают и уничтожают хэшированные таблицы:
table::table(int sz)
{
if (sz < 0) error("размер таблицы отрицателен");
tbl = new name*[size = sz];
for ( int i = 0; i<sz; i++) tbl[i] = 0;
}
table::~table()
{
for (int i = 0; i<size; i++) {
name* nx;
for (name* n = tbl[i]; n; n=nx) {
nx = n->next;
delete n->string;
delete n;
}
}
delete tbl;
}
Описав деструктор для класса name, можно получить более ясный и простой вариант table::~table(). Функция поиска практически совпадает с приведенной в примере калькулятора ($$3.13):
name* table::look(const char* p, int ins)
{
int ii = 0;
char* pp = p;
while (*pp) ii = ii<<1 ^ *pp++;
if (ii < 0) ii = -ii;
ii %= size;
for (name* n=tbl[ii]; n; n=n->next)
if (strcmp(p,n->string) == 0) return n;
name* nn = new name;
nn->string = new char[strlen(p)+1];
strcpy(nn->string,p);
nn->value = 1;
nn->next = tbl[ii];
tbl[ii] = nn;
return nn;
}
Очевидно, что функции-члены класса должны перетранслироваться всякий раз, когда в описание класса вносится какое-либо изменение. В идеале такое изменение никак не должно отражаться на пользователях класса. К сожалению, обычно бывает не так. Для размещения переменной, имеющей тип класса, транслятор должен знать размер объекта класса. Если размер объекта изменится, нужно перетранслировать файлы, в которых использовался класс. Можно написать системную программу (и она даже уже написана), которая будет определять минимальное множество файлов, подлежащих перетрансляции после изменения класса. Но такая программа еще не получила широкого распространения.
Возможен вопрос: почему С++ был спроектирован таким образом, что после изменения частной части класса требуется перетрансляция программ пользователя? Почему вообще частная часть класса присутствует в описании класса? Иными словами, почему описания частных членов присутствуют в заголовочных файлах, доступных пользователю, если все равно недоступны для него в программе? Ответ один - эффективность. Во многих системах программирования процесс трансляции и последовательность команд, производящая вызов функции, будет проще, если размер автоматических (т.е. размещаемых в стеке) объектов известен на стадии трансляции.
Можно не знать определения всего класса, если представлять каждый объект как указатель на "настоящий" объект. Это позволяет решить задачу, поскольку все указатели будут иметь одинаковый размер, а размещение настоящих объектов будет проводиться только в одном файле, в котором доступны частные части классов. Однако, такое решение приводит к дополнительному расходу памяти на каждый объект и дополнительному обращению к памяти при каждом использовании члена. Еще хуже, что каждый вызов функции с автоматическим объектом класса требует вызовов функций выделения и освобождения памяти. К тому же становится невозможной реализация подстановкой функций-членов, работающих с частными членами класса. Наконец, такое изменение сделает невозможным связывание программ на С++ и на С, поскольку транслятор С будет по другому обрабатывать структуры (struct). Поэтому такое решение было сочтено неприемлемым для С++.
С другой стороны, С++ предоставляет средство для создания абстрактных типов, в которых связь между интерфейсом пользователя и реализацией довольно слабая. В главе 6 вводятся производные классы и описываются абстрактные базовые классы, а в $$13.3 поясняется, как с помощью этих средств реализовать абстрактные типы. Цель этого - дать возможность определять пользовательские типы столь же эффективные
и конкретные, как и стандартные, и дать основные средства определения
более гибких вариантов типов, которые могут оказаться и не столь эффективными.
Анализатор
Грамматика языка калькулятора определяется следующими правилами:
программа:
END // END - это конец ввода
список-выражений END
список-выражений:
выражение PRINT // PRINT - это '\n' или ';'
выражение PRINT список-выражений
выражение:
выражение + терм
выражение - терм
терм
терм:
терм / первичное
терм * первичное
первичное
первичное:
NUMBER // число с плавающей запятой в С++
NAME // имя в языке С++ за исключением '_'
NAME = выражение
- первичное
( выражение )
Иными словами, программа есть последовательность строк, а каждая строка содержит одно или несколько выражений, разделенных точкой с запятой. Основные элементы выражения - это числа, имена и операции *, /, +, - (унарный и бинарный минус) и =. Имена необязательно описывать до использования.
Для синтаксического анализа используется метод, обычно называемый рекурсивным спуском. Это распространенный и достаточно очевидный метод. В таких языках как С++, то есть в которых операция вызова не сопряжена с большими накладными расходами, это метод эффективен.
Для каждого правила грамматики имеется своя функция, которая вызывает
другие функции. Терминальные символы (например, END, NUMBER, + и -) распознаются лексическим анализатором get_token(). Нетерминальные символы распознаются функциями синтаксического анализатора expr(), term() и prim(). Как только оба операнда выражения или подвыражения стали известны, оно вычисляется. В настоящем трансляторе в этот момент создаются команды, вычисляющие выражение.
Анализатор использует для ввода функцию get_token(). Значение последнего вызова get_token() хранится в глобальной переменной curr_tok. Переменная curr_tok принимает значения элементов перечисления token_value:
enum token_value {
NAME, NUMBER, END,
PLUS='+', MINUS='-', MUL='*', DIV='/',
PRINT=';', ASSIGN='=', LP='(', RP=')'
};
token_value curr_tok;
Для всех функций анализатора предполагается, что get_token() уже была вызвана, и поэтому в curr_tok хранится следующая лексема, подлежащая анализу. Это позволяет анализатору заглядывать на одну лексему вперед. Каждая функция анализатора всегда читает на одну лексему больше, чем нужно для распознавания того правила, для которого она вызывалась. Каждая функция анализатора вычисляет "свое" выражение и возвращает его результат. Функция expr() обрабатывает сложение и вычитание. Она состоит из одного цикла, в котором распознанные термы складываются или вычитаются:
double expr() // складывает и вычитает
{
double left = term();
for(;;) // ``вечно''
switch(curr_tok) {
case PLUS:
get_token(); // случай '+'
left += term();
break;
case MINUS:
get_token(); // случай '-'
left -= term();
break;
default:
return left;
}
}
Сама по себе эта функция делает немного. Как принято в высокоуровневых функциях больших программ, она выполняет задание, вызывая другие функции. Отметим, что выражения вида 2-3+4 вычисляются как (2-3)+4, что предопределяется правилами грамматики. Непривычная запись for(;;) - это стандартный способ задания бесконечного цикла, и его можно обозначить словом "вечно". Это вырожденная форма оператора for, и альтернативой ей может служить оператор while(1). Оператор switch выполняется повторно до тех пор, пока не перестанут появляться операции + или - , а тогда по умолчанию выполняется оператор return (default).
Операции += и -= используются для выполнения операций сложения и вычитания. Можно написать эквивалентные присваивания: left=left+term() и left=left-term(). Однако вариант left+=term() и left-=term() не только короче, но и более четко определяет требуемое действие. Для бинарной операции @ выражение x@=y означает x=x@y, за исключением того, что x вычисляется только один раз. Это применимо к бинарным операциям:
+ - * / % & | ^ << >>
поэтому возможны следующие операции присваивания:
+= -= *= /= %= &= |= ^= <<= >>=
Каждая операция является отдельной лексемой, поэтому a + =1 содержит синтаксическую ошибку (из-за пробела между + и =). Расшифровка операций следующая: % - взятие остатка, &, | и ^ - разрядные логические операции И, ИЛИ и Исключающее ИЛИ; << и >> сдвиг влево и сдвиг вправо. Функции term() и get_token() должны быть описаны до определения expr(). В главе 4 рассматривается построение программы в виде совокупности файлов. За одним исключением, все программы калькулятора можно составить так, чтобы в них все объекты описывались только один раз и до их использования. Исключением является функция expr(), которая вызывает функцию term(), а она, в свою очередь, вызывает prim(), и уже та, наконец,
вызывает expr(). Этот цикл необходимо как-то разорвать, для чего вполне
подходит заданное до определения prim() описание:
double expr(); // это описание необходимо
Функция term() справляется с умножением и делением аналогично тому, как функция expr() со сложением и вычитанием:
double term() // умножает и складывает
{
double left = prim();
for(;;)
switch(curr_tok) {
case MUL:
get_token(); // случай '*'
left *= prim();
break;
case DIV:
get_token(); // случай '/'
double d = prim();
if (d == 0) return error("деление на 0");
left /= d;
break;
default:
return left;
}
}
Проверка отсутствия деления на нуль необходима, поскольку результат деления на нуль неопределен и, как правило, приводит к катастрофе.
Функция error() будет рассмотрена позже. Переменная d появляется в программе там, где она действительно нужна, и сразу же инициализируется. Во многих языках описание может находиться только в начале блока. Но такое ограничение может искажать естественную структуру программы и способствовать появлению ошибок. Чаще всего не инициализированные локальные переменные свидетельствуют о плохом стиле программирования. Исключение составляют те переменные, которые инициализируются операторами ввода, и переменные типа массива или структуры, для которых нет традиционной инициализации с помощью одиночных присваиваний. Следует напомнить, что = является операцией присваивания, тогда как == есть операция сравнения.
Функция prim, обрабатывающая первичное, во многом похожа на функции expr и term(). Но раз мы дошли до низа в иерархии вызовов, то в ней кое-что придется сделать. Цикл для нее не нужен:
double number_value;
char name_string[256];
double prim() // обрабатывает первичное
{
switch (curr_tok) {
case NUMBER: // константа с плавающей точкой
get_token();
return number_value;
case NAME:
if (get_token() == ASSIGN) {
name* n = insert(name_string);
get_token();
n->value = expr();
return n->value;
}
return look(name_string)->value;
case MINUS: // унарный минус
get_token();
return -prim();
case LP:
get_token();
double e = expr();
if (curr_tok != RP) return error("требуется )");
get_token();
return e;
case END:
return 1;
default:
return error("требуется первичное");
}
}
Когда появляется NUMBER (то есть константа с плавающей точкой), возвращается ее значение. Функция ввода get_token() помещает значение константы в глобальную переменную number_value. Если в программе используются глобальные переменные, то часто это указывает на то, что структура не до конца проработана, и поэтому требуется некоторая оптимизация. Именно так обстоит дело в данном случае. В идеале лексема должна состоять из двух частей: значения, определяющего вид лексемы (в данной программе это token_value), и (если необходимо) собственно значения лексемы. Здесь же имеется только одна простая переменная curr_tok, поэтому для хранения последнего прочитанного значения NUMBER требуется глобальная переменная number_value. Такое решение проходит потому, что калькулятор во всех вычислениях вначале выбирает одно число, а затем считывает другое из входного потока. В качестве упражнения предлагается избавиться от этой излишней глобальной переменной ($$3.5 [15]).
Если последнее значение NUMBER хранится в глобальной переменной number_value, то строковое представление последнего значения NAME хранится в name_string. Перед тем, как что-либо делать с именем, калькулятор должен заглянуть вперед, чтобы выяснить, будет ли ему присваиваться значение, или же будет только использоваться существующее его значение. В обоих случаях надо обратиться к таблице имен. Эта таблица рассматривается в $$3.1.3; а здесь достаточно только знать, что она состоит из записей, имеющих вид:
struct name {
char* string;
name* next;
double value;
};
Член next используется только служебными функциями, работающими с таблицей:
name* look(const char*);
name* insert(const char*);
Обе функции возвращают указатель на ту запись name, которая соответствует
их параметру-строке. Функция look() "ругается", если имя не было занесено в таблицу. Это означает, что в калькуляторе можно использовать имя без предварительного описания, но в первый раз оно может появиться только в левой части присваивания.
Ассоциативный массив
Из всех универсальных невстроенных типов самым полезным, по всей видимости, является ассоциативный массив. Его часто называют таблицей (map), а иногда словарем, и он хранит пары значений. Имея одно из значений, называемое ключом, можно получить доступ к другому, называемому просто значением. Ассоциативный массив можно представлять как массив, в котором индекс не обязан быть целым:
template<class K, class V> class Map {
// ...
public:
V& operator[](const K&); // найти V, соответствующее K
// и вернуть ссылку на него
// ...
};
Здесь ключ типа K обозначает значение типа V. Предполагается, что ключи можно сравнивать с помощью операций == и <, так что массив можно хранить в упорядоченном виде. Отметим, что класс Map отличается от типа assoc из $$7.8 тем, что для него нужна операция "меньше чем", а не функция хэширования.
Приведем простую программу подсчета слов, в которой используются шаблон Map и тип String:
#include <String.h>
#include <iostream.h>
#include "Map.h"
int main()
{
Map<String,int> count;
String word;
while (cin >> word) count[word]++;
for (Mapiter<String,int> p = count.first(); p; p++)
cout << p.value() << '\t' << p.key() << '\n';
return 0;
}
Мы используем тип String для того, чтобы не беспокоиться о выделении памяти и переполнении ее, о чем приходится помнить, используя тип char*. Итератор Mapiter нужен для выбора по порядку всех значений массива. Итерация в Mapiter задается как имитация работы с указателями. Если входной поток имеет вид
It was new. It was singular. It was simple. It must succeed.
программа выдаст
4 It
1 must
1 new.
1 simple.
1 singular.
1 succeed.
3 was.
Конечно, определить ассоциативный массив можно многими способами, а, имея определение Map и связанного с ним класса итератора, мы можем предложить много способов для их реализации. Здесь выбран тривиальный способ реализации. Используется линейный поиск, который не подходит для больших массивов. Естественно, рассчитанная на коммерческое применение реализация будет создаваться, исходя из требований быстрого поиска и компактности представления (см. упражнение 4 из $$8.9).
Мы используем список с двойной связью Link:
template<class K, class V> class Map;
template<class K, class V> class Mapiter;
template<class K, class V> class Link {
friend class Map<K,V>;
friend class Mapiter<K,V>;
private:
const K key;
V value;
Link* pre;
Link* suc;
Link(const K& k, const V& v) : key(k), value(v) { }
~Link() { delete suc; } // рекурсивное удаление всех
// объектов в списке
};
Каждый объект Link содержит пару (ключ, значение). Классы описаны в Link как друзья, и это гарантирует, что объекты Link можно создавать, работать с ними и уничтожать только с помощью соответствующих классов итератора и Map. Обратите внимание на предварительные описания шаблонных классов Map и Mapiter.
Шаблон Map можно определить так:
template<class K, class V> class Map {
friend class Mapiter<K,V>;
Link<K,V>* head;
Link<K,V>* current;
V def_val;
K def_key;
int sz;
void find(const K&);
void init() { sz = 0; head = 0; current = 0; }
public:
Map() { init(); }
Map(const K& k, const V& d)
: def_key(k), def_val(d) { init(); }
~Map() { delete head; } // рекурсивное удаление
// всех объектов в списке
Map(const Map&);
Map& operator= (const Map&);
V& operator[] (const K&);
int size() const { return sz; }
void clear() { delete head; init(); }
void remove(const K& k);
// функции для итерации
Mapiter<K,V> element(const K& k)
{
(void) operator[](k); // сделать k текущим элементом
return Mapiter<K,V>(this,current);
}
Mapiter<K,V> first();
Mapiter<K,V> last();
};
Элементы хранятся в упорядоченном списке с дойной связью. Для простоты ничего не делается для ускорения поиска (см. упражнение 4 из $$8.9). Ключевой здесь является функция operator[]():
template<class K, class V>
V& Map<K,V>::operator[] (const K& k)
{
if (head == 0) {
current = head = new Link<K,V>(k,def_val);
current->pre = current->suc = 0;
return current->value;
}
Link<K,V>* p = head;
for (;;) {
if (p->key == k) { // найдено
current = p;
return current->value;
}
if (k < p->key) { // вставить перед p (в начало)
current = new Link<K,V>(k,def_val);
current->pre = p->pre;
current->suc = p;
if (p == head) // текущий элемент становится начальным
head = current;
else
p->pre->suc = current;
p->pre = current;
return current->value;
}
Link<K,V>* s = p->suc;
if (s == 0) { // вставить после p (в конец)
current = new Link<K,V>(k,def_val);
current->pre = p;
current->suc = 0;
p->suc = current;
return current->value;
}
p = s;
}
}
Операция индексации возвращает ссылку на значение, которое соответствует заданному как параметр ключу. Если такое значение не найдено, возвращается новый элемент со стандартным значением. Это позволяет использовать операцию индексации в левой части присваивания. Стандартные значения для ключей и значений устанавливаются конструкторами Map. В операции индексации определяется значение current, используемое итераторами.
Реализация остальных функций-членов оставлена в качестве упражнения:
template<class K, class V>
void Map<K,V>::remove(const K& k)
{
// см. упражнение 2 из $$8.10
}
template<class K, class V>
Map<K,V>::Map(const Map<K,V>& m)
{
// копирование таблицы Map и всех ее элементов
}
template<class K, class V>
Map& Map<K,V>::operator=(const Map<K,V>& m)
{
// копирование таблицы Map и всех ее элементов
}
Теперь нам осталось только определить итерацию. В классе Map есть функции-члены first(), last() и element(const K&), которые возвращают итератор, установленный соответственно на первый, последний или задаваемый ключом-параметром элемент. Сделать это можно, поскольку элементы хранятся в упорядоченном по ключам виде. Итератор Mapiter для Map определяется так:
template<class K, class V> class Mapiter {
friend class Map<K,V>;
Map<K,V>* m;
Link<K,V>* p;
Mapiter(Map<K,V>* mm, Link<K,V>* pp)
{ m = mm; p = pp; }
public:
Mapiter() { m = 0; p = 0; }
Mapiter(Map<K,V>& mm);
operator void*() { return p; }
const K& key();
V& value();
Mapiter& operator--(); // префиксная
void operator--(int); // постфиксная
Mapiter& operator++(); // префиксная
void operator++(int); // постфиксная
};
После позиционирования итератора функции key() и value() из Mapiter выдают ключ и значение того элемента, на который установлен итератор.
template<class K, class V> const K& Mapiter<K,V>::key()
{
if (p) return p->key; else return m->def_key;
}
template<class K, class V> V& Mapiter<K,V>::value()
{
if (p) return p->value; else return m->def_val;
}
По аналогии с указателями определены операции ++ и -- для продвижения по элементам Map вперед и назад:
Mapiter<K,V>& Mapiter<K,V>::operator--() //префиксный декремент
{
if (p) p = p->pre;
return *this;
}
void Mapiter<K,V>::operator--(int) // постфиксный декремент
{
if (p) p = p->pre;
}
Mapiter<K,V>& Mapiter<K,V>::operator++() // префиксный инкремент
{
if (p) p = p->suc;
return *this;
}
void Mapiter<K,V>::operator++(int) // постфиксный инкремент
{
if (p) p = p->suc;
}
Постфиксные операции определены так, что они не возвращают никакого значения. Дело в том, что затраты на создание и передачу нового объекта Mapiter на каждом шаге итерации значительны, а польза от него будет не велика.
Объект Mapiter можно инициализировать так, чтобы он был установлен на начало Map:
template<class K, class V> Mapiter<K,V>::Mapiter(Map<K,V>& mm)
{
m == &mm; p = m->head;
}
Операция преобразования operator void*() возвращает нуль, если итератор не установлен на элемент Map, и ненулевое значение иначе. Значит можно проверять итератор iter, например, так:
void f(Mapiter<const char*, Shape*>& iter)
{
// ...
if (iter) {
// установлен на элемент таблицы
}
else {
// не установлен на элемент таблицы
}
// ...
}
Аналогичный прием используется для контроля потоковых операций ввода-вывода в $$10.3.2.
Если итератор не установлен на элемент таблицы, его функции key() и value() возвращают ссылки на стандартные объекты.
Если после всех этих определений вы забыли их назначение, можно привести еще одну небольшую программу, использующую таблицу Map. Пусть входной поток является списком пар значений следующего вида:
hammer 2
nail 100
saw 3
saw 4
hammer 7
nail 1000
nail 250
Нужно отсортировать список так, чтобы значения, соответствующие одному предмету, складывались, и напечатать получившийся список вместе с итоговым значением:
hammer 9
nail 1350
saw 7
-------------------
total 1366
Вначале напишем функцию, которая читает входные строки и заносит предметы с их количеством в таблицу. Ключом в этой таблице является первое слово строки:
template<class K, class V>
void readlines(Map<K,V>&key)
{
K word;
while (cin >> word) {
V val = 0;
if (cin >> val)
key[word] +=val;
else
return;
}
}
Теперь можно написать простую программу, вызывающую функцию readlines() и печатающую получившуюся таблицу:
main()
{
Map<String,int> tbl("nil",0);
readlines(tbl);
int total = 0;
for (Mapiter<String,int> p(tbl); p; ++p) {
int val = p.value();
total +=val;
cout << p.key() << '\t' << val << '\n';
}
cout << "--------------------\n";
cout << "total\t" << total << '\n';
}
Библиотека фигур
Начнем с определения общего понятия фигуры. Определение должно быть таким, чтобы им можно было воспользоваться (как базовым классом shape) в разных классах, представляющих все конкретные фигуры (окружности, квадраты и т.д.). Оно также должно позволять работать со всякой фигурой исключительно с помощью интерфейса, определяемого классом shape:
struct shape {
static shape* list;
shape* next;
shape() { next = list; list = this; }
virtual point north() const = 0;
virtual point south() const = 0;
virtual point east() const = 0;
virtual point west() const = 0;
virtual point neast() const = 0;
virtual point seast() const = 0;
virtual point nwest() const = 0;
virtual point swest() const = 0;
virtual void draw() = 0;
virtual void move(int, int) = 0;
};
Фигуры помещаются на экран функцией draw(), а движутся по нему с помощью move(). Фигуры можно помещать относительно друг друга, используя понятие точек контакта. Для обозначения точек контакта используются названия сторон света в компасе: north - север, ... , neast - северо-восток, ... , swest - юго-запад. Класс каждой конкретной фигуры сам определяет смысл этих точек и определяет, как рисовать фигуру. Конструктор shape::shape() добавляет фигуру к списку фигур shape::list. Для построения этого списка используется член next, входящий в каждый объект shape. Поскольку нет смысла в объектах типа общей фигуры, класс shape определен как абстрактный класс.
Для задания отрезка прямой нужно указать две точки или точку и целое. В последнем случае отрезок будет горизонтальным, а целое задает его длину. Знак целого показывает, где должна находиться заданная точка относительно конечной точки, т.е. слева или справа от нее:
class line : public shape {
/*
отрезок прямой ["w", "e" ]
north() определяет точку - `` выше центра отрезка и
так далеко на север, как самая его северная точка''
*/
point w, e;
public:
point north() const { return point((w.x+e.x)/2,e.y<w.y?w.y:e:y); }
Бинарные и унарные операции
Бинарную операцию можно определить как функцию-член с одним параметром, или как глобальную функцию с двумя параметрами. Значит, для любой бинарной операции @ выражение aa @ bb интерпретируется либо как aa.operator(bb), либо как operator@(aa,bb). Если определены обе функции, то выбор интерпретации происходит по правилам сопоставления параметров ($$R.13.2). Префиксная или постфиксная унарная операция может определяться как функция-член без параметров, или как глобальная функция с одними параметром. Для любой префиксной унарной операции @ выражение @aa интерпретируется либо как aa.operator@(), либо как operator@(aa). Если определены обе функции, то выбор интерпретации происходит по правилам сопоставления параметров ($$R.13.2). Для любой постфиксной унарной операции @ выражение @aa интерпретируется либо как aa.operator@(int), либо как operator@(aa,int). Подробно это объясняется в $$7.10. Если определены обе функции, то выбор интерпретации происходит по правилам сопоставления параметров ($$13.2). Операцию можно определить только в соответствии с синтаксическими правилами, имеющимися для нее в грамматике С++. В частности, нельзя определить % как унарную операцию, а + как тернарную. Проиллюстрируем сказанное примерами:
class X {
// члены (неявно используется указатель `this'):
X* operator&(); // префиксная унарная операция &
// (взятие адреса)
X operator&(X); // бинарная операция & (И поразрядное)
X operator++(int); // постфиксный инкремент
X operator&(X,X); // ошибка: & не может быть тернарной
X operator/(); // ошибка: / не может быть унарной
};
// глобальные функции (обычно друзья)
X operator-(X); // префиксный унарный минус
X operator-(X,X); // бинарный минус
X operator--(X&,int); // постфиксный инкремент
X operator-(); // ошибка: нет операнда
X operator-(X,X,X); // ошибка: тернарная операция
X operator%(X); // ошибка: унарная операция %
Операция [] описывается в $$7.7, операция () в $$7.8, операция -> в $$7.9, а операции ++ и -- в $$7.10.
Большие объекты
При выполнении любой бинарной операции для типа complex реализующей эту операцию функции будут передаваться как параметры копии обоих операндов. Дополнительные расходы, вызванные копированием двух значений типа double, заметны, хотя по всей видимости допустимы. К сожалению представление не всех классов является столь удобно компактным. Чтобы избежать избыточного копирования, можно определять функции с параметрами типа ссылки:
class matrix {
double m[4][4];
public:
matrix();
friend matrix operator+(const matrix&, const matrix&);
friend matrix operator*(const matrix&, const matrix&);
};
Ссылки позволяют без излишнего копирования использовать выражения с обычными арифметическими операциями и для больших объектов. Указатели для этой цели использовать нельзя, т.к. невозможно переопределить интерпретацию операции, если она применяется к указателю. Операцию плюс для матриц можно определить так:
matrix operator+(const matrix& arg1, const& arg2)
{
matrix sum;
for (int i = 0; i<4; i++)
for (int j=0; j<4; j++)
sum.m[i] [j] = arg1.m[i][j] + arg2.m[i][j];
return sum;
}
Здесь в функции operator+() операнды выбираются по ссылке, а возвращается само значение объекта. Более эффективным решением был бы возврат тоже ссылки:
class matrix {
// ...
friend matrix& operator+(const matrix&, const matrix&);
friend matrix& operator*(const matrix&, const matrix&);
};
Это допустимо, но возникает проблема с выделением памяти. Поскольку ссылка на результат операции будет передаваться как ссылка на возвращаемое функцией значение, оно не может быть автоматической переменной этой функции. Поскольку операция может использоваться неоднократно в одном выражении, результат не может быть и локальной статической переменной. Как правило, результат будет записываться в отведенный в свободной памяти объект. Обычно бывает дешевле (по затратам на время выполнения и память данных и команд) копировать результирующее значение, чем размещать его в свободной памяти и затем в конечном счете освобождать выделенную память. К тому же этот способ проще запрограммировать.
Буферизация
Все операции ввода-вывода были определены без всякой связи с типом файла, но нельзя одинаково работать со всеми устройствами без учета алгоритма буферизации. Очевидно, что потоку ostream, привязанному к строке символов, нужен не такой буфер, как ostream, привязанному к файлу. Такие вопросы решаются созданием во время инициализации разных буферов для потоков разных типов. Но существует только один набор операций над этими типами буферов, поэтому в ostream нет функций, код которых учитывает различие буферов. Однако, функции, следящие за переполнением и обращением к пустому буферу, являются виртуальными. Это хороший пример применения виртуальных функций для единообразной работы с эквивалентными логически, но различно реализованными структурами, и они вполне справляются с требуемыми алгоритмами буферизации. Описание буфера потока в файле <iostream.h> может выглядеть следующим образом:
class streambuf { // управление буфером потока
protected:
char* base; // начало буфера
char* pptr; // следующий свободный байт
char* gptr; // следующий заполненный байт
char* eptr; // один из указателей на конец буфера
char alloc; // буфер, размещенный с помощью "new"
//...
// Опустошить буфер:
// Вернуть EOF при ошибке, 0 - удача
virtual int overflow(int c = EOF);
// Заполнить буфер:
// Вернуть EOF в случае ошибки или конца входного потока,
// иначе вернуть очередной символ
virtual int underflow();
//...
public:
streambuf();
streambuf(char* p, int l);
virtual ~streambuf();
int snextc() // получить очередной символ
{
return (++gptr==pptr) ? underflow() : *gptr&0377;
}
int allocate(); // отвести память под буфер
//...
};
Подробности реализации класса streambuf приведены здесь только для полноты представления. Не предполагается, что есть общедоступные реализации, использующие именно эти имена. Обратите внимание на определенные здесь указатели, управляющие буфером; с их помощью простые посимвольные операции с потоком можно определить максимально эффективно (и причем однократно) как функции-подстановки. Только функции overflow() и underflow() требует своей реализации для каждого алгоритма буферизации, например:
class filebuf : public streambuf {
protected:
int fd; // дескриптор файла
char opened; // признак открытия файла
public:
filebuf() { opened = 0; }
filebuf( int nfd, char* p, int l)
: streambuf(p,l) { /* ... */ }
~filebuf() { close(); }
int overflow(int c=EOF);
int underflow();
filebuf* open(char *name, ios::open_mode om);
int close() { /* ... */ }
//...
};
int filebuf::underflow() // заполнить буфер из "fd"
{
if (!opened || allocate()==EOF) return EOF;
int count = read(fd, base, eptr-base);
if (count < 1) return EOF;
gptr = base;
pptr = base + count;
return *gptr & 0377; // &0377 предотвращает размножение знака
}
За дальнейшими подробностями обратитесь к руководству по реализации класса streambuf.
Цели и средства
Цель программирования - создать продукт, удовлетворяющий пользователя.
Важнейшим средством для достижении этой цели является создание программы с ясной внутренней структурой и воспитание коллектива программистов и разработчиков, имеющих достаточный опыт и мотивацию, чтобы быстро и эффективно реагировать на все изменения.
Почему это так? Ведь внутрення структура программы и процесс, с помощью которого она получена, в идеале никак не касаются конечного пользователя. Более того, если конечный пользователь почему-то интересуется тем, как написана программа, то что-то с этой программой не так. Почему, несмотря на это, так важны структура программы и люди, ее создавшие? В конце концов конечный пользователь ничего об этом не должен знать.
Ясная внутренняя структура программы облегчает:
- тестирование,
- переносимость,
- сопровождение,
- расширение,
- реорганизацию и
- понимание.
Главное здесь в том, что любая удачная большая программа имеет долгую жизнь, в течение которой над ней работают поколения программистов и разработчиков, она переносится на новую машину, приспосабливается к непредусмотренным требованиям и несколько раз перестраивается. Во все время жизни необходимо в приемлемое время и с допустимым числом ошибок выдавать версии программы. Не планировать все это - все равно, что запланировать неудачу.
Отметим, что, хотя в идеальном случае случае пользователи не должны знать внутреннюю структуру системы, на практике они обычно хотят ее знать. Например, пользователь может желать познакомиться в деталях с разработкой системы с целью научиться контролировать возможности и надежность системы на случай переделок и расширений. Если рассматриваемый программный продукт есть не полная система, а набор библиотек для получения программных систем, то пользователь захочет узнать побольше "деталей", чтобы они служили источником идей и помогали лучше использовать библиотеку.
Нужно уметь очень точно определить объем проектирования программы. Недостаточный объем приводит к бесконечному срезанию острых углов ("побыстрее передадим систему, а ошибку устраним в следующей версии"). Избыточный объем приводит к усложненному описанию системы, в котором существенное теряется в формальностях, в результате чего при реорганизации программы получение работающей версии затягивается ("новая структура намного лучше старой, пользователь согласен ждать ради нее"). К тому же возникают такие потребности в ресурсах, которые непозволительны для большинства потенциальных пользователей. Выбор объема проектирования - самый трудный момент в разработке, именно здесь проявляется талант и опыт. Выбор трудно сделать и для одного программиста или разработчика, но он еще труднее для больших задач, где занято много людей разного уровня квалификации.
Организация должна создавать программный продукт и сопровождать его, несмотря на изменения в штате, в направлении работы или в управляющей структуре. Распространенный способ решения этих проблем заключался в попытке сведения процесса создания системы к нескольким относительно простым задачам, укладывающимся в жесткую структуру. Например, создать группу легко обучаемых (дешевых) и взаимозаменяемых программистов низкого уровня ("кодировщиков") и группу не таких дешевых, но взаимозаменяемых (а значит также не уникальных) разработчиков. Считается, что кодировщики не принимают решений по проектированию, а разработчики не утруждают себя "грязными" подробностями кодирования. Обычно такой подход приводит к неудаче, а где он срабатывает, получается слишком громоздкая система с плохими характеристиками.
Недостатки такого подхода состоят в следующем:
- слабое взаимодействие между программистами и разработчиками приводит к неэффективности, промедлению, упущенным возможностям и повторению ошибок из-за плохого учета и отсутствия обмена опытом;
- сужение области творчества разработчиков приводит к слабому профессиональному росту, безынициативности, небрежности и большой текучести кадров.
По сути, подобные системы - это бесполезная трата редких человеческих
талантов. Создание структуры, в рамках которой люди могут найти применение разным талантам, овладеть новым родом деятельности и участвовать в творческой работе - это не только благородное дело, но и практичное, коммерчески выгодное предприятие.
С другой стороны, нельзя создать систему, представить документацию по ней и бесконечно ее сопровождать без некоторой жесткой организационной структуры. Для чисто новаторского проекта хорошо начать с того, что просто найти лучших специалистов и позволить им решать задачу в соответствии с их идеями. Но по мере развития проекта требуется все больше планирования, специализации и строго определенного взаимодействия между занятыми в нем людьми. Под строго определенным понимается не математическая или автоматически верифицируемая запись (хотя это безусловно хорошо там, где возможно и применимо), а скорее набор указаний по записи, именованию, документации, тестированию и т.п. Но и здесь необходимо чувство меры. Слишком жесткая структура может мешать росту и затруднять совершенствование. Здесь подвергается проверке талант и опыт менеджера. Для отдельного работника аналогичная проблема сводится к определению, где нужно проявить смекалку, а где действовать по рецептам.
Можно рекомендовать планировать не на период до выдачи следующей версии системы, а на более долгий срок. Строить планы только до выпуска очередной версии - значит планировать неудачу. Нужно иметь организацию и стратегию развития программного обеспечения, которые нацелены на создание и поддержание многих версий разных систем, т.е. нужно многократное планирование успеха.
Цель проектирования в выработке ясной и относительно простой внутренней структуры программы, называемой иногда архитектурой, иными словами каркаса, в который укладываются отдельные программные фрагменты, и который помогает написанию этих фрагментов.
Проект - конечный результат процесса проектирования ( если только бывает конечный продукт у итеративного процесса). Он является средоточием взаимодействий между разработчиком и программистом и между программистами. Здесь необходимо соблюсти чувство меры. Если я, как отдельный программист, проектирую небольшую программу, которую собираюсь написать завтра, то точность и полнота описания проекта может свестись к нескольким каракулям на обратной стороне конверта. На другом полюсе находится система, над которой работают сотни программистов и разработчиков, и здесь могут потребоваться тома тщательно составленных спецификаций проекта на формальном или полуформальном языке. Определение нужной степени точности, детализации и формальности проектирования является уже само по себе нетривиальной технической и административной задачей.
Далее будет предполагаться, что проект системы записывается как ряд определений классов (в которых частные описания опущены как лишние детали) и взаимоотношений между ними. Это упрощение, т.к. конкретный проект может учитывать: вопросы параллельности, использование глобального пространства имен, использование глобальных функций и данных, построение программы для минимизации перетрансляции, устойчивость, многомашинный режим и т.п. Но при обсуждении на данном уровне детализации без упрощения не обойтись, а классы в контексте С++ являются ключевым понятием проектирования. Некоторые из указанных вопросов будут обсуждаться ниже, а те, которые прямо затрагивают проектирование библиотек С++, будут рассмотрены в главе 13. Более подробное обсуждение и примеры определенных методов объектно-ориентированного проектирования содержатся в [2].
Мы сознательно не проводили четкого разделения анализа и проектирования, поскольку обсуждение их различий выходит за рамки этой книги, и оно зависит от применяемых методов проектирования. Главное в том, чтобы выбрать метод анализа, подходящий для метода проектирования, и выбрать метод проектирования, подходящий для стиля программирования и используемого языка.
Цели проектирования
Каковы самые общие цели проектирования? Конечно, простота, но в чем критерий простоты? Поскольку мы считаем, что проект должен развиваться во времени, т.е. система будет расширяться, переноситься, настраиваться и, вообще, изменяться массой способов, которые невозможно предусмотреть, необходимо стремиться к такой системе проектирования и реализации, которая была бы простой с учетом, что она будет меняться многими способами. На самом деле, практично допустить, что сами требования к системе будут меняться неоднократно за период от начального проекта до выдачи первой версии системы.
Вывод таков: система должна проектироваться максимально простой при условии, что она будет подвергаться серии изменений. Мы должны проектировать в расчете на изменения, т.е. стремиться к
-
гибкости,
- расширяемости и
- переносимости
Лучшее решение - выделить части системы, которые вероятнее всего будут меняться, в самостоятельные единицы, и предоставить программисту или разработчику гибкие возможности для модификаций таких единиц. Это можно сделать, если выделить ключевые для данной задачи понятия и предоставить класс, отвечающий за всю информацию, связанную с отдельным понятием (и только с ним). Тогда изменение будет затрагивать только определенный класс. Естественно, такой идеальный способ гораздо легче описать, чем воплотить.
Рассмотрим пример: в задаче моделирования метеорологических объектов нужно представить дождевое облако. Как это сделать? У нас нет общего метода изображения облака, поскольку его вид зависит от внутреннего состояния облака, а оно может быть задано только самим облаком.
Первое решение: пусть облако изображает себя само. Оно подходит для многих ограниченных приложений. Но оно не является достаточно общим, поскольку существует много способов представления облака: детальная картина, набросок очертаний, пиктограмма, карта и т.п. Другими словами, вид облака определяется как им самим, так и его окружением.
Второе решение заключается в том, чтобы предоставить самому облаку для его изображения сведения о его окружении. Оно годится для большего числа случаев. Однако и это не общее решение. Если мы предоставляем облаку сведения об его окружении, то нарушаем основной постулат, который требует, чтобы класс отвечал только за одно понятие, и каждое понятие воплощалось определенным классом. Может оказаться невозможным предложить согласованное определение "окружения облака", поскольку, вообще говоря, как выглядит облако зависит от самого облака и наблюдателя. Чем представляется облако мне, сильно зависит от того, как я смотрю на него: невооруженным глазом, с помощью поляризационного фильтра, с помощью метеорадара и т.д. Помимо наблюдателя и облака следует учитывать и "общий фон", например, относительное положение солнца. К дальнейшему усложнению картины приводит добавление новых объектов типа других облаков, самолетов. Чтобы сделать задачу разработчика практически неразрешимой, можно добавить возможность одновременного существования нескольких наблюдателей.
Третье решение состоит в том, чтобы облако, а также и другие объекты, например, самолеты или солнце, сами описывали себя по отношению к наблюдателю. Такой подход обладает достаточной общностью, чтобы удовлетворить большинство запросовЬ. Однако, он может привести к значительному усложнению и большим накладным расходам при выполнении. Как, например, добиться того, чтобы наблюдатель понимал описания, произведенные облаком или другими объектами?
Даже эта модель будет, по всей видимости, не достаточной для таких предельных случаев, как графика с высокой степенью разрешимости. Я думаю, что для получения очень детальной картины нужен другой уровень абстракции.
Дождевые облака - это не тот объект, который часто встретишь в программах, но объекты, участвующие в различных операциях ввода и вывода, встречаются часто. Поэтому можно считать пример с облаком пригодным для программирования вообще и для разработки библиотек в частности. Логически схожий пример в С++ представляют манипуляторы, которые используются для форматирования вывода в потоковом вводе-выводе ($$10.4.2). Заметим, что третье решение не есть "верное решение", это просто более общее решение. Разработчик должен сбалансировать различные требования системы, чтобы найти уровень общности и абстракции, пригодный для данной задачи в данной области. Золотое правило: для программы с долгим сроком жизни правильным будет самый общий уровень абстракции, который вам еще понятен и который вы можете себе позволить, но не обязательно абсолютно общий. Обобщение, выходящее за пределы данного проекта и понятия людей, в нем участвующих, может принести вред, т.е. привести к задержкам, неприемлемым характеристикам, неуправляемым проектам и просто к провалу.
Чтобы использование указанных методов было экономично и поддавалось управлению, проектирование и управление должно учитывать повторное использование, о чем говорится в $$11.4.1 и не следует совсем забывать об эффективности (см. $$11.3.7).
Целые константы
Целые константы могут появляться в четырех обличьях: десятичные, восьмеричные, шестнадцатеричные и символьные константы. Десятичные константы используются чаще всего и выглядят естественно:
0 1234 976 12345678901234567890
Десятичная константа имеет тип int, если она умещается в память, отводимую для int, в противном случае ее тип long. Транслятор должен предупреждать о константах, величина которых превышает выбранный формат представления чисел. Константа, начинающаяся с нуля, за которым следует x (0x), является шестнадцатеричным числом (с основанием 16), а константа, которая начинающаяся с нуля, за которым следует цифра, является восьмеричным числом (с основанием 8). Приведем примеры восьмеричных констант:
0 02 077 0123
Их десятичные эквиваленты равны соответственно: 0, 2, 63, 83. В шестнадцатеричной записи эти константы выглядят так:
0x0 0x2 0x3f 0x53
Буквы a, b, c, d, e и f или эквивалентные им заглавные буквы используются для представления чисел 10, 11, 12, 13, 14 и 15, соответственно. Восьмеричная и шестнадцатеричная формы записи наиболее подходят для задания набора разрядов, а использование их для обычных чисел может дать неожиданный эффект. Например, на машине, в которой int представляется как 16-разрядное число в дополнительном коде, 0xffff есть отрицательное десятичное число -1. Если бы для представления целого использовалось большее число разрядов, то это было бы числом 65535.
Окончание U может использоваться для явного задания констант типа unsigned. Аналогично, окончание L явно задает константу типа long. Например:
void f(int);
void f(unsigned int);
void f(long int);
void g()
{
f(3); // вызов f(int)
f(3U); // вызов f(unsigned int)
f(3L); // вызов f(long int)
}
Человеческий фактор
Описанный здесь метод проектирования рассчитан на искусных разработчиков и программистов, поэтому от их подбора зависит успех организации.
Менеджеры часто забывают, что организация состоит из индивидуумов. Распространено мнение, что программисты равны и взаимозаменяемы. Это заблуждение может погубить организацию за счет вытеснения многих самых активных сотрудников и принуждения остальных работать над задачами значительно ниже их уровня. Индивидуумы взаимозаменяемы только, если им не дают применить свой талант, который поднимает их над общим минимальным уровнем, необходимым для решения данной задачи. Поэтому миф о взаимозаменяемости бесчеловечен и по сути своей расточителен.
Многие системы оценок производительности программиста поощряют расточительность и не могут учесть существенный личный вклад человека. Самым очевидным примером служит широко распространенная практика оценивать успех в количестве запрограммированных строк, выданных страниц документации, пропущенных тестов и т.п. Такие цифры эффектно выглядят на диаграммах, но имеют самое отдаленное отношение к действительности. Например, если производительность измерять числом запрограммированных строк, то удачное повторное использование ухудшит оценку труда программиста. Обычно тот же эффект будет иметь удачное применение лучших приемов в процессе перепроектирования большой части системы.
Качество результата измерить значительно труднее, чем количество, и вознаграждать исполнителя или группу следует за качество их труда, а не на основе грубых количественных оценок. К сожалению, насколько известно, практическая разработка способов оценки качества еще не началась. К тому же оценки, которые неполно описывают состояние проекта, могут исказить процесс его развития. Люди приспосабливаются, чтобы уложиться в отведенный срок и перестраивают свою работу в соответствии с оценками производительности, в результате страдает общая целостность системы и ее производительность. Например, если отведен срок для выявления определенного числа ошибок, то для того, чтобы уложиться в него, активно используют проверки на стадии выполнения, что ухудшает производительность системы. Обратно, если учитываются только характеристики системы на стадии выполнения, то число невыявленных ошибок будет расти при условии недостатка времени у исполнителей. Отсутствие хороших и разумных оценок качества повышает требования к технической квалификации менеджеров, иначе будет постоянная тенденция поощрять произвольную активность, а не реальный прогресс. Не надо забывать, что менеджеры тоже люди, и они должны по крайней мере настолько разбираться в новых технологиях, как и те, кем они управляют.
Здесь, как и в других аспектах процесса развития программного обеспечения, следует рассматривать большие временные сроки. По сути невозможно указать производительность человека на основе его работы за год. Однако, многие сотрудники имеют карточку своих достижений за большой период, и она может послужить надежным указанием для предсказания их производительности. Если не принимать во внимание такие карточки, что и делается, когда сотрудников считают взаимозаменяемыми спицами в колесе организации, то у менеджера остаются только вводящие в заблуждения количественные оценки.
Если мы рассматриваем только достаточно большие временные сроки и отказываемся от методов управления, рассчитанных на "взаимозаменяемых недоумков", то надо признать, что индивидууму (как разработчику или программисту, так и менеджеру) нужен большой срок, чтобы дорасти до более интересной и важной работы. Такой подход не одобряет как "скакание" с места на место, так и передачу работы другому из-за карьерных соображений. Целью должен быть низкий оборот ключевых специалистов и ключевых менеджеров. Никакой менеджер не добьется успеха без подходящих технических знаний и взаимопонимания с основными разработчиками и программистами. В тоже время, в конечном счете никакая группа разработчиков или программистов не добьется успеха без поддержки компетентных менеджеров и без понимания хотя бы основных нетехнических вопросов, касающихся окружения, в котором они работают.
Когда требуется предложить нечто новое, на передний план выходят основные специалисты - аналитики, разработчики, программисты. Именно они должны решить трудную и критическую задачу внедрения новой технологии. Это те люди, которые должны овладеть новыми методами и во многих случаях забыть старые привычки. Это не так легко. Ведь эти люди сделали большой личный вклад в создание старых методов и свою репутацию как специалиста обосновывают успехами, полученными с помощью старых методов. Так же обстоит дело и с многими менеджерами.
Естественно у таких людей есть страх перед изменениями. Он может привести к преувеличению проблем, возникающих при изменениях, и к нежеланию признать проблемы, вызванные старыми методами. Естественно, с другой стороны люди, выступающие за изменения, могут переоценивать выгоды, которые принесут изменения, и недооценивать возникающие здесь проблемы. Эти две группы людей должны общаться, они должны научиться говорить на одном языке и должны помочь друг другу разработать подходящую схему перехода. Альтернативой будет организационный паралич и уход самых способных людей из обоих групп. Тем и другим следует знать, что самые удачливые из "старых ворчунов" могли быть "молодыми львами" в прошлом году, и если человеку дали возможность научиться без всяких издевательств, то он может стать самым стойким и разумным сторонником перемен. Он будет обладать неоценимыми свойствами здорового скептицизма, знания пользователей и понимания организационных препятствий. Сторонники немедленных и радикальных изменений должны осознать, что гораздо чаще нужен переход, предполагающий постепенное внедрение новых методов. С другой стороны, те, кто не желает перемен, должны поискать для себя такие области, где это возможно, чем вести ожесточенные, арьергардные бои в той области, где новые требования уже задали совершенно иные условия для успешного проекта.
Члены istream
Как и для ostream, большинство функций форматирования и управления вводом находится не в классе iostream, а в базовом классе ios.
class istream : public virtual ios {
//...
public:
int peek()
istream& putback(char c);
istream& seekg(streampos);
istream& seekg(streamoff, seek_dir);
streampos tellg();
//...
};
Функции позиционирования работают как и их двойники из ostream. Окончание на букву g показывает, что именно позиция используется при вводе символов из заданного потока. Буквы p и g нужны, поскольку мы можем создать производный класс iostreams из классов ostream и istream, и в нем необходимо следить за позициями ввода и вывода.
С помощью функции peek() программа может узнать следующий символ, подлежащий вводу, не затрагивая результата последующего чтения. С помощью функции putback(), как показано в $$10.3.3, можно вернуть ненужный символ назад в поток, чтобы он был прочитан в другое время.
Члены ostream
В классе ostream есть лишь несколько функций для управления выводом,
большая часть таких функций находится в классе ios.
class ostream : public virtual ios {
//...
public:
ostream& flush();
ostream& seekp(streampos);
ostream& seekp(streamoff, seek_dir);
streampos tellp();
//...
};
Как мы уже говорили, функция flush() опустошает буфер в выходной поток.
Остальные функции используются для позиционирования в ostream при записи. Окончание на букву p указывает, что именно позиция используется при выдаче символов в заданный поток. Конечно эти функции имеют смысл, только если поток присоединен к чему-либо, что допускает позиционирование, например файл. Тип streampos представляет позицию символа в файле, а тип streamoff представляет смещение относительно позиции, заданной seek_dir. Все они определены в классе ios:
class ios {
//...
enum seek_dir {
beg=0, // от начала файла
cur=1, // от текущей позиции в файле
end=2 // от конца файла
};
//...
};
Позиции в потоке отсчитываются от 0, как если бы файл был массивом из n символов:
char file[n-1];
и если fout присоединено к file, то
fout.seek(10);
fout<<'#';
поместит # в file[10].
Что представляют классы?
По сути в системе бывают классы двух видов:
[1] классы, которые прямо отражают понятия области приложения, т.е. понятия, которые использует конечный пользователь для описания своих задач и возможных решений; и
[2] классы, которые являются продуктом самой реализации, т.е. отражают понятия, используемые разработчиками и программистами для описания способов реализации.
Некоторые из классов, являющихся продуктами реализации, могут представлять и понятия реального мира. Например, программные и аппаратные ресурсы системы являются хорошими кандидатами на роль классов, представляющих область приложения. Это отражает тот факт, что систему можно рассматривать с нескольких точек зрения, и то, что с одной является деталью реализации, с другой может быть понятием области приложения. Хорошо спроектированная система должна содержать классы, которые дают возможность рассматривать систему с логически разных точек зрения. Приведем пример:
[1] классы, представляющие пользовательские понятия (например, легковые машины и грузовики),
[2] классы, представляющие обобщения пользовательских понятий (движущиеся средства),
[3] классы, представляющие аппаратные ресурсы (например, класс управления памятью),
[4] классы, представляющие системные ресурсы (например, выходные потоки),
[5] классы, используемые для реализации других классов (например, списки, очереди, блокировщики) и
[6] встроенные типы данных и структуры управления.
В больших системах очень трудно сохранять логическое разделение типов различных классов и поддерживать такое разделение между различными уровнями абстракции. В приведенном выше перечислении представлены три уровня абстракции:
[1+2] представляет пользовательское отражение системы,
[3+4] представляет машину, на которой будет работать система,
[5+6] представляет низкоуровневое (со стороны языка программирования) отражение реализации.
Чем больше система, тем большее число уровней абстракции необходимо для ее описания, и тем труднее определять и поддерживать эти уровни абстракции. Отметим, что таким уровням абстракции есть прямое соответствие в природе и в различных построениях человеческого интеллекта. Например, можно рассматривать дом как объект, состоящий из
[1] атомов,
[2] молекул,
[3] досок и кирпичей,
[4] полов, потолков и стен;
[5] комнат.
Пока удается хранить раздельно представления этих уровней абстракции, можно поддерживать целостное представление о доме. Однако, если смешать их, возникнет бессмыслица. Например, предложение "Мой дом состоит из нескольких тысяч фунтов углерода, некоторых сложных полимеров, из 5000 кирпичей, двух ванных комнат и 13 потолков" - явно абсурдно. Из-за абстрактной природы программ подобное утверждение о какой-либо сложной программной системе далеко не всегда воспринимают как бессмыслицу.
В процессе проектирования выделение понятий из области приложения в класс вовсе не является простой механической операцией. Обычно эта задача требует большой проницательности. Заметим, что сами понятия области приложения являются абстракциями. Например, в природе не существуют "налогоплательщики", "монахи" или "сотрудники". Эти понятия не что иное, как метки, которыми обозначают бедную личность, чтобы классифицировать ее по отношению к некоторой системе. Часто реальный или воображаемый мир (например, литература, особенно фантастика) служат источником понятий, которые кардинально преобразуются при переводе их в классы. Так, экран моего компьютера (Маккинтош) совсем не походит на поверхность моего стола, хотя компьютер создавался с целью реализовать понятие "настольный", а окна на моем дисплее имеют самое отдаленное отношение к приспособлениям для презентации чертежей в моей комнате. Я бы не вынес такого беспорядка у себя на экране.
Суть моделирования реальности не в покорном следовании тому, что мы видим, а в использовании реальности как начала для проектирования, источника вдохновения и как якоря, который удерживает, когда стихия программирования грозит лишить нас способности понимания своей собственной программы.
Здесь полезно предостеречь: новичкам обычно трудно "находить"
классы, но вскоре это преодолевается без каких-либо неприятностей. Далее обычно приходит этап, когда классы и отношения наследования между ними бесконтрольно множатся. Здесь уже возникают проблемы, связанные со сложностью, эффективностью и ясностью полученной программы. Далеко не каждую отдельную деталь следует представлять отдельным классом, и далеко не каждое отношение между классами следует представлять как отношение наследования. Старайтесь не забывать, что цель проекта – смоделировать систему с подходящим уровнем детализации и подходящим уровнем абстракции. Для больших систем найти компромисс между простотой и общностью далеко не простая задача.
Цикл развития
Процесс развития системы - это итеративная деятельность. Основной цикл сводится к повторяемым в следующей последовательности шагам:
[1] Создать общее описание проекта.
[2] Выделить стандартные компоненты.
[a] Подогнать компоненты под данный проект.
[3] Создать новые стандартные компоненты.
[a] Подогнать компоненты под данный проект.
[4] Составить уточненное описание проекта.
В качестве примера рассмотрим автомобильный завод. Проект должен начинаться с самого общего описания новой машины. Этот первый шаг базируется на некотором анализе и описании машины в самых общих терминах, которые скорее относятся к предполагаемому использованию, чем к характеристикам желаемых возможностей машины. Часто самой трудной частью проекта бывает выбор желаемых возможностей, или, точнее, определение относительно простого критерия выбора желаемых возможностей. Удача здесь, как правило, является результатом работы отдельного проницательного человека и часто называется предвидением. Слишком типично как раз отсутствие ясных целей, что приводит к неуверенно развивающимся или просто проваливающимся проектам.
Итак, допустим необходимо создать машину среднего размера с четырьмя дверцами и достаточно мощным мотором. Очевидно, что на первом этапе проекта не следует начинать проектирование машины (и всех ее компонентов) с нуля. Хотя программист или разработчик программного обеспечения в подобных обстоятельствах поступит именно так.
На первом этапе надо выяснить, какие компоненты доступны на вашем собственном складе и какие можно получить от надежных поставщиков. Найденные таким образом компоненты не обязательно в точности подойдут для новой машины. Всегда требуется подгонка компонентов. Может быть даже потребуется изменить характеристики "следующей версии" выбранных компонентов, чтобы сделать их пригодными для проекта. Например, может существовать вполне пригодный мотор, вырабатывающий немного меньшую мощность.Тогда или вы, или поставщик мотора должны предложить, не изменяя общего описания проекта, в качестве компенсации дополнительный зарядный генератор. Заметим, что сделать это,"не изменяя общего описания проекта", маловероятно, если только само описание не приспособлено к определенной подгонке. Обычно подобная подгонка требует кооперации между вами и поставщиком моторов. Сходные вопросы возникают и у программиста или разработчика программного обеспечения. Здесь подгонку обычно облегчает эффективное использование производных классов. Но не рассчитывайте провести произвольные расширения в проекте без определенного предвидения или кооперации с создателем таких классов.
Когда исчерпается набор подходящих стандартных компонентов, проектировщик машины не спешит заняться проектированием новых оптимальных компонентов для своей машины. Это было бы слишком расточительно. Допустим, что не нашлось подходящего блока кондиционирования воздуха, зато есть свободное пространство, имеющее форму буквы L, в моторном отсеке. Возможно решение разработать блок кондиционирования указанной формы. Но вероятность того, что блок подобной странной формы будет использоваться в машинах другого типа (даже после значительной подгонки), крайне низка. Это означает, что наш проектировщик машины не сможет разделить затраты на производство такого блока с создателями машин другого типа, а значит время жизни этого блока коротко. Поэтому стоит спроектировать блок, который найдет более широкое применение, т.е. разработать разумный проект блока, более приспособленный для подгонки, чем наше L-образное чудище. Возможно, это потребует больших усилий, и даже придется для приспособления более универсального блока изменить общее описание проекта машины. Поскольку новый блок разрабатывался для более общего применения, чем наше L-образное чудище, предположительно, для него потребуется некоторая подгонка, чтобы полностью удовлетворить наши пересмотренные запросы. Подобная же альтернатива возникает и у программиста или разработчика программного обеспечения: вместо того, чтобы создать программу, привязанную к конкретному проекту, разработчик может спроектировать новую достаточно универсальную программу, которая будет иметь хорошие шансы стать стандартной в определенной области.
Наконец, когда мы прошлись по всем стандартным компонентам, составляется "окончательное" общее описание проекта. Несколько специально разработанных средств указываются как возможные. Вероятно, в следующем году придется для новой модели повторить наши шаги, и как раз эти специальные средства придется переделать или выбросить. Как ни печально, но опыт традиционно проектировавшихся программ показывает, что лишь несколько частей системы можно выделить в отдельные компоненты и лишь несколько из них пригодны вне данного проекта.
Мы не пытаемся утверждать, что все разработчики машин действуют столь разумно, как в приведенном примере, а разработчики программ совершают все указанные ошибки. Утверждается, что указанная методика разработки машин применима и для программного обеспечения. Так, в этой и следующей главах даны приемы использования ее для С++. Тем не менее можно сказать, что сама природа программирования способствует совершению указанных ошибок ($$12.2.1 и $$12.2.5). В разделе 11.4.3 опровергается профессиональное предубеждение против использования описанной здесь модели проектирования. Заметим, что модель развития программного обеспечения хорошо применима только в расчете на большие сроки. Если ваш горизонт сужается до времени выдачи очередной версии, нет смысла создавать и поддерживать функционирование стандартных компонентов. Это просто приведет к излишним накладным расходам. Наша модель рассчитана на организации со временем жизни, за которое проходит несколько проектов, и с размерами, которые позволяют нести дополнительные расходы и на средства проектирования, программирования, и на сопровождение проектов, и на повышение квалификации разработчиков, программистов и менеджеров. Фактически это эскиз некоторой фабрики по производству программ. Как ни удивительно, она только масштабом отличается от действий лучших программистов, которые для повышения своей производительности в течении лет накапливали запас приемов и методов проектирования, создавали инструменты и библиотеки. Похоже, что большинство организаций просто не умеет воспользоваться достижениями лучших сотрудников, как из-за отсутствия предвидения, так и по неспособности применить эти достижения в достаточно широком объеме.
Все-таки неразумно требовать, чтобы "стандартные компоненты" были стандартными универсально. Существует лишь малое число международных стандартных библиотек, а в своем большинстве компоненты окажутся стандартными только в пределах страны, отрасли, компании, производственной цепочки, отдела или области приложения и т.д. Просто мир слишком велик, чтобы универсальный стандарт всех компонентов и средств был реальной или желанной целью проекта.
Динамическая информация о типе
Иногда бывает полезно знать истинный тип объекта до его использования в каких-либо операциях. Рассмотрим функцию my(set&) из $$13.3.
void my_set(set& s)
{
for ( T* p = s.first(); p; p = s.next()) {
// мой код
}
// ...
}
Она хороша в общем случае, но представим,- стало известно, что многие параметры множества представляют собой объекты типа slist. Возможно также стал известен алгоритм перебора элементов, который значительно эффективнее для списков, чем для произвольных множеств. В результате эксперимента удалось выяснить, что именно этот перебор является узким местом в системе. Тогда, конечно, имеет смысл учесть в программе отдельно вариант с slist. Допустив возможность определения истинного типа параметра, задающего множество, функцию my(set&) можно записать так:
void my(set& s)
{
if (ref_type_info(s) == static_type_info(slist_set)) {
// сравнение двух представлений типа
// s типа slist
slist& sl = (slist&)s;
for (T* p = sl.first(); p; p = sl.next()) {
// эффективный вариант в расчете на list
}
}
else {
for ( T* p = s.first(); p; p = s.next()) {
// обычный вариант для произвольного множества
}
}
// ...
}
Как только стал известен конкретный тип slist, стали доступны определенные операции со списками, и даже стала возможна реализация основных операций подстановкой.
Приведенный вариант функции действует отлично, поскольку slist - это конкретный класс, и действительно имеет смысл отдельно разбирать вариант, когда параметр является slist_set. Рассмотрим теперь такую ситуацию, когда желательно отдельно разбирать вариант как для класса, так и для всех его производных классов. Допустим, мы имеем класс dialog_box из $$13.4 и хотим узнать, является ли он классом dbox_w_str. Поскольку может существовать много производных классов от dbox_w_str, простую проверку на совпадение с ним нельзя считать хорошим решением. Действительно, производные классы могут представлять самые разные варианты запроса строки. Например, один производный от dbox_w_str класс может предлагать пользователю варианты строк на выбор, другой может обеспечить поиск в каталоге и т.д. Значит, нужно проверять и на совпадение со всеми производными от dbox_w_str классами. Это так же типично для узловых классов, как проверка на вполне определенный тип типична для абстрактных классов, реализуемых конкретными типами.
void f(dialog_box& db)
{
dbox_w_str* dbws = ptr_cast(dbox_w_str, &db);
if (dbws) { // dbox_w_str
// здесь можно использовать dbox_w_str::get_string()
}
else {
// ``обычный'' dialog_box
}
// ...
}
Здесь "операция" приведения ptr_cast() свой второй параметр (указатель) приводит к своему первому параметру (типу) при условии, что указатель настроен на объект тип, которого совпадает с заданным (или является производным классом от заданного типа). Для проверки типа dialog_box используется указатель, чтобы после приведения его можно было сравнить с нулем.
Возможно альтернативное решение с помощью ссылки на dialog_box:
void g(dialog_box& db)
{
try {
dbox_w_str& dbws = ref_cast(dialog_box,db);
// здесь можно использовать dbox_w_str::get_string()
}
catch (Bad_cast) {
// ``обычный'' dialog_box
}
// ...
}
Поскольку нет приемлемого представления нулевой ссылки, с которой можно сравнивать, используется особая ситуация, обозначающая ошибку приведения (т.е. случай, когда тип не есть dbox_w_str). Иногда лучше избегать сравнения с результатом приведения.
Различие функций ref_cast() и ptr_cast() служит хорошей иллюстрацией различий между ссылками и указателями: ссылка обязательно ссылается на объект, тогда как указатель может и не ссылаться, поэтому для указателя часто нужна проверка.
Доступ к базовым классам
Подобно члену базовый класс можно описать как частный, защищенный или общий:
class X {
public:
int a;
// ...
};
class Y1 : public X { };
class Y2 : protected X { };
class Y3 : private X { };
Поскольку X - общий базовый класс для Y1, в любой функции, если есть необходимость, можно (неявно) преобразовать Y1* в X*, и притом в ней будут доступны общие члены класса X:
void f(Y1* py1, Y2* py2, Y3* py3)
{
X* px = py1; // нормально: X - общий базовый класс Y1
py1->a = 7; // нормально
px = py2; // ошибка: X - защищенный базовый класс Y2
py2->a = 7; // ошибка
px = py3; // ошибка: X - частный базовый класс Y3
py3->a = 7; // ошибка
}
Теперь пусть описаны
class Y2 : protected X { };
class Z2 : public Y2 { void f(); };
Поскольку X - защищенный базовый класс Y2, только друзья и члены Y2, а также друзья и члены любых производных от Y2 классов (в частности Z2) могут при необходимости преобразовывать (неявно) Y2* в X*. Кроме того они могут обращаться к общим и защищенным членам класса X:
void Z2::f(Y1* py1, Y2* py2, Y3* py3)
{
X* px = py1; // нормально: X - общий базовый класс Y1
py1->a = 7; // нормально
px = py2; // нормально: X - защищенный базовый класс Y2,
// а Z2 - производный класс Y2
py2->a = 7; // нормально
px = py3; // ошибка: X - частный базовый класс Y3
py3->a = 7; // ошибка
}
Наконец, рассмотрим:
class Y3 : private X { void f(); };
Поскольку X - частный базовый класс Y3, только друзья и члены Y3 могут при необходимости преобразовывать (неявно) Y3* в X*. Кроме того они могут обращаться к общим и защищенным членам класса X:
void Y3::f(Y1* py1, Y2* py2, Y3* py3)
{
X* px = py1; // нормально: X - общий базовый класс Y1
py1->a = 7; // нормально
px = py2; // ошибка: X - защищенный базовый класс Y2
py2->a = 7; // ошибка
px = py3; // нормально: X - частный базовый класс Y3,
// а Y3::f член Y3
py3->a = 7; // нормально
}
Драйвер
Когда все части программы определены, нужен только драйвер, чтобы инициализировать и запустить процесс. В нашем примере с этим справится функция main():
int main()
{
// вставить предопределенные имена:
insert("pi")->value = 3.1415926535897932385;
insert("e")->value = 2.7182818284590452354;
while (cin) {
get_token();
if (curr_tok == END) break;
if (curr_tok == PRINT) continue;
cout << expr() << '\n';
}
return no_of_errors;
}
Принято, что функция main() возвращает нуль, если программа завершается нормально, и ненулевое значение, если происходит иначе. Ненулевое значение возвращается как число ошибок. Оказывается, вся инициализация сводится к занесению предопределенных имен в таблицу.
В цикле main читаются выражения и выдаются результаты. Это делает одна строка:
cout << expr() << '\n';
Проверка cin при каждом проходе цикла гарантирует завершение программы,
даже если что-то случится с входным потоком, а проверка на лексему END нужна для нормального завершения цикла, когда функция get_token() обнаружит конец файла. Оператор break служит для выхода из ближайшего объемлющего оператора switch или цикла (т.е. оператора for, while или do). Проверка на лексему PRINT (т.е. на '\n' и ';') снимает с функции expr() обязанность обрабатывать пустые выражения. Оператор continue эквивалентен переходу на конец цикла, поэтому в нашем случае фрагмент:
while (cin) {
// ...
if (curr_tok == PRINT) continue;
cout << expr() << "\n";
}
эквивалентен фрагменту:
while (cin) {
// ...
if (curr_tok == PRINT) goto end_of_loop;
cout << expr() << "\n";
end_of_loop: ;
}
Более подробно циклы описываются в $$R.6
Другие способы обработки ошибок
Механизм особых ситуаций нужен для того, чтобы из одной части программы можно было сообщить в другую о возникновении в первой "особой ситуации". При этом предполагается, что части программы написаны независимо друг от друга, и в той части, которая обрабатывает особую ситуацию, возможна осмысленная реакция на ошибку.
Как же должен быть устроен обработчик особой ситуации? Приведем несколько вариантов:
int f(int arg)
{
try {
g(arg);
}
catch (x1) {
// исправить ошибку и повторить
g(arg);
}
catch (x2) {
// произвести вычисления и вернуть результат
return 2;
}
catch (x3) {
// передать ошибку
throw;
}
catch (x4) {
// вместо x4 запустить другую особую ситуацию
throw xxii;
}
catch (x5) {
// исправить ошибку и продолжить со следующего оператора
}
catch (...) {
// отказ от обработки ошибки
terminate();
}
// ...
}
Укажем, что в обработчике доступны переменные из области видимости, содержащей проверяемый блок этого обработчика. Переменные, описанные в других обработчиках или других проверяемых блоках, конечно, недоступны:
void f()
{
int i1;
// ...
try {
int i2;
// ...
}
catch (x1) {
int i3;
// ...
}
catch (x4) {
i1 = 1; // нормально
i2 = 2; // ошибка: i2 здесь невидимо
i3 = 3; // ошибка: i3 здесь невидимо
}
}
Нужна общая стратегия для эффективного использования обработчиков в программе. Все компоненты программы должны согласованно использовать особые ситуации и иметь общую часть для обработки ошибок. Механизм обработки особых ситуаций является нелокальным по своей сути, поэтому так важно придерживаться общей стратегии. Это предполагает, что стратегия обработки ошибок должна разрабатываться на самых ранних стадиях проектах. Кроме того, эта стратегия должна быть простой (по сравнению со сложностью всей программы) и ясной. Последовательно проводить сложную стратегию в такой сложной по своей природе области программирования, как восстановление после ошибок, будет просто невозможно.
Прежде всего стоит сразу отказаться от того, что одно средство или один прием можно применять для обработки всех ошибок. Это только усложнит систему. Удачная система, обладающая устойчивостью к ошибкам, должна строиться как многоуровневая. На каждом уровне надо обрабатывать настолько много ошибок, насколько это возможно без нарушения структуры системы, оставляя обработку других ошибок более высоким уровням. Назначение terminate() поддержать такой подход, предоставляя возможность экстренного выхода из такого положения, когда нарушен сам механизм обработки особых ситуаций, или когда он используется полностью, но особая ситуация оказалась неперехваченной. Функция unexpected() предназначена для выхода из такого положения, когда не сработало основанное на описании всех особых ситуаций средство защиты. Это средство можно представлять как брандмауер, т.е. стену, окружающую каждую функцию, и препятствующую распространению ошибки. Попытка проводить в каждой функции полный контроль, чтобы иметь гарантию, что функция либо успешно завершится, либо закончится неудачно, но одним из определенных и корректных способов, не может принести успех. Причины этого могут быть различными для разных программ, но для больших программ можно назвать следующие:
[1] работа, которую нужно провести, чтобы гарантировать надежность каждой функции, слишком велика, и поэтому ее не удастся провести достаточно последовательно;
[2] появятся слишком большие дополнительные расходы памяти и времени, которые будут недопустимы для нормальной работы системы (будет тенденция неоднократно проверять на одну и ту же ошибку, а значит постоянно будут проверяться переменные с правильными значениями);
[3] таким ограничениям не будут подчиняться функции, написанные на других языках;
[4] такое понятие надежности является чисто локальным и оно настолько усложняет систему, что становится дополнительной нагрузкой для ее общей надежности.
Однако, разбить программу на отдельные подсистемы, которые либо успешно завершаются, либо заканчиваются неудачно, но одним из определенных и корректных способов, вполне возможно, важно и даже выгодно. Таким свойством должны обладать основные библиотеки, подсистемы или ключевые функции. Описание особых ситуаций должно входить в интерфейсы таких библиотек или подсистем.
Иногда приходится от одного стиля реакции на ошибку переходить на другой. Например, можно после вызова стандартной функции С проверять значение errno и, возможно, запускать особую ситуацию, а можно, наоборот, перехватывать особую ситуацию и устанавливать значение errno перед выходом из стандартной функции в С-программу:
void callC()
{
errno = 0;
cfunction();
if (errno) throw some_exception(errno);
}
void fromC()
{
try {
c_pl_pl_function();
}
catch (...) {
errno = E_CPLPLFCTBLEWIT;
}
}
При такой смене стилей важно быть последовательным, чтобы изменение реакции на ошибку было полным.
Обработка ошибок должна быть, насколько это возможно, строго иерархической системой. Если в функции обнаружена динамическая ошибка, то не нужно обращаться за помощью для восстановления или выделения ресурсов к вызывающей функции. При таких обращениях в структуре системы возникают циклические зависимости, в результате чего ее труднее понять, и возможно возникновение бесконечных циклов в процессе обработки и восстановления после ошибки.
Чтобы часть программы, предназначенная для обработки ошибок была более упорядоченной, стоит применять такие упрощающие дело приемы, как "запрос ресурсов путем инициализации", и исходить из таких упрощающих дело допущений, что "особые ситуации являются ошибками".
Другие точки зрения на особые ситуации
"Особая ситуация" - одно из тех понятий, которые имеют разный смысл для разных людей. В С++ механизм особых ситуаций предназначен для обработки ошибок. В частности, он предназначен для обработки ошибок в программах, состоящих из независимо создаваемых компонентов.
Этот механизм рассчитан на особые ситуации, возникающие только при последовательном выполнении программы (например, контроль границ массива). Асинхронные особые ситуации такие, например, как прерывания от клавиатуры, нельзя непосредственно обрабатывать с помощью этого механизма. В различных системах существуют другие механизмы, например, сигналы, но они здесь не рассматриваются, поскольку зависят от конкретной системы.
Механизм особых ситуаций является конструкцией с нелокальной передачей управления и его можно рассматривать как вариант оператора return. Поэтому особые ситуации можно использовать для целей, никак не связанных с обработкой ошибок ($$9.5). Все-таки основным назначением механизма особых ситуаций и темой этой главы будет обработка ошибок и создание устойчивых к ошибкам программ.
Друзья
Пусть определены два класса: vector (вектор) и matrix (матрица). Каждый из них скрывает свое представление, но дает полный набор операций для работы с объектами его типа. Допустим, надо определить функцию, умножающую матрицу на вектор. Для простоты предположим, что вектор имеет четыре элемента с индексами от 0 до 3, а в матрице четыре вектора тоже с индексами от 0 до 3. Доступ к элементам вектора обеспечивается функцией elem(), и аналогичная функция есть для матрицы. Можно определить глобальную функцию multiply (умножить) следующим образом:
vector multiply(const matrix& m, const vector& v);
{
vector r;
for (int i = 0; i<3; i++) { // r[i] = m[i] * v;
r.elem(i) = 0;
for (int j = 0; j<3; j++)
r.elem(i) +=m.elem(i,j) * v.elem(j);
}
return r;
}
Это вполне естественное решение, но оно может оказаться очень неэффективным. При каждом вызове multiply() функция elem() будет вызываться 4*(1+4*3) раз. Если в elem() проводится настоящий контроль границ массива, то на такой контроль будет потрачено значительно больше времени, чем на выполнение самой функции, и в результате она окажется непригодной для пользователей. С другой стороны, если elem() есть некий специальный вариант доступа без контроля, то тем самым мы засоряем интерфейс с вектором и матрицей особой функцией доступа, которая нужна только для обхода контроля.
Если можно было бы сделать multiply членом обоих классов vector и matrix, мы могли бы обойтись без контроля индекса при обращении к элементу матрицы, но в то же время не вводить специальной функции elem(). Однако, функция не может быть членом двух классов. Надо иметь в языке возможность предоставлять функции, не являющейся членом, право доступа к частным членам класса. Функция - не член класса, - имеющая доступ к его закрытой части, называется другом этого класса. Функция может стать другом класса, если в его описании она описана как friend (друг). Например:
class matrix;
class vector {
float v[4];
// ...
friend vector multiply(const matrix&, const vector&);
};
class matrix {
vector v[4];
// ...
friend vector multiply(const matrix&, const vector&);
};
Функция- друг не имеет никаких особенностей, за исключением права доступа к закрытой части класса. В частности, в такой функции нельзя использовать указатель this, если только она действительно не является членом класса. Описание friend является настоящим описанием. Оно вводит имя функции в область видимости класса, в котором она была описана, и при этом происходят обычные проверки на наличие других описаний такого же имени в этой области видимости. Описание friend может находится как в общей, так и в частной частях класса, это не имеет значения.
Теперь можно написать функцию multiply, используя элементы вектора и матрицы непосредственно:
vector multiply(const matrix& m, const vector& v)
{
vector r;
for (int i = 0; i<3; i++) { // r[i] = m[i] * v;
r.v[i] = 0;
for ( int j = 0; j<3; j++)
r.v[i] +=m.v[i][j] * v.v[j];
}
return r;
}
Отметим, что подобно функции-члену дружественная функция явно описывается в описании класса, с которым дружит. Поэтому она является неотъемлемой частью интерфейса класса наравне с функцией-членом.
Функция-член одного класса может быть другом другого класса:
class x {
// ...
void f();
};
class y {
// ...
friend void x::f();
};
Вполне возможно, что все функции одного класса являются друзьями другого класса. Для этого есть краткая форма записи:
class x {
friend class y;
// ...
};
В результате такого описания все функции-члены y становятся друзьями класса x.
Друзья и члены
В заключении можно обсудить, когда при обращении в закрытую часть пользовательского типа стоит использовать функции-члены, а когда функции-друзья. Некоторые функции, например конструкторы, деструкторы и виртуальные функции ($$R.12), обязаны быть членами, но для других есть возможность выбора. Поскольку, описывая функцию как член, мы не вводим нового глобального имени, при отсутствии других доводов следует использовать функции-члены.
Рассмотрим простой класс X:
class X {
// ...
X(int);
int m1();
int m2() const;
friend int f1(X&);
friend int f2(const X&);
friend int f3(X);
};
Вначале укажем, что члены X::m1() и X::m2() можно вызывать только для объектов класса X. Преобразование X(int) не будет применяться к объекту, для которого вызваны X::m1() или X::m2():
void g()
{
1.m1(); // ошибка: X(1).m1() не используется
1.m2(); // ошибка: X(1).m2() не используется
}
Глобальная функция f1() имеет то же свойство ($$4.6.3), поскольку ее параметр - ссылка без спецификации const. С функциями f2() и f3() ситуация иная:
void h()
{
f1(1); // ошибка: f1(X(1)) не используется
f2(1); // нормально: f2(X(1));
f3(1); // нормально: f3(X(1));
}
Следовательно операция, изменяющая состояние объекта класса, должна быть членом или глобальной функцией с параметром-ссылкой без спецификации const. Операции над основными типами, которые требуют в качестве операндов адреса (=, *, ++ и т.д.), для пользовательских типов естественно определять как члены.
Обратно, если требуется неявное преобразование типа для всех операндов некоторой операции, то реализующая ее функция должна быть не членом, а глобальной функцией и иметь параметр типа ссылки со спецификацией const или нессылочный параметр. Так обычно обстоит дело с функциями, реализующими операции, которые для основных типов не требуют адресов в качестве операндов (+, -, || и т.д.).
Если операции преобразования типа не определены, то нет неопровержимых доводов в пользу функции-члена перед функцией-другом с параметром-ссылкой и наоборот. Бывает, что программисту просто одна форма записи вызова нравится больше, чем другая. Например, многим для обозначения функции обращения матрицы m больше нравится запись inv(m), чем m.inv(). Конечно, если функция inv() обращает саму матрицу m, а не возвращает новую, обратную m, матрицу, то inv() должна быть членом.
При всех прочих равных условиях лучше все-таки остановиться на функции-члене. Можно привести такие доводы. Нельзя гарантировать, что когда-нибудь не будет определена операция обращения. Нельзя во всех случаях гарантировать, что будущие изменения не повлекут за собой изменения в состоянии объекта. Запись вызова функции-члена ясно показывает программисту, что объект может быть изменен, тогда как запись с параметром-ссылкой далеко не столь очевидна. Далее, выражения допустимые в функции-члене могут быть существенно короче эквивалентных выражений в глобальной функции. Глобальная функция должна использовать явно заданные параметры, а в функции-члене можно неявно использовать указатель this. Наконец, поскольку имена членов не являются глобальными именами, они обычно оказываются короче, чем имен глобальных функций.
Единственный заголовочный файл
Проще всего разбить программу на несколько файлов следующим образом: поместить определения всех функций и данных в некоторое число входных файлов, а все типы, необходимые для связи между ними, описать в единственном заголовочном файле. Все входные файлы будут включать заголовочный файл. Программу калькулятора можно разбить на четыре входных файла .c: lex.c, syn.c, table.c и main.c. Заголовочный файл dc.h будет содержать описания каждого имени, которое используется более чем в одном .c файле:
// dc.h: общее описание для калькулятора
#include <iostream.h>
enum token_value {
NAME, NUMBER, END,
PLUS='+', MINUS='-', MUL='*', DIV='/',
PRINT=';', ASSIGN='=', LP='(', RP=')'
};
extern int no_of_errors;
extern double error(const char* s);
extern token_value get_token();
extern token_value curr_tok;
extern double number_value;
extern char name_string[256];
extern double expr();
extern double term();
extern double prim();
struct name {
char* string;
name* next;
double value;
};
extern name* look(const char* p, int ins = 0);
inline name* insert(const char* s) { return look(s,1); }
Если не приводить сами операторы, lex.c должен иметь такой вид:
// lex.c: ввод и лексический анализ
#include "dc.h"
#include <ctype.h>
token_value curr_tok;
double number_value;
char name_string[256];
token_value get_token() { /* ... */ }
Используя составленный заголовочный файл, мы добьемся, что описание каждого объекта, введенного пользователем, обязательно окажется в том файле, где этот объект определяется. Действительно, при обработке файла lex.c транслятор столкнется с описаниями
extern token_value get_token();
// ...
token_value get_token() { /* ... */ }
Это позволит транслятору обнаружить любое расхождение в типах, указанных при описании данного имени. Например, если бы функция get_token() была описана с типом token_value, но определена с типом int, трансляция файла lex.c выявила бы ошибку: несоответствие типа.
Файл syn.c может иметь такой вид:
// syn.c: синтаксический анализ и вычисления
#include "dc.h"
double prim() { /* ... */ }
double term() { /* ... */ }
double expr() { /* ... */ }
Файл table.c может иметь такой вид:
// table.c: таблица имен и функция поиска
#include "dc.h"
extern char* strcmp(const char*, const char*);
extern char* strcpy(char*, const char*);
extern int strlen(const char*);
const int TBLSZ = 23;
name* table[TBLSZ];
name* look(char* p, int ins) { /* ... */ }
Отметим, что раз строковые функции описаны в самом файле table.c, транслятор не может проверить согласованность этих описаний по типам. Всегда лучше включить соответствующий заголовочный файл, чем описывать в файле .c некоторое имя как extern. Это может привести к включению "слишком многого", но такое включение нестрашно, поскольку не влияет на скорость выполнения программы и ее размер, а программисту позволяет сэкономить время. Допустим, функция strlen() снова описывается в приведенном ниже файле main.c. Это только лишний ввод символов и потенциальный источник ошибок, т.к. транслятор не сможет обнаружить расхождения в двух описаниях strlen() (впрочем, это может сделать редактор связей). Такой проблемы не возникло бы, если бы в файле dc.h содержались все описания extern, как первоначально и предполагалось. Подобная небрежность присутствует в нашем примере, поскольку она типична для программ на С. Она очень естественна для программиста, но часто приводит к ошибкам и таким программам, которые трудно сопровождать. Итак, предупреждение сделано!
Наконец, приведем файл main.c:
// main.c: инициализация, основной цикл, обработка ошибок
#include "dc.h"
double error(char* s) { /* ... */ }
extern int strlen(const char*);
int main(int argc, char* argv[]) { /* ... */ }
В одном важном случае заголовочные файлы вызывают большое неудобство. С помощью серии заголовочных файлов и стандартной библиотеки расширяют возможности языка, вводя множество типов (как общих, так и рассчитанных на конкретные приложения; см. главы 5-9). В таком случае текст каждой единицы трансляции может начинаться тысячами строк заголовочных файлов. Содержимое заголовочных файлов библиотеки, как правило, стабильно и меняется редко. Здесь очень пригодился бы претранслятор, который обрабатывает его. По сути, нужен язык специального назначения со своим транслятором. Но устоявшихся методов построения такого претранслятора пока нет.
Еще о классах
В этом разделе описаны дополнительные свойства класса. Описан способ обеспечить доступ к частным членам в функциях, не являющихся членами ($$5.4.1). Описано, как разрешить коллизии имен членов ($$5.4.2) и как сделать описания классов вложенными ($$5.4.3), но при этом избежать нежелательной вложенности ($$5.4.4). Вводится понятие статических членов (static), которые используются для представления операций и данных, относящихся к самому классу, а не к отдельным его объектам ($$5.4.5). Раздел завершается примером, показывающим, как можно построить дискриминирующее (надежное) объединение ($$5.4.6).
Файлы и потоки
Ниже приведена программа копирования одного файла в другой. Имена файлов берутся из командной строки программы:
#include <fstream.h>
#include <libc.h>
void error(char* s, char* s2 ="")
{
cerr << s << ' ' << s2 << '\n';
exit(1);
}
int main(int argc, char* argv[])
{
if (argc != 3) error("wrong number of arguments");
ifstream from(argv[1]);
if (!from) error("cannot open input file",argv[1]);
ostream to(argv[2]);
if (!to) error("cannot open output file",argv[2]);
char ch;
while (from.get(ch)) to.put(ch);
if (!from.eof() || to.bad())
error("something strange happened");
return 0;
}
Для открытия выходного файла создается объект класса ofstream - выходной поток файла, использующий в качестве аргумента имя файла. Аналогично, для открытия входного файла создается объект класса ifstream - входной файловый поток, также использующий в качестве аргумента имя файла. В обоих случаях следует проверить состояние созданного объекта, чтобы убедиться в успешном открытии файла, а если это не так, операции завершатся не успешно, но корректно.
По умолчанию ifstream всегда открывается на чтение, а ofstream открывается на запись. В ostream и в istream можно использовать необязательный второй аргумент, указывающий иные режимы открытия:
class ios {
public:
//...
enum open_mode {
in=1, // открыть на чтение
out=2, // открыть как выходной
ate=4, // открыть и переместиться в конец файла
app=010, // добавить
trunc=020, // сократить файл до нулевой длины
nocreate=040, // неудача, если файл не существует
noreplace=0100 // неудача, если файл существует
};
//...
};
Настоящие значения для open_mode и их смысл вероятно будут зависеть от реализации. Будьте добры, за деталями обратитесь к руководству по вашей библиотеке или экспериментируйте. Приведенные комментарии могут прояснить их назначение. Например, можно открыть файл с условием, что операция открытия не выполнится, если файл уже не существует:
void f()
{
ofstream mystream(name,ios::out|ios::nocreate);
if (ofstream.bad()) {
//...
}
//...
}
Также можно открыть файл сразу на чтение и запись:
fstream dictionary("concordance", ios::in|ios::out);
Все операции, допустимые для ostream и ostream, можно применять к fstream. На самом деле, класс fstream является производным от iostream, который является, в свою очередь, производным от istream и ostream. Причина, по которой информация по буферизации и форматированию для ostream и istream находится в виртуальном базовом классе ios, в том, чтобы заставить действовать всю эту последовательность производных классов. По этой же причине операции позиционирования в istream и ostream имеют разные имена - seekp() и seekg(). В iostream есть отдельные позиции для чтения и записи.
Философские замечания
Язык программирования решает две взаимосвязанные задачи: позволяет программисту записать подлежащие выполнению действия и формирует понятия, которыми программист оперирует, размышляя о своей задаче. Первой цели идеально отвечает язык, который очень "близок машине". Тогда со всеми ее основными "сущностями" можно просто и эффективно работать на этом языке, причем делая это очевидным для программиста способом. Именно это имели в виду создатели С. Второй цели идеально отвечает язык, который настолько "близок к поставленной задаче", что на нем непосредственно и точно выражаются понятия, используемые в решении задачи. Именно это имелось в виду, когда первоначально определялись средства, добавляемые к С.
Связь между языком, на котором мы думаем и программируем, а также между задачами и их решениями, которые можно представить в своем воображении, довольно близка. По этой причине ограничивать возможности языка только поиском ошибок программиста - в лучшем случае опасно. Как и в случае естественных языков, очень полезно обладать, по крайней мере, двуязычием. Язык предоставляет программисту некоторые понятия в виде языковых инструментов; если они не подходят для задачи, их просто игнорируют. Например, если существенно ограничить понятие указателя, то программист будет вынужден для создания структур, указателей и т.п. использовать вектора и операции с целыми. Хороший проект программы и отсутствие в ней ошибок нельзя гарантировать только наличием или отсутствием определенных возможностей в языке.
Типизация языка должна быть особенно полезна для нетривиальных задач. Действительно, понятие класса в С++ проявило себя как мощное концептуальное средство.
Форматирование
Все примеры из 10.2 содержали неформатированный вывод, который являлся
преобразованием объекта в последовательность символов, задаваемую стандартными правилами, длина которой также определяется этими правилами. Часто программистам требуются более развитые возможности. Так, возникает потребность контролировать размер памяти, необходимой для операции вывода, и формат, используемый для выдачи чисел. Точно так же допустимо управление некоторыми аспектами ввода.
Функции
Функция - это поименованная часть программы, которая может вызываться из других частей программы столько раз, сколько необходимо. Приведем программу, выдающую степени числа два:
extern float pow ( float, int );
// pow () определена в другом месте
int main ()
{
for ( int i=0; i<10; i++ ) cout << pow ( 2, i ) << '\n';
}
Первая строка является описанием функции. Она задает pow как функцию с параметрами типа float и int, возвращающую значение типа float. Описание функции необходимо для ее вызова, ее определение находится в другом месте.
При вызове функции тип каждого фактического параметра сверяется с типом, указанным в описании функции, точно так же, как если бы инициализировалась переменная описанного типа. Это гарантирует надлежащую проверку и преобразования типов. Например, вызов функции pow(12.3,"abcd") транслятор сочтет ошибочным, поскольку "abcd" является строкой, а не параметром типа int. В вызове pow(2,i) транслятор преобразует целую константу (целое 2) в число с плавающей точкой (float), как того требует функция. Функция pow может быть определена следующим образом:
float pow ( float x, int n )
{
if ( n < 0 )
error ( "ошибка: для pow () задан отрицательный показатель");
switch ( n )
{
case 0: return 1;
case 1: return x;
default: return x * pow ( x, n-1 );
}
}
Первая часть определения функции задает ее имя, тип возвращаемого значения (если оно есть), а также типы и имена формальных параметров (если они существуют). Значение возвращается из функции с помощью оператора return.
Разные функции обычно имеют разные имена, но функциям, выполняющим сходные операции над объектами разных типов, лучше дать одно имя. Если типы параметров таких функций различны, то транслятор всегда может разобраться, какую функцию нужно вызывать. Например, можно иметь две функции возведения в степень: одну - для целых чисел, а другую - для чисел с плавающей точкой:
int pow ( int, int );
double pow ( double, double );
//...
x = pow ( 2,10 ); // вызов pow ( int, int )
y = pow ( 2.0, 10.0 );// вызов pow ( double, double )
Такое многократное использование имени называется перегрузкой имени функции или просто перегрузкой; перегрузка рассматривается особо в главе 7.
Параметры функции могут передаваться либо "по значению", либо "по
ссылке". Рассмотрим определение функции, которая осуществляет взаимообмен значений двух целых переменных. Если используется стандартный способ передачи параметров по значению, то придется передавать указатели:
void swap ( int * p, int * q )
{
int t = * p;
* p = * q;
* q = t;
}
Унарная операция * называется косвенностью (или операцией разыменования), она выбирает значение объекта, на который настроен указатель. Функцию можно вызывать следующим образом:
void f ( int i, int j )
{
swap ( & i, & j );
}
Если использовать передачу параметра по ссылке, можно обойтись без
явных операций с указателем:
void swap (int & r1, int & r2 )
{
int t = r1;
r1 = r2;
r2 = t;
}
void g ( int i, int j )
{
swap ( i, j );
}
Для любого типа T запись T& означает "ссылка на T". Ссылка служит
синонимом той переменной, которой она инициализировалась. Отметим, что перегрузка допускает сосуществование двух функций swap в одной программе.
Функции-члены
Посмотрим, как можно представить в языке понятие даты, используя для этого тип структуры и набор функций, работающих с переменными этого типа:
struct date { int month, day, year; };
date today;
void set_date(date*, int, int, int);
void next_date(date*);
void print_date(const date*);
// ...
Никакой явной связи между функциями и структурой date нет. Ее можно установить, если описать функции как члены структуры:
struct date {
int month, day, year;
void set(int, int, int);
void get(int*, int* int*);
void next();
void print();
};
Описанные таким образом функции называются функциями-членами. Их можно вызывать только через переменные соответствующего типа, используя стандартную запись обращения к члену структуры:
date today;
date my_birthday;
void f()
{
my_birthday.set(30,12,1950);
today.set(18,1,1991);
my_birthday.print();
today.next();
}
Поскольку разные структуры могут иметь функции-члены с одинаковыми именами, при определении функции-члена нужно указывать имя структуры:
void date::next()
{
if (++day > 28 ) {
// здесь сложный вариант
}
}
В теле функции-члена имена членов можно использовать без указания имени объекта. В таком случае имя относится к члену того объекта, для которого была вызвана функция.
Простые структуры данных вроде employee и manager сами по себе не слишком интересны, а часто и не особенно полезны. Поэтому добавим к ним функции:
class employee {
char* name;
// ...
public:
employee* next; // находится в общей части, чтобы
// можно было работать со списком
void print() const;
// ...
};
class manager : public employee {
// ...
public:
void print() const;
// ...
};
Надо ответить на некоторые вопросы. Каким образом функция-член производного класса manager может использовать члены базового класса employee? Какие члены базового класса employee могут использовать функции-члены производного класса manager? Какие члены базового класса employee может использовать функция, не являющаяся членом объекта типа manager? Какие ответы на эти вопросы должна давать реализация языка, чтобы они максимально соответствовали задаче программиста?
Рассмотрим пример:
void manager::print() const
{
cout << " имя " << name << '\n';
}
Член производного класса может использовать имя из общей части своего базового класса наравне со всеми другими членами, т.е. без указания имени объекта. Предполагается, что есть объект, на который настроен this, поэтому корректным обращением к name будет this->name. Однако, при трансляции функции manager::print() будет зафиксирована ошибка: члену производного класса не предоставлено право доступа к частным членам его базового класса, значит name недоступно в этой функции.
Возможно многим это покажется странным, но давайте рассмотрим альтернативное решение: функция-член производного класса имеет доступ к частным членам своего базового класса. Тогда само понятие частного (закрытого) члена теряет всякий смысл, поскольку для доступа к нему достаточно просто определить производный класс. Теперь уже будет недостаточно для выяснения, кто использует частные члены класса, просмотреть все функции-члены и друзей этого класса. Придется просмотреть все исходные файлы программы, найти производные классы, затем исследовать каждую функцию этих классов. Далее надо снова искать производные классы от уже найденных и т.д. Это, по крайней мере, утомительно, а скорее всего нереально. Нужно всюду, где это возможно, использовать вместо частных членов защищенные (см. $$6.6.1).
Как правило, самое надежное решение для производного класса - использовать только общие члены своего базового класса:
void manager::print() const
{
employee::print(); // печать данных о служащих
// печать данных об управляющих
}
Отметим, что операция :: необходима, поскольку функция print() переопределена в классе manager. Такое повторное использование имен типично для С++. Неосторожный программист написал бы:
void manager::print() const
{
print(); // печать данных о служащих
// печать данных об управляющих
}
В результате он получил бы рекурсивную последовательность вызовов manager::print().
.Функции размещения и освобождения
Во всех приведенных примерах память рассматривалась как нечто данное.
Однако, обычная функция общего назначения для распределения свободной памяти оказывается до удивления менее эффективной, чем функция размещения специального назначения. Вырожденным случаем таких функций можно считать приведенный пример с размещением в "бесконечной" памяти и с пустой функцией освобождения. В библиотеке могут быть более содержательные функции размещения, и бывает, что с их помощью удается удвоить скорость выполнения программы. Но прежде, чем пытаться с их помощью оптимизировать программу, запустите для нее профилировщик, чтобы выявить накладные расходы, связанные с выделением памяти.
В разделах $$5.5.6 и $$6.7 было показано как с помощью определения функций X::operator new() и X::operator delete() можно использовать функцию размещения для объектов класса X. Здесь есть определенная трудность. Для двух классов X и Y функции размещения могут быть настолько сходными, что желательно иметь одну такую функцию. Иными словами, желательно иметь в библиотеке такой класс, который предоставляет функции размещения и освобождения, пригодные для размещения объектов данного класса. Если такой класс есть, то функции размещения и освобождения для данного класса получаются за счет привязки к нему общих функций размещения и освобождения:
class X {
static Pool my_pool;
// ...
public:
// ...
void* operator new(size_t) { return my_pool.alloc(); }
void operator delete(void* p) { my_pool.free(p); }
};
Pool X::my_pool(sizeof(X));
С помощью класса Pool память распределяется блоками одного размера.
В приведенном примере объект my_pool отводит память блоками размером sizeof(X).
Составляется описание класса X и используется Pool с учетом оптимизации скорости программы и компактности представления. Обратите внимание, что размер выделяемых блоков памяти является для класса "встроенным", поэтому задающий размер параметр функции X::operator new() не используется. Используется вариант функции X::operator delete() без параметра. Если класс Y является производным класса X, и sizeof(Y)>sizeof(X), то для класса Y должны быть свои функции размещения и освобождения. Наследование функций класса X приведет к катастрофе. К счастью, задать такие функции для Y очень просто.
Класс Pool предоставляет связанный список элементов требуемого размера. Элементы выделяются из блока памяти фиксированного размера и по мере надобности запрашиваются новые блоки памяти. Элементы группируются большими блоками, чтобы минимизировать число обращений за памятью к функции размещения общего назначения. До тех пор пока не будет уничтожен сам объект PooL, память никогда не возвращается функции размещения общего назначения.
Приведем описание класса Pool:
class Pool {
struct Link { Link* next; }
const unsigned esize;
Link* head;
Pool(Pool&); // защита от копирования
void operator= (Pool&); // защита от копирования
void grow();
public:
Pool(unsigned n);
~Pool();
void* alloc();
void free(void* b);
};
inline void* Pool::alloc()
{
if (head==0) grow();
Link* p = head;
head = p->next;
return p;
}
inline void Pool::free(void* b)
{
Link* p = (Link*) b;
p->next = head;
head = p;
}
Приведенные описания логично поместить в заголовочный файл Pool.h. Следующие определения могут находиться в любом месте программе и завершают наш пример. Объект Pool должен инициализироваться конструктором:
Pool::Pool(unsigned sz) : esize(sz)
{
head = 0;
}
Функция Pool::grow() будет связывать все элементы в список квантов свободной памяти head, образуя из них новый блок. Определения остальных функций-членов оставлены в качестве упражнений 5 и 6 в $$13.11.
void Pool::grow()
{
const int overhead = 12;
const int chunk_size = 8*1024-overhead;
const int nelem = (chunk_size-esize)/esize;
char* start = new char[chunk_size];
char* last = &start[(nelem-1)*esize];
for (char* p = start; p<last; p+=esize)
((Link*)p)->next = ((Link*)p)+1;
((Link*)last)->next = 0;
head = (Link*)start;
}
Функция ввода
Получение входных данных - часто самая запутанная часть программы. Причина кроется в том, что программа должна взаимодействовать с пользователем, то есть "мириться" с его прихотями, учитывать принятые соглашения и предусматривать кажущиеся редкими ошибки. Попытки заставить человека вести себя более удобным для машины образом, как правило, рассматриваются как неприемлемые, что справедливо. Задача ввода для функции низкого уровня состоит в последовательном считывании символов и составлении из них лексемы, с которой работают уже функции более высокого уровня. В этом примере низкоуровневый ввод делает функция get_token(). К счастью, написание низкоуровневой функции ввода достаточно редкая задача. В хороших системах есть стандартные функции для таких операций.
Правила ввода для калькулятора были специально выбраны несколько громоздкими для потоковых функций ввода. Незначительные изменения в определениях лексем превратили бы get_token() в обманчиво простую функцию.
Первая сложность состоит в том, что символ конца строки '\n' важен для калькулятора, но потоковые функции ввода воспринимают его как символ обобщенного пробела. Иначе говоря, для этих функций '\n' имеет значение только как символ, завершающий лексему. Поэтому приходится анализировать все обобщенные пробелы (пробел, табуляция и т.п.). Это делается в операторе do, который эквивалентен оператору while, за исключением того, что тело оператора do всегда выполняется хотя бы один раз:
char ch;
do { // пропускает пробелы за исключением '\n'
if(!cin.get(ch)) return curr_tok = END;
} while (ch!='\n' && isspace(ch));
Функция cin.get(ch) читает один символ из стандартного входного потока в ch. Значение условия if(!cin.get(ch)) - ложь, если из потока cin нельзя получить ни одного символа. Тогда возвращается лексема END, чтобы закончить работу калькулятора. Операция ! (NOT) нужна потому, что в случае успешного считывания get() возвращает ненулевое значение.
Функция-подстановка isspace() из <ctype.h> проверяет, не является ли ее параметр обобщенным пробелом ($$10.3.1). Она возвращает ненулевое значение, если является, и нуль в противном случае. Проверка реализуется как обращение к таблице, поэтому для скорости лучше вызывать isspace(), чем проверять самому. То же можно сказать о функциях isalpha(), isdigit() и isalnum(), которые используются в get_token().
После пропуска обобщенных пробелов следующий считанный символ определяет, какой будет начинающаяся с него лексема. Прежде, чем привести всю функцию, рассмотрим некоторые случаи отдельно. Лексемы '\n' и ';', завершающие выражение, обрабатываются следующим образом:
switch (ch) {
case ';':
case '\n':
cin >> ws; // пропуск обобщенного пробела
return curr_tok=PRINT;
Необязательно снова пропускать пробел, но, сделав это, мы избежим повторных вызовов функции get_token(). Переменная ws, описанная в файле <stream.h>, используется только как приемник ненужных пробелов. Ошибка во входных данных, а также конец ввода не будут обнаружены до следующего вызова функции get_token(). Обратите внимание, как несколько меток выбора помечают одну последовательность операторов, заданную для этих вариантов. Для обоих символов ('\n' и ';') возвращается лексема PRINT, и она же помещается в curr_tok.
Числа обрабатываются следующим образом:
case '0':
case '1':
case '2':
case '3':
case '4':
case '5':
case '6':
case '7':
case '8':
case '9':
case '.':
cin.putback(ch);
cin >> number_value;
return curr_tok=NUMBER;
Размещать метки вариантов горизонтально, а не вертикально,- не самый лучший способ, поскольку такой текст труднее читать; но писать строку для каждой цифры утомительно. Поскольку оператор >> может читать константу с плавающей точкой типа double, программа тривиальна: прежде всего начальный символ (цифра или точка) возвращается назад в cin, а затем константу можно считать в number_value. Имя, т.е. лексема NAME, определяется как буква, за которой может идти несколько букв или цифр:
if (isalpha(ch)) {
char* p = name_string;
*p++ = ch;
while (cin.get(ch) && isalnum(ch)) *p++ = ch;
cin.putback(ch);
*p = 0;
return curr_tok=NAME;
}
Этот фрагмент программы заносит в name_string строку, оканчивающуюся нулевым символом. Функции isalpha() и isalnum() определены в <ctype.h>. Результат isalnum(c) ненулевой, если c - буква или цифра, и нулевой в противном случае.
Приведем, наконец, функцию ввода полностью:
token_value get_token()
{
char ch;
do { // пропускает обобщенные пробелы за исключением '\n'
if(!cin.get(ch)) return curr_tok = END;
} while (ch!='\n' && isspace(ch));
switch (ch) {
case ';':
case '\n':
cin >> ws; // пропуск обобщенного пробела
return curr_tok=PRINT;
case '*':
case '/':
case '+':
case '-':
case '(':
case ')':
case '=':
return curr_tok=token_value(ch);
case '0': case '1': case '2': case '3': case '4':
case '5': case '6': case '7': case '8': case '9':
case '.':
cin.putback(ch);
cin >> number_value;
return curr_tok=NUMBER;
default: // NAME, NAME= или ошибка
if (isalpha(ch)) {
char* p = name_string;
*p++ = ch;
while (cin.get(ch) && isalnum(ch)) *p++ = ch;
cin.putback(ch);
*p = 0;
return curr_tok=NAME;
}
error("недопустимая лексема");
return curr_tok=PRINT;
}
}
Преобразование операции в значение лексемы для нее тривиально, поскольку в перечислении token_value лексема операции была определена как целое (код символа операции).
Гибридный проект
Переход на новые методы работы может быть мучителен для любой организации. Раскол внутри нее и расхождения между сотрудниками могут быть значительными. Но резкий решительный переход, способный в одночасье превратить эффективных и квалифицированных сторонников "старой школы" в неэффективных новичков "новой школы" обычно неприемлем. В то же время, нельзя достичь больших высот без изменений, а значительные изменения обычно связаны с риском.
Язык С++ создавался с целью сократить такой риск за счет постепенного введения новых методов. Хотя очевидно, что наибольшие преимущества при использовании С++ достигаются за счет абстракции данных, объектно-ориентированного программирования и объектно-ориентированного проектирования, совершенно неочевидно, что быстрее всего достичь этого можно решительным разрывом с прошлым. Вряд ли такой явный разрыв будет возможен, обычно стремление к усовершенствованиям сдерживается или должно сдерживаться, чтобы переход к ним был управляемым. Нужно учитывать следующее:
- Разработчикам и программистам требуется время для овладения новыми методами.
- Новые программы должны взаимодействовать со старыми программами.
- Старые программы нужно сопровождать (часто бесконечно).
- Работа по текущим проектам и программам должна быть выполнена в срок.
- Средства, рассчитанные на новые методы, нужно адаптировать к локальному окружению.
Здесь рассматриваются как раз ситуации, связанные с перечисленными требованиями. Легко недооценить два первых требования.
Поскольку в С++ возможны несколько схем программирования, язык допускает постепенный переход на него, используя следующие преимущества такого перехода:
- Изучая С++, программисты могут продолжать работать.
- В окружении, бедном на программные средства, использование С++ может принести значительные выгоды.
- Программы, написанные на С++, могут хорошо взаимодействовать с программами, написанными на С или других традиционных языках.
- Язык имеет большое подмножество, совместимое с С.
Идея заключается в постепенном переходе программиста с традиционного языка на С++: вначале он программирует на С++ в традиционном процедурном стиле, затем с помощью методов абстракции данных, и наконец, когда овладеет языком и связанными с ним средствами, полностью переходит на объектно-ориентированное программирование. Заметим, что хорошо спроектированную библиотеку использовать намного проще, чем проектировать и реализовывать, поэтому даже с первых своих шагов новичок может получить преимущества, используя более развитые средства С++.
Идея постепенного, пошагового овладения С++, а также возможность смешивать программы на С++ с программами, написанными на языках, не имеющих средств абстракции данных и объектно-ориентированного программирования, естественно приводит к проекту, имеющему гибридный стиль. Большинство интерфейсов можно пока оставить на процедурном уровне, поскольку что-либо более сложное не принесет немедленного выигрыша. Например, обращение к стандартной библиотеке math из С определяется на С++ так:
extern "C" {
#include <math.h>
}
и стандартные математические функции из библиотеки можно использовать
так же, как и в С. Для всех основных библиотек такое включение должно быть сделано теми, кто поставляет библиотеки, так что программист на С++ даже не будет знать, на каком языке реализована библиотечная функция. Использование библиотек, написанных на таких языках как С, является первым и вначале самым важным способом повторного использования на С++.
На следующем шаге, когда станут необходимы более сложные приемы, средства, реализованные на таких языках как С или Фортран, представляются в виде классов за счет инкапсуляции структур данных и функций в интерфейс классов С++. Простым примером введения более высокого семантического уровня за счет перехода от уровня процедур плюс структур данных к уровню абстракции данных может служить класс строк из $$7.6. Здесь за счет инкапсуляции символьных строк и стандартных строковых функций С получается новый строковый тип, который гораздо проще использовать.
Подобным образом можно включить в иерархию классов любой встроенный или отдельно определенный тип. Например, тип int можно включить в иерархию классов так:
class Int : public My_object {
int i;
public:
// definition of operations
// see exercises [8]-[11] in section 7.14 for ideas
// определения операций получаются в упражнениях [8]-[11]
// за идеями обратитесь к разделу 7.14
};
Так следует делать, если действительно есть потребность включить такие типы в иерархию.
Обратно, классы С++ можно представить в программе на С или Фортране как функции и структуры данных. Например:
class myclass {
// representation
public:
void f();
T1 g(T2);
// ...
};
extern "C" { // map myclass into C callable functions:
void myclass_f(myclass* p) { p->f(); }
T1 myclass_g(myclass* p, T2 a) { return p->g(a); }
// ...
};
В С-программе следует определить эти функции в заголовочном файле следующим образом:
// in C header file
extern void myclass_f(struct myclass*);
extern T1 myclass_g(struct myclass*, T2);
Такой подход позволяет разработчику на С++, если у него уже есть запас программ, написанных на языках, в которых отсутствуют понятия абстракции данных и иерархии классов, постепенно приобщаться к этим понятиям, даже при том требовании, что окончательную версии программы можно будет вызывать из традиционных процедурных языков.
Все нетривиальные программы состоят из
Итерация присуща человеку, а рекурсия - богу.
- Л. Дойч
Все нетривиальные программы состоят из нескольких раздельно транслируемых единиц, по традиции называемых файлами. В этой главе описано, как раздельно транслируемые функции могут вызывать друг друга, каким образом они могут иметь общие данные, и как добиться непротиворечивости типов, используемых в разных файлах программы. Подробно обсуждаются функции, в том числе: передача параметров, перегрузка имени функции, стандартные значения параметров, указатели на функции и, естественно, описания и определения функций. В конце главы обсуждаются макровозможности языка.
Эта глава посвящена понятию производного
Не плоди объекты без нужды.
- В. Оккам
Эта глава посвящена понятию производного класса. Производные классы - это простое, гибкое и эффективное средство определения класса. Новые возможности добавляются к уже существующему классу, не требуя его перепрограммирования или перетрансляции. С помощью производных классов можно организовать общий интерфейс с несколькими различными классами так, что в других частях программы можно будет единообразно работать с объектами этих классов. Вводится понятие виртуальной функции, которое позволяет использовать объекты надлежащим образом даже в тех случаях, когда их тип на стадии трансляции неизвестен. Основное назначение производных классов – упростить программисту задачу выражения общности классов.
я выбираю слово, оно значит
Если я выбираю слово, оно значит только то,
что я решу, ни больше и ни меньше.
- Шалтай Болтай
Глава содержит описание механизма перегрузки операций в С++. Программист может задать интерпретацию операций, когда они применяются к объектам определенного класса. Помимо арифметических, логических и операций отношения можно переопределить вызов функций (), индексацию [], косвенное обращение ->, а также присваивание и инициализацию. Можно определить явные и скрытые преобразования между пользовательскими и основными типами. Показано, как определить класс, объект которого можно копировать и уничтожать только с помощью специальных, определенных пользователем функций.
Я прервал вас, поэтому не
Я прервал вас, поэтому не прерывайте меня.
- Уинстон Черчилл
В этой главе описан механизм обработки особых ситуаций и некоторые, основывающиеся на нем, способы обработки ошибок. Механизм состоит
в запуске особой ситуации, которую должен перехватить специальный обработчик. Описываются правила перехвата особых ситуаций и правила реакции на неперехваченные и неожиданные особые ситуации. Целые группы особых ситуаций можно определить как производные классы. Описывается способ, использующий деструкторы и обработку особых ситуаций, который обеспечивает надежное и скрытое от пользователя управление ресурсами.
КРАТКИЙ ОБЗОР С++
"Начнем с того, что вздернем всех этих законников, языковедов".
("Король Генрих VI", действие II)
В этой главе содержится краткий обзор основных концепций и конструкций языка С++. Он служит для беглого знакомства с языком. Подробное описание возможностей языка и методов программирования на нем дается в следующих главах. Разговор ведется в основном вокруг абстракции данных и объектно-ориентированного программирования, но перечисляются и основные возможности процедурного программирования.
ОПИСАНИЯ И КОНСТАНТЫ
"Совершенство достижимо только в момент краха".
(С.Н. Паркинсон)
В данной главе описаны основные типы (char, int, float и т.д.) и способы построения на их основе новых типов (функций, векторов, указателей и т.д.). Описание вводит в программу имя, указав его тип и, возможно, начальное значение. В этой главе вводятся такие понятия, как описание и определение, типы, область видимости имен, время жизни объектов. Даются обозначения литеральных констант С++ и способы задания символических констант. Приводятся примеры, которые просто демонстрируют возможности языка. Более осмысленные примеры, иллюстрирующие возможности выражений и операторов языка С++, будут приведены в следующей главе. В этой главе лишь упоминаются средства для определения пользовательских типов и операций над ними. Они обсуждаются в главах 5 и 7.
ПОТОКИ
"Доступно только то, что видимо"
Б. Керниган
В языке С++ нет средств для ввода-вывода. Их и не нужно, поскольку такие средства можно просто и элегантно создать на самом языке. Описанная здесь библиотека потокового ввода-вывода реализует строгий типовой и вместе с тем гибкий и эффективный способ символьного ввода и вывода целых, вещественных чисел и символьных строк, а также является базой для расширения, рассчитанного на работу с пользовательскими типами данных. Пользовательский интерфейс библиотеки находится в файле <iostream.h>. Эта глава посвящена самой потоковой библиотеке, некоторым способам работы с ней и определенным приемам реализации библиотеки.
ПРОЕКТИРОВАНИЕ БИБЛИОТЕК
Проект библиотеки - это проект языка,
(фольклор фирмы Bell Laboratories)
... и наоборот.
- А. Кениг
Эта глава содержит описание различных приемов, оказавшихся полезными при создании библиотек для языка С++. В частности, в ней рассматриваются конкретные типы, абстрактные типы, узловые классы, управляющие классы и интерфейсные классы. Помимо этого обсуждаются понятия обширного интерфейса и структуры области приложения, использование динамической информации о типах и методы управления памятью. Внимание акцентируется на том, какими свойствами должны обладать библиотечные классы, а не на специфике языковых средств, которые используются для реализации таких классов, и не на определенных полезных функциях, которые должна предоставлять библиотека.
ПРОЕКТИРОВАНИЕ И РАЗВИТИЕ
"Серебряной пули не существует."
- Ф. Брукс
В этой главе обсуждаются подходы к разработке программного обеспечения.
Обсуждение затрагивает как технические, так и социологические аспекты процесса развития программного обеспечения. Программа рассматривается как модель реальности, в которой каждый класс представляет определенное понятие. Ключевая задача проектирования состоит в определении доступной и защищенной частей интерфейса класса, исходя из которых определяются различные части программы. Определение этих интерфейсов есть итеративный процесс, обычно требующий экспериментирования. Упор делается на важной роли проектирования и организационных факторов в процессе развития программного обеспечения.
ПРОЕКТИРОВАНИЕ И С++
Стремись к простоте, максимальной простоте, но не сверх того.
- А. Эйнштейн
Эта глава посвящена связи между проектированием и языком программирования С++. В ней исследуется применение классов при проектировании и указываются определенные виды зависимостей, которые следует выделять как внутри класса, так и между классами. Изучается роль статического контроля типов. Исследуется применение наследования и связь наследования и принадлежности. Обсуждается понятие компонента и даются некоторые образцы для интерфейсов.