Часть I Основы

Глава 2 Hello, World!

“Чтобы научиться программированию,

необходимо писать программы”.

Брайан Керниган (Brian Kernighan)


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

• Дать вам возможность поработать с интегрированной средой разработки программ.

• Дать вам почувствовать, как можно заставить компьютер делать то, что нужно.


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

2.1. Программы

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

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

В противоположность этому компьютер действительно глуп. Ему все необходимо точно и подробно описать. Вернемся к инструкциям “по коридору направо, вверх по лестнице, а потом налево”. Где находится коридор? Что такое коридор? Что значит “направо”? Что такое лестница? Как подняться по лестнице? По одной ступеньке? Через две ступеньки? Держась за перила? Что находится слева от меня? Когда это окажется слева от меня? Для того чтобы подробно описать инструкции для компьютера, необходим точно определенный язык, имеющий специфическую грамматику (естественный язык слишком слабо структурирован), а также хорошо определенный словарь для всех видов действий, которые мы хотим выполнить. Такой язык называется языком программирования, и язык программирования С++ — один из таких языков, разработанных для решения широкого круга задач.

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

2.2. Классическая первая программа

Приведем вариант классической первой программы. Она выводит на экран сообщение.


// Эта программа выводит на экран сообщение "Hello, World!"

#include "std_lib_facilities.h"

int main() // Программы на C++ начинаются с выполнения функции main

{

  cout << "Hello, World!\n"; // вывод "Hello, World!"

 return 0;

}


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


cout << "Hello, World!\n"; // вывод "Hello, World!"


Именно эта строка выводит сообщение на экран. Она печатает символы

Hello, World!
, за которыми следует символ перехода на новую строку; иначе говоря, после вывода символов
Hello,World!
курсор будет установлен на начало новой строки. Курсор — это небольшой мерцающий символ или строка, показывающая, где будет выведен следующий символ.

В языке С++ строковые литералы выделяются двойными кавычками (

"
); т.е.
"Hello, Word!\n"
— это строка символов. Символ
\n
— это специальный символ, означающий переход на новую строку. Имя
cout
относится к стандартному потоку вывода. Символы, “выведенные в поток
cout
” с помощью оператора вывода
<<
, будут отображены на экране. Имя
cout
произносится как “see-out”, но является аббревиатурой “haracter put stream” (“поток вывода символов”). Аббревиатуры довольно широко распространены в программировании. Естественно, аббревиатура на первых порах может показаться неудобной для запоминания, но привыкнув, вы уже не сможете от них отказаться, так как они позволяют создавать короткие и управляемые программы.

Конец строки


// вывод "Hello, World!"


является комментарием. Все, что написано после символа

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

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

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

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


// Эта программа выводит на экран сообщение "Hello, World!"


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

Строка


#include "std_lib_facilities.h"


представляет собой директиву

#include
. Она заставляет компьютер “включить” возможности, описанные в файле
std_lib_facilities.h
. Этот файл упрощает использование возможностей, предусмотренных во всех реализациях языках С++ (стандартной библиотеке языка С++).

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

std_lib_facilities.h
для данной программы заключается в том, что с его помощью мы получаем доступ к стандартным средствам ввода-вывода языка С++. Здесь мы просто используем стандартный поток вывода
cout
и оператор вывода
<<
. Файл, включаемый в программу с помощью директивы
#include
, обычно имеет расширение
.h
и называется заголовком (header), или заголовочным файлом (header file). Заголовок содержит определения терминов, таких как
cout
, которые мы используем в нашей программе.

Как компьютер находит точку, с которой начинается выполнение программы? Он просматривает функцию с именем

main
и начинает выполнять ее инструкции. Вот как выглядит функция
main
нашей программы “Hello, World!”:


int main() // Программы на C++ начинаются с выполнения функции main

{

 cout << "Hello, World!\n"; // вывод "Hello, World!"

  return 0;

}


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

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

• Тип возвращаемого значения, в этой функции — тип

int
(т.е. целое число), определяет, какой результат возвращает функция в точку вызова (если она возвращает какое-нибудь значение). Слово
int
является зарезервированным в языке С++ (ключевым словом), поэтому его нельзя использовать как имя чего-нибудь еще (см. раздел А.3.1).

• Имя, в данном случае

main
.

• Список параметров, заключенный в круглые скобки (см. разделы 8.2 и 8.6); в данном случае список параметров пуст.

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


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


int main() { }


Пользы от этой программы мало, так как она ничего не делает. Тело функции

main
программы “Hello, World!” содержит две инструкции:


cout << "Hello, World!\n"; // вывод "Hello, World!"

return 0;


Во-первых, она выводит на экран строку

Hello, World!
, а затем возвращает значение
0
(нуль) в точку вызова. Поскольку функция
main()
вызывается системой, мы не будем использовать возвращаемое значение. Однако в некоторых системах (в частности, Unix/Linux) это значение можно использовать для проверки успешности выполнения программы. Нуль (
0
), возвращаемый функцией
main()
, означает, что программа выполнена успешно.

Часть программы на языке С++, определяющая действие и не являющаяся директивой

#include
(или другой директивой препроцессора; см. разделы 4.4 и А.17), называется инструкцией.

2.3. Компиляция

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

.cpp
(например,
hello_world.cpp
) или
.h
(например,
std_lib_facilities.h
), а файлы с объектным кодом имеют расширение
.obj
(в системе Windows) или
.o
(в системе Unix). Следовательно, простое слово код является двусмысленным и может ввести в заблуждение; его следует употреблять с осторожностью и только в ситуациях, когда недоразумение возникнуть не может. Если не указано иное, под словом код подразумевается исходный код или даже исходный код, за исключением комментариев, поскольку комментарии предназначены для людей и компилятор не переводит их в объектный код.

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

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


// пропущен заголовочный файл

int main()

{

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

 return 0;

}


Мы не сообщили компилятору о том, что представляет собой объект, поэтому он сообщает об ошибке. Для того чтобы исправить программу, следует добавить директиву

#include
.


#include "std_facilities.h"

int main()

{

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

  return 0;

}



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

std_lib_facilities.h
. Компилятор заметил это.


#include "std_lib_facilities.h

int main()

{

 cout << "Hello, World!\n;

 return 0;

}


В этом примере мы пропустили закрывающую двойную кавычку (

"
). Компилятор указывает нам на это.


#include "std_lib_facilities.h"

integer main()

{

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

  return 0;

}


Теперь мы вместо ключевого слова

int
использовали слово
integer
, которого в языке С++ нет. Компилятор таких ошибок не прощает.


#include "std_lib_facilities.h"

int main()

{

  cout < "Hello, World!\n";

 return 0;

}


Здесь вместо символов

<<
(оператор вывода) использован символ
<
(оператор “меньше”). Компилятор это заметил.


#include "std_lib_facilities.h"

int main()

{

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

 return 0;

}


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


#include "std_lib_facilities.h"

int main()

{

 cout << "Hello, World!\n"

 return 0;

}


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

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

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

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

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

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

Итак, помните: компилятор — ваш друг; возможно, лучший друг.

2.4. Редактирование связей

Программа обычно состоит из нескольких отдельных частей, которые часто разрабатываются разными людьми. Например, программа “Hello, World!” состоит из части, которую написали вы, и частей стандартной библиотеки языка С++. Эти отдельные части (иногда называемые единицами трансляции) должны быть скомпилированы, а файлы с результирующим объектным кодом должны быть связаны вместе, образуя выполняемый файл. Программа, связывающая эти части в одно целое, называется (вполне ожидаемо) редактором связей.

Заметьте, что объектные и выполняемые коды не переносятся из одной системы в другую. Например, когда вы компилируете программу под управлением системы Windows, то получите объектный код именно для системы Windows, а не Linux.



Библиотека — это просто некий код (обычно написанный другими), который можно использовать с помощью директивы

#include
. Объявление — это инструкция программы, указывающая, как можно использовать фрагмент кода; объявления будут подробно описаны позднее (см., например, раздел 4.5.2).

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

2.5. Среды программирования

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

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

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

В этой книге в качестве интегрированной среды программирования используется программа Visual C++ компании Microsoft. Если мы говорим просто “компилятор” или ссылаемся на какую-то часть интегрированной среды разработки, то это значит, что мы имеем в виду часть программы Visual C++. Однако вы можете использовать любую другую систему, обеспечивающую современную и стандартную реализацию языка С++. Все, что мы напишем, при очень небольшой модификации, остается справедливым для всех реализаций языка С++, и код будет работать на любом компьютере. В нашей работе мы обычно используем несколько разных реализаций.


Задание

До сих пор мы говорили о программировании, коде и инструментах (например, о компиляторах). Теперь нам необходимо выполнить программу. Это очень важный момент в изложении и в обучении программированию вообще. Именно с этого начинается усвоение практического опыта и овладение хорошим стилем программирования. Упражнения в этой главе предназначены для того, чтобы вы освоились с вашей интегрированной средой программирования. Запустив программу “Hello, World!” на выполнение, вы сделаете первый и главный шаг как программист.

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

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

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

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

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

Итак, вот первое задание.

1. Откройте приложение В и выполните все шаги, необходимые для настройки проекта. Создайте пустой консольный проект на С++ под названием hello_world.

2. Введите в файл

hello_world.cpp
приведенные ниже строки, сохраните его в рабочем каталоге и включите в проект
hello_world
.


#include "std_lib_facilities.h"

int main() // Программы на C++ начинаются с выполнения функции
 main

{

 cout << "Hello, World!\n"; // вывод строки"Hello, World!"

 keep_window_open();     // ожидание ввода символа

 return 0;

}


Вызов функции

keep_window_open()
нужен при работе под управлением некоторых версий операционной системы Windows для того, чтобы окно не закрылось прежде, чем вы прочитаете строку вывода. Это особенность вывода системы Windows, а не языка С++. Для того чтобы упростить разработку программ, мы поместили определение функции
keep_window_open()
в файл
std_lib_facilities.h.
Как найти файл
std_lib_facilities.h
? Если вы этого не знаете, спросите преподавателя. Если знаете, то загрузите его с сайта
www.stroustrup.com/Programming.
А что, если у вас нет учителя и доступа к веб? В этом (и только в этом) случае замените директиву
#include
строками


#include

#include

#include

#include

#include

using namespace std;

inline void keep_window_open() { char ch; cin>>ch; }


В этих строках стандартная библиотека используется непосредственно. Подробности этого кода изложены в главе 5 и разделе 8.7.

3. Скомпилируйте и выполните программу “Hello, World!”. Вполне вероятно, что у вас это сразу не получится. Очень редко первая попытка использовать новый язык программирования или новую среду разработки программ завершается успехом. Найдите источник проблем и устраните его! В этот момент целесообразно заручиться поддержкой более опытного специалиста, но перед этим следует убедиться, что вы сами сделали все, что могли.

• Возможно, вы нашли несколько ошибок и исправили их. На этом этапе следует поближе ознакомиться с тем, как компилятор находит ошибки и сообщает о них программисту! Посмотрите, как отреагирует компилятор на шесть ошибок, сделанных в разделе 2.3. Придумайте еще как минимум пять ошибок в вашей программе (например, пропустите вызов функции

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


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

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

1. Для чего предназначена программа “Hello, World!”?

2. Назовите четыре части функции.

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

4. Для чего предназначена строка

return 0
в программе “Hello,World!”?

5. Для чего предназначен компилятор?

6. Для чего предназначена директива

#include
?

7. Что означает расширение

.h
после имени файла в языке C++?

8. Что делает редактор связей?

9. В чем заключается различие между исходным и объектным файлом?

10. Что такое интегрированная среда разработки и для чего она предназначена?

11. Если вам все понятно, то зачем нужны упражнения?


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


Термины

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



Упражнения

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

1. Измените программу так, чтобы она выводила две строки:


Hello, programming!

Here we go!


2. Используя приобретенные знания, напишите программу, содержащую инструкции, с помощью которых компьютер нашел бы ванную на верхнем этаже, о которой шла речь в разделе 2.1. Можете ли вы указать большее количество шагов, которые подразумевают люди, а компьютер — нет? Добавьте эти команды в ваш список. Это хороший способ научиться думать, как компьютер. Предупреждаем: для большинства людей “иди в ванную” — вполне понятная команда. Для людей, у которых нет собственного дома или ванной (например, для неандертальцев, каким-то образом попавших в гостиную), этот список может оказаться очень длинным. Пожалуйста, не делайте его больше страницы. Для удобства читателей можете изобразить схему вашего дома.

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

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

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

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

• Составьте словарь использованных терминов. (Что такое противень? Что такое предварительный разогрев? Что подразумевается под духовкой?)

• Теперь приготовьте несколько булочек и насладитесь результатом.


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

www.research.att.com/~bs/glossary.html.
Формулируя свое описание, вы закрепите полученные знания. Если для этого вам пришлось перечитать главу, то это только на пользу. Можете пересказывать смысл термина своими словами и уточнять его по своему разумению. Часто для этого полезно использовать примеры, размещенные после основного определения. Целесообразно записывать свои ответы в отдельный файл, постепенно добавляя в него новые термины.


Послесловие

Почему программа “Hello, World!” так важна? Ее цель — ознакомить вас с основными инструментами программирования. Мы стремились использовать для этого максимально простой пример.

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

Глава 3 Объекты, типы и значения

“Фортуна благоволит подготовленному уму”.

Луи Пастер (Louis Pasteur)

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

char, int, double
и
string
.

3.1. Ввод

Программа “Hello, World!” просто записывает текст на экран. Она осуществляет вывод. Она ничего не считывает, т.е. не получает ввода от пользователя. Это довольно скучно. Реальные программы, как правило, производят результаты на основе каких-то данных, которые мы им даем, а не делают одно и то же каждый раз, когда мы их запускаем.

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

string
, а целые числа — в переменные типа
int
. Объект можно интерпретировать как “коробку”, в которую можно поместить значение, имеющее тип объекта.



Например, на рисунке изображен объект типа

int
с именем
age
, содержащий целое число 42. Используя строковую переменную, мы можем считать строку с устройства ввода и вывести ее на экран, как показано ниже.


// считать и записать имя

#include "std_lib_facilities.h"


int main()

{

 cout << "Пожалуйста, введите ваше имя (затем нажмите 'enter'):\n";

 string first_name;  // first_name — это переменная типа string

 cin >> first_name;  // считываем символы в переменную first_name

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

}


Директива

#include
и функция
main()
известны нам из главы 2. Поскольку директива
#include
необходима во всех наших программах (вплоть до главы 12), мы отложим ее изучение, чтобы не запутывать ситуацию. Аналогично иногда мы будем демонстрировать код, который работает, только если поместить его в тело функции
main()
или какой-нибудь другой.


cout << "Пожалуйста, введите ваше имя (затем нажмите 'enter'):\n";


Будем считать, что вы понимаете, как включить этот код в полную программу, чтобы провести ее тестирование.

Первая строка функции

main()
просто выводит на экран сообщение, предлагающее пользователю ввести свое имя. Такое сообщение называется приглашением (prompt), поскольку оно предлагает пользователю предпринять какое-то действие. Следующие строки определяют переменную типа
string
с именем
first_name
, считывают данные с клавиатуры в эту переменную и выводят на экран слово Hello. Рассмотрим эти строки по очереди.


string first_name; // first_name — это переменная типа string


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

first_name
.



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

Следующая строка считывает символы с устройства ввода (клавиатуры) в переменную:


cin >> first_name; // считываем символы в переменную first_name


Имя

cin
относится к стандартному потоку ввода (читается как “си-ин” и является аббревиатурой от haracter put), определенному в стандартной библиотеке. Второй операнд оператора
>>
(“ввести”) определяет участок памяти, в который производится ввод. Итак, если мы введем некое имя, например
Nicolas
, а затем выполним переход на новую строку, то строка “
Nicolas
” станет значением переменной
first_name
.



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

Введя входную строку в переменную

first_name
, можем использовать ее в дальнейшем.


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


Эта строка выводит на экран слово Hello за которым следует имя Nicolas (значение переменной

first_name
) с восклицательным знаком (
!
) и символом перехода на новую строку экрана (
'\n'
).


Hello, Nicolas!


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


cout << "Hello, ";

cout << first_name;

cout << "!\n";


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

Обратите внимание на то, что мы заключили выражение

Hello
в двойные кавычки, а не указали имя
first_name
. Двойные кавычки используются для работы с литеральными строками. Если двойные кавычки не указаны, то мы ссылаемся на нечто, имеющее имя.


cout << "Имя " << " — " << first_name;


Здесь строка "

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


Имя — Nicolas

3.2. Переменные

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

int
или
string
), определяющий, какую информацию можно записать в объект (например, в переменную типа
int
можно записать число
123
, а в объект типа
string
— строку символов "
Hello, World!\n
", а также какие операции к нему можно применять (например, переменные типа
int
можно перемножать с помощью оператора
*
, а объекты типа
string
можно сравнивать с помощью оператора
<=
). Данные, записанные в переменные, называют значениями. Инструкция, определяющая переменную, называется (вполне естественно) определением, причем в определении можно (и обычно желательно) задавать начальное значение переменной. Рассмотрим следующий пример:


string name = "Annemarie";

int number_of_steps = 39;


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



Мы не можем записывать в переменную значение неприемлемого типа.


string name2 = 39;  // ошибка: 39 — это не строка

int number_of_steps = "Annemarie"; // ошибка: "Annemarie"

                  // — не целое число


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

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


int number_of_steps = 39;  // int — для целых чисел

double flying_time = 3.5;  // double — для чисел с плавающей точкой

char decimal_point = '.';  // char — для символов

string name = "Annemarie"; // string — для строк

bool tap_on = true;     // bool — для логических переменных


Ключевое слово

double
используется по историческим причинам: оно является сокращением от выражения “число с плавающей точкой и двойной точностью” (“double precision floating point.”) Числом с плавающей точкой в компьютерных науках называют действительное число.

Обратите внимание на то, что каждый из этих типов имеет свой характерный способ записи.


39      // int: целое число

3.5     // double: число с плавающей точкой

'.'     // char: отдельный символ, заключенный в одинарные кавычки

"Annemarie" // string: набор символов, выделенный двойными кавычками

true     // bool: либо истина, либо ложь


