До сих пор мы обсуждали только классы, и хотя операции упоминались, если не считать обсуждения шагов процесса развития программного обеспечения ($$11.3.3.2), то они были на втором плане, объекты же практически вообще не упоминались. Понять это просто: в С++ класс, а не функция или объект, является основным понятием организации системы.
Класс может скрывать в себе всякую специфику реализации, наравне с "грязными" приемами программирования, а иногда он вынужден это делать. В то же время объекты большинства классов сами образуют регулярную структуру и используются такими способами, что их достаточно просто описать. Объект класса может быть совокупностью других вложенных объектов (их часто называют членами), многие из которых, в свою очередь, являются указателями или ссылками на другие объекты. Поэтому отдельный объект можно рассматривать как корень дерева объектов, а все входящие в него объекты как "иерархию объектов", которая дополняет иерархию классов, рассмотренную в $$12.2.4. Рассмотрим в качестве примера класс строк из $$7.6:
class String {
int sz;
char* p;
public:
String(const char* q);
~String();
//...
};
Объект типа String можно изобразить так:
Объектно-ориентированное программирование - это метод программирования, способ написания "хороших" программ для множества задач. Если этот термин имеет какой-то смысл, то он должен подразумевать: такой язык программирования, который предоставляет хорошие возможности для объектно-ориентированного стиля программирования.
Здесь следует указать на важные различия. Говорят, что язык поддерживает некоторый стиль программирования, если в нем есть такие возможности, которые делают программирование в этом стиле удобным (достаточно простым, надежным и эффективным). Язык не поддерживает некоторый стиль программирования, если требуются большие усилия или даже искусство, чтобы написать программу в этом стиле. Однако это не означает, что язык запрещает писать программы в этом стиле. Действительно, можно писать структурные программы на Фортране и объектно-ориентированные программы на С, но это будет пустой тратой сил, поскольку данные языки не поддерживают указанных стилей программирования.
Поддержка языком определенной парадигмы (стиля) программирования явно проявляется в конкретных языковых конструкциях, рассчитанных на нее. Но она может проявляться в более тонкой, скрытой форме, когда отклонение от парадигмы диагностируется на стадии трансляции или выполнения программы. Самый очевидный пример - это контроль типов. Кроме того, языковая поддержка парадигмы может дополняться проверкой на однозначность и динамическим контролем. Поддержка может предоставляться и помимо самого языка, например, стандартными библиотеками или средой программирования.
Нельзя сказать, что один язык лучше другого только потому, что в нем есть возможности, которые в другом отсутствуют. Часто бывает как раз наоборот. Здесь более важно не то, какими возможностями обладает язык, а то, насколько имеющиеся в нем возможности поддерживают избранный стиль программирования для определенного круга задач. Поэтому можно сформулировать следующие требования к языку:
[1] Все конструкции языка должны естественно и элегантно определяться в нем.
Если в качестве параметра функции указан массив, то передается указатель на его первый элемент. Например:
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++) {
Когда программа калькулятора уже была написана и отлажена, выяснилось, что неудобно вначале запускать ее, вводить выражение, а затем выходить из калькулятора. Тем более, что обычно нужно просто вычислить одно выражение. Если это выражение задать как параметр командной строки запуска калькулятора, то можно сэкономить несколько нажатий клавиши.
Как уже было сказано, выполнение программы начинается вызовом 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> >); // нормально
Здесь все ошибки вызваны тем, что параметр-тип шаблона никак не влияет на формальные параметры функций.
Есть способ связывания имен с целыми константами, который часто более удобен, чем описание с 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>
Обычно имеет смысл давать разным функциям разные имена. Если же несколько функций выполняет одно и то же действие над объектами разных типов, то удобнее дать одинаковые имена всем этим функциям. Перегрузкой имени называется его использование для обозначения разных операций над разными типами. Собственно уже для основных операций С++ применяется перегрузка. Действительно: для операций сложения есть только одно имя +, но оно используется для сложения и целых чисел, и чисел с плавающей точкой, и указателей. Такой подход легко можно распространить на операции, определенные пользователем, т.е. на функции. Например:
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)) ?
Каждое имя и каждое выражение обязаны иметь тип. Именно тип определяет операции, которые могут выполняться над ними. Например, в описании
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"; // нормально
Кажется расточительным использовать для признака, принимающего только два значения ( например: да, нет) тип 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)
{
Функция 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. Рассмотрим такой пример:
Абстрактный тип данных определяется как некий "черный ящик". После своего определения он по сути никак не взаимодействует с программой. Его никак нельзя приспособить для новых целей, не меняя определения. В этом смысле это негибкое решение. Пусть, например, нужно определить для графической системы тип 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).
Язык С++ проектировался как "лучший С", поддерживающий абстракцию
данных и объектно-ориентированное программирование. При этом он должен быть пригодным для большинства основных задач системного программирования.
Основная трудность для языка, который создавался в расчете на методы упрятывания данных, абстракции данных и объектно-ориентированного программирования, в том, что для того, чтобы быть языком общего назначения, он должен:
-
идти на традиционных машинах;
- сосуществовать с традиционными операционными системами и языками;
- соперничать с традиционными языками программирования в эффективности выполнения программы;
- быть пригодным во всех основных областях приложения.
Это значит, что должны быть возможности для эффективных числовых операций (арифметика с плавающей точкой без особых накладных расходов, иначе пользователь предпочтет Фортран) и средства такого доступа к памяти, который позволит писать на этом языке драйверы устройств. Кроме того, надо уметь писать вызовы функций в достаточно непривычной записи, принятой для обращений в традиционных операционных системах. Наконец, должна быть возможность из языка, поддерживающего объектно-ориентированное программирование, вызывать функции, написанные на других языках, а из других языков вызывать функцию на этом языке, поддерживающем объектно-ориентированное программирование.
Далее, нельзя рассчитывать на широкое использование искомого языка программирования как языка общего назначения, если реализация его целиком полагается на возможности, которые отсутствуют в машинах с традиционной архитектурой.
Если не вводить в язык возможности низкого уровня, то придется для основных задач большинства областей приложения использовать некоторые языки низкого уровня, например С или ассемблер. Но С++ проектировался с расчетом, что в нем можно сделать все, что допустимо на С, причем без увеличения времени выполнения. Вообще, С++ проектировался, исходя из принципа, что не должно возникать никаких дополнительных затрат времени и памяти, если только этого явно не пожелает сам программист.
Используется только несколько предположений о свойствах пользовательских операций. В частности, 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). Корректность явного преобразования типа часто существенно зависит от того, насколько программист понимает, как язык работает с объектами различных типов, и какова специфика данной реализации языка. Приведем пример:
Определяемые пользователем преобразования типа, например, такие, как преобразование числа с плавающей точкой в комплексное, которое необходимо для конструктора 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, как выбирать между наследованием и принадлежностью? Рассмотрим классы самолет и мотор.Новички обычно спрашивают: будет ли хорошим решением сделать класс самолет производным от класса мотор. Это плохое решение, поскольку самолет не "есть" мотор, самолет "имеет" мотор. Следует подойти к этому вопросу, рассмотрев, может ли самолет "иметь" два или больше моторов. Поскольку это представляется вполне возможным (даже если мы имеем дело с программой, в которой все самолеты будут с одним мотором), следует использовать принадлежность, а не наследование. Вопрос "Может ли он иметь два..?" оказывается удивительно полезным во многих сомнительных случаях. Как всегда, наше изложение затрагивает неуловимую сущность программирования. Если бы все классы было так же легко представить, как самолет и мотор, то было бы просто избежать и тривиальных ошибок типа той, когда самолет определяется как производное от класса мотор. Однако, такие ошибки достаточно часты, особенно у тех, кто считает наследование еще одним механизмом для сочетания конструкций языка программирования. Несмотря на удобство и лаконичность записи, которую предоставляет наследование, его надо использовать только для выражения тех отношений, которые четко определены в проекте. Рассмотрим определения:
Для многих типов задача управления ими сводится к построению и уничтожению связанных с ними объектов, но есть типы, для которых этого мало. Иногда необходимо управлять всеми операциями копирования. Вернемся к классу 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 );
// ..
}
Двойная наклонная черта // начинает комментарий, который продолжается до конца строки.
При такой организации программы функции вносят определенный порядок в хаос различных алгоритмов.
Процесс развития программного обеспечения - это итеративный и расширяющийся процесс. По мере развития каждая стадия повторяется многократно, и при всяком возврате на некоторую стадию процесса уточняется конечный продукт, получаемый на этой стадии. В общем случае процесс не имеет ни начала, ни конца, поскольку, проектируя и реализуя систему, вы начинаете, используя как базу другие проекты, библиотеки и прикладные системы, в конце работы после вас остается описание проекта и программа, которые другие могут уточнять, модифицировать, расширять и переносить. Естественно конкретный проект имеет определенное начало и конец, и важно (хотя часто удивительно трудно) четко и строго ограничить время и область действия проекта. Но заявление, что вы начинаете с "чистого листа", может привести к серьезным проблемам для вас, также как и позиция, что после передачи окончательной версии - хоть потоп, вызовет серьезные проблемы для ваших последователей (или для вас в новой роли).
Из этого вытекает, что следующие разделы можно читать в любом порядке, поскольку вопросы проектирования и реализации могут в реальном проекте переплетаться почти произвольно. Именно, "проект" почти всегда подвергается перепроектированию на основе предыдущего проекта, определенного опыта реализации, ограничений, накладываемых сроками, мастерством работников, вопросами совместимости и т.п. Здесь основная трудность для менеджера или разработчика или программиста в том, чтобы создать такой порядок в этом процессе, который не препятствует усовершенствованиям и не запрещает повторные проходы, необходимые для успешного развития.
У процесса развития три стадии:
-
Анализ: определение области задачи.
- Проектирование: создание общей структуры системы.
- Реализация: программирование и тестирование.
Не забудьте об итеративной природе этих процессов (неспроста стадии не были пронумерованы), и заметьте, что никакие важные аспекты процесса развития программы не выделяются в отдельные стадии, поскольку они должны допускать:
Если бы мне надо было построить мост, то я серьезно подумал бы, из какого материала его строить, и проект моста сильно зависел бы от выбранного материала, а, следовательно, разумные проекты каменного моста отличаются от разумных проектов металлического моста или от разумных проектов деревянного моста и т.д. Не стоит рассчитывать на выбор подходящего для моста материала без определенных знаний о материалах и их использовании. Конечно, вам не надо быть специалистом плотником для проектирования деревянного моста, но вы должны знать основы конструирования из дерева, чтобы предпочесть его металлу в качестве материала для моста. Более того, хотя для проектирования деревянного моста вы и не должны быть специалистом плотником, вам необходимо достаточно детально знать свойства дерева и еще больше знать о плотниках.
Аналогично, при выборе языка программирования для определенного программного обеспечения надо знать несколько языков, а для успешного проектирования программы надо достаточно детально знать выбранный язык реализации, даже если вам лично не предстоит написать ни одной строчки программы. Хороший проектировщик моста ценит свойства используемых им материалов и применяет их для улучшения проекта. Аналогично, хороший разработчик программ использует сильные стороны языка реализации и, насколько возможно, стремится избежать такого его использования, которое вызовет трудности на стадии реализации.
Можно подумать, что так получается естественным образом, если в проектировании участвует только один разработчик или программист, однако даже в этом случае программист в силу недостатка опыта или из-за неоправданной приверженности к стилю программирования, рассчитанному на совершенно другие языки, может сбиться на неверное использование языка. Если разработчик существенно отличается от программиста, особенно если у них разная программистская культура, возможность появления в окончательной версии системы ошибок, неэффективных и неэлегантных решений почти наверняка превратится в неизбежность.
Самая маленькая программа на С++ выглядит так:
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. Поскольку неявные применения операций преобразования типа и операций, определяемых конструкторами, могут привести к неприятностям, полезно проанализировать их в отдельности еще в проекте.
Обсудим, как написать программу учета служащих некоторой фирмы. В ней может использоваться, например, такая структура данных:
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
Обычно говорят, что производный класс наследует базовый класс, поэтому и отношение между ними называется наследованием. Иногда базовый класс называют суперклассом, а производный - подчиненным классом. Но эти термины могут вызывать недоумение, поскольку объект производного класса содержит объект своего базового класса. Вообще производный класс больше своего базового в том смысле, что в нем содержится больше данных и определено больше функций.
В предыдущем разделе функция сравнения была "встроенной" в теле 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* полностью подобно тому, как в предыдущем разделе мы определили специальный вариант шаблонной функции для этой же цели. Чтобы описание специального варианта шаблонного класса сработало, транслятор должен обнаружить его до использования. Иначе будет использоваться создаваемый по шаблону класс. Поскольку класс должен иметь в точности одно определение в программе, использовать и специальный вариант класса, и вариант, создаваемый по шаблону, будет ошибкой.
Если для обработки особых ситуаций мы используем иерархию классов, то, естественно, каждый обработчик должен разбираться только с частью информации, передаваемой при особых ситуациях. Можно сказать, что, как правило, особая ситуация перехватывается обработчиком ее базового класса, а не обработчиком класса, соответствующего именно этой особой ситуации. Именование и перехват обработчиком особой ситуации семантически эквивалентно именованию и получению параметра в функции. Проще говоря, формальный параметр инициализируется значением фактического параметра. Это означает, что запущенная особая ситуация "низводится" до особой ситуации, ожидаемой обработчиком. Например:
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) {
Исходя из основных (и определенных пользователем) типов, можно с помощью следующих операций описания:
* указатель
& ссылка
[] массив
() функция
а также с помощью определения структур, задать другие, производные типы.
Например:
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;
Но такие описания запутывают программу, и, возможно, их следует избегать.