Язык программирования C++ от Страуструпа

         

Отношения внутри класса


До сих пор мы обсуждали только классы, и хотя операции упоминались, если не считать обсуждения шагов процесса развития программного обеспечения ($$11.3.3.2), то они были на втором плане, объекты же практически вообще не упоминались. Понять это просто: в С++ класс, а не функция или объект, является основным понятием организации системы.

Класс может скрывать в себе всякую специфику реализации, наравне с "грязными" приемами программирования, а иногда он вынужден это делать. В то же время объекты большинства классов сами образуют регулярную структуру и используются такими способами, что их достаточно просто описать.  Объект класса может  быть совокупностью других вложенных объектов (их часто называют членами), многие из которых, в свою очередь, являются указателями или ссылками на другие объекты. Поэтому отдельный объект можно рассматривать как корень дерева объектов, а все входящие в него объекты как "иерархию объектов", которая дополняет иерархию классов, рассмотренную в $$12.2.4. Рассмотрим в качестве примера класс строк из $$7.6:

class String {

  int sz;

  char* p;

  public:

     String(const char* q);

     ~String();

     //...

};

Объект типа String можно изобразить так:



Парадигмы программирования


Объектно-ориентированное программирование - это метод программирования, способ написания "хороших" программ для множества задач. Если этот термин имеет какой-то смысл, то он должен подразумевать: такой язык программирования, который предоставляет хорошие возможности для объектно-ориентированного стиля программирования.

Здесь следует указать на важные различия. Говорят, что язык поддерживает некоторый стиль программирования, если в нем есть такие возможности, которые делают программирование в этом стиле удобным (достаточно простым, надежным и эффективным). Язык не поддерживает некоторый стиль программирования, если требуются большие усилия или даже искусство, чтобы написать программу в этом стиле. Однако это не означает, что язык запрещает писать программы в этом стиле. Действительно, можно писать структурные программы на Фортране и объектно-ориентированные программы на С, но это будет пустой тратой сил, поскольку данные языки не поддерживают указанных стилей программирования.

Поддержка языком определенной парадигмы (стиля) программирования явно проявляется в конкретных языковых конструкциях, рассчитанных на нее.  Но она может проявляться в более тонкой, скрытой форме, когда отклонение от парадигмы диагностируется на стадии трансляции или выполнения программы. Самый очевидный пример - это контроль типов.  Кроме того, языковая поддержка парадигмы может дополняться проверкой на однозначность и динамическим контролем. Поддержка может предоставляться и помимо самого языка, например, стандартными библиотеками или средой программирования.

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

[1]    Все конструкции языка должны естественно и элегантно определяться в нем.




[2]    Для решения определенной задачи должна быть возможность использовать сочетания конструкций, чтобы избежать необходимости вводить для этой цели новую конструкцию.

[3]    Должно быть минимальное число неочевидных конструкций специального назначения.

[4]    Конструкция должна допускать такую реализацию, чтобы в не использующей ее программе не возникло дополнительных расходов.

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

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

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

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


Параметр-массив


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

int strlen(const char*);

void f()

{

  char v[] = "массив";

  strlen(v);

  strlen("Николай");

}

Это означает, что фактический параметр типа T[] преобразуется к типу T*, и затем передается. Поэтому присваивание элементу формального параметра-массива изменяет  этот элемент. Иными словами, массивы отличаются от других типов тем, что они не передаются и не могут передаваться по значению.

В вызываемой функции размер передаваемого массива неизвестен. Это неприятно, но есть несколько способов обойти данную трудность. Прежде всего, все строки оканчиваются нулевым символом, и значит их размер легко вычислить. Можно передавать еще один параметр, задающий размер массива. Другой способ: определить структуру, содержащую указатель на массив и размер массива, и передавать ее как параметр (см. также $$1.2.5). Например:

void compute1(int* vec_ptr, int vec_size);  // 1-ый способ

struct vec {                       // 2-ой способ

  int* ptr;

  int size;

};

void compute2(vec v);

Сложнее с многомерными массивами, но часто вместо них можно использовать массив указателей, сведя эти случаи к одномерным массивам. Например:

char* day[] = {

  "mon", "tue", "wed", "thu", "fri", "sat", "sun"

};

Теперь рассмотрим функцию, работающую с двумерным массивом - матрицей. Если размеры обоих индексов известны на этапе трансляции, то проблем нет:

void print_m34(int m[3][4])

{

  for (int i = 0; i<3; i++) {

     for (int j = 0; j<4; J++)

       cout << ' ' << m[i][j];

     cout << '\n';

  }

}

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

Первая размерность для вычисления адреса элемента неважна($$R.8.2.4), поэтому ее можно передавать как параметр:

void print_mi4(int m[][4], int dim1)

{

for ( int i = 0; i<dim1; i++) {


  for ( int j = 0; j<4; j++)
     cout << ' ' << m[i][j];
  cout << '\n';
  }
}
Самый сложный случай - когда надо передавать обе размерности. Здесь "очевидное" решение просто непригодно:
void print_mij(int m[][], int dim1, int dim2)   // ошибка
{
  for ( int i = 0; i<dim1; i++) {
     for ( int j = 0; j<dim2; j++)
       cout << ' ' << m[i][j];
     cout << '\n';
  }
}
Во-первых, описание параметра m[][] недопустимо, поскольку для вычисления адреса элемента многомерного массива нужно знать вторую размерность. Во-вторых, выражение m[i][j] вычисляется как *(*(m+i)+j), а это, по всей видимости, не то, что имел в виду программист. Приведем правильное решение:
void print_mij(int** m, int dim1, int dim2)
{
  for (int i = 0; i< dim1; i++) {
     for (int j = 0; j<dim2; j++)
       cout << ' ' << ((int*)m)[i*dim2+j];  // запутано
     cout << '\n';
  }
}
Выражение, используемое для выбора элемента матрицы, эквивалентно тому, которое создает для этой же цели транслятор, когда известна последняя размерность. Можно ввести дополнительную переменную, чтобы это выражение стало понятнее:
int* v = (int*)m;
// ...
v[i*dim2+j]
Лучше такие достаточно запутанные места в программе упрятывать. Можно определить тип многомерного массива с соответствующей операцией индексирования. Тогда пользователь может и не знать, как размещаются данные в массиве (см. упражнение 18 в $$7.13).

Параметры командной строки


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

Как уже было сказано, выполнение программы начинается вызовом main().

При этом вызове main() получает два параметра: число параметров (обычно называемый argc) и массив строк параметров (обычно называемый argv). Параметры - это символьные строки, поэтому argv имеет тип char*[argc+1]. Имя программы (в том виде, как оно было задано в командной строке) передается в argv[0], поэтому argc всегда не меньше единицы. Например, для командной строки

dc 150/1.1934

параметры имеют значения:

argc        2

argv[0]     "dc"

argv[1]     "150/1.1934"

argv[2]     0

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

int main(int argc, char* argv[])

{

  switch(argc) {

     case 1:    // считывать из стандартного входного потока

       break;

     case 2:    // считывать из строки параметров

       cin = *new istream(argv[1],strlen(argv[1]));

       break;

     default:

       error("слишком много параметров");

       return 1;

  }

  // дальше прежний вариант main

}

При этом istrstream - это функция istream, которая считывает символы из строки, являющейся ее первым параметром. Чтобы использовать istrstream нужно включить в программу файл <strstream.h>, а не обычный <iostream.h>. В остальном же программа осталась без изменений, кроме добавления параметров в функцию main() и использования их в операторе switch. Можно легко изменить функцию main() так, чтобы она могла принимать несколько параметров из командной строки. Однако это не слишком нужно, тем более, что можно нескольких выражений передать как один параметр:

dc "rate=1.1934;150/rate;19.75/rate;217/rate"

Кавычки необходимы потому, что символ ';' служит в системе UNIX разделителем команд. В других системах могут быть свои соглашения о параметрах командной строки.



Параметры шаблона типа


Параметр шаблона типа не обязательно должен быть именем типа (см. $$R.14.2). Помимо имен типов можно задавать строки, имена функций и выражения-константы. Иногда бывает нужно задать как параметр целое:

template<class T, int sz> class buffer {

  T v[sz];   // буфер объектов произвольного типа

  // ...

};

void f()

{

  buffer<char,128> buf1;

  buffer<complex,20> buf2;

  // ...

}

Мы сделали sz параметром шаблона buffer, а не его объектов, и это означает, что размер буфера должен быть известен на стадии трансляции, чтобы его объекты было можно размещать, не используя свободную память. Благодаря этому свойству такие шаблоны как buffer полезны для реализации контейнерных классов, поскольку для последних первостепенным фактором, определяющим их эффективность, является возможность размещать их вне свободной памяти. Например, если в реализации класса string короткие строки размещаются в стеке, это дает существенный выигрыш для программы, поскольку в большинстве задач практически все строки очень короткие. Для реализации таких типов как раз и может пригодиться шаблон buffer.

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

template<class T> void f1(T);                  // нормально

template<class T> void f2(T*);                 // нормально

template<class T> T f3(int);                   // ошибка

template<int i> void f4(int[][i]);             // ошибка

template<int i> void f5(int = i);              // ошибка

template<class T, class C> void f6(T);         // ошибка

template<class T> void f7(const T&, complex);  // нормально

template<class T> void f8(Vector< List<T> >);  // нормально

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


Подобного ограничения нет в шаблонах типа для классов. Дело в том, что параметр для такого шаблона нужно указывать всякий раз, когда описывается объект шаблонного класса. С другой стороны, для шаблонных классов возникает вопрос: когда два созданных по шаблону типа можно считать одинаковыми? Два имени шаблонного класса обозначают один и тот же класс, если совпадают имена их шаблонов, а используемые в этих именах параметры имеют одинаковые значения (с учетом возможных определений typedef, вычисления выражений-констант и т.д.). Вернемся к шаблону buffer:
template<class T, int sz>
class buffer {
  T v[sz];
  // ...
};
void f()
{
  buffer<char,20> buf1;
  buffer<complex,20> buf2;
  buffer<char,20> buf3;
  buffer<char,100> buf4;
  buf1 = buf2;  // ошибка: несоответствие типов
  buf1 = buf3;  // нормально
  buf1 = buf4;  // ошибка: несоответствие типов
  // ...
}
Если в шаблоне типа для класса используются параметры, задающие не типы, возможно появление конструкций, выглядящих двусмысленно:
template<int i>
class X { /* ... */ };
void f(int a, int b)
{
  X < a > b>;                 // Как это понимать: X<a> b и потом
                               // недопустимая лексема, или X< (a>b) >; ?
}
Этот пример синтаксически ошибочен, поскольку первая угловая скобка > завершает параметр шаблона. В маловероятном случае, когда вам понадобится параметр шаблона, являющийся выражением "больше чем", используйте скобки: X< (a>b)>.

Перечисления


Есть способ связывания имен с целыми константами, который часто более удобен, чем описание с const. Например:

enum { ASM, AUTO, BREAK };

Здесь определены три целых константы, которые называются элементами перечисления, и им присвоены значения. Поскольку по умолчанию значения элементов перечисления  начинаются с 0 и идут в возрастающем порядке, то приведенное перечисление эквивалентно определениям:

const ASM = 0;

const AUTO = 1;

const BREAK = 2;

Перечисление может иметь имя, например:

enum keyword { ASM, AUTO, BREAK };

Имя перечисления становится новым типом. С помощью стандартных преобразований тип перечисления может неявно приводиться к типу int. Обратное преобразование (из типа int в перечисление) должно быть задано явно. Например:

void f()

{

  keyword k = ASM;

  int i = ASM;

  k = i     // ошибка

  k = keyword(i);

  i = k;

  k = 4;    // ошибка

}

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

keyword key;

switch (key) {

  case ASM:

     // выполнить что-либо

     break;

  case BREAK:

     // выполнить что-либо

     break;

}

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

enum int16 {

  sign=0100000,

  most_significant=040000,

  least_significant=1

};

Задаваемые значения необязательно должны быть различными, положительными и идти в возрастающем порядке.



Передача операций как параметров функций