Иначе говоря, последовательность цифр (например,

1234
,
2
или
976
) означает целое число, отдельный символ в одинарных кавычках (например, '
1
', '
@
' или '
x
') означает символ, последовательность цифр с десятичной точкой (например,
1.234
,
0.12
или
.98
) означает число с плавающей точкой, а последовательность символов, заключенных в двойные кавычки (например, "
1234
", "
Howdy!
" или "
Annemarie
"), обозначает строку. Подробное описание литералов приведено в разделе А.2.

3.3. Ввод и тип

Операция ввода

>>
(“извлечь из”) очень чувствительна к типу данных, т.е. она считывает данные в соответствии с типом переменной, в которую производится запись. Рассмотрим пример.


// ввод имени и возраста

int main()

{

 cout << "Пожалуйста, введите свое имя и возраст \n";

 string first_name; // переменная типа string

 int age;      // переменная типа integer

 cin >> first_name; // считываем значение типа string

 cin >> age;     // считываем значение типа integer

 cout << "Hello, " << first_name << " (age " << age << ")\n";

}


Итак, если вы наберете на клавиатуре

Carlos 22
, то оператор
>>
считает значение
Carlos
в переменную
first_name
число
22
— в переменную age и выведет на экран следующий результат.


Hello, Carlos (age 22)


Почему вся строка

Carlos 22
не была введена в переменную
first_name
? Потому что по умолчанию считывание строк прекращается, как только будет обнаружен так называемый разделитель (whitespace), т.е. пробел, символ перехода на новую строку или символ табуляции. В других ситуациях разделители по умолчанию игнорируются оператором
>>
. Например, перед считываемым числом можно поместить сколько угодно пробелов; оператор
>>
пропустит их и считает число.

Если вы наберете на клавиатуре строку

22 Carlos
, то увидите нечто неожиданное. Число
22
будет считано в переменную
first_name
, так как, в конце концов,
22
— это тоже последовательность символов. С другой стороны, строка
Carlos
не является целым числом, поэтому она не будет считана. В результате на экран будет выведено число
22
, за которым будет следовать строковый литерал "
(age
" и какое-то случайное число, например
–96739
или
0
. Почему? Потому что вы не указали начальное значение переменной
age
и впоследствии в нее ничего не записали. В итоге получили какое-то “мусорное значение”, хранившееся в участке памяти в момент запуска программы. В разделе 10.6 мы покажем способ исправления ошибок, связанных с форматом ввода. А пока просто инициализируем переменную
age
так, чтобы она имела определенное значение и ввод осуществлялся успешно.


// ввод имени и возраста (2-я версия)

int main()

{

 cout << "Пожалуйста, введите свое имя и возраст \n";

 string first_name = "???";  // переменная типа string

                // ("???" означает, что "имя неизвестно")

 int age = –1;        // переменная типа int (–1 означает

                // "возраст неизвестен")

 cin >> first_name >> age;  // считываем строку, а затем целое число

 cout << "Hello, " << first_name << " (age " << age << ")\n";

}


Теперь ввод строки

22 Carlos
приводит к следующему результату:


Hello, 22 (age –1)


Обратите внимание на то, что мы можем одним оператором ввода ввести одновременно несколько значений, а одним оператором вывода — вывести их на экран. Кроме того, оператор

<<
, как и оператор
>>
, чувствителен к типу, поэтому можем вывести переменную
age
типа
int
вместе со строковой переменной
first_name
и строковыми литералами "
Hello,
", "
(age
" и "
\n
" .

Ввод объекта типа

string
с помощью оператора
>>
(по умолчанию) прекращается, когда обнаруживается разделитель; иначе говоря, оператор
>>
считывает отдельные слова. Однако иногда нам необходимо прочитать несколько слов. Для этого существует много возможностей. Например, можно прочитать имя, состоящее из двух слов.


int main()

{

  cout << "Пожалуйста, введите свое имя и отчество\n";

  string first;

 string second;

 cin >> first >> second; // считываем две строки

 cout << "Hello, " << first << ' ' << second << '\n';

}


Здесь мы просто использовали оператор

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


ПОПРОБУЙТЕ

Запустите программу “имя и возраст”. Измените ее так, чтобы она выводила возраст, измеренный месяцами: введите возраст, выраженный в годах, и умножьте это число на 12 (используя оператор

*
). Запишите возраст в переменную типа
double
, чтобы дети могли гордиться, что им пять с половиной, а не пять лет.

3.4. Операции и операторы

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


int count;

cin >> count;        // оператор >> считывает целое число в объект count

string name;

cin >> name;         // оператор >> считывает строку в переменную name

int c2 = count+2;      // оператор + складывает целые числа

string s2 = name + " Jr. "; // оператор + добавляет символы

int c3 = count–2;      // оператор – вычитает целые числа

string s3 = name – "Jr. ";  // ошибка: оператор – для строк не определен


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


int age = –100;


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



Пустые ячейки означают, что операция не может применяться к данному типу непосредственно (хотя существует множество косвенных способов их использования; см. раздел 3.9.1). Со временем мы объясним все эти операции. Дело в том, что существует множество полезных операций и их смысл у схожих типов почти одинаков.

Рассмотрим пример, в котором фигурируют числа с плавающей точкой.


// простая программа, демонстрирующая работу операторов

int main()

{

 cout << "Пожалуйста, введите значение с плавающей точкой: ";

 double n;

 cin >> n;

 cout << "n == " << n

 << "\nn+1 == " << n+1

  << "\n три раза по n == " << 3*n

 << "\n два раза по n == " << n+n

  << "\nn в квадрате == " << n*n

  << "\n половина n == " << n/2

  << "\n корень квадратный из n == " << sqrt(n)

 << endl; // синоним перехода на новую строку ("end of line")

}


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

sqrt(n)
из стандартной библиотеки. Система обозначений близка к математической. Более подробно функции рассматриваются в разделах 4.5 и 8.5.


ПОПРОБУЙТЕ

Запустите эту небольшую программу. Затем измените ее так, чтобы считать значение типа

int
, а не
double
. Обратите внимание на то, что функция
sqrt()
для целых чисел не определена, поэтому присвойте число переменной типа
double
и лишь затем примените к ней функцию
sqrt()
. Кроме того, выполните несколько других операций. Обратите внимание на то, что операция для целых чисел представляет собой целочисленное деление, а операция — вычисление остатка, так что
5/2
равно
2
(а не
2.5
или
3
), а
5%2
равно
1
. Определения целочисленных операций
*
,
/
и
%
гарантируют, что для двух положительных переменных
a
и
b
типа
int
выполняется равенство
a/b*b+a%b==a
.


Для типа

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


// ввод имени и отчества

int main()

{

 cout << "Пожалуйста, введите свое имя и отчество \n";

 string first;

 string second;

 cin >> first >> second;       // считываем две строки

 string name = first + ' ' + second; // конкатенируем строки

 cout << "Hello, " << name << '\n';

}


Для строк оператор

+
означает конкатенацию; иначе говоря, если переменные
s1
и
s2
имеют тип
string
, то
s1+s2
является строкой, в которой вслед за символами строки
s1
следуют символы строки
s2
. Например, если строка
s1
имеет значение "
Hello
", а строка
s2
— значение "
World
", то
s1+s2
содержит значение "
HelloWorld
". Особенно полезным является сравнение строк.


// ввод и сравнение имен

int main()

{

  cout << "Пожалуйста, введите два имени\n";

 string first;

 string second;

 cin >> first >> second;   // считываем две строки

 if (first == second) cout << " имена совпадают \n";

 if (first < second)

   cout << first << " по алфавиту предшествует " << second <<'\n';

  if (first > second)

    cout << first << " по алфавиту следует за " << second <<'\n';

}


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

if
, смысл которой будет подробно изложен в разделе 4.4.1.1.

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

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

=
. Этот оператор присваивает переменной новое значение. Рассмотрим пример.


int a = 3; // начальное значение переменной a равно 3



a = 4; // переменная а принимает значение 4

    //("становится четверкой")



int b = a; // начальное значение переменной b является копией

      // значения переменной a (т.е. 4)



b = a+5; // переменная b принимает значение a+5 (т.е. 9)



a = a+7; // переменная a принимает значение a+7 (т.е. 11)



Последнее присваивание заслуживает внимания. Во-первых, оно ясно показывает, что знак “равно” не означает равенства, поскольку очевидно, что

а
не равно
а+7
. Этот знак означает присваивание, т.е. помещение в переменную нового значения. Рассмотрим подробнее, что происходит при выполнении инструкции
a= a+7
.

1. Сначала получаем значение переменной

a
; оно равно целому числу
4
.

2. Затем добавляем к четверке семерку, получаем целое число

11
.

3. В заключение записываем значение

11
в переменную
a
.


Эту операцию можно продемонстрировать также на примере строк.


string a = "alpha"; // начальное значение переменной a равно "alpha"



a = "beta"; // переменная a принимает значение "beta"

      // (становится равной "beta")



string b = a; // начальное значение переменной b является

        // копией значения переменной a (т.е. "beta")



b = a+"gamma"; // переменная b принимает значение a+"gamma"

         // (т.е. "betagamma")



a = a+"delta"; // переменная a принимает значение a+"delta"

         // (т.е. "betadelta")



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

• Инициализация (присваивание переменной ее начального значения).

• Присваивание (запись в переменную нового значения).


Эти операции настолько похожи, что в языке С++ для них используется одно и то же обозначение.


int y = 8;      // инициализация переменной y значением 8

x = 9;        // присваивание числа 9 переменной x

string t = "howdy!"; // инициализация переменной t значением "howdy!"

s = "G'day";     // присваивание переменной s значения "G’day" 


Однако с логической точки зрения присваивание и инициализация различаются. Например, инициализация всегда происходит одновременно с определением типа (например,

int
или
string
), а присваивание нет. В принципе инициализация всегда осуществляется с пустой переменной. С другой стороны, присваивание (в принципе) сначала должно стереть старое значение из переменной и лишь затем записать в нее новое значение. Переменную можно представить в виде небольшого ящика, а значение — в виде конкретной вещи (например, монеты), лежащей в этом ящике. Перед инициализацией ящик пуст, но после нее он всегда содержит монету, поэтому, для того чтобы положить в него новую монету, вы (т.е. оператор присваивания) сначала должны вынуть из него старую (“стереть старое значение”), причем ящик нельзя оставлять пустым. Разумеется, в памяти компьютера эти операции происходят не так буквально, как мы описали, но ничего вредного в такой аллегории нет.

3.5.1. Пример: выявление повторяющихся слов

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


int main()

{

 string previous = " ";    // переменная previous;

                // инициализована "не словом"

 string current;       // текущее слово

 while (cin>>current) {   // считываем поток слов

   if (previous == current) // проверяем, совпадает ли

                // слово с предыдущим

   cout << " повторяющееся слово: " << current << '\n';

   previous = current;

 }

}


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


string current; // текущее слово


Это строковая переменная, в которую мы сразу же считываем текущее (т.е. только что прочитанное) слово с помощью оператора


while (cin>>current)


Эта конструкция, называемая инструкцией

while
, интересна сама по себе, поэтому мы еще вернемся к ней в разделе 4.4.2.1. Ключевое слово
while
означает, что инструкция, стоящая следом за выражением
cin>>current
, будет повторяться до тех пор, пока выполняется операция
cin>>current
, а операция
cin>>current
будет выполняться до тех пор, пока в стандартном потоке ввода есть символы.

Напомним, что для типа

string
оператор считывает слова, отделенные друг от друга разделителями. Этот цикл завершается вводом символа конца ввода (как правило, называемым концом файла). В системе Windows этот символ вводится путем нажатия комбинации клавиш , а затем — клавиши . В системе Unix или Linux для этого используется комбинация клавиш .

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


if (previous == current)  // проверяем, совпадает ли слово

              // с предыдущим

  cout << " повторяющееся слово: " << current << '\n';


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

current
в переменную
previous
.


previous = current;


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

previous
:


string previous = " "; // переменная previous; инициализована

            // "не словом"


Строка состоит из одного символа (пробела, который вводится путем нажатия клавиши пробела). Оператор ввода

>>
пропускает разделители, поэтому мы не смогли бы считать этот символ из потока ввода. Следовательно, в ходе первой проверки
while
сравнение


if (previous == current)


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

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


ПОПРОБУЙТЕ

Выполните эту программу самостоятельно, записывая промежуточные результаты на лист бумаги. Для проверки используйте фразу “The cat cat jamped”. Даже опытные программисты используют этот прием для визуализации относительно неочевидных действий в небольших фрагментах кода.


ПОПРОБУЙТЕ

Запустите программу для выявления повторяющихся слов. Проверьте предложение “She she laughed He He because what he did did not look very very good good”. Сколько раз повторяются слова в этом предложении? Почему? Что значит слово в этой программе? А что значит повторяющееся слово? (Например, “She she” — это повтор или нет?).

3.6. Составные операторы присваивания

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


++counter


означает


counter = counter + 1


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

7
, вычесть
9
или умножить на
2
. Такие операции также непосредственно поддерживаются в языке С++. Рассмотрим пример.


a += 7; // означает a = a+7

b –= 9; // означает b = b–9

c *= 2; // означает c = c*2


В целом для любого бинарного оператора

oper
выражение
a oper= b
означает
a= a oper b
(см. раздел А.5). Благодаря этому правилу можно составить операторы
+=
,
–=
,
*=
,
/=
и
%=
. Эта компактная запись позволяет просто и ясно выражать свои идеи. Например, во многих приложениях операторы
*=
и
/=
означают масштабирование.

3.6.1. Пример: поиск повторяющихся слов

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


int main()

{

 int number_of_words = 0;

 string previous = " ";  // не слово

 string current;

 while (cin >> current) {

   ++number_of_words;  // увеличиваем счетчик слов

   if (previous == current)

    cout << " количество слов " << number_of_words

    << " repeated: " << current << '\n';

   previous = current;

 }

}


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


++number_of_words;


Таким образом, первое слово имеет номер 1, второе — 2 и т.д. Эту операцию можно записать иначе:


number_of_words += 1;


или даже так:


number_of_words = number_of_words+1;


но выражение

++number_of_words
короче и выражает идею инкрементации намного проще.

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

3.7. Имена

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


x

number_of_elements

Fourier_transform

z2

Polygon


Приведенные ниже слова не являются именами.


2x       // имя должно начинаться с буквы

time$to$market // символ $ — не буква, не цифра и не подчеркивание

Start menu   // пробел — не буква, не цифра и не подчеркивание


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

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

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

Имена чувствительны к регистру; иначе говоря, буквы, набранные в нижнем и верхнем регистрах, отличаются друг от друга, так что

x
и
X
— это разные имена. Приведем небольшую программу, в которой сделано по крайней мере четыре ошибки.


#include "std_lib_facilities.h"

int Main()

{

 STRING s = "Прощай, жестокий мир!";

 cOut << S << '\n';

}


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

one
и
One
, — плохая идея; это не может ввести компилятор в заблуждение, но легко сбивает с толку самого программиста.


ПОПРОБУЙТЕ

Скомпилируйте программу “Прощай, жестокий мир!” и проверьте сообщения об ошибках. Смог ли компилятор выявить все ошибки? Какие проблемы обнаружил компилятор? Не запутался ли компилятор и не выявил ли он больше четырех ошибок? Удалите ошибки одну за другой, начиная с первой, и проанализируйте новые сообщения об ошибках (а затем уточните программу).


В языке С++ зарезервировано около семидесяти ключевых слов. Они перечислены в разделе A.3.1. Их нельзя использовать в качестве имен переменных, типов, функций и т.п. Рассмотрим пример.


int if = 7; // ошибка: "if" — это ключевое слово


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

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


int string = 7; // это порождает проблемы


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

x1
,
x2
,
s3
и
p7
. Аббревиатуры и акронимы могут запутать людей, поэтому использовать их следует как можно реже. Эти акронимы могут быть понятными для вас, но впоследствии вы можете забыть, что значат следующие обозначения:


mtbf

TLA

myw

NBV


Через несколько месяцев вы забудете, что все это значило. Короткие имена, такие как

x
и
i
, целесообразно использовать в стандартных ситуациях, т.е. когда
x
— локальная переменная или параметр (см. разделы 4.5 и 8.4), а
i
— счетчик цикла (см. раздел 4.4.2.3).

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


partial_sum

element_count

stable_partition


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


the_number_of_elements

remaining_free_slots_in_symbol_table


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

element_count
, а не
elementCount
или
Element-Count
. Мы никогда не используем имена, состоящие лишь из прописных букв, такие как
ALL_CAPITAL_LETTERS
, поскольку по умолчанию они зарезервированы для макросов (см. разделы 27.8 и A.17.2), которых мы избегаем. Мы используем прописные буквы в качестве первых букв в именах типов, например
Square
и
Graph
. В языке С++ и его стандартной библиотеке прописные буквы не используются, поэтому типы называются
int
и
string
, а не
Int
и
String
. Таким образом, принятое правило позволяет минимизировать вероятность конфликтов имен между пользовательскими и стандартными типами

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

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


Name names nameS

foo f00 fl

f1 fI fi


Символы

0
,
o
,
O
,
1
,
l
,
I
особенно часто порождают ошибки.

3.8. Типы и объекты

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

Тип — определяет набор возможных значений и операций, выполняемых над объектом.

Объект — участок памяти, в котором хранится значение определенного типа.

Значение — набор битов в памяти, интерпретируемый в соответствии с типом.

Переменная — именованный объект.

Объявление — инструкция, приписывающая объекту определенное имя.

Определение — объявление, выделяющее память для объекта.


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

int
можно хранить только целые числа, например 7, 42 и –399. В ящике для объектов типа
string
можно хранить символьные строки, например "
Interoperability
", "
tokens: @#$%^&*
" и "
Old MacDonald had a farm
". Графически это можно представить так:



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

string
немного сложнее, чем объекта типа
int
, так как тип
string
хранит количество символов в строке. Обратите внимание на то, что объект типа
double
хранит число, а объект типа
string
— символы. Например, переменная
x
содержит число
1.2
, а переменная
s2
— три символа: '
1
', '
.
' и '
2
'. Кавычки вокруг символа и строковых литералов в переменных не хранятся.

Все переменные типа

int
имеют одинаковый размер; иначе говоря, для каждой переменной типа
int
компилятор выделяет одинаковое количество памяти. В типичном настольном компьютере этот объем равен 4 байтам (32 бита). Аналогично, объекты типов
bool
,
char
и
double
имеют фиксированный размер. В настольном компьютере переменные типа
bool
и
char
, как правило, занимают один байт (8 бит), а переменная типа
double
— 8 байт. Обратите внимание на то, что разные типы объектов занимают разное количество памяти в компьютере. В частности, переменная типа
char
занимает меньше памяти, чем переменная типа
int
, а переменная типа
string
отличается от переменных типов
double
,
int
и
char
тем, что разные строки занимают разное количество памяти.

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

12.5
? Мы не знаем. Это может быть
12.5
долл.,
12.5
см или
12.5
галлонов. Только после того, как мы припишем числу
12.5
единицу измерения, оно приобретет конкретный смысл. Например, один и тот же набор битов в памяти может представлять число
120
, если его интерпретировать как переменную типа
int
, и символ
'x'
, если трактовать его как объект типа
char
. Если взглянуть на него как на объект типа
string
, то он вообще потеряет смысл и попытка его использовать приведет к ошибке, возникшей в ходе выполнения программы. Эту ситуацию можно проиллюстрировать следующим образом (здесь 1 и 0 означают значения битов в памяти).



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

int (120)
или
char ('x')
, если учитывать только младшие биты. Бит — это единица памяти компьютера, которая может хранить либо 0, либо 1.

Смысл двоичных чисел описан в разделе А.2.1.1.

3.9. Типовая безопасность

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


int main()

{

 double x;     // мы забыли проинициализировать переменную х:

           // ее значение не определено

 double y = x;   // значение переменной y не определено

 double z = 2.0+x; // смысл операции + и значение переменной z

           // не определены

}


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

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

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

3.9.1. Безопасные преобразования

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

char
или сравнивать объекты типов
double
и
int
. Однако в языке С++ это можно сделать косвенным образом. При необходимости объект типа
char
можно преобразовать в объект типа
int
, а объект типа
int
— в объект типа
double
. Рассмотрим пример.


char c = 'x';

int i1 = c;

int i2 = 'x';


Здесь значения переменных

i1
и
i2
равны
120
, т.е. 8-битовому ASCII коду символа
'x'
. Это простой и безопасный способ получения числового представления символа. Мы называем это преобразование типа
char
в тип
int
безопасным, поскольку при этом не происходит потери информации; иначе говоря, мы можем скопировать результат, хранящийся в переменной типа
int
, обратно в переменную типа
char
и получить исходное значение.


char c2 = i1;

cout << c << ' ' << i1 << ' ' << c2 << '\n';


Этот фрагмент программы выводит на экран следующий результат:


x 120 x


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

double
) в наилучшее приближение эквивалентного значения, — такие преобразования являются безопасными.


bool
в
char

bool
в
int

bool
в
double

char
в
int

char
в
double

int
в
double


Наиболее полезным является преобразование переменной типа

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


double d1 = 2.3;

double d2 = d1+2;  // перед сложением число преобразуется в число 2.0

if (d1 < 0)    // перед сравнением число 0 преобразуется в число 0.0

  cout("d1 — отрицательно");


Для действительно больших чисел типа

int
при их преобразовании в переменные типа
double
мы можем (в некоторых компьютерах) потерять точность. Однако эта проблема возникает редко.

3.9.2. Опасные преобразования

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

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


int main()

{

 int a = 20000;

 char c = a;  // попытка втиснуть большое значение типа int

        // в маленькую переменную типа char

 int b = c;

 if (a != b)  // != означает "не равно"

   cout << "Ой!: " << a << "!=" << b << '\n';

 else

   cout << "Ого! Мы получили большие значения типа char\n";

}


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

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


int main()

{

 double d = 0;

 while (cin>>d) {        // повторяем последующие инструкции,

                // пока мы вводим целые числа

  int i = d;         // попытка втиснуть double в int

  char c = i;         // попытка втиснуть int в char

  int i2 = c;         // получаем целое значение переменной типа char

  cout << " d==" << d     // исходное значение типа double

  << " i==" << i       // преобразуется в значение типа int

  << " i2==" << i2      // целое значение переменной типа char

  << " char(" << c << ")\n"; // значение типа char

 }

}


Использованная в этой программе инструкция

while
позволяет ввести много значений (см. раздел 4.4.2.1).


ПОПРОБУЙТЕ

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

2
и
3
); большие значения (больше чем
127
, больше чем
1000
); отрицательные значения; введите число
56
;
89
;
128
; неотрицательные целые числа (например,
56.9
и
56.2
). Кроме демонстрации преобразования типа
double
в тип
int
и типа
int
в тип
char
на вашем компьютере, эта программа показывает, какое значение типа
char
выводится для заданного целого числа.


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


double
в
int

double
в
char

double
в
bool

int
в
char

int
в
bool

char
в
bool


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


double x = 2.7;

// какой-то код

int y = x; // значение переменной y становится равным 2


С момента определения переменной

y
вы могли забыть, что переменная
x
имеет тип
double
, или упустить из виду, что преобразование
double
в
int
приводит к усечению (округлению вниз). Результат вполне предсказуем: семь десятых потеряны. Преобразование
int
в
char
не порождает проблем с усечением — ни тип
int
, ни тип
char
невозможно представить в виде дробной части целого числа. Однако переменная типа
char
может хранить только очень небольшие целые числа. В персональных компьютерах переменная типа
char
занимает 1 байт, в то время как переменная типа
int
— 4 байта.



Итак, мы не можем записать большое число, например 1000, в переменную типа

char
без потери информации: значение “сужается”. Рассмотрим пример.


int a = 1000;

char b = a; // переменная b становится равной –24


Не все значения типа

int
эквивалентны значению типа
char
. Точный диапазон значения типа
char
зависит от конкретной реализации. На персональных компьютерах значения типа
char
колеблются в диапазоне [–128:127], но мобильность программ можно обеспечить только в диапазоне [0:127], поскольку не каждый компьютер является персональным, и на некоторых из них значения типа
char
лежат в диапазоне [0:255].

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

Итак, что делать, если вы подозреваете, что преобразование может привести к неверным результатам? Перед присваиванием проверьте значение, как это сделано в рассмотренном примере. Более простой способ такой проверки описан в разделах 5.6.4 и 7.4.


Задание

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

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

first_name
”, где
first_name
— это имя, введенное пользователем. Затем модифицируйте программу следующим образом: измените приглашение на строку “Введите имя адресата” и измените вывод на строку “Dear
first_name
,”. Не забудьте о запятой.

2. Введите одну или две вступительные фразы, например “Как дела? У меня все хорошо. Я скучаю по тебе”. Убедитесь, что первая строка отделена от других. Добавьте еще несколько строк по своему усмотрению — это же ваше письмо.

3. Предложите пользователю ввести имя другого приятеля и сохраните его в переменной

friend_name
. Добавьте в ваше письмо следующую строку: “Видел ли ты
friend_name
недавно?”.

4. Объявите переменную типа

char
с именем
friend_sex
и инициализируйте его нулем. Предложите пользователю ввести значение
m
, если ваш друг — мужчина, и
f
— если женщина. Присвойте переменной
friend_sex
введенное значение. Затем с помощью двух инструкций
if
запишите следующее. Если друг — мужчина, то напишите строку: “Если ты увидишь
friend_name
, пожалуйста, попроси его позвонить мне”. Если друг — женщина, то напишите строку: “Если ты увидишь
friend_name
, пожалуйста, попроси ее позвонить мне”.

5. Предложите пользователю ввести возраст адресата и присвойте его переменной

age
, имеющей тип
int
. Ваша программа должна вывести на экран строку: “Я слышал, ты только что отметил день рождения и тебе исполнилось
age
лет”. Если значение переменной
age
меньше или равно 0 или больше или равно 110, выведите на экран строку
simple_error("ты шутишь!")
, используя функцию
simple_error()
из заголовочного файла
std_lib_facilities.h
.

6. Добавьте в ваше письмо следующие строки Если вашему другу меньше 12 лет, напишите: “На следующий год тебе исполнится

age+1
лет”. Если вашему другу 18 лет, напишите: “На следующий год ты сможешь голосовать”. Если вашему другу больше 60 лет, напишите: “Я надеюсь, что ты не скучаешь на пенсии”. Убедитесь, что ваша программа правильно обрабатывает каждое из этих значений.

7. Добавьте строку “Искренне твой,” затем введите две пустые строки для подписи и укажите свое имя.


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

1. Что подразумевается под приглашением?

2. Какой оператор используется для ввода переменной?

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

number
?

4. Как называется символ

\n
и для чего он предназначен?

5. Что является признаком конца строки?

6. Как прекращается ввод значения в целочисленную переменную?

7. Как записать

cout << "Hello, ";

cout << first_name;

cout << "!\n";

в одной строке?

8. Что такое объект?

9. Что такое литерал?

10. Какие существуют виды литералов?

11. Что такое переменная?

12. Назовите типичные размеры переменных типов

char
,
int
и
double
?

13. В каких единицах измеряется объем памяти, занимаемой небольшими переменными, например объектами типов

int
и
string
?

14. В чем заключается разница между операторами

=
и
==
?

15. Что такое определение?

16. Что такое инициализация и чем она отличается от присваивания?

17. Что такое конкатенация строк и как она выполняется в языке С++?

18. Какие из следующих имен являются допустимыми в языке С++? Если имя является недопустимым, то укажите, по какой причине.

This_little_pig This_1_is fine 2_For_1_special

latest thing the_$12_method _this_is_ok

MiniMineMine number correct?

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

20. Сформулируйте разумные правила для выбора имен.

21. Что такое типовая безопасность и почему она так важна?

22. Почему преобразование типа

double
в тип
int
может привести к неверным результатам?

23. Сформулируйте правило, помогающее выявить безопасные и опасные преобразования типов.


Термины


Упражнения

1. Выполните задание из раздела ПОПРОБУЙТЕ, если вы не сделали его раньше.

2. Напишите программу на языке C++, которая преобразует мили в километры. Ваша программа должна содержать понятное приглашение пользователю ввести количество миль. Подсказка: в одной миле 1,609 км.

3. Напишите программу, которая ничего не делает, а просто объявляет переменные с допустимыми и недопустимыми именами (например,

int double = 0;
), и посмотрите на реакцию компилятора.

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

int
с именами
val1
и
val2
. Напишите программу, определяющую наименьшее и наибольшее значение, а также сумму, разность, произведение и частное этих значений.

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

double
. Сравните результаты работы этих двух программ на нескольких вариантах. Совпадают ли эти результаты? Должны ли они совпадать? Чем они отличаются?

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

7. Выполните упр. 6 для трех строковых значений. Так, если пользователь вводит значения "

Steinbeck
", "
Hemingway
", "
Fitzgerald
", то программа должна вывести на экран строку "
Fitzgerald, Hemingway, Steinbeck
".

8. Напишите программу, проверяющую четность или нечетность целого числа. Как всегда, убедитесь, что результат ясен и полон. Иначе говоря, не следует ограничиваться простой констатацией вроде “да” или “нет”. Вывод должен быть информативным, например “Число 4 является четным”. Подсказка: см. оператор вычисления остатка в разделе 3.4.

9. Напишите программу, преобразующую слова “нуль”, “два” и т.д. в цифры 0, 2 и т.д. Когда пользователь вводит число в виде слова, программа должна вывести на экран соответствующую цифру. Выполните эту программу для цифр 0, 1, 2, 3 и 4. Если пользователь введет что-нибудь другое, например фразу “глупый компьютер!”, программа должна ответить “Я не знаю такого числа!”

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

+ 100 3.14

* 4 5

Считайте символ операции в объект типа

string
с именем
operation
и, используя инструкцию
if
, выясните, какую операцию хочет выполнить пользователь, например
if (operation=="+")
. Считайте операнды в переменные типа
double
. Выполните операции с именами
+
,
,
*
,
/
,
plus
,
minus
,
mul
и
div
, имеющие очевидный смысл.

11. Напишите программу, предлагающую пользователю ввести определенное количество 1-, 5-, 10-, 25-, 50-центовых и долларовых монет. Пользователь должен по отдельности ввести количество монет каждого достоинства, например “Сколько у вас одноцентовых монет?” Результат должен выглядеть следующим образом.

У вас 23 одноцентовые монеты.

У вас 17 пятицентовых монет.

У вас 14 десятицентовых монет.

У вас 7 25-центовых монет.

У вас 3 50-центовые монеты.

Общая стоимость ваших монет равна 573 центам.

Усовершенствуйте программу: если у пользователя только одна монета, выведите ответ в грамматически правильной форме. Например, “14 десятицентовых монет” и “1 одноцентовая монета” (а не “1 одноцентовых монет”). Кроме того, выведите результат в долларах и центах, т.е. 5,73 доллара, а не 573 цента.


Послесловие

Не следует недооценивать важность типовой безопасности. Тип — наиболее важное понятие для создания правильных программ, и некоторые из наиболее эффективных методов разработки программ основаны на разработке и использовании типов (см. главы 6 и 9, части II–IV).

Глава 4 Вычисления

Если результат не обязательно должен быть точным,

я могу вычислить его сколь угодно быстро”.

Джеральд Вайнберг (Gerald M. Weinberg)


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

vector
, предназначенный для хранения последовательностей значений.

4.1. Вычисления

Все программы что-нибудь вычисляют; иначе говоря, они получают на вход какие-то данные и выводят какие-то результаты. Кроме того, само устройство, на котором выполняются программы, называется компьютером[5]. Эта точка зрения является правильной и обоснованной, пока мы придерживаемся широкой трактовки ввода и вывода.



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

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

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

С программистской точки зрения наиболее важными и интересными категориями ввода-вывода являются “в другую программу” и “в другие части программы”. Большая часть настоящей книги посвящена последней категории: как представить программу в виде взаимодействующих частей и как обеспечить взаимный доступ к данным и обмен информацией. Это ключевые вопросы программирования. Проиллюстрируем их графически.



Аббревиатура I/O означает ввод-вывод. В данном случае вывод из одной части программы является вводом в следующую часть. Эти части программы имеют доступ к данным, хранящимся в основной памяти, на постоянном устройстве хранения данных (например, на диске) или передающимся через сетевые соединения. Под частями программы мы подразумеваем сущности, такие как функция, вычисляющая результат на основе полученных аргументов (например, извлекающая корень квадратный из числа с плавающей точкой), функция, выполняющая действия над физическими объектами (например, рисующая линию на экране), или функция, модифицирующая некую таблицу в программе (например, добавляющая имя в таблицу клиентов).

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

Вычислением мы называем некое действие, создающее определенные результаты и основанное на определенных входных данных, например порождение результата (вывода), равного 49, на основе аргумента (ввода), равного 7, с помощью вычисления (функции) извлечения квадратного корня (см. раздел 4.5). Как курьезный факт, напомним, что до 1950-х годов компьютером[6] в США назывался человек, выполнявший вычисления, например бухгалтер, навигатор, физик. В настоящее время мы просто перепоручили большинство вычислений компьютерам (машинам), среди которых простейшими являются калькуляторы.

4.2. Цели и средства

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

• правильно;

• просто;

• эффективно.


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

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

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

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

sort(b, e)
, где
b
и
e
— начало и конец телефонной книги соответственно. Другой пример связан с использованием памяти компьютера. Непосредственное использование памяти может быть довольно сложным, поэтому чаще к участкам памяти обращаются через переменные, имеющие тип и имя (раздел 3.2), объекты класса
vector
из стандартной библиотеки (раздел 4.6, главы 17–19), объекты класса
map
(глава 21) и т.п.

• “Разделяй и властвуй”. Этот способ подразумевает разделение большой задачи на несколько меньших задач. Например, если требуется создать словарь, то работу можно разделить на три части: чтение, сортировка и вывод данных. Каждая из новых задач намного меньше исходной.


Чем это может помочь? Помимо всего прочего, программа, созданная из частей, обычно немного больше, чем программа, в которой все фрагменты оптимально согласованы друг с другом. Причина заключается в том, что мы плохо справляемся в большими задачами. Как правило, как в программировании, так и в жизни, — мы разбиваем их на меньшие части, полученные части разделяем на еще более мелкие, пока не получим достаточно простую задачу, которую легко понять и решить. Возвращаясь к программированию, легко понять, что программа, состоящая из 1000 строк, содержит намного больше ошибок, чем программа, состоящая из 100 строк, поэтому стоит разделить большую программу на части, размер которых меньше 100 строк. Для более крупных программ, скажем, длиной более 10 тыс. строк, применение абстракции и метода “разделяй и властвуй” является даже не пожеланием, а настоятельным требованием.

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

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

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

4.3. Выражения

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

a
',
3.14
или
"Norah"
.

Имена переменных также являются выражениями. Переменная — это объект, имеющий имя. Рассмотрим пример.


// вычисление площади:

int length = 20; // литеральное целое значение

         // (используется для инициализации переменной)

int width = 40;

int area = length*width; // умножение


Здесь литералы

20
и
40
используются для инициализации переменных, соответствующих длине и ширине. После этого длина и ширина перемножаются; иначе говоря, мы перемножаем значения
length
и
width
. Здесь выражение “значение
length
” представляет собой сокращение выражения “значение, хранящееся в объекте с именем
length
”. Рассмотрим еще один пример.


length = 99; // присваиваем length значение 99


Здесь слово

length
, обозначающее левый операнд оператора присваивания, означает “объект с именем
length
”, поэтому это выражение читается так: “записать число 99 в объект с именем
length
”. Следует различать имя
length
, стоящее в левой части оператора присваивания или инициализации (оно называется “
lvalue
переменной
length
”) и в правой части этих операторов (в этом случае оно называется “
rvalue
переменной
length
”, “значением объекта с именем
length
”, или просто “значением
length
”). В этом контексте полезно представить переменную в виде ящика, помеченного именем.



Иначе говоря,

length
— это имя объекта типа
int
, содержащего значение 99. Иногда (в качестве
lvalue
) имя
length
относится к ящику (объекту), а иногда (в качестве
rvalue
) — к самому значению, хранящемуся в этом ящике.

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

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


int perimeter = (length+width)*2; // сложить и умножить


Без скобок это выражение пришлось бы записать следующим образом:


int perimeter = length*2+width*2;


что слишком громоздко и провоцирует ошибки.


int perimeter = length+width*2; // сложить width*2 с length


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

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

В программах применяются обычные математические правила, регламентирующие порядок выполнения операторов, поэтому

length+width*2
означает
length+(width*2)
. Аналогично выражение
a*b+c/d
означает
(a*b)+(c/d)
, а не
a*(b+c)/d
. Таблица приоритетов операторов приведена в разделе A.5.

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

a*b+c/d
. Слишком широкое использование операторов, например
(a*b)+(c/d)
, снижает читабельность программы.

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


a*b+c/d*(e–f/g)/h+7 // слишком сложно


и всегда старайтесь выбирать осмысленные имена.

4.3.1. Константные выражения

В программах, как правило, используется множество констант. Например, в программе для геометрических вычислений может использоваться число “пи”, а в программе для пересчета дюймов в сантиметры — множитель 2.54. Очевидно, что этим константам следует приписывать осмысленные имена (например,

pi
, а не
3.14159
). Аналогично, константы не должны изменяться случайным образом. По этой причине в языке С++ предусмотрено понятие символической константы, т.е. именованного объекта, которому после его инициализации невозможно присвоить новое значение. Рассмотрим пример.


const double pi = 3.14159;

pi = 7;  // ошибка: присваивание значения константе

double c = 2*pi/r; // OK: мы просто используем переменную pi,

          // а не изменяем ее


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

3.14159
является приближением числа “пи”, но что вы скажете о числе
299792458
? Кроме того, если вас попросят изменить программу так, чтобы число “пи” было записано с точностью до 12 десятичных знаков, то, возможно, вы станете искать в программе число
3.14
, но если кто-нибудь неожиданно решил аппроксимировать число “пи” дробью
22/7
, то, скорее всего, вы ее не найдете. Намного лучше изменить определение константы
pi
, указав требуемое количество знаков.


const double pi = 3.14159265359;


Следовательно, в программах предпочтительнее использовать не литералы (за исключением самых очевидных, таких как

0
и
1
). Вместо них следует применять константы с информативными именами. Неочевидные литералы в программе (за рамками определения констант) насмешливо называют “магическими”.

В некоторых местах, например в метках оператора

case
(см. раздел 4.4.1.3), язык С++ требует использовать константные выражения, т.е. выражения, имеющие целочисленные значения и состоящие исключительно из констант. Рассмотрим пример.


const int max = 17; // литерал является константным выражением

int val = 19;

max+2 // константное выражение (константа плюс литерал)

val+2 // неконстантное выражение: используется переменная


Кстати, число

299792458
— одна из универсальных констант Вселенной, означающая скорость света в вакууме, измеренную в метрах в секунду. Если вы ее сразу не узнали, то вполне возможно, будете испытывать трудности при распознавании остальных констант в программе. Избегайте “магических” констант!

4.3.2. Операторы

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



В выражениях, в которых оператор изменяет операнд, мы использовали имя

lval
(сокращение фразы “значение, стоящее в левой части оператора присваивания”). Полный список операторов приведен в разделе А.5.

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

&&
(И),
||
(ИЛИ) и
!
(НЕ) приведены в разделах 5.5.1, 7.7, 7.8.2 и 10.4.

Обратите внимание на то, что выражение

a
означает
(a
, а значение выражения
a
имеет тип
bool
, т.е. оно может быть либо
true
, либо
false
. Итак, выражение
a
эквивалентно тому, что выполняется либо неравенство
true
, либо неравенство
false
. В частности, выражение
a
не означает “Лежит ли значение
b
между значениями
a
и
c
?”, как многие наивно (и совершенно неправильно) думают. Таким образом, выражение
a
в принципе является бесполезным. Не используйте такие выражения с двумя операциями сравнения и настораживайтесь, когда видите их в чужой программе — скорее всего, это ошибка.

Инкрементацию можно выразить по крайней мере тремя способами:


++a

a+=1

a=a+1


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

++a
, поскольку он точнее остальных отражает идею инкрементации. Он показывает, что мы хотим сделать (добавить к значению переменной
a
единицу и записать результат в переменную). В целом всегда следует выбирать тот способ записи, который точнее выражает вашу идею. Благодаря этому ваша программа станет точнее, а ее читатель быстрее в ней разберется. Если мы запишем
a=a+1
, то читатель может засомневаться, действительно ли мы хотели увеличить значение переменной
a
на единицу. Может быть, мы просто сделали опечатку вместо
a=b+1
,
a=a+2
или даже
a=a–1
; если же в программе будет использован оператор
++a
, то простора для сомнений останется намного меньше. Пожалуйста, обратите внимание на то, что этот аргумент относится к области читабельности и корректности программы, но не к ее эффективности. Вопреки распространенному мнению, если переменная
a
имеет встроенный тип, то современные компиляторы для выражений
a=a+1
и
++a
, как правило, генерируют совершенно одинаковые коды. Аналогично, мы предпочитаем использовать выражение
a *= scale
, а не
a = a*scale
.

4.3.3. Преобразования

Типы в выражениях можно “смешивать”. Например, выражение

2.5/2
означает деление переменной типа
double
на переменную типа
int
. Что это значит? Какое деление выполняется: целых чисел или с плавающей точкой? Целочисленное деление отбрасывает остаток, например
5/2
равно
2
. Деление чисел с плавающей точкой отличается тем, что остаток в его результате не отбрасывается; например
5.0/2.0
равно
2.5
. Следовательно, ответ на вопрос “Какие числа делятся в выражении
2.5/2
: целые или с плавающей точкой?” совершенно очевиден: “Разумеется, с плавающей точкой; в противном случае мы потеряли бы информацию”. Мы хотели бы получить ответ
1.25
, а не
1
, и именно
1.25
мы и получим. Правило (для рассмотренных нами типов) гласит: если оператор имеет операнд типа
double
, то используется арифметика чисел с плавающей точкой и результат имеет тип
double
; в противном случае используется целочисленная арифметика, и результат имеет тип
int
.

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


5/2 равно 2 (а не 2.5)

2.5/2 равно 2.5/double(2), т.е. 1.25

'a'+1 означает int('a')+1


Иначе говоря, при необходимости компилятор преобразовывает (“продвигает”) операнд типа

int
в операнд типа
double
, а операнд типа
char
— в операнд типа
int
. Вычислив результат, компилятор может преобразовать его снова для использования при инициализации или в правой части оператора присваивания. Рассмотрим пример.


double d = 2.5;

int i = 2;

double d2 = d/i; // d2 == 1.25

int i2 = d/i;   // i2 == 1

d2 = d/i;     // d2 == 1.25

i2 = d/i;     // i2 == 1


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

f = 9/5*с+32
. Ее можно записать так:


double dc;

cin >> dc;

double df = 9/5*dc+32; // осторожно!


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

9/5
равно
1
, а не
1.8
, как мы рассчитывали. Для того чтобы формула стала правильной, либо
9
, либо
5
(либо оба числа) следует представить в виде константы типа
double
.


double dc;

cin >> dc;

double df = 9.0/5*dc+32; // лучше

4.4. Инструкции

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

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


a = b;

++b;


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

=
— это оператор, поэтому
a=b
— это выражение, и для его завершения необходимо поставить точку с запятой
a=b
; в итоге возникает инструкция. Зачем нужна точка с запятой? Причина носит скорее технический характер.

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


a = b ++ b; // синтаксическая ошибка: пропущена точка с запятой


Без точки с запятой компилятор не знает, что означает это выражение:

a=b++; b;
или
a=b; ++b;
. Проблемы такого рода не ограничиваются языками программирования. Например, рассмотрим выражение “Казнить нельзя помиловать!” Казнить или помиловать?! Для того чтобы устранить неоднозначность, используются знаки пунктуации. Так, поставив запятую, мы полностью решаем проблему: “Казнить нельзя, помиловать!” Когда инструкции следуют одна за другой, компьютер выполняет их в порядке записи. Рассмотрим пример.


int a = 7;

cout << a << '\n';


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


1+2; // выполняется сложение, но сумму использовать невозможно

a*b; // выполняется умножение, но произведение не используется


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

Упомянем еще об одной разновидности: пустой инструкции. Рассмотрим следующий код:


if (x == 5);

{ y = 3; }


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

Что произойдет, когда эта программа начнет выполняться? Компилятор проверит, равно ли значение переменной

x
числу
5
. Если это условие истинно, то будет выполнена следующая инструкция (пустая). Затем программа перейдет к выполнению следующей инструкции, присвоив переменной
y
число
3
. Если же значение переменной
x
не равно
5
, то компилятор не будет выполнять пустую инструкцию (что также не порождает никакого эффекта) и присвоит переменной y число
3
(это не то, чего вы хотели, если значение переменной
x
не равно
5
).

Иначе говоря, эта инструкция

if
присваивает переменной
y
число
3
независимо от значения переменной
x
. Эта ситуация типична для программ, написанных новичкам, причем такие ошибки трудно обнаружить.

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

4.4.1. Инструкции выбора

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

if
и
switch
.

4.4.1.1. Инструкции if

Простейшая форма выбора в языке С++ реализуется с помощью инструкции

if
, позволяющей выбрать одну из двух альтернатив. Рассмотрим пример.


int main()

{

 int a = 0;

 int b = 0;

 cout << "Пожалуйста, введите два целых числа \n";

 cin >> a >> b;

  if (a

      // 1-я альтернатива (выбирается, если условие истинно):

    cout << "max(" << a << "," << b <<") равно " << b <<"\n";

 else

       // 2-я альтернатива (выбирается, когда условие ложно):

    cout << "max(" << a << "," << b <<") равно " << a << "\n";

}


Инструкция

if
осуществляет выбор из двух альтернатив. Если его условие является истинным, то выполняется первая инструкция; в противном случае выполняется вторая. Это простая конструкция. Она существует в большинстве языков программирования. Фактически большинство основных конструкций в языках программирования представляют собой просто новое обозначение понятий, известных всем еще со школьной скамьи или даже из детского сада. Например, вам, вероятно, говорили в детском саду, что, для того чтобы перейти улицу, вы должны дождаться, пока на светофоре не загорится зеленый свет: “если горит зеленый свет, то можно переходить, а если горит красный свет, то необходимо подождать”. В языке С++ это можно записать как-то так:


if (traffic_light==green) go();

if (traffic_light==red) wait();


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

#include
).


// преобразование дюймов в сантиметры и наоборот

// суффикс 'i' или 'c' означает единицу измерения на входе

int main()

{

  const double cm_per_inch = 2.54; // количество сантиметров

                   // в дюйме

  double length = 1;  // длина в дюймах или

            // сантиметрах

 char unit = 0;

 cout<< "Пожалуйста, введите длину и единицу измерения
 (c или i):\n";

 cin >> length >> unit;

 if (unit == 'i')

   cout << length << "in == " << cm_per_inch*length << "cm\n";

 else

    cout << length << "cm == " << length/cm_per_inch << "in\n";

}


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

1i
, и вы получите сообщение
1in==2.54cm
введите
2.54c
, и вы получите сообщение
2.54cm==1in
. Поэкспериментируйте — это полезно.

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

unit=='i'
отличает единицу измерения
'i'
от любых других вариантов. Она никогда не проверяет его для единицы измерения
'c'
.

Что произойдет, если пользователь введет

15f
(футов) “просто, чтобы посмотреть, что будет”? Условие (
unit=='i'
) станет ложным, и программа выполнит часть инструкции
else
(вторую альтернативу), преобразовывая сантиметры в дюймы. Вероятно, это не то, чего вы хотели, вводя символ
'f'
.

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

Приведем улучшенную версию программы.


// преобразование дюймов в сантиметры и наоборот

// суффикс 'i' или 'c' означает единицу измерения на входе

// любой другой суффикс считается ошибкой

int main()

{

 const double cm_per_inch = 2.54; // количество сантиметров

                  // в дюйме

 double length = 1;        // длина в дюймах или сантиметрах

 char unit = ' ';         // пробел - не единица измерения

 cout<< "Пожалуйста, введите длину и единицу измерения (
c или i):\n";

 cin >> length >> unit;

 if (unit == 'i')

    cout << length << "in == " << cm_per_inch*length << "cm\n";

 else if (unit == 'c')

   cout << length << "cm == " << length/cm_per_inch << "in\n";

 else

    cout << "Извините, я не знаю, что такое '" << unit << "'\n";

}


Сначала мы проверяем условие

unit=='i'
, а затем условие
unit=='c'
. Если ни одно из этих условий не выполняется, выводится сообщение "
Извините, ...
". Это выглядит так, будто вы использовали инструкцию "
else-if
", но такой инструкции в языке С++ нет. Вместо этого мы использовали комбинацию двух инструкций
if
. Общий вид инструкции
if
выглядит так:


if (выражение) инструкция else инструкция


Иначе говоря, за ключевым словом

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


if (выражение) инструкция else if (выражение) инструкция else инструкция


В нашей программе этот примем использован так:


if (unit == 'i')

  ... // 1-я альтернатива

else if (unit == 'c')

  ... // 2-я альтернатива

else

  ... // 3-я альтернатива


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


ПОПРОБУЙТЕ

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

4.4.1.2. Инструкции switch

Сравнение единиц измерения с символами

'i'
и
'c'
представляет собой наиболее распространенную форму выбора: выбор, основанный на сравнении значения с несколькими константами. Такой выбор настолько часто встречается на практике, что в языке C++ для него предусмотрена отдельная инструкция:
switch
. Перепишем наш пример в ином виде


int main()

{

 const double cm_per_inch = 2.54; // количество сантиметров

                   // в дюйме

  double length = 1; // длина в дюймах или сантиметрах

 char unit = 'a';

 cout<< "Пожалуйста, введите длину и единицу измерения 
(c или i):\n";

 cin >> length >> unit;

 switch (unit) {

 case 'i':

   cout << length << " in == " << cm_per_inch*length << " cm\n";

    break;

 case 'c':

   cout << length << " cm == " << length/cm_per_inch << " in\n";

   break;

 default:

   cout << "Извините, я не знаю, что такое '" << unit << "'\n";

   break;

 }

}


Синтаксис оператора

switch
архаичен, но он намного яснее вложенных инструкций
if
, особенно если необходимо сравнить значение со многими константами. Значение, указанное в скобках после ключевого слова
switch
, сравнивается с набором констант. Каждая константа представлена как часть метки
case
. Если значение равно константе в метке
case
, то выбирается инструкция из данного раздела
case
. Каждый раздел case завершается ключевым словом
break
. Если значение не соответствует ни одной метке
case
, то выбирается оператор, указанный в разделе
default
. Этот раздел не обязателен, но желателен, чтобы гарантировать перебор всех альтернатив. Если вы еще не знали, то знайте, что программирование приучает человека сомневаться практически во всем.

4.4.1.3. Технические подробности инструкции switch

Здесь под техническими подробностями подразумеваются следующие детали, касающиеся инструкции

switch
.

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

int
,
char
или
enum
(см. раздел 9.5). В частности, переключение по строке произвести невозможно.

2. Значения меток разделов

case
должны быть константными выражениями (см. раздел 4.3.1). В частности, переменная не может быть меткой раздела
case
.

3. Метки двух разделов

case
не должны иметь одинаковые значения.

4. Один раздел

case
может иметь несколько меток.

5. Не забывайте, что каждый раздел

case
должен завершаться ключевым словом
break
. К сожалению, компилятор не предупредит вас, если вы забудете об этом.


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


int main() // переключение можно производить только по целым

      // числам и т.п.

{

 cout << "Вы любите рыбу?\n";

 string s;

 cin >> s;

 switch (s) { // ошибка: значение должно иметь тип int,

        // char или enum

 case " нет ":

   // ...

   break;

 case " да ":

   // ...

   break;

 }

}


Для выбора альтернатив по строке следует использовать инструкцию

if
или ассоциативный массив (подробнее об этом речь пойдет в главе 21). Инструкция
switch
генерирует оптимизированный код для сравнения значения с набором констант. Для крупных наборов констант он обычно создает более эффективный код по сравнению с коллекцией инструкций
if
. Однако это значит, что значения меток разделов
case
должны быть разными константами. Рассмотрим пример.


int main() // метки разделов case должны быть константами

{

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

 int y = 'y'; // это может создать проблемы

 const char n = 'n';

 const char m = '?';

 cout << "Вы любите рыбу ?\n";

 char a;

 cin >> a;

  switch (a) {

 case n:

   // ...

   break;

 case y:  // ошибка: переменная метка раздела case

   // ...

   break;

 case m:

   // ...

   break;

 case 'n': // ошибка: дубликат метки раздела case

       // (значение метки n равно 'n')

   // ...

   break;

 default:

    // ...

   break;

 }

}


Часто для разных значений инструкции

switch
целесообразно выполнить одно и то же действие. Было бы утомительно повторять это действие для каждой метки из этого набора. Рассмотрим пример.


int main() // одна инструкция может иметь несколько меток

{

 cout << "Пожалуйста, введите цифру \n";

 char a;

 cin >> a;

 switch (a) {

 case '0': case '2': case '4': case '6': case '8':

   cout << " четная \n";

   break;

 case '1': case '3': case '5': case '7': case '9':

   cout << " нечетная \n";

   break;

 default:

   cout << " не цифра \n";

   break;

 }

}


Чаще всего, используя инструкцию

switch
, программисты забывают завершить раздел
case
ключевым словом
break
. Рассмотрим пример.


int main() // пример плохой программы (забыли об инструкции break)

{

 const double cm_per_inch = 2.54; // количество сантиметров

                  // в дюйме

 double length = 1;  // длина в дюймах или сантиметрах

 char unit = 'a';

 cout << "Пожалуйста, введите длину и единицу 
измерения (c или i):\n";

 cin >> length >> unit;

 switch (unit) {

 case 'i':

   cout << length << "in == " << cm_per_inch*length << "cm\n";

 case 'c':

   cout << length << "cm == " << length/cm_per_inch << "in\n";

 }

}


К сожалению, компилятор примет этот текст, и когда вы закончите выполнение раздела

case
с меткой
'i'
, просто “провалитесь” в раздел case с меткой
'c'
, так что при вводе строки
2i
программа выведет на экран следующие результаты:


2in == 5.08cm

2cm == 0.787402in


Мы вас предупредили!


ПОПРОБУЙТЕ

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

switch
. Добавьте конвертацию юаня и кроны. Какую из версий программы легче писать, понимать и модифицировать? Почему?

4.4.2. Итерация

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

4.4.2.1. Инструкции while

В качестве примера итерации рассмотрим первую программу, выполненную на компьютере EDSAC. Она была написана Дэвидом Уилером (David Wheeler) в компьютерной лаборатории Кэмбриджского университета (Cambridge University, England) 6 мая 1949 года. Эта программа вычисляет и распечатывает простой список квадратов.


0 0

1 1

2 4

3 9

4 16

...

98 9604

99 9801


Здесь в каждой строке содержится число, за которым следуют знак табуляции (

'\t'
) и квадрат этого числа. Версия этой программы на языке C++ выглядит так:


// вычисляем и распечатываем таблицу квадратов чисел 0–99

int main()

{

 int i = 0;  // начинаем с нуля

 while (i<100) {

   cout << i << '\t' << square(i) << '\n';

   ++i;    // инкрементация i (т.е. i становится равным i+1)

  }

}


Обозначение

square(i)
означает квадрат числа
i
. Позднее, в разделе 4.5, мы объясним, как это работает.

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

• Вычисления начинаются с нуля.

• Проверяем, не достигли ли мы числа 100, и если достигли, то завершаем вычисления.

• В противном случае выводим число и его квадрат, разделенные символом табуляции (

'\t'
), увеличиваем число и повторяем вычисления. Очевидно, что для этого необходимо сделать следующее.

• Способ для повторного выполнения инструкции (цикл).

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

int
и называется
i
.

• Начальное значение счетчика цикла (в данном случае — 0).

• Критерий прекращения вычислений (в данном случае мы хотим выполнить возведение в квадрат 100 раз).

• Сущность, содержащая инструкции, находящиеся в цикле (тело цикла).


В данной программе мы использовали инструкцию

while
. Сразу за ключевым словом
while
следует условие и тело цикла.


while (i<100) // условие цикла относительно счетчика i

{

  cout << i << '\t' << square(i) << '\n';

  ++i; // инкрементация счетчика цикла i

}


Тело цикла — это блок (заключенный в фигурные скобки), который распечатывает таблицу и увеличивает счетчик цикла

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

Счетчик цикла для инструкции

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


int i = 0; // начинаем вычисления с нуля


и все станет хорошо.

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


ПОПРОБУЙТЕ

Символ

'b'
равен
char('a'+1)
,
'c'
— равен
char('a'+2)
и т.д. Используя цикл, выведите на экран таблицу символов и соответствующих им целых чисел.


a 97

b 98

...

z 122

4.4.2.2. Блоки

Обратите внимание на то, как мы сгруппировали две инструкции, подлежащие выполнению.


while (i<100) {

  cout << i << '\t' << square(i) << '\n';

  ++i; // инкрементация i (т.е. i становится равным i+1)

}


Последовательность инструкций, заключенных в фигурные скобки (

{
и
}
), называется блоком, или составной инструкцией. Блок — это разновидность инструкции. Пустой блок (
{}
) иногда оказывается полезным для выражения того, что в данном месте программы не следует ничего делать. Рассмотрим пример.


if (a<=b) { // ничего не делаем

}

else {    // меняем местами a и b

  int t = a;

 a = b;

  b = t;

}

4.4.2.3. Инструкции for

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

for
похожа на инструкцию
while
за исключением того, что управление счетчиком цикла сосредоточено в его начале, где за ним легко следить. Первую программу можно переписать так:


// вычисляем и распечатываем таблицу квадратов чисел 0–99

int main()

{

 for (int i = 0; i<100; ++i)

   cout << i << '\t' << square(i) << '\n';

}


Это значит: “Выполнить тело цикла, начиная с переменной

i
, равной нулю, и увеличивать ее на единицу при каждом выполнении тела цикла, пока переменная
i
не станет равной
100
”. Инструкция
for
всегда эквивалентна некоей инструкции
while
. В данном случае конструкция


for (int i = 0; i<100; ++i)

  cout << i << '\t' << square(i) << '\n';


эквивалентна


{

 int i = 0;    // инициализатор инструкции for

 while (i<100) { // условие инструкции for

   cout << i << '\t' << square(i) << '\n'; // тело инструк
ции for

   ++i;     // инкрементация инструкции for

 }

}


Некоторые новички предпочитают использовать инструкции

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

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

for
. Это нарушит все разумные предположения читателя программы о содержании цикла. Рассмотрим пример.


int main()

{

  for (int i = 0; i<100; ++i) { // для i из диапазона [0:100)

   cout << i << '\t' << square(i) << '\n';

   ++i; // Что это? Похоже на ошибку!

 }

}


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

++i
в его теле гарантирует, что счетчик каждый раз будет инкрементирован дважды, так что вывод будет осуществлен только для 50 четных чисел. Увидев такой код, вы можете предположить, что это ошибка, вызванная некорректным преобразованием инструкции
for
из инструкции
while
. Если хотите, чтобы счетчик увеличивался на
2
, сделайте следующее:


// вычисляем и выводим на печать таблицу квадратов

// четных чисел из диапазона [0:100]

int main()

{

 for (int i = 0; i<100; i+=2)

   cout << i << '\t' << square(i) << '\n';

}


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


ПОПРОБУЙТЕ

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

for
. Затем модифицируйте программу так, чтобы таблица содержала прописные символы и цифры.

4.5. Функции

В приведенной выше программе осталось невыясненной роль выражения

square(i)
. Это вызов функции. Конкретнее, это вызов функции, вычисляющей квадрат аргумента
i
. Функция — это именованная последовательность инструкций. Она может возвращать результат, который также называется возвращаемым значением.

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

sqrt()
, использованная в разделе 3.4. Однако многие функции мы пишем самостоятельно. Рассмотрим возможное определение функции
square
.


int square(int x) // возвращает квадрат числа x

{

 return x*x;

}


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

square
, принимающая аргумент типа
int
(с именем) и возвращающая значение типа
int
(тип результата всегда предшествует объявлению функции); иначе говоря, ее можно использовать примерно так:


int main()

{

 cout << square(2) << '\n';  // выводим 4

 cout << square(10) << '\n'; // выводим 100

}


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


square(2);        // возвращаемое значение не используется

int v1 = square();    // ошибка: пропущен аргумент

int v2 = square;     // ошибка: пропущены скобки

int v3 = square(1,2);  // ошибка: слишком много аргументов

int v4 = square("two"); // ошибка: неверный тип аргумента —

             // ожидается int


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

two
", вы на самом деле имели в виду число
2
. Однако компилятор языка С++ совсем не так умен. Компьютер просто проверяет, соответствуют ли ваши инструкции синтаксическим правилам языка С++, и точно их выполняет. Если компилятор станет угадывать, что вы имели в виду, то он может ошибиться и вы — или пользователи вашей программы — будете огорчены. Достаточно сложно предсказать, что будет делать ваша программа, если компилятор будет пытаться угадывать ваши намерения.

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


{

 return x*x; // возвращаем квадрат числа x

}


Для функции

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

Синтаксис определения функции можно описать так:


тип идентификатора (список параметров) тело функции


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

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

void
(означающее “ничего”). Рассмотрим пример.


void write_sorry() // не принимает никаких аргументов;

          // ничего не возвращает

{

 cout << "Извините \n";

}


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

4.5.1. Зачем нужны функции

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

• Эти вычисления логически отделены от других.

• Отделение вычислений делает программу яснее (с помощью присваивания имен функциям).

• Функцию можно использовать в разных местах программы.

• Использование функций упрощает отладку программы.


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

x*x
, или
7*7
, или
(x+7)*(x+7)
, а не
square(x)
,
square(7)
или
square(x+7)
. Однако функция square сильно упрощает такие вычисления. Рассмотрим теперь извлечение квадратного корня (в языке С++ эта функция называется
sqrt
): можете написать выражение
sqrt(x)
, или
sqrt(7)
, или
sqrt(x+7)
, а не повторять код, вычисляющий квадратный корень, запутывая программу. И еще один аргумент: можете даже не интересоваться, как именно вычисляется квадратный корень числа в функции
sqrt(x)
, — достаточно просто передать функции аргумент
x
.

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

main()
, то можно было бы написать такой код:


void print_square(int v)

{

 cout << v << '\t' << v*v << '\n';

}


int main()

{

  for (int i = 0; i<100; ++i) print_square(i);

}


Почему же мы не использовали версию программы на основе функции

print_square()
? Дело в том, что эта программа ненамного проще, чем версия, основанная на функции
square()
, и, кроме того,

• функция

print_square()
является слишком специализированной и вряд ли будет использована в другой программе, в то время как функция
square()
, скорее всего, будет полезной для других пользователей;

• функция

square()
не требует подробной документации, а функция
print_square()
очевидно требует пояснений.


Функция

print_square()
выполняет два логически отдельных действия:

• печатает числа;

• вычисляет квадраты.


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

square()
является более предпочтительной.

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

square(i)
, а не выражение
i*i
, использованное в первой версии программы? Одной из целей функций является упрощение кода путем распределения сложных вычислений по именованным функциям, а для программы 1949 года еще не было аппаратного обеспечения, которое могло бы непосредственно выполнить операцию “умножить”. По этой причине в первоначальной версии этой программы выражение
i*i
представляло собой действительно сложное вычисление, как если бы вы выполняли его на бумаге. Кроме того, автор исходной версии, Дэвид Уилер, ввел понятие функций (впоследствии названных процедурами) в современном программировании, поэтому было вполне естественно, что он использовал их в своей программе.


ПОПРОБУЙТЕ

Реализуйте функцию

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

4.5.2. Объявления функций

Вы заметили, что вся информация, необходимая для вызова функции, содержится в первой строке ее объявления? Рассмотрим пример.


int square(int x)


Этой строки уже достаточно, чтобы написать инструкцию


int x = square(44);


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

sqrt()
? Мы знаем, что она извлекает квадратный корень из своего аргумента. А зачем нам знать, как устроено тело функции
square()
? Разумеется, в нас может разжечься любопытство. Но в подавляющем большинстве ситуаций достаточно знать, как вызвать функцию, взглянув на ее определение. К счастью, в языке С++ существует способ, позволяющий получить эту информацию, не заглядывая в тело функции. Эта конструкция называется объявлением функции.


int square(int);    // объявление функции square

double sqrt(double);  // объявление функции sqrt


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


int square(int x) // определение функции square

{

 return x*x;

}


Итак, если мы хотим просто использовать функцию, то достаточно написать ее объявление, а чаще — выполнить директиву

#include
. Определение функции может быть в любом другом месте. Это “любое другое место” мы укажем в разделах 8.3 и 8.7. В более крупных программах разница между объявлениями и определениями становится существеннее. В этих программах определения позволяют сосредоточиться на локальном фрагменте программы (см. раздел 4.2), не обращая внимания на остальную часть кода.

4.6. Вектор

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

vector
(вектор).

Вектор — это последовательность элементов, к которым можно обращаться по индексу. Например, рассмотрим объект типа

vector
с именем
v
.



Иначе говоря, индекс первого элемента равен 0, индекс второго элемента — 1 и т.д. Мы ссылаемся на элемент, указывая имя вектора и индекс элемента в квадратных скобках, так что значение

v[0]
равно
5
, значение
v[1]
равно
7
и т.д. Индексы вектора всегда начинаются с нуля и увеличиваются на единицу. Это вам должно быть знакомым: вектор из стандартной библиотеки С++ — это просто новый вариант старой и хорошо известной идеи. Я нарисовал вектор так, как показано на рисунке, чтобы подчеркнуть, что вектор “знает свой размер”, т.е. всегда хранит его в одной из ячеек.

Такой вектор можно создать, например, так:


vector v(6); // вектор из 6 целых чисел

v[0] = 5;

v[1] = 7;

v[2] = 9;

v[3] = 4;

v[4] = 6;

v[5] = 8;


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

vector
в угловых скобках (
<>
). Здесь использован тип
, а количество элементов указано после имени в круглых скобках (
(6)
). Рассмотрим еще один пример.


vector philosopher(4); // вектор из 4 строк

philosopher [0] = "Kant";

philosopher [1] = "Plato";

philosopher [2] = "Hume";

philosopher [3] = "Kierkegaard";


Естественно, в векторе можно хранить элементы только одного типа.


philosopher[2] = 99; // ошибка: попытка присвоить целое число строке

v[2] = "Hume";    // ошибка: попытка присвоить строку целому числу


Когда мы объявляем объект типа

vector
с заданным размером, его элементы принимают значения, заданные по умолчанию для указанного типа. Рассмотрим пример.


vector v(6); // вектор из 6 целых чисел инициализируется нулями

vector philosopher(4); // вектор из 4 строк инициализируется

                 // значениями ""


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


vector vd(1000,–1.2); // вектор из 1000 действительных

                // чисел, инициализированных как –1.2


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


vd[20000] = 4.7; // ошибка во время выполнения программы


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

4.6.1. Увеличение вектора

Часто мы начинаем работу с пустым вектором и увеличиваем его размер по мере считывания или вычисления данных. Для этого используется функция

push_back()
, добавляющая в вектор новый элемент. Новый элемент становится последним элементом вектора. Рассмотрим пример.


vector v; // начинаем с пустого вектора,

          // т.е. объект v не содержит ни одного элемента



v.push_back(2.7); // добавляем в конец вектора v элемент

          // со значением 2.7

          // теперь вектор v содержит один элемент

          // и v[0]==2.7



v.push_back(5.6); // добавляем в конец вектора v элемент

          // со значением 5.6

          // теперь вектор v содержит два элемента

          // и v[1]==5.6



v.push_back(7.9); // добавляем в конец вектора v элемент

          // со значением 7.9

          // теперь вектор v содержит три элемента

          // и v[2]==7.9



Обратите внимание на синтаксис вызова функции

push_back()
. Он называется вызовом функции-члена; функция
push_back()
является функцией-членом объекта типа
vector
, и поэтому для ее вызова используется особая форма вызова.


вызов функции-члена:

имя_объекта.имя_функции_члена(список_аргументов)


Размер вектора можно определить, вызвав другую функцию-член объекта типа

vector: size()
. В начальный момент значение
v.size()
равно 0, а после третьего вызова функции
push_back()
значение
v.size()
равно
3
. Зная размер вектора, легко выполнить цикл по всем элементам вектора. Рассмотрим пример.


for(int i=0; i

 cout << "v[" << i << "]==" << v[i] << '\n';


Этот цикл выводит на экран следующие строки:


v[0]==2.7

v[1]==5.6

v[2]==7.9


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

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

4.6.2. Числовой пример

Рассмотрим более реалистичный пример. Часто нам требуется считать коллекцию данных в программу и что-то с ними сделать. Это “что-то” может означать построение графика, вычисление среднего и медианы, сортировку, смешивание с другими данными, поиск интересующих нас значений, сравнение с другими данными и т.п. Перечислять операции с данными можно бесконечно, но сначала данные необходимо считать в память компьютера. Рассмотрим основной способ ввода неизвестного — возможно, большого — объема данных. В качестве конкретного примера попробуем считать числа с плавающей точкой, представляющие собой значения температуры.


// считываем значения температуры в вектор

int main()

{

 vector temps;   // значения температуры

 double temp;

 while (cin>>temp)     // считываем

   temps.push_back(temp); // записываем в вектор

  // ...что-то делаем...

}


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


vector temps; // значения температуры

double temp;


Вот где указывается тип входных данных. Как видим, мы считываем и храним числа типа

double
.

Теперь выполняется цикл считывания.


while (cin>>temp)     // считываем

  temps.push_back(temp); // записываем в вектор


Инструкция

cin>>temp
считывает число типа
double
, а затем это число “заталкивается” в вектор (записывается в конец вектора). Эти операции уже были продемонстрированы выше. Новизна здесь заключается в том, что в качестве условия выхода из цикла
while
мы используем операцию ввода
cin>>temp
. В основном условие
cin>>temp
является истинным, если значение считано корректно, в противном случае оно является ложным. Таким образом, в цикле
while
считываются все числа типа
double
, пока на вход не поступит нечто иное. Например, если мы подадим на вход следующие данные


1.2 3.4 5.6 7.8 9.0 |


то в вектор

temps
будут занесены пять элементов:
1.2
,
3.4
,
5.6
,
7.8
,
9.0
(именно в таком порядке, т.е.
temps[0]==1.2
). Для прекращения ввода используется символ
|
, т.е. значение, не имеющее тип
double
. В разделе 10.6 мы обсудим способы прекращения ввода и способы обработки ошибок ввода.

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


// вычисляем среднее и медиану значений температур

int main()

{

 vector temps; // значения температур

 double temp;

 while (cin>>temp) // считываем данные

   temps.push_back(temp); // заносим их в вектор

              // вычисляем среднюю температуру:

  double sum = 0;

 for (int i = 0; i < temps.size(); ++i) sum += temps[i];

 cout << "Average temperature: " << sum/temps.size() << endl;

 // вычисляем медиану температуры:

  sort(temps.begin(),temps.end()); // сортируем значения

                   // температуры

                  // "от начала до конца"

 cout << "Медиана температуры: " << temps[temps.size()/2] << endl;

}


Мы вычисляем среднее значение, просто суммируя все элементы и деля сумму на количество элементов (т.е. на значение

temps.size()
).


// вычисляем среднюю температуру :

double sum = 0;

for (int i = 0; i < temps.size(); ++i) sum += temps[i];

cout << "Средняя температура: " << sum/temps.size() << endl;


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

+=
. Для вычисления медианы (значения, относительно которого половина всех значений оказывается меньше, в другая половина — больше) элементы следует упорядочить. Для этой цели используется алгоритм
sort()
из стандартной библиотеки.


// вычисляем медиану температуры:

sort(temps.begin(),temps.end()); // сортировка

cout << "Медиана температуры: " << temps[temps.size()/2] << endl;


Стандартная функция

sort()
принимает два аргумента: начало и конец сортируемой последовательности. Этот алгоритм будет рассмотрен позднее (в главе 20), но, к счастью, вектор “знает” свое начало и конец, поэтому нам не следует беспокоиться о деталях: эту работу выполняют функции
temps.begin()
и
temps.end()
. Обратите внимание на то, что функции
begin()
и
end()
являются функциями-членами объекта типа
vector
, как и функция
size()
, поэтому мы вызываем их из вектора с помощью точки. После сортировки значений температуры медиану легко найти: мы просто находим средний элемент, т.е. элемент с индексом
temps.size()/2
. Если проявить определенную придирчивость (характерную для программистов), то можно обнаружить, что найденное нами значение может оказаться не медианой в строгом смысле. Решение этой маленькой проблемы описано в упр. 2.

4.6.3. Текстовый пример

Приведенный выше пример интересен нам с общей точки зрения. Разумеется, среднее значение и медиана температуры интересуют многих людей — метеорологов, аграриев и океанографов, — но нам важна общая схема: использование вектора и простых операций. Можно сказать, что при анализе данных нам необходим вектор (или аналогичная структура данных; см. главу 21). В качестве примера создадим простой словарь.


// простой словарь : список упорядоченных слов

int main()

{

 vector words;

 string temp;

 while (cin>>temp) // считываем слова, отделенные разделителями

   words.push_back(temp); // заносим в вектор

 cout << "Количество слов: " << words.size() << endl;

 sort(words.begin(),words.end()); // сортируем весь вектор

 for (int i = 0; i < words.size(); ++i)

   if (i==0 || words[i–1]!=words[i]) // это новое слово?

 cout << words[i] << "\n";

}


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


man a plan panama


В ответ программа выведет на экран следующие слова:


a

man

panama

plan


Как остановить считывание строки? Иначе говоря, как прекратить цикл ввода?


while (cin>>temp) // считываем

  words.push_back(temp); // заносим в вектор


Когда мы считывали числа (см. раздел 4.6.2), для прекращения ввода просто вводили какой-то символ, который не был числом. Однако для строк этот прием не работает, так как в строку может быть считан любой (одинарный) символ. К счастью, существуют символы, которые не являются одинарными. Как указывалось в разделе 3.5.1, в системе Windows поток ввода останавливается нажатием клавиш , а в системе Unix — .

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


if (i==0 || words[i–1]!=words[i]) // это новое слово?


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


a

a

man

panama

plan


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

words[i-1]!=words[i]
), и если отличается, то слово выводится на экран, а если нет, то не выводится. Очевидно, что у первого слова предшественника нет (
i==0
), поэтому сначала следует проверить первый вариант и объединить эти проверки с помощью оператора
||
(или).


if (i==0 || words[i–1]!=words[i]) // это новое слово?


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

!=
(не равно);
==
(равно),
<
(меньше),
<=
(меньше или равно),
>
(больше) и
>=
(больше или равно), которые можно применять и к строкам. Операторы, и тому подобные основаны на лексикографическом порядке, так что строка "
Ape
" предшествует строкам "
Apple
" и "
Chimpanzee
".


ПОПРОБУЙТЕ

Напишите программу, заглушающую нежелательные слова; иначе говоря, считайте слова из потока

cin
и выведите их в поток
cout
, заменив нежелательные слова словом
BLEEP
. Начните с одного нежелательного слова, например


string disliked = "Broccoli";


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

4.7. Свойства языка

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

for
и
while
), выбор (инструкция
if
), простые арифметические инструкции (операторы
++
и
+=
), логические операторы и операторы сравнения (
==
,
!=
и
||
), переменные и функции (например,
main()
,
sort()
и
size()
). Кроме того, мы использовали возможности стандартной библиотеки, например
vector
(контейнер элементов),
cout
(поток вывода) и
sort()
(алгоритм).

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


