С операторами и выражениями вас познакомит приведенная здесь
программа настольного калькулятора, предоставляющего четыре
стандартные арифметические операции над числами с плавающей точкой.
Пользователь может также определять переменные. Например, если
вводится
r=2.5
area=pi*r*r
(pi определено заранее), то программа калькулятора напишет:
2.5
19.635
где 2.5 - результат первой введенной строки, а 19.635 - результат
второй.
Калькулятор состоит из четырех основных частей: программы
синтаксического разбора (parser'а), функции ввода, таблицы имен и
управляющей программы (драйвера). Фактически, это миниатюрный
компилятор, в котором программа синтаксического разбора производит
синтаксический анализ, функция ввода осуществляет ввод и
лексический анализ, в таблице имен хранится долговременная
информация, а драйвер распоряжается инициализацией, выводом и
обработкой ошибок. Можно было бы многое добавить в этот
калькулятор, чтобы сделать его более полезным, но в существующем
виде эта программа и так достаточно длинна (200 строк), и большая
часть дополнительных возможностей просто увеличит текст программы
не давая дополнительного понимания применения C++.
Каждая является отдельной лексемой, поэтому a+ =1 является
синтаксической ошибкой из-за пробела между + и =. (% является
операцией взятия по модулю; &,| и ^ являются побитовыми операциями
И, ИЛИ и исключающее ИЛИ; << и >> являются операциями левого и
правого сдвига). Функции term() и get_token() должны быть описаны
до expr().
Как организовать программу в виде набора файлов, обсуждается в
Главе 4. За одним исключением все описания в данной программе
настольного калькулятора можно упорядочить так, чтобы все
описывалось ровно один раз и до использования. Исключением является
expr(), которая обращается к term(), которая обращается к prim(),
которая в свою очередь обращается к expr(). Этот круг надо как-то
разорвать; описание
double expr(); // без этого нельзя
перед prim() прекрасно справляется с этим.
Функция term() аналогичным образом обрабатывает умножение и
сложение:
double term() // умножает и складывает
{
double left = prim();
for(;;)
switch(curr_tok) {
case MUL:
get_token(); // ест '*'
left *= prim();
break;
case DIV:
get_token(); // ест '/'
double d = prim();
if (d == 0) return error("деление на 0");
left /= d;
break;
default:
return left;
}
}
Проверка, которая делается, чтобы удостовериться в том, что нет
деления на ноль, необходима, поскольку результат деления на ноль
не определен и как правило является роковым. Функция error(char*)
будет описана позже. Переменная d вводится в программе там, где она
нужна, и сразу же инициализируется. Во многих языках описание может
располагаться только в голове блока. Это ограничение может
приводить к довольно скверному искажению стиля программирования
и/или излишним ошибкам. Чаще всего неинициализированные локальные
переменные являются просто признаком плохого стиля; исключением
являются переменные, подлежащие инициализации посредством ввода, и
переменные векторного или структурного типа, которые нельзя удобно
инициализировать одними присваиваниями *3. Заметьте, что = является операцией присваивания, а == операцией сравнения.
Функция prim, обрабатывающая primary, написана в основном в том
же духе, не считая того, что немного реальной работы в ней все-таки
выполняется, и нет нужды в цикле, поскольку мы попадаем на более
низкий уровень иерархии вызовов:
double prim() // обрабатывает primary (первичные)
{
switch (curr_tok) {
case NUMBER: // константа с плавающей точкой
get_token();
return number_value;
case NAME:
if (get_token() == ASSIGN) {
name* n = insert(name_string);
get_token();
n->value = expr();
return n->value;
}
return look(name-string)->value;
case MINUS: // унарный минус
get_token();
return -prim();
case LP:
get_token();
double e = expr();
if (curr_tok != RP) return error("должна быть )");
get_token();
return e;
case END:
return 1;
default:
return error("должно быть primary");
}
}
При обнаружении NUMBER (то есть, константы с плавающей точкой),
возвращается его значение. Функция ввода get_token() помещает
значение в глобальную переменную number_value. Использование в
программе глобальных переменных часто указывает на то, что
структура не совсем прозрачна, что применялась некоторого рода
оптимизация. Здесь дело обстоит именно так. Теоретически
лексический символ обычно состоит из двух частей: значения,
определяющего вид лексемы (в данной программе token_value), и (если
необходимо) значения лексемы. У нас имеется только одна простая
переменная curr_tok, поэтому для хранения значения последнего
считанного NUMBER понадобилась глобальная переменная number_value.
Это работает только потому, что калькулятор при вычислениях
использует только одно число перед чтением со входа другого.
Так же, как значение последнего встреченного NUMBER хранится в
number_value, в name_string в виде символьной строки хранится
представление последнего прочитанного NAME. Перед тем, как что-либо
сделать с именем, калькулятор должен заглянуть вперед, чтобы
посмотреть, осуществляется ли присваивание ему, или оно просто
используется. В обоих случаях надо справиться в таблице имен. Сама
таблица описывается в #3.1.3; здесь надо знать только, что она
состоит из элементов вида:
srtuct name {
char* string;
char* next;
double value;
}
где next используется только функциями, которые поддерживают работу
с таблицей:
name* look(char*);
name* insert(char*);
Обе возвращают указатель на name, соответствующее параметру -
символьной строке; look() выражает недовольство, если имя не было
определено. Это значит, что в калькуляторе можно использовать имя
без предварительного описания, но первый раз оно должно
использоваться в левой части присваивания.
switch (ch) {
case ';':
case '\n':
cin >> WS; // пропустить пропуск
return curr_tok=PRINT;
Пропуск пустого места делать необязательно, но он позволяет
избежать повторных обращений к get_token(). WS - это стандартный
пропусковый объект, описанный в ; он используется только
для сброса пропуска. Ошибка во вводе или конец ввода не будут
обнаружены до следующего обращения к get_token(). Обратите внимание
на то, как можно использовать несколько меток case (случаев) для
одной и той же последовательности операторов, обрабатывающих эти
случаи. В обоих случаях возвращается лексема PRINT и помещается в
curr_tok.
Числа обрабатываются так:
case '0': case '1': case '2': case '3': case '4':
case '5': case '6': case '7': case '8': case '9':
case '.':
cin.putback(ch);
cin >> number_value;
return curr_tok=NUMBER;
Располагать метки случаев case горизонтально, а не вертикально,
не очень хорошая мысль, поскольку читать это гораздо труднее, но
отводить по одной строке на каждую цифру нудно.
Поскольку операция >> определена также и для чтения констант с
плавающей точкой в double, программирование этого не составляет
труда: сперва начальный символ (цифра или точка) помещается обратно
в cin, а затем можно считывать константу в number_value.
Имя, то есть лексема NAME, определяется как буква, за которой
возможно следует несколько букв или цифр:
if (isalpha(ch)) {
char* p = name_string;
*p++ = ch;
while (cin.get(ch) && isalnum(ch)) *p++ = ch;
cin.putback(ch);
*p = 0;
return curr_tok=NAME;
}
Эта часть строит в name_string строку, заканчивающуюся нулем.
Функции isalpha() и isalnum() заданы в ; isalnum(c) не
ноль, если c буква или цифра, ноль в противном случае.
Вот, наконец, функция ввода полностью:
token_value get_token()
{
char ch;
do { // пропускает пропуски за исключением '\n'
if(!cin.get(ch)) return curr_tok = END;
} while (ch!='\n' && isspace(ch));
switch (ch) {
case ';':
case '\n':
cin >> WS; // пропустить пропуск
return curr_tok=PRINT;
case '*':
case '/':
case '+':
case '-':
case '(':
case ')':
case '=':
return curr_tok=ch;
case '0': case '1': case '2': case '3': case '4':
case '5': case '6': case '7': case '8': case '9':
case '.':
cin.putback(ch);
cin >> number_value;
return curr_tok=NUMBER;
default: // NAME, NAME= или ошибка
if (isalpha(ch)) {
char* p = name_string;
*p++ = ch;
while (cin.get(ch) && isalnum(ch)) *p++ = ch;
cin.putback(ch);
*p = 0;
return curr_tok=NAME;
}
error("плохая лексема");
return curr_tok=PRINT;
}
}
Поскольку token_value (значение лексемы) операции было
определено как целое значение этой операции *4, обработка всех операций тривиальна.
int main(int argc, char* argv[])
{
switch(argc) {
case 1: // читать из стандартного ввода
break;
case 2: // читать параметр строку
cin = *new istream(strlen(argv[1]),argv[1]);
break;
default:
error("слишком много параметров");
return 1;
}
// как раньше
}
Программа осталась без изменений, за исключением добавления в
main() параметров и использования этих параметров в операторе
switch. Можно было бы легко модифицировать main() так, чтобы она
получала несколько параметров командной строки, но это оказывается
ненужным, особенно потому, что несколько выражений можно передавать
как один параметр:
dc "rate=1.1934;150/rate;19.75/rate;217/rate"
Здесь кавычки необходимы, поскольку ; является разделителем
команд в системе UNIX.
Операции C++ подробно и систематически описываются в #с.7;
прочитайте, пожалуйста, этот раздел. Здесь же приводится краткая
сводка и некоторые примеры. После каждой операции приведено одно
или более ее общеупотребительных названий и пример ее
использования. В этих примерах имя_класса - это имя класса, член -
имя члена, объект - выражение, дающее в результате объект класса,
указатель - выражение, дающее в результате указатель, выр -
выражение, а lvalue - выражение, денотирующее неконстантный объект.
Тип может быть совершенно произвольным именем типа (со *, () и
т.п.) только когда он стоит в скобках, во всех остальных случаях
существуют ограничения.
Унарные операции и операции присваивания правоассоциативны, все
остальные левоассоциативны. Это значит, что a=b=c означает a=(b=c),
a+b+c означает (a+b)+c, и *p++ означает *(p++), а не (*p)++.
Сводка Операций (часть 1)
:: разрешение области видимости имя_класса :: член
:: глобальное :: имя
->
выбор члена
указатель->член
[]
индексация
указатель [ выр ]
()
вызов функции
выр (список_выр)
()
построение значения
тип (список_выр)
sizeof
размер объекта
sizeof выр
sizeof
размер типа
sizeof ( тип )
++
приращение после
lvalue++
++
приращение до
++lvalue
--
уменьшение после
lvalue--
--
уменьшение до
--lvalue
~
дополнение
~ выр
!
не
! выр
-
унарный минус
- выр
+
унарный плюс
+ выр
&
адрес объекта
& lvalue
*
разыменование
* выр
new
создание (размещение)
new тип
delete
уничтожение (освобождение)
delete указатель
delete[]
уничтожение вектора
delete[ выр ]
указатель
()
приведение (преобразование типа)
( тип ) выр
*
умножение
выр * выр
/
деление
выр / выр
%
взятие по модулю (остаток)
выр % выр
+
сложение (плюс)
выр + выр
-
вычитание (минус)
выр - выр
В каждой отчерченной части находятся операции с одинаковым
приоритетом. Операция имеет приоритет больше, чем операции из
частей, расположенных ниже. Например: a+b*c означает a+(b*c), так
как * имеет приоритет выше, чем +, а a+b-c означает (a+b)-c,
поскольку + и - имеют одинаковый приоритет (и поскольку +
левоассоциативен).
f1(v[i],i++); // два параметра
f2( (v[i],i++) ) // один параметр
В вызове f1 два параметра, v[i] и i++, и порядок вычисления
выражений-параметров не определен. Зависимость выражения-параметра
от порядка вычисления - это очень плохой стиль, а также
непереносимо. В вызове f2 один параметр, выражение с запятой,
которое эквивалентно i++.
С помощью скобок нельзя задать порядок вычисления. Например,
a*(b/c) может вычисляться и как (a*b)/c, поскольку * и / имеют
одинаковый приоритет. В тех случаях, когда важен порядок
вычисления, можно вводить дополнительную переменную, например,
(t=b/c,a*t).
Операция ++ используется для явного выражения приращения вместо
его неявного выражения с помощью комбинации сложения и
присваивания. По определению ++lvalue означает lvalue+=1, что в
свою очередь означает lvalue=lvalue+1 при условии, что lvalue не
вызывает никаких побочных эффектов. Выражение, обозначающее
(денотирующее) объект, который должен быть увеличен, вычисляется
один раз (только). Аналогично, уменьшение выражается операцией --.
Операции ++ и -- могут применяться и как префиксные, и как
постфиксные. Значением ++x является новое (то есть увеличенное)
значение x. Например, y=++x эквивалентно y=(x+=1). Значение x++,
напротив, есть старое значение x. Например, y=x++ эквивалентно
y=(t=x,x+=1,t), где t - переменная того же типа, что и x.
Операции приращения особенно полезны для увеличения и уменьшения
переменных в циклах. Например, оканчивающуюся нулем строку можно
копировать так:
Напомню, что увеличение и уменьшение указателей, так же как
сложение и вычитание указателей, осуществляется в терминах
элементов вектора, на которые указывает указатель; p++ приводит к
тому, что p указывает на следующий элемент. Для указателя p типа T*
по определению выполняется следующее:
unsigned short middle(int a) { return (a>>8)&0xffff; }
Не путайте побитовые логические операции с логическими
операциями:
&& || !
Последние возвращают 0 или 1, и они главным образом используются
для записи проверки в операторах if, while или for (#3.3.1).
Например, !0 (не ноль) есть значение 1, тогда как ~0 (дополнение
нуля) есть набор битов все-единицы, который обычно является
значением -1.
int i = 1;
char* pc = "asdf";
int* pi = &i;
i = (int)pc;
pc = (char*)i; // остерегайтесь: значение pc может измениться
// на некоторых машинах
// sizeof(int)
Именованный объект является либо статическим, либо автоматическим
см. #2.1.3). Статический объект размещается во время запуска программы и существует в течение всего выполнения программы.
Автоматический объект размещается каждый раз при входе в его блок и
существует только до тех пор, пока из этого блока не вышли. Однако
часто бывает полезно создать новый объект, существующий до тех пор,
пока он не станет больше не нужен. В частности, часто полезно
создать объект, который можно использовать после возврата из
функции, где он создается. Такие объекты создает операция new, а
в последствие уничтожать их можно операцией delete. Про объекты,
выделенные с помощью операции new, говорят, что они в свободной
памяти. Такими объектами обычно являются вершины деревьев или
элементы связанных списков, являющиеся частью большей структуры
данных, размер которой не может быть известен на стадии компиляции.
Рассмотрим, как можно было бы написать компилятор в духе
написанного настольного калькулятора. Функции синтаксического
анализа могут строить древовидное представление выражений, которое
будет использоваться при генерации кода. Например:
struct enode {
token_value oper;
enode* left;
enode* right;
};
enode* expr()
{
enode* left = term();
for(;;)
switch(curr_tok) {
case PLUS:
case MINUS:
get_token();
enode* n = new enode;
n->oper = curr_tok;
n->left = left;
n->right = term();
left = n;
break;
default:
return left;
}
}
Получающееся дерево генератор кода может использовать например так:
void generate(enode* n)
{
switch (n->oper) {
case PLUS:
// делает нечто соответствующее
delete n;
}
}
Объект, созданный с помощью new, существует, пока он не будет
явно уничтожен delete, после чего пространство, которое он
занимал, опять может использоваться new. Никакого "сборщика
мусора", который ищет объекты, на которые нет ссылок, и
предоставляет их в распоряжение new, нет. Операция delete может
применяться только к указателю, который был возвращен операцией new,
или к нулю. Применение delete к нулю не вызывает никаких действий.
С помощью new можно также создавать вектора объектов. Например:
char* save_string(char* p)
{
char* s = new char[strlen(p)+1];
strcpy(s,p);
return s;
}
Следует заметить, что чтобы освободить пространство, выделенное
new, delete должна иметь возможность определить размер выделенного
объекта. Например:
int main(int argc, char* argv[])
{
if (argc < 2) exit(1);
char* p = save_string(argv[1]);
delete p;
}
Это приводит к тому, что объект, выделенный стандартной реализацией
new, будет занимать больше места, чем статический объект (обычно,
больше на одно слово).
Можно также явно указывать размер вектора в операции уничтожения
delete. Например:
int main(int argc, char* argv[])
{
if (argc < 2) exit(1);
int size = strlen(argv[1])+1;
char* p = save_string(argv[1]);
delete[size] p;
}
Заданный пользователем размер вектора игнорируется за исключением
некоторых типов, определяемых пользователем (#5.5.5).
Операции свободной памяти реализуются функциями (#с.7.2.3):
Стандартная реализация new не инициализирует возвращаемый объект.
Что происходит, когда new не находит памяти для выделения?
Поскольку даже виртуальная память конечна, это иногда должно
происходить. Запрос вроде
char* p = new char[100000000];
как правило, приводит к каким-то неприятностям. Когда у new ничего
не получается, она вызывает функцию, указываемую указателем
_new_handler (указатели на функции обсуждаются в #4.6.9). Вы можете задать указатель явно или использовать функцию set_new_handler().
Например:
#include
void out_of_store()
{
cerr << "операция new не прошла: за пределами памяти\n";
exit(1);
}
typedef void (*PF)(); // тип указатель на функцию
extern PF set_new_handler(PF);
main()
{
set_new_handler(out_of_store);
char* p = new char[100000000];
cout << "сделано, p = " << long(p) << "\n";
}
как правило, не будет писать "сделано", а будет вместо этого
выдавать
операция new не прошла: за пределами памяти
_new_handler может делать и кое-что поумнее, чем просто завершать
выполнение программы. Если вы знаете, как работают new и delete,
например, потому, что вы задали свои собственные operator new() и
operator delete(), программа обработки может попытаться найти
некоторое количество памяти, которое возвратит new. Другими
словами, пользователь может сделать сборщик мусора, сделав, таким
образом, использование delete необязательным. Но это, конечно, все-
таки задача не для начинающего.
По историческим причинам new просто возвращает указатель 0, если
она не может найти достаточное количество памяти и не был задан
никакой _new_handler. Например
include
main()
{
char* p = new char[100000000];
cout << "сделано, p = " << long(p) << "\n";
}
выдаст
сделано, p = 0
Вам сделали предупреждение! Заметьте, что тот, кто задает
_new_handler, берет на себя заботу по проверке истощения памяти при
каждом использовании new в программе (за исключением случая, когда
пользователь задал отдельные подпрограммы для размещения объектов
заданных типов, определяемых пользователем; см. #5.5.6).
Операторы C++ систематически и полностью изложены в #с.9,
прочитайте, пожалуйста, этот раздел. А здесь приводится краткая
сводка и некоторые примеры.
Синтаксис оператора
оператор:
описание
{список_операторов opt}
выражение opt
if ( выражение ) опреатор
if ( выражение ) оператор else оператор
switch ( выражение ) оператор
while ( выражение ) оператор
do оператор while (выражение)
for ( оператор выражение opt ; выражение opt ) оператор
case константное_выражение : оператор
default : оператор
break ;
continue ;
return выражение opt ;
goto идентификатор ;
идентификатор : оператор
список_операторов:
оператор
оператор список_операторов
Заметьте, что описание является оператором, и что нет операторов
присваивания и вызова процедуры. Присваивание и вызов функции
обрабатываются как выражения.
a = b+c; // a становится b+c
count++; // увеличить счетчик
Такие комментарии хуже чем просто излишни, они увеличивают объем
текса, который надо прочитать, они часто затуманивают структуру
программы, и они могут быть неправильными.
Автор предпочитает:
[1] Комментарий для каждого исходного файла, сообщающий, для чего
в целом предназначены находящиеся в нем комментарии, дающий
ссылки на справочники и руководства, общие рекомендации по
использованию и т.д.;
[2] Комментарий для каждой нетривиальной функции, в котором
сформулировано ее назначение, используемый алгоритм (если он
неочевиден) и, быть может, что-то о принимаемых в ней
предположениях относительно среды выполнения;
[3] Небольшое число комментариев в тех местах, где программа
неочевидна и/или непереносима; и
[4] Очень мало что еще.
Например:
// tbl.c: Реализация таблицы имен
/*
Гауссовское исключение с частичным
См. Ralston: "A first course ..." стр. 411.
*/
// swap() предполагает размещение стека AT&T sB20.
/**************************************
Copyright (c) 1984 AT&T, Inc.
All rights reserved
****************************************/
Удачно подобранные и хорошо написанные комментарии - существенная
часть программы. Написание хороших комментариев может быть столь же
сложным, сколь и написание самой программы.
Заметьте также, что если в функции используются исключительно
комментарии //, то любую часть этой функции можно закомментировать
с помощью комментариев /* */, и наоборот.
(*2) Добавьте в настольный калькулятор из #3.1 такие
функции, как sqrt(), log() и sin(). Подсказка: предопределите
имена и вызывайте функции с помощью вектора указателей на
функции. Не забывайте проверять параметры в вызове функции.
(*3) Дайте пользователю возможность определять функции в
настольном калькуляторе. Подсказка: определяйте функции как
последовательность действий, прямо так, как их набрал
пользователь. Такую последовательность можно хранить или как
символьную строку, или как список лексем. После этого, когда
функция вызывается, читайте и выполняйте эти действия. Если вы
хотите, чтобы пользовательская функция получала параметры, вы
должны придумать форму записи этого.
(*1.5) Преобразуйте настольный калькулятор так, чтобы вместо
статических переменных name_string и number_value
использовалась структура символа symbol:
struct symbol {
token_value tok;
union {
double number_value;
char* name_string;
};
};
(*2.5) Напишите программу, которая выбрасывает комментарии из
C++ программы. То есть, читает из cin, удаляет // и /* */
комментарии и пишет результат в cout. Не заботьтесь о приятном
виде выходного текста (это могло бы быть другим, более сложным
упражнением). Не беспокойтесь о правильности программ.
Остерегайтесь // и /* и */ внутри комментариев, строк и
символьных констант.
(*2) Посмотрите какие-нибудь программы, чтобы понять принцип
различных стилей комментирования и выравнивания, которые
используются на практике.