Можно не задавать функцию сравнения как часть типа Vector, а передавать ее как второй параметр функции sort(). Этот параметр является объектом класса, в котором определена реализация операции сравнения:

template<class T> void sort(Vector<T>& v, Comparator<T>& cmp)

{

  unsigned n = v.size();

  for (int i = 0; i<n-1; i++)

     for ( int j = n-1; i<j; j--)

       if (cmp.lessthan(v[j],v[j-1])) {

          // меняем местами v[j] и v[j-1]

          T temp = v[j];

          v[j] = v[j-1];

          v[j-1] = temp;

       }

}

Этот вариант можно рассматривать как обобщение традиционного приема, когда операция сравнения передается как указатель на функцию. Воспользоваться этим можно так:

void f(Vector<int>& vi,

       Vector<String>& vc,

       Vector<int>& vi2,

       Vector<char*>& vs)

{

  Comparator<int> ci;

  Comparator<char*> cs;

  Comparator<String> cc;

  sort(vi,ci);   // sort(Vector<int>&);

  sort(vc,cc);   // sort(Vector<String>&);

  sort(vi2,ci);  // sort(Vector<int>&);

  sort(vs,cs);   // sort(Vector<char*>&);

}

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



Передача параметров


При вызове функции выделяется память для ее формальных параметров, и каждый формальный параметр инициализируется значением соответствующего фактического параметра. Семантика передачи параметров тождественна семантике инициализации. В частности, сверяются типы формального и соответствующего ему фактического параметра, и выполняются все стандартные и пользовательские преобразования типа. Существуют специальные правила передачи массивов ($$4.6.5). Есть возможность передать параметр, минуя контроль типа ($$4.6.8), и возможность задать стандартное значение параметра ($$4.6.7). Рассмотрим функцию:

void f(int val, int& ref)

{

  val++;

  ref++;

}

При вызове f() в выражении val++ увеличивается локальная копия первого фактического параметра, тогда как в ref++ - сам второй фактический параметр увеличивается сам. Поэтому в функции

void g()

{

  int i = 1;

  int j = 1;

  f(i,j);

}

увеличится значение j, но не i. Первый параметр i передается по значению, а второй параметр j передается по ссылке. В $$2.3.10 мы говорили, что функции, которые изменяют свой передаваемый по ссылке параметр, труднее понять, и что поэтому лучше их избегать (см. также $$10.2.2). Но большие объекты, очевидно, гораздо эффективнее передавать по ссылке, чем по значению. Правда можно описать параметр со спецификацией const, чтобы гарантировать, что передача по ссылке используется только для эффективности, и вызываемая функция не может изменить значение объекта:

void f(const large& arg)

{

  // значение "arg" нельзя изменить без явных

  // операций преобразования типа

}

Если в описании параметра ссылки const не указано, то это рассматривается как намерение изменять передаваемый объект:

void g(large& arg); // считается, что в g() arg будет меняться

Отсюда мораль: используйте const всюду, где возможно.

Точно так же, описание параметра, являющегося указателем, со спецификацией const говорит о том, что указуемый объект не будет изменяться в вызываемой функции. Например:

extern int strlen(const char*);  // из <string.h>


extern char* strcpy(char* to, const char* from);

extern int strcmp(const char*, const char*);

Значение такого приема растет вместе с ростом программы.

Отметим, что семантика передачи параметров отличается от семантики присваивания. Это различие существенно для параметров, являющихся const или ссылкой, а также для параметров с типом, определенным пользователем ($1.4.2).

Литерал, константу и параметр, требующий преобразования, можно передавать как параметр типа const&, но без спецификации const передавать нельзя. Допуская преобразования для параметра типа const T&, мы гарантируем, что он может принимать значения из того же множества, что и параметр типа T, значение которого передается при необходимости с помощью временной переменной.

float fsqrt(const float&);         // функция sqrt в стиле Фортрана

void g(double d)

{

  float r;

  r = fsqrt(2.0f);                 // передача ссылки на временную

                                   // переменную, содержащую 2.0f

  r = fsqrt(r);                    // передача ссылки на r

  r = fsqrt(d);                    // передача ссылки на временную

                                   // переменную, содержащую float(d)

}

Запрет на преобразования типа для параметров-ссылок без спецификации const введен для того, чтобы избежать нелепых ошибок, связанных с использованием при передаче параметров временных переменных:

void update(float& i);

void g(double d)

{

  float r;

  update(2.0f);    // ошибка: параметр-константа

  update(r);       // нормально: передается ссылка на r

  update(d);       // ошибка: здесь нужно преобразовывать тип

}


Перегрузка имени функции


Обычно имеет смысл давать разным функциям разные имена. Если же несколько функций выполняет одно и то же действие над объектами разных типов, то удобнее дать одинаковые имена всем этим функциям. Перегрузкой имени называется его использование для обозначения разных операций над разными типами. Собственно уже для основных операций С++ применяется перегрузка. Действительно: для операций сложения есть только одно имя +, но оно используется для сложения и целых чисел, и чисел с плавающей точкой, и указателей. Такой подход легко можно распространить на операции, определенные пользователем, т.е. на функции. Например:

void print(int);                   // печать целого

void print(const char*)            // печать строки символов

Для транслятора в таких перегруженных функциях общее только одно - имя. Очевидно, по смыслу такие функции сходны, но язык не способствует и не препятствует выделению перегруженных функций. Таким образом, определение перегруженных функций служит, прежде всего, для удобства записи. Но для функций с такими традиционными именами, как sqrt, print или open, нельзя этим удобством пренебрегать. Если само имя играет важную семантическую роль, например, в таких операциях, как + , *  и << ($$7.2), или для конструктора класса ($$5.2.4 и $$7.3.1), то такое удобство становится существенным фактором. При вызове функции с именем f транслятор должен разобраться, какую именно функцию f следует вызывать. Для этого сравниваются типы фактических параметров, указанные в вызове, с типами формальных параметров всех описаний функций с именем f. В результате вызывается та функция, у которой формальные параметры наилучшим образом сопоставились с параметрами вызова, или выдается ошибка если такой функции не нашлось. Например:

void print(double);

void print(long);

void f()

{

  print(1L);                       // print(long)

  print(1.0);                      // print(double)

  print(1);                        // ошибка, неоднозначность: что вызывать

                                   // print(long(1)) или print(double(1)) ?


}

Подробно правила сопоставления параметров описаны в $$R.13.2. Здесь достаточно привести их суть. Правила применяются в следующем порядке по убыванию их приоритета:

 [1]   Точное сопоставление: сопоставление произошло без всяких преобразований типа или только с неизбежными преобразованиями (например, имени массива в указатель, имени функции в указатель на функцию и типа T в const T).

 [2]   Сопоставление с использованием стандартных целочисленных преобразований, определенных в $$R.4.1 (т.е. char в int, short в int и их беззнаковых двойников в int), а также преобразований float в double.

 [3]   Сопоставление с использованием стандартных преобразований, определенных в $$R.4 (например, int в double, derived* в base*, unsigned в int).

 [4]   Сопоставление с использованием пользовательских преобразований ($$R.12.3).

 [5]   Сопоставление с использованием эллипсиса ... в описании функции.

 Если найдены два сопоставления по самому приоритетному правилу, то вызов считается неоднозначным, а значит ошибочным. Эти правила сопоставления параметров работают с учетом правил преобразований числовых типов для С и С++. Пусть имеются такие описания функции print:

void print(int);

void print(const char*);

void print(double);

void print(long);

void print(char);

Тогда результаты следующих вызовов print() будут такими:

void h(char c, int i, short s, float f)

{

  print(c);         // точное сопоставление: вызывается print(char)

  print(i);         // точное сопоставление: вызывается print(int)

  print(s);         // стандартное целочисленное преобразование:

                    // вызывается print(int)

  print(f);         // стандартное преобразование:

                    // вызывается print(double)

  print('a');       // точное сопоставление: вызывается print(char)

  print(49);        // точное сопоставление: вызывается print(int)

  print(0);         // точное сопоставление: вызывается print(int)

  print("a");       // точное сопоставление:

                    // вызывается print(const char*)



}

Обращение print(0) приводит к вызову print(int), ведь 0 имеет тип int. Обращение print('a') приводит к вызову print(char), т.к. 'a' - типа char ($$R.2.5.2).

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

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

int pow(int, int);

double pow(double, double);     // из <math.h>

complex pow(double, complex);   // из <complex.h>

complex pow(complex, int);

complex pow(complex, double);

complex pow(complex, complex);

void k(complex z)

{

  int i = pow(2,2);       // вызывается pow(int,int)

  double d = pow(2.0,2);  // вызывается pow(double,double)

  complex z2 = pow(2,z);  // вызывается pow(double,complex)

  complex z3 = pow(z,2);  // вызывается pow(complex,int)

  complex z4 = pow(z,z);  // вызывается pow(complex,complex)

}


Переменные и арифметические операции


Каждое имя и каждое выражение обязаны иметь тип. Именно тип определяет операции, которые могут выполняться над ними. Например, в описании

int inch;

говорится, что inch имеет тип int, т.е. inch является целой переменной.

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

Основные типы, наиболее приближенные к "аппаратной реальности" машины,

таковы:

char

short

int

long

Они представляют целые числа.  Следующие типы:

float

double

long double

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

Следующие арифметические операции можно использовать над любым сочетанием перечисленных типов:

+    (плюс, унарный и бинарный)

-    (минус, унарный и бинарный)

*    (умножение)

/    (деление)

%    (остаток от деления)

То же верно для операций отношения:

==   (равно)

!=   (не равно)

<    (меньше чем)

<=   (меньше или равно)

>=   (больше или равно)

Для операций присваивания и арифметических операций в С++ выполняются

все осмысленные преобразования основных типов, чтобы их можно было неограниченно использовать любые их сочетания:

double d;

int i;

short s;

// ...

d = d + i;

i = s * i;

Символ = обозначает обычное присваивание.



Перестройка иерархии классов


Шаги 1 и 3 требуют исследования классов и их иерархии, чтобы убедиться, что они адекватно отвечают нашим требованиям. Обычно это не так, и приходится проводить перестройку для улучшения структуры, проекта или реализации.

Самая типичная перестройка иерархии классов состоит в выделении общей части двух классов в новый класс или в разбиении класса на два новых. В обоих случаях в результате получится три класса: базовый класс и два производных. Когда следует проводить такую перестройку? Каковы общие показания, что такая перестройка будет полезной?

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

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



Поддержка абстракции данных


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



Поддержка объектно-ориентированного программирования


Поддержку объектно-ориентированного программирования обеспечивают классы вместе с механизмом наследования, а также механизм вызова функций-членов в зависимости от истинного типа объекта (дело в том, что возможны случаи, когда этот тип неизвестен на стадии трансляции). Особенно важную роль играет механизм вызова функций-членов.  Не менее важны средства, поддерживающие абстракцию данных (о них мы говорили ранее). Все доводы в пользу абстракции данных и базирующихся на ней методов, которые позволяют естественно и красиво работать с типами, действуют и для языка, поддерживающего объектно-ориентированное программирование. Успех обоих методов зависит от способа построения типов, от того, насколько они просты, гибки и эффективны. Метод объектно-ориентированного программирования позволяет определять более общие и гибкие пользовательские типы по сравнению с теми, которые получаются, если использовать только абстракцию данных.



Подстановка


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

Преодолеть эту трудность помогают функции-подстановки (inline). Если в описании класса функция-член определена, а не только описана, то она считается подстановкой. Это значит, например, что при трансляции функций, использующих char_stack из предыдущего примера, не будет использоваться никаких операций вызова функций, кроме реализации операций вывода! Другими словами, при разработке такого класса не нужно принимать во внимание затраты на вызов функций. Любое, даже самое маленькое действие, можно смело определять как функцию без потери эффективности. Это замечание снимает наиболее часто приводимый довод в пользу общих членов данных.

Функцию-член можно описать со спецификацией inline и вне описания класса:

class char_stack {

  int size;

  char* top;

  char* s;

  public:

     char pop();

     // ...

};

inline char char_stack::pop()

{

  return *--top;

}