Задание

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

1. Напишите программу, содержащую цикл

while
, в котором считываются и выводятся на экран два целых числа. Для выхода из программы введите символ.

2. Измените программу так, чтобы она выводила на экран строку "

Наименьшее из двух значений равно:
", а затем — меньшее и большее значения.

3. Настройте программу так, чтобы она выводила только равные числа.

4. Измените программу так, чтобы она работала с числами типа

double
, а не
int
.

5. Измените программу так, чтобы она выводила числа, которые почти равны друг другу. При этом, если числа отличаются меньше, чем на 1.0/100, то сначала следует вывести меньшее число, а затем большее.

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

double
за один проход. Определите две переменные, чтобы определить, какое из них имеет меньшее значение, а какое — большее среди всех ранее введенных значений. За каждый проход цикла выводите на экран одно введенное число. Если оно окажется наименьшим среди ранее введенных, выведите на экран строку "
Наименьшее среди ранее введенных
". Если оно окажется наибольшим среди ранее введенных, выведите на экран строку "
Наибольшее среди ранее введенных
".

7. Добавьте к каждому введенному числу типа

double
единицу измерения; иначе говоря, введите значения, такие как
10cm
,
2.5in
,
5ft
или
3.33m
. Допустимыми являются четыре единицы измерения:
cm
,
m
,
in
,
ft
. Коэффициенты преобразования равны:
1m==100cm
,
1in==2.54cm
,
1ft==12in
. Индикаторы единиц измерения введите в строку.

8. Если введена неправильная единица измерения, например

yard
,
meter
,
km
и
gallons
, то ее следует отклонить.

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

10. Сохраните все введенные значения (преобразованные в метры) в векторе и выведите их на экран.

11. Перед тем как вывести значения из вектора, отсортируйте их в возрастающем порядке.


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

1. Что такое вычисления?

2. Что подразумевается под входными данными и результатами вычислений?

Приведите примеры.

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

4. Для чего предназначены выражения?

5. В чем разница между инструкцией и выражением?

6. Что такое значение

lvalue
? Перечислите операторы, требующие наличия значения
lvalue
. Почему именно эти, а не другие операторы требуют наличия значения
lvalue
?

7. Что такое константное выражение?

8. Что такое литерал?

9. Что такое символическая константа и зачем она нужна?

10. Что такое “магическая” константа? Приведите примеры.

11. Назовите операторы, которые можно применять как к целым числам, так и к числам с плавающей точкой.

12. Какие операторы можно применять только к целым числам, но не к числам с плавающей точкой?

13. Какие операторы можно применять к строкам?

14. Когда оператор

switch
предпочтительнее оператора
if
?

15. Какие проблемы порождает использование оператора

switch
?

16. Объясните, каково предназначение каждой части заголовка цикла

for
и в каком порядке они выполняются?

17. Когда используется оператор

for
, а когда оператор
while
?

18. Как вывести числовой код символа?

19. Опишите смысл выражения

char foo(int x)
в определении функции.

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

21. Какие операции можно выполнить над объектом типа

int
, но нельзя применить к объекту типа
string
?

22. Какие операции можно выполнить над объектом типа

string
, но нельзя применить к объекту типа
int
?

23. Чему равен индекс третьего элемента вектора?

24. Напишите цикл

for
, в котором выводятся все элементы вектора?

25. Что делает выражение

vector alphabet(26);
?

26. Что делает с вектором функция

push_back()
?

27. Что делают функции-члены вектора

begin()
,
end()
и
size()
?

28. Чем объясняется полезность и популярность типа

vector
?

29. Как упорядочить элементы вектора?


Термины


Упражнения

1. Выполните задание ПОПРОБУЙТЕ, если еще не сделали этого раньше.

2. Допустим, мы определяем медиану последовательности как “число, относительно которого ровно половина элементов меньше, а другая половина — больше”. Исправьте программу из раздела 4.6.2 так, чтобы она всегда выводила медиану. Подсказка: медиана не обязана быть элементом последовательности.

3. Считайте последовательности чисел типа

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

4. Напишите программу, угадывающую число. Пользователь должен задумать число от 1 до 100, а программа должна задавать вопросы, чтобы выяснить, какое число он задумал (например, “Задуманное число меньше 50”). Ваша программа должна уметь идентифицировать число после не более семи попыток. Подсказка: используйте операторы

<
и
<=
, а также конструкцию
if-else
.

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

double
и символ операции. Если входные аргументы равны
35.6
,
24.1
и
'+'
, то программа должна вывести на экран строку "
Сумма 35.6 и 24.1 равна 59.7
". В главе 6 мы опишем более сложный калькулятор.

6. Создайте вектор, хранящий десять строковых значений "

zero
", "
one
", ..., "
nine
". Введите их в программу, преобразующую цифру в соответствующее строковое представление; например, при вводе цифры 7 на экран должна быть выведена строка
seven
. С помощью этой же программы, используя тот же самый цикл ввода, преобразуйте строковое представление цифры в числовое; например, при вводе строки
seven
на экран должна быть выведена цифра.

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

8. Легенда гласит, что некий царь захотел поблагодарить изобретателя шахмат и предложил ему попросить любую награду. Изобретатель попросил положить на первую клетку одно зерно риса, на вторую — 2, на третью — 4 и т.д., удваивая количество зерен на каждой из 64 клеток. На первый взгляд это желание выглядит вполне скромным, но на самом деле в царстве не было такого количества риса! Напишите программу, вычисляющую, сколько клеток надо заполнить, чтобы изобретатель получил хотя бы 1000 зерен риса, хотя бы 1 000 000 зерен риса и хотя бы 1 000 000 000 зерен риса. Вам, разумеется, понадобится цикл и, вероятно, переменная типа

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

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

int
, ни
double
. Определите наибольшее количество клеток, на котором еще может поместиться столько зерен риса, чтобы хранить их количество в переменной типа
int
. Определите наибольшее количество клеток, на котором еще может поместиться столько зерен риса, чтобы хранить их примерное количество в переменной типа
double
?

10. Напишите программу для игры “Камень, бумага, ножницы”. Если вы не знаете правил этой игры, попробуйте выяснить их у друзей или с помощью поисковой машины Google. Такие исследования — обычное занятие программистов. Для решения поставленной задачи используйте инструкцию

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

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

primes
, то
primes[0]==2
,
primes[1]==3
,
primes[2]==5
и т.д.). Напишите цикл перебора чисел от 1 до 100, проверьте каждое из них и сохраните найденные простые числа в векторе. Напишите другой цикл, в котором все найденные простые числа выводятся на экран. Сравните полученные результаты с вектором
primes
. Первым простым числом считается число
2
.

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

max
, а затем найдите все простые числа от 1 до
max
.

13. Напишите программу, находящую все простые числа от 1 до 100. Для решения этой задачи существует классический метод “Решето Эратосфена”. Если этот метод вам неизвестен, поищите его описание в веб. Напишите программу на основе этого метода.

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

max
, а затем найдите все простые числа от
1
до
max
.

15. Напишите программу, принимающую на вход число

n
и находящую первые
n
простых чисел.

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

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

18. Напишите программу для решения квадратичных уравнений. Квадратичное уравнение имеет вид

2
. Если вы не знаете формул для решения этого уравнения, проведите дополнительные исследования. Напоминаем, что программисты часто проводят такие исследования, прежде чем приступают к решению задачи. Для ввода чисел
a
,
b
и с используйте переменные типа
double
. Поскольку квадратичное уравнение имеет два решения, выведите оба значения,
x1
и
x2
.

19. Напишите программу, в которую сначала вводится набор пар, состоящих из имени и значения, например

Joe 17
и
Barbara 22
. Для каждой пары занесите имя в вектор
names
, а число — в вектор
scores
(в соответствующие позиции, так что если
names[7]=="Joe"
, то
scores[7]==17
). Прекратите ввод, введя строку
NoName 0
. Убедитесь, что каждое имя уникально, и выведите сообщение об ошибке, если имя введено дважды. Выведите на печать все пары (имя, баллы) по одной в строке.

20. Измените программу из упр. 19 так, чтобы при вводе имени она выводила соответствующее количество баллов или сообщение "

name not found
".

21. Измените программу из упр. 19 так, чтобы при вводе целого числа она выводила все имена студентов, получивших заданное количество баллов или сообщение "

score not found
".


Послесловие

С философской точки зрения вы уже можете делать с помощью компьютера все, что захотите, — остальное детали! Разумеется, важность деталей и практических навыков несомненна, поскольку вы только начинаете программировать. Но мы говорим серьезно. Инструменты, представленные в главе, позволяют описывать любые вычисления: у вас может быть столько переменных, сколько вам нужно (включая векторы и строки), вы можете выполнять арифметические операции, сравнения, а также выбор и итерации. С помощью этих примитивов можно выразить любые вычисления. Вы можете вводить и выводить числа и строки в виде текста (и даже графиков). Можете даже организовать вычисления в виде набора функций. Осталось только научиться писать хорошие программы, т.е. правильные, понятные и эффективные. Не менее важно, чтобы вы смогли научиться этому, затратив разумное количество сил.

Глава 5 Ошибки

“Я понял, что с этого момента большую часть моей жизни

буду искать и исправлять свои же ошибки”.

Морис Уилкс (Maurice Wilkes, 1949)


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

5.1. Введение

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

Существует множество способов классификации ошибок. Рассмотрим пример.

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

 • синтаксические ошибки;

 • ошибки, связанные с типами.

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

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

 • ошибки, обнаруженные компьютером (аппаратным обеспечением и/или операционной системой);

 • ошибки, обнаруженные с помощью библиотеки (например, стандартной);

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

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


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

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

1. Должна вычислять желаемые результаты при всех допустимых входных данных.

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

3. Не обязана обрабатывать ошибки аппаратного обеспечения.

4. Не обязана обрабатывать ошибки программного обеспечения.

5. Должна завершать работу после обнаружения ошибки.


Программы, для которых предположения 3–5 не выполняются, выходят за рамки рассмотрения нашей книги. В то же время предположения 1 и 2 являются частью основных профессиональных требований, а профессионализм — это именно то, к чему мы стремимся. Даже если мы не всегда соответствуем идеалу на 100%, он должен существовать.

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

Мы предлагаем три подхода к разработке приемлемого программного обеспечения.

• Организовать программное обеспечение так, чтобы минимизировать количество ошибок.

• Исключить большинство ошибок в ходе отладки и тестирования.

• Убедиться, что оставшиеся ошибки не серьезны.


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

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

5.2. Источники ошибок

Перечислим несколько источников ошибок.

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

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

Непредусмотренные аргументы. Функции принимают аргументы. Если функция принимает аргумент, который не был предусмотрен, то возникнет проблема, как, например, при вызове стандартной библиотечной функции извлечения корня из –1,2:

sqrt(–1.2)
. Поскольку функция
sqrt()
получает положительную переменную типа
double
, в этом случае она не сможет вернуть правильный результат. Такие проблемы обсуждаются в разделе 5.5.3.

Непредусмотренные входные данные. Обычно программы считывают данные (с клавиатуры, из файлов, из средств графического пользовательского интерфейса, из сетевых соединений и т.д.). Как правило, программы выдвигают к входным данным много требований, например, чтобы пользователь ввел число. А что, если пользователь введет не ожидаемое целое число, а строку “Отстань!”? Этот вид проблем обсуждается в разделах 5.6.3 и 10.6.

Неожиданное состояние. Большинство программ хранит большое количество данных (“состояний”), предназначенных для разных частей системы. К их числу относятся списки адресов, каталоги телефонов и данные о температуре, записанные в объекты типа

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

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


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

5.3. Ошибки во время компиляции

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

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

В качестве примера рассмотрим вызовы следующей простой функции:


int area(int length, int width); // вычисление площади треугольника

5.3.1. Синтаксические ошибки

Что произойдет, если мы вызовем функцию area() следующим образом:


