Единственный способ изучать новый язык программирования - писать на нем программы.
- Брайэн Керниган
Эта глава представляет собой краткий обзор основных черт языка
программирования C++. Сначала приводится программа на C++, затем
показано, как ее откомпилировать и запустить, и как такая программа
может выводить выходные данные и считывать входные. В первой трети
этой главы после введения описаны наиболее обычные черты C++:
основные типы, описания, выражения, операторы, функции и структура
программы. Оставшаяся часть главы посвящена возможностям C++ по
определению новых типов, скрытию данных, операциям, определяемым
пользователем, и иерархии определяемых пользователем типов.
1.1 Введение
1.1.1 Вывод | |
1.1.2 Компиляция | |
1.1.3 Ввод |
Это турне проведет вас через ряд программ и частей программ на
C++. К концу у вас должно сложиться общее представление об основных
особенностях C++, и будет достаточно информации, чтобы писать
простые программы. Для точного и полного объяснения понятий,
затронутых даже в самом маленьком законченном примере,
потребовалось бы несколько страниц определений. Чтобы не превращать
эту главу в описание или в обсуждение общих понятий, примеры
снабжены только самыми короткими определениями используемых
терминов. Термины рассматриваются позже, когда будет больше
примеров, способствующих обсуждению.
Прежде всего, давайте напишем программу, выводящую строку
выдачи:
1.1.1 Вывод
#include
main()
{
cout << "Hello, world\n";
}
Строка #include сообщает компилятору, чтобы он включил
стандартные возможности потока ввода и вывода, находящиеся в файле
stream.h. Без этих описаний выражение cout << "Hello, world\n" не
имело бы смысла. Операция << ("поместить в"*1) пишет свой первый аргумент во второй (в данном случае, строку "Hello, world\n" в
стандартный поток вывода cout). Строка - это последовательность
символов, заключенная в двойные кавычки. В строке символ обратной
косой \, за которым следует другой символ, обозначает один
специальный символ; в данном случае, \n является символом новой
строки. Таким образом выводимые символы состоят из Hello, world и
перевода строки.
Остальная часть программы
main() { ... }
Откуда появились выходной поток cout и код, реализующий операцию
вывода < Для получения выполняемого кода написанная на C++
программа должна быть скомпилирована; по своей сути процесс
компиляции такой же, как и для С, и в нем участвует большая часть
входящих в последний программ. Производится чтение и анализ текста
программы, и если не обнаружены ошибки, то генерируется код. Затем
программа проверяется на наличие имен и операций, которые
использовались, но не были определены (в нашем случае это cout и
<<). Если это возможно, то программа делается полной посредством
дополнения недостающих определений из библиотеки (есть стандартные
библиотеки, и пользователи могут создавать свои собственные). В
нашем случае cout и << были описаны в stream.h, то есть, были
указаны их типы, но не было дано никаких подробностей относительно
их реализации. В стандартной библиотеке содержится спецификация
пространства и инициализирующий код для cout и <<. На самом деле, в
этой библиотеке содержится и много других вещей, часть из которых
описана в stream.h, однако к скомпилированной версии добавляется
только подмножество библиотеки, необходимое для того, чтобы сделать
нашу программу полной.
Команда компиляции в C++ обычно называется CC. Она используется
так же, как команда cc для программ на C; подробности вы можете
найти в вашем руководстве. Предположим, что программа с "Hello,
world" хранится в файле с именем
hello.c, тогда вы можете ее скомпилировать и запустить примерно так
($ - системное приглашение):
$ CC hello.c $ a.out Hello,world $
$ CC hello.c -o hello $ hello Hello,world $
Следующая (довольно многословная) программа предлагает вам ввести число дюймов. После того, как вы это сделаете, она напечатает соответствующее число сантиметров.
#include main() { int inch = 0; // inch - дюйм cout << "inches"; cin >> inch; cout << inch; cout << " in = "; cout << inch*2.54; cout << " cm\n"; }
$ a.out inches=12 12 in = 30.48 cm $
cout << inch << " in = " << inch*2.54 << " cm\n";
Часто бывает полезно вставлять в программу текст, который
предназначается в качестве комментария только для читающего
программу человека и игнорируется компилятором в программе. В C++
это можно сделать одним из двух способов.
Символы /* начинают комментарий, заканчивающийся символами */.
Вся эта последовательность символов эквивалентна символу пропуска
(например, символу пробела). Это наиболее полезно для многострочных
комментариев и изъятия частей программы при редактировании, однако
следует помнить, что комментарии /* */ не могут быть вложенными.
Символы // начинают комментарий, который заканчивается в конце
строки, на которой они появились. Опять, вся последовательность
символов эквивалентна пропуску. Этот способ наиболее полезен для
коротких комментариев. Символы // можно использовать для того,
чтобы закомментировать символы /* или */, а символами /* можно
закомментировать //.
1.3 Типы и Описания
1.3.1 Основные Tипы | |
1.3.2 Производные Типы |
Каждое имя и каждое выражение имеет тип, определяющий операции, которые могут над ними производиться. Например, описание
int inch;
cout << inch << " in = " << inch*2.54 << " cm\n";
Основные типы, наиболее непосредственно отвечающие средствам аппаратного обеспечения, такие:
char short int long float double
1 = sizeof(char) <= sizeof(short) <= sizeof(int) <= sizeof(long) sizeof(float) <= sizeof(double)
const float pi = 3.14; const char plus = '+';
+ | (плюс, унарный и бинарный) |
- | (минус, унарный и бинарный) |
* | (умножение) |
/ | (деление) |
== | (равно) |
!= | (не равно) |
< | (меньше) |
> | (больше) |
<= | (меньше или равно) |
>= | (больше или равно) |
double d = 1; int i = 1; d = d + i; i = d + i;
Вот операции, создающие из основных типов новые типы:
* | указатель на |
*const | константный указатель на |
& | ссылка на |
[] | вектор*2 |
() | функция, возвращающая |
char* p | // указатель на символ |
char *const q | // константный указатель на символ |
char v[10] | // вектор из 10 символов |
char c; // ... p = &c; // p указывает на c
1.4.1 Выражения | |
1.4.2 Операторы Выражения | |
1.4.3 Пустой оператор | |
1.4.4 Блоки | |
1.4.5 Операторы if | |
1.4.6 Операторы switch | |
1.4.7 Оператор while | |
1.4.8 Оператор for | |
1.4.9 Описания |
В C++ имеется богатый набор операций, с помощью которых в
выражениях образуются новые значения и изменяются значения
переменных. Поток управления в программе задается с помощью
операторов , а описания используются для введения в программе имен
переменных, констант и т.д. Заметьте, что описания являются
операторами, поэтому они свободно могут сочетаться с другими
операторами.
В C++ имеется большое число операций, и они будут объясняться
там, где (и если) это потребуется. Следует учесть, что операции
1.4.1 Выражения
~ | (дополнение) |
& | (И) |
^ | (исключающее ИЛИ) |
| | (включающее ИЛИ) |
<< | (логический сдвиг влево) |
>> | (логический сдвиг вправо) |
Самый обычный вид оператора - оператор выражение. Он состоит из выражения, за которым следует точка с запятой. Например:
a = b*3+c; cout << "go go go"; lseek(fd,0,2);
Простейшей формой оператора является пустой оператор:
;
Он не делает ничего. Однако он может быть полезен в тех случаях,
когда синтаксис требует наличие оператора, а вам оператор не нужен.
Блок - это возможно пустой список операторов, заключенный в
фигурные скобки:
Программа в следующем примере осуществляет преобразование дюймов
в сантиметры и сантиметров в дюймы; предполагается, что вы укажете
единицы измерения вводимых данных, добавляя i для дюймов и c для
сантиметров:
Оператор switch производит сопоставление значения с множеством
констант. Проверки в предыдущем примере можно записать так:
Рассмотрим копирование строки, когда заданы указатель p на ее
первый символ и указатель q на целевую строку. По соглашению строка
оканчивается символом с целым значением 0.
Рассмотрим копирование десяти элементов одного вектора в другой:
Описание - это оператор, вводящий имя в программе. Оно может
также инициализировать объект с этим именем. Выполнение описания
означает, что когда поток управления доходит до описания,
вычисляется инициализирующее выражение (инициализатор) и
производится инициализация. Например:
Функция - это именованная часть программы, к которой можно
обращаться из других частей программы столько раз, сколько
потребуется. Рассмотрим программу, печатающую степени числа 2:
Программа на C++ обычно состоит из большого числа исходных
файлов, каждый из которых содержит описания типов, функций,
переменных и констант. Чтобы имя можно было использовать в разных
исходных файлах для ссылки на один и тот же объект, оно должно
быть описано как внешнее. Например:
1.4.4 Блоки
{ a=b+2; b++; }
Блок позволяет рассматривать несколько операторов как один. Область
видимости имени, описанного в блоке, простирается до конца блока.
Имя можно сделать невидимым с помощью описаний такого же имени во
внутренних блоках.
1.4.5 Операторы if
#include
main()
{
const float fac = 2.54;
float x, in, cm;
char ch = 0;
cout << "введите длину: ";
cin >> x >> ch;
if (ch == 'i') { // inch - дюймы
in = x;
cm = x*fac;
}
else if (ch == 'c') // cm - сантиметры
in = x/fac;
cm = x;
}
else
in = cm = 0;
cout << in << " in = " << cm << " cm\n";
}
Заметьте, что условие в операторе if должно быть заключено в
круглые скобки.
1.4.6 Операторы switch
switch (ch) {
case 'i':
in = x;
cm = x*fac;
break;
case 'c':
in = x/fac;
cm = x;
break;
default:
in = cm = 0;
break;
}
Операторы break применяются для выхода из оператора switch.
Константы в вариантах case должны быть различными, и если
проверяемое значение не совпадает ни с одной из констант,
выбирается вариант default. Программисту не обязательно
предусматривать default.
1.4.7 Оператор while
while (p != 0) {
*q = *p; // скопировать символ
q = q+1;
p = p+1;
}
*q = 0; // завершающий символ 0 скопирован не был
Следующее после while условие должно быть заключено в круглые
скобки. Условие вычисляется, и если его значение не ноль,
выполняется непосредственно следующий за ним оператор. Это
повторяется до тех пор, пока вычисление условия не даст ноль.
Этот пример слишком пространен. Можно использовать операцию ++
для непосредственного указания увеличения, и проверка упростится:
while (*p) *q++ = *p++;
*q = 0;
где конструкция *p++ означает: "взять символ, на который указывает
p, затем увеличить p."
Пример можно еще упростить, так как указатель p разыменовывается
дважды за каждый цикл. Копирование символа можно делать тогда же,
когда производится проверка условия:
while (*q++ = *p++) ;
Здесь берется символ, на который указывает p, p увеличивается, этот
символ копируется туда, куда указывает q, и q увеличивается. Если
символ ненулевой, цикл повторяется. Поскольку вся работа
выполняется в условии, не требуется ни одного оператора. Чтобы
указать на это, используется пустой оператор. C++ (как и C)
одновременно любят и ненавидят за возможность такого чрезвычайно
краткого ориентированного на выразительность программирования *4.
1.4.8 Оператор for
for (int i=0; i<10; i++) q[i]=p[i];
Это эквивалентно
int i = 0;
while (i<10) {
q[i] = p[i];
i++;
}
но более удобочитаемо, поскольку вся информация, управляющая
циклом, локализована. При применении операции ++ к целой
переменной к ней просто добавляется единица. Первая часть оператора
for не обязательно должна быть описанием, она может быть любым
оператором. Например:
for (i=0; i<10; i++) q[i]=p[i];
тоже эквивалентно предыдущей записи при условии, что i
соответствующим образом описано раньше.
1.4.9 Описания
for (int i = 1; i
1.5 Функции
extern float pow(float, int); //pow() определена в другом месте
main()
{
for (int i=0; i<10; i++) cout << pow(2,i) << "\n";
}
Первая строка функции - описание, указывающее, что pow - функция,
получающая параметры типа float и int и возвращающая float.
Описание функции используется для того, чтобы сделать определенными
обращения к функции в других местах.
При вызове тип каждого параметра функции сопоставляется с
ожидаемым типом точно так же, как если бы инициализировалась
переменная описанного типа. Это гарантирует надлежащую проверку и
преобразование типов. Например, обращение pow(12.3,"abcd") вызовет
недовольство компилятора, поскольку "abcd" является строкой, а не
int. При вызове pow(2,i) компилятор преобразует 2 к типу float, как
того требует функция. Функция pow может быть определена например
так:
float pow(float x, int n)
{
if (n < 0) error("извините, отрицательный показатель для pow()");
switch (n) {
case 0: return 1;
case 1: return x;
default: return x*pow(x,n-1);
}
}
Первая часть определения функции задает имя функции, тип
возвращаемого ею значения (если таковое имеется) и типы и имена ее
параметров (если они есть). Значение возвращается из функции с
помощью оператора return.
Разные функции обычно имеют разные имена, но функциям,
выполняющим сходные действия над объектами различных типов, иногда
лучше дать возможность иметь одинаковые имена. Если типы их
параметров различны, то компилятор всегда может различить их и
выбрать для вызова нужную функцию. Может, например, иметься одна
функция возведения в степень для целых переменных и другая для
переменных с плавающей точкой:
overload pow;
int pow(int, int);
double pow(double, double);
//...
x=pow(2,10);
y=pow(2.0,10.0);
Описание
overload pow;
сообщает компилятору, что использование имени pow более чем для
одной функции является умышленным.
Если функция не возвращает значения, то ее следует описать как
void:
void swap(int* p, int* q) // поменять местами
{
int t = *p;
*p = *q;
*q = t;
}
1.6 Структура программы
extern double sqrt(double);
extern instream cin;
Самый обычный способ обеспечить согласованность исходных файлов -
это поместить такие описания в отдельные файлы, называемые
заголовочными (или хэдер) файлами, а затем включить, то есть
скопировать, эти заголовочные файлы во все файлы, где нужны эти
описания. Например, если описание sqrt хранится в заголовочном
файле для стандартных математических функций math.h, и вы хотите
извлечь квадратный корень из 4, можно написать:
#include
//...
x = sqrt(4);
Поскольку обычные заголовочные файлы включаются во многие исходные
файлы, они не содержат описаний, которые не должны повторяться.
Например, тела функций даются только для inline-подставляемых
функций (#1.12) и инициализаторы даются только для констант (#1.3.1). За исключением этих случаев, заголовочный файл является хранилищем информации о типах. Он обеспечивает интерфейс между
отдельно компилируемыми частями программы.
В команде включения include имя файла, заключенное в угловые
скобки, например , относится к файлу с этим именем в
стандартном каталоге (часто это /usr/include/CC); на файлы,
находящиеся в каких-либо других местах ссылаются с помощью имен,
заключенных в двойные кавычки. Например:
#include "math1.h" #include "/usr/bs/math2.h"
// header.h extern char* prog_name; extern void f();
// main.c #include "header.h" char* prog_name = "дурацкий, но полный"; main() { f(); }
// f.c #include #include "header.h" void f() { cout << prog_name << "\n"; }
$ CC main.c f.c -o silly $ silly дурацкий, но полный $
Давайте посмотрим, как мы могли бы определить тип потока вывода
ostream. Чтобы упростить задачу, предположим, что для буферизации
определен тип streambuf. Тип streambuf на самом деле определен в
, где также находится и настоящее определение ostream.
Пожалуйста, не испытывайте примеры, определяющие ostream в этом и
последующих разделах; пока вы не сможете полностью избежать
использования , компилятор будет возражать против
переопределений.
Определение типа, определяемого пользователем (который в C++
называется class, т.е. класс), специфицирует данные, необходимые
для представления объекта этого типа, и множество операций для
работы с этими объектами. Определение имеет две части: закрытую
(private) часть, содержащую информацию, которой может пользоваться
только его разработчик, и открытую (public) часть, представляющую
интерфейс типа с пользователем:
class ostream { streambuf* buf; int state; public: void put(char*); void put(long); void put(double); }
ostream my_out;
my_out.put("Hello, world\n");
void ostream::put(char* p) { while (*p) buf.sputc(*p++); }
void ostream::put(char* p) { while (*p) this->buf.sputc(*p++); }
Настоящий класс ostream определяет операцию <<, чтобы сделать
удобным вывод нескольких объектов одним оператором. Давайте
посмотрим, как это сделано.
Чтобы определить @, где @ - некоторая операция языка C++, для
каждого определяемого пользователем типа вы определяете функцию с
именем operator@, которая получает параметры соответствующего типа.
Например:
class ostream { //... ostream operator<<(char*); }; ostream ostream::operator<<(char* p) { while (*p) buf.sputc(*p++); return *this; }
&s1 == &my_out
Первая очевидная польза от ссылок состоит в том, чтобы обеспечить передачу адреса объекта, а не самого объекта, в функцию вывода (в некоторых языках это называется передачей параметра по ссылке):
ostream& operator<<(ostream& s, complex z) { return s << "(" << z.real << "," << z.imag << ")"; }
class istream { //... int state; public: istream& operator>>(char&); istream& operator>>(char*); istream& operator>>(int&); istream& operator>>(long&); //... };
Определение ostream как класса сделало члены данные закрытыми. Только функция член имеет доступ к закрытым членам, поэтому надо предусмотреть функцию для инициализации. Такая функция называется конструктором и отличается тем, что имеет то же имя, что и ее класс:
class ostream { //... ostream(streambuf*); ostream(int size, char* s); };
ostream my_out(&some_stream_buffer); char xx[256]; ostream xx_stream(256,xx);
Встроенное в C++ понятие вектора было разработано так, чтобы обеспечить максимальную эффективность выполнения при минимальном расходе памяти. Оно также (особенно когда используется совместно с указателями) является весьма универсальным инструментом для построения средств более высокого уровня. Вы могли бы, конечно, возразить, что размер вектора должен задаваться как константа, что нет проверки выхода за границы вектора и т.д. Ответ на подобные возражения таков: "Вы можете запрограммировать это сами." Давайте посмотрим, действительно ли оправдан такой ответ. Другими словами, проверим средства абстракции языка C++, попытавшись реализовать эти возможности для векторных типов, которые мы создадим сами, и посмотрим, какие с этим связаны трудности, каких это требует затрат, и насколько получившиеся векторные типы удобны в обращении.
class vector { int* v; int sz; public: vector(int); // конструктор ~vector(); // деструктор int size() { return sz; } void set_size(int); int& operator[](int); int& elem(int i) { return v[i]; } };
vector::vector(int s) { if (s<=0) error("плохой размер вектора"); sz = s; v = new int[s]; }
vector v1(100); vector v2(nelem*2-4);
int& vector::operator[](int i) { if(i<0 || sz<=i) error("индекс выходит за границы"); return v[i]; }
v1[x] = v2[y];
vector::~vector() { delete v; }
Если часто повторяется обращение к очень маленькой функции, то вы можете начать беспокоиться о стоимости вызова функции. Обращение к функции члену не дороже обращения к функции не члену с тем же числом параметров (надо помнить, что функция член всегда имеет хотя бы один параметр), и вызовы в функций в C++ примерно столь же эффективны, сколь и в любом языке. Однако для слишком маленьких функций может встать вопрос о накладных расходах на обращение. В этом случае можно рассмотреть возможность спецификации функции как inline-подставляемой. Если вы поступите таким образом, то компилятор сгенерирует для функции соответствующий код в месте ее вызова. Семантика вызова не изменяется. Если, например, size и elem inline-подставляемые, то
vector s(100); //... i = s.size(); x = elem(i-1);
//... i = 100; x = s.v[i-1];
Теперь давайте определим вектор, для которого пользователь может задавать границы изменения индекса.
class vec: public vector { int low, high; public: vec(int,int); int& elem(int); int& operator[](int); };
:public vector
int& vec::elem(int i) { return vector::elem(i-low); }
vec::vec(int lb, int hb) : (hb-lb+1) { if (hb-lb<0) hb = lb; low = lb; high = hb; }
#include void error(char* p) { cerr << p << "n\"; // cerr - выходной поток сообщений об ошибках exit(1); } void vector::set_size(int) { /* пустышка */ } int& vec::operator[](int i) { if (i
Другое направление развития - снабдить вектора операциями:
class Vec : public vector { public: Vec(int s) : (s) {} Vec(Vec&); ~Vec() {} void operator=(Vec&); void operator*=(Vec&); void operator*=(int); //... };
void Vec::operator=(Vec& a) { int s = size(); if (s!=a.size()) error("плохой размер вектора для ="); for (int i = 0; i void error(char* p) { cerr << p << "\n"; exit(1); } void vector::set_size(int) { /*...*/ } int& vec::operator[](int i) { /*...*/ } main() { Vec a(10); Vec b(10); for (int i=0; i
Функция operator+() не воздействует непосредственно на представление вектора. Действительно, она не может этого делать, поскольку не является членом. Однако иногда желательно дать функциям не членам возможность доступа к закрытой части класса. Например, если бы не было функции "доступа без проверки" vector::elem(), вам пришлось бы проверять индекс i на соответствие границам три раза за каждый проход цикла. Здесь мы избежали этой сложности, но она довольно типична, поэтому у класса есть механизм предоставления права доступа к своей закрытой части функциям не членам. Просто в описание класса помещается описание функции, перед которым стоит ключевое слово friend. Например, если имеется
class Vec; // Vec - имя класса class vector { friend Vec operator+(Vec, Vec); //... };
Vec operator+(Vec a, Vec b) { int s = a.size(); if (s != b.size()) error("плохой размер вектора для +"); Vec& sum = *new Vec(s); int* sp = sum.v; int* ap = a.v; int* bp = b.v; while (s--) *sp++ = *ap++ + *bp++; return sum; }
"Пока все хорошо," - можете сказать вы, - "но я хочу, чтобы один
из этих векторов был типа matrix, который я только что определил."
К сожалению, в C++ не предусмотрены средства для определения класса
векторов с типом элемента в качестве параметра. Один из способов -
продублировать описание и класса, и его функций членов. Это не
идеальный способ, но зачастую вполне приемлемый.
Вы можете воспользоваться препроцессором (#4.7), чтобы механизировать работу. Например, класс vector - упрощенный вариант
класса, который можно найти в стандартном заголовочном файле. Вы
могли бы написать:
#include declare(vector,int); main() { vector(int) vv(10); vv[2] = 3; vv[10] = 4; // ошибка: выход за границы }
declare(vector,char); //... implement(vector,char);
У вас есть другая возможность - определить ваш векторный и другие вмещающие классы через указатели на объекты некоторого класса:
class common { //... }; class vector { common** v; //... public: cvector(int); common*& elem(int); common*& operator[](int); //... };
class apple : public common { /*...*/ } class orange : public common { /*...*/ } class apple_vector : public cvector { public: cvector fruitbowl(100); //... apple aa; orange oo; //... fruitbowl[0] = &aa; fruitbowl[1] = &oo; }
class apple_vector : public cvector { public: apple*& elem(int i) { return (apple*&) cvector::elem(i); } //... };
Предположим, что мы пишем программу для изображения фигур на экране. Общие атрибуты фигуры представлены классом shape, а специальные атрибуты - специальными классами:
class shape { point center; color col; //... public: void move(point to) { center=to; draw(); } point where() { return center; } virual void draw(); virtual void rotate(int); //... };
class circle: public shape { int radius; public: void draw(); void rotatte(int i) {} //... };
for (int i = 0; i
*1 Программирующим на C << известно как операция сдвига влево для
целых. Такое использование << не утеряно; просто в дальнейшем <<
было определено для случая, когда его левый операнд является
потоком вывода. Как это делается, описано в #1.8. (прим. автора)
*2 одномерный массив. Это принятый термин (например, вектора
прерываний), и мы сочли, что стандартный перевод его как "массив"
затуманит изложение. (прим. перев.)
*3 англ. dereference - получить значение объекта, на который
указывает данный указатель. (прим. перев.)
*4 в оригинале expression-oriented (expression - выразительность и
выражение). (прим. перев.)