Программирование — страница 23 из 57

“Цыплят по осени считают”.

Поговорка


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

7.1. Введение

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

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

7.2. Ввод и вывод

В начале главы 6 мы решили, что приглашение пользователю ввести данные должно выглядеть следующим образом:


Выражение:


Кроме того, вывод результатов предварялся словом

Результат:
.


Результат:


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

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

На каком основании? Простые размышления тут вряд ли помогут. Мы должны проверить разные варианты и выбрать лучший.

В текущей версии при вычислении выражения


2+3; 5*7; 2+9;


программа выводит следующие результаты:


= 5

= 35

= 11


Если добавить слова

Выражение:
и
Результат:
, получим следующее:


Выражение: 2+3; 5*7; 2+9;

Результат: 5

Выражение: Результат: 35

Выражение: Результат: 11

Выражение:


Мы уверены, что кому-то нравится один стиль, а кому-то — другой. В таких ситуациях мы можем предоставить пользователям выбор, но для данной простой задачи это было бы излишне, поэтому мы должны принять волевое решение. По нашему мнению, слова Выражение: и Результат: слишком загромождают экран и сбивают с толку. Из-за них сами выражения и результаты занимают лишь небольшую часть экрана, а ведь именно они являются предметом нашего внимания, поэтому ничто не должно нас отвлекать от них. С другой стороны, если каким-то образом не отделить входную информацию, которую печатает пользователь, и результаты, вычисленные компьютером, получится путаница. Во время первоначальной отладки для индикации результата мы использовали символ =, а для короткого приглашения — символ >, который часто используется для этой цели.


> 2+3;

= 5

> 5*7;

= 35

>


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

main()
.


double val = 0;

while (cin) {

  cout << "> ";                    // приглашение к вводу

  Token t = ts.get();

  if (t.kind == 'q') break;

    if (t.kind == ';')

      cout << "= " << val << '\n'; // вывод результатов

    else

      ts.putback(t);

  val = expression();

}


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


> 2+3; 5*7; 2+9;

= 5

> = 35

> = 11

>


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


> 2+3; 5*7; 2+9;

= 5

= 35

= 11

>


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

main()
. Существует ли способ выводить символ
>
тогда и только тогда, когда он не следует за символом
=
немедленно? Неизвестно! Мы должны вывести символ
>
до вызова функции
get()
, но мы не знаем, действительно ли функция
get()
считывает новые символы или просто возвращает объект класса
Token
, созданный из символов, уже считанных с клавиатуры. Иначе говоря, для того чтобы внести это улучшение, нам придется переделать поток
Token_stream
.

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

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

7.3. Обработка ошибок

Первое, что необходимо сделать, получив в принципе работающую программу,— попытаться “сломать” ее, т.е. ввести входные данные, надеясь вызвать неправильную работу программы. Мы говорим “надеясь”, потому что основная задача на этом этапе — найти как можно больше ошибок, чтобы исправить их до того, как их обнаружит кто-то другой. Если вы приступите к проверке с убеждением: “Моя программа работает, и я не делаю никаких ошибок!”, то не сможете найти многих ошибок и будете очень огорчены, если все же обнаружите их. Вы должны подвергать сомнению то, что делаете! Правильная позиция формулируется так: “Я “сломаю” ее! Я умнее, чем любая программа, даже моя собственная!” Итак, введем в калькулятор мешанину правильных и неправильных выражений. Рассмотрим пример.


1+2+3+4+5+6+7+8

1–2–3–4

!+2

;;;