int s1 = area(7;  // ошибка: пропущена скобка )

int s2 = area(7)  // ошибка: пропущена точка с запятой ;

Int s3 = area(7);  // ошибка: Int — это не тип

int s4 = area('7); // ошибка: пропущена кавычка '


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

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

s3
, компилятор вряд ли напишет что-то вроде следующей фразы:

“Вы неправильно написали слово

int
; не следует употреблять прописную букву
i
.”

Скорее, он выразится так:

“Синтаксическая ошибка: пропущена

';'
перед идентификатором '
s3'

“У переменной

's3'
пропущен идентификатор класса или типа”

“Неправильный идентификатор класса или типа

'Int'

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

“Перед переменной

s3
сделана синтаксическая ошибка, и надо что-то сделать либо с типом
Int
, либо с переменной
s3
.”

Поняв это, уже нетрудно решить проблему.


ПОПРОБУЙТЕ

Попробуйте скомпилировать эти примеры и проанализируйте ответы компиляторов.

5.3.2. Ошибки, связанные с типами

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


int x0 = arena(7); // ошибка: необъявленная функция

int x1 = area(7);  // ошибка: неправильное количество аргументов

int x2 = area("seven",2); // ошибка: первый аргумент

              // имеет неправильный тип


Рассмотрим эти ошибки.

1. При вызове функции

arena(7)
мы сделали опечатку: вместо
area
набрали arena, поэтому компилятор думает, что мы хотим вызвать функцию с именем
arena
. (А что еще он может “подумать”? Только то, что мы сказали.) Если в программе нет функции с именем
arena()
, то вы получите сообщение об ошибке, связанной с необъявленной функцией. Если же в программе есть функция с именем
arena
, принимающая число
7
в качестве аргумента, то вы столкнетесь с гораздо худшей проблемой: программа будет скомпилирована как ни в чем ни бывало, но работать будет неправильно (такие ошибки называют логическими; см. раздел 5.7).

2. Анализируя выражение

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

3. Записывая выражение

area("seven",2)
, вы могли рассчитывать, что компилятор увидит строку "
seven
" и поймет, что вы имели в виду целое число
7
. Напрасно. Если функция ожидает целое число, то ей нельзя передавать строку. Язык C++ поддерживает некоторые неявные преобразования типов (см. раздел 3.9), но не позволяет конвертировать тип
string
в тип
int
. Компилятор даже не станет угадывать, что вы имели в виду. А что вы могли бы ожидать от вызовов
area("Hovel lane",2)
,
area("7,2")
и
area("sieben","zwei")
?


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


ПОПРОБУЙТЕ

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

5.3.3. Не ошибки

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


int x4 = area(10,–7); // OK: но что представляет собой прямоугольник,

            // у которого ширина равна минус 7?

int x5 = area(10.7,9.3);  // OK: но на самом деле вызывается area(10,9)

char x6 = area(100,9999); // OK: но результат будет усечен


Компилятор не выдаст никаких сообщений о переменной

x4
. С его точки зрения вызов
area(10,–7)
является правильным: функция
area()
запрашивает два целых числа, и вы их ей передаете; никто не говорил, что они должны быть положительными.

Относительно переменной

x5
хороший компилятор должен был бы предупредить, что значения типа
double
, равные 10.7 и 9.3, будут преобразованы в значения типа
int
, равные
10
и
9
(см. 3.9.2). Однако (устаревшие) правила языка утверждают, что вы можете неявно преобразовать переменную типа
double
в переменную типа
int
, поэтому у компилятора нет никаких оснований отвергать вызов
area(10.7,9.3)
.

Инициализация переменной

x6
представляет собой вариант той же проблемы, что и вызов
area(10.7,9.3)
. Значение типа
int
, возвращенное после вызова
area(100,9999)
, вероятно, равное
999900
, будет присвоено переменной типа
char
. В итоге, скорее всего, в переменную
x6
будет записано “усеченное” значение
–36
. И опять-таки хороший компилятор должен выдать предупреждение, даже если устаревшие правила языка позволяют ему не делать этого.

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

5.4. Ошибки во время редактирования связей

Любая программа состоит из нескольких отдельно компилируемых частей, которые называют единицами трансляции (translation units). Каждая функция в программе должна быть объявлена с теми же самыми типами, которые указаны во всех единицах трансляции, откуда она вызывается. Для этого используются заголовочные файлы (подробно о них речь пойдет в разделе 8.3). Кроме того, каждая функция должна быть объявлена в программе только один раз. Если хотя бы одно из этих правил нарушено, то редактор связей выдаст ошибку. Способы исправления ошибок во время редактирования связей рассматриваются в разделе 8.3. А пока рассмотрим пример программы, которая порождает типичную ошибку на этапе редактирования связей.


int area(int length, int width); // вычисляет площадь прямоугольника

int main()

{

 int x = area(2,3);

}


Если функция

area()
не объявлена в другом исходном файле и не связана с нашим файлом с помощью редактора связей, то он сообщит об отсутствии объявления функции
area()
.

Определение функции

area()
должно иметь точно такие же типы (как возвращаемого значения, так и аргументов), как и в нашем файле.


int area(int x, int y) { /* ... */ } // "наша" функция area()


Функции с таким же именем, но с другими типами аргументов будут проигнорированы.


double area(double x, double y) { /* ... */ }  // не "наша" area()

int area(int x, int y, char unit) { /* ... */ } // не "наша" area()


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

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

5.5. Ошибки во время выполнения программы

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


int area(int length, int width) // Вычисляем площадь прямоугольника

{

 return length*width;

}


int framed_area(int x, int y) // Вычисляем площадь,

                // ограниченную рамкой

{

 return area(x–2,y–2);

}


int main()

{

 int x = –1;

 int y = 2;

 int z = 4;

  // ...

  int area1 = area(x,y);

 int area2 = framed_area(1,z);

 int area3 = framed_area(y,z);

 double ratio = double(area1)/area3; // Преобразуем к типу double,

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

                    //
 с плавающей точкой

}


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

x
,
y
и
z
, а не непосредственные числа. Однако эти вызовы функций возвращают отрицательные числа, присвоенные переменным
area1
и
area2
. Можно ли принять эти ошибочные результаты, противоречащие законам математики и физики? Если нет, то где следует искать ошибку: в модуле, вызвавшем функцию
area()
, или в самой функции? И какое сообщение об ошибке следует выдать?

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

ratio
в приведенном выше коде. Оно выглядит довольно невинно. Вы заметили, что с этим кодом что-то не так? Если нет, посмотрите снова: переменная
area3
будет равна
0
, поэтому в выражении
double(area1)/area3
возникает деление на нуль. Это приводит к ошибке, обнаруживаемой аппаратным обеспечением, которое прекращает выполнение программы, выдав на экран довольно непонятное сообщение. Вы и ваши пользователи будете сталкиваться с такими проблемами постоянно, если не научитесь выявлять и исправлять ошибки, возникающие на этапе выполнения программы. Большинство людей нервно реагируют на такие сообщения аппаратного обеспечения, так как им сложно понять, что происходит, когда на экране появляется сообщение вроде “Что-то пошло не так!” Этого недостаточно для того, чтобы предпринять какие-то конструктивные действия, поэтому пользователи злятся и проклинают программиста, написавшего такую программу.

Итак, попробуем разобраться с ошибкой, связанной с вызовом функции

area()
. Существуют две очевидные альтернативы.

1. Следует исправить ошибку в модуле, вызывающем функцию

area()
.

2. Позволить функции

area()
(вызываемой функции) выполнять вычисления с неправильными аргументами.

5.5.1. Обработка ошибок в вызывающем модуле

Сначала рассмотрим первую альтернативу (“Берегись, пользователь!”). Именно ее нам следовало бы принять, например, если бы функция

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

Предотвратить ошибку при вызове функции

area(x,y)
в модуле
main()
относительно просто:


if (x<=0) error("неположительное x");

if (y<=0) error("неположительное y");

int area1 = area(x,y);


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

error()
, которая должна сделать что-то полезное. В заголовочном файле
std_lib_facilities.h
действительно описана функция
error()
, которая по умолчанию останавливает выполнение программы, сопровождая это сообщением системы и строкой, которая передается как аргумент функции
error()
. Если вы предпочитаете писать свои собственные сообщения об ошибках или предпринимать другие действия, то можете перехватывать исключение
runtime_error
(разделы 5.6.2, 7.3, 7.8, Б.2.1). Этого достаточно для большинства несложных программ.

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


if (x<=0 || y<=0) error("неположительный аргумент функции area()");

// || значит ИЛИ

int area1 = area(x,y);


Для того чтобы полностью защитить функцию

area()
от неправильных аргументов, необходимо исправить вызовы функции
framed_area()
. Этот фрагмент кода можно переписать следующим образом:


if (z<=2)

  error("неположительный второй аргумент функции area() \\

     при вызове из функции framed_area()");

int area2 = framed_area(1,z);

if (y<=2 || z<=2)

  error("неположительный аргумент функции area()\\

     при вызове из функции framed_area()");

int area3 = framed_area(y,z);


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

framed_area()
использует функцию
area()
.

Мы должны знать, что функция

framed_area()
вычитает
2
из каждого аргумента. Но мы не должны знать такие детали! А что, если кто-нибудь изменит функцию
framed_area()
и вместо
2
станет вычитать
1
?

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

framed_area()
и соответствующим образом изменить фрагменты кода, обрабатывающего ошибки. Такой код называется “хрупким”, потому что легко выходит из строя. Он также иллюстрирует вред от “магических констант” (см. раздел 4.3.1). Код можно сделать более надежным, дав величине, вычитаемой функцией
framed_area()
, имя.


const int frame_width = 2;

int framed_area(int x, int y) // вычисляем площадь,

                // ограниченную рамкой

{

  return area(x–frame_width,y–frame_width);

}


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

framed_area()
.


if (1–frame_width<=0 || z–frame_width<=0)

  error("неположительный второй аргумент функции area() \\

     при вызове из функции framed_area()");

int area2 = framed_area(1,z);

if (y–frame_width<=0 || z–frame_width<=0)

  error("неположительный аргумент функции area() \\

     при вызове из функции framed_area()");

int area3 = framed_area(y,z);


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

framed_area()
всплыли наружу.

Существует более правильное решение!

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


int area2 = framed_area(1,z);

int area3 = framed_area(y,z);


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

framed_area()
.

5.5.2. Обработка ошибок в вызываемом модуле

Проверка корректности аргументов в функцию

framed_area()
не вызывает затруднений, а выдачу сообщения об ошибках можно по-прежнему поручить функции
error()
.


int framed_area(int x, int y) // вычисляем площадь, ограниченную рамкой

{

  const int frame_width = 2;

  if (x–frame_width<=0 || y–frame_width<=0)

    error("неположительный аргумент функции area() \\

       при вызове из функции framed_area()");

  return area(x–frame_width,y–frame_width);

}


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

framed_area()
. Для полезной функции, которая 500 раз вызывается в крупной программе, это большое преимущество. Более того, если обработка ошибок по какой-то причине изменится, нам будет достаточно изменить код только в одном месте.

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

Итак, применим найденное решение к функции

area()
.


int area(int length, int width) // вычисляем площадь прямоугольника

{

 if (length<=0 || width <=0)

    error("неположительный аргумент area()");

 return length*width;

}


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

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

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

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

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

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

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

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


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

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

5.5.3. Сообщения об ошибках

Рассмотрим немного иной вопрос: что делать, если вы проверили набор аргументов и обнаружили ошибку? Иногда можно вернуть сообщение “Неправильное значение”. Рассмотрим пример.


// Попросим пользователя ввести да или нет;

// Символ 'b' означает неверный ответ (т.е. ни да ни нет)

char ask_user(string question)

{

 cout << question << "? (да или нет)\n";

 string answer = " ";

  cin >> answer;

 if (answer =="y" || answer=="yes") return 'y';

 if (answer =="n" || answer=="no") return 'n';

 return 'b'; // 'b', если "ответ неверный"

}


// Вычисляет площадь прямоугольника;

// возвращает –1, если аргумент неправильный

int area(int length, int width)

{

  if (length<=0 || width <=0) return –1;

   return length*width;

}


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

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

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

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

>>
потока
cin
), может возвращать любое целое число, поэтому использовать целое число в качестве индикатора ошибки бессмысленно.


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

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


int f(int x, int y, int z)

{

 int area1 = area(x,y);

 if (area1<=0) error("Неположительная площадь");

 int area2 = framed_area(1,z);

 int area3 = framed_area(y,z);

  double ratio = double(area1)/area3;

  // ...

}


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


ПОПРОБУЙТЕ

Выполните эту программу при разных значениях. Выведите на печать значения переменных

area1
,
area2
,
area3
и
ratio
. Вставьте в программу больше проверок разных ошибок. Вы уверены, что перехватите все ошибки? Это вопрос без подвоха; в данном конкретном примере можно ввести правильный аргумент и перехватить все возможные ошибки.


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

5.6. Исключения

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

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

return
как обычно, а генерирует исключение с помощью оператора
throw
, показывая, что произошло нечто неправильное.

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

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

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

5.6.1. Неправильные аргументы

Рассмотрим вариант функции

area()
, использующий исключения.


class Bad_area { }; // Тип, созданный специально для сообщений

          // об ошибках,

   // возникших в функции area()

   // Вычисляет площадь прямоугольника;

   // при неправильном аргументе генерирует исключение Bad_area

int area(int length, int width)

{

 if (length<=0 || width<=0) throw Bad_area();

  return length*width;

}


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

area()
с помощью оператора
throw
, надеясь найти ответ в одном из разделов
catch
.
Bad_area
— это новый тип, предназначенный исключительно для генерирования исключений в функции
area()
, так, чтобы один из разделов
catch
распознал его как исключение, сгенерированное функцией
area()
. Типы, определенные пользователями (классы и перечисления), обсуждаются в главе 9. Обозначение
Bad_area()
означает “Создать объект типа Bad_area”, а выражение
throw Bad_area()
означает “Создать объект типа
Bad_area
и передать его (
throw
) дальше”.

Теперь функцию можно написать так:


int main()

try {

  int x = –1;

  int y = 2;

 int z = 4;

  // ...

 int area1 = area(x,y);

  int area2 = framed_area(1,z);

  int area3 = framed_area(y,z);

  double ratio = area1/area3;

}

catch (Bad_area) {

  cout << "Ой! Неправильный аргумент функции area()\n";

}


Во-первых, этот фрагмент программы обрабатывает все вызовы функции

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

5.6.2. Ошибки, связанные с диапазоном

Большинство реальных программ работает с наборами данных. Иначе говоря, они используют разнообразные таблицы, списки и другие структуры данных. В контексте языка С++ наборы данных часто называют контейнерами (containers). Наиболее часто используемым контейнером стандартной библиотеки является тип vector, введенный в разделе 4.6.

Объект типа

vector
хранит определенное количество элементов, которое можно узнать с помощью его функции-члена
size()
. Что произойдет, если мы попытаемся использовать элемент с индексом, не принадлежащим допустимому диапазону
[0:v.size()]
? Обычное обозначение
[low:high]
означает, что индексы могут принимать значения от low до
high-1
, т.е. включая нижнюю границу, но исключая верхнюю.



Прежде чем ответить на этот вопрос, необходимо ответить на другой: “Как это может быть?” Помимо всего прочего, известно, что индекс вектора

v
должен лежать в диапазоне
[0:v.size()]
, поэтому достаточно просто убедиться в этом!

Легко сказать, но трудно сделать. Рассмотрим следующую вполне разумную программу:


vector v; // вектор целых чисел

int i;

while (cin>>i) v.push_back(i);   // вводим значения в контейнер

for (int i = 0; i<=v.size(); ++i) // печатаем значения

  cout << "v[" << i <<"] == " << v[i] << endl;


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

Мы использовали

0
и
size()
, чтобы попытаться гарантировать, что индекс
i
всегда будет находиться в допустимом диапазоне, когда мы обратимся к элементу
v[i]
. К сожалению, мы сделали ошибку. Посмотрите на цикл
for
: условие его завершения сформулировано как
i<=v.size()
, в то время как правильно было бы написать
i
. В результате, прочитав пять чисел, мы попытаемся вывести шесть. Мы попытаемся обратиться к элементу
v[5]
, индекс которого ссылается за пределы вектора. Эта разновидность ошибок настолько широко известна, что даже получила несколько названий: ошибка занижения или завышения на единицу (off-by-obe error), ошибка диапазона (range error), так как индекс не принадлежит допустимому диапазону вектора, и ошибка пределов (bounds error), поскольку индекс выходит за пределы вектора.

Эту ошибку можно спровоцировать намного проще.


vector v(5);

int x = v[5];


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

vector
знает размер вектора, поэтому может проверить его (и действительно, делает это; см. разделы 4.6 и 19.4). Если проверка заканчивается неудачей, то операция доступа по индексу генерирует исключение типа
out_of_range
. Итак, если бы ошибочный код, приведенный выше, являлся частью какой-то программы, перехватывающей исключения, то мы получили бы соответствующее сообщение об ошибке.


int main()

try {

  vector v; // вектор целых чисел

  int x;

  while (cin>>x) v.push_back(x);   // записываем значения

 for (int i = 0; i<=v.size(); ++i) // выводим значения

   cout << "v[" << i <<"] == " << v[i] << endl;

} catch (out_of_range) {

   cerr << "Ой! Ошибка диапазона \n";

   return 1;

  } catch (...) { // перехват всех других исключений

  cerr << "Исключение: что-то не так \n";

  return 2;

}


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

vector::operator[]
) сообщает об ошибке, генерируя исключение. Что еще может произойти? Оператор доступа по индексу не имеет представления о том, что бы мы хотели в этой ситуации делать. Автор класса vector даже не знает, частью какой программы может стать его код.

5.6.3. Неправильный ввод

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

Рассмотрим фрагмент кода, в котором вводится число с плавающей точкой.


double d = 0;

cin >> d;


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

cin
.


if (cin) {

  // все хорошо, и мы можем считывать данные дальше

}

else {

  // последнее считывание не было выполнено,

  // поэтому следует что-то сделать

}


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

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


double some_function()

{

 double d = 0;

 cin >> d;

 if (!cin)

  error("невозможно считать число double в 'some_function()'");

  // делаем что-то полезное

}


Строку, переданную функции

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

В стандартной библиотеке определено несколько типов исключений, таких как

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

Итак, нашу простую функцию

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


void error(string s)

{

 throw runtime_error(s);

}


Когда нам потребуется поработать с исключением

runtime_error
, мы просто перехватим его. Для простых программ перехват исключения
runtime_error
в функции
main()
является идеальным.


int main()

try {

  // наша программа

  return 0; // 0 означает успех

}

catch (runtime_error& e) {

  cerr << "runtime error: " << e.what() << '\n';

  keep_window_open();

  return 1; // 1 означает сбой

}


Вызов

e.what()
извлекает сообщение об ошибке из исключения
runtime_error
.

Символ

&
в выражении


catch(runtime_error& e) {


означает, что мы хотим передать исключение по ссылке. Пожалуйста, пока рассматривайте это выражение просто как техническую подробность. В разделах 8.5.4–8.5.6 мы объясним, что означает передача сущности по ссылке.

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

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

Исключение

out_of_range
отличается от исключения
runtime_error
, поэтому перехват исключения
runtime_error
не приводит к обработке ошибок
out_of_range
, которые могут возникнуть при неправильном использовании класса
vector
или других контейнерных типов из стандартной библиотеки. Однако и
out_of_range
, и
runtime_error
являются исключениями, поэтому для работы с ними необходимо предусмотреть перехват объекта класса exception.


int main()

try {

 // наша программа

 return 0; // 0 означает успех

}

catch (exception& e) {

  cerr << "error: " << e.what() << '\n';

  keep_window_open();

  return 1; // 1 означает сбой

}

catch (...) {

  cerr << "Ой: неизвестное исключение !\n";

  keep_window_open();

  return 2; // 2 означает сбой

}


Здесь, для того чтобы перехватить все исключения, мы добавили инструкцию

catch(...)
.

Когда исключения обоих типов (

out_of_range
и
runtime_error
) рассматриваются как разновидности одного и того же типа
exception
, говорят, что тип exception является базовым типом (супертипом) для них обоих. Этот исключительно полезный и мощный механизм будет описан в главах 13–16.

Снова обращаем ваше внимание на то, что значение, возвращаемое функцией

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

При использовании функции

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


void error(string s1, string s2)

{

 throw runtime_error(s1+s2);

}


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

Обратите внимание на то, что использование функции

error()
не зависит от количества ее предыдущих вызовов: функция
error()
всегда находит ближайший раздел
catch
, предусмотренный для перехвата исключения
runtime_error
(обычно один из них размещается в функции
main()
). Примеры использования исключений и функции
error()
приведены в разделах 7.3. и 7.7. Если исключение осталось неперехваченным, то система выдаст сообщение об ошибке (неперехваченное исключение).


ПОПРОБУЙТЕ

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

error()
не перехватывает никаких исключений.

5.6.4. Суживающие преобразования

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


int x = 2.9;

char c = 1066;


Здесь

x
будет равно
2
, а не
2.9
, поскольку переменная
x
имеет тип
int
, а такие числа не могут иметь дробных частей. Аналогично, если используется обычный набор символов ASCII, то переменная
c
будет равна
42
(что соответствует символу
*
), а не
1066
, поскольку переменные типа
char
не могут принимать такие большие значения.

В разделе 3.9.2 показано, как защититься от такого сужения путем проверки. С помощью исключений (и шаблонов; см. раздел 19.3) можно написать функцию, проверяющую и генерирующую исключение

runtime_exception
, если присваивание или инициализация может привести к изменению значения. Рассмотрим пример.


int x1 = narrow_cast(2.9);   // генерирует исключение

int x2 = narrow_cast(2.0);   // OK

char c1 = narrow_cast(1066); // генерирует исключение

char c2 = narrow_cast(85);  // OK


Угловые скобки,

<...>
, означают то же самое, что и в выражении
vector
. Они используются, когда для выражения идеи возникает необходимость указать тип, а не значение. Аргументы, стоящие в угловых скобках, называют шаблонными (template arguments). Если необходимо преобразовать значение и мы не уверены, что оно поместится, то можно использовать тип
narrow_cast
, определенный в заголовочном файле
std_lib_facilities.h
и реализованный с помощью функции
error()
. Слово
cast
[7] означает приведение типа и отражает роль этой операции в ситуации, когда что-то “сломалось” (по аналогии с гипсовой повязкой на сломанной ноге). Обратите внимание на то, что приведение типа не изменяет операнд, а создает новое значение, имеющее тип, указанный в угловых скобках и соответствующий операнду.

5.7. Логические ошибки

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

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

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


int main()

{

 vector temps; // температуры

 double temp = 0;

 double sum = 0;

 double high_temp = 0;

 double low_temp = 0;

 while (cin>>temp) // считываем и записываем в вектор temps

   temps.push_back(temp);

 for (int i = 0; i

 {

   if(temps[i] > high_temp) high_temp = temps[i]; // находим
 максимум

   if(temps[i] < low_temp) low_temp = temps[i];  // находим
 минимум

    sum += temps[i]; // вычисляем сумму

 }

 cout << "Максимальная температура: " << high_temp<< endl;

  cout << "Минимальная температура: " << low_temp << endl;

 cout << "Средняя температура:" << sum/temps.size() << endl;

}


Мы проверили эту программу, введя почасовые данные о температуре в центре Люббока, штат Техас (Lubbock, Texas) 16 февраля 2005 года (в штате Техас по-прежнему используется шкала Фаренгейта).


–16.5, –23.2, –24.0, –25.7, –26.1, –18.6, –9.7, –2.4,

7.5, 12.6, 23.8, 25.3, 28.0, 34.8, 36.7, 41.5,

40.3, 42.6, 39.7, 35.4, 12.6, 6.5, –3.7, –14.3


Результаты оказались следующими:


Максимальная температура: 42.6

Минимальная температура: –26.1

Средняя температура: 9.3


Наивный программист может прийти к выводу, что программа работает просто отлично. Безответственный программист продаст ее заказчику. Благоразумный программист проверит программу еще раз. Для этого мы ввели данные, полученные 23 июля 2005 года.


76.5, 73.5, 71.0, 73.6, 70.1, 73.5, 77.6, 85.3,

88.5, 91.7, 95.9, 99.2, 98.2, 100.6, 106.3, 112.4,

110.2, 103.6, 94.9, 91.7, 88.4, 85.2, 85.4, 87.7


На этот раз результаты таковы:


Максимальная температура: 112.4

Минимальная температура: 0.0

Средняя температура: 89.2


Ой, что-то не так. Крепкий мороз (0,0°F соответствует примерно 18°C) в Люббоке в июле — это же просто конец света! Вы видите ошибку? Поскольку переменная

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


ПОПРОБУЙТЕ

Выполните эту программу. Убедитесь, что она действительно выдает такие результаты. Попробуйте ее “сломать” (т.е. вынудить выдать неправильные результаты), введя другой набор данных. Сколько данных вам для этого может потребоваться?


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

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

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


int main()

{

 double temp = 0;

 double sum = 0;

 double high_temp = –1000; // инициализация невозможно низким
 значением

 double low_temp = 1000;  // инициализация невозможно высоким
 значением

 int no_of_temps = 0;

 while (cin>>temp) { // считываем температуру

   ++no_of_temps;   // подсчитываем количество данных

   sum += temp;    // вычисляем сумму

   if (temp > high_temp) high_temp = temp; // находим максимум

   if (temp < low_temp) low_temp = temp;   // находим минимум

 }

 cout << "Максимальная температура: " << high_temp<< endl;

 cout << "Минимальная температура: " << low_temp << endl;

 cout << "Средняя температура:" << sum/temps.size() << endl;

}


Эта программа работает? Почему вы уверены в этом? Вы сможете дать точное определение слова “работает”? Откуда взялись числа

1000
и
–1000
. Помните о “магических” константах (см. раздел 5.5.1). Указывать числа
1000
и
1000
как литеральные константы в тексте программы — плохой стиль, но может быть, и эти числа неверны? Существуют ли места, где температура опускается ниже —1000°F (–573°C)? Существуют ли места, где температура поднимается выше 1000°F (538°C)?


ПОПРОБУЙТЕ

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

min_temp
(минимальная температура) и
max_temp
(максимальная температура). Эти значения определят пределы применимости вашей программы.

5.8. Оценка

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

–34.56
. Очевидно, что ответ неверен. Почему? Потому что ни одна фигура не может иметь отрицательную площадь. Итак, вы исправляете ошибку и получаете ответ
21.65685
. Этот результат правильный? Ответить на этот вопрос труднее, потому что мы обычно не помним формулу для вычисления площади шестиугольников. Итак, чтобы не опозориться перед пользователями и не поставить им программу, выдающую глупые результаты, необходимо проверить, что ответ правильный. В данном случае это просто. Шестиугольник похож на квадрат. Набросав на бумаге рисунок, легко убедиться, что площадь шестиугольника близка к площади квадрата 3×3. Площадь этого квадрата равна 9. Итак, ответ 21.65685 не может быть правильным! Переделаем программу и получим ответ 10.3923. Это уже похоже на правду!

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

1. Является ли данный ответ разумным для данной задачи?

Можно даже задать более общий (и более трудный) вопрос.

2. Как распознать разумный результат?


Обратите внимание на то, что мы не спрашиваем: “Каков точный ответ?” или “Каков правильный ответ?” Этот ответ нам даст сама программа. Нам лишь хочется, чтобы ответ не был глупым. Только в том случае, если ответ является разумным, имеет смысл продолжать работать над программой.

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


ПОПРОБУЙТЕ

Длины сторон нашего правильного шестиугольника равны 2 см. Получили ли мы правильный ответ? Просто выполните прикидочные вычисления. Возьмите лист бумаги и набросайте эскиз. Не считайте это занятием ниже своего достоинства. Многие знаменитые ученые восхищали людей своими способностями получать примерный ответ с помощью карандаша и клочка бумаги (или салфетки). Эта способность — на самом деле простая привычка — поможет сэкономить массу времени и избежать ошибок.


Часто оценка связана с предварительным анализом данных, необходимых для вычисления, но не имеющихся в наличии. Представьте, что вы протестировали программу, оценивающую время путешествия из одного города в другой. Правдоподобно ли, что из Нью-Йорка в Денвер можно доехать на автомобиле за 15 часов 33 минуты? А из Лондона в Ниццу? Почему да и почему нет? На каких данных основана ваша догадка об ответах на эти вопросы? Часто на помощь приходит быстрый поиск в веб. Например, 2000 миль — это вполне правдоподобная оценка расстояния между Нью-Йорком и Денвером. По этой причине было бы трудно (да и не законно) поддерживать среднюю скорость, равную 130 миль/ч, чтобы добраться из Нью-Йорка в Денвер за 15 часов (15*130 ненамного меньше 2000). Можете проверить сами: мы переоценили и расстояние, и среднюю скорость, но наша оценка правдоподобности ответа вполне обоснована.


ПОПРОБУЙТЕ

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

5.9. Отладка

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

Итак, написав определенную программу, вы должны найти и удалить ошибки. Этот процесс обычно называют отладкой (debugging), а ошибки — жучками (bugs). Иногда говорят, что термин жучок возник в те времена, когда аппаратное обеспечение выходило из строя из-за насекомых, случайно заблудившихся среди электронных ламп и реле, заполнявших комнаты. Иногда считают, что этот термин изобрела Грейс Мюррей Хоппер (Grace Murray Hopper), создатель языка программирования COBOL (см. раздел 22.2.2.2). Кто бы ни придумал этот термин пятьдесят лет назад, ошибки в программах неизбежны и повсеместны. Их поиск и устранение называют отладкой (debugging).

Отладка выглядит примерно так.

1. Компилируем программу.

2. Редактируем связи.

3. Выполняем программу.


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

Приведем пример, как не надо проводить отладку.


while (программа не будет выглядеть работоспособной) { // псевдокод

  Бегло просматриваем программу в поисках странностей

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

}


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

Основной вопрос отладки звучит так:

Как понять, что программа действительно работает правильно?

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

5.9.1. Практические советы по отладке

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

error()
и перехватывать исключение в функции
main()
”.

Старайтесь, чтобы программу было легко читать.

• Хорошо комментируйте свою программу. Это не значит просто: “Добавьте много комментариев”. Вы не можете сформулировать смысл операции на естественном языке лучше, чем на языке программирования. В комментариях следует ясно и коротко указать то, что невозможно выразить в коде.

• Название программы.

 • Цель программы.

 • Кто написал программу и зачем.

 • Номера версий.

 • Какие фрагменты кода могут вызвать сложности.

 • Основные идеи.

 • Как организован код.

 • Какие предположения сделаны относительно вводных данных.

 • Каких фрагментов кода пока не хватает и какие варианты еще не обработаны.

• Используйте осмысленные имена.

 • Это не значит: “Используйте длинные имена”.

• Используйте логичную схему кода.

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

 • Воспользуйтесь стилем, принятым в книге.

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

 • Старайтесь, чтобы функция не превышала больше одной-двух страниц; большинство функций будет намного короче.

• Избегайте сложных выражений.

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

• Используйте, где только можно, библиотечные функции, а не собственный код.

 • Библиотеки, как правило, лучше продуманы и протестированы, чем ваши собственные программы.


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

Скомпилируйте программу. Разумеется, для этого понадобится компилятор. Его сообщения обычно весьма полезны, даже если мы хотели бы лучшего, и если вы не профессионал, то должны считать, что компьютер всегда прав. Если же вы реальный эксперт, то закройте книгу — она написана не для вас. Иногда программисту кажется, что правила компилятора слишком тупые и слишком строгие (как правило, это не так), и многие вещи можно было бы сделать проще (как бы не так). Однако, как говорится, “свой инструмент проклинает только плохой мастер”. Хороший мастер знает сильные и слабые стороны своего инструмента и соответственно его настраивает. Рассмотрим наиболее распространенные ошибки компиляции.

• Закрыта ли кавычка строки литералов?


cout << "Привет, << name << '\n'; // Ой!


• Закрыта ли кавычка отдельного литерала?


cout << "Привет, " << name << '\n; // Ой!


• Закрыта ли фигурная скобка блока?


int f(int a)

{

 if (a>0) {/* что-то делаем */ else {/* делаем что-то 
другое */}

} // Ой!


• Совпадает ли количество открывающих и закрывающих скобок?


if (a<=0 // Ой!

x = f(y);


Компилятор обычно сообщает об этих ошибках “поздно”; он просто не знает, что вы имели в виду, когда забыли поставить закрывающую скобку после нуля.

• Каждое ли имя объявлено?

• Включены ли все необходимые заголовочные файлы (например,

#include "std_lib_facilities.h"
)?

• Объявлено ли каждое имя до его использования?

• Правильно ли набраны все имена?


int count; /* ... */ ++Count; // Ой!

char ch;   /* ... */ Cin>>c;  // Ой-ой!


• Поставлена ли точка с запятой после каждой инструкции?


x = sqrt(y)+2 // Ой!

z = x+3;


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

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

keep_window_open()
из заголовочного файла
std_lib_facilities.h
в конце функции
main()
. В таком случае программа попросит вас ввести что-нибудь перед выходом, и вы сможете просмотреть результаты ее работы до того, как окно закроется. В поисках ошибок тщательно проверьте инструкцию за инструкцией, начиная с того места, до которого, по вашему мнению, программа работала правильно. Встаньте на место компьютера, выполняющего вашу программу. Соответствует ли вывод вашим ожиданиям? Разумеется, нет, иначе вы не занимались бы отладкой.

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


for (int i = 0; i<=max; ++j) { // Ой! (Дважды)

  for (int i=0; 0

   cout << "v[" << i << "]==" << v[i] << '\n';


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

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


int my_fct(int a, double d)

{

 int res = 0;

 cerr << "my_fct(" << a << "," << d << ")\n";

 // ...какой-то код...

 cerr << "my_fct() возвращает " << res << '\n';

 return res;

}


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

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


int my_complicated_function(int a, int b, int c)

// Аргументы являются положительными и a < b < c

{

  if (!(0

    error("Неверные аргументы функции mcf");

  // ...

}


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

assert
.


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

5.10. Пред- и постусловия

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


int my_complicated_function(int a, int b, int c)

// Аргументы являются положительными и a < b < c

{

  if (!(0

    error("Неверные аргументы функции mcf");

  // ...

}


Во-первых, в комментарии утверждается, какие аргументы ожидает функция, а затем происходит проверка этого условия (и генерирование исключения, если это условие нарушается). Это правильная стратегия. Требования, которые функция предъявляет к своим аргументам, часто называют предусловиями (pre-condition): они должны выполняться, чтобы функция работала правильно. Вопрос заключается в том, что делать, если предусловия нарушаются. У нас есть две возможности.

1. Игнорировать это (надеясь или предполагая, что все вызывающие функции передают правильные аргументы).

2. Проверить их (и каким-то образом сообщить об ошибке).


С этой точки зрения типы аргументов — это лишь способ проверки простейших предусловий на этапе компиляции. Рассмотрим пример.


int x = my_complicated_function(1, 2, "horsefeathers");


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

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

• Никто не может передать неправильные аргументы.

• Проверка слишком сильно замедлит выполнение программы.

• Проверка является слишком сложной.


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

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

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

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


int my_complicated_function(int a, int b, int c)

// Аргументы являются положительными и a < b < c

{

if (!(0

  error("Неверные аргументы функции mcf");

  // ...

}


сэкономит ваше время и силы по сравнению с более простым вариантом:


int my_complicated_function(int a, int b, int c)

{

  // ...

}

5.10.1. Постусловия

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

return!
Помимо всего прочего, следует указать, что именно функция будет возвращать; иначе говоря, если мы возвращаем из функции какое-то значение, то всегда обещаем вернуть что-то конкретное (а как иначе вызывающая функция будет знать, чего ей ждать?).

Вернемся к нашей функции, вычисляющей площадь прямоугольника (см. раздел 5.6.1).


// Вычисляет площадь прямоугольника;

// если аргументы неправильные, генерирует исключение Bad_area

int area(int length, int width)

{

 if (length<=0 || width <=0) throw Bad_area();

    return length*width;

}


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


int area(int length, int width)

// Вычисляет площадь прямоугольника;

// предусловия: аргументы length и width являются положительными

// постусловия: возвращает положительное значение, являющееся

// площадью

{

 if (length<=0 || width <=0) error("area() pre-condition");

 int a = length*width;

 if (a<=0) error("area() post-condition");

  return a;

}


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


ПОПРОБУЙТЕ

Найдите пару значений, при которых предусловие выполняется, а постусловие — нет.


Пред- и постусловия обеспечивают проверку логичности кода. Они тесно связаны с понятиями инвариантов (раздел 9.4.3), корректности (разделы 4.2 и 5.2), а также с тестированием (глава 26).

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

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

Кроме отладки, нам необходим систематический подход к поиску ошибок. Он называется тестированием (testing) и рассматривается в разделе 7.3, упражнениях к главе 10 и в главе 26. В принципе тестирование — это выполнение программы с большим и систематически подобранным множеством входных данных и сравнение результатов с ожидаемыми. Выполнение программы с заданным множеством входных данных называют тестовым вариантом (test case). Для реальных программ могут потребоваться миллионы тестовых вариантов. Тестирование не может быть ручным, когда программист набирает варианты тест за тестом, поэтому в последующих главах мы рассмотрим инструменты, необходимые для правильного тестирования.

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

Точка зрения 1. Я умнее любой программы! Я могу взломать код @#$%^!

Точка зрения 2. Я вылизывал эту программу две недели. Она идеальна!

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

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


Задание

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


#include "std_lib_facilities.h"

int main()

try {

  << здесь будет ваш код >>

  keep_window_open();

  return 0;

}

catch (exception& e) {

  cerr << "error: " << e.what() << '\n';

  keep_window_open();

  return 1;

}

catch (…) {

  cerr << "Ой: неизвестное исключение !\n";

  keep_window_open();

 return 2;

}


В некоторых из них есть ошибки, а в некоторых — нет. Ваша задача — найти и устранить все ошибки. Устранив эти ошибки, скомпилируйте программу, выполните ее и выведите на экран слово “Success!”. Даже если вы считаете, что нашли все ошибки, вставьте в программу исходный (неисправленный) вариант и протестируйте его; может быть, ваша догадка об ошибке была неверной или во фрагменте их несколько. Кроме того, одной из целей этого задания является анализ реакции компилятора на разные виды ошибок. Не набирайте эти фрагменты двадцать пять раз — для этого существует прием “copy–paste”. Не устраняйте проблемы, просто удаляя инструкции; исправляйте их, изменяя, добавляя или удаляя символы.


1. cout << "Success!\n";

2. cout << "Success!\n;

3. cout << "Success" << !\n"

4. cout << success << endl;

5. string res = 7; vector v(10); v[5] = res; cout << "Success!\n";

6. vector v(10); v(5) = 7; if (v(5)!=7) cout << "Success!\n";

7. if (cond) cout << "Success!\n"; else cout << "Fail!\n";

8. bool c = false; if (c) cout << "Success!\n"; else cout << "Fail!\n";

9. string s = "ape"; boo c = "fool"

10. string s = "ape"; if (s=="fool") cout << "Success!\n";

11. string s = "ape"; if (s=="fool") cout < "Success!\n";

12. string s = "ape"; if (s+"fool") cout < "Success!\n";

13. vector v(5); for (int i=0; 0

  cout << "Success!\n";

14. vector v(5); for (int i=0; i<=v.size(); ++i);

   cout << "Success!\n";

15. string s = "Success!\n"; for (int i=0; i<6; ++i) cout << s[i];

16. if (true) then cout << "Success!\n"; else cout << "Fail!\n";

17. int x = 2000; char c = x; if (c==2000) cout << "Success!\n";

18. string s = "Success!\n"; for (int i=0; i<10; ++i) cout << s[i];

19. vector v(5); for (int i=0; i<=v.size(); ++i);

   cout << "Success!\n";

20. int i=0; int j = 9; while (i<10) ++j;

   if (j

21. int x = 2; double d = 5/(x–2); if (d==2*x+0.5) cout << "Success!\n";

22. string s = "Success!\n"; for (int i=0; i<=10; 
++i) cout << s[i];

23. int i=0; while (i<10) ++j; if (j

24. int x = 4; double d = 5/(x–2); if (d=2*x+0.5) cout << "Success!\n";

25. cin << "Success!\n";


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

1. Назовите четыре основных вида ошибок и кратко опишите их.

2. Какие виды ошибок в студенческих программах можно проигнорировать?

3. Что должен гарантировать любой законченный проект?

4. Перечислите три подхода к исключению ошибок в программе и разработке правильного программного обеспечения.

5. Почему мы ненавидим отладку?

6. Что такое синтаксическая ошибка? Приведите пять примеров.

7. Что такое ошибка типа? Приведите пять примеров.

8. Что такое ошибка этапа редактирования связей? Приведите три примера.

9. Что такое логическая ошибка? Приведите три примера.

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

11. Как распознать разумные результаты? Какие методы используются для ответа на этот вопрос?

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

13. Почему использование исключений лучше, чем возврат признака ошибки?

14. Как выполнить тестирование при последовательном вводе данных?

15. Опишите процесс генерирования и перехвата исключений.

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

v[v.size()]
относительно вектора
v
порождает ошибку диапазона? Каким может быть результат такого вызова?

17. Дайте определение пред- и постусловия; приведите пример (который отличается от функции

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

18. В каких ситуациях можно не проверять предусловие?

19. В каких ситуациях можно не проверять постусловие?

20. Назовите этапы отладки.

21. Чем комментарии могут помочь при отладке?

22. Чем тестирование отличается от отладки?


Термины


Упражнения

1. Выполните задание из раздела ПОПРОБУЙТЕ, если вы его еще не сделали.

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


double ctok(double c) // преобразует шкалу Цельсия в шкалу Кельвина

{

 int k = c + 273.15;

 return int

}


int main()

{

 double c = 0;     // объявляем переменную для ввода

 cin >> d;       // вводим температуру в переменную ввода

 double k = ctok("c"); // преобразуем температуру

 Cout << k << endl;   // выводим температуру на печать

}


3. Самой низкой температурой является абсолютный нуль, т.е. –273,15°C, или 0 K. Даже после исправления приведенная выше программа выводит неверные результаты для температуры ниже абсолютного нуля. Поместите в функцию

main()
проверку, которая выводит сообщение об ошибке, если температура ниже –273,15°C.

4. Повторите упр. 3, но на этот раз ошибку обработайте в функции

ctok()
.

5. Измените программу так, чтобы она преобразовывала шкалу Кельвина в шкалу Цельсия.

6. Напишите программу, преобразовывающую шкалу Цельсия в шкалу Фаренгейта и наоборот (по формуле из раздела 4.3.3). Для того чтобы распознать разумные результаты, используйте оценку из раздела 5.8.

7. Квадратное уравнение имеет вид



Для решения этого уравнения используется формула



Тем не менее есть одна проблема: если b2–4ac меньше нуля, возникнет ошибка. Напишите программу, вычисляющую решение квадратного уравнения. Напишите функцию, которая выводит на печать все корни квадратного уравнения при заданных коэффициентах a, b и c. Вызовите эту функцию из модуля

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

8. Напишите программу, считывающую ряд чисел и записывающую их в

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

“Пожалуйста, введите несколько чисел (для прекращения ввода нажмите клавишу <|>):”

12 23 13 24 15
“Пожалуйста, введите количество чисел, которые хотите просуммировать:”

“Сумма первых 3 чисел: 12 , 23 и 13 равна 48.”

9. Измените программу из упр. 8, чтобы она использовала тип

double
вместо
int
. Кроме того, создайте вектор действительных чисел, содержащий N–1 разностей между соседними величинами, и выведите этот вектор на печать.

10. Напишите программу, вычисляющую начальный отрезок последовательности Фибоначчи, т.е. последовательности, начинающиеся с чисел 1 1 2 3 5 8 13 21 34. Каждое число в этой последовательности равно сумме двух предыдущих. Найдите последнее число Фибоначчи, которое можно записать в переменную типа

int
.

11. Реализуйте простую игру на угадывание “Быки и коровы”. Программа должна хранить вектор из четырех чисел в диапазоне от 0 до 9, а пользователь должен угадать загаданное число. Допустим, программа загадала число 1234, а пользователь назвал число 1359; программа должна ответить “1 бык и 1 корова”, поскольку пользователь угадал одну правильную цифру (1) на правильной позиции (бык) и одну правильную цифру (3) на неправильной позиции (корова). Угадывание продолжается, пока пользователь не получит четырех быков, т.е. не угадает четыре правильные цифры на четырех правильных позициях.

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

randint(10)
из заголовочного файла
std_lib_facilities.h
. Обратите внимание на то, что при постоянном выполнении программы вы каждый раз при новом сеансе будете получать одинаковые последовательности, состоящие из четырех цифр. Для того чтобы избежать этого, предложите пользователю ввести любое число и вызовите функцию
srand(n)
, где
n
— число, введенное пользователем до вызова функции
randint(10)
. Такое число
n
называется начальным значением (seed), причем разные начальные значения приводят к разным последовательностям случайных чисел.

13. Введите пары (день недели, значение) из стандартного потока ввода. Например:


Tuesday 23 Friday 56 Tuesday –3 Thursday 99


Запишите все значения для каждого дня недели в вектор

vector
. Запишите значения семи дней недели в отдельный вектор. Напечатайте сумму чисел для каждого из векторов. Неправильный день недели, например
Funday
, можно игнорировать, но синонимы допускаются, например
Mon
и
monday
. Выведите на печать количество отвергнутых чисел.


Послесловие

Не считаете ли вы, что мы придаем ошибкам слишком большое значение? Новички могут подумать именно так. Очевидная и естественная реакция такова: “Все не может быть настолько плохо!” Именно так, все именно настолько плохо. Лучшие умы планеты поражаются и пасуют перед сложностью создания правильных программ. По нашему опыту, хорошие математики, как правило, недооценивают проблему ошибок, но всем ясно, что программ, которые с первого раза выполняются правильно, очень немного. Мы вас предупредили! К счастью, за пятьдесят лет мы научились организовывать код так, чтобы минимизировать количество проблем, и разработали методы поиска ошибок, которые, несмотря на все наши усилия, неизбежны. Методы и примеры, описанные в этой главе, являются хорошей отправной точкой.

Глава 6. Создание программ

“Программирование — это понимание”.

Кристен Нюгорд (Kristen Nygaard)


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

6.1. Задача

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

• Иллюстрирует методы проектирования и программирования.

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

• Не требует слишком большого количества новых языковых конструкций.

• Достаточно сложная и требует размышлений над ее проектированием.

• Допускает много вариантов решения.

• Решает понятную задачу.

• Решает задачу, которая заслуживает решения.

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


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

Например, если вы введете строку


2+3.1*4


то программа должна ответить


14.4


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

6.2. Размышления над задачей

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

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

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

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

6.2.1. Стадии разработки программы

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

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

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

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

6.2.2. Стратегия

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

• Какая задача должна быть решена? Для того чтобы ответить на этот вопрос, необходимо прежде всего попытаться уточнить, что вы пытаетесь сделать. Как правило, для этого формулируют описание задачи, т.е. пытаются понять, в чем заключается ее суть. На этом этапе вы должны встать на точку зрения пользователя (а не программиста); иначе говоря, должны задавать вопросы о том, что должна делать программа, а не о том, как она будет это делать. Спросите: “Что эта программа может сделать для меня?” и “Как бы я хотел взаимодействовать с этой программой?” Помните, большинство из нас являются опытными пользователями компьютеров.

 • Ясна ли постановка задачи? Для реальных задач на этот вопрос никогда нельзя ответить положительно. Даже студенческое упражнение бывает трудно сформулировать достаточно точно и конкретно. Поэтому попытайтесь уточнить постановку задачи. Было бы обидно решить неправильно поставленную задачу. Другая ловушка состоит в том, что вы можете поставить слишком много вопросов. Пытаясь понять, что вы хотите, легко увлечься и стать претенциозным. Почти всегда лучше задавать поменьше вопросов, чтобы программу было легче описать, понять, использовать и (возможно) реализовать. Убедившись, что этот подход работает, можно создать более изощренную “версию 2.0”, основанную на вашем опыте.

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

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

 • Знаете ли вы, какие инструменты, библиотеки и тому подобные ресурсы вам могут понадобиться? Ответ почти всегда положительный. Даже на самых ранних этапах изучения языка программирования в вашем распоряжении есть небольшие фрагменты стандартной библиотеки С++. Позднее вы узнаете больше об этой библиотеке и способах ее эффективного использования. Вам понадобятся графика и библиотеки графического интерфейса пользователя, а также библиотеки для работы с матрицами и т.п. Получив небольшой опыт, вы сможете найти тысячи таких библиотек в веб. Помните: не стоит изобретать колесо, разрабатывая программное обеспечение для решения реальных задач. Однако при обучении программированию все обстоит в точности наоборот: ученик должен заново изобрести колесо, чтобы увидеть, как оно действует. Время, которое вы сэкономите, используя хорошую библиотеку, можно посвятить разработке других частей программы или отдыху. Как понять, что та или иная библиотека подходит для решения вашей задачи и имеет достаточно высокое качество? Это трудная проблема. Можно поспрашивать у коллег, в дискуссионных группах по интересам или попытаться поэкспериментировать с библиотекой на небольших примерах, прежде чем подключать ее к вашему проекту.

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

vector
), строки (класс
string
), а также потоки ввода и вывода (
cin
и
cout
). Эта глава содержит первые завершенные примеры проектирования, реализации и использования программы, содержащей типы, определенные пользователем (
Token
и
Token_stream
). В главах 8 и 13–15 представлено много других примеров вместе с принципами их проектирования. Пока рассмотрим аналогию: если бы вы конструировали автомобиль, то начали бы с идентификации его составных частей, например колес, двигателя, сидений, дверных ручек и т.д. Современный автомобиль состоит из десятков тысяч таких компонентов. Реальная программа в этом отношении не отличается от автомобиля за исключением того, что состоит из фрагментов кода. Мы же не пытаемся создавать автомобили непосредственно из исходного сырья, т.е. из стали, пластика и дерева. Поэтому и программы не следует конструировать непосредственно из выражений, инструкций и типов, предусмотренных в языке. Проектирование и реализация составных компонентов является основной темой нашей книги и проектирования программного обеспечения вообще (пользовательские типы описаны в главе 9, иерархии классов — в главе 14, а обобщенные типы — в главе 20).

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

 • Выявить свое понимание идеи и требуемые инструменты.

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

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

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

6.3. Назад к калькулятору!

Как мы хотим взаимодействовать с калькулятором? Это просто: мы знаем, как использовать потоки

cin
и
cout
, но графические пользовательские интерфейсы (GUI) будут рассмотрены лишь в главе 16, поэтому остановимся на клавиатуре и консольном окне. Введя выражение с помощью клавиатуры, мы вычисляем его и выводим результат на экран. Рассмотрим пример.


Выражение: 2+2

Результат: 4

Выражение: 2+2*3

Результат: 8

Выражение: 2+3–25/5

Результат: 0


Эти выражения, т.е. 2+2 и 2+2*3, должны быть введены пользователем; все остальное сделает программа. Для приглашения к вводу мы используем слово “

Выражение:
”. Мы могли бы выбрать фразу “
Пожалуйста, введите выражение и символ перехода на новую строку
”, но этот вариант выглядит слишком многословным и бессмысленным. С другой стороны, такие короткие приглашения, как
>
, выглядят чересчур загадочно. Анализировать такие варианты использования на ранней стадии проектирования программы весьма важно. Это позволяет сформулировать очень практичное определение того, что программа должна делать как минимум.

Обсуждая проектирование и анализ, мы будем называть такие примеры прецедентами использования (use cases). Впервые сталкиваясь с разработкой калькулятора, большинство людей сразу приходят к следующей логике программы:


read_a_line

calculate // выполните работу

write_result


Этот набросок, конечно, не программа; он называется псевдокодом (pseudo code). Псевдокоды обычно используются на ранних этапах проектирования, когда еще не совсем ясно, какой смысл мы вкладываем в обозначения. Например, является ли слово “calculate” вызовом функции? Если да, то каковы его аргументы? Для ответа на этот вопрос просто еще не настало время.

6.3.1. Первое приближение

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


#include "std_lib_facilities.h"

int main()

{

  cout << "Пожалуйста, введите выражение (допускаются + и –): ";

 int lval = 0;

 int rval;

 char op;

 int res;

 cin>>lval>>op>>rval; // считываем что-то вроде 1 + 3

 if (op=='+')

   res = lval + rval; // сложение

 else if (op=='–')

    res = lval – rval; // вычитание

  cout << "Результат: " << res << '\n';

  keep_window_open();

 return 0;

}


Иначе говоря, программа считывает пару значений, разделенных оператором, например

2+2
, вычисляет результат (в данном случае
4
) и выводит его на печать. Здесь переменная, стоящая слева от оператора, обозначена как
lval
, а переменная, стоящая справа от оператора, — как
rval
.

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

1. Несколько упростим код.

2. Добавим операции умножения и деления (например,

2*3
).

3. Добавим возможность выполнять несколько операторов (например,

1+2+3
).


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

switch
, а не
if
.

Цепочку операций, например

1+2+3+4
, будем выполнять по мере считывания значений; иначе говоря, начнем с
1
, потом увидим
+2
и добавим
2
к
1
(получим промежуточный результат, равный
3
), увидим
+3
и добавим
3
к промежуточному результату, равному
3
, и т.д.

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


#include "std_lib_facilities.h"

int main()

{

 cout <<

 << "Пожалуйста, введите выражение (допускаются +, –, * и /): ";

 int lval = 0;

 int rval;

 char op;

 cin>>lval; // считываем самый левый операнд

 if (!cin) error("нет первого операнда");

 while (cin>>op) { // считываем оператор и правый операнд в цикле

   cin>>rval;

   if (!cin) error("нет второго операнда ");

   switch(op) {

   case '+':

    lval += rval; // сложение: lval = lval + rval

    break;

   case '–':

    lval –= rval; // вычитание: lval = lval – rval

    break;

    case '*':

    lval *= rval; // умножение: lval = lval * rval

    break;

   case '/':

    lval /= rval; // деление: lval = lval / rval

     break;

    default: // нет другого оператора: выводим результат

     cout << "Результат: " << lval << '\n';

     keep_window_open();

     return 0;

   }

 }

 error("неверное выражение");

}


Это неплохо, но попытайтесь вычислить выражение

1+2*3
, и вы увидите, что результат равен
9
, а не
7
, как утверждают учителя математики. Аналогично,
1–2*3
равно
–3
, а не
–5
, как мы думали. Мы выполняем операции в неправильном порядке:
1+2*3
вычисляется как
(1+2)*3
, а не
1+(2*3)
, как обычно. Аналогично,
1–2*3
вычисляется как
(1–2)*3
, а не
1–(2*3)
, как обычно. Лентяи! Мы можем считать правило, согласно которому умножение выполняется раньше, чем сложение, устаревшим, но не стоит отменять многовековые правила просто для того, чтобы упростить себе программирование.

6.3.2. Лексемы

Теперь (каким-то образом) мы должны заранее узнать, содержит ли строка символ

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

1. Выражение не обязательно занимает только одну строку. Рассмотрим пример.


1

+

2


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

2. Как обнаружить символ

*
(или
/
) среди цифр и символов
+
,
,
(
и
)
в нескольких строках ввода?

3. Как запомнить, в каком месте стоит символ

*
?

4. Как вычислить выражение, которое не выполняется слева направо (как

1+2*3
). Если бы мы были безоглядными оптимистами, то сначала решили бы задачи 1–3, отложив задачу 4 на более позднее время. Кроме того, нам понадобится помощь. Кто-то ведь должен знать, как считать такие вещи, как числа и операторы, из входного потока и сохранить их так, чтобы с ними было удобно работать. Общепринятый и самый полезный ответ на эти вопросы таков: разложите выражение на лексемы, т.е. сначала считайте символы, а затем объедините их в лексемы (tokens). В этом случае после ввода символов


45+11.5/7


программа должна создать список лексем


45

+

11.5

/

7 


Лексема (token) — это последовательность символов, выражающих нечто, что мы считаем отдельной единицей, например число или оператор. Именно так компилятор языка С++ работает с исходным кодом программы. На самом деле разложение на лексемы часто в том или ином виде применяется при анализе текста. Анализируя примеры выражений на языке С++, можно выделить три вида лексем.

• Литералы с плавающей точкой, определенные в языке C++, например

3.14
,
0.274e2
и
42
.

• Операторы, например

+
,
,
*
,
/
,
%
.

• Скобки

(
,
)
.


Внешний вид литералов с плавающей точкой может создать проблемы: считать число

12
намного легче, чем
12.3е–3
, но калькуляторы обычно выполняют вычисления над числами с плавающей точкой. Аналогично, следует ожидать, что скобки в программе, имитирующей вычисления калькулятора, окажутся весьма полезными.

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

42
и где-то храним символы
4
и
2
, то позднее должны выяснить, что эта строка представляет число
42
(т.е.
4*10+2
). Общепринятое решение этой задачи — хранить каждую лексему в виде пары (вид, значение).

Вид идентифицирует лексему как число, оператор или скобку. Для чисел (в нашем примере — только для чисел) в качестве значения используется само число.

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

int
позволяет хранить целые числа и выполнять операции сложения, вычитания, умножения и вычисления остатка, в то время как тип
string
позволяет хранить последовательности символов и выполнять конкатенацию и доступ к символу по индексу. В языке С++ и его стандартной библиотеке определено много типов, например
char
,
int
,
double
,
string
,
vector
и
ostream
, но не тип
Token
. На самом деле существует огромное количество типов — тысячи и сотни тысяч, — которые мы хотели бы иметь, но которых нет в языке и в стандартной библиотеке.

Среди наших любимых типов, которых нет в библиотеке, — классы

Matrix
(см. главу 24),
Date
(см. главу 9) и целые числа с бесконечной точностью (поищите в веб класс
Bignum
). Если вы еще раз поразмыслите над этим, то поймете, что язык не может поддерживать десятки тысяч типов: кто их определит, кто их реализует, как их найти и какое толстое руководство по использованию языка при этом получится? Как и большинство современных языков программирования, язык С++ решает эту проблему, позволяя программисту при необходимости определять свои собственные типы (типы, определенные пользователем).

6.3.3. Реализация лексем

Как должна выглядеть лексема в нашей программе? Иначе говоря, как должен выглядеть тип

Token
? Класс
Token
должен предусматривать выполнение операторов, например
+
и
, а также представлять числа, такие как
42
и
3.14
. В самой простой реализации нужно придумать, как задать вид лексемы и как хранить числа.



Существует много способов реализации этой идеи в программе на языке С++. Вот ее простейший вариант:


class Token { // очень простой тип, определенный пользователем

public:

 char kind;

  double value;

};


Класс

Token
— это тип (такой же, как
int
или
char
), поэтому его можно использовать для определения переменных и хранения значений. Он состоит из двух частей (членов):
kind
и
value
. Ключевое слово
class
означает “тип, определенный пользователем”; это значит, что он содержит члены (хотя в принципе может их и не содержать). Первый член,
kind
, имеет тип
char
и представляет собой символ. С его помощью удобно хранить символы
'+'
и
'*'
, чтобы представить операции
*
и
+
. Рассмотрим пример использования этого типа.


Token t;     // t — объект класса Token

t.kind = '+';  // t представляет операцию +

Token t2;    // t2 — другой объект класса Token

t2.kind = '8'; // цифра 8 означает, что "вид" является числом

t2.value = 3.14;


Для доступа к члену класса используется обозначение имя_объекта.имя_члена. Выражение

t.kind
читается как “член
kind
объекта
t
”, а выражение
t2.value
— как “член
value
объекта
t2
”. Объекты класса
Token
можно копировать так же, как и переменные типа
int
.


Token tt = t;   // копирование при инициализации

if (tt.kind != t.kind) error("невозможно!");

t = t2;      // присваивание

cout << t.value; // вывод числа 3.14


Имея класс

Token
, можно выразить выражение
(1.5+4)*11
с помощью семи лексем.



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

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

Класс

Token
представляет пример типа, определенного пользователем. Тип, определенный пользователем, может иметь функции-члены (операции), а также данные члены. Существует много причин для определения функций-членов. В данном примере мы описали две функции-члена для того, чтобы инициализация объекта класса
Token
стала проще.


class Token {

public:

 char kind;   // вид лексемы

 double value;  // для чисел: значение

 Token(char ch) // создает объект класса Token

         // из переменной типа char

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

 Token(char ch, double val)  // создает объект класса Token

   :kind(ch), value(val) { }  // из переменных типа

                // char и double

};


Эти две функции-члена называют конструкторами (constructors). Их имя совпадает с именем типа, и они используются для инициализации (конструирования) объектов класса

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


Token t1('+');   // инициализируем t1, так что t1.kind = '+'

Token t2('8',11.5); // инициализируем t2,

           // так что t2.kind = '8' и t2.value = 11.5


В первом конструкторе фрагмент

:kind(ch)
,
value(0)
означает “инициализировать член kind значением переменной
ch
и установить член
value
равным нулю”. Во втором конструкторе фрагмент
:kind(ch)
,
value(val)
означает “инициализировать член
kind
значением переменной
ch
и установить член
value
равным переменной val”. В обоих вариантах нам требуется лишь создать объект класса
Token
, поэтому тело функции ничего не содержит:
{ }
. Специальный синтаксис инициализации (список инициализации членов класса) начинается с двоеточия и используется только в конструкторах.

Обратите внимание на то, что конструктор не возвращает никаких значений, потому что в конструкторе это не предусмотрено. (Подробности изложены в разделах 9.4.2 и 9.7.)

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

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

Token
в калькуляторе?

Можно считать входную информацию в вектор объектов

Token
.


Token get_token(); // считывает объекты класса Token из потока cin

vector tok; // здесь храним объекты класса Token

int main()

{

 while (cin) {

    Token t = get_token();

   tok.push_back(t);

 }

 // ...

}


Теперь можно сначала считать выражение, а вычислить его позднее. Например, для выражения

11*12
получим следующие лексемы:



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

11
и
12
хранятся как числовые значения, а не как строки.

Рассмотрим теперь более сложные выражения. Выражение

1+2*3
состоит из пяти объектов класса
Token
.



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


for (int i = 0; i

 if (tok[i].kind=='*') { // мы нашли умножение!

    double d = tok[i–1].value*tok[i+1].value;

   // и что теперь?

 }

}


Да, и что теперь? Что делать с произведением

d
? Как определить порядок выполнения частичных выражений? Хорошо, символ
+
предшествует символу
*
, поэтому мы не можем выполнить операции просто слева направо. Можно попытаться выполнить их справа налево! Этот подход сработает для выражения
1+2*3
, но не для выражения
1*2+3
. Рассмотрим выражение
1+2*3+4
. Это пример “внутренних вычислений”:
1+(2*3)+4
. А как обработать скобки? Похоже, мы зашли в тупик. Теперь необходимо вернуться назад, прекратить на время программировать и подумать о том, как считывается и интерпретируется входная строка и как вычисляется арифметическое выражение.

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


ПОПРОБУЙТЕ

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

12.5+2
. Ее можно разбить на лексемы, понять, что выражение простое, и вычислить ответ. Это может оказаться несколько запутанным, но прямым решением, поэтому, возможно, следовало бы идти в этом направлении! Определите, что следует сделать, если строка содержит операции
+
и
*
в выражении
2+3*4
? Его также можно вычислить с помощью “грубой силы”. А что делать с более сложным выражением, например
1+2*3/4%5+(6–7*(8))
? И как выявлять ошибки, такие как
2+*3
и
2&3
? Подумайте об этом, опишите на бумаге возможные решения, используя интересные или типичные арифметические выражения.

6.3.5. Назад к школьной доске!

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


while (not_finished) {

 read_a_line

  calculate // выполняем вычисления

  write_result

}


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

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

1. Если мы введем выражение

45+5/7
, то как выделить его отдельные части —
45
,
+
,
5
,
/
и
7
? (Выделение лексем!)

2. Как идентифицировать конец ввода выражения? Разумеется, с помощью символа перехода на новую строку! (Слово “разумеется” всегда подозрительно: “разумеется” — это не причина.)

3. Как представить выражение

45+5/7
в виде данных, чтобы потом вычислить его? Прежде чем выполнить сложение, необходимо из цифр
4
и
5
образовать целое число
45
(т.е. вычислить выражение
4*10+5
). (Таким образом, выделение лексем — только часть решения.)

4. Как гарантировать, что выражение

45+5/7
вычисляется как
45+(5/7)
, а не как
(45+5)/7
?

5. Чему равно значение

5/7
? Около
.71
, но это число не целое. Используя свой опыт работы с калькуляторами, легко понять, что ответ должен быть числом с плавающей точкой. Следует ли разрешить ввод таких чисел? Конечно!

6. Можно ли использовать переменные? Например, можно написать


v=7

m=9

v*m


Хорошая идея, но давайте подождем. Сначала следует понять, как работает программа. Возможно, ответ на шестой вопрос является самым важным. В разделе 7.8 мы увидим, что, ответив “да”, мы практически вдвое увеличим размер программы. Это приведет к удвоенным затратам времени, необходимого для разработки первого приближения. Если вы новичок, то ваши усилия увеличатся даже вчетверо и проект выйдет из-под вашего контроля. Очень важно избегать углубления во всевозможные детали на ранних этапах проекта. Сначала создайте простую версию, реализовав лишь основные функции. Получив работоспособную программу, вы станете более уверенными. Намного проще разрабатывать программу поэтапно, а не сразу всю. Ответив “да” на шестой вопрос, вы столкнетесь с еще одним неприятным эффектом: теперь вам будет сложнее устоять перед соблазном реализовать еще одно “важное свойство”. Как насчет вычисления математических функций? А насчет циклов? Начав накапливать “важные свойства”, трудно остановиться.

С точки зрения программиста вопросы 1, 3 и 4 бессмысленны. Они связаны друг с другом, поскольку, обнаружив число

45
и оператор
+
, мы должны решить, что с ними делать? Иначе говоря, мы должны решить, как их хранить в программе?

Очевидно, что выделение лексем является частью решения, но только частью.

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

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

6.4. Грамматики

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


45+11.5/7


программа должна создать список лексем

45

+

11.5

/

7


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

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

45+11.5/7
означает
45+(11.5/7)
, а не
(45+11.5)/7
, но как объяснить программе, что деление имеет более высокий приоритет, чем сложение? Стандартный ответ — написать грамматику, определяющую синтаксис ввода, а затем программу, реализующую правила этой грамматики. Рассмотрим пример.


// Пример простой грамматики выражений:

Выражение:

  Терм

  Выражение "+" Терм // сложение

  Выражение "–" Терм // вычитание

Терм:

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

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

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

  Терм "%" Первичное выражение // остаток (деление по модулю)

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

  Число

  "(" Выражение ")" // группировка

Число:

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


Это набор простых правил. Последнее правило читается так: “

Число
— это
литерал с плавающей точкой
”. Предыдущее правило утверждает: “
Первичное выражение
— это
Число
или скобка,
'('
, за которой следует
Выражение
и скобка,
')'
”. Правила для
Выражения
и
Терма
аналогичны; каждый из них определяется в терминах одного из предыдущих правил.

Как показано в разделе 6.3.2, наши лексемы, позаимствованные из определения языка C++, таковы:

литерал_с_плавающей_точкой
(по правилам языка C++, например,
3.14
,
0.274e2
или
42
);

+
,
,
*
,
/
,
%
(операторы);

(
,
)
(скобки).


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

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

1–2*3
,
1+2–3
и
3*2+4/2
. Кажется, что эти вычисления “зашиты” в вашем мозге. Однако можете ли вы объяснить, как вы это делаете? Можете ли вы объяснить это достаточно хорошо кому-нибудь, кто таких вычислений никогда не делал? Можете ли вы сделать это для любого сочетания операторов и операндов? Для того чтобы достаточно точно и подробно объяснить все это компьютеру, необходимы обозначения, и грамматика является наиболее мощным и удобным инструментом.

Как читать грамматику? Получив некое входное выражение, мы ищем среди правил совпадения для считанной лексемы, начиная с первого правила Выражение. Считывание потока лексем в соответствии с грамматикой называется синтаксическим разбором (parsing), а программа, выполняющая эту работу, называется синтаксическим анализатором (parser, или syntax analyser). Синтаксический анализатор считывает лексемы слева направо, точно так же, как мы печатаем, а затем читаем слова. Рассмотрим простой пример: 2 — это выражение?

1. Выражение должно быть Термом или заканчиваться Термом. Этот Терм должен быть Первичным выражением или заканчиваться Первичным выражением. Это Первичное выражение должно начинаться с открывающей скобки, (, или быть Числом. Очевидно, что 2 — не открывающая скобка, (, а литерал_с_плавающей_точкой, т.е. Число, которое является Первичным выражением.

2. Этому Первичному выражению (Число 2) не предшествует ни символ /, ни *, ни %, поэтому оно является завершенным Термом (а не выражением, которое заканчивается символом /, * или %).

3. Этому Терму (Первичное выражение 2) не предшествует ни символ +, ни , поэтому оно является завершенным Выражением (а не выражением, которое заканчивается символами + или ).


Итак, в соответствии с нашей грамматикой 2 — это выражение. Этот просмотр грамматики можно описать так.



Этот рисунок иллюстрирует путь, который мы прошли, перебирая определения. Повторяя этот путь, мы видим, что 2 — это выражение, поскольку 2 — это литерал_с_плавающей_точкой, который является Числом, которое является Первичным выражением, которое является Термом, который является Выражением.

Попробуем проделать более сложное упражнение: 2+3 — это Выражение? Естественно, большинство рассуждений совпадает с рассуждениями для числа 2.

1. Выражение должно быть Термом или заканчиваться Термом, который должен быть Первичным выражением или заканчиваться Первичным выражением, а Первичное выражение должно начинаться с открывающей скобки, (, или быть Числом. Очевидно, что 2 является не открывающей скобкой, (, а литералом_с_плавающей_точкой, который является Числом, которое является Первичным выражением.

2. Этому Первичному выражению (Число 2) не предшествует ни символ /, ни *, ни %, поэтому оно является завершенным Термом (а не выражением, которое заканчивается символом /, * или %).

3. За этим Термом (Числом 2) следует символ +, поэтому он является окончанием первой части Выражения, и мы должны поискать Терм, который следует за символом +. Точно так же мы приходим к выводу, что 2 и 3 — это Термы. Поскольку за Термом 3 не следует ни символ +, ни , он является завершенным Термом (а не первой частью Выражения, содержащего символ + или -). Следовательно, 2+3 соответствует правилу Выражение+Term и является Выражением.


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

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



Действительная причина, по которой мы так интересуемся грамматиками, заключается в том, что с их помощью можно решить проблему корректного грамматического разбора выражений, содержащих символы + и *, такие как 45+11.5*7. Однако заставить компьютер анализировать правила так, как это сделали мы, очень трудно. Поэтому пропустим промежуточные этапы, которые проделали для выражений 2 и 2+3. Очевидно, что 45, 11.5 и 7 являются литералами_с_ плавающей_точкой, которые являются Числами, которые являются Первичными выражениями, так что можем игнорировать все остальные правила.

1. 45 — это Выражение, за которым следует символ +, поэтому следует искать Терм, чтобы применить правило Выражение+Терм.

2. 11.5 — это Терм, за которым следует символ *, поэтому следует искать Первичное выражение, чтобы применить правило Терм*Первичное выражение.

3. 7 — это первичное выражение, поэтому 11.5*7 — это Терм в соответствии с правилом Терм*Первичное выражение. Теперь можем убедиться, что 45+11.5*7 — это Выражение в соответствии с правилом Выражение+Терм. В частности, это Выражение, которое сначала выполняет умножение 11.5*7, а затем сложение 45+11.5*7 так, будто мы написали выражение 45+(11.5*7).


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

Как и прежде, этот рисунок иллюстрирует путь, который мы прошли, перебирая определения. Обратите внимание на то, что правило Терм*Первичное выражение гарантирует, что 11.5 умножается на 7, а не добавляется к 45.



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

2+2
или
45+11.5*7
. Очевидно, вы это и так знаете. Мы лишь стараемся выяснить, как выполняет эти вычисления компьютер. Разумеется, для того чтобы выполнять такие вычисления, людям грамматики не нужны, а вот компьютерам они очень хорошо подходят. Компьютер быстро и правильно применяет правила грамматики. Точные правила — вот что нужно компьютеру.

6.4.1. Отступление: грамматика английского языка

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


Предложение:

  Имя существительное Глагол  // например, C++ rules

  Предложение Союз Предложение // например, Birds fly but

                // fish swim


Союз:

 "and"

 "or"

 "but"


Имя существительное:

 "birds"

 "fish"

 "C++"


Глагол:

 "rules"

 "fly"

 "swim"


Предложение состоит из частей речи (например, имен существительных, глаголов и союзов). В соответствии с этими правилами предложение можно разложить на слова — имена существительные, глаголы и т.д. Эта простая грамматика также включает в себя семантически бессмысленные предложения, такие как “C++ fly and birds rules,” но решение этой проблемы выходит далеко за рамки рассмотрения нашей книги.

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

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



Сложности еще не закончились. Если вы не уверены, что все правильно поняли, то вернитесь и перечитайте раздел 6.4 с самого начала. Возможно, при втором чтении вы поймете, о чем идет речь!

6.4.2. Запись грамматики

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

1. Отличать правило от лексемы.

  2. Записывать правила одно за другим (последовательно).

  3. Выражать альтернативные варианты (разветвление).

  4. Выражать повторяющиеся варианты (повторение).

  5. Распознавать начальное правило.


В разных учебниках и системах грамматического разбора используются разные соглашения и терминология. Например, иногда лексемы называют терминалами (terminals), а правила — нетерминалами (non-terminals), или продукциями (productions). Мы просто заключаем лексемы в двойные кавычки и начинаем с первого правила. Альтернативы выражаются с помощью линий. Рассмотрим пример.


Список:

  "{"Последовательность"}"

Последовательность:

  Элемент

  Элемент "," Последовательность

Элемент:

  "A"

  "B"


Итак, Последовательность — это Элемент или Элемент, за которым следует разделяющая запятая и другая Последовательность. Элемент — это либо буква A, либо B. Список — это Последовательность в фигурных скобках. Можно сгенерировать следующие Списки (как?):


{A}

{B}

{A,B}

{A,A,A,A,B}


Однако то, что перечислено ниже, списком не является (почему?):


{}

A

{A,A,A,A,B

{A,A,C,A,B}

{A B C}

{A,A,A,A,B,}


Этим правилам вас в детском садике не учили, и в вашем мозге они не “встроены”, но понять их не сложно. Примеры их использования для выражения синтаксических идей можно найти в разделах 7.4 и 7.8.1.

6.5. Превращение грамматики в программу

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

Token
. Программу, реализующую грамматику, часто называют программой грамматического разбора (parser).

6.5.1. Реализация грамматических правил

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


get_token()  // считывает символы и составляет лексемы

       // использует поток cin

expression() // реализует операции + и –

       // вызывает функции term() и get_token()

term()    // реализует операции *, / и %

       // вызывает функции primary() и get_token()

primary()   // реализует числа и скобки

       // вызывает функции expression() и get_token()


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

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

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


get_token()  // чтобы обработать скобки ( и )

expression() // чтобы обработать Выражение


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

2+3
функция
expression()
должна была бы возвращать
5
. Теперь понятно! Именно это мы и должны сделать! Поступая таким образом, мы избегаем ответа на один из труднейших вопросов: “Как представить выражение
45+5/7
в виде данных, чтобы его можно было вычислить?” Вместо того чтобы хранить представление этого выражения в памяти, мы просто вычисляем его по мере считывания входных данных. Эта простая идея коренным образом изменяет ситуацию! Она позволяет в четыре раза уменьшить размер программы по сравнению с вариантом, в котором функция
expression()
возвращает что-то сложное для последующего вычисления. Таким образом, мы сэкономим около 80% объема работы.

Функция

get_token()
стоит особняком: поскольку она обрабатывает лексемы, а не выражения, она не может возвращать значения подвыражений. Например,
+
и
(
— это не выражения. Таким образом, функция
get_token()
должна возвращать объект класса
Token
.


// функции, подчиняющиеся грамматическим правилам

Token get_token()  // считывает символы и составляет лексемы

double expression() // реализует операции + и –

double term()    // реализует операции *, / и %

double primary()   // реализует числа и скобки

6.5.2. Выражения

Сначала напишем функцию

expression()
. Грамматическое правило
Выражение
выглядит следующим образом:


Выражение:

  Терм

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

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


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

6.5.2.1. Выражения: первая попытка

Посмотрев на правило Выражение '+' Терм, сначала попытаемся вызвать функцию

expression()
, поищем операцию
+
), а затем вызовем функцию
term()
.


double expression()

{

 double left = expression();  // считываем и вычисляем Выражение

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

 switch (t.kind) {       // определяем вид лексемы

 case '+':

   return left + term();    // считываем и вычисляем Терм,

                 // затем выполняем сложение

  case '–':

   return left – term();   // считываем и вычисляем Терм,

                 // затем выполняем вычитание

  default:

   return left;        // возвращаем значение Выражения

  }

}


Программа выглядит неплохо. Это почти тривиальная транскрипция грамматики. Она довольно проста: сначала считываем Выражение, а затем проверяем, следует ли за ним символ + или , и в случае положительного ответа считываем Терм.

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

expression()
никогда не продвинется дальше своей первой строки: функция
expression()
начинает работу с вызова функции
expression()
, которая, в свою очередь, начинается с вызова функции
expression()
, и так до бесконечности. Этот процесс называется бесконечной рекурсией, но на самом деле он довольно быстро заканчивается, исчерпав память компьютера. Термин рекурсия используется для описания процесса, который выполняется, когда функция вызывает саму себя. Не любая рекурсия является бесконечной; рекурсия является очень полезным методом программирования (см. раздел 8.5.8).

6.5.2.2. Выражения: вторая попытка

Итак, что же мы делаем? Каждый Терм является Выражением, но не любое Выражение является Термом; иначе говоря, можно начать поиск с Терма и переходить к поиску полного Выражения, только обнаружив символ + или . Рассмотрим пример.


double expression()

{

 double left = Term();     // считываем и вычисляем Терм

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

 switch (t.kind) {        // определяем вид лексемы

 case '+':

   return left + expression(); // считываем и вычисляем

                 // Выражение, затем 

                  // выполняем сложение

 case '–':

   return left – expression(); // считываем и вычисляем

                  // Выражение, затем

                  // выполняем вычитание

 default:

   return left;         // возвращаем значение Терма

  }

}

Этот программный код действительно — более или менее — работает. Мы включим его в окончательный вариант программы для грамматического разбора правильных выражений и отбраковки неправильных. Он позволяет правильно вычислить большинство выражений. Например, выражение 1+2 считывается как Терм (имеющий значение 1), за которым следует символ +, а за ним — Выражение (которое оказывается Термом, имеющим значение

2
). В итоге получаем ответ, равный 3. Аналогично, выражение 1+2+3 дает ответ 6. Можно было бы много говорить о том, что эта программа делает хорошо, но мы сразу поставим вопрос ребром: а чему равно выражение 1–2–3? Функция
expression()
считает число 1 как Терм, затем переходит к считыванию 2–3 как Выражения (состоящего их Терма 2, за которым следует Выражение 3). Таким образом, из 1 будет вычтено значение выражения 2–3. Иначе говоря, программа вычисляет выражение 1–(2–3). Оно равно 2. Однако мы еще со школьной скамьи знаем, что выражение 1–2–3 означает (1–2)–3 и, следовательно, равно –4.

Итак, мы написали превосходную программу, которая выполняет вычисления неправильно. Это опасно. Это особенно опасно, поскольку во многих случаях программа дает правильный ответ. Например, выражение 1+2+3 будет вычислено правильно (6), так как 1+(2+3) эквивалентно (1+2)+3.

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

Анализ ошибок — часто лучший способ найти правильное решение. В данном случае функция

expression()
сначала искала Терм, а затем, если за Термом следовал символ + или , искала Выражение. На самом деле функция реализовала немного другую грамматику.


Выражение:

  Терм

  Терм '+' Выражение // сложение

  Терм '–' Выражение // вычитание


Отличие от нашей грамматики заключается именно в том, что выражение 1–2–3 должно трактоваться как Выражение 1–2, за которым следует символ и Терм 3, а на самом деле функция интерпретирует выражение 1–2–3 как Терм 1, за которым следует символ и Выражение 2–3. Иначе говоря, мы хотели, чтобы выражение 1–2–3 было эквивалентно (1–2)–3 , а не 1–(2–3).

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

Обратите внимание на то, что мы могли бы определить выражение 1–2–3 как 1–(2–3), а не (1–2)–3 и вообще избежать этой дискуссии. Довольно часто самые трудные программистские проблемы возникают тогда, когда мы работаем с привычными для людей правилами, которые изобрели задолго до компьютеров.

6.5.2.3. Выражения: третья попытка (удачная)

Итак, что теперь? Еще раз взгляните на грамматику (правильная грамматика приведена в разделе 6.5.2): любое Выражение начинается с Терма, за которым может следовать символ + или . Следовательно, мы должны найти Терм, проверить, следует ли за ним символ + или , и делать это, пока символы “плюс” и “минус” не закончатся. Рассмотрим пример.


double expression()

{

 double left = term();   // считываем и вычисляем Терм

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

 while (t.kind=='+' || t.kind=='–') { // ищем + или –

   if (t.kind == '+')

    left += term();    // вычисляем Терм и добавляем его

   else

    left –= term();    // вычисляем Терм и вычитаем его

   t = get_token();

  }

 return left;        // финал: символов + и – нет; возвращаем ответ

}


Этот вариант немного сложнее: мы ввели цикл для поиска символов + и . Кроме того, дважды повторили проверку символов + и , а также дважды вызвали функцию

get_token()
. Поскольку это запутывает логику кода, просто продублируем проверку символов + и .


double expression()

{

 double left = term();  // считываем и вычисляем Терм

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

 while(true) {

    switch(t.kind) {

    case '+':

    left += term();  // вычисляем Терм и добавляем его

    t = get_token();

     break;

    case '–':

     left –= term();  // вычисляем Терм и вычитаем его

     t = get_token();

     break;

    default:

     return left;    // финал: символов + и – нет;

              // возвращаем ответ

    }

  }

}


Обратите внимание на то, что — за исключением цикла — этот вариант напоминает первый (см. раздел 6.5.2.1). Мы просто удалили вызов функции

expression()
в функции
expression()
и заменили его циклом. Другими словами, перевели Выражение в грамматическом правиле в цикл поиска Терма, за которым следует символ + или .

6.5.3. Термы

Грамматическое правило для Терма очень похоже на правило для Выражения.


Терм:

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

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

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

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


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


double term()

{

 double left = primary();

 Token t = get_token();

 while(true) {

   switch (t.kind) {

   case '*':

     left *= primary();

    t = get_token();

    break;

    case '/':

    left /= primary();

    t = get_token();

     break;

    case '%':

     left %= primary();

     t = get_token();

     break;

    default:

     return left;

    }

 }

}


К сожалению, этот код не компилируется: операция вычисления остатка (

%
) для чисел с плавающей точкой не определена. Компилятор вежливо предупредит нас об этом. Когда мы утвердительно ответили на вопрос 5 из раздела 6.3.5 — “Следует ли позволить ввод чисел с плавающей точкой?”, — мы не думали о таких последствиях и просто поддались искушению добавить в программу дополнительные возможности. Вот так всегда! Что же делать? Можно во время выполнения программы проверить, являются ли оба операнда операции
%
целыми числами, и сообщить об ошибке, если это не так. А можно просто исключить операцию
%
из возможностей нашего калькулятора. Эту функцию всегда можно добавить позднее (см. раздел 7.5). Исключив операцию
%
, получим вполне работоспособную функцию: термы правильно распознаются и вычисляются. Однако опытный программист заметит нежелательную деталь, которая делает функцию
term()
неприемлемой. Что произойдет, если ввести выражение
2/0
? На нуль делить нельзя. Если попытаться это сделать, то аппаратное обеспечение компьютера обнаружит это и прекратит выполнение программы, выдав сообщение об ошибке. Неопытный программист обязательно столкнется с этой проблемой. По этой причине лучше провести проверку и выдать подходящее сообщение.


double term()

{

 double left = primary();

 Token t = get_token();

 while(true) {

   switch (t.kind) {

   case '*':

    left *= primary();

    t = get_token();

    break;

   case '/':

   { double d = primary();

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

     left /= d;

     t = get_token();

   break;

   }

   default:

    return left;

   }

 }

}


Почему мы поместили обработку операции

/
внутри блока? На этом настоял компилятор. Если мы хотим определить и инициализировать переменные в операторе
switch
, то должны поместить ее в блоке.

6.5.4. Первичные выражения

Грамматическое правило для первичных выражений также простое.


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

 Число

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


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


double primary()

{

 Token t = get_token();

 switch (t.kind) {

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

    { double d = expression();

    t = get_token();

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

   return d;

   }

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

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

  default:

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

  }

}


По сравнению с функциями

expression()
и
term()
в этом программном коде нет ничего нового. В нем используются те же самые языковые конструкции и методы, и объекты класса
Token
обрабатываются точно так же.

6.6. Испытание первой версии

Для того чтобы выполнить эти функции калькулятора, необходимо реализовать функции

get_token()
и
main()
. Функция
main()
тривиальна: мы просто вызываем функцию
expression()
и выводим результат на печать.


int main()

try {

 while (cin)

   cout << expression() << '\n';

 keep_window_open();

}

catch (exception& e) {

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

 keep_window_open ();

 return 1;

}

catch (...) {

 cerr << "exception \n";

 keep_window_open ();

 return 2;

}


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

get_token()
до раздела 6.8 и протестируем эту первую версию калькулятора.


ПОПРОБУЙТЕ

Первая версия программы, имитирующей работу калькулятора (включая функцию

get_token()
), содержится в файле
calculator00.cpp
. Запустите его и испытайте.


Нет ничего удивительного в том, что эта первая версия калькулятора работает не совсем так, как мы ожидали. Мы пожимаем плечами и спрашиваем себя: “Почему?”, или “Почему программа работает так, а не иначе?”, или “Что же она делает?” Введите число

2
и символ перехода на новую строку. Ответа вы не получите! Введите символ перехода на новую строку еще раз, чтобы убедиться, что компьютер не завис. Ответа по-прежнему нет. Введите число
3
и символ перехода на новую строку. Ответа нет! Введите число
4
и символ перехода на новую строку. Ответ равен
2
! Теперь экран выглядит так:


2

3

4

2


Введем выражение

5+6
. Ответ равен
5
, а экран выглядит так:


2

3

4

2

5+6

5


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

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

Однако мы забыли провести различие между вводом и выводом на экран. Прежде чем перейти к решению основной задачи, давайте исправим вывод, чтобы экран лучше отражал то, что мы делаем. Добавим символ =, чтобы отметить результат.


while (cin) cout << "= " << expression() << '\n'; // версия 1


Теперь введем ту же самую последовательность символов, что и раньше. На экране появится следующее:


2 

3 

4 

= 2 

5+6 

= 5 

x 

Неправильная лексема


Странно! Попробуйте понять, почему программа делает это. Мы попробовали еще несколько примеров. Только посмотрите на эту головоломку!

• Почему программа реагирует после ввода символов

2
и
3
и ввода символа перехода на новую строку?

• Почему после ввода числа

4
программа выводит на экран число
2
, а не
4
?

• Почему при вычислении выражения

5+6
программа выводит число
5
, а не
11
?


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

4
не может быть равным
2
, а
5+6
равно
11
, а не
5
. Попробуем разобраться, что происходит, когда мы вводим символы
1 2 3 4+5 6+7 8+9 10 11 12
и символ перехода на новую строку.


1 2 3 4+5 6+7 8+9 10 11 12

= 1

= 4

= 6

= 8

= 10


Что? Ни

2
, ни
3
. Почему число
4
в выводе есть, а числа
9
нет (т.е.
4+5
)? Почему среди результатов есть число
6
и нет числа
13
(т.е.
6+7
)?

Хорошенько подумайте: программа выводит каждую третью лексему! Может быть, программа “съедает” часть входной информации без вычислений? Похоже на это. Проанализируем функцию

expression()
.


double expression()

{

 double left = term();  // считываем и вычисляем Терм

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

 while(true) {

   switch(t.kind) {

    case '+':

     left += term();   // вычисляем и добавляем Term

    t = get_token();

     break;

   case '–':

    left –= term();   // вычисляем и вычитаем Терм

   t = get_token();

    break;

   default:

     return left;    // финал: символов + и – нет;

              // возвращаем ответ

   }

  }

}


Если объект класса

Token
, возвращаемый функцией
get_token()
, не равен
'+'
или
'–'
, выполняем оператор
return
. Мы не используем этот объект и не храним его в памяти для использования в других функциях. Это не умно. Отбрасывание входной информации без анализа недальновидно. Беглый анализ показывает, что функции
term()
присущ такой же недостаток. Это объясняет, почему наш калькулятор “съедает” по две лексемы после одной использованной.

Модифицируем функцию

expression()
так, чтобы она не “съедала” лексемы. Куда поместить следующую лексему (
t
), если программа никак не использует ее? Можно рассмотреть много сложных схем, но давайте просто перейдем к очевидному ответу (его очевидность станет ясной позднее): поскольку лексема будет использована другой функцией, которая будет считывать ее из потока ввода, давайте вернем лексему обратно в поток ввода, чтобы ее могла считать другая функция! Действительно, мы можем вернуть символ обратно в поток ввода, но это не совсем то, что мы хотим. Мы хотим работать с лексемами, а не возиться с символами. Итак, хотелось бы, чтобы поток ввода работал с лексемам, а мы имели бы возможность записывать в него уже считанные лексемы.

Предположим, в нашем распоряжении есть поток лексем — “

Token_stream
” — с именем
ts
. Допустим также, что поток
Token_stream
имеет функцию-член
get()
, возвращающую следующую лексему, и функцию-член
putback(t)
, возвращающую лексему
t
обратно в поток.

Мы реализуем класс

Token_stream
в разделе 6.8, как только увидим, как его следует использовать. Имея поток
Token_stream
, можем переписать функцию
expression()
так, чтобы она записывала неиспользованную лексему обратно в поток
Token_stream
.


double expression()

{

 double left = term(); // считываем и вычисляем Терм

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

             // из потока лексем

 while(true) {

   switch(t.kind) {

   case '+':

     left += term();  // вычисляем и добавляем Терм

    t = ts.get();

    break;

   case '–':

     left –= term();  // вычисляем и вычитаем Терм

    t = ts.get();

     break;

   default:

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

              // в поток лексем

     return left;    // финал: символов + и – нет;

             // возвращаем ответ

   }

  }

}


Кроме того, такие же изменения следует внести в функцию

term()
.


double term()

{

 double left = primary();

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

            // из потока лексем

 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;

    }

   default:

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

     return left;

    }

  }

}


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

primary()
достаточно заменить функцию
get_token()
функцией
ts.get()
; функция
primary()
использует каждую лексему, которую она считывает.

6.7. Испытание второй версии

Итак, мы готовы к испытанию второй версии. Введем число

2
и символ перехода на новую строку. Нет ответа. Попробуйте ввести еще один символ перехода на новую строку, чтобы убедиться, что компьютер не завис. По-прежнему нет ответа. Введите число
3
и символ перехода на новую строку. Ответ равен
2
. Попробуйте ввести выражение
2+2
и символ перехода на новую строку. Ответ равен 3. Экран выглядит следующим образом:


2

3

=2

2+2

=3


Хм... Может быть, наша функция

putback()
и ее использование в функции
expression()
и
term()
не решает проблему. Попробуем другой тест.


2 3 4 2+3 2*3

= 2

= 3

= 4

= 5


Да! Это правильные ответы! Но последний ответ (

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

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

q
(первая буква слова
quit
(выход)). Функция
main()
содержит инструкцию


while (cin) cout << "=" << expression() << '\n'; // version 1


Заменим ее более запутанной, но более полезной инструкцией.


double val = 0;

while (cin) {

  Token t = ts.get();

  if (t.kind == 'q') break; // 'q' для выхода

  if (t.kind == ';')     // ';' для команды "печатать немедленно"

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

 else

   ts.putback(t);

  val = expression();

}


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


2;

= 2

2+3;

= 5

3+4*5;

= 23

q


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

6.8. Потоки лексем

Прежде чем далее улучшать наш калькулятор, продемонстрируем реализацию класса

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

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

(1.5+4)*11
(см. раздел 6.3.3). Нам лишь нужна функция, считывающая символы из стандартного потока
cin
и вводящая в программу следующую лексему по запросу. Кроме того, мы видели, что наша программа часто считывает слишком много лексем, поэтому необходимо как-то возвращать их обратно, чтобы использовать в дальнейшем. Эта ситуация очень типична. Допустим, мы считываем выражение
1.5+4
слева направо. Как убедиться, что число
1.5
считано полностью, а символ
+
— нет. Пока мы не увидим символ
+
, можем считывать число
1.55555
. Таким образом, нам нужен поток, порождающий лексему при вызове функции
get()
, и возможность возвращать лексему обратно в поток при вызове функции
putback()
. Все сущности в языке С++ имеют тип, поэтому необходимо определить тип
Token_stream
.

Возможно, вы заметили ключевое слово

public
в определении класса
Token
, приведенном в разделе 6.3.3. В том случае для его использования не было очевидных причин. Однако при определении класса
Token_stream
мы должны применить его и объяснить его предназначение. В языке С++ тип, определенный пользователем, часто состоит из двух частей: открытого интерфейса (помеченного как
public:
) и реализации деталей типа (помеченной как
private:
). Идея заключается в том, чтобы отделить то, что пользователю необходимо для удобства, от деталей реализации типа, в которые пользователю вникать не обязательно.


class Token_stream {

public:

 // пользовательский интерфейс

private:

 // детали реализации

 // (скрывается от пользователей класса Token_stream)

};


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

Приступим к разработке типа

Token_stream
. Что пользователь ждет от него? Очевидно, что нам нужны функции
get()
и
putback()
— именно поэтому мы ввели понятие потока лексем. Класс
Token_stream
должен создавать объекты класса
Token
из символов, считанных из потока ввода, поэтому нам необходима возможность создавать объекты класса
Token_stream
, способные считывать данные из потока
cin
. Таким образом, простейший вариант класса
Token_stream
выглядит примерно так:


class Token_stream {

public:

  Token_stream();      // создает объект класса Token_stream,

              // считывающий данные из потока cin

  Token get();      // получает объект класса Token

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

              // обратно

private:

              // детали реализации

};


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

Token_stream
. Опытные программисты могут поинтересоваться, почему поток
cin
является единственным возможным источником символов, — просто мы решили вводить символы с клавиатуры. Это решение можно пересмотреть в упражнении, приведенном в главе 7.

Почему мы использовали “длинное” имя

putback()
, а не логичное имя
put()
? Тем самым мы подчеркнули асимметрию между функциями
get()
и
putback()
: мы возвращаем лексему в поток ввода, а не вставляем ее в поток вывода. Кроме того, функция
putback()
есть в классе
istream
: непротиворечивость имен — полезное свойство. Это позволяет людям запоминать имена функций и избегать ошибок.

Теперь можем создать класс Token_stream и использовать его.


Token_stream ts;   // объект класса Token_stream с именем ts

Token t = ts.get(); // получаем следующий объект класса Token из
 объекта ts

// ...

ts.putback(t); // возвращает объект t класса Token обратно в объект ts


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

6.8.1. Реализация класса Token_stream

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

Token_stream
. Как представить класс
Token_stream
? Иначе говоря, какие данные необходимо хранить в объекте класса
Token_stream
, чтобы он мог выполнить свое задание? Необходима память для лексемы, которая будет возвращена обратно в объект класса
Token_stream
. Для простоты будем считать, что лексемы возвращаются в поток по одной. Этого вполне достаточно для нашей программы (а также для очень многих аналогичных программ). Таким образом, нужна память для одного объекта класса
Token
и индикатор ее занятости.


class Token_stream {

public:

 Token_stream(); // создает объект класса Token_stream,

          // считывающий данные из потока cin

  Token get();   // получает объект класса Token

          // (функция get() определена в разделе 6.8.2)

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

              // обратно

private:

  bool full;   // находится ли в буфере объект класса Token?

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

         // возвращаемый в поток функцией putback()

};


Теперь можно определить (написать) три функции-члена. Конструктор и функция

putback()
никаких трудностей не вызывают, поскольку они невелики. Мы определим их в первую очередь. Конструктор просто устанавливает настройки, свидетельствующие о том, что буфер пуст.


Token_stream::Token_stream()

  :full(false), buffer(0) // в буфере нет ни одного объекта

              // класса Token

{

}


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

имя_класса::имя_функции_члена
. В данном случае нам необходимо определить конструктор класса
Token_stream
. Конструктор — это член класса, имя которого совпадает с именем класса.

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

Члены класса инициализированы в списке инициализации (см. раздел 6.3.3); выражение

full(false)
устанавливает член класса
Token_stream
с именем
full
равным значению
false
, а выражение
buffer(0)
инициализирует член
buffer
пустой лексемой, которую мы специально для этого изобрели. Определение класса
Token
(см. раздел 6.3.3) утверждает, что каждый объект класса
Token
должен иметь начальное значение, поэтому мы не можем просто проигнорировать член
Token_stream::buffer
.

Функция-член

putback()
возвращает аргументы обратно в буфер объекта класса
Token_stream
.


void Token_stream::putback(Token t)

{

 buffer = t;  // копируем объект t в буфер

  full = true; // теперь буфер полон

}


Ключевое слово

void
(означающее “ничто”) означает, что функция
putback()
не возвращает никакого значения. Если бы мы хотели гарантировать, что эта функция не будет использована дважды без считывания лексем, возвращенных в промежутке между ее вызовами (с помощью функции
get()
), то нам следовало бы добавить проверку.


void Token_stream::putback(Token t)

{

 if (full) error("putback() в полный буфер");

  buffer = t;  // копируем объект t в буфер

  full = true; // буфер теперь полон

}


Проверка переменной

full
соответствует проверке предусловия “В буфере нет ни одного объекта класса
Token
”.

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

Всю реальную работу выполняет функция

get()
. Если в переменной
Token_stream::buffer
еще нет ни одного объекта класса
Token
, то функция
get()
должна считать символы из потока
cin
и составить из них объект класса
Token
.


Token Token_stream::get()

{

  if (full) { // если в буфере есть лексема,

        // удаляем ее оттуда

   full=false;

   return buffer;

  }

  char ch;

  cin >> ch;  // обратите внимание на то, что оператор >>

        // пропускает разделители (пробелы, символы перехода

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

 switch (ch) {

  case ';': // для печати

 case 'q': // для выхода

 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('8',val); // пусть символ '8' означает "число"

   }

 default:

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

 }

}


Детально рассмотрим функцию

get()
. Сначала проверим, есть ли в буфере объект класса
Token
. Если есть, то мы просто вернем его.


if (full) { // если в буфере есть лексема,

       // удаляем ее оттуда

  full=false;

  return buffer;

}


Только если переменная

full
равна
false
(т.е. в буфере нет лексем), нам придется иметь дело с символами. В данном случае считываем символ и соответствующим образом обрабатываем его. Мы распознаем скобки, операторы и числа. Любой другой символ становится причиной вызова функции
error()
, которая прекращает выполнение программы.


default:

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


Функция

error()
описана в разделе 5.6.3 и находится в заголовочном файле
std_lib_facilities.h
.

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

Это позволяет чрезвычайно просто обрабатывать скобки и операторы.


case '(': case ')': case '+': case '–': case '*': case '/': 

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


Честно говоря, мы “забыли” точку с запятой,

';'
, для вывода и букву
q
в первой версии. Мы не будем добавлять их, пока в них не возникнет потребность во второй версии.

6.8.3. Считывание чисел

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

123
? Хорошо, оно равно
100+20+3
. А что вы скажете о числе
12.34
? Следует ли принять научную систему обозначения, такую как
12.34е5
? Мы могли бы провести часы и дни, решая эту задачу, но, к счастью, это не обязательно. Потоки ввода в языке С++ распознают литералы и сами умеют переводить их в тип
double
. Все, что нам нужно, — как-то заставить поток
cin
сделать это в функции
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('8',val); // пусть символ '8' обозначает "число"

  }


Мы в некотором смысле произвольно решили, что символ

'8'
будет представлять число в классе
Token
. Как узнать, что на вход поступило число? Хорошо, зная по опыту или изучая справочник по языку С++ (например, в приложении А), можно установить, что числовой литерал должен начинаться с цифры или символа
'.'
(десятичной точки). Итак, этот факт следует проверить. Далее, мы хотим, чтобы поток
cin
считывал число, но мы уже считали первый символ (цифру или десятичную точку), поэтому пропуск оставшейся части лексемы приведет к ошибке. Можно попытаться скомбинировать значение первого символа со значением оставшейся части; например, если некто ввел число
123
, можем взять число
1
, а поток
cin
считает число
23
, и нам останется лишь сложить
100
и
23
. Это тривиальный случай.

К счастью (и не случайно), поток

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

Пожалуйста, обратите внимание на то, как мы снова и снова избегаем сложностей и вместо этого находим простые решения, часто полагаясь на библиотеки. В этом заключается смысл программирования: постоянно искать простые решения. Иногда в шутку говорят: “Хороший программист — ленивый программист”. Это означает, что мы должны быть ленивыми (в хорошем смысле): зачем писать длинную программу, если можно написать короткую?

6.9. Структура программы

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


#include "std_lib_facilities.h"


class Token {/* ... */};

class Token_stream {/* ... */};

Token_stream::Token_stream():full(false), buffer(0) {/* ... */
}

void Token_stream::putback(Token t) {/* ... */}

Token Token_stream::get() {/* ... */}

Token_stream ts;   // содержит функции get() и putback()

double expression(); // объявление, позволяющее функции primary()

           // вызывать функцию expression()

double primary() {/* ... */}   // обрабатывает числа и скобки

double term() {/* ... */}    // обрабатывает операции * и /

double expression() {/* ... */} // обрабатывает операции + и –


int main() {/* ... */} // основной цикл и обработка ошибок


Порядок следования объявлений важен. Мы не можем использовать имя, пока оно не объявлено, поэтому объект ts должен быть объявлен до того, как будет вызвана функция

ts.get()
, а функция
error()
должна быть объявлена до функций грамматического анализа, поскольку они используют ее. В графе вызовов существует интересный цикл: функция
expression()
вызывает функцию
term()
, которая вызывает функцию
primary()
, которая вызывает функцию
expression()
.

Эту ситуацию можно проиллюстрировать графически (удалив вызовы функции

error()
).

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

expression()
.



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


ПОПРОБУЙТЕ

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


Задание

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

1. Откройте файл

calculator02buggy.cpp
. Скомпилируйте его. Найдите и исправьте несколько ошибок. Этих ошибок в тексте книги нет.

2. Измените символ, кодирующий команду выхода, с

q
на
x
.

3. Измените символ, кодирующий команду печати, с

;
на
=
.

4. Добавьте в функцию

main()
приветствие.

Добро пожаловать в программу–калькулятор!

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

5. Усовершенствуйте это приветствие, указав, какие операторы можно выполнить и как вывести данные на экран и выйти из программы.

6. Найдите три логические ошибки, преднамеренно внесенные в файл

calculator02buggy.cpp
, и удалите их из программы.


Резюме

1. Что означает выражение “Программирование — это понимание”?

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

3. Как разбить задачу на небольшие части?

4. Почему следует начинать с небольшой версии программы?

5. Почему нагромождение возможностей может привести в тупик?

6. Перечислите три основных этапа разработки программного обеспечения.

7. Что такое прецедент использования?

8. Для чего предназначено тестирование?

9. Следуя схеме, лежащей в основе этой главы, опишите разницу между Термом, Выражением, Числом и Первичным выражением.

10. В главе входная информация разделена на компоненты: Термы, Выражения, Первичные выражения и Числа. Сделайте это для арифметического выражения (17+4)/(5–1).

11. Почему в программе нет функции

number()
?

12. Что такое лексема?

13. Что такое грамматика? Что такое грамматическое правило?

14. Что такое класс? Для чего мы используем классы?

15. Что такое конструктор?

16. Почему в функции

expression()
в операторе
switch
по умолчанию предусмотрен возврат лексемы обратно в поток?

17. Что значит “смотреть вперед”?

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

putback()
и чем она полезна?

19. Почему операцию вычисления остатка (деление по модулю)

%
трудно реализовать с помощью функции
term()
?

20. Для чего используются два члена класс

Token
?

21. Зачем члены класса разделяются на закрытые и открытые?

22. Что произойдет в классе

Token_stream
, если в буфере есть лексема и вызвана функция
get()
?

23. Зачем в оператор

switch
в функцию
get()
в классе
Token_stream
добавлены символы
';'
и
'q'
?

24. Когда следует начинать тестирование программы?

25. Что такое тип, определенный пользователем? Зачем он нужен?

26. Что такое интерфейс типа, определенного пользователем?

27. Почему следует полагаться на библиотечные коды?


Термины


Упражнения

1. Выполните упражнения из раздела ПОПРОБУЙТЕ, если вы не сделали этого раньше.

2. Добавьте в программу возможность обработки скобок

{}
и
()
, чтобы выражение
{(4+5)*6}/(3+4)
стало корректным.

3. Добавьте оператор вычисления факториала: для его представления используйте знак восклицания,

!
. Например, выражение
7!
означает
7*6*5*4*3*2*1
. Присвойте оператору
!
более высокий приоритет по сравнению с операторами
*
и
/
, т.е.
7*8!
должно означать
7*(8!)
, а не
(7*8)!
. Начните с модификации грамматики, чтобы учесть оператор с более высоким приоритетом. Для того чтобы учесть стандартное математическое определение факториала, установите выражение
0!
равным
1
.

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

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

5. Добавьте пункт в английскую грамматику из раздела 6.4.1, чтобы можно было описать предложения вида “The birds fly but the fish swim”.

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

birds fly but the fish swim
. является предложением, а фразы
but birds fly but the fish swim
(пропущена точка) и
birds fly but the fish swim
. (перед точкой нет пробела) — нет. Для каждого введенного предложения программа должна просто отвечать “Да” или “Нет”. Подсказка: не возитесь с лексемами, просто считайте строку с помощью оператора
>>
.

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

!
(отрицание),
~
(дополнение),
&
(и),
|
(или) и
^
(исключающее или). Операторы
!
и
~
являются префиксными унарными операторами. Оператор
^
имеет более высокий приоритет, чем оператор
|
(так же, как оператор
*
имеет более высокий приоритет, чем оператор
+
), так что выражение
x|y^z
означает
x|(y^z
), а не
(x|y)^z
. Оператор
&
имеет более высокий приоритет, чем оператор
^
, так что выражение
x^y&z
означает
x^y&z)
.

8. Повторите упр. 12 из главы 5 (игра “Коровы и быки”), используя четыре буквы, а не четыре цифры.

9. Напишите программу, считывающую цифры и составляющую из них целые числа. Например, число

123
считывается как последовательность символов
1
,
2
и
3
. Программа должна вывести на экран сообщение: “
123 — это 1 сотня, 2 десятки и 3 единицы
”. Число должно быть выведено как значение типа
int
. Обработайте числа, состоящие из одной цифры, двух, трех и четырех цифр. Подсказка: для того чтобы получить число
5
из символа
'5'
, вычтите из него символ
'0'
, иначе говоря,
'5'–'0'==5
.

10. Перестановка — это упорядоченное подмножество множества. Например, допустим, что вы хотите подобрать код к сейфу. Существует шестьдесят возможных чисел, а вам необходимо выбрать три числа для комбинации. Для этой комбинации чисел существует

P(60,3)
перестановок, где количество перестановок определяется по формуле:



где символ

!
означает факториал. Например,
4!
— это
4*3*2*1
. Сочетания напоминают перестановки за исключением того, что в них порядок следования не имеет значения. Например, если вы делаете банановое мороженое и хотите использовать три разных вкуса из пяти, имеющихся в наличии, вам все равно, когда вы используете ваниль — в начале или в конце, вы просто хотите использовать ваниль. Формула для вычисления количества сочетаний имеет следующий вид:



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


Послесловие

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

Глава 7. Завершение программы

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

Поговорка


Создание программы предполагает последовательное уточнение того, что вы хотите сделать и как вы желаете это выразить. В главе 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. Технические детали: функции и прочее

“Ни один талант не может преодолеть

пристрастия к деталям”.

Восьмой закон Леви


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

8.1. Технические детали

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

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

• Мы изучаем программирование.

  • Результатом нашей работы являются программы и системы.

  • Язык программирования — это лишь средство.


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

Большинство понятий, связанных с проектированием и программированием, являются универсальными, и многие из них поддерживаются популярными языками программирования. Это значит, что фундаментальные идеи и методы, изучаемые нами в рамках достаточно продуманного курса программирования, переходят из одного языка в другой. Они могут быть реализованы — с разной степенью легкости — во всех языках программирования. Однако технические детали языка весьма специфичны. К счастью, языки программирования разрабатываются не в вакууме, поэтому у понятий, которые мы изучаем в нашем курсе, очевидно, есть аналоги в других языках программирования. В частности, язык С++ принадлежит к группе языков, к которым помимо него относятся языки С (глава 27), Java и C#, поэтому между ними есть много общего.

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

f
,
g
,
X
и
y
. Мы делаем это, чтобы подчеркнуть техническую природу таких примеров, сделать их очень короткими и не смешивать языковые детали с логикой программы. Когда вы увидите неопределенные имена (которые ни в коем случае нельзя использовать в реальном коде), пожалуйста, сосредоточьтесь на технических аспектах кода. Технические примеры обычно содержат код, который просто иллюстрирует правила языка. Если вы скомпилируете и запустите его, то получите множество предупреждений о неиспользуемых переменных, причем некоторые из таких программ вообще не делают никаких осмысленных действий.

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

8.2. Объявления и определения

Объявление (declaration) — это инструкция, которая вводит имя в область видимости (раздел 8.4), устанавливает тип именованной сущности (например, переменной или функции) и, необязательно, устанавливает инициализацию (например, начальное значение или тело функции).

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


int a = 7;        // переменная типа int

const double cd = 8.7;  // константа с плавающей точкой

            // двойной точности

double sqrt(double);   // функция, принимающая аргумент типа double

             // и возвращающая результат типа double

vector v;    // переменная — вектор объектов класса Token


До того как имя в программе на языке С++ будет использовано, оно должно быть объявлено. Рассмотрим пример.


int main()

{

  cout << f(i) << '\n';

}


Компилятор выдаст как минимум три сообщения об ошибках, связанных с необъявленными идентификаторами: сущности

cout
,
f
и
i
в программе нигде не объявлены. Исправить ошибку, связанную с потоком
cout
, можно, включив в программу заголовочный файл
std_lib_facilities.h
, содержащий его объявление.


#include "std_lib_facilities.h" // здесь содержится объявление

                 // потока cout

int main()

{

  cout << f(i) << '\n';

}


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

cout
и его операторов
<<
; мы просто включаем их объявления в программу с помощью директивы
#include
. Мы можем даже не заглядывать в их объявления; из учебников, справочников, примеров программ и других источников нам известно, как используется поток
cout
. Компилятор считывает объявления из заголовочных файлов, необходимых для понимания кода.

Однако нам по-прежнему необходимо объявить переменные

f
и
i
. И сделать это можно следующим образом:


#include "std_lib_facilities.h" // здесь содержится объявление

                 // потока cout

int f(int); // объявление переменной f


int main()

{

 int i = 7; // объявление переменной i

 cout << f(i) << '\n';

}


Этот код компилируется без ошибок, поскольку каждое имя было определено, но он не проходит редактирование связей (см. раздел 2.4), поскольку в нем не определена функция

f()
; иначе говоря, мы нигде не указали, что именно делает функция
f()
.

Объявление, которое полностью описывает объявленную сущность, называют определением (definition). Рассмотрим пример.


int a = 7;

vector v;

double sqrt(double d) {/* ... */}


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


double sqrt(double); // здесь функция не имеет тела

extern int a;    // "extern плюс отсутствие инициализатора"

            // означает, что это — не определение


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

Определение устанавливает, на что именно ссылается имя. В частности, определение переменной выделяет память для этой переменной. Следовательно, ни одну сущность невозможно определить дважды. Рассмотрим пример.


double sqrt(double d) {/* ... */} // определение

double sqrt(double d) {/* ... */} // ошибка: повторное определение

int a; // определение

int a; // ошибка: повторное определение


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


int x = 7;             // определение

extern int x;            // объявление

extern int x;           // другое объявление

double sqrt(double);        // объявление

double sqrt(double d) {/* ... */} // определение

double sqrt(double);        // другое объявление функции sqrt

double sqrt(double);        // еще одно объявление функции sqrt

int sqrt(double);         // ошибка: несогласованное определение


Почему последнее объявление является ошибкой? Потому что в одной и той же программе не может быть двух функций с именем

sqrt
, принимающих аргумент типа
double
и возвращающих значения разных типов (
int
и
double
).

Ключевое слово

extern
, использованное во втором объявлении переменной
x
, утверждает, что это объявление не является определением. Это редко бывает нужным. Мы не рекомендуем делать это, но в принципе такие объявления можно встретить в некоторых программах, особенно в программах, использующих слишком много глобальных переменных (см. разделы 8.4 и 8.6.2).



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

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

expression()
вызывает функцию
term()
, которая, в свою очередь, вызывает функцию
primary()
, которая вызывает функцию
expression()
. Поскольку любое имя в программе на языке С++ должно быть объявлено до того, как будет использовано, мы вынуждены объявить эти три функции.


double expression(); // это лишь объявление, но не определение

double primary()

{

  // ...

  expression();

  // ...

 }

double term()

{

  // ...

  primary();

  // ...

}

double expression()

{

  // ...

  term();

  // ...

}


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

expression()
до определения функции
primary()
, и все было в порядке. Такие циклические вызовы весьма типичны.

Почему имя должно быть определено до его использования? Не могли бы мы просто потребовать, чтобы компилятор читал программу (как это делаем мы), находил определение и выяснял, какую функцию следует вызвать? Можно, но это приведет к “интересным” техническим проблемам, поэтому мы решили этого не делать. Спецификация языка С++ требует, чтобы определение предшествовало использованию имени (за исключением членов класса; см. раздел 9.4.4).

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

8.2.1. Виды объявлений

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

• Переменные.

• Константы.

• Функции (см. раздел 8.5).

• Пространства имен (см. раздел 8.7).

• Типы (классы и перечисления; см. главу 9).

• Шаблоны (см. главу 19).

8.2.2. Объявления переменных и констант

Объявление переменной или константы задает ее имя, тип и (необязательно) начальное значение. Рассмотрим пример.


int a;        // без инициализации

double d = 7;    // инициализация с помощью синтаксической конструкции =

vector vi(10); // инициализация с помощью синтаксической

           // конструкции ()


Полная грамматика языка описана в книге Язык программирования С++ Страуструпа и в стандарте ISO C++.

Константы объявляются так же, как переменные, за исключением ключевого слова

const
и требования инициализации.


const int x = 7; // инициализация с помощью синтаксической

          // конструкции =

const int x2(9); // инициализация с помощью синтаксической

         // конструкции ()

const int y;   // ошибка: нет инициализации


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


void f(int z)

{

 int x; // неинициализированная переменная

     // ...здесь нет присваивания значений переменной x...

 x = 7; // присваивание значения переменной x

      // ...

}


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

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


void f(int z)

{

 int x; // неинициализированная переменная

     // ...здесь нет присваивания значений переменной x...

 if (z>x) {

 // ...

}

 // ...

 x = 7; // присваивание значения переменной x

 // ...

}


Поскольку переменная

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

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

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

8.2.3. Инициализация по умолчанию

Возможно, вы заметили, что мы часто не инициализируем объекты классов

string
,
vector
и т.д. Рассмотрим пример.


vector v;

string s;

while (cin>>s) v.push_back(s);


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

v
пуст (т.е. не содержит элементов), и строка
s
перед входом в цикл также пуста (
""
). Механизм, гарантирующий инициализацию по умолчанию, называется конструктором по умолчанию (default constructor).

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

Не говорите, что вас не предупреждали!

8.3. Заголовочные файлы

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

cout
и функции
sqrt()
были написаны много лет назад кем-то другим. Мы просто используем их. Главным средством управления сущностями, определенными где-то в другом месте, в языке С++ являются заголовки. В принципе заголовок (header) — это коллекция объявлений, записанных в файле, поэтому заголовок часто называют заголовочным файлом (header file). Такие заголовки подставляются в исходные файлы с помощью директивы
#include
. Например, вы можете решить улучшить организацию исходного кода нашего калькулятора (см. главы 6 и 7), выделив объявления лексем в отдельный файл. Таким образом, можно определить заголовочный файл
token.h
, содержащий объявления, необходимые для использования классов
Token
и
Token_stream
.



Объявления классов

Token
и
Token_stream
находятся в заголовке
token.h
. Их определения находятся в файле
token.cpp
. В языке C++ расширение
.h
относится к заголовочным файлам, а расширение
.cpp
чаще всего используется для исходных файлов. На самом деле в языке С++ расширение файла не имеет значения, но некоторые компиляторы и большинство интегрированных сред разработки программ настаивают на использовании определенных соглашений относительно расширений файлов.

В принципе директива

#include "file.h"
просто копирует объявления из файла
file.h
в ваш файл в точку, отмеченную директивой
#include
. Например, мы можем написать заголовочный файл
f.h
.


// f.h

int f(int);


А затем можем включить его в файл

user.cpp
.


// user.cpp

#include "f.h"

int g(int i)

{

  return f(i);

}


При компиляции файла

user.cpp
компилятор выполнит подстановку заголовочного файла и скомпилирует следующий текст:


int f(int);

int g(int i)

{

  return f(i);

}


Поскольку директива

#include
выполняется компилятором в самом начале, выполняющая ее часть компилятора называется препроцессором (preprocessing) (раздел A.17).

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

Token_stream::putback()
сделал ошибки.


Token Token_stream::putback(Token t)

{

 buffer.push_back(t);

  return t;

}

Этот фрагмент выглядит вполне невинно. К счастью, компилятор перехватывает ошибки, потому что он видит (благодаря директиве

#include
) объявление функции
Token_stream::putback()
. Сравнивая это объявление с соответствующим определением, компилятор выясняет, что функция
putback()
не должна возвращать объект класса
Token
, а переменная
buffer
имеет тип
Token
, а не
vector
, так что мы не можем использовать функцию
push_back()
. Такие ошибки возникают, когда мы работаем над улучшением кода и вносим изменения, забывая о необходимости согласовывать их с остальной частью программы.

Рассмотрим следующие ошибки:


Token t = ts.gett(); // ошибка: нет члена gett

           // ...

ts.putback();     // ошибка: отсутствует аргумент


Компилятор немедленно выдаст ошибку; заголовок

token.h
предоставляет ему всю информацию, необходимую для проверки.

Заголовочный файл

std_lib_facilities.h
содержит объявления стандартных библиотечных средств, таких как
cout
,
vector
и
sqrt()
, а также множества простых вспомогательных функций, таких как
error()
, не являющихся частью стандартной библиотеки. В разделе 12.8 мы продемонстрируем непосредственное использование заголовочных файлов стандартной библиотеки.

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

8.4. Область видимости

Область видимости (scope) — это часть текста программы. Каждое имя объявляется в своей области видимости и является действительным (т.е. находится в области видимости), начиная с точки объявления и заканчивая концом данной области. Рассмотрим пример.


void f()

{

 g();    // ошибка: g() не принадлежит (пока) области видимости

}


void g()

{

 f();    // OK: функция f() находится в области видимости

}


void h()

{

 int x = y; // ошибка: переменная y не принадлежит (пока)

       // области видимости

 int y = x; // OK: переменная x находится в области видимости

 g();    // OK: функция g() находится в области видимости

}


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

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

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

Глобальная область видимости (global scope). Часть текста, не входящая ни в одну другую область видимости.

Пространство имен (namespace scope). Именованная область видимости, вложенная в глобальную область видимости или другое пространство имен (раздел 8.7).

Область видимости класса (class scope). Часть текста, находящаяся в классе (раздел 9.2).

• Локальная область видимости (local scope). Часть текста, заключенная в фигурные скобки, { ... }, в блоке или функции.

• Область видимости инструкции (например, в цикле

for
).


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


void f(int x)  // функция f является глобальной;

        // переменная x является локальной в функции f

{

  int z = x+7; // переменная z является локальной

}


int g(int x)  // переменная g является глобальной;

        // переменная x является локальной в функции g

{

  int f = x+2; // переменная f является локальной

 return 2*f;

}


Изобразим это графически.



Здесь переменная

x
, объявленная в функции
f()
, отличается от переменной
x
, объявленной в функции
g()
. Они не создают недоразумений, потому что принадлежат разным областям видимости: переменная
x
, объявленная в функции
f()
, не видна извне функции
f()
, а переменная
x
, объявленная в функции
g()
, не видна извне функции
g()
. Два противоречащих друг другу объявления в одной и той же области видимости создают коллизию (clash). Аналогично, переменная
f
объявлена и используется в функции
g()
и (очевидно) не является функцией
f()
.

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


int max(int a, int b) // функция max является глобальной;

            // а переменные a и b — локальными

{

  return (a>=b) ? a : b;

}


int abs(int a)     // переменная a, не имеющая отношения

            // к функции max()

{

  return (a<0) ? –a : a;

}


Функции

max()
и
abs()
принадлежат стандартной библиотеке, поэтому их не нужно писать самому. Конструкция
?:
называется арифметической инструкцией if (arithmetic if), или условным выражением (conditional expression). Значение инструкции (
a>=b)?a:b
равно
a
, если
a>=b
, и
b
— в противном случае. Условное выражение позволяет не писать длинный код наподобие следующего:


int max(int a, int b) // функция max является глобальной;

            // а переменные a и b — локальными

{

  int m; // переменная m является локальной

  if (a>=b)

   m = a;

 else

  m = b;

  return m;

}


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

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


// здесь переменные r, i и v не видны

class My_vector {

 vector v;      // переменная v принадлежит области

              // видимости класса

public:

 int largest()

 {

  int r = 0;        // переменная r является локальной

              // (минимальное неотрицательное целое число)

  for (int i = 0; i

   r = max(r,abs(v[i])); // переменная i принадлежит

              // области видимости цикла

              // здесь переменная i не видна

  return r;

 }

              // здесь переменная r не видна

}


// здесь переменная v не видна

int x;      // глобальная переменная — избегайте по возможности

int y;

int f()

{

  int x;     // локальная переменная, маскирующая глобальную

         // переменную x

 x = 7;     // локальная переменная x

  {

   int x = y;  // локальная переменная x инициализируется

         // глобальной переменной y, маскируя локальную

         // переменную x, объявленную выше

  ++x;      // переменная x из предыдущей строки

  }

  ++x;      // переменная x из первой строки функции f()

  return x;

}


Если можете, избегайте ненужных вложений и сокрытий. Помните девиз: “Будь проще!”

Чем больше область видимости имени, тем длиннее и информативнее должно быть ее имя: хуже имен

x
,
y
и
z
для глобальных переменных не придумаешь. Основная причина, по которой следует избегать глобальных переменных, заключается в том, что трудно понять, какие функции изменяют их значения. В больших программах практически невозможно понять, какие функции изменяют глобальную переменную. Представьте себе: вы пытаетесь отладить программу, и выясняется, что глобальная переменная принимает неожиданное значение. Какая инструкция присвоила ей это значение? Почему? В какой функции? Как это узнать?

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

ts
и таблицу символов
names
.

Обратите внимание на то, что большинство конструкций в языке С++ создают вложенные области видимости.

• Функции в классах: функции-члены (раздел 9.4.2).


class C {

public:

 void f();

 void g()   // функция-член может быть определена в классе

 {

  // ...

 }

  // ...

  void C::f() // определение функции-члена за пределами класса

 {

  // ...

 }


Это наиболее типичный и полезный вариант.

• Классы в других классах: члены-классы (или вложенные классы).


class C {

public:

 struct M {

   // ...

  };

  // ...

};


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

• Классы в функциях: локальные классы.


void f()

{

 class L {

   // ...

 };

 // ...

}


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

• Функции в других функциях: локальные функции (или вложенные функции).


void f()

{

  void g() // незаконно

 {

   // ...

 }

 // ...

}


В языке С++ это не допускается; не поступайте так. Компилятор выдаст ошибку.

• Блоки в функциях и других блоках: вложенные блоки.


void f(int x, int y)

{

  if (x>y) {

   // ...

 }

 else {

   // ...

 {

   // ...

  }

   // ...

  }

}


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

В языке C++ существует еще одно средство —

namespace
, которое используется исключительно для разграничения областей видимости (раздел 8.7).

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


// опасно уродливый код

struct X {

void f(int x) {

struct Y {

int f() { return 1; } int m; };

int m;

m=x; Y m2;

return f(m2.f()); }

int m; void g(int m) {

if (m) f(m+2); else {

g(m+2); }}

X() { } void m3() {

}


void main() {

X a; a.f(2);}

};


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

8.5. Вызов функции и возврат значения

Функции позволяют нам выражать действия и вычисления. Если мы хотим сделать что-то, заслуживающее названия, то пишем функцию. В языке С++ есть операторы (такие как

+
и
*
), с помощью которых можно вычислить новые значения по операндам, входящим в выражение, и инструкции (такие как
for
и
if
), позволяющие управлять порядком вычислений. Для того чтобы организовать код из этих примитивов, у нас есть функции.

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

8.5.1. Объявление аргументов и тип возвращаемого значения

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


double fct(int a, double d); // объявление функции fct (без тела)

double fct(int a, double d) { return a*d; } // объявление функции fct


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


int current_power(); // функция current_power не имеет аргументов


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

void
. Например:


void increase_power(int level); // функция increase_power

                 // ничего не возвращает


Здесь ключевое слово

void
означает “ничего не возвращает”. Параметры можно как именовать, так и не именовать. Главное, чтобы объявления и определения были согласованы друг с другом. Рассмотрим пример.


// поиск строки s в векторе vs;

// vs[hint] может быть подходящим местом для начала поиска

// возвращает индекс найденного совпадения; –1 означает "не найдена"

int my_find(vector vs, string s, int hint); // именованные

                           // аргументы

int my_find(vector, string, int); // неименованные аргументы


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

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

Как правило, все аргументы в объявлении имеют имена. Рассмотрим пример.


int my_find(vector vs, string s, int hint)

// поиск строки s в векторе vs, начиная с позиции hint

{

  if (hint<0 || vs.size()<=hint) hint = 0;

  for (int i = hint; i

                    // с позиции hint

   if (vs[i]==s) return i;

 if (0

   for (int i = 0; i

    if (vs[i]==s) return i;

 }

 return –1;

}


Переменная

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


int my_find(vector vs, string s, int) // 3-й аргумент

                       // не используется

{

 for (int i = 0; i

  if (vs[i]==s) return i;

 return –1;

}


Полная грамматика объявлений функций изложена в книге Язык программирования С++ Страуструпа и в стандарте ISO C++.

8.5.2. Возврат значения

Функция возвращает вычисленное значение с помощью инструкции

return
.


T f() // функция f() возвращает объект класса T

{

  V v;

 // ...

 return v;

}

T x = f();


Здесь возвращаемое значение — это именно то значение, которые мы получили бы при инициализации переменной типа

T
значением типа
V
.


V v;

// ...

T t(v); // инициализируем переменную t значением v


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


double my_abs(int x) // предупреждение: этот код содержит ошибки

{

  if (x < 0)

   return –x;

 else if (x > 0)

   return x;

} // ошибка: если х равно нулю, функция ничего не возвращает


На самом деле компилятор может не заметить, что вы “забыли” про вариант

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

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

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

В функции, не возвращающей никаких значений, инструкцию

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


void print_until_s(vector v, string quit)

{

  for(int i=0; i

   if (v[i]==quit) return;

   cout << v[i] << '\n';

 }

}


Как видим, достичь последней точки функции, перед именем которой стоит ключевое слово

void
, вполне возможно. Это эквивалентно инструкции
return;
.

8.5.3. Передача параметров по значению

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

f()
является локальной переменной, которая инициализируется при каждом ее вызове. Рассмотрим пример.


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

// значения)

int f(int x)

{

  x = x+1; // присваиваем локальной переменной x новое значение

  return x;

}


int main()

{

 int xx = 0;

 cout << f(xx) << endl; // вывод: 1

 cout << xx << endl;   // вывод: 0; функция f() не изменяет xx

 int yy = 7;

 cout << f(yy) << endl; // вывод: 8

 cout << yy << endl;   // вывод: 7; функция f() не изменяет yy

}


Поскольку в функцию передается копия, инструкция

x=x+1
в функции
f()
не изменяет значения переменных
xx
и
yy
, передаваемых ей при двух вызовах. Передачу аргумента по значению можно проиллюстрировать следующим образом.



Передача по значению представляет собой довольно простой механизм, а ее стоимость определяется стоимостью копирования значения.

8.5.4. Передача параметров по константной ссылке

Передача по значению проста, понятна и эффективна, если передаются небольшие значения, например переменные типа

int
,
double
или
Token
(см. раздел 6.3.2). А что если передаваемое значение велико и представляет собой изображение (занимающее несколько миллионов бит), большую таблицу чисел (например, несколько тысяч целых чисел) или длинную строку (например, сотни символов)? Тогда копирование оказывается очень затратным механизмом. Не стоит слишком сильно беспокоиться о стоимости выполняемых операций, но делать ненужную работу также не следует, так как это свидетельствует о плохом воплощении идеи, которую мы хотим реализовать. Например, можно написать следующую функцию, выводящую на экран вектор чисел с плавающей точкой:


void print(vector v) // передача по значению; приемлемо ?

{

 cout << "{ ";

 for (int i = 0; i

   cout << v[i];

   if (i!=v.size()–1) cout << ", ";

 }

 cout << " }\n";

}


Функцию

print()
можно применять к векторам любых размеров. Рассмотрим пример.


void f(int x)

{

 vector vd1(10);    // небольшой вектор

 vector vd2(1000000); // большой вектор

 vector vd3(x);    // вектор неопределенного размера

 // ...заполняем векторы vd1, vd2, vd3 значениями...

 print(vd1);

 print(vd2);

 print(vd3);

}


Этот код работает, но при первом вызове функции

print()
будет скопирован десяток чисел типа
double
(вероятно, 80 байт), при втором — миллионы чисел типа
double
(вероятно, восемь мегабайт), а при третьем количество копируемых чисел неизвестно. Возникает вопрос: “Зачем вообще что-то копировать?” Мы же хотим распечатать вектор, а не скопировать его. Очевидно, нам нужен способ передачи переменных функциям без их копирования. Например, если вы получили задание составить список книг, находящихся в библиотеке, то совершенно не обязательно приносить копии всех книг домой — достаточно взять адрес библиотеки, пойти туда и просмотреть все книги на месте.

Итак, нам необходим способ передачи функции

print()
“адреса” вектора, а не копии вектора. “Адрес” вектора называется ссылкой (reference) и используется следующим образом:


void print(const vector& v) // передача по константной ссылке

{

 cout << "{ ";

 for (int i = 0; i

   cout << v[i];

   if (i!=v.size()–1) cout << ", ";

 }

 cout << " }\n";

}


Символ

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


void f(int x)

{

 vector vd1(10);    // небольшой вектор

 vector vd2(1000000); // большой вектор

 vector vd3(x);    // вектор неопределенного размера

 // ...заполняем векторы vd1, vd2, vd3 значениями...

 print(vd1);

 print(vd2);

 print(vd3);

}


Этот механизм можно проиллюстрировать графически.



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

print()
, какое-то значение, то компилятор сразу выдаст сообщение об этом.


void print(const vector& v) // передача по константной ссылке

{

 // ...

  v[i] = 7; // ошибка: v — константа (т.е. не может изменяться)

  // ...

}


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

my_find()
(см. раздел 8.5.1), выполняющей поиск строки в векторе строк. Передача по значению здесь была бы слишком неэффективной.


int my_find(vector vs, string s); // передача по значению:

                      // копия


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

my_find()
, передавая ее аргументы по константной ссылке.


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

int my_find(const vector& vs, const string& s);

8.5.5. Передача параметров по ссылке

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

init()
, которая должна присваивать начальные значения элементам вектора.


void init(vector& v) // передача по ссылке

{

  for (int i = 0; i

}


void g(int x)

{

  vector vd1(10);    // небольшой вектор

 vector vd2(1000000); // большой вектор

 vector vd3(x);    // вектор неопределенного размера

  init(vd1);

 init(vd2);

 init(vd3);

}


Итак, мы хотим, чтобы функция

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

Рассмотрим ссылки более подробно. Ссылка — это конструкция, позволяющая пользователю объявлять новое имя объекта. Например,

int&
— это ссылка на переменную типа
int
. Это позволяет нам написать следующий код:


int i = 7;

int& r = i; // r — ссылка на переменную i

r = 9;    // переменная i становится равной 9 

i = 10;

cout << r << ' ' << i << '\n'; // вывод: 10 10


Иначе говоря, любая операция над переменной

r
на самом деле означает операцию над переменной
i
. Ссылки позволяют уменьшить размер выражений. Рассмотрим следующий пример:


vector< vector > v; // вектор векторов чисел типа double


Допустим, нам необходимо сослаться на некоторый элемент

v[f(x)][g(y)]
несколько раз. Очевидно, что выражение
v[f(x)][g(y)]
выглядит слишком громоздко и повторять его несколько раз неудобно. Если бы оно было просто значением, то мы могли бы написать следующий код:


double val = v[f(x)][g(y)]; // val — значение элемента v[f(x)][g(y)]


В таком случае можно было бы повторно использовать переменную

val
. А что, если нам нужно и читать элемент
v[f(x)][g(y)]
, и присваивать ему значения
v[f(x)][g(y)]
? В этом случае может пригодиться ссылка.


double& var = v[f(x)][g(y)]; // var — ссылка на элемент v[f(x)][g(y)]


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

v[f(x)][g(y)]
с помощью ссылки
var
. Рассмотрим пример.


var = var/2+sqrt(var);


Это ключевое свойство ссылок — оно может служить “аббревиатурой” объекта и использоваться как удобный аргумент. Рассмотрим пример.


// передача по ссылке (функция ссылается на полученную переменную)

int f(int& x)

{

 x = x+1;

 return x;

}


int main()

{

 int xx = 0;

 cout << f(xx) << endl;  // вывод: 1

 cout << xx << endl;   // вывод: 1; функция f() изменяет

              // значение xx

 int yy = 7;

  cout << f(yy) << endl;  // вывод: 8

 cout << yy << endl;   // вывод: 8; функция f() изменяет

             // значение yy

}


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



Сравните этот пример с соответствующим примером из раздела 8.5.3.

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

double
.


void swap(double& d1, double& d2)

{

 double temp = d1; // копируем значение d1 в переменную temp

 d1 = d2;      // копируем значение d2 в переменную d1

 d2 = temp;     // копируем старое значение d1 в переменную d2

}


int main()

{

 double x = 1;

 double y = 2;

 cout << "x == " << x << " y== " << y << '\n'; // вывод: x==1 y==2

 swap(x,y);

 cout << "x == " << x << " y== " << y << '\n'; // вывод: x==2 y==1

}


В стандартной библиотеке предусмотрена функция

swap()
для любого типа, который можно скопировать, поэтому его можно применять к любому типу.

8.5.6. Сравнение механизмов передачи параметров по значению и по ссылке

Зачем нужны передачи по значению, по ссылке и по константной ссылке. Для начала рассмотрим один формальный пример.


void f(int a, int& r, const int& cr)

{

 ++a; // изменяем локальную переменную a

 ++r; // изменяем объект, с которым связана ссылка r

 ++cr; // ошибка: cr — константная ссылка

}


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


void g(int a, int& r, const int& cr)

{

 ++a;     // изменяем локальную переменную a

 ++r;     // изменяем объект, с которым связана ссылка r

 int x = cr; // считываем объект, с которым связана ссылка cr

}


int main()

{

 int x = 0;

 int y = 0;

 int z = 0;

 g(x,y,z); // x==0; y==1; z==0

 g(1,2,3); // ошибка: ссылочный аргумент r должен быть переменным

 g(1,y,3); // OK: поскольку ссылка cr является константной,

       // можно передавать литерал

}


Итак, если хотите изменить значение объекта, передаваемого по ссылке, следует передать объект. С формальной точки зрения целочисленный литерал

2
— это значение (а точнее, r-значение, т.е. значение в правой части оператора присваивания), а не объект, хранящий значение. Для аргумента
r
функции
f()
требуется l-значение (т.е. значение, стоящее в левой части оператора присваивания).

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

g(1,y,3)
компилятор зарезервирует переменную типа
int
для аргумента
cr
функции
g()


g(1,y,3); // означает: int __compiler_generated = 3;

      // g(1,y,__compiler_generated)


Такой объект, создаваемый компилятором, называется временным объектом (temporary object).

Правило формулируется следующим образом.

1. Для передачи очень маленьких объектов следует использовать передачу аргументов по значению.

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

3. Следует возвращать результат, а не модифицированный объект, передаваемый по ссылке.

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


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

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


int incr1(int a) { return a+1; } // возвращает в качестве результата

                 // новое значение

void incr2(int& a) { ++a; }    // модифицирует объект,

                 // передаваемый по ссылке

int x = 7;

x = incr1(x);           // совершенно очевидно

incr2(x);             // совершенно непонятно


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

• Для манипуляций с контейнерами (например, векторами) и другими крупными объектами.

• Для функций, изменяющих сразу несколько объектов (в языке С++ функция может возвращать с помощью оператора return только одно значение).


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


void larger(vector& v1, vector& v2)

 // каждый элемент вектора v1 становится больше

 // соответствующих элементов в векторах v1 и v2;

 // аналогично, каждый элемент вектора v2 становится меньше

{

 if (v1.size()!=v2.size() error("larger(): разные размеры");

 for (int i=0; i

   if (v1[i]

    swap(v1[i],v2[i]);

 }


void f()

{

 vector vx;

 vector vy;

 // считываем vx и vy из входного потока

 larger(vx,vy);

 // ...

}


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

larger()
.

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

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

8.5.7. Проверка аргументов и преобразование типов

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


void f(T x);

f(y);

T x=y; // инициализация переменной x значением переменной y

    // (см раздел 8.2.2)


Вызов

f(y)
является корректным, если инициализация
T x=y;
произошла и если обе переменные с именем
x
могут принимать одно и то же значение. Рассмотрим пример.


void f(double);

void g(int y)

{

  f(y);

  double x(y); // инициализируем переменную x значением

        // переменной y (см. раздел 8.2.2)

}


Обратите внимание на то, что для инициализации переменной

x
значением переменной
y
необходимо преобразовать переменную типа
int
в переменную типа
double
. То же самое происходит при вызове функции
f()
. Значение типа
double
, полученное функцией
f()
, совпадает со значением, хранящимся в переменной
x
.

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

double
в качестве аргумента функции, ожидающей переменную типа
int
, редко можно оправдать.


void ff(int);

void gg(double x)

{

 ff(x); // как понять, имеет ли это смысл?

}


Если вы действительно хотите усечь значение типа

double
до значения типа
int
, то сделайте это явно.


void ggg(double x)

{

 int x1 = x; // усечение x

 int x2 = int(x);

 ff(x1);

  ff(x2);

 ff(x);    // усечение x

  ff(int(x));

}


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

8.5.8. Реализация вызова функции

Как же на самом деле компилятор выполняет вызов функции? Функции

expression()
,
term()
и
primary()
, описанные в главах 6 и 7, прекрасно подходят для иллюстрации этой концепции за исключением одной детали: они не принимают никаких аргументов, поэтому на их примере невозможно объяснить механизм передачи параметров. Однако погодите! Они должны принимать некую входную информацию; если бы это было не так, то они не смогли бы делать ничего полезного. Они принимают неявный аргумент, используя объект
ts
класса
Token_stream
для получения входной информации; объект
ts
является глобальной переменной. Это несколько снижает прозрачность работы программы. Мы можем улучшить эти функции, позволив им принять аргумент типа
Token_stream&
. Благодаря этому нам не придется переделывать ни один вызов функции.

Во-первых, функция expression() совершенно очевидна; она имеет один аргумент (

ts
) и две локальные переменные (
left
и
t
).


double expression(Token_stream& ts)

{

  double left = term(ts);

 Token t = ts.get();

  // ...

}


Во-вторых, функция

term()
очень похожа на функцию
expression()
, за исключением того, что имеет дополнительную локальную переменную (
d
), которая используется для хранения результата деления (раздел
case '/'
).


double term(Token_stream& ts)

{

  double left = primary(ts);

 Token t = ts.get();

 // ...

 case '/':

  {

   double d = primary(ts);

   // ...

 }

  // ...

}


В-третьих, функция

primary()
очень похожа на функцию
term()
, за исключением того, что у нее нет локальной переменной
left
.


double primary(Token_stream& ts)

{

 Token t = ts.get();

 switch (t.kind) {

  case '(':

   { double d = expression(ts);

   // ...

  }

   // ...

 }

}


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

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

expression()
компилятор создает структуру, напоминающую показанную на рисунке.



Детали зависят от реализации, но в принципе к ним относится информация о том, что функция должна вернуть управление и некое значение в точку вызова. Такую структуру данных называют записью активации функции (function activation record), или просто активационной записью. Каждая функция имеет свою собственную запись активации. Обратите внимание на то, что с точки зрения реализации параметр представляет собой всего лишь локальную переменную.

Теперь функция

expression()
вызывает
term()
, поэтому компилятор создает активационную запись для вызова функции
term()
.



Обратите внимание на то, что функция

term()
имеет дополнительную переменную
d
, которую необходимо хранить в памяти, поэтому при вызове мы резервируем для нее место, даже если в коде она нигде не используется. Все в порядке. Для корректных функций (а именно такие функции мы явно или неявно используем в нашей книге) затраты на создание активизационных записей не зависят от их размера. Локальная переменная
d
будет инициализирована только в том случае, если будет выполнен раздел
case '/'
.

Теперь функция

term()
вызывает функцию
primary()
, и мы получаем следующую картину.



Все это становится довольно скучным, но теперь функция

primary()
вызывает функцию
expression()
.



Этот вызов функции

expression()
также имеет свою собственную активационную запись, отличающуюся от активационной записи первого вызова функции
expression()
. Хорошо это или плохо, но мы теперь попадаем в очень запутанную ситуацию, поскольку переменные
left
и
t
при двух разных вызовах будут разными. Функция, которая прямо или (как в данном случае) косвенно вызывает себя, называется рекурсивной (recursive). Как видим, рекурсивные функции являются естественным следствием метода реализации, который мы используем для вызова функции и возврата управления (и наоборот).

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

expression()
управление возвращается функции
primary()
, стек возвращается в предыдущее состояние.



Когда функция

primary()
возвращает управление функции
term()
, стек возвращается в состояние, показанное ниже.

И так далее. Этот стек, который часто называют стеком вызовов (call stack), — структура данных, которая увеличивается и уменьшается с одного конца в соответствии с правилом: последним вошел — первым вышел.

Запомните, что детали реализации стека зависят от реализации языка С++, но в принципе соответствуют схеме, описанной выше. Надо ли вам знать, как реализованы вызовы функции? Разумеется, нет; мы и до этого прекрасно обходились, но многие программисты любят использовать термины “активационная запись” и “стек вызовов”, поэтому лучше понимать, о чем они говорят.



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

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


string program_name = "silly";

vector v; // v — глобальная переменная

void f()

{

 string s; // s — локальная переменная в функции f

 while (cin>>s && s!="quit") {

   string stripped; // stripped — локальная переменная в цикле

   string not_letters;

   for (int i=0; i

                  // видимости инструкции

    if (isalpha(s[i]))

     stripped += s[i];

    else

     not_letters += s[i];

    v.push_back(stripped);

   // ...

 }

  // ...

}


Глобальные переменные, такие как

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

Когда какая-нибудь функция вызывает функцию

f()
, сначала создается переменная
s;
иначе говоря, переменная
s
инициализируется пустой строкой. Она будет существовать, пока функция
f()
не вернет управление. Каждый раз, когда мы входим в тело цикла
while
, создаются переменные
stripped
и
not_letters
. Поскольку переменная
stripped
определена до переменной
not_letters
, сначала создается переменная
stripped
. Они существуют до выхода из тела цикла. В этот момент они уничтожаются в обратном порядке (иначе говоря, переменная
not_letters
уничтожается до переменной
stripped
) и до того, как произойдет проверка условия выхода из цикла. Итак, если, до того, как мы обнаружим строку
quit
, мы выполним цикл десять раз, переменные
stripped
и
not_letters
будут созданы и уничтожены десять раз.

Каждый раз, когда мы входим в цикл

for
, создается переменная
i
. Каждый раз, когда мы выходим из цикла
for
, переменная
i
уничтожается до того, как мы достигнем инструкции
v.push_back(stripped);
.

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

8.6.1. Вычисление выражения

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


v[i] = ++i; // неопределенный порядок вычислений

v[++i] = i; // неопределенный порядок вычислений

int x = ++i + ++i; // неопределенный порядок вычислений

cout << ++i << ' ' << i << '\n'; // неопределенный порядок вычислений

f(++i,++i); // неопределенный порядок вычислений


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

Компиляторы действительно по-разному обрабатывают этот код; избегайте таких ситуаций.

Обратите внимание на то, что оператор

=
(присваивание) в выражениях используется наряду с остальными, поэтому нет никакой гарантии того, что левая часть оператора будет вычислена раньше правой части. По этой причине выражение
v[++i] = i
имеет неопределенный результат.

8.6.2. Глобальная инициализация

Глобальные переменные (и переменные из пространства имен; раздел 8.7) в отдельной единице трансляции инициализируются в том порядке, в котором они появляются. Рассмотрим пример.


// файл f1.cpp

int x1 = 1;

int y1 = x1+2; // переменная y1 становится равной 3


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

main()
. Использование глобальной переменной, за исключением редких ситуаций, нецелесообразно. Мы уже говорили, что не существует эффективного способа, позволяющего программисту определить, какие части программы считывают или записывают переменную (см. раздел 8.4). Другая проблема заключается в том, что порядок инициализации глобальных переменных не определен. Рассмотрим пример.


// файл f2.cpp

extern int y1;

int y2 = y1+2; // переменная y2 становится равной 2 или 5


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

f1.cpp
инициализируются до глобальных переменных в файле
f2.cpp
, то переменная
y2
будет инициализирована числом
5
(как наивно ожидает программист).

Однако, если глобальные переменные в файле

f2.cpp
инициализируются до глобальных переменных в файле
f1.cpp
, переменная
y2
будет инициализирована числом
2
(поскольку память, используемая для глобальных переменных, инициализируется нулем до попытки сложной инициализации). Избегайте этого и старайтесь не использовать нетривиальную инициализацию глобальных переменных; любой инициализатор, отличающийся от константного выражения, следует считать сложным.

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

Date
.


const Date default_date(1970,1,1); // дата по умолчанию: 1 января 1970


Как узнать, что переменная

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


const Date default_date() // возвращает дату по умолчанию

{

 return Date(1970,1,1);

}


Эта функция создает объект типа

Date
каждый раз, когда вызывается функция
default_date()
. Часто этого вполне достаточно, но если функция
default_date()
вызывается часто, а создание объекта класса Date связано с большими затратами, предпочтительнее было бы конструировать его только один раз. В таком случае код мог бы выглядеть так:


const Date& default_date()

{

  static const Date dd(1970,1,1); // инициализируем dd

                  // только при первом вызове

  return dd;

}


Статическая локальная переменная инициализируется (создается) только при первом вызове функции, в которой она объявлена. Обратите внимание на то, что мы вернули ссылку, чтобы исключить ненужное копирование, и, в частности, вернули константную ссылку, чтобы предотвратить несанкционированное изменение значения аргумента при вызове функции. Рассуждения о передаче аргумента (см. раздел 8.5.6) относятся и к возвращаемому значению.

8.7. Пространства имен

Для организации кода в рамках функции используются блоки (см. раздел 8.4).

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

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

• Позволяют именовать то, что мы определили.


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

Color
,
Shape
,
Line
,
Function
и
Text
(глава 13).


namespace Graph_lib {

 struct Color { /* ... */ };

 struct Shape { /* ... */ };

 struct Line: Shape { /* ... */ };

  struct Function: Shape { /* ... */ };

  struct Text: Shape { /* ... */ };

  // ...

 int gui_main() { /* ... */ }

}


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

Text
, но ее уже невозможно перепутать с нашим классом, имеющим то же имя. Наш класс называется
Graph_lib::Text
, а ваш класс — просто
Text
. Проблема возникнет только в том случае, если в вашей программе есть класс или пространство имен
Graph_lib
, в которое входит класс
Text
. Имя
Graph_lib
довольно неудачное; мы выбрали его потому, что “прекрасное и очевидное” имя
Graphics
имеет больше шансов встретиться где-нибудь еще.

Допустим, ваш класс

Text
является частью библиотеки для обработки текстов. Та же логика, которая заставила нас разместить графические средства в пространстве имен
Graph_lib
, подсказывает, что средства для обработки текстов следует поместить в пространстве имен, скажем, с именем
TextLib
.


namespace TextLib {

 class Text { /* ... */ };

 class Glyph { /* ... */ };

 class Line { /* ... */ };

 // ...

}


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

Text
и
Line
. И что еще хуже, если бы мы были не создателями, а пользователями библиотеки, то не никак не смогли бы изменить эти имена и решить проблему. Использование пространств имен позволяет избежать проблем; иначе говоря, наш класс
Text
— это класс
Graph_lib::Text
, а ваш —
TextLib::Text
. Имя, составленное из имени пространства имен (или имени класса) и имени члена с помощью двух двоеточий,
::
, называют полностью определенным именем (fully qualified name).

8.7.1. Объявления using и директивы using

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

std
и могут использоваться примерно так:


#include  // доступ к библиотеке string

#include // доступ к библиотеке iostream

int main()

{

 std::string name;

 std::cout << " Пожалуйста, введите имя \n";

 std::cin >> name;

 std::cout << " Привет, " << name << '\n';

}


Тысячи раз обращаясь к элементам стандартной библиотеки

string
и
cout
, мы на самом деле вовсе не хотим каждый раз указывать их полностью определенные имена —
std::string
и
std::cout
. Напрашивается решение: один раз и навсегда указать, что под классом
string
мы имеем в виду класс
std::string
, а под потоком
cout
— поток
std::cout
и т.д.


using std::string; // string означает std::string

using std::cout;  // cout означает std::cout

// ...


Эта конструкция называется объявлением

using
. Она эквивалентна обращению “Грэг”, которое относится к Грэгу Хансену при условии, что никаких других Грэгов в комнате нет.

Иногда мы предпочитаем ссылаться на пространство имен еще “короче”: “Если вы не видите объявления имени в области видимости, ищите в пространстве имен std”. Для того чтобы сделать это, используется директива

using
.


using namespace std; // открывает доступ к именам из пространства std


Эта конструкция стала общепринятой.


#include   // доступ к библиотеке string

#include  // доступ к библиотеке iostream

using namespace std; // открывает доступ к именам из пространства std


int main()

{

 string name;

 cout << "Пожалуйста, введите имя \n";

 cin >> name;

 cout << "Привет, " << name << '\n';

}


Здесь поток

cin
— это поток
std::cin
, класс
string
это класс
std::string
и т.д. Поскольку мы используем заголовочный файл
std_lib_facilities.h
, не стоит беспокоиться о стандартных заголовках и пространстве имен
std
. Мы рекомендуем избегать использования директивы using для любых пространств имен, за исключением тех из них, которые широко известны в конкретной области приложения, например пространства имен
std
. Проблема, связанная с чрезмерным использованием директивы
using
, заключается в том, что мы теряем след имен и рискуем создать коллизию. Явная квалификация с помощью соответствующих имен пространств имен и объявлений
using
не решает эту проблему. Итак, размещение директивы
using
в заголовочный файл (куда пользователю нет доступа) — плохая привычка. Однако, для того чтобы упростить первоначальный код, мы разместили директиву using для пространства имен
std
в заголовочном файле
std_lib_facilities.h
. Это позволило нам написать следующий код:


#include "std_lib_facilities.h"

int main()

{

 string name;

 cout << "Пожалуйста, введите имя \n";

 cin >> name;

 cout << "Привет, " << name << '\n';

}


Мы обещаем больше никогда так не делать, если речь не идет о пространстве имен

std
.


Задание

• Создайте три файла:

my.h
,
my.cpp
и
use.cpp
. Заголовочный файл
my.h
содержит следующий код:


extern int foo;

void print_foo();

void print(int);


Исходный файл

my.cpp
содержит директивы
#include
для вставки файлов
my.h
и
std_lib_facilities.h
, определение функции
print_foo()
для вывода значения переменной
foo
в поток
cout
и определение функции
print(int i)
для вывода в поток
cout
значения переменной
i
.

Исходный файл

use.cpp
содержит директивы
#include
для вставки файла
my.h
, определение функции
main()
для присвоения переменной
foo
значения
7
и вывода ее на печать с помощью функции
print_foo()
, а также для вывода значения
99
с помощью функции
print()
. Обратите внимание на то, что файл
use.cpp
не содержит директивы
#include std_lib_facilities.h
, поскольку он не использует явно ни одну из его сущностей.

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

use.cpp
и
my.cpp
и использовать в файле
use.cpp
код
{ char cc; cin>>cc; }
.

2. Напишите три функции:

swap_v(int,int)
,
swap_r(int&,int&)
и
swap_cr(const int&,const int&)
. Каждая из них должна иметь тело


{ int temp; temp = a, a=b; b=temp; }


где

a
и
b
— имена аргументов.


Попробуйте вызвать каждую из этих функций, как показано ниже.


int x = 7;

int y =9;

swap_?(x,y); // замените знак ? буквами v, r или cr

swap_?(7,9);

const int cx = 7;

const int cy = 9;

swap_?(cx,cy);

swap_?(7.7,9.9);

double dx = 7.7;

double dy = 9.9;

swap_?(dx,dy);

swap_?(dx,dy);


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

3. Напишите программу, использующую единственный файл, содержащий пространства имен

X
,
Y
и
Z
, так, чтобы функция
main()
, приведенная ниже, работала правильно.


int main()

{

 X::var = 7;

 X::print(); // выводим переменную var из пространства имен X

 using namespace Y;

 var = 9;

 print();   // выводим переменную var из пространства имен Y

 { using Z::var;

   using Z::print;

   var = 11;

   print();  // выводим переменную var из пространства имен Z

  }

  print();   // выводим переменную var из пространства имен Y

  X::print(); // выводим переменную var из пространства имен X

}


Каждое пространство имен должно содержать определение переменной

var
и функции
print()
, выводящей соответствующую переменную
var
в поток
cout
.


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

1. В чем заключается разница между объявлением и определением?

2. Как синтаксически отличить объявление функции от определения функции?

3. Как синтаксически различить объявление переменной от определения переменной?

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

5. Чем является инструкция

int a;
определением или просто объявлением?

6. Почему следует инициализировать переменные при их объявлении?

7. Из каких элементов состоит объявление функции?

8. Какую пользу приносит включение файлов?

9. Для чего используются заголовочные файлы?

10. Какую область видимости имеет объявление?

11. Перечислите разновидности областей видимости. Приведите пример каждой из них.

12. В чем заключается разница между областью видимости класса и локальной областью видимости?

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

14. В чем заключается разница между передачей аргумента по значению и передачей аргумента по ссылке?

15. В чем заключается разница между передачей аргумента по значению и передачей по константной ссылке?

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

swap()
?

17. Следует ли определять функцию с параметром типа

vector
, передаваемым по значению?

18. Приведите пример неопределенного порядка выполнения вычислений. Какие проблемы создает неопределенный порядок вычислений?

19. Что означают выражения

x&&y
и
x||y
?

20. Соответствуют ли стандарту языка С++ следующие конструкции: функции внутри функций, функции внутри классов, классы внутри классов, классы внутри функций?

21. Что входит в активационную запись?

22. Что такое стек вызовов и зачем он нужен?

23. Для чего нужны пространства имен?

24. Чем пространство имен отличается от класса?

25. Объясните смысл объявления

using
.

26. Почему следует избегать директив

using
в заголовочных файлах?

27. Опишите пространство имен

std
.


Термины


Упражнения

1. Модифицируйте программу-калькулятор из главы 7, чтобы поток ввода стал явным параметром (как показано в разделе 8.5.8). Кроме того, напишите конструктор класса

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

2. Напишите функцию

print()
, которая выводит в поток
cout
вектор целых чисел. Пусть у нее будет два аргумента: строка для комментария результатов и объект класса
vector
.

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

fibonacci(x,y,v,n)
, в которой аргументы
x
и
y
имеют тип
int
, аргумент
v
является пустой переменной типа
vector
, а аргумент
n
— это количество элементов, подлежащих записи в вектор
v;
элемент
v[0]
равен
x
, а
v[1]
y
. Число Фибоначчи — это элемент последовательности, в которой каждый элемент является суммой двух предыдущих. Например, последовательность начинается с чисел 1 и 2, за ними следуют числа 1, 2, 3, 5, 8, 13, 21... Функция
fibonacci()
должна генерировать такую последовательность, начинающуюся с чисел
x
и
y
.

4. Переменная типа

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

5. Напишите две функции, изменяющие порядок следования элементов в объекте типа

vector
. Например, вектор 1, 3, 5, 7, 9 становится вектором 9, 7, 5, 3, 1. Первая функция, изменяющая порядок следования элементов на противоположный, должна создавать новый объект класса
vector
, а исходный объект класса
vector
должен оставаться неизменным. Другая функция должна изменять порядок следования элементов без использования других векторов. (Подсказка: как функция
swap
.)

6. Напишите варианты функций из упражнения 5 для класса

vector
.

7. Запишите пять имен в вектор

vector name
, затем предложите пользователю указать возраст названных людей и запишите их в вектор
vector age
. Затем выведите на печать пять пар
(name[i],age[i])
. Упорядочьте имена
(sort(name.begin(), name.end()))
и выведите на печать пары
(name[i], age[i])
. Сложность здесь заключается в том, чтобы получить вектор
age
, в котором порядок следования элементов соответствовал бы порядку следования элементов вектора
name
. (Подсказка: перед сортировкой вектора
name
создайте его копию и используйте ее для получения упорядоченного вектора
age
. Затем выполните упражнение снова, разрешив использование произвольного количества имен).

8. Напишите простую функцию

randint()
, генерирующую псевдослучайные числа в диапазоне
[0:MAXINT]
. (Подсказка: Д. Кнут Искусство программирования, том 2.)

9. Напишите функцию, которая с помощью функции

randint()
из предыдущего упражнения вычисляет псевдослучайное целое число в диапазоне [a:b]:
rand_in_range(int a, int b)
. Примечание: эта функция очень полезна для создания простых игр.

10. Напишите функцию, которая по двум объектам,

price
и
weight
, класса
vector
вычисляет значение (“индекс”), равное сумме всех произведений
price[i]*weight[i]
. Заметьте, что должно выполняться условие
weight.size()<=price.size()
.

11. Напишите функцию

maxv()
, возвращающую наибольший элемент вектора.

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

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

13. Усовершенствуйте функцию

print_until_s()
из раздела 8.5.2. Протестируйте ее. Какие наборы данных лучше всего подходят для тестирования? Укажите причины. Затем напишите функцию
print_until_ss()
, которая выводит на печать сроки, пока не обнаружит строку аргумента
quit
.

14. Напишите функцию, принимающую аргумент типа

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

15. Можно ли объявить константный аргумент функции, который передается не по ссылке (например,

void f(const int);)
? Что это значит? Зачем это нужно? Почему эта конструкция применяется редко? Испытайте ее; напишите несколько маленьких программ, чтобы увидеть, как она работает.


Послесловие

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

Глава 9. Технические детали: классы и прочее

“Помните, все требует времени”.

Пит Хейн (Piet Hein)


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

Date
. Кроме того, это позволяет продемонстрировать некоторые полезные приемы разработки классов.

9.1. Типы, определенные пользователем

В языке С++ есть встроенные типы, такие как

char
,
int
и
double
(подробнее они описаны в разделе A.8). Тип называется встроенным, если компилятор знает, как представить объекты такого типа и какие операторы к нему можно применять (такие как
+
и
) без уточнений в виде объявлений, которые создает программист в исходном коде.

Типы, не относящиеся к встроенным, называют типами, определенными пользователем (user-defined types — UDT). Они могут быть частью стандартной библиотеки, доступной в любой реализации языка С++ (например, классы

string
,
vector
и
ostream
, описанные в главе 10), или типами, самостоятельно созданными программистом, как классы
Token
и
Token_stream
(см. разделы 6.5 и 6.6). Как только мы освоим необходимые технические детали, мы создадим графические типы, такие как
Shape
,
Line
и
Text
(речь о них пойдет в главе 13). Стандартные библиотечные типы являются такой же частью языка, как и встроенные типы, но мы все же рассматриваем их как определенные пользователем, поскольку они созданы из таких же элементарных конструкций и с помощью тех же приемов, как и типы, разработанные нами; разработчики стандартных библиотек не имеют особых привилегий и средств, которых нет у нас. Как и встроенные типы, большинство типов, определенных пользователем, описывают операции. Например, класс
vector
содержит операции
[]
и
size()
(см. разделы 4.6.1 и В.4.8), класс
ostream
операцию
<<
, класс
Token_stream
операцию
get()
(см. раздел 6.8), а класс
Shape
операции
add(Point)
и
set_color()
(см. раздел 14.2).

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

int;
когда хотим манипулировать текстом, класс
string
— хороший выбор; когда хотим манипулировать входной информацией для калькулятора, нам нужны классы
Token
и
Token_stream
. Необходимость этих классов имеет два аспекта.

Представление. Тип “знает”, как представить данные, необходимые в объекте.

Операции. Тип знает, какие операции можно применить к объектам.


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

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

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

В языке С++ (как и в большинстве современных языков) класс является основной строительной конструкцией в крупных программах, которая также весьма полезна для разработки небольших программ, как мы могли убедиться на примере калькулятора (см. главы 6 и 7).

9.2. Классы и члены класса

Класс — это тип, определенный пользователем. Он состоит из встроенных типов, других типов, определенных пользователем, и функций. Компоненты, использованные при определении класса, называются его членами (members). Класс может содержать несколько членов, а может и не иметь ни одного члена. Рассмотрим пример.


class X {

public:

 int m; // данные - члены

 int mf(int v) { int old = m; m=v; return old; } // функция - член

};


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


X var; // var — переменная типа X

var.m = 7; // присваиваем значение члену m объекта var

int x = var.mf(9); // вызываем функцию - член mf() объекта var


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

int
, вызывать функцию-член и т.д.

9.3. Интерфейс и реализация

Как правило, класс имеет интерфейс и реализацию. Интерфейс — это часть объявления класса, к которой пользователь имеет прямой доступ. Реализация — это часть объявления класса, доступ к которой пользователь может получить только с помощью интерфейса. Открытый интерфейс идентифицируется меткой

public:
, а реализация — меткой
private:
. Итак, объявление класса можно представить следующим образом:


class X { // класс имеет имя X

public:

 // открытые члены:

 // – пользовательский интерфейс (доступный всем)

 // функции

 // типы

 // данные (лучше всего поместить в раздел private)

private:

 // закрытые члены:

 // – детали реализации (используется только членами

 // данного класса)

 // функции

 // типы

 // данные

};


Члены класса по умолчанию являются закрытыми. Иначе говоря, фрагмент


class X {

 int mf(int);

 // ...

};


означает


class X {

private:

 int mf(int);

 // ...

};


поэтому


X x;       // переменная x типа X

int y = x.mf(); // ошибка: переменная mf является закрытой

         // (т.е. недоступной)


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


class X {

 int m;

  int mf(int);

public:

 int f(int i) { m=i; return mf(i); }

};


X x;

int y = x.f(2);


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


struct X {

 int m;

 // ...

};


Он эквивалентен следующему коду:


class X {

public:

 int m;

 // ...

};


Структуры (

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

9.4. Разработка класса

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

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

9.4.1. Структуры и функции

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


// простая структура Date (слишком просто?)

struct Date {

 int y; // год

  int m; // месяц года

  int d; // день месяца

};


Date today; // переменная типа Date (именованный объект)


Объект типа

Date
, например
today
, может просто состоять из трех чисел типа
int
.



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

Date
, — это предположение будет использовано во всех вариантах этой структуры на протяжении всей главы. Итак, теперь у нас есть объекты типа
Date;
что с ними можно делать? Все что угодно, в том смысле, что мы можем получить доступ ко всем членам объекта
today
(и другим объектам типа
Date
), а также читать и записывать их по своему усмотрению. Загвоздка заключается в том, что все это не совсем удобно. Все, что мы хотим делать с объектами типа
Date
, можно выразить через чтение и запись их членов. Рассмотрим пример.


// установить текущую дату 24 декабря 2005 года

today.y = 2005;

today.m = 24;

today.d = 12;


Этот способ утомителен и уязвим для ошибок. Вы заметили ошибку? Все, что является утомительным, уязвимо для ошибок! Например, ответьте, имеет ли смысл следующий код?


Date x;

x.y = –3;

x.m = 13;

x.d = 32;


Вероятно нет, и никто не стал бы писать такую чушь — или стал? А что вы скажете о таком коде?


Date y;

y.y = 2000;

y.m = 2;

y.d = 29;


Был ли двухтысячный год високосным? Вы уверены?

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

Date
к общим операциям относится также увеличение значения объекта
Date
. Итак, напишем следующий код:


// вспомогательные функции:

void init_day(Date& dd, int y, int m, int d)

{

  // проверяет, является ли (y,m,d) правильной датой

 // если да, то инициализирует объект dd

}


void add_day(Date& dd, int n)

{

 // увеличивает объект dd на n дней

}


Попробуем использовать объект типа

Date
.


void f()

{

 Date today;

 init_day(today, 12, 24, 2005); // Ой! (в 12-м году не было

                 // 2005-го дня)

 add_day(today,1);

}


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

9.4.2. Функции-члены и конструкторы

Мы предусмотрели функцию инициализации для типа

Date
, которая проверяет корректность его объектов. Однако функции проверки приносят мало пользы, если мы не можем их использовать. Например, допустим, что мы определили для типа
Date
оператор вывода
<<
(раздел 9.8):


void f()

{

  Date today;

 // ...

 cout << today << '\n'; // использовать объект today

 // ...

 init_day(today,2008,3,30);

 // ...

 Date tomorrow;

 tomorrow.y = today.y;

 tomorrow.m = today.m;

 tomorrow.d = today.d+1;  // добавляем единицу к объекту today

 cout << tomorrow << '\n'; // используем объект tomorrow

}


Здесь мы “забыли” немедленно инициализировать объект

today
, и до вызова функции
init_day()
этот объект будет иметь неопределенное значение. Кроме того, “кто-то” решил, что вызывать функцию
add_day()
лишняя потеря времени (или просто не знал о ее существовании), и создал объект
tomorrow
вручную. Это плохой и даже очень плохой код. Вероятно, в большинстве случае эта программа будет работать, но даже самые небольшие изменения приведут к серьезным ошибкам. Например, отсутствие инициализации объекта типа
Date
приведет к выводу на экран так называемого “мусора”, а прибавление единицы к члену
d
вообще представляет собой мину с часовым механизмом: когда объект
today
окажется последним днем месяца, его увеличение на единицу приведет к появлению неправильной даты. Хуже всего в этом очень плохом коде то, что он не выглядит плохим.

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


// простая структура Date,

// гарантирующая инициализацию с помощью конструктора

// и обеспечивающая удобство обозначений

struct Date {

 int y, m, d; // год, месяц, день

 Date(int y, int m, int d); // проверяем корректность даты

                // и выполняем инициализацию

 void add_day(int n);    // увеличиваем объект типа Date на n дней

};


Функция-член, имя которой совпадает с именем класса, является особой. Она называется конструктором (constructor) и используется для инициализации (конструирования) объектов класса. Если программист забудет проинициализировать объект класса, имеющего конструктор с аргументом, то компилятор выдаст сообщение об ошибке. Для такой инициализации существует специальная синтаксическая конструкция.


Date my_birthday;     // ошибка: объект my_birthday не инициализирован

Date today(12,24,2007);  // Ой! Ошибка на этапе выполнения

Date last(2000, 12, 31); // OK (разговорный стиль)

Date christmas = Date(1976,12,24); // также OK (многословный стиль)


Попытка объявить объект

my_birthday
провалится, поскольку мы не указали требуемое начальное значение. Попытку объявить объект
today
компилятор пропустит, но проверочный код в конструкторе на этапе выполнения программы обнаружит неправильную дату ((
12,24,2007
) — 2007-й день 24-го месяца 12-го года).

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

last
содержит в скобках сразу после имени переменной начальное значение — аргументы, требуемые конструктором класса
Date
. Этот стиль инициализации переменных класса, имеющего конструктор с аргументами, является наиболее распространенным. Кроме того, можно использовать более многословный стиль, который позволяет явно продемонстрировать создание объекта (в данном случае
Date(1976,12,24)
) с последующей инициализацией с помощью синтаксиса инициализации
=
. Если вы действительно пишете в таком стиле, то скоро устанете от него.

Теперь можно попробовать использовать вновь определенные переменные.


last.add_day(1);

add_day(2); // ошибка: какой объект типа Date?


Обратите внимание на то, что функция-член

add_day()
вызывается из конкретного объекта типа
Date
с помощью точки, означающей обращение к члену класса. Как определить функцию-член класса, показано в разделе 9.4.4.

9.4.3. Скрываем детали

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

add_day()
? Что произойдет, если кто-то решит непосредственно изменить месяц? Оказывается, мы забыли предусмотреть возможности для выполнения этой операции.


Date birthday(1960,12,31); // 31 декабря 1960 года

++birthday.d;        // Ой! Неправильная дата

Date today(1970,2,3);

today.m = 14;        // Ой! Неправильная дата

              // today.m == 14


Поскольку мы хотим сделать представление типа

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

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

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


// простой типа Date (управление доступом)

class Date {

 int y, m, d; // год, месяц, день

public:

 Date(int y, int m, int d); // проверка и инициализация даты

 void add_day(int n);    // увеличение объекта типа Date на n дней

 int month() { return m; }

 int day() { return d; }

 int year() { return y; }

};


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


Date birthday(1970, 12, 30);    // OK

birthday.m = 14;          // ошибка: Date::m — закрытый член

cout << birthday.month() << endl; // доступ к переменной m


Понятие “правильный объект типа

Date
” — важная разновидность идеи о корректном значении. Мы пытаемся разработать наши типы так, чтобы их значения гарантированно были корректными; иначе говоря, скрываем представление, предусматриваем конструктор, создающий только корректные объекты, и разрабатываем все функции-члены так, чтобы они получали и возвращали только корректные значения. Значение объекта часто называют состоянием (state), а корректное значение — корректным состоянием объекта.

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

Правило, регламентирующее смысл корректного значения, называют инвариантом (invariant). Инвариант для класса

Date
(“Объект класса
Date
должен представлять дату в прошлом, настоящем и будущем времени”) необычайно трудно сформулировать точно: вспомните о високосных годах, григорианском календаре, часовых поясах и т.п. Однако для простых и реалистичных ситуаций можно написать класс
Date
. Например, если мы инициализируем интернет-протоколы, нас не должны беспокоить ни григорианский, ни юлианский календари, ни календарь племени майя. Если мы не можем придумать хороший инвариант, то, вероятно, имеют место простые данные. В таких случаях следует использовать обычные структуры
struct
.

9.4.4. Определение функций-членов

До сих пор мы смотрели на класс

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


// простой класс Date (детали реализации будут рассмотрены позднее)

class Date {

public:

 Date(int y, int m, int d); // проверка и инициализация даты

 void add_day(int n);    // увеличивает объект класса Date на n дней

 int month();

 // ...

private:

 int y, m, d;        // лет, месяцев, дней

};


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

Определяя члены за пределами класса, мы должны указать, какому классу они принадлежат. Для этого используется обозначение имя_класса::имя_члена.


Date::Date(int yy, int mm, int dd)// конструктор

   :y(yy), m(mm), d(dd)     // примечание: инициализация члена

{

}


void Date::add_day(int n)

{

  // ...

}


int month() // Ой: мы забыли про класс Date::

{

   return m; // не функция-член, к переменной m доступа нет

}


Обозначение

:y(yy)
,
m(mm)
,
d(dd)
указывает на то, как инициализируются члены. Оно называется списком инициализации. Мы могли бы написать эквивалентный фрагмент кода.


Date::Date(int yy, int mm, int dd) // конструктор

{

 y = yy;

 m = mm;

 d = dd;

}


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

:y(yy)
,
m(mm)
,
d(dd)
точнее отражает наши намерения. Разница между этими фрагментами точно такая же, как между двумя примерами, приведенными ниже. Рассмотрим первый из них.


int x; // сначала определяем переменную x

// ...

x = 2; // потом присваиваем ей значение


Второй пример выглядит так:


int x = 2; // определяем и немедленно инициализируем двойкой


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


int x(2);        // инициализируем двойкой

Date sunday(2009,8,29); // инициализируем объект Sunday

             // триадой (2009,8,29)


Функцию-член класса можно также определить в определении класса.


// простой класс Date (детали реализации будут рассмотрены позднее)

class Date {

public:

  Date(int yy, int mm, int dd)

  :y(yy), m(mm), d(dd)

  {

   // ...

 }


void add_day(int n)

{

  // ...

}


int month() { return m; }

  // ...

private:

  int y, m, d; // год, месяц, день

};


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

add_day()
могут содержать десятки строк. Это в несколько раз увеличивает размер объявления класса и затрудняет поиск интерфейса среди деталей реализации. Итак, мы не рекомендуем определять большие функции в объявлении класса. Тем не менее посмотрите на определение функции
month()
. Оно проще и короче, чем определение
Date::month()
, размещенное за пределами объявления класса. Определения коротких и простых функций можно размещать в объявлении класса.

Обратите внимание на то, что функция

month()
может обращаться к переменной m, даже несмотря на то, что переменная m определена позже (ниже) функции
month()
. Член класса может ссылаться на другой член класса независимо от того, в каком месте класса он определен. Правило, утверждающее, что имя переменной должно быть объявлено до ее использования, внутри класса ослабляется.

Определение функции-члена в классе приводит к следующим последствиям.

• Функция становится подставляемой (inlined), т.е. компилятор попытается сгенерировать код подставляемой функции вместо ее вызова. Это может дать значительное преимущество часто вызываемым функциям, таким как

month()
.

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


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

9.4.5. Ссылка на текущий объект

Рассмотрим простой пример использования класса

Date
.


class Date {

 // ...

 int month() { return m; }

 // ...

private:

  int y, m, d; // год, месяц, день

};


void f(Date d1, Date d2)

{

 cout << d1.month() << ' ' << d2.month() << '\n';

}


Откуда функции

Date::month()
известно, что при первом вызове следует вернуть значение переменной
d1.m
, а при втором —
d2.m
? Посмотрите на функцию
Date::month()
еще раз; ее объявление не имеет аргумента! Как функция
Date::month()
“узнает”, для какого объекта она вызывается? Функции-члены класса, такие как
Date::month()
, имеют неявный аргумент, позволяющий идентифицировать объект, для которого они вызываются. Итак, при первом вызове переменная m правильно ссылается на
d1.m
, а при втором — на
d2.m
. Другие варианты использования неявного аргумента описаны в разделе 17.10.

9.4.6. Сообщения об ошибках

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

Date
. Если мы создали правильные объекты класса
Date
и все функции-члены написаны правильно, то мы никогда не получим объект класса
Date
с неверным значением. Итак, следует предотвратить создание неправильных объектов класса
Date
.


// простой класс Date (предотвращаем неверные даты)

class Date {

public:

 class Invalid { };     // используется как исключение

 Date(int y, int m, int d); // проверка и инициализация даты

 // ...

private:

 int y, m, d;  // год, месяц, день

 bool check(); // если дата правильная, возвращает true

};


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

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


Date::Date(int yy, int mm, int dd)

   :y(yy), m(mm), d(dd) // инициализация данных - членов класса 

{

  if (!check()) throw Invalid(); // проверка корректности

}


bool Date::check() // возвращает true, если дата корректна

{

  if (m<1 || 12

  // ...

}


Имея это определение класса

Date
, можно написать следующий код:


void f(int x, int y)

try {

 Date dxy(2009,x,y);

 cout << dxy << '\n';  // объявление оператора << см. в разделе 9.8

 dxy.add_day(2);

}

catch(Date::Invalid) {

 error("invalid date"); // функция error() определена

  // в разделе 5.6.3

}


Теперь мы знаем, что оператор

<<
и функция
add_day()
всегда будут работать с корректными объектами класса
Date
. До завершения разработки класса
Date
, описанной в разделе 9.7, опишем некоторые свойства языка, которые потребуются нам для того, чтобы сделать это хорошо: перечисления и перегрузку операторов.

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

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

enum
(enumeration) — это очень простой тип, определенный пользователем, который задает множество значений (элементов перечисления) как символические константы. Рассмотрим пример.


enum Month {

  jan=1, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec

};


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

jan
, или предоставить компилятору подобрать подходящее значение. Если положиться на компилятор, то он присвоит каждому элементу перечисления число, на единицу превышающее значение предыдущего. Таким образом, наше определение перечисления
Month
присваивает каждому месяцу последовательные значения, начиная с единицы. Это эквивалентно следующему коду:


enum Month {

 jan=1, feb=2, mar=3, apr=4, may=5, jun=6,

 jul=7, aug=8, sep=9, oct=10, nov=11, dec=12

};


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

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


enum Day {

 monday, tuesday, wednesday, thursday, friday, saturday, sunday

};


где

monday==0
и
sunday==6
. На практике лучше всего выбирать начальное значение счетчика, равным нулю.

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

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


Month m = feb;

m = 7;   // ошибка: нельзя присвоить целое число перечислению

int n = m; // OK: целочисленной переменной можно присвоить

      // значение Month

Month mm = Month(7); // преобразование типа int в тип Month

           //(без проверки)


Обратите внимание на то, что

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


Month bad = 9999; // ошибка: целое число невозможно преобразовать

          // объект типа Month


Если вы настаиваете на использовании обозначения

Month(9999)
, то сами будете виноваты! Во многих ситуациях язык С++ не пытается останавливать программиста от потенциально опасных действий, если программист явно на этом настаивает; в конце концов, программисту, действительно, виднее.

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


Month int_to_month(int x)

{

 if (x

 return Month(x);

}


Теперь можно написать следующий код:


void f(int m)

{

 Month mm = int_to_month(m);

 // ...

}


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

up
,
down
;
yes
,
no
,
maybe
;
on
,
off
;
n
,
ne
,
e
,
se
,
s
,
sw
,
w
,
nw
) или отличительных признаков (
red
,
blue
,
green
,
yellow
,
maroon
,
crimson
,
black
).

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


enum Traffic_sign { red, yellow, green };

int var = red; // примечание: правильно Traffic_sign::red


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

red
,
on
,
ne
и
dec
. Например, что значит
ne:
“северо-восток” (northeast) или “не равно” (nor equal)? Что значит
dec:
“десятичный” (decimal) или “декабрь” (December)? Именно о таким проблемах мы предупреждали в разделе 3.7. Они легко возникнут, если определить перечисление с короткими и общепринятыми именами элементов в глобальном пространстве имен. Фактически мы сразу сталкиваемся с этой проблемой, когда пытаемся использовать перечисление
Month
вместе с потоками
iostream
, поскольку для десятичных чисел существует манипулятор с именем
dec
(см. раздел 11.2.1). Для того чтобы избежать возникновения этих проблем, мы часто предпочитаем определять перечисления в более ограниченных областях видимости, например в классе. Это также позволяет нам явно указать, на что ссылаются значения элементов перечисления, такие как
Month::jan
и
Color::red
. Приемы работы с перечислениями описываются в разделе 9.7.1. Если нам очень нужны глобальные имена, то необходимо минимизировать вероятность коллизий, используя более длинные или необычные имена, а также прописные буквы. Тем не менее мы считаем более разумным использовать имена перечислений в локальных областях видимости.

9.6. Перегрузка операторов

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


enum Month {

 Jan=1, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec

};


Month operator++(Month& m)     // префиксный инкрементный оператор

{

 m = (m==Dec) ? Jan : Month(m+1); // "циклический переход"

 return m;

}


Конструкция

? :
представляет собой арифметический оператор “если”: переменная
m
становится равной
Jan
, если (
m==Dec
), и
Month(m+1)
в противном случае. Это довольно элегантный способ, отражающий цикличность календаря. Тип
Month
теперь можно написать следующим образом:


Month m = Sep;

++m; // m становится равным Oct

++m; // m становится равным Nov

++m; // m становится равным Dec

++m; // m становится равным Jan ("циклический переход")


Можно не соглашаться с тем, что инкрементация перечисления

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


vector month_tbl;

ostream& operator<<(ostream& os, Month m)

{

 return os << month_tbl[m];

}


Это значит, что объект

month_tbl
был инициализирован где-то, так что, например,
month_tbl[Mar]
представляет собой строку "March" или какое-то другое подходящее название месяца (см. раздел 10.11.3).

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

+
,
,
*
,
/
,
%
,
[]
,
()
,
^
,
!
,
&
,
<
,
<=
,
>
и
>=
. Невозможно определить свой собственный оператор; можно себе представить, что программист захочет иметь операторы
**
или
$=
, но язык С++ этого не допускает. Операторы можно определить только для установленного количества операндов; например, можно определить унарный оператор
, но невозможно перегрузить как унарный оператор
<=
(“меньше или равно”). Аналогично можно перегрузить бинарный оператор
+
, но нельзя перегрузить оператор
!
(“нет”) как бинарный. Итак, язык позволяет использовать для определенных программистом типов существующие синтаксические выражения, но не позволяет расширять этот синтаксис.

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


int operator+(int,int); // ошибка: нельзя перегрузить встроенный

             // оператор +

Vector operator+(const Vector&, const Vector &); // OK

Vector operator+=(const Vector&, int);      // OK


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

+
должен обозначать сложение; бинарный оператор
*
— умножение; оператор
[]
— доступ; оператор
()
— вызов функции и т.д. Это просто совет, а не правило языка, но это хороший совет: общепринятое использование операторов, такое как символ
+
для сложения, значительно облегчает понимание программы. Помимо всего прочего, этот совет является результатом сотен лет опыта использования математических обозначений.

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

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

+
,
,
*
, и
/
, как можно было бы предположить, а
=
,
==
,
!=
,
<
,
[]
и
()
.

9.7. Интерфейсы классов

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

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

• Интерфейс должен быть полным.

• Интерфейс должен быть минимальным.

• Класс должен иметь конструкторы.

• Класс доложен поддерживать копирование (или явно запрещать его) (см. раздел 14.2.4).

• Следует предусмотреть тщательную проверку типов аргументов.

• Необходимо идентифицировать немодифицирующие функции-члены (см. раздел 9.7.4).

• Деструктор должен освобождать все ресурсы (см. раздел 17.5). См. также раздел 5.5, в котором описано, как выявлять ошибки и сообщать о них на этапе выполнения программы.


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

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

9.7.1. Типы аргументов

Определяя конструктор класса

Date
в разделе 9.4.3, мы использовали в качестве аргументов три переменные типа
int
. Это породило несколько проблем.


Date d1(4,5,2005); // Ой! Год 4, день 2005

Date d2(2005,4,5); // 5 апреля или 4 мая?


Первая проблема (недопустимый день месяца) легко решается путем проверки в конструкторе. Однако вторую проблему (путаницу между месяцем и днем месяца) невозможно выявить с помощью кода, написанного пользователем. Она возникает из-за того, что существуют разные соглашения о записи дат; например, 4/5 в США означает 5 апреля, а в Англии — 4 мая. Поскольку эту проблему невозможно устранить с помощью вычислений, мы должны придумать что-то еще. Очевидно, следует использовать систему типов.


// простой класс Date (использует тип Month)

class Date {

public:

 enum Month {

   jan=1, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec

 };

  Date(int y, Month m, int d); // проверка даты и инициализация

 // ...

private:

 int y; // год

 Month m;

 int d; // день

};


Когда мы используем тип

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


Date dx1(1998, 4, 3);       // ошибка: 2-й аргумент не имеет

                 // тип Month

Date dx2(1998, 4, Date::mar);  // ошибка: 2-й аргумент не имеет

                 // тип Month

Date dx2(4, Date::mar, 1998);  // Ой: ошибка на этапе выполнения:

                 // день 1998

Date dx2(Date::mar, 4, 1998);  // ошибка: 2-й аргумент не имеет

                 // тип Month

Date dx3(1998, Date::mar, 30); // OK


Этот код решает много проблем. Обратите внимание на квалификатор

Date
перечисления
mar: Date::mar
. Тем самым мы указываем, что это перечисление
mar
из класса
Date
. Это не эквивалентно обозначению
Date.mar
, поскольку
Date
— это не объект, а тип, а
mar
— не член класса, а символическая константа из перечисления, объявленного в классе. Обозначение
::
используется после имени класса (или пространства имен; см. раздел 8.7), а
.
(точка) — после имени объекта.

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

А нельзя ли подобным образом выявить путаницу между днем месяца и годом? Можно, но решение этой проблемы будет не таким элегантным, как для типа

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

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


class Year { // год в диапазоне [min:max)

  static const int min = 1800;

 static const int max = 2200;

public:

 class Invalid { };

 Year(int x) : y(x) { if (x

 int year() { return y; }

private:

 int y;

};


class Date {

public:

 enum Month {

   jan=1, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec

  };

 Date(Year y, Month m, int d); // проверка даты и инициализация

  // ...

private:

 Year y;

 Month m;

 int d; // день

};


Теперь получаем фрагмент кода.


Date dx1(Year(1998),4,3);      // ошибка: 2-й аргумент — не Month

Date dx2(Year(1998),4,Date::mar);  // ошибка: 2-й аргумент — не Month

Date dx2(4, Date::mar,Year(1998)); // ошибка: 1-й аргумент — не Year

Date dx2(Date::mar,4,Year(1998));  // ошибка: 2-й аргумент — не Month

Date dx3(Year(1998),Date::mar,30); // OK


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


Date dx2(Year(4),Date::mar,1998); // ошибка на этапе выполнения:

                  // Year::Invalid


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

Year
.

Когда мы программируем, то всегда устанавливаем критерии качества для данного приложения. Как правило, мы не можем позволить себе роскошь очень долго искать идеальное решение, если уже нашли достаточно хорошее. Втягиваясь в поиски наилучшего решения, мы настолько запутаем программу, что она станет хуже, чем первоначальный вариант. Как сказал Вольтер: “Лучшее — враг хорошего”.

Обратите внимание на слова

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

9.7.2. Копирование

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

Затем необходимо решить, можно ли копировать объекты и как это делать? Для класса

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


Date holiday(1978, Date::jul, 4);   // инициализация

Date d2 = holiday;

Date d3 = Date(1978, Date::jul, 4);

holiday = Date(1978, Date::dec, 24); // присваивание

d3 = holiday;


Обозначение

Date(1978, Date::dec, 24)
означает создание соответствующего неименованного объекта класса Date, которое затем можно соответствующим образом использовать. Рассмотрим пример.


cout << Date(1978, Date::dec, 24);


В данном случае конструктор класса действует почти как литерал. Это часто удобнее, чем сначала создавать переменную или константу, а затем использовать ее лишь один раз.

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

9.7.3. Конструкторы по умолчанию

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

Date::Date(int,Month,int)
, чтобы гарантировать, что каждый объект класса
Date
будет правильно проинициализирован. В данном случае это значит, что программист должен предоставить три аргумента соответствующих типов. Рассмотрим пример.


Date d1;         // ошибка: нет инициализации

Date d2(1998);      // ошибка: слишком мало аргументов

Date d3(1,2,3,4);    // ошибка: слишком много аргументов

Date d4(1,"jan",2);   // ошибка: неправильный тип аргумента

Date d5(1,Date::jan,2); // OK: используется конструктор с тремя

             // аргументами

Date d6 = d5;      // OK: используется копирующий конструктор


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

Date
, мы по-прежнему можем копировать объекты класса
Date
. Многие классы имеют вполне разумные значения по умолчанию; иначе говоря, для них существует очевидный ответ на вопрос: какое значение следует использовать, если инициализация не выполнена? Рассмотрим пример.


string s1;       // значение по умолчанию: пустая строка ""

vector v1;   // значение по умолчанию: вектор без элементов

vector v2(10); // вектор, по умолчанию содержащий 10 строк


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

vector
и
string
имеют конструкторы по умолчанию, которые неявно выполняют желательную инициализацию.

Для типа

T
обозначение
T()
— значение по умолчанию, определенное конструктором, заданным по умолчанию. Итак, можно написать следующий код:


string s1 = string();  // значение по умолчанию: пустая строка ""

vector v1 = vector(); // значение по умолчанию:

                 // пустой вектор; без элементов

vector v2(10,string()); // вектор, по умолчанию содержащий

                 // 10 строк


Однако мы предпочитаем эквивалентный и более краткий стиль.


string s1;       // значение по умолчанию: пустая строка ""

vector v1;   // значение по умолчанию: пустой вектор;

             // без элементов

vector v2(10); // вектор, по умолчанию содержащий 10 строк


Для встроенных типов, таких как

int
и
double
, конструктор по умолчанию подразумевает значение
0
, так что запись
int()
— это просто усложненное представление нуля, а
double()
— долгий способ записать число
0.0
.

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

()
при инициализации.


string s1("Ike"); // объект, инициализированный строкой "Ike"

string s2();    // функция, не получающая аргументов и возвращающая

          // строку


Использование конструктора, заданного по умолчанию, — это не просто вопрос стиля. Представьте себе, что отказались от инициализации объектов класса

string
и
vector
.


string s;

for (int i=0; i

                // количество раз

 s[i] = toupper(s[i]);     // ой: изменяется содержание

                 // случайной ячейки памяти

vector v;

v.push_back("bad");       // ой: запись по случайному адресу


Если значения переменных

s
и
v
действительно не определены, то непонятно, сколько элементов они содержат или (при общепринятом способе реализации; см. раздел 17.5) неясно, где эти элементы должны храниться. В результате будут использованы случайные адреса — и это худшее, что может произойти. В принципе без конструктора мы не можем установить инвариант, поскольку не можем гарантировать, что его объекты будут корректными (см. раздел 9.4.3). Мы настаиваем на том, что такие переменные должны быть проинициализированы. В таком случае фрагмент можно было бы переписать следующим образом:


string s1 = "";

vector v1(0);

vector v2(10,""); // вектор, содержащий 10 пустых строк


Однако этот код не кажется нам таким уж хорошим. Для объекта класса

string
строка
""
является очевидным обозначением пустой строки, а для объекта класса vector легко догадаться, что число
0
означает пустой вектор. Однако для многих типов правильно интерпретировать значение, заданное по умолчанию, совсем не так легко. В таких случаях лучше было бы определить конструктор, создающий объект без использования явной инициализации. Такие конструкторы не имеют аргументов и называются конструкторами по умолчанию.

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


class Date {

public:

 // ...

 Date(); // конструктор по умолчанию

 // ...

private:

 int y;

 Month m;

 int d;

};


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


Date::Date()

    :y(2001), m(Date::jan), d(1)

{

}


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


const Date& default_date()

{

 static Date dd(2001,Date::jan,1);

 return dd;

}


Здесь использовано ключевое слово

static
, чтобы переменная
dd
создавалась только один раз, а не каждый раз при очередном вызове функции
default_date()
. Инициализация этой переменной происходит при первом вызове функции
default_date()
. С помощью функции
default_date()
легко определить конструктор, заданный по умолчанию, для класса
Date
.


Date::Date()

   :y(default_date().year()),

    m(default_date().month()),

    d(default_date().day())

}


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

default_date
, уже сделал это. Имея конструктор для класса
Date
по умолчанию, мы можем создать векторы объектов класса
Date
.


vector birthdays(10);


Без конструктора по умолчанию мы были бы вынуждены сделать это явно.


vector birthdays(10,default_date());

9.7.4. Константные функции-члены

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

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


void some_function(Date& d, const Date& start_of_term)

{

 int a = d.day();       // OK

 int b = start_of_term.day(); // должно бы правильно (почему ?)

 d.add_day(3);         // отлично

 start_of_term.add_day(3);   // ошибка

}


Здесь подразумевается, что переменная

d
будет изменяться, а переменная
start_of_term
— нет; другими словами, функция
some_function()
не может изменить переменную
start_of_term
. Откуда компилятору это известно? Дело в том, что мы сообщили ему об этом, объявив переменную
start_of_term
константой (
const
). Однако почему же с помощью функции
day()
можно прочитать переменную
day
из объекта
start_of_term
? В соответствии с предыдущим определением класса
Date
функция
start_of_term.day()
считается ошибкой, поскольку компилятор не знает, что функция
day()
не изменяет свой объект класса
Date
. Об этом в программе нигде не сказано, поэтому компилятор предполагает, что функция
day()
может модифицировать свой объект класса
Date
, и выдаст сообщение об ошибке.

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


class Date {

public:

 // ...

 int day() const;    // константный член: не может изменять

             // объект

 Month month() const;  // константный член: не может изменять

             // объект 

 int year() const;    // константный член: не может изменять

             // объект

 void add_day(int n);  // неконстантный член: может изменять

             // объект

 void add_month(int n); // неконстантный член: может изменять

             // объект

 void add_year(int n);  // неконстантный член: может изменять

             // объект

private:

 int y; // год

 Month m;

 int d; // день месяца

};


Date d(2000, Date::jan, 20);

const Date cd(2001, Date::feb, 21);

cout << d.day() << " — " << cd.day() << endl; // OK

d.add_day(1);  // OK

cd.add_day(1); // ошибка: cd — константа


Ключевое слово

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


int Date::day() const

{

 ++d; // ошибка: попытка изменить объект в константной

    // функции - члене

  return d;

}


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

9.7.5. Члены и вспомогательные функции

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

Пятьдесят функций для класса

Date
! Возможно, вы думаете, что мы шутим. Вовсе нет: несколько лет назад я делал обзор нескольких коммерческих библиотек для работы с календарем и обнаружил в них множество функций вроде
next_Sunday()
,
next_workday()
и т.д. Пятьдесят — это совсем не невероятное число для класса, разработанного для удобства пользователей, а не для удобства его проектирования, реализации и сопровождения.

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

Date
, мы могли решить, что дату лучше представлять в виде целого числа дней, прошедших с 1 января 1900 года, а не в виде тройки (год, месяц, день). В этом случае нам придется изменить только функции-члены.

Рассмотрим несколько примеров вспомогательных функций (helper functions).


Date next_Sunday(const Date& d)

{

 // имеет доступ к объекту d, используя d.day(), d.month()

 // и d.year()

 // создает и возвращает новый объект класса Date

}


Date next_weekday(const Date& d) { /* ... */ }


bool leapyear(int y) { /* ... */ }


bool operator==(const Date& a, const Date& b)

{

 return a.year()==b.year()

 && a.month()==b.month()

 && a.day()==b.day();

}


bool operator!=(const Date& a, const Date& b)

{

 return !(a==b);

}


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

leapyear()
. Часто для идентификации вспомогательных функций используются пространства имен (см. раздел 8.7).


namespace Chrono {

class Date { /* ... */ };

 bool is_date(int y, Date::Month m, int d); // true для

                       // корректных данных

 Date next_Sunday(const Date& d) { /* ... */ }

 Date next_weekday(const Date& d) { /* ... */ }

 bool leapyear(int y) { /* ... */ } // см. пример 10

 bool operator==(const Date& a, const Date& b) { /* ... */ }

 // ...

}


Обратите внимание на функции

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

Отметьте также, что мы ввели вспомогательную функцию

is_date()
, которая заменяет функцию
Date::check()
, поскольку проверка корректности даты во многом не зависит от представления класса
Date
. Например, нам не нужно знать, как представлены объекты класса
Date
для того, чтобы узнать, что дата “30 января 2008 года” является корректной, а “30 февраля 2008 года” — нет. Возможно, существуют аспекты даты, которые зависят от ее представления (например, корректна ли дата “30 января 1066 года”), но (при необходимости) конструктор
Date
может позаботиться и об этом.

9.8. Класс Date

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

Date
. Там, где тело функции содержит лишь комментарий
...
, фактическая реализация слишком сложна (пожалуйста, не пытайтесь пока ее написать). Сначала разместим объявления в заголовочном файле
Chrono.h
.


// файл Chrono.h

#include "Chrono.h"


namespace Chrono {

class Date {

public:

 enum Month {

    jan=1, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec

  };


class Invalid { }; // для генерации в виде исключения


Date(int y, Month m, int d); // проверка и инициализация даты

 Date();           // конструктор по умолчанию

               // операции копирования по умолчанию

               // в порядке


 // немодифицирующие операции:

 int day() const { return d; }

 Month month() const { return m; }

 int year() const { return y; }


 // модифицирующие операции:

 void add_day(int n);

 void add_month(int n);

 void add_year(int n);

private:

 int y;

 Month m;

 int d;

};


bool is_date(int y, Date::Month m, int d); // true для корректных дат


bool leapyear(int y); // true, если y — високосный год


bool operator==(const Date& a, const Date& b);

bool operator!=(const Date& a, const Date& b);


ostream& operator<<(ostream& os, const Date& d);

istream& operator>>(istream& is, Date& dd);

} // Chrono


Определения находятся в файле

Chrono.cpp
.


// Chrono.cpp

namespace Chrono {

// определения функций-членов:

  Date::Date(int yy, Month mm, int dd)

     :y(yy), m(mm), d(dd)

  {

    if (!is_date(yy,mm,dd)) throw Invalid();

  }


  Date& default_date()

  {

   static Date dd(2001,Date::jan,1); // начало XXI века

   return dd;

  }


  Date::Date()

      :y(default_date().year()),

     m(default_date().month()),

     d(default_date().day())

  {

  }


  void Date:: add_day(int n)

  {

   // ...

  }


  void Date::add_month(int n)

  {

    // ...

  }


  void Date::add_year(int n)

  {

   if (m==feb && d==29 && !leapyear(y+n)) { // помните о високосных годах!

     m = mar; // 1 марта вместо

          // 29 февраля

     d = 1;

   }

    y+=n;

  }


  // вспомогательные функции:

  bool is_date(int y, Date::Month m, int d)

  {

   // допустим, что y — корректный объект

   if (d<=0) return false; // d должна быть положительной

   if (m < Date::jan || Date::dec < m) return false;

   int days_in_month = 31; // большинство месяцев состоит из 31 дня

   switch (m) {

   case Date::feb: // продолжительность февраля варьирует

     days_in_month = (leapyear(y)) ? 29:28;

     break;

   case Date::apr: case Date::jun: case Date::sep: case

     Date::nov:

     days_in_month = 30; // остальные месяцы состоят из 30 дней

    break;

   }

   if (days_in_month

   return true;

  }


  bool leapyear(int y)

  {

   // см. упражнение 10

  }


  bool operator==(const Date& a, const Date& b)

  {

   return a.year()==b.year()

    && a.month()==b.month()

    && a.day()==b.day(); 

  }


  bool operator!=(const Date& a, const Date& b)

  {

   return !(a==b);

  }


  ostream& operator<<(ostream& os, const Date& d)

  {

   return os << '(' << d.year()

    << ',' << d.month()

     << ',' << d.day() << ')';

  }


  istream& operator>>(istream& is, Date& dd)

  {

   int y, m, d;

   char ch1, ch2, ch3, ch4;

   is >> ch1 >> y >> ch2 >> m >> ch3 >> d >> ch4;

   if (!is) return is;

   if (ch1!='(' || ch2!=',' || ch3!=',' || ch4!=')') { // ошибка 
формата

    is.clear(ios_base::failbit); // установлен неправильный 
бит

     return is;

   }

   dd = Date(y, Date::Month(m),d); // обновляем dd

   return is;

  }


  enum Day {

   sunday, monday, tuesday, wednesday, thursday, friday, saturday

  };


  Day day_of_week(const Date& d)

  {

   // ...

  }


  Date next_Sunday(const Date& d)

  {

   // ...

  }


  Date next_weekday(const Date& d)

  {

   // ...

  }


} // Chrono 


Функции, реализующие операции

>>
и
<<
для класса
Date
, будут подробно рассмотрены в разделах 10.7 и 10.8.


Задание

Это задание сводится к запуску последовательности версий класса

Date
. Для каждой версии определите объект класса
Date
с именем
today
, инициализированный датой 25 июня 1978 года. Затем определите объект класса
Date
с именем tomorrow и присвойте ему значение, скопировав в него объект
today
и увеличив его день на единицу с помощью функции
add_day()
. Выведите на печать объекты
today
и
tomorrow
, используя оператор
<<
, определенный так, как показано в разделе 9.8.

Проверка корректности даты может быть очень простой. В любом случае не допускайте, чтобы месяц выходил за пределы диапазона [1,12], а день месяца — за пределы диапазона [1,31]. Проверьте каждую версию хотя бы на одной некорректной дате, например (2009, 13, –5).

1. Версия из раздела 9.4.1.

2. Версия из раздела 9.4.2.

3. Версия из раздела 9.4.3.

4. Версия из раздела 9.7.1.

5. Версия из раздела 9.7.4.


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

1. Какие две части класса описаны в главе?

2. В чем заключается разница между интерфейсом и реализацией класса?

3. Какие ограничения и проблемы, связанные со структурой

Date
, описаны в этой главе?

4. Почему в классе

Date
используется конструктор, а не функция
init_day()
?

5. Что такое инвариант? Приведите примеры.

6. Когда функции следует размещать в определении класса, а когда — за его пределами? Почему?

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

8. Почему открытый интерфейс класса должен быть минимальным?

9. Что изменится, если к объявлению функции-члена добавить ключевое слово

const
?

10. Почему вспомогательные функции лучше всего размещать за пределами класса?


Термины


Упражнения

1. Перечислите разумные операторы для реальных объектов, указанных в разделе 9.1 (например, для тостера).

2. Разработайте и реализуйте класс

Name_pairs
, содержащий пару (имя,возраст), где имя — объект класса
string
, а возраст — переменная типа
double
. Представьте эти члены класса в виде объектов классов
vector
(с именем name ) и
vector
(с именем
age
). Предусмотрите операцию ввода
read_names()
, считывающую ряд имен. Предусмотрите операцию
read_ages()
, предлагающую пользователю ввести возраст для каждого имени. Предусмотрите операцию
print()
, которая выводит на печать пары (
name[i]
,
age[i]
) (по одной на строке) в порядке, определенном вектором name. Предусмотрите операцию
sort()
, упорядочивающую вектор
name
в алфавитном порядке и сортирующую вектор
age
соответствующим образом. Реализуйте все “операции” как функции-члены. Проверьте этот класс (конечно, проверять надо как можно раньше и чаще).

3. Замените функцию

Name_pair::print()
(глобальным) оператором
operator<<
и определите операции
==
и
!=
для объектов класса
Name_pair
.

4. Посмотрите на головоломный пример из раздела 8.4. Вставьте его в программу и объясните смысл каждой конструкции. Обратите внимание на то, что этот код не делает никаких осмысленных операций; он используется только для усложнения примера.

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

Book
, который является частью программного обеспечения библиотеки. Класс
Book
должен иметь члены для хранения кода ISBN, названия, фамилии автора и даты регистрации авторских прав. Кроме того, он должен хранить данные о том, выдана книга на руки или нет. Создайте функции, возвращающие эти данные. Создайте функции, проверяющие, выдана ли книга на руки или нет. Предусмотрите простую проверку данных, которые вводятся в объект класса
Book;
например, код ISBN допускается только в форме
n-n-n-x
, где
n
— целое число;
x
— цифра или буква.

6. Добавьте операторы в класс

Book
. Пусть оператор
==
проверяет, совпадают ли коды ISBN у двух книг. Пусть также оператор
!=
сравнивает цифры ISBN, а оператор
<<
выводит на печать название, фамилию автора и код ISBN в отдельных строках.

7. Создайте перечисление для класса

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

8. Создайте класс

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

9. Создайте класс

Library
. Включите в него векторы классов
Book
и
Patron
. Включите также структуру
Transaction
и предусмотрите в ней члены классов
Book
,
Patron
и
Date
. Создайте вектор объектов класса
Transaction
. Создайте функции, добавляющие записи о книгах и клиентах библиотеки, а также о состоянии книг. Если пользователь взял книгу, библиотека должна быть уверена, что пользователь является ее клиентом, а книга принадлежит ее фондам. Если эти условия не выполняются, выдайте сообщение об ошибке. Проверьте, есть ли у пользователя задолженность по уплате членских взносов. Если задолженность есть, выдайте сообщение об ошибке. Если нет, создайте объект класса
Transaction
и замените его в векторе объектов класса
Transaction
. Кроме того, создайте метод, возвращающий вектор, содержащий имена всех клиентов, имеющих задолженность.

10. Реализуйте функцию

leapyear()
из раздела 9.8.

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

Date
, включая такие функции, как
next_workday()
(в предположении, что любой день, кроме субботы и воскресенья, является рабочим) и
week_of_year()
(в предположении, что первая неделя начинается 1 января, а первый день недели — воскресенье).

12. Измените представление класса

Date
и пронумеруйте дни, прошедшие с 1 января 1970 года (так называемый нулевой день), с помощью переменной типа
long
и переработайте функции из раздела 9.8. Предусмотрите идентификацию дат, выходящих за пределы допустимого диапазона (отбрасывайте все даты, предшествующие нулевому дню, т.е. не допускайте отрицательных дней).

13. Разработайте и реализуйте класс для представления рациональных чисел

Rational
. Рациональное число состоит из двух частей: числителя и знаменателя, например 5/6 (пять шестых, или .83333). При необходимости еще раз проверьте определение класса. Предусмотрите операторы присваивания, сложения, вычитания, умножения, деления и проверки равенства. Кроме того, предусмотрите преобразование в тип
double
. Зачем нужен класс
Rational
?

14. Разработайте и реализуйте класс

Money
для вычислений, связанных с долларами и центами, точность которых определяется по правилу округления 4/5 (0,5 цента округляется вверх, все, что меньше 0,5, округляется вниз). Денежные суммы должны представляться в центах с помощью переменной типа
long
, но ввод и вывод должны использовать доллары и центы, например $123.45. Не беспокойтесь о суммах, выходящих за пределы диапазона типа
long
.

15. Уточните класс

Money
, добавив валюту (как аргумент конструктора). Начальное значение в виде десятичного числа допускается, поскольку такое число можно представить в виде переменной типа
long
. Не допускайте некорректных операций. Например, выражение
Money*Money
не имеет смысла, а
USD1.23+DKK5.00
имеет смысл, только если существует таблица преобразования, определяющая обменный курс между американскими долларами (USD) и датскими кронами (DKK).

16. Приведите пример вычислений, в котором класс

Rational
позволяет получить более точные результаты, чем класс
Money
.

17. Приведите пример вычислений, в котором класс

Rational
позволяет получить более точные результаты, чем тип
double
.


Послесловие

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

Загрузка...