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

“Ни один талант не может преодолеть

пристрастия к деталям”.

Восьмой закон Леви


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

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);      // небольшой вектор

  vectorvd2(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. Технические детали: классы и прочее