Отметим, что недопустимо описывать разные определения функции-члена, являющейся подстановкой, в различных исходных файлах ($$R.7.1.2). Это нарушило бы понятие о классе как о цельном типе.



Поименованные константы


Добавив к описанию объекта служебное слово const, можно превратить этот объект из переменной в константу, например:

const int model = 90;

const int v[] = { 1, 2, 3, 4 };

Поскольку константе нельзя ничего присвоить, она должна быть инициализирована. Описывая какой-либо объект как const, мы гарантируем, что его значение не изменяется в области видимости:

model = 200;                       // ошибка

model++;                           // ошибка

Отметим, что спецификация const скорее ограничивает возможности использования объекта, чем указывает, где следует размещать объект. Может быть вполне разумным и даже полезным описание функции с типом возвращаемого значения const:

const char* peek(int i)    // вернуть указатель на строку-константу

{

  return hidden[i];

}

Приведенную функцию можно было бы использовать для передачи строки, защищенной от записи, в другую программу, где она будет читаться. Вообще говоря, транслятор может воспользоваться тем фактом, что объект является const, для различных целей (конечно, это зависит от "разумности" транслятора). Самое очевидное - это то, что для константы не нужно отводить память, поскольку ее значение известно транслятору. Далее, инициализатор для константы, как правило (но не всегда) является постоянным выражением, которое можно вычислить на этапе трансляции. Однако, для массива констант обычно приходится отводить память, поскольку в общем случае транслятор не знает, какой элемент массива используется в выражении. Но и в этом случае на многих машинах возможна оптимизация, если поместить такой массив в защищенную от записи память.

Задавая указатель, мы имеем дело с двумя объектами: с самим указателем и с указуемым объектом.  Если в описании указателя есть "префикс" const, то константой  объявляется сам объект, но не указатель на него, например:

const char* pc = "asdf";           // указатель на константу

pc[3] = 'a';                       // ошибка

pc = "ghjk";                       // нормально


Чтобы описать как константу сам указатель, а не указуемый объект, нужно использовать операцию * перед const. Например:

char *const cp = "asdf";           // указатель-константа

cp[3] = 'a';                       // нормально

cp = "ghjk";                       // ошибка

Чтобы сделать константами и указатель, и объект, надо оба объявить const, например:

const char *const cpc = "asdf";    // указатель-константа на const

cpc[3] = 'a';                      // ошибка

cpc = "ghjk";                      // ошибка

Объект может быть объявлен константой при обращении к нему с помощью указателя, и в то же время быть изменяемым, если обращаться к нему другим способом. Особенно это удобно использовать для параметров функции. Описав параметр-указатель функции как const, мы запрещаем изменять в ней указуемый объект, например:

char* strcpy(char* p, const char* q); // не может изменять *q

Указателю на константу можно присвоить адрес переменной, т.к. это не принесет вреда. Однако, адрес константы нельзя присваивать указателю без спецификации const, иначе станет возможным менять ее значение, например:

int a = 1;

const int c = 2;

const int* p1 = &c;                // нормально

const int* p2 = &a;                // нормально

int* p3 = &c;                      // ошибка

*p3 = 7;                           // меняет значение c


Поля


Кажется расточительным использовать для признака, принимающего только два значения ( например: да, нет) тип char, но объект типа char является в С++ наименьшим объектом, который может независимо размещаться в памяти. Однако, есть возможность собрать переменные с малым диапазоном значений воедино, определив их как поля структуры. Член структуры является полем, если в его определении после имени указано число разрядов, которое он должен занимать. Допустимы безымянные поля. Они не влияют на работу с поименованными полями, но могут улучшить размещение полей в памяти для конкретной машины:

struct sreg {

  unsigned enable : 1;

  unsigned page : 3;

  unsigned : 1;                    // не используется

  unsigned mode : 2;

  unsigned : 4;                    // не используется

  unsigned access : 1;

  unsigned length : 1;

  unsigned non_resident : 1;

};

Приведенная структура описывает разряды нулевого регистра состояния DEC PDP11/45 (предполагается, что поля в слове размещаются слева  направо).  Этот  пример  показывает   также   другое возможное применение полей: давать имена тем частям объекта, размещение которых определено извне. Поле должно иметь целый тип ($$R.3.6.1 и $$R.9.6), и оно используется аналогично другим объектам целого типа. Но есть исключение: нельзя брать адрес поля. В ядре операционной системы или в отладчике тип sreg мог бы использоваться следующим образом:

sreg* sr0 = (sreg*)0777572;

//...

if (sr0->access) {                 // нарушение прав доступа

  // разобраться в ситуации

  sr0->access = 0;

}

Тем не менее,  применяя поля для упаковки нескольких переменных в один байт, мы  необязательно сэкономим память. Экономится память для данных, но  на большинстве машин одновременно возрастает объем команд,  нужных для работы с упакованными данными.  Известны даже такие программы, которые значительно сокращались в объеме,  если двоичные переменные, задаваемые полями, преобразовывались в  переменные типа char! Кроме того, доступ к char или int обычно  происходит намного быстрее, чем доступ к полю. Поля - это просто  удобная краткая форма задания логических операций для извлечения или занесения информации в части слова.



Поля типа


Чтобы производные классы были не просто удобной формой краткого описания, в реализации языка должен быть решен вопрос: к какому из производных классов относится объект, на который смотрит указатель base*? Существует три основных способа ответа:

[1]    Обеспечить, чтобы указатель мог ссылаться на объекты только одного типа ($$6.4.2);

[2]    Поместить в базовый класс поле типа, которое смогут проверять функции;

[3]    использовать виртуальные функции ($$6.2.5).

 Указатели на базовые классы обыкновенно используются при проектировании контейнерных классов (множество, вектор, список и т.д.). Тогда в случае [1] мы получим однородные списки, т.е. списки объектов одного типа. Способы [2] и [3] позволяют создавать разнородные списки, т.е. списки объектов нескольких различных типов (на самом деле, списки указателей на эти объекты). Способ [3] - это специальный надежный в смысле типа вариант способа [2]. Особенно интересные и мощные варианты дают комбинации способов [1] и [3]; они обсуждаются в главе 8.

Вначале обсудим простой способ с полем типа, т.е. способ [2]. Пример с классами manager/employee можно переопределить так:

struct employee {

  enum empl_type { M, E };

  empl_type type;

  employee* next;

  char*     name;

  short     department;

  // ...

};

struct manager : employee {

  employee* group;

  short     level;

  // ...

};

Имея эти определения, можно написать функцию, печатающую данные о произвольном служащем:

void print_employee(const employee* e)

{

  switch (e->type) {

     case E:

       cout << e->name << '\t' << e->department << '\n';

       // ...

       break;

     case M:

       cout << e->name << '\t' << e->department << '\n';

       // ...

       manager* p = (manager*) e;

       cout << "level" << p->level << '\n';

       // ...

       break;

  }

}

Напечатать список служащих можно так:

void f(const employee* elist)

{


  for (; elist; elist=elist->next) print_employee(elist);

}

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

void print(const employee* e)

{

  cout << e->name << '\t' << e->department << '\n';

  // ...

  if (e->type == M) {

     manager* p = (manager*) e;

     cout << "level" << p->level << '\n';

     // ...

  }

}

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

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


Поля вывода


Функция width() устанавливает минимальное число символов, использующееся в последующей операции вывода числа или строки. Так в результате следующих операций

cout.width(4);

cout << '(' << 12 << ')';

получим число 12 в поле размером 4 символа, т.е.

(  12)

Заполнение поля заданными символами или выравнивание можно установить с помощью функции fill(), например:

cout.width(4);

cout.fill('#');

cout << '(' << "ab" << ')';

напечатает