(1+3;

(1+);

1*2/3%4+5–6;

();

1+;

+1

1++;

1/0

1/0;

1++2;

–2;

–2;;;;

1234567890123456;

'a';

q

1+q

1+2; q 


ПОПРОБУЙТЕ

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


Формально говоря, этот процесс называется тестированием (testing). Существуют даже люди, занимающиеся испытанием программ профессионально. Тестирование — очень важная часть разработки программного обеспечения. Оно может быть весьма увлекательным занятием. Более подробно тестирование рассматривается в главе 26. Есть один большой вопрос: “Существует ли способ систематического тестирования программ, позволяющий найти все ошибки?” Универсального ответа на этот вопрос, т.е. ответа, который относился бы ко всем программам, нет. Однако, если отнестись к тестированию серьезно, можно неплохо протестировать многие программы. Пытаясь систематически тестировать программы, не стоит забывать, что выбор тестов не бывает полным, поэтому следует использовать и так называемые “странные” тесты, такие как следующий:


Mary had a little lamb

srtvrqtiewcbet7rewaewre–wqcntrretewru754389652743nvcqnwq;

!@#$%^&*()~:;


 Тестируя компиляторы, я привык подавать на вход компилятора электронные отчеты о его собственных сообщениях — заголовки писем, объяснения пользователей и все остальное. Это было неразумно, поскольку этого никто никогда не делал. Однако программа идеально кэшировала все ошибки, а не только разумные, и вскоре компилятор стал очень устойчивым к странному вводу.

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


+1;

()

!+2


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

Справиться с этой ошибкой можно, модифицировав функцию

main()
(см. раздел 5.6.3).


catch (runtime_error& e) {

  cerr << e.what() << endl;

  // keep_window_open():

  cout << "Чтобы закрыть окно, введите символ ~\n";

  char ch;

  while(cin >> ch) // продолжает чтение после ввода символа ~

    if (ch=='~') return 1;

  return 1;

}


По существу, мы заменили функцию

keep_window_open()
своим собственным кодом. Обратите внимание на то, что проблема останется нерешенной, если символ окажется следующим после возникновения ошибки, но это маловероятно.

Обнаружив эту проблему, мы написали вариант функции

keep_window_open()
, аргументом которой была строка, закрывающая окно, как только пользователь вводил ее после приглашения. Таким образом, более простое решение выглядит так:


catch (runtime_error& e) {

  cerr << e.what() << endl;

  keep_window_open("~~");

  return 1;

}


Рассмотрим еще один пример.


+1

!1~~

()


Эти данные вынуждают калькулятор выдавать соответствующие сообщения об ошибках, например


Чтобы выйти, введите ~~


и не прекращать работу, пока пользователь не введет строку

~~
.

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

cin
считывать данные из файла без модификации программы, а потоку
cout
— направлять данные в файл. В других случаях мы должны модифицировать программу так, чтобы она использовала файл (подробнее об этом — в главе 10).

Рассмотрим примеры.


1+2; q

1+2 q


Мы хотели бы вывести результат (

3
) и выйти из программы. Забавно, что строка


1+2 q


приводит к этому результату, а более очевидная строка


1+2; q


вызывает ошибку Ожидается первичное выражение. Где следует искать эту ошибку? Конечно, в функции

main()
, где обрабатываются символы ; и q. Мы добавили инструкции “печать” и “выход” просто для того, чтобы поскорее получить работающий вариант калькулятора (см. раздел 6.6), а теперь расплачиваемся за эту поспешность. Рассмотрим еще раз следующий фрагмент:


double val = 0;

while (cin) {

  cout << "> ";

  Token t = ts.get();

  if (t.kind == 'q') break;

  if (t.kind == ';')

    cout << "= " << val << '\n';

  else

    ts.putback(t);

 val = expression();

}


Если обнаруживаем точку с запятой, то вызываем функцию

expression()
, не проверяя символ
q
. Эта функция в первую очередь ищет вызов функции
term()
, которая вызывает функцию
primary()
, обнаруживающую символ q. Буква q не является первичным выражением, поэтому получаем сообщение об ошибке. Итак, после тестирования точки с запятой мы должны обработать символ q. В этот момент мы почувствовали необходимость несколько упростить логику, поэтому окончательный вариант функции
main()
выглядит так:


int main()

try

{

  while (cin) {

    cout << "> ";

    Token t = ts.get();

    while (t.kind == ';') t=ts.get(); // считываем ';'

    if (t.kind == 'q') {

      keep_window_open();

      return 0;

    }

    ts.putback(t);

    cout << "= " << expression() << endl;

  }

  keep_window_open();

  return 0;

}

catch (exception& e) {

  cerr << e.what() << endl;

  keep_window_open("~~");

  return 1;

}

catch (...) {

  cerr << "exception \n";

  keep_window_open("~~");

  return 2;

}


Это повышает надежность обработки ошибок. Таким образом, теперь можно искать новые пути улучшения калькулятора.

7.4. Отрицательные числа

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


–1/2


является ошибочным.

Для того чтобы калькулятор работал корректно, мы должны были бы написать


(0–1)/2


Однако это неприемлемо.

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

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


Первичное выражение:

  Число

  "("Выражение")"


Нам требуется, чтобы этот пункт выглядел примерно таким образом:


Первичное выражение:

  Число

  "("Выражение")"

  "–" Первичное выражение

  "+" Первичное выражение


Мы добавили унарный плюс, поскольку он есть в языке С++. Если есть унарный минус, то легче реализовать унарный плюс, чем объяснить его бесполезность. Код, реализующий Первичное выражение, принимает следующий вид:


double primary()

{

  Token t = ts.get();

  switch (t.kind) {

  case '(': // обработка пункта '(' выражение ')'

  {

    double d = expression();

    t = ts.get();

    if (t.kind != ')') error("')' expected");

    return d;

  }

  case '8':         // символ '8' используется для представления числа

    return t.value; // возвращаем число

  case '–':

    return – primary();

  case '+':

    return primary();

  default:

    error("ожидается первичное выражение");

  }

}


Этот код настолько прост, что работает с первого раза.

7.5. Остаток от деления: %

Обдумывая проект калькулятора, мы хотели, чтобы он вычислял остаток от деления — оператор

%
. Однако этот оператор не определен для чисел с плавающей точкой, поэтому мы отказались от этой идеи. Настало время вернуться к ней снова.

Это должно быть простым делом.

1. Добавляем символ % как Token.

2. Преобразовываем число типа

double
в тип
int
, чтобы впоследствии применить к нему оператор
%
.


Вот как изменится код функции

term()
:


case '%':

  { double d = primary();

    int i1 = int(left);

    int i2 = int(d);

    return i1%i2;

  }


Для преобразования чисел типа

double
в числа типа
int
проще всего использовать явное выражение
int(d)
, т.е. отбросить дробную часть числа. Несмотря на то что это избыточно (см. раздел 3.9.2), мы предпочитаем явно указать, что знаем о произошедшем преобразовании, т.е. избегаем непреднамеренного или неявного преобразования чисел типа
double
в числа типа
int
. Теперь получим правильные результаты для целочисленных операндов. Рассмотрим пример.


> 2%3;

= 0

> 3%2;

= 1

> 5%3;

= 2


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


> 6.7%3.3;


Это выражение не имеет корректного результата, поэтому запрещаем применение оператора

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

Вот как выглядит результат функции

term()
:


double term()

{

  double left = primary();

  Token t = ts.get(); // получаем следующую лексему

                      // из потока Token_stream

  while(true) {

    switch (t.kind) {

    case '*':

      left *= primary();

      t = ts.get();

      break;

    case '/':

      { double d = primary();

      if (d == 0) error("Деление на нуль");

      left /= d;

      t = ts.get();

      break;

    }

    case '%':

      { double d = primary();

        int i1 = int(left);

        if (i1 != left)

          error ("Левый операнд % не целое число");

        int i2 = int(d);

        if (i2 != d) error ("Правый операнд % не целое число");

        if (i2 == 0) error("%: деление на нуль");

        left = i1%i2;

        t = ts.get();

        break;

    }

    default:

      ts.putback(t); // возвращаем t обратно в поток

                     // Token_stream

      return left;

    }

  }

}


Здесь мы лишь проверяем, изменилось ли число при преобразовании типа

double
в тип
int
. Если нет, то можно применять оператор
%
. Проблема проверки целочисленных операндов перед использованием оператора
%
— это вариант проблемы сужения (см. разделы 3.9.2 и 5.6.4), поэтому ее можно решить с помощью оператора
narrow_cast
.


case '%':

  { int i1 = narrow_cast(left);

    int i2 = narrow_cast(term());

    if (i2 == 0) error("%: деление на нуль");

    left = i1%i2;

    t = ts.get();

    break;

  }


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

7.6. Приведение кода в порядок

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

7.6.1. Символические константы

Оглядываясь назад, вспомним, что с помощью символа

'8'
мы решили обозначать объекты класса
Token
, содержащие числовое значение. На самом деле совершенно не важно, какое именно число будет обозначать числовые лексемы, нужно лишь, чтобы оно отличалось от индикаторов других разновидностей лексем. Однако наш код пока выглядит довольно странно, и мы должны вставить в него несколько комментариев.


case '8':         // символ '8' обозначает число

  return t.value; // возвращаем число

case '–':

  return – primary();


 Честно говоря, здесь мы также сделали несколько ошибок, напечатав

'0'
, а не
'8'
, поскольку забыли, какое число выбрали для этой цели. Иначе говоря, использование символа '8' непосредственно в коде, предназначенном для обработки объектов класса
Token
, является непродуманным, трудным для запоминания и уязвимым для ошибок; символ
'8'
представляет собой так называемую “магическую константу”, о которой мы предупреждали в разделе 4.3.1. Теперь необходимо ввести символическое имя константы, которая будет представлять число.


const char number = '8'; // t.kind==number означает, что t — число


Модификатор

const
сообщает компилятору, что мы определили объект, который не будет изменяться: например, выражение
number='0'
должно вызвать сообщение об ошибке. При таком определении переменной number нам больше не нужно использовать символ
'8'
явным образом.

Фрагмент кода функции

primary()
, упомянутый выше, теперь принимает следующий вид:


case number:

  return t.value; // возвращает число

case '–':

  return – primary(); 


 Этот фрагмент не требует комментариев. Совершенно необязательно сообщать в комментариях, что очевидно в самом коде. Повторяющиеся комментарии, объясняющие нечто, часто свидетельствуют о том, что программа требует улучшения. Аналогично, код функции

Token_stream::get()
, распознающий числа, принимает такой вид:


case '.':

case '0': case '1': case '2': case '3': case '4':

case '5': case '6': case '7': case '8': case '9':

  { cin.putback(ch); // вернуть цифру в поток ввода

    double val;

    cin >> val;      // считать число с плавающей точкой

    return Token(number,val);

  }


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

'('
и
'+'
самоочевидны. Анализируя лексемы, легко понять, что лишь символы
';'
для инструкции “печать” (или “конец выражения”) и
'q'
для инструкции “выход” выбраны произвольным образом. А почему не
'p'
или
'e'
? В более крупной программе такая малопонятная и произвольная система обозначения рано или поздно вызвала бы проблемы, поэтому введем следующие переменные:


const char quit = 'q';  // t.kind==quit значит, что лексема t —

                        // код выхода

const char print = ';'; // t.kind==print значит, что лексема t — 

                        // код печати


Теперь цикл в функции

main()
можно переписать так:


while (cin) {

  cout << "> ";

  Token t = ts.get();

  while (t.kind == print) t=ts.get();

  if (t.kind == quit) {

    keep_window_open();

    return 0;

  }

  ts.putback(t);

  cout << "= " << expression() << endl;

}


Введя символические имена для инструкции “печать” и “выход”, мы сделали код понятнее. Кроме того, теперь тот, кто будет читать текст функции

main()
, не будет гадать, как кодируются эти инструкции. Например, не удивительно, если мы решим изменить представление инструкции “выход” на символ
'e'
(от слова “exit”). Для этого не требуется вносить изменения в функцию
main()
. Теперь в глаза бросаются строки "
>
" и "
". Почему мы используем эти “магические” литералы в своей программе? Как новый программист, читающий текст функции
main()
, сможет догадаться об их предназначении? Может быть, стоит добавить комментарий? Это может оказаться удачной идеей, но использование символического имени более эффективно.


const string prompt = "> ";

const string result = "= "; // используется для указания на то, что

                            // далее следует результат


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


while (cin) {

  cout << prompt;

  Token t = ts.get();

  while (t.kind ==print) t=ts.get();

  if (t.kind == quit) {

    keep_window_open();

    return 0;

  }

  ts.putback(t);

  cout << result << expression() << endl;

}

7.6.2. Использование функций

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

expression()
,
term()
и
primary()
непосредственно отражают наше понимание грамматики, а функция
get()
выполняет ввод и распознавание лексем. Тем не менее анализ функции
main()
показывает, что ее можно разделить на две логически разные части.

1. Функция

main()
описывает общую логическую структуру: начало программы, конец программы и обработку фатальных ошибок.

2. Функция

main()
выполняет цикл вычислений.


 Теоретически любая функция выполняет отдельное логическое действие (см. раздел 4.5.1). Если функция

main()
выполняет оба эти действия, то это затемняет структуру программы. Напрашивается выделение цикла вычислений в виде отдельной функции
calculate()
.


void calculate() // цикл вычисления выражения

{

  while (cin) {

    cout << prompt;

    Token t = ts.get();

    while (t.kind == print) t=ts.get(); // отмена печати

    if (t.kind == quit) return;

    ts.putback(t);

    cout << result << expression() << endl;

  }

}


int main()

try {

  calculate();

  keep_window_open(); // обеспечивает консольный режим Windows

  return 0;

}

catch (runtime_error& e) {

  cerr << e.what() << endl;

  keep_window_open("~~");

  return 1;

}

catch (...) {

  cerr << "exception \n";

  keep_window_open("~~");

  return 2;

}


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

7.6.3. Расположение кода

Поиск некрасивого кода приводит нас к следующему фрагменту:


switch (ch) {

case 'q': case ';': case '%': case '(': case ')':

case '+': case '–': case '*': case '/':

  return Token(ch); // пусть каждый символ обозначает сам себя


Этот код был неплох, пока мы не добавили символы

'q'
,
';'
и
'%'
, но теперь он стал непонятным. Код, который трудно читать, часто скрывает ошибки. И конечно, они есть в этом фрагменте! Для их выявления необходимо разместить каждый раздел
case
в отдельной строке и расставить комментарии. Итак, функция
Token_stream::get()
принимает следующий вид:


Token Token_stream::get()

  // считываем символ из потока cin и образуем лексему

{

  if (full) { // проверяем, есть ли в потоке хотя бы одна лексема

    full=false;

    return buffer;

  }

  char ch;

  cin >> ch; // Перевод:" оператор >> игнорирует разделители пробелы,

             // переходы на новую строку, табуляцию и пр.)"

  switch (ch) {

  case quit:

  case print:

  case '(':

  case ')':

  case '+':

  case '–':

  case '*':

  case '/':

  case '%':

    return Token(ch); // пусть каждый символ обозначает сам себя

  case '.': // литерал с плавающей точкой может начинаться с точки

  case '0': case '1': case '2': case '3': case '4':

  case '5': case '6': case '7': case '8': case '9': // числовой

                                                    // литерал

  { cin.putback(ch); // возвращаем цифру обратно во входной

                     // поток

    double val;

    cin >> val; // считываем число с плавающей точкой

    return Token(number,val);

  }

  default:

    error("Неправильная лексема");

  }

}


Разумеется, можно было бы поместить в отдельной строке раздел

case
для каждой цифры, но это нисколько не прояснит программу. Кроме того, в этом случае функция
get()
вообще осталась бы за пределами экрана. В идеале на экране должны поместиться все функции; очевидно, что ошибку легче скрыть в коде, который находится за пределами экрана. Расположение кода имеет важное значение. Кроме того, обратите внимание на то, что мы заменили простой символ
'q'
символическим именем
quit
. Это повышает читабельность кода и гарантирует появление сообщения компилятора при попытке выбрать для имени
quit
значение, уже связанное с другим именем лексемы.

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

7.6.4. Комментарии

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

1. Корректность (вы могли изменить код, оставив старый комментарий).

2. Адекватность (редкое качество).

3. Немногословность (чтобы не отпугнуть читателя).


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


x = b+c; // складываем переменные b и c и присваиваем результат

         // переменной x


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


/*

Простой калькулятор

История версий:

Переработан Бьярне Страуструпом в мае 2007 г.

Переработан Бьярне Страуструпом в августе 2006 г.

Переработан Бьярне Страуструпом в августе 2004 г.

Разработан Бьярне Страуструпом

 (bs@cs.tamu.edu) весной 2004 г.


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

Ввод из потока с in; вывод в поток cout.


Грамматика для ввода:


Инструкция:

  Выражение

  Печать

  Выход


Печать:

  ;


Выход:

  q


Выражение:

  Терм

  Выражение + Терм

  Выражение – Терм

Терм:

  Первичное выражение

  Терм * Первичное выражение

Терм / Первичное выражение

  Терм % Первичное выражение

Первичное выражение:

  Число

  (Выражение)

  – Первичное выражение

  + Первичное выражение

Число:

  литерал_с_плавающей_точкой


Ввод из потока cin через поток Token_stream с именем ts.

*/


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

/*
и заканчивается символами
*/
. В реальной программе история пересмотра может содержать сведения о том, какие именно изменения были внесены и какие улучшения были сделаны. Обратите внимание на то, что эти комментарии помещены за пределами кода. Фактически это несколько упрощенная грамматика: сравните правило для Инструкции с тем, что на самом деле происходит в программе (например, взгляните на код в следующем разделе). Этот комментарий ничего не говорит от цикле в функции
calculate()
, позволяющем выполнять несколько вычислений в рамках одного сеанса работы программы. Мы вернемся к этой проблеме в разделе 7.8.1.

7.7. Исправление ошибок

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

До сих пор все ошибки представлялись в виде исключений и обрабатывались функцией

main()
. Если мы хотим исправить ошибку, то функция
calculate()
должна перехватывать исключения и попытаться устранить неисправность прежде, чем приступить к вычислению следующего выражения.


void calculate()

{

  while (cin)

  try {

    cout << prompt;

    Token t = ts.get();

    while (t.kind == print) t=ts.get(); // сначала

                                        // игнорируем все

                                        // 
инструкции 
"печать"

    if (t.kind == quit) return;

    ts.putback(t);

    cout << result << expression() << endl;

  }

  catch (exception& e) {

    cerr << e.what() << endl; // выводим сообщение об ошибке

    clean_up_mess();

  }

}


Мы просто поместили цикл

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

Рассмотрим пример.


1++2*3; 4+5;


Эти выражения вызывают ошибку, и лексемы

2*3; 4+5
останутся в буферах потоков
Token_stream
и
cin
после того, как второй символ
+
породит исключение.

У нас есть две возможности.

1. Удалить все лексемы из потока

Token_stream
.

2. Удалить из потока все лексемы

Token_stream
, связанные с текущими вычислениями.


В первом случае отбрасываем все лексемы (включая

4+5;
), а во втором — отбрасываем только лексему
2*3
, оставляя лексему
4+5
для последующего вычисления. Один выбор является разумным, а второй может удивить пользователя. Обе альтернативы одинаково просто реализуются. Мы предпочли второй вариант, поскольку его проще протестировать. Он выглядит проще. Чтение лексем выполняется функцией
get()
, поэтому можно написать функцию
clean_up_mess()
, имеющую примерно такой вид:


void clean_up_mess() // наивно

{

  while (true) { // пропускаем,

                 // пока не обнаружим инструкцию "печать"

    Token t = ts.get();

    if (t.kind == print) return;

  }

}


К сожалению, эта функция не всегда работает хорошо. Почему? Рассмотрим следующий вариант:


1@z; 1+3;


Символ

@
приводит нас к разделу
catch
в цикле
while
. Тогда для выявления следующей точки с запятой вызываем функцию
clean_up_mess()
. Функция
clean_up_mess()
вызывает функцию
get()
и считывает символ
z
. Это порождает следующую ошибку (поскольку символ
z
не является лексемой), и мы снова оказываемся в блоке
catch
внутри функции
main()
и выходим из программы. Ой! У нас теперь нет шансов вычислить лексему
1+3
. Вернитесь к меловой доске!

Можно было бы уточнить содержание блоков

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


class Token_stream {

public:

  Token_stream(); // создает поток Token_stream, считывающий

                  // данные из потока cin

  Token get();    // считывает лексему

  void putback(Token t); // возвращает лексему

  void ignore(char c);   // отбрасывает символы,

                         // предшествующие символу с включительно

private:

  bool full;             // есть лексема в буфере?

  Token buffer; // здесь хранится лексема, которая возвращается

                // назад с помощью функции putback()

};


Функция

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


void Token_stream::ignore(char c)

  // символ c обозначает разновидность лексем

{

  // сначала проверяем буфер:

  if (full && c==buffer.kind) {

    full = false;

    return;

  }

  full = false;

  // теперь проверяем входные данные:

  char ch = 0;

  while (cin>>ch)

    if (ch==c) return;

}


В этом коде сначала происходит проверка буфера. Если в буфере есть символ

c
, прекращаем работу, отбрасывая этот символ
c
; в противном случае необходимо считывать символы из потока
cin
, пока не встретится символ
c
. Теперь функцию
clean_up_mess()
можно написать следующим образом:


void clean_up_mess()

{

  ts.ignore(print);

}


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

7.8. Переменные

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

Аналогично для научных вычислений хотелось бы иметь встроенные имена, такие как

pi
и
e
, как в научных калькуляторах. Переменные и константы — основные новшества, которые мы внесем в калькулятор. Это коснется многих частей кода. Такие действия не следует предпринимать без весомых причин и без достаточного времени на работу. В данном случае мы вносим переменные и константы, поскольку это дает возможность еще раз проанализировать код и освоить новые методы программирования.

7.8.1. Переменные и определения

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

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


class Variable {

public:

  string name;

  double value;

  Variable (string n, double v) :name(n), value(v) { }

};


Член класса name используется для идентификации объекта класса

Variable
, а член
value
— для хранения значения, соответствующего члену
name
. Конструктор добавлен просто для удобства.

Как хранить объекты класса

Variable
так, чтобы их значение можно было найти или изменить по строке
name
? Оглядываясь назад, видим, что на этот вопрос есть только один правильный ответ: в виде вектора объектов класса
Variable
.


vector var_table;


В вектор

var_table
можно записать сколько угодно объектов класса
Variable
, а найти их можно, просматривая элементы вектора один за другим. Теперь можно написать функцию
get_value()
, которая ищет заданную строку
name
и возвращает соответствующее ей значение
value
.


double get_value(string s)

  // возвращает значение переменной с именем s

{

  for (int i = 0; i

  if (var_table[i].name == s) return var_table[i].value;

  error("get: неопределенная переменная", s);

}


Этот код действительно прост: он перебирает объекты класса

Variable
в векторе
var_table
(начиная с первого элемента и продолжая до последнего включительно) и проверяет, совпадает ли их член name c аргументом
s
. Если строки name и
s
совпадают, функция возвращает член
value
соответствующего объекта. Аналогично можно определить функцию
set_value()
, присваивающую новое значение члену
value
объекта класса
Variable
.


void set_value(string s, double d)

  // присваивает объекту класса Variable с именем s значение d

{

  for (int i = 0; i

  if (var_table[i].name == s) {

    var_table[i].value = d;

    return;

  }

  error("set: неопределенная переменная", s);

}


Теперь можем считать и записывать переменные, представленные в виде объектов класса

Variable
в векторе
var_table
. Как поместить новый объект класса
Variable
в вектор
var_table
? Как пользователь калькулятора должен сначала записать переменную, а затем присвоить ей значения? Можно сослаться на обозначения, принятые в языке С++.


double var = 7.2;


Это работает, но все переменные в данном калькулятора и так хранят значения типа

double
, поэтому явно указывать этот тип совершенно не обязательно. Можно было бы написать проще.


var = 7.2;


Что ж, возможно, но теперь мы не можем отличить определение новой переменной от синтаксической ошибки.


var1 = 7.2; // определение новой переменной с именем var1

var1 = 3.2; // определение новой переменной с именем var2


Ой! Очевидно, что мы имели в виду

var2 = 3.2;
но не сказали об этом явно (за исключением комментария). Это не катастрофа, но будем следовать традициям языков программирования, в частности языка С++, в которых объявления переменных с их инициализацией отличаются от присваивания. Мы можем использовать ключевое слово
double
, но для калькулятора нужно что-нибудь покороче, поэтому — следуя другой старой традиции — выбрали ключевое слово
let
.


let var = 7.2;


Грамматика принимает следующий вид:


Вычисление:

  Инструкция

  Печать

  Выход

  Инструкция вычисления


Инструкция:

  Объявление

  Выражение


Объявление:

  "let" Имя "=" Выражение


Вычисление — это новое правило вывода в грамматике. Оно выражает цикл (в функции

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


double statement()

{

  Token t = ts.get();

  switch (t.kind) {

  case let:

    return declaration();

    default:

    ts.putback(t);

    return expression();

  }

}


Вместо функции

expression()
в функции
calculate()
можем использовать функцию
statement()
.


void calculate()

{

  while (cin)

  try {

    cout << prompt;

    Token t = ts.get();

    while (t.kind == print) t=ts.get(); // игнорируем
 "печать"

    if (t.kind == quit) return;         // выход

    ts.putback(t);

    cout << result << statement() << endl;

  }

  catch (exception& e) {

    cerr << e.what() << endl;           // выводим сообщение об ошибке

    clean_up_mess();

  }

}


Теперь необходимо написать функцию

declaration()
. Что следует сделать? Нужно убедиться, что после ключевого слова
let
следует Имя, а за ним — символ = и Выражение. Именно это утверждает грамматика. Что делать с членом
name
? Мы должны добавить в вектор
var_table
типа
vector
объект класса
Variable
c заданными строкой name и значением выражения. После этого мы сможем извлекать значения с помощью функции
get_value()
и изменять их с помощью функции
set_value()
. Однако сначала надо решить, что случится, если мы определим переменную дважды. Рассмотрим пример.


let v1 = 7;

let v1 = 8;


Мы решили, что повторное определение является ошибкой. Обычно это просто синтаксическая ошибка. Вероятно, мы имели в виду не то, что написали, а следующие инструкции:


let v1 = 7;

let v2 = 8;


Определение объекта класса

Variable
с именем
var
и значением
val
состоит из двух логических частей.

1. Проверяем, существует ли в векторе

var_table
объект класса
Variable
с именем
var
.

2. Добавляем пару (

var
,
val
) в вектор
var_table
.


Мы не должны использовать неинициализированные переменные, поэтому определили функции

is_declared()
и
define_name()
, представляющие эти две операции.


bool is_declared(string var)

  // есть ли переменная var в векторе var_table?

{

  for (int i = 0; i

  if (var_table[i].name == var) return true;

  return false;

}

double define_name(string var, double val)

  // добавляем пару (var,val) в вектор var_table

{

  if (is_declared(var)) error(var,"declared twice");

  var_table.push_back(Variable(var,val));

  return val;

}


Добавить новый объект класса

Variable
в вектор типа
vector
легко; эту операцию выполняет функция-член вектора
push_back()
.


var_table.push_back(Variable(var,val));


Вызов конструктора

Variable(var,val)
создает соответствующий объект класса
Variable
, а затем функция
push_back()
добавляет этот объект в конец вектора
var_table
. В этих условиях и с учетом лексем
let
и
name
функция
declaration()
становится вполне очевидной.


double declaration()

  // предполагается, что мы можем выделить ключевое слово "let"

  // обработка: name = выражение

  // объявляется переменная с именем "name" с начальным значением,

  // заданным "выражением"

{

  Token t = ts.get();

  if (t.kind != name) error ("в объявлении ожидается переменная 
name");

  string var_name = t.name;

  Token t2 = ts.get();

  if (t2.kind != '=') error("в объявлении пропущен символ =",

  var_name);

  double d = expression();

  define_name(var_name,d);

  return d;

}


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


let v = d/(t2–t1);


Это объявление определяет переменную

v
и выводит ее значение. Кроме того, печать переменной упрощает код функции
calculate()
, поскольку при каждом вызове функция
statement()
возвращает значение. Как правило, общие правила позволяют сохранить простоту кода, а специальные варианты приводят к усложнениям.

Описанный механизм отслеживания переменных часто называют таблицей символов (symbol tables). Его можно радикально упростить с помощью стандартной библиотеки

map
(см. раздел 21.6.1).

7.8.2. Использование имен

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

'='
, но это легко исправить, добавив дополнительный раздел
case
в функцию
Token_stream::get()
(см. раздел 7.6.3). А как представить ключевые слова
let
и
name
в виде лексем? Очевидно, для того чтобы распознавать эти лексемы, необходимо модифицировать функцию
get()
. Как? Вот один из способов.


const char name = 'a';        // лексема name

const char let = 'L';         // лексема let

const string declkey = "let"; // ключевое слово let


Token Token_stream::get()

{

  if (full) { full=false; return buffer; }

    char ch;

    cin >> ch;

    switch (ch) {

    // как и прежде

    default:

    if (isalpha(ch)) {

      cin.putback(ch);

      string s;

      cin>>s;

      if (s == declkey) return Token(let); // ключевое
 слово let

      return Token(name,s);

    }

    error("Неправильная лексема");

  }

}


В первую очередь обратите внимание на вызов функции

isalpha(ch)
. Этот вызов отвечает на вопрос “Является ли символ
ch
буквой?”; функция
isalpha()
принадлежит стандартной библиотеке и описана в заголовочном файле
std_lib_facilities.h
. Остальные функции классификации символов описаны в разделе 11.6. Логика распознавания имен совпадает с логикой распознавания чисел: находим первый символ соответствующего типа (в данном случае букву), а затем возвращаем его назад в поток с помощью функции
putback()
и считываем все имя целиком с помощью оператора
>>
.

К сожалению, этот код не компилируется; класс

Token
не может хранить строку, поэтому компилятор отказывается распознавать вызов
Token(name,s)
. К счастью, эту проблему легко исправить, предусмотрев такую возможность в определении класса
Token
.


class Token {

public:

  char kind;

  double value;

  string name;

  Token(char ch):kind(ch), value(0) { }

  Token(char ch, double val) :kind(ch), value(val) { }

  Token(char ch, string n) :kind(ch), name(n) { }

};


Для представления лексемы

let
мы выбрали букву
'L'
, а само ключевое слово храним в виде строки. Очевидно, что это ключевое слово легко заменить ключевыми словами
double
,
var
,
#
, просто изменив содержимое строки
declkey
, с которой сравнивается строка
s
.

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


let x = 3.4;

let y = 2;

x + y * 2;


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


let x = 3.4;

let y = 2;

x+y*2;


Чем различаются эти примеры? Посмотрим, что происходит. Проблема в том, что мы небрежно определили лексему

Имя
. Мы даже “забыли” включить правило вывода
Имя
в грамматику (раздел 7.8.1). Какие символы могут бы частью имени? Буквы? Конечно. Цифры? Разумеется, если с них не начинается имя. Символ подчеркивания? Нет? Символ
+
? Неужели?

Посмотрим на код еще раз. После первой буквы считываем строку в объект класса

string
с помощью оператора
>>
. Он считывает все символы, пока не встретит пробел. Так, например, строка
x+y*2;
является отдельным именем — даже завершающая точка с запятой считывается как часть имени. Это неправильно и неприемлемо.

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

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


a

ab

a1

Z12

asdsddsfdfdasfdsa434RTHTD12345dfdsa8fsd888fadsf


А следующие строки именами не являются:


1a

as_s

#

as*

a car


За исключением отброшенного символа подчеркивания это совпадает с правилом языка С++. Мы можем реализовать его в разделе

default
в функции
get()
.


default:

  if (isalpha(ch)) {

    string s;

    s += ch;

    while (cin.get(ch) && (isalpha(ch) || isdigit(ch))) 

      s+=ch;

    cin.putback(ch);

    if (s == declkey) return Token(let); // ключевое слово let

    return Token(name,s);

  }

  error("Неправильная лексема");


Вместо непосредственного считывания в объект

string s
считываем символ и записываем его в переменную
s
, если он является буквой или цифрой. Инструкция
s+=ch
добавляет (приписывает) символ
ch
в конец строки
s
. Любопытная инструкция


while (cin.get(ch) && (isalpha(ch) || isdigit(ch)) s+=ch;


считывает символ в переменную

ch
(используя функцию-член
get()
потока
cin
) и проверяет, является ли он символом или цифрой. Если да, то она добавляет символ
ch
в строку
s
и считывает символ снова. Функция-член
get()
работает как оператор
>>
, за исключением того, что не может по умолчанию пропускать пробелы.

7.8.3. Предопределенные имена

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

pi
и
e
. В каком месте кода их следует определить? В функции
main()
до вызова функции
calculate()
или в функции
calculate()
до цикла. Мы поместим их определения в функцию
main()
, поскольку они не являются частью каких-либо вычислений.


int main()

try {

  // предопределенные имена:

  define_name("pi",3.1415926535);

  define_name("e",2.7182818284);

  calculate();

  keep_window_open(); // обеспечивает консольный режим Windows

  return 0;

}

catch (exception& e) {

  cerr << e.what() << endl;

  keep_window_open("~~");

  return 1;

}

catch (...) {

  cerr << "exception \n";

  keep_window_open("~~");

  return 2;

}

7.8.4. Все?

Еще нет. Мы внесли так много изменений, что теперь программу необходимо снова протестировать, привести в порядок код и пересмотреть комментарии. Кроме того, можно было бы сделать больше определений. Например, мы “забыли” об операторе присваивания (см. упр. 2), а наличие этого оператора заставит нас как-то различать переменные и константы (см. упр. 3). Вначале мы отказались от использования именованных переменных в калькуляторе. Теперь, просматривая код их реализации, можем выбрать одну из двух реакций.

1. Реализация переменных была совсем неплохой; она заняла всего три дюжины строк кода.

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


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


Задание

1. Скомпилируйте файл

calculator08buggy.cpp
.

2. Пройдитесь по всей программе и добавьте необходимые комментарии.

3. В ходе комментирования вы обнаружите ошибки (специально вставленные в код, чтобы вы их нашли). Исправьте их; в тексте книги их нет.

4. Тестирование: подготовьте набор тестовых вводных данных и используйте их для тестирования калькулятора. Насколько полон ваш список? Что вы ищете? Включите в список отрицательные числа, нуль, очень маленькие числа и “странный” ввод.

5. Проведите тестирование и исправьте все ошибки, которые пропустили при комментировании.

6. Добавьте предопределенное имя

k
со значением
1000
.

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

sqrt()
, например
sqrt(2+6.7)
. Естественно, значение
sqrt(x)
— это квадратный корень из числа
x;
например
sqrt(9)
равно
3
.

8. Используйте стандартную функцию

sqrt()
, описанную в заголовочном файле
std_lib_facilities.h
. Не забудьте обновить комментарии и грамматику.

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

10. Предусмотрите возможность использовать функцию

pow(x,i)
, означающую “умножить
x
на себя
i
раз”; например
pow(2.5,3)
равно
2.5*2.5*2.5
. Аргумент
i
должен быть целым числом. Проверьте это с помощью оператора
%
.

11. Измените “ключевое слово объявления” с

let
на
#
.

12. Измените “ключевое слово выхода” с

q
на
exit
. Для этого понадобится строка для кодирования инструкции “выход”, как мы уже делали для инструкции “let” в разделе 7.8.2.


Контрольные вопросы

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

2. Почему выражение “

1+2; q
”, введенное в программу, не приводит к выходу из нее после обнаружения ошибки?

3. Зачем нам понадобилась символьная константа с именем

number
?

4. Мы разбили функцию

main()
на две разные функции. Что делает новая функция и зачем мы разделили функцию
main()
?

5. Зачем вообще разделять код на несколько функций? Сформулируйте принципы.

6. Зачем нужны комментарии и как они должны быть организованы?

7. Что делает оператор

narrow_cast
?

8. Как используются символические константы?

9. Почему важна организация кода?

10. Как мы реализовали оператор

%
(остаток) применительно к числам с плавающей точкой?

11. Что и как делает функция

is_declared()
?

12. Реализация “ключевого слова”

let
использует несколько символов. Как обеспечен ввод этой лексемы как единого целого в модифицированном коде?

13. Сформулируйте правило, определяющее, что является именем в калькуляторе и что нет?

14. Чем хороша идея о постепенной разработке программ?

15. Когда следует начинать тестирование?

16. Когда следует проводить повторное тестирование?

17. Как вы принимаете решение о том, какие функции следует сделать отдельными?

18. Как вы выбираете имена для переменных и функций? Обоснуйте свой выбор.

19. Зачем нужны комментарии?

20. Что следует писать в комментариях, а что нет?

21. Когда следует считать программу законченной?


Термины


Упражнения

1. Предусмотрите использование символа подчеркивания в именах внутри программы–калькулятора.

2. Реализуйте оператор присваивания

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

3. Реализуйте именованные константы, которые действительно не могут изменять свои значения. Подсказка: в класс

Variable
необходимо добавить функцию-член, различающую константы и переменные и проверяющую это при выполнении функции
set_value()
. Если хотите дать пользователю возможность объявлять собственные именованные константы (а не только
pi
и
e
), то необходимо добавить соответствующее обозначение, например
const pi = 3.14;
.

4. Функции

get_value()
,
set_value()
,
is_declared()
и
define_name()
оперируют переменной
var_table
. Определите класс
Symbol_table
с членом
var_table
типа
vector
и функциями-членами
get()
,
set()
,
is_declared()
и
define()
. Перепишите программу так, чтобы использовать переменную типа
Symbol_table
.

5. Модифицируйте функцию

Token_stream::get()
так, чтобы, обнаружив символ перехода на следующую строку, она возвращала лексему
Token(print)
. Для этого требуется обеспечить поиск разделителей и обработку символа
'\n'
. Для этого можно использовать стандартную библиотечную функцию
isspace(ch)
, возвращающую значение
true
, если символ
ch
является разделителем.

6. Каждая программа должна содержать подсказки для пользователя. Пусть при нажатии клавиши

<Н>
калькулятор выводит на экран инструкции по эксплуатации.

7. Измените команды

q
и
h
на
quit
и
help
соответственно.

8. Грамматика в разделе 7.6.4 является неполной (мы уже предостерегали вас от чрезмерного увлечения комментариями); в ней не определена последовательность инструкций, например

4+4;
5–6;
, и не учтены усовершенствования, описанные в разделе 7.8. Исправьте грамматику. Кроме того, добавьте в первый и все остальные комментарии программы все, что считаете нужным.

9. Определите класс

Table
, содержащий объект типа
vector
и функции-члены
get()
,
set()
и
define()
. Замените вектор
var_table
в калькуляторе объектом класса
Table
с именем
symbol_table
.

10. Предложите три усовершенствования калькулятора (не упомянутых в главе). Реализуйте одно из них.

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

12. Реализуйте оператор присваивания, чтобы значение переменной можно было изменять после ее инициализации. Объясните целесообразность этого новшества и потенциальные проблемы, связанные с ним.

13. Переработайте две программы, написанные вами при выполнении упражнений к главам 4 и 5. Приведите в порядок их код в соответствии с правилами, приведенными в данной главе. Найдите ошибки.


Послесловие

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

Глава 8. Технические детали: функции и прочее