(##ab)

По умолчанию поле заполняется пробелами, а размер поля по умолчанию есть 0, что означает "столько символов, сколько нужно". Вернуть размеру поля стандартное значение можно с помощью вызова

cout.width(0); // ``столько символов, сколько надо''

Функция width() задает минимальное число символов. Если появится больше символов, они будут напечатаны все, поэтому

cout.width(4);

cout << '(' << "121212" << ")\n";

напечатает

(121212)

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

Вызов width() влияет только на одну следующую за ним операцию вывода, поэтому

cout.width(4);

cout.fill('#');

cout << '(' << 12 << "),(" << '(' <<12 << ")\n";

напечатает

(##12),(12)

а не

(##12),(##12)

как можно было бы ожидать. Однако, заметьте, что если бы влияние распространялось на все операции вывода чисел и строк, получился бы еще более неожиданный результат:

(##12#),(##12#

)

С помощью стандартного манипулятора, показанного в 10.4.2.1, можно более

элегантно задавать размера поля вывода.



Пользовательские операции преобразования типа


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

class complex {

  double re, im;

  public:

     complex(double r, double i) { re=r; im=i; }

     friend complex operator+(complex, complex);

     friend complex operator+(complex, double);

     friend complex operator+(double, complex);

     friend complex operator-(complex, double);

     friend complex operator-(complex, double);

     friend complex operator-(double, complex);

     complex operator-();  // унарный -

     friend complex operator*(complex, complex);

     friend complex operator*(complex, double);

     friend complex operator*(double, complex);

     // ...

};

Имея такое определение комплексного числа, можно писать:

void f()

{

  complex a(1,1), b(2,2), c(3,3), d(4,4), e(5,5);

  a = -b-c;

  b = c*2.0*c;

  c = (d+e)*a;

}

Все-таки утомительно, как мы это только что делали для operator*() писать для каждой комбинации complex и double свою функцию. Более того, разумные средства для комплексной арифметики должны предоставлять десятки таких функций (посмотрите, например, как описан тип complex в <complex.h>).



Поразрядные логические операции


Поразрядные логические операции

&    |    ^    ~    >>    <<

применяются к целым, то есть к объектам типа char, short, int, long и к их беззнаковым аналогам. Результат операции также будет целым.

Чаще всего поразрядные логические операции используются для работы с небольшим по величине множеством данных (массивом разрядов). В этом случае каждый разряд беззнакового целого представляет один элемент множества, и число элементов определяется количеством разрядов. Бинарная операция & интерпретируется как пересечение множеств, операция | как объединение, а операция ^ как разность множеств. С помощью перечисления можно задать имена элементам множества. Ниже приведен пример, заимствованный из <iostream.h>:

class ios {

  public:

     enum io_state {

       goodbit=0, eofbit=1, failbit=2, badbit=4

     };

     // ...

};

Состояние потока можно установить следующим присваиванием:

cout.state = ios::goodbit;

Уточнение именем ios необходимо, потому что определение io_state находится в классе ios, а также чтобы не возникло коллизий, если пользователь заведет свои имена наподобие goodbit.

Проверку на корректность потока и успешное окончание операции можно задать так:

if (cout.state&(ios::badbit|ios::failbit))  // ошибка в потоке

Еще одни скобки необходимы потому, что операция & имеет более высокий

приоритет, чем операция "|".

Функция, обнаружившая конец входного потока, может сообщать об этом так:

cin.state |= ios::eofbit;

Операция |= используется потому, что в потоке уже могла быть ошибка (т.е. state==ios::badbit), и присваивание

cin.state =ios::eofbit;

могло бы затереть ее признак. Установить отличия в состоянии двух потоков можно следующим способом:

ios::io_state diff = cin.state^cout.state;

Для таких типов, как io_state, нахождение различий не слишком полезная

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

Отметим, что использование полей ($$R.9.6) может служить удобным и более лаконичным способом работы с частями слова, чем сдвиги и маскирование. С частями слова можно работать и с помощью поразрядных логических  операций. Например, можно выделить средние 16 разрядов из средины 32-разрядного целого:

unsigned short middle(int a) { return (a>>8)&0xffff; }

Только не путайте поразрядные логические операции с просто логическими операциями:

&&    ||    !

Результатом последних может быть 0 или 1, и они в основном используются в условных выражениях операторов if, while или for ($$3.3.1).  Например,  !0 (не нуль) имеет значение 1, тогда как ~0 (дополнение нуля) представляет собой набор разрядов "все единицы", который обычно является значением -1 в дополнительном коде.



Порядок вычислений


Порядок вычисления подвыражений, входящих в выражение, не всегда определен. Например:

int i = 1;

v[i] = i++;

Здесь выражение может вычисляться или как v[1]=1, или как v[2]=1. Если нет ограничений на порядок вычисления подвыражений, то транслятор получает возможность создавать более оптимальный код. Транслятору следовало бы предупреждать о двусмысленных выражениях, но к сожалению большинство из них не делает этого. Для операций

&&    ||   ,

гарантируется, что их левый операнд вычисляется раньше правого операнда. Например, в выражении b=(a=2,a+1) b присвоится значение 3. Пример операции || был дан в $$3.2.1, а пример операции && есть в $$3.3.1. Отметим, что операция запятая отличается по смыслу от той запятой, которая используется для разделения параметров при вызове функций. Пусть есть выражения:

f1(v[i],i++);       // два параметра

f2( (v[i],i++) )    // один параметр

Вызов функции f1 происходит с двумя параметрами: v[i] и i++, но порядок вычисления выражений параметров неопределен. Зависимость вычисления значений фактических параметров от порядка вычислений - далеко не лучший стиль программирования. К тому же программа становится непереносимой. Вызов f2 происходит с одним параметром, являющимся выражением, содержащим операцию запятая:  (v[i], i++). Оно эквивалентно i++.

Скобки могут принудительно задать порядок вычисления. Например, a*(b/c) может вычисляться как (a*b)/c (если только пользователь видит в этом какое-то различие). Заметим, что для значений с плавающей точкой результаты вычисления выражений a*(b/c) и (a*b)/ могут различаться весьма значительно.



Повторное использование


Часто основной причиной перехода на новый язык или новый метод проектирования называют то, что это облегчает повторное использование программ или проекта. Однако, во многих организациях поощряют сотрудника или группу, когда они предпочитают изобретать колесо. Например, если производительность программиста измеряется числом строк программы, то будет ли он писать маленькие программы, работающие со стандартными библиотеками, за счет своего дохода и, может быть, положения? А менеджер, если он оплачивается пропорционально числу людей в его группе, будет ли он использовать программы, сделанные другими коллективами, если он может просто нанять еще пару программистов в свою группу? Компания может получить правительственный контракт, в котором ее доход составляет фиксированный процент от расходов на проект, будет ли она сокращать свой доход за счет использования наиболее эффективных средств? Трудно обеспечить вознаграждение за повторное использование, но если администрация не найдет способов поощрения и вознаграждения, то его просто не будет.

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

[1]    она работает; нельзя использовать повторно, если это невозможно и в первый раз;

[2]    она понятна; здесь имеет значение структура программы, наличие комментариев, документации, руководства;

[3]    она может работать вместе с программами, которые не создавались специально с таким условием;

[4]    можно рассчитывать на ее сопровождение (или придется делать это самому, что обычно не хочется);

[5]    это выгодно (хотя можно и разделить расходы по разработке и сопровождению с другими пользователями) и, наконец;

[6]    ее можно найти.

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


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

Нельзя недооценивать такие группы "стандартных компонентов". Укажем, что в первом приближении, система отражает организацию, которая ее создала. Если в организации нет средств поощрения и вознаграждения кооперации и разделения труда, то и на практике они будут исключением. Группа стандартных компонентов должна активно предлагать свои компоненты. Обычная традиционная документация важна, но ее недостаточно. Помимо этого указанная группа должна предоставлять руководства и другую информацию, которая позволит потенциальному пользователю отыскать компонент и понять как он может ему помочь. Значит эта группа должна предпринимать действия, которые обычно связываются с системой образования и маркетинга. Члены группы компонентов должны всегда, когда это возможно, работать в тесном сотрудничестве с разработчиками из областей приложения. Только тогда они будут в курсе запросов пользователей и сумеют почуять возможности использования стандартного компонента в различных областях. Это является аргументом за использование такой группы в роли консультанта и в пользу внутренних поставок программ, чтобы информация из группы компонентов могла свободно распространяться.

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


Правильное и неправильное использование динамической информации о типе


Динамическая информация о типе может использоваться во многих ситуациях, в том числе для: объектного ввода-вывода, объектно-ориентированных баз данных,  отладки. В тоже время велика вероятность ошибочного использования такой информации. Известно,что в языке Симула использование таких средств, как правило, приводит к ошибкам. Поэтому эти средства не были включены в С++. Слишком велик соблазн воспользоваться динамической информацией о типе, тогда как правильнее вызвать виртуальную функцию. Рассмотрим в качестве примера класс Shape из $$1.2.5. Функцию rotate можно было задать так:

void rotate(const Shape& s)

  // неправильное использование динамической

  // информации о типе

{

  if (ref_type_info(s)==static_type_info(Circle)) {

     // для этой фигуры ничего не надо

  }

  else if (ref_type_info(s)==static_type_info(Triangle)) {

     // вращение треугольника

  }

  else if (ref_type_info(s)==static_type_info(Square)) {

     // вращение квадрата

  }

  // ...

}

Если для переключателя по типу поля мы используем динамическую информацию о типе, то тем самым нарушаем в программе принцип модульности и отрицаем сами цели объектно-ориентированного программирования. К тому же это решение чревато ошибками: если в качестве параметра функции будет передан объект производного от Circle класса, то она сработает неверно (действительно, вращать круг (Circle) нет смысла, но для объекта, представляющего производный класс, это может потребоваться). Опыт показывает, что программистам, воспитанным на таких языках как С или Паскаль, трудно избежать этой ловушки. Стиль программирования этих языков требует меньше предусмотрительности, а при создании библиотеки такой стиль можно просто считать небрежностью.

Может возникнуть вопрос, почему в интерфейс с системой динамической информации о типе включена условная операция приведения ptr_cast(), а не операция is_base(), которая непосредственно определяется с помощью операции has_base() из класса Type_info. Рассмотрим такой пример:


void f(dialog_box& db)

{

  if (is_base(&db,dbox_w_str)) {   // является ли db базовым

                                   // для  dbox_w-str?

     dbox_w_str* dbws = (dbox_w_str*) &db;

     // ...

  }

  // ...

}

Решение с помощью ptr_cast ($$13.5) более короткое, к тому же здесь явная и безусловная операция приведения отделена от проверки в операторе if, значит появляется возможность ошибки, неэффективности и даже неверного результата. Неверный результат может возникнуть в тех редких случаях, когда система динамической идентификации типа распознает, что один тип является производным от другого, но транслятору этот факт неизвестен, например:

class D;

class B;

void g(B* pb)

{

  if (is_base(pb,D)) {

     D* pb = (D*)pb;

     // ...

  }

  // ...

}

Если транслятору пока неизвестно следующее описание класса D:

class D : public A, public B {

  // ...

};

то возникает ошибка, т.к. правильное приведение указателя pb к D* требует изменения значения указателя. Решение с операцией ptr_cast() не сталкивается с этой трудностью, поскольку эта операция применима только при условии, что в области видимости находятся описания обеих ее параметров. Приведенный пример показывает, что операция приведения для неописанных классов по сути своей ненадежна, но запрещение ее существенно ухудшает совместимость с языком С.


Пределы абстракции данных


Абстрактный тип данных определяется как некий "черный ящик". После своего определения он по сути никак не взаимодействует с программой. Его никак нельзя приспособить для новых целей, не меняя определения. В этом смысле это негибкое решение. Пусть, например, нужно определить для графической системы тип shape (фигура).  Пока считаем, что в системе могут быть такие фигуры: окружность (circle), треугольник (triangle) и квадрат (square). Пусть уже есть определения точки и цвета:

class point { /* ... */ };

class color { /* ... */ };

Тип shape можно определить следующим образом:

enum kind { circle, triangle, square };

class shape

{

  point center;

  color col;

  kind k;

  // представление фигуры

  public:

     point where () { return center; }

     void move ( point to )  { center = to; draw (); }

     void draw ();

     void rotate ( int );

     // еще некоторые операции

};

"Поле типа" k необходимо для того, чтобы такие операции, как draw () и

rotate (), могли определять, с какой фигурой они имеют дело (в языках вроде Паскаля можно использовать для этого запись с вариантами, в которой k является полем-дескриминантом). Функцию draw () можно определить так:

void shape :: draw ()

{

  switch ( k )

  {

     case circle:

       // рисование окружности

       break;

     case triangle:

       // рисование треугольника

       break;

     case square:

       // рисование квадрата

       break;

  }

}

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



Пределы совершенства


Язык С++ проектировался как "лучший С", поддерживающий абстракцию

данных и объектно-ориентированное программирование. При этом он должен быть пригодным для большинства основных задач системного программирования.

Основная трудность для языка, который создавался в расчете на методы упрятывания данных, абстракции данных и объектно-ориентированного программирования, в том, что для того, чтобы быть языком общего назначения, он должен:

-

идти на традиционных машинах;

-          сосуществовать с традиционными операционными системами и языками;

-          соперничать с традиционными языками программирования в эффективности выполнения программы;

-          быть пригодным во всех основных областях приложения.

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

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

Если не вводить в язык возможности низкого уровня, то придется для основных задач большинства областей приложения использовать некоторые языки низкого уровня, например С или ассемблер. Но С++ проектировался с расчетом, что в нем можно сделать все, что допустимо на С, причем без увеличения времени выполнения.  Вообще, С++ проектировался, исходя из принципа, что не должно возникать никаких дополнительных затрат времени и памяти, если только этого явно не пожелает сам программист.


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

С++ проектировался для поддержки того принципа, что всякая программа есть модель некоторых существующих в реальности понятий, а класс является конкретным представлением понятия, взятого из области приложения ($$12.2). Поэтому классы пронизывают всю программу на С++, и налагаются жесткие требования на гибкость понятия класса, компактность объектов класса и эффективность их использования. Если работать с классами будет неудобно или слишком накладно, то они просто не будут использоваться, и программы выродятся в программы на "лучшем С". Значит пользователь не сумеет насладиться теми возможностями, ради которых, собственно, и создавался язык.


А дорога идет все дальше


" А дорога идет все дальше и дальше"
 (Бильбо Бэгинз)
Как было обещано в первом издании книги, запросы пользователей определили развитие С++. Его направлял опыт широкого круга пользователей, работающих в разных областях программирования.  За шесть лет, отделяющих нас от первого издания описания С++, число пользователей возросло в сотни раз. За эти годы были усвоены многие уроки, были предложены и подтвердили практикой свое право на существование различные приемы программирования. О некоторых из них и пойдет речь ниже.
Сделанные за эти шесть лет расширения языка прежде всего были направлены на повышение выразительности С++ как языка абстракции данных и объектно-ориентированного программирования вообще и как средства для создания высококачественных библиотек с пользовательскими типами данных в
частности. Библиотекой высокого качества мы считаем библиотеку, позволяющую пользователю определять с помощью классов понятия, работа с которыми сочетает удобство, эффективность и надежность. Под  надежностью понимается то, что класс предоставляет защищенный по типам интерфейс между пользователями библиотеки и ее разработчиками. Эффективность предполагает, что использование классов не влечет за собой больших накладных расходов по памяти или времени по сравнению с "ручными" программами на С.
Эта книга является полным описанием языка С++. Главы с 1 по 10 представляют собой учебник, знакомящий с языком. В главах с 11 по 13 обсуждаются вопросы проектирования и развития программного обеспечения. Завершается книга справочным руководством по языку С++. Естественно, что все расширения языка и способы их использования, которые появились после выхода в свет первого издания, являются частью изложения. К ним относятся уточненные правила для разрешения перегрузки имени, средства управления памятью и средства контроля доступа, надежная по типам процедура связывания, статические и постоянные функции-члены, абстрактные классы, множественное наследование, шаблоны типов и обработка особых ситуаций.


С++ является языком программирования общего назначения. Естественная для него область применения - системное программирование, понимаемое в широком смысле этого слова. Кроме того, С++ успешно используется во многих областях приложения, далеко выходящих за указанные рамки. Реализации С++ теперь есть на всех машинах, начиная с самых скромных микрокомпьютеров - до самых больших супер-ЭВМ, и практически для всех операционных систем. Поэтому книга дает лишь описание собственно языка, не объясняя особенности конкретных реализаций, среды программирования или библиотек.
Читатель найдет в книге много примеров с классами, которые, несмотря на несомненную пользу, можно считать игрушечными. Такой стиль изложения позволяет лучше выделить основные понятия и полезные приемы, тогда как в настоящих, законченных программах они были бы скрыты массой деталей.  Для большинства предложенных здесь классов, как то связанные списки, массивы, строки символов, матрицы, графические классы, ассоциативные массивы и т.д., - приводятся версии "со 100% гарантией" надежности и правильности, полученные на основе классов из самых разных коммерческих и некоммерческих программ. Многие из "промышленных" классов и библиотек получились как прямые или косвенные потомки игрушечных классов, приводимых здесь как примеры.
В этом издании книги по сравнению с первым больше внимания уделено задаче обучения. Вместе с тем, уровень изложения в равной мере учитывает и опытных программистов, ни в чем не умаляя их знаний и профессионализма. Обсуждение вопросов проектирования сопровождается более широкой подачей материала, выходящей за рамки описаний конструкций языка и способам их использования. В этом издании приводится больше технических деталей и повышена строгость изложения. В особенности это относится к справочному руководству, которое вобрало в себя многолетний опыт работы в этом направлении. Предполагалось создать книгу с достаточно высоким уровнем изложения, которая бы служила программистам не только книгой для чтения. Итак, перед вами книга с описанием языка С++, его основных принципов и методов программирования. Надеемся, что она доставит вам радость.

и задуман для того, чтобы


"Язык образует  среду мышления и формирует представление о том, о чем мы думаем".
(Б.Л.Уорф)
С++ - язык общего назначения и задуман для того, чтобы настоящие программисты получили удовольствие от самого процесса программирования. За исключением второстепенных деталей он содержит язык С как подмножество. Язык С расширяется введением гибких и эффективных средств, предназначенных для построения новых типов. Программист структурирует свою задачу, определив новые типы, которые точно соответствуют понятиям предметной области задачи. Такой метод построения программы обычно называют абстракцией данных. Информация о типах содержится в некоторых объектах типов, определенных пользователем. С такими объектами можно работать надежно и просто даже в тех случаях, когда их тип нельзя установить на стадии трансляции. Программирование с использованием таких объектов обычно называют объектно-ориентированным. Если этот метод применяется правильно, то программы становятся короче и понятнее, а сопровождение их упрощается.
Ключевым понятием С++ является класс. Класс - это определяемый пользователем тип. Классы обеспечивают упрятывание данных, их инициализацию, неявное преобразование пользовательских типов, динамическое задание типов, контролируемое пользователем управление памятью и средства для перегрузки операций. В языке С++ концепции контроля типов и модульного построения программ реализованы более полно, чем в С. Кроме того, С++ содержит усовершенствования, прямо с классами не связанные: символические константы, функции-подстановки, стандартные значения параметров функций, перегрузка имен функций, операции управления свободной памятью и ссылочный тип. В С++ сохранены все возможности С эффективной работы с основными объектами, отражающими аппаратную "реальность" (разряды, байты, слова, адреса и т.д.). Это позволяет достаточно эффективно реализовывать пользовательские типы.
Как язык, так и стандартные библиотеки С++ проектировались в расчете на переносимость. Имеющиеся реализации языка будут работать в большинстве систем, поддерживающих С. В программах на С++ можно использовать библиотеки С. Большинство служебных программ, рассчитанных на С, можно использовать и в С++.
Данная книга в первую очередь рассчитана на профессиональных программистов, желающих изучить новый язык и использовать его для нетривиальных задач. В книге дается полное описание С++, содержится много завершенных примеров и еще больше фрагментов программ.

Предопределенные свойства операций


Используется только несколько предположений о свойствах пользовательских операций. В частности, operator=, operator[], operator() и operator-> должны быть нестатическими функциями-членами. Этим обеспечивается то, что первый операнд этих операций является адресом.

Для некоторых встроенных операций их интерпретация определяется как комбинация других операций, выполняемых над теми же операндами. Так, если a типа int, то ++a означает a+=1, что в свою очередь означает a=a+1. Такие соотношения не сохраняются для пользовательских операций, если только пользователь специально не определил их с такой целью. Так, определение operator+=() для типа complex нельзя вывести из определений complex::operator+() и complex operator=().

По исторической случайности оказалось, что операции = (присваивание), &(взятие адреса) и , (операция запятая) обладают предопределенными свойствами для объектов классов. Но можно закрыть от произвольного пользователя эти свойства, если описать эти операции как частные:

class X {

  // ...

  private:

     void operator=(const X&);

     void operator&();

     void operator,(const X&);

     // ...

};

void f(X a, X b)

{

  a= b;                            // ошибка: операция = частная

  &a;                              // ошибка: операция & частная

  a,b                              // ошибка: операция , частная

}

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



Предостережения


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

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


Не все программы должны быть устойчивы ко всем видам ошибок. Не все ресурсы являются настолько критичными, чтобы оправдать попытки защитить их с помощью описанного способа "запроса ресурсов путем инициализации". Есть множество программ, которые просто читают входные данные и выполняются до конца. Для них самой подходящей реакцией на динамическую ошибку будет просто прекращение счета (после выдачи соответствующего сообщения). Освобождение всех затребованных ресурсов возлагается на систему, а пользователь должен произвести повторный запуск программы с более подходящими входными данными. Наша схема предназначена для задач, в которых такая примитивная реакция на динамическую ошибку неприемлема. Например, разработчик библиотеки обычно не в праве делать допущения о том, насколько устойчива к ошибкам, должна быть программа, работающая с библиотекой. Поэтому он должен учитывать все динамические ошибки и освобождать все ресурсы до возврата из библиотечной функции в пользовательскую программу. Метод "запроса ресурсов путем инициализации" в совокупности с особыми ситуациями, сигнализирующими об ошибке, может пригодиться при создании многих библиотек.



ПРЕДВАРИТЕЛЬНЫЕ ЗАМЕЧАНИЯ


Книга Б. Страуструпа "Язык программирования С++" дает описание языка, его ключевых понятий и основных приемов программирования на нем. Это завершенное руководство, написанное создателем языка, которое содержит описание всех средств С++, в том числе управление исключительными ситуациями, шаблоны типа (параметризованные типы данных) и множественное наследование.

Книга делится на три части. Первые десять глав являются учебником, служащим введением в язык, включая подмножество собственно С. В трех последующих главах обсуждаются вопросы проектирования и создания программного обеспечения с помощью С++. Книга завершается полным справочным руководством по языку.

В книге вы найдете:

·         законченный учебник и руководство по языку.

·         полное освещение средств языка, нацеленных на абстрактные типы данных  и объектно-ориентированное программирование.

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

·         описание способов построения библиотек высокого класса.

·         примеры реализации ключевых типов данных, определяемых пользователем,  таких как графические объекты, ассоциативные массивы и потоки ввода- вывода.

Эта книга будет хорошим помощником опытному программисту, решившему использовать С++ для нетривиальных задач. Ее можно считать ключевой в любом собрании книг по С++.


"О многом - молвил Морж,- пришла пора поговорить ".

Л. Кэрролл

Данная глава содержит краткий обзор книги, список литературы и некоторые дополнительные замечания о языке С++. Замечания касаются истории создания С++, идей, которые оказали существенное влияние на разработку языка, и некоторых мыслей по поводу программирования на С++. Эта глава не является введением; приведенные замечания не являются необходимыми для понимания последующих глав. Некоторые из них предполагают знакомство читателя с С++.



Преобразование типа


Иногда бывает необходимо явно преобразовать значение одного типа в значение другого. Результатом явного преобразования будет значение указанного типа, полученное из значения другого типа. Например:

float r = float(1);

Здесь перед присваиванием целое значение 1 преобразуется в значение с плавающей точкой 1.0f. Результат преобразования типа не является адресом, поэтому ему присваивать нельзя (если только тип не является ссылкой).

Существуют два вида записи явного преобразования типа: традиционная запись, как операция приведения в С, например, (double)a и функциональная запись, например, double(a). Функциональную запись нельзя использовать для типов, которые не имеют простого имени. Например, чтобы преобразовать значение в тип указателя, надо или использовать приведение

char* p = (char*)0777;

или определить новое имя типа:

typedef char* Pchar;

char* p = Pchar(0777);

По мнению автора, функциональная запись в нетривиальных случаях предпочтительнее. Рассмотрим два эквивалентных примера:

Pname n2 = Pbase(n1->tp)->b_name; // функциональная запись

Pname n3 = ((Pbase)n2->tp)->b_name;   // запись с приведением

Поскольку операция -> имеет больший приоритет, чем операция приведения, последнее выражение выполняется так:

((Pbase)(n2->tp))->b_name

Используя явное преобразование в тип указателя можно выдать данный объект за объект произвольного типа. Например, присваивание

any_type* p = (any_type*)&some_object;

позволит обращаться к некоторому объекту (some_object) через указатель p как к объекту произвольного типа (any_type). Тем не менее, если some_object в действительности имеет тип не any_type, могут получиться странные и нежелательные результаты.

Если преобразование типа не является необходимым, его вообще следует избегать. Программы, в которых есть такие преобразования, обычно труднее понимать, чем программы, их не имеющие. В то же время программы с явно заданными преобразованиями типа понятнее, чем программы, которые обходятся без таких преобразований, потому что не вводят типов для представления понятий более высокого уровня. Так, например, поступают программы, управляющие регистром устройства с помощью сдвига и маскирования целых, вместо того, чтобы определить подходящую структуру (struct) и работать непосредственно с ней (см. $$2.6.1). Корректность явного преобразования типа часто существенно зависит от того, насколько программист понимает, как язык работает с объектами различных типов, и какова специфика данной реализации языка. Приведем пример:


int i = 1;

char* pc = "asdf";

int* pi = &i;

i = (int)pc;

pc = (char*)i;   // осторожно: значение pc может измениться.

                 // На некоторых машинах sizeof(int)

                 // меньше, чем sizeof(char*)

pi = (int*)pc;

pc = (char*)pi;  // осторожно: pc может измениться

                 // На некоторых машинах char* имеет не такое

                 // представление, как int*

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

В языке С++ явные преобразования типа оказывается излишними во многих

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


Преобразования типов


Определяемые пользователем преобразования типа, например, такие, как преобразование числа с плавающей точкой в комплексное, которое необходимо для конструктора complex(double), оказались очень полезными в С++. Программист может задавать эти преобразования явно, а может полагаться на транслятор, который выполняет их неявно в том случае, когда они необходимы и однозначны:

complex a = complex ( 1 );

complex b = 1;                     // неявно: 1 -> complex ( 1 )

a = b + complex ( 2 );

a = b + 2;                         // неявно: 2 -> complex ( 2)

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

Преобразования типов способствуют более естественной записи программы:

complex a = 2;

complex b = a + 2;  // это означает: operator + ( a, complex ( 2  ))

b = 2 + a;          // это означает: operator + ( complex ( 2 ), a )

В обоих случаях для выполнения операции "+" нужна только одна функция,

а ее параметры единообразно трактуются системой типов языка. Более того, класс complex описывается так, что для естественного и беспрепятственного обобщения понятия числа нет необходимости что-то изменять для целых чисел.



Прикладная программа


Прикладная программа предельно проста. Определяется новая фигура myshape (если ее нарисовать, то она напоминает лицо), а затем приводится функция main(), в которой она рисуется со шляпой. Вначале дадим описание фигуры myshape:

#include "shape.h"

class myshape : public rectangle {

  line* l_eye;                     // левый глаз

  line* r_eye;                     // правый глаз

  line* mouth;                     // рот

  public:

     myshape(point, point);

     void draw();

     void move(int, int);

};

Глаза и рот являются отдельными независимыми объектами которые создает конструктор класса myshape:

myshape::myshape(point a, point b) : rectangle(a,b)

{

  int ll = neast().x-swest().x+1;

  int hh = neast().y-swest().y+1;

  l_eye = new line(point(swest().x+2,swest().y+hh*3/4),2);

  r_eye = new line(point(swest().x+ll-4,swest().y+hh*3/4),2);

  mouth = new line(point(swest().x+2,swest().y+hh/4),ll-4);

}

Объекты, представляющие глаза и рот, выдаются функцией shape_refresh() по отдельности. В принципе с ними можно работать независимо от объекта my_shape, к которому они принадлежат. Это один из способов задания черт лица для строящегося иерархически объекта myshape. Как это можно сделать иначе, видно из задания носа. Никакой тип "нос" не определяется, он просто дорисовывается в функции draw():

void myshape::draw()

{

  rectangle::draw();

  int a = (swest().x+neast().x)/2;

  int b = (swest().y+neast().y)/2;

  put_point(point(a,b));

}

Движение фигуры myshape сводится к движению объекта базового класса rectangle и к движению вторичных объектов (l_eye, r_eye и mouth):

void myshape::move(int a, int b)

{

  rectangle::move(a,b);

  l_eye->move(a,b);

  r_eye->move(a,b);

  mouth->move(a,b);

}

Наконец, определим несколько фигур и будем их двигать:

int main()

{

  screen_init();

  shape* p1 = new rectangle(point(0,0),point(10,10));

  shape* p2 = new line(point(0,15),17);

  shape* p3 = new myshape(point(15,10),point(27,18));

  shape_refresh();

  p3->move(-10,-10);

  stack(p2,p3);

  stack(p1,p2);

  shape_refresh();

  screen_destroy();

  return 0;

}

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

Вот получившееся лицо со шляпой:

   ***********

   *         *

   *         *

   *         *

   *         *

   *         *

   *         *

   *         *

   ***********

*****************

   ***********

   *         *

   * **   ** *

   *         *

   *    *    *

   *         *

   * ******* *

   *         *

   ***********

Для упрощения примера копирование и удаление фигур не обсуждалось.



Пример законченной программы


Рассмотрим программу рисования геометрических фигур на экране. Она естественным образом распадается на три части:

[1]    монитор экрана: набор функций и структур данных низкого уровня для работы с экраном; оперирует только такими понятиями, как точки, линии;

[2]    библиотека фигур: множество определений фигур общего вида (например, прямоугольник, окружность) и стандартные функции для работы с ними;

[3]    прикладная программа: конкретные определения фигур, относящихся к задаче, и работающие с ними функции.

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



Принадлежность и наследование


Учитывая сложность важность отношений наследования, нет ничего удивительного в том, что часто их неправильно понимают и используют сверх меры. Если класс D описан как общий производный от класса B, то часто говорят, что D есть B:

class B { /* ... */ ;

class D : public B  /* ... */ };   // D сорта B

Иначе это можно сформулировать так: наследование – это отношение "есть", или, более точно для классов D и B, наследование - это отношение D сорта B. В отличие от этого, если класс D содержит в качестве члена другой класс B, то говорят, что D "имеет" B:

class D {  // D имеет B

  // ...

  public:

     B b;

     // ...

};

Иными словами, принадлежность - это отношение "иметь" или для классов D и B просто: D содержит B.

Имея два класса B и D, как выбирать между наследованием и принадлежностью? Рассмотрим классы самолет и мотор.Новички обычно спрашивают: будет ли хорошим решением сделать класс самолет производным от класса мотор. Это плохое решение, поскольку самолет не "есть" мотор, самолет "имеет" мотор. Следует подойти к этому вопросу, рассмотрев, может ли самолет "иметь" два или больше моторов. Поскольку это представляется вполне возможным (даже если мы имеем дело с программой, в которой все самолеты будут с одним мотором), следует использовать принадлежность, а не наследование. Вопрос "Может ли он иметь два..?" оказывается удивительно полезным во многих сомнительных случаях. Как всегда, наше изложение затрагивает неуловимую сущность программирования. Если бы все классы было так же легко представить, как самолет и мотор, то было бы просто избежать и тривиальных ошибок типа той, когда самолет определяется как производное от класса мотор. Однако, такие ошибки достаточно часты, особенно у тех, кто считает наследование еще одним механизмом для сочетания конструкций языка программирования. Несмотря на удобство и лаконичность записи, которую предоставляет наследование, его надо использовать только для выражения тех отношений, которые четко определены в проекте. Рассмотрим определения:


class B {

  public:

     virtual void f();

     void g();

};

class D1 {                  // D1 содержит B

  public:

     B b;

     void f();               // не переопределяет b.f()

};

void h1(D1* pd)

{

  B* pb = pd;               // ошибка: невозможно преобразование D1* в B*

  pb = &pd->b;

  pb->q();                  // вызов B::q

  pd->q();                  // ошибка: D1 не имеет член q()

  pd->b.q();

  pb->f();                  // вызов B::f (здесь D1::f не переопределяет)

  pd->f();                  // вызов D1::f

}

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

class D2 : public B {       // D2 есть B

  public:

     void f();               // переопределение B::f()

};

void h2(D2* pd)

{

  B* pb = pd;               // нормально: D2* неявно преобразуется в B*

  pb->q();                  // вызов B::q

  pd->q();                  // вызов B::q

  pb->f();                  // вызов виртуальной функции: обращение к D2::f

  pd->f();                  // вызов D2::f

}

Удобство записи, продемонстрированное в примере с классом D2, по сравнению с записью в примере с классом D1, является причиной, по которой таким наследованием злоупотребляют. Но следует помнить, что существует определенная плата за удобство записи в виде возросшей зависимости между B и D2 (см. $$12.2.3). В частности, легко забыть о неявном преобразовании D2 в B. Если только такие преобразования не относятся к семантике ваших классов, следует избегать описания производного класса в общей части. Если класс представляет определенное понятие, а наследование используется как отношение "есть", то такие преобразования обычно как раз то, что нужно.

Однако, бывают такие ситуации, когда желательно иметь наследование, но нельзя допускать преобразования. Рассмотрим задание класса cfield (controled field - управляемое поле), который, помимо всего прочего, дает возможность контролировать на стадии выполнения доступ к другому классу field. На первый взгляд кажется совершенно правильным определить класс cfield как производный от класса field:



class cfield : public field {

  // ...

};

Это выражает тот факт, что cfield, действительно, есть сорта field, упрощает запись функции, которая использует член части field класса cfield, и, что самое главное, позволяет в классе cfield переопределять виртуальные функции из field. Загвоздка здесь в том, что преобразование cfield* к field*, встречающееся в определении класса cfield, позволяет обойти любой контроль доступа к field:

void q(cfield* p)

{

  *p = "asdf";              // обращение к field контролируется

                            // функцией присваивания cfield:

                            // p->cfield::operator=("asdf")

  field* q = p;             // неявное преобразование cfield* в field*

  *q = "asdf";              // приехали! контроль обойден

}

Можно было бы определить класс cfield так, чтобы field был его членом, но тогда cfield не может переопределять виртуальные функции field. Лучшим решением здесь будет использование наследования со спецификацией private (частное наследование):

class cfield : private field { /* ... */ }

С позиции проектирования, если не учитывать (иногда важные) вопросы

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

Для дальнейшего обсуждения возможности выбора наследования или принадлежности рассмотрим, как представить в диалоговой графической системе свиток (область для прокручивания в ней информации), и как привязать свиток к окну на экране. Потребуются свитки двух видов: горизонтальные и вертикальные. Это можно представить с помощью двух типов horizontal_scrollbar и vertical_scrollbar или с помощью одного типа scrollbar, который имеет аргумент, определяющий, является расположение вертикальным или горизонтальным. Первое решение предполагает, что есть еще третий тип, задающий просто свиток - scrollbar, и этот тип является базовым классом для двух определенных свитков. Второе решение предполагает дополнительный аргумент у типа scrollbar и наличие значений, задающих вид свитка. Например, так:



enum orientation { horizontal, vertical };

Как только мы остановимся на одном из решений, определится объем изменений, которые придется внести в систему. Допустим, в этом примере нам потребуется ввести свитки третьего вида. Вначале предполагалось, что могут быть свитки только двух видов (ведь всякое окно имеет только два измерения), но в этом примере, как и во многих других, возможны расширения, которые возникают как вопросы перепроектирования. Например, может появиться желание использовать "управляющую кнопку" (типа мыши) вместо свитков двух видов. Такая кнопка задавала бы прокрутку в различных направлениях в зависимости от того, в какой части окна нажал ее пользователь. Нажатие в середине верхней строчки должно вызывать "прокручивание вверх", нажатие в середине левого столбца - "прокручивание влево", нажатие в левом верхнем углу - "прокручивание вверх и влево". Такая кнопка не является чем-то необычным, и ее можно рассматривать как уточнение понятия свитка, которое особенно подходит для тех областей приложения, которые связаны не с обычными текстами, а с более сложной информацией.

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

свиток

горизонтальный_свиток

вертикальный_свиток

управляющая_кнопка

Это положительная сторона "иерархического решения".

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



Положительной стороной решения с единым типом свитка является то, что легко передавать информацию о виде нужного нам свитка другой функции:

void helper(orientation oo)

{

  //...

  p = new scrollbar(oo);

  //...

}

void me()

{

  helper(horizontal);

}

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

Теперь рассмотрим как привязать свиток к окну. Если считать window_with_scrollbar (окно_со_свитком) как нечто, что является window и scrollbar, мы получим подобное:

class window_with_scrollbar

  : public window, public scrollbar {

     // ...

};

Это позволяет любому объекту типа window_with_scrollbar выступать и как window, и как scrollbar, но от нас требуется решение использовать только единственный тип scrollbar.

Если, с другой стороны, считать window_with_scrollbar объектом типа window, который имеет scrollbar, мы получим такое определение:

class window_with_scrollbar : public window {

  // ...

  scrollbar* sb;

  public:

     window_with_scrollbar(scrollbar* p, /* ... */)

       : window(/* ... */), sb(p)

     {

       // ...

     }

};

Здесь мы можем использовать решение со свитками трех типов. Передача самого свитка в качестве параметра позволяет окну (window) не запоминать тип его свитка. Если потребуется, чтобы объект типа window_with_scrollbar действовал как scrollbar, можно добавить операцию преобразования:

window_with_scrollbar :: operator scrollbar&()

{

  return *sb;

}


Присваивание и инициализация


Для многих типов задача управления ими сводится к построению и уничтожению связанных с ними объектов, но есть типы, для которых этого мало. Иногда необходимо управлять всеми операциями копирования.  Вернемся к классу vector:

void f ()

{

  vector v1 ( 100 );

  vector v2 = v1;                  // построение нового вектора v2,

                                   // инициализируемого v1

  v1 = v2;                         // v2 присваивается v1

  // ...

}

Должна быть возможность определить интерпретацию операций инициализации v2 и присваивания v1. Например, в описании:

class vector

{

  int * v;

  int sz;

  public:

     // ...

     void operator = ( const vector & );  // присваивание

     vector ( const vector & );     // инициализация

};

указывается, что присваивание и инициализация объектов типа vector должны выполняться с помощью определенных пользователем операций. Присваивание можно определить так:

void vector::operator = ( const vector & a )

  // контроль размера и копирование элементов

{

  if ( sz != a.sz )

     error ( "недопустимый размер вектора для =" );

  for ( int i = 0; i < sz; i++ ) v [ i ] = a.v [ i ];

}

Поскольку эта операция использует для присваивания "старое значение"

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

vector::vector ( const vector & a )

          // инициализация вектора значением другого вектора

{

  sz = a.sz;                       // размер тот же

  v = new int [ sz ];              // выделить память для массива

  for ( int i = 0; i < sz; i++ )   //копирование элементов

  v [ i ] = a.v [ i ];

}

В языке С++ конструктор вида T(const T&) называется конструктором

копирования для типа T. Любую инициализацию объектов типа T он выполняет с помощью значения некоторого другого объекта типа T.  Помимо явной инициализации конструкторы вида T(const T&) используются для передачи параметров по значению и получения возвращаемого функцией значения.



Процедурное программирование


Первоначальной (и, возможно, наиболее используемой) парадигмой программирования было:

Определите, какие процедуры вам нужны; используйте лучшие из известных вам алгоритмов!

Ударение делалось на обработку данных с помощью алгоритма, производящего нужные вычисления. Для поддержки этой парадигмы языки предоставляли механизм передачи параметров и получения результатов функций. Литература, отражающая такой подход, заполнена рассуждениями о способах передачи параметров, о том, как различать параметры разных типов, о различных видах функций (процедуры, подпрограммы, макрокоманды, ...) и т.д. Первым процедурным языком был Фортран, а Алгол60, Алгол68, Паскаль и С продолжили это направление.

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

 double sqrt ( double arg )

 {

  // программа для вычисления квадратного корня

 }

 void some_function ()

 {

  double root = sqrt ( 2 );

 // ..

 }

Двойная наклонная черта // начинает комментарий, который продолжается до конца строки.

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



Процесс развития


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

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

У процесса развития три стадии:

-

Анализ: определение области задачи.

-          Проектирование: создание общей структуры системы.

-          Реализация: программирование и тестирование.

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


-          Экспериментирование.

-          Тестирование.

-          Анализ проектирования и реализации.

-          Документирование.

-          Сопровождение.

  Сопровождение программного обеспечения рассматривается просто как  еще несколько проходов по стадиям процесса развития (см. также $$11.3.6).

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

В больших проектах слишком часто бывает иначе. В идеале, в процессе

развития проекта работники должны сами переходить с одной стадии на другую: лучший способ передачи тонкой информации - это использовать голову работника. К сожалению, в организациях часто устанавливают барьеры для таких переходов, например, у разработчика может быть более высокий статус и (или) более высокий оклад, чем у "простого" программиста. Не принято, чтобы сотрудники ходили по отделам с целью набраться опыта и знаний, но пусть, по крайней мере, будут регулярными собеседования сотрудников, занятых на разных стадиях проекта.    Для средних и малых проектов обычно не делают различия между анализом и проектированием - эти стадии сливаются в одну. Для малых проектов также не разделяют проектирование и программирование. Конечно, тем самым решается проблема взаимодействия. Для данного проекта важно найти подходящую степень формализации и выдержать нужную степень разделения между стадиями ($$11.4.2). Нет единственно верного способа для этого.

Приведенная здесь модель процесса развития программного обеспечения радикально отличается от традиционной модели "каскад" (waterfall). В последней процесс развития протекает линейно от стадии анализа до стадии тестирования. Основной недостаток модели каскад тот, что в ней информация движется только в одном направлении. Если выявлена проблема "ниже по течению", то возникает сильное методологическое и организационное давление, чтобы решить проблему на данном уровне, не затрагивая предыдущих стадий процесса. Отсутствие повторных проходов приводит к дефектному проекту, а в результате локального устранения проблем получается искаженная реализация. В тех неизбежных случаях, когда информация должна быть передана назад к источнику ее получения и вызвать изменения в проекте, мы получим лишь слабое "колыхание" на всех уровнях системы, стремящейся подавить внесенное изменение, а значит система плохо приспособлена к изменениям. Аргумент в пользу "никаких изменений" или "только локальные изменения" часто сводится к тому, что один отдел не хочет перекладывать большую работу на другой отдел "ради их же блага". Часто бывает так, что ко времени, когда ошибка уже найдена, исписано столько бумаги относительно ошибочного решения, что усилия, нужные на исправление документации, затмевают усилия для исправления самой программы. Таким образом, бумажная работа может стать главной проблемой процесса создания системы. Конечно, такие проблемы могут быть и возникают в процессе развития больших систем. В конце концов, определенная работа с бумагами необходима. Но выбор линейной модели развития (каскад) многократно увеличивает вероятность, что эта проблема выйдет из-под контроля. Недостаток модели каскад в отсутствии повторных проходов и неспособности реагировать на изменения. Опасность предлагаемой здесь итеративной модели состоит в искушении заменить размышление и реальное развитие на последовательность бесконечных изменений. Тот и другой недостатки легче указать, чем устранить, и для того, кто организует работу, легко принять простую активность за реальный прогресс.

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


Проектирование и язык программирования.


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

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

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


Итак, чем может помочь разработчику язык программирования? Он может предоставить такие языковые средства, которые позволят выразить прямо на языке программирования основные понятия проекта. Тогда облегчается реализация, проще поддерживать ее соответствие проекту, проще организовать общение между разработчиками и программистами, и появляется возможность создать более совершенные средства как для разработчиков, так и для программистов.

Например, многие методы проектирования уделяют значительное внимание зависимостям между различными частями программы (обычно с целью их уменьшения и гарантии того, что эти части будут понятны и хорошо определены). Язык, допускающий явное задание интерфейсов между частями программы, может помочь в этом вопросе разработчикам. Он может гарантировать, что действительно будут существовать только предполагаемые зависимости. Поскольку большинство зависимостей явно выражено в программе на таком языке, можно разработать средства, читающие программу и выдающие графы зависимостей. В этом случае разработчику и другим исполнителям легче уяснить структуру программы. Такие языки программирования как С++ помогают сократить разрыв между проектом и программой, а значит уменьшают возможность путаницы и недопониманий.

Базовое понятие С++ - это класс. Класс имеет определенный тип. Кроме того, класс является первичным средством упрятывания информации. Можно описывать программы в терминах пользовательских типов и иерархий этих типов. Как встроенные, так и пользовательские типы подчиняются правилам статического контроля типов. Виртуальные функции предоставляют, не нарушая правил статических типов, механизм связывания на этапе выполнения. Шаблоны типа позволяют создавать параметризованные типы. Особые ситуации позволяют сделать регулярной реакцию на ошибки. Все эти средства С++ можно использовать без дополнительных накладных расходов в сравнении с программой на С. Таковы главнейшие средства С++, которые должен представлять и учитывать разработчик. Кроме того, существенно повлиять на принятие решений на стадии проектирования может наличие  доступных больших библиотек следующего назначения: для работы с матрицами, для связи с базами данных, для поддержки параллельного программирования, графические библиотеки и т.д.



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

[1]    Игнорирование классов и составление проекта таким образом, что программистам приходится ограничиваться только С.

[2]    Игнорирование производных классов и виртуальных функций, использование только подмножества абстрактных данных.

[3]    Игнорирование статического контроля типов и составление проекта таким образом, что программисты вынуждены применять динамические проверки типов. Обычно указанные моменты возникают у разработчиков, связанных с:

[1]    C, или традиционной системой CASE или методами структурного проектирования;

[2]    Адой или методами проектирования с помощью абстракции данных;

[3]    языками, близкими Smalltalk или Lisp. каждом случае следует решить: неправильно выбран язык реализации (считая, что метод проектирования выбран верно), или разработчику не удалось приспособиться и оценить язык (считая, что язык реализации выбран верно).

Следует сказать, что нет ничего необычного или позорного в таком расхождении. Просто это расхождение, которое приведет к неоптимальному проекту, возложит дополнительную работу на программистов, а в случае, когда структура понятий проекта значительно беднее структуры языка С++, то и на самих разработчиков.

Отметим, что необязательно все программы должны структурироваться опираясь на понятия классов и (или) иерархий классов, и необязательно всякая программа должна использовать все средства, предоставляемые С++. Как раз наоборот, для успеха проекта необходимо, чтобы людям не навязывали использование языковых средств, с которыми они только познакомились. Цель последующего изложения не в том, чтобы навязать догматичное использование классов, иерархий и строго типизированных интерфейсов, а в том, чтобы показать возможности их использования всюду, где позволяет область приложения, ограничения С++ и опыт исполнителей. В $$12.1.4 будут рассмотрены подходы к различному использованию С++ в проекте под заголовком "Проект-гибрид".


Программа и стандартный вывод


Самая маленькая программа на С++ выглядит так:

main () { }

В этой программе определяется функция, называемая main, которая не имеет параметров и ничего не делает. Фигурные скобки { и } используются в С++ для группирования операторов. В данном случае они обозначают начало и конец тела (пустого) функции main. В каждой программе на С++ должна быть своя функция main(), и программа начинается с выполнения этой функции.

Обычно программа выдает какие-то результаты. Вот программа, которая выдает приветствие Hello, World! (Всем привет!):

#include <iostream.h>

int main ()

{

  cout << "Hello, World!\n";

}

Строка #include <iostream.h> сообщает транслятору, что надо включить в

программу описания, необходимые для работы стандартных потоков ввода-вывода, которые находятся в iostream.h. Без этих описаний выражение

cout << "Hello, World!\n"

не имело бы смысла. Операция << ("выдать") записывает свой второй

параметр в первый параметр. В данном случае строка "Hello, World!\n" записывается в стандартный выходной поток cout. Строка – это последовательность символов, заключенная в двойные кавычки. Два символа: обратной дробной черты \ и непосредственно следующий за ним – обозначают некоторый специальный символ. В данном случае \n является символом конца строки (или перевода строки), поэтому он выдается после символов Hello, world!

Целое значение, возвращаемое функцией main(), если только оно есть, считается возвращаемым системе значением программы. Если ничего не возвращается, система получит какое-то "мусорное" значение.

Средства ввода/вывода потоковой библиотеки подробно описываются в главе 10.



Программируемые отношения


Конкретный язык программирования не может прямо поддерживать любое понятие любого метода проектирования. Если язык программирования не способен прямо представить понятие проектирования, следует установить удобное отображение конструкций, используемых в проекте, на языковые конструкции. Например, метод проектирования может использовать понятие делегирования, означающее, что всякая операция, которая не определена для класса A, должна выполняться в нем с помощью указателя p на соответствующий член класса B, в котором она определена. На С++ нельзя выразить это прямо. Однако, реализация этого понятия настолько в духе С++, что легко представить программу реализации:

class A {

  B* p;

  //...

  void f();

  void ff();

};

class B {

  //...

  void f();

  void g();

  void h();

};

Тот факт, что В делегирует A с помощью указателя A::p, выражается в следующей записи:

class A {

  B* p;                            // делегирование с помощью p

  //...

  void f();

  void ff();

  void g() { p->g(); }             // делегирование q()

  void h() { p->h(); }             // делегирование h()

};

Для программиста совершенно очевидно, что здесь происходит, однако здесь

явно нарушается принцип взаимнооднозначного соответствия. Такие "программируемые" отношения трудно выразить на языках программирования, и поэтому к ним трудно применять различные вспомогательные средства. Например, такое средство может не отличить "делегирование" от B к A с помощью A::p от любого другого использования B*.

Все-таки следует всюду, где это возможно, добиваться взаимнооднозначного соответствия между понятиями проекта и понятиями языка программирования. Оно дает определенную простоту и гарантирует, что проект адекватно отображается в программе, что упрощает работу программиста и вспомогательных средств. Операции преобразований типа являются механизмом, с помощью которого можно представить в языке класс программируемых отношений, а именно: операция преобразования X::operator Y() гарантирует, что всюду, где допустимо использование Y, можно применять и X. Такое же отношение задает конструктор Y::Y(X). Отметим, что операция преобразования типа (как и конструктор) скорее создает новый объект, чем изменяет тип существующего объекта. Задать операцию преобразования к функции Y - означает просто потребовать неявного применения функции, возвращающей Y. Поскольку неявные применения операций преобразования типа и операций, определяемых конструкторами, могут привести к неприятностям, полезно проанализировать их в отдельности еще в проекте.


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

class Big_int {

  //...

  friend Big_int operator+(Big_int,Big_int);

  //...

  operator Rational();

  //...

};

class Rational {

  //...

  friend Rational operator+(Rational,Rational);

  //...

  operator Big_int();

};

Типы Rational и Big_int не так гладко взаимодействуют, как можно было бы подумать:

void f(Rational r, Big_int i)

{

  //...

  g(r+i);                              // ошибка, неоднозначность:

                                   // operator+(r,Rational(i)) или

                                   // operator+(Big_int(r),i)

  g(r,Rational(i));                // явное разрешение неопределенности

  g(Big_int(r),i);                 // еще одно

}

Можно было бы избежать таких "взаимных" преобразований, сделав

некоторые из них явными. Например, преобразование Big_int к типу Rational можно было бы задать явно с помощью функции make_Rational() вместо операции преобразования, тогда сложение в приведенном примере разрешалось бы как g(BIg_int(r),i). Если нельзя избежать "взаимных" операций преобразования типов, то нужно преодолевать возникающие столкновения или с помощью явных преобразований (как было показано), или с помощью определения нескольких различных версий бинарной операции (в нашем случае +).


Производные классы


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

struct employee {                  // служащие

  char*     name;                  // имя

  short     age;                   // возраст

  short     department;            // отдел

  int       salary;                // оклад

  employee* next;

  // ...

};

Поле next нужно для связывания в список записей о служащих одного отдела (employee). Теперь попробуем определить структуру данных для управляющего (manager):

struct manager {

  employee emp;                    // запись employee для управляющего

  employee* group;                 // подчиненный коллектив

  short   level;

  // ...

};

Управляющий также является служащим, поэтому запись employee хранится в члене emp объекта manager. Для человека эта общность очевидна, но для транслятора член emp ничем не отличается от других членов класса. Указатель на структуру manager (manager*) не является указателем на employee (employee*), поэтому нельзя свободно использовать один вместо другого. В частности, без специальных действий нельзя объект manager включить в список объектов типа employee. Придется либо использовать явное приведение типа manager*, либо в список записей employee включить адрес члена emp. Оба решения некрасивы и могут быть достаточно запутанными. Правильное решение состоит в том, чтобы тип manager был типом employee с некоторой дополнительной информацией:

struct manager : employee {

  employee* group;

  short level;

  // ...

};

Класс manager является производным от employee, и, наоборот, employee является базовым классом для manager. Помимо члена group в классе manager есть члены класса employee (name, age и т.д.).     Графически отношение наследования обычно изображается в виде стрелки от производных классов к базовому:

employee

  ^

  |

manager

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


Имея определения employee и manager, можно создать список служащих, часть из которых является и управляющими:

void f()

{

  manager m1, m2;

  employee e1, e2;

  employee* elist;

  elist = &m1;                     // поместить m1 в elist

  m1.next = &e1;                   // поместить e1 в elist

  e1.next = &m2;                   // поместить m2 в elist

  m2.next = &e2;                   // поместить m2 в elist

  e2.next = 0;                     // конец списка

}

Поскольку управляющий является и служащим, указатель manager* можно использовать как employee*. В то же время служащий не обязательно является управляющим, и поэтому employee* нельзя использовать как manager*.

В общем случае, если класс derived имеет общий базовый класс base, то указатель на derived можно без явных преобразований типа присваивать переменной, имеющей тип указателя на base. Обратное преобразование от указателя на base к указателю на derived может быть только явным:

void g()

{

  manager mm;

  employee* pe = &mm;         // нормально

  employee ee;

  manager* pm = &ee;          // ошибка:

                               // не всякий служащий является управляющим

  pm->level = 2;              // катастрофа: при размещении ee

                               // память для члена `level' не выделялась

  pm = (manager*) pe;         // нормально: на самом деле pe

                               // не настроено на объект mm типа manager

  pm->level = 2;              // отлично: pm указывает на объект mm

                               // типа manager, а в нем при размещении

                               // выделена память для члена `level'

}

Иными словами, если работа с объектом производного класса идет через указатель, то его можно рассматривать как объект базового класса. Обратное неверно. Отметим, что в обычной реализации С++ не предполагается динамического контроля над тем, чтобы после преобразования типа, подобного тому, которое использовалось в присваивании pe в pm, получившийся в результате указатель действительно был настроен на объект требуемого типа (см. $$13.5).


Производные классы позволяют ввести новые операции


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

template<class T> void sort(SortableVector<T>& v)

{

  unsigned n = v.size();

  for (int i=0; i<n-1; i++)

     for (int j=n-1; i<j; j--)

       if (v.lessthan(v[j],v[j-1])) {

          // меняем местами v[j] и v[j-1]

          T temp = v[j];

          v[j] = v[j-1];

          v[j-1] = temp;

       }

}

Класс SortableVector (сортируемый вектор) можно определить так:

template<class T> class SortableVector

  : public Vector<T>, public Comparator<T> {

  public:

     SortableVector(int s) : Vector<T>(s) { }

};

Чтобы это определение имело смысл еще надо определить шаблонный класс Comparator (сравниватель):

template<class T> class Comparator {

  public:

     inline static lessthan(T& a, T& b)  // функция "меньше"

       { return strcmp(a,b)<0; }

     // ...

};

Чтобы устранить тот эффект, что в нашем случае операция < дает не тот результат для типа char*, мы определим специальный вариант класса сравнивателя:

class Comparator<char*> {

  public:

     inline static lessthan(const char* a, const char* b)

     // функция "меньше"

     { return strcmp(a,b)<0; }

     // ...

};

Описание специального варианта шаблонного класса для char* полностью подобно тому, как в предыдущем разделе мы определили специальный вариант шаблонной функции для этой же цели. Чтобы описание специального варианта шаблонного класса сработало, транслятор должен обнаружить его до использования. Иначе будет использоваться создаваемый по шаблону класс. Поскольку класс должен иметь в точности одно определение в программе, использовать и специальный вариант класса, и вариант, создаваемый по шаблону, будет ошибкой.


Поскольку у нас уже специальный вариант класса Comparator для char*, специальный вариант класса SortableVector для char* не нужен, и можем, наконец, попробовать сортировку:

void f(SortableVector<int>& vi,

       SortableVector<String>& vc,

       SortableVector<int>& vi2,

       SortableVector<char*>& vs)

{

  sort(vi);

  sort(vc);

  sort(vi2);

  sort(vs);

}

Возможно иметь два вида векторов и не очень хорошо, но, по крайней мере, SortableVector является производным от Vector. Значит если в функции не нужна сортировка, то в ней и не надо знать о классе SortableVector, а там, где нужно, сработает неявное преобразование ссылки на производный класс в ссылку на общий базовый класс. Мы ввели производный от Vector и Comparator класс SortableVector (вместо того, чтобы добавить функции к классу, производному от одного Vector) просто потому, что класс Comparator уже напрашивался в предыдущим примере. Такой подход типичен при создании больших библиотек. Класс Comparator естественный кандидат для библиотеки, поскольку в нем можно указать различные требования к операциям сравнения для разных типов.


Производные особые ситуации


Если для обработки особых ситуаций мы используем иерархию классов, то, естественно, каждый обработчик должен разбираться только с частью информации, передаваемой при особых ситуациях. Можно сказать, что, как правило, особая ситуация перехватывается обработчиком ее базового класса, а не обработчиком класса, соответствующего именно этой особой ситуации. Именование и перехват обработчиком особой ситуации семантически эквивалентно именованию и получению параметра в функции. Проще говоря, формальный параметр инициализируется значением фактического параметра. Это означает, что запущенная особая ситуация "низводится" до особой ситуации, ожидаемой обработчиком. Например:

class Matherr {

  // ...

  virtual void debug_print();

};

class Int_overflow : public Matherr {

  public:

     char* op;

     int opr1, opr2;;

     int_overflow(const char* p, int a, int b)

       { cerr << op << '(' << opr1 << ',' << opr2 << ')'; }

};

void f()

{

  try {

     g();

  }

  catch (Matherr m) {

     // ...

  }

}

При входе в обработчик Matherr особая ситуация m является объектом Matherr, даже если при обращении к g() была запущена Int_overflow. Это означает, что дополнительная информация, передаваемая в Int_overflow, недоступна.

Как обычно, чтобы иметь доступ к дополнительной информации можно использовать указатели или ссылки. Поэтому можно было написать так:

int add(int x, int y)              // сложить x и y с контролем

{

  if (x > 0 && y > 0 && x > MAXINT - y

     || x < 0 && y < 0 && x < MININT + y)

     throw Int_overflow("+", x, y);

     // Сюда мы попадаем, либо когда проверка

     // на переполнение дала отрицательный результат,

     // либо когда x и y имеют разные знаки

  return x + y;

}

void f()

{

  try {

     add(1,2);

     add(MAXINT,-2);

     add(MAXINT,2);                 // а дальше - переполнение

  }

  catch (Matherr& m) {


     // ...

     m.debug_print();

  }

}

Здесь последнее обращение к add приведет к запуску особой ситуации, который, в свою очередь, приведет к вызову Int_overflow::debug_print().Если бы особая ситуация передавалась по значению, а не по ссылке, то была бы вызвана функция Matherr::debug_print().

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

void h()

{

  try {

     // какие-то операторы

  }

  catch (Matherr) {

     if (can_handle_it) {           // если обработка возможна,

       // сделать ее

     }

     else {

       throw;                      // повторный запуск перехваченной

       // особой ситуации

     }

  }

}

Повторный запуск записывается как оператор throw без параметров. При этом снова запускается исходная особая ситуация, которая была перехвачена, а не та ее часть, на которую рассчитан обработчик Matherr. Иными словами, если была запущена Int_overflow, вызывающая h() функция могла бы перехватить ее как Int_overflow, несмотря на то, что она была перехвачена в h() как Matherr и запущена снова:

void k()

{

  try {

     h();

     // ...

  }

  catch (Int_overflow) {

     // ...

  }

}

Полезен вырожденный случай перезапуска. Как и для функций, эллипсис ... для обработчика означает "любой параметр", поэтому  оператор catch (...) означает перехват любой особой ситуации:

void m()

{

  try {

     // какие-то операторы

  }

  catch (...) {

     // привести все в порядок

     throw;

  }

}

Этот пример надо понимать так: если при выполнении основной части m() возникает особая ситуация, выполняется обработчик, которые выполняет общие действия по устранению последствий особой ситуации, после этих действий особая ситуация, вызвавшая их, запускается повторно.

Поскольку обработчик может перехватить производные особые ситуации нескольких типов, порядок, в котором идут обработчики в проверяемом блоке, существенен. Обработчики пытаются перехватить особую ситуацию в порядке их описания. Приведем пример:



try {

  // ...

}

catch (ibuf) {

  // обработка переполнения буфера ввода

}

catch (io) {

  // обработка любой ошибки ввода-вывода

}

catch (stdlib) {

  // обработка любой особой ситуации в библиотеке

}

catch (...) {

  // обработка всех остальных особых ситуаций

}

Тип особой ситуации в обработчике соответствует типу запущенной особой ситуации в следующих случаях: если эти типы совпадают, или второй тип является типом доступного базового класса запущенной ситуации, или он является указателем на такой класс, а тип ожидаемой ситуации тоже указатель ($$R.4.6).

Поскольку транслятору известна иерархия классов, он способен обнаружить такие нелепые ошибки, когда обработчик  catch (...) указан не последним, или когда обработчик ситуации базового класса предшествует обработчику производной от этого класса ситуации ($$R15.4). В обоих случая, последующий обработчик (или обработчики) не могут быть запущены, поскольку они "маскируются" первым обработчиком.


Производные типы


Исходя из основных (и определенных пользователем) типов, можно с помощью следующих операций описания:

*     указатель

&     ссылка

[]    массив

()    функция

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

Например:

int* a;

float v[10];

char* p[20];                       // массив из 20 символьных указателей

void f(int);

struct str { short length; char* p; };

Правила построения типов с помощью этих операций подробно объяснены в $$R.8. Ключевая идея состоит в том, что описание объекта производного типа должно отражать его использование, например:

int v[10];                         // описание вектора

i = v[3];                          // использование элемента вектора

int* p;                                 // описание указателя

i = *p;                                 // использование указуемого объекта

Обозначения, используемые для производных типов, достаточно трудны для понимания лишь потому, что операции * и & являются префиксными, а [] и () - постфиксными. Поэтому в задании типов, если приоритеты операций не отвечают цели, надо ставить скобки. Например, приоритет операции [] выше, чем у *, и мы имеем:

int* v[10];                        // массив указателей

int (*p)[10];                      // указатель массива

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

int x, y;                          // int x; int y;

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

int* p, y;                         // int* p; int y; НО НЕ int* y;

int x, *p;                         // int x; int* p;

int v[10], *p;                     // int v[10]; int* p;

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