Локальность – это прекрасно.
Б. Страуструп «Дизайн и эволюция языка С++»

Лекция 9. Время жизни и область видимости.
Пространства имён. Компоновка

1. Время жизни и область видимости. Спецификации класса памяти

Введение

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

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

Область видимости имени начинается в точке объявления, точнее, сразу после объявителя, но перед инициализатором. Поэтому допускается использование имени в качестве инициализирующего значения для себя самого.

int x = x; // Странно!

Это допустимо, но не разумно.

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

int count;
int count;
// Ошибка – повторное определение
extern int error_number;
extern short error_number; // Ошибка – несоответствие типов объявлений

Объявления и определения, записанные внутри какого-либо блока, называются внутренними или локальными. Объявления и определения, записанные за пределами всех блоков, называются внешними или глобальными.

1.1. Переменные

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

Объявления переменных в языке C++ имеют следующий синтаксис:
[<спецификация класса памяти>] <тип> <описатель> [= <инициализатор>] [, <описатель> [= <инициализатор>] ...];

В языке C++ имеется четыре спецификации класса памяти:

Спецификации класса памяти auto и register могут быть использованы только на внутреннем уровне.

1.1.1. Глобальные переменные

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

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

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

1.1.2. Локальные переменные

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

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

Для каждого рекурсивного входа в блок порождается новый набор переменных класса памяти auto и register. При этом каждый раз производится инициализация переменных, в объявлении которых заданы инициализаторы.

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

Переменная, объявленная со спецификацией класса памяти extern, является ссылкой на переменную с тем же самым именем, определенную на внешнем уровне в любом исходном файле программы. Цель внутреннего объявления extern состоит в том, чтобы сделать определение переменной доступным именно внутри данного блока.

Файл 1

Файл 2

int a, b;
static int c;

extern int d = 10;
int m[10];
int f(int x) { int y;

static int z;

static int c; ... }
// Глобальные переменные
// На переменную с нельзя будет // сослаться из другого файла
// Нельзя использовать инициализатор с extern



// Переменная класса auto. // Локальные время жизни и область видимости.
// Локальная переменная класса static. // Имеет глобальное время жизни.
// Локальная статическая переменная c // скрывает глобальную переменную c
extern int a;
static double c;

int d = 10;
extern int m[];
auto int e; register int e;
int g(int x) { extern int b;

register int w; ... }
// Ссылка на переменную а из файла 1
// Но можно объявить переменную // с таким же именем c в другом файле


// С extern можно опускать размер массива
// Нельзя использовать auto и register // на внешнем уровне

// Ссылка на переменную b из файла 1, // которая будет доступна только в функции g
// Переменная класса register

1.1.3. Сокрытие имён

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

int x;
void f()
{ double x = 0; // Глобальная переменная х скрыта
::x = 2; // Присваивание глобальной переменной х
x = 2.5; // Присваивание локальной переменной х
}

int f(int x) { ... }
class X
{ public:
static int f() { ... }
};
int ff()
{ return X::f(); } // Вызов функции f класса Х, а не глобальной функции f

Не существует способа обращений к скрытой локальной переменной.

1.1.4. Объявления в условиях и цикле for

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

Одним из самых элегантных применений этих идей является объявление переменной в условии. Рассмотрим пример.

if (double d = f(x)) y /= d;

Область видимости переменной d простирается от точки её объявления до конца оператора, контролируемого условием. Если бы в инструкции if была ветвь else, то областью видимости переменной d были бы обе ветви.

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

double d; ... d2 = d; ...
if (d = f(x)) y /= d; ... // Внимание!!!
d = 2.0; // Два несвязанных использования переменной d

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

Объявление в условии должно объявлять и инициализировать единственную переменную или константу.

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

void f(int x[], int n) { for (int i = 0; i < n; i++) x[i] = i * i; }

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

1.2. Функции

Объявление функции (прототип) задает её имя, тип возвращаемого значения и атрибуты её формальных параметров. Объявления функций в языке C++ имеют следующий синтаксис:
[<спецификация класса памяти>] <тип> <имя> (<список формальных параметров>);

Определение функции специфицирует тело функции, которое представляет собой составной оператор, содержащий объявления и операторы. Определение функции также задает её имя, тип возвращаемого значения и атрибуты её формальных параметров. Определение функции имеет следующий синтаксис:
[<спецификация класса памяти>] <тип> <имя> (<список формальных параметров>) { <тело функции> }

Функции имеют глобальное время жизни.

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

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

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

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

2. Пространства имён

2.1. Пространство имён

Пространство имён является механизмом отражения логического группирования. То есть если некоторые объявления можно объединить по какому-либо критерию, их можно поместить в одно пространство имён для отражения этого факта.

Пространство имён объявляется следующим образом:
namespace <имя пространства имён> { <объявления и определения> }

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

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

namespace N1 { int g(); char h(); }
namespace N2 { int f1(); int f2(); }
int N2::f1() // Используем квалификатор для того, чтобы указать, что функция f1 объявлена // в пространстве имён N2, а не является глобальной функцией.
{ return f2() + N1::g(); } // Т.к. функция f2 является членом пространства имён N2, нет необходимости // использовать квалификатор. Однако без использования квалификатора N1 // функция g считалась бы необъявленной, потому что члены пространства имён N1 // не находятся в области видимости в пространстве N2.

2.2. Объявления using

Если имя часто используется вне пределов своего пространства имён, неудобно каждый раз использовать квалификатор. Для того чтобы избежать этого, используются объявления using, которые вводят локальные синонимы.

namespace N2 { ... using N1::g; } // Теперь функцию g можно использовать без квалификатора

Такие синонимы следует делать как можно более локальными во избежание конфликтов имён.

Единственное объявление using делает видимыми все версии перегруженной функции.

2.3. Директивы using

Директива using делает доступными все имена из пространства имён.

namespace N2 { ... using namespace N1; } // Теперь все функции из пространства имён N1 можно использовать без квалификатора

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

2.4. Неименованные пространства имён

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

namespace { void f() { ... } void g() { ... } ... }

Ясно, что должен существовать способ доступа к членам неименованного пространства имён. Следовательно, неименованное пространство имён подразумевает использование директивы using.

namespace XXX { void f() { ... } void g() { ... } ... } using namespace XXX;

Здесь XXX является некоторым уникальным именем, которое даётся пространству имён компилятором и не известно программисту.

Неименованные пространства имён в разных единицах компиляции различны. Нельзя осуществить доступ из одной единицы компиляции к члену неименованного пространства имён из другой единицы компиляции.

2.5. Поиск имён

Является скорее правилом, чем исключением, что функция с параметром типа Т определяется в том же пространстве имён, что и Т. Поэтому, если функция не найдена в контексте её использования, осуществляется поиск в пространстве имён её параметров.

namespace NS1 { class X { ... }; void f(const X &x); } void g(NS1::X x)
{ f(x); } // Используем NS1::f, т.к. тип параметра функции определён в пространстве имён NS1

По сравнению с использованием явных квалификаторов это правило поиска имён экономит время при вводе программы и не приводит к «загрязнению» пространства имён, как это может сделать директива using.

2.6. Псевдонимы пространств имён

Короткие названия пространств имён могут войти в конфликт друг с другом. Однако, длинные названия пространств имён непрактичны при написании реального кода. Эту дилемму можно решить путём создания короткого псевдонима длинного названия пространства имён.

namespace RFBR = Russian_Fund_of_Basic_Researches;

Псевдонимы, кроме того, позволяют пользователю ссылаться на «библиотеку» и в одном единственном объявлении определять реально используемую библиотеку.

namespace Lib = Library_v2r15;

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

2.7. Объединение и отбор

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

namespace One { class Matrix { ... }; class Vector { ... }; ... } namespace Two { class Vector { ... }; class Matrix { ... }; ... } namespace Three { class Set { ... }; class String { ... }; ... }
namespace All { using Three::Set; using namespace One; using namespace Two; using One::Vector; using Two::Matrix; class List { ... }; } // Используем только класс Set из пространства Three // Используем все имена из пространства One // Используем все имена из пространства Two // Разрешение возможных конфликтов в пользу One::Vector // Разрешение возможных конфликтов в пользу Two::Matrix // Класс, не входящий в пространства имён One, Two и Three

Имена, явно объявленные в пространстве имён, включая имена, объявленные с помощью объявлений using, имеют приоритет по отношению к именам, сделанным доступными при помощи директив using. Поэтому конфликты будут разрешать в пользу One::Vector и Two::Matrix. Кроме того, All::List будет использоваться по умолчанию, независимо от того, имеется ли List в пространстве One или пространстве Two.

3. Компоновка

3.1. Многофайловые программы

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

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

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

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

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

// file1.cpp int x = 1; int b = 0; extern int c; // file2.cpp int x; extern double b; extern int c;

В этом примере содержатся три ошибки:

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

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

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

// file1.cpp inline int f(int i) { return i; } // file2.cpp inline int f(int i) { return i + 1; }

По умолчанию const и typedef подразумевают внутреннюю компоновку. Поэтому следующий пример допустим, хотя может привести к ошибкам.

// file1.cpp typedef int T; const int x = 7; // file2.cpp typedef void T; const int x = 8;

Глобальные переменные, которые являются локальными для одной единицы компиляции, являются типичным источником ошибок, и их лучше избегать. Для обеспечения согласованности лучше помещать глобальные const, typedef и inline только в заголовочные файлы.

Можно заставить константу компоноваться внешним образом путём её явного объявления с помощью ключевого слова extern.

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

В программах на С и старых программах на C++ ключевое слово static используют (что приводит к путанице) для указания «использовать внутреннюю компоновку». Лучше не использовать static кроме как внутри функций и классов.

3.2. Заголовочные файлы

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

Практическое правило гласит, что заголовочный файл может содержать:

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

3.3. Правило одного определения

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

С практической точки зрения это означает, что должно существовать ровно одно определение, например, класса, находящееся где-то в одном файле. К сожалению, правило языка не может быть таким простым. Определение класса может быть включено в два исходных файла. Хуже того, концепция «файла» не является частью определения языка C++.

Следовательно, правило стандарта, говорящее о том, что должно существовать уникальное определение класса, шаблона и т.д. должно быть изложено в более сложной форме. Это правило называют «правилом одного определения» (One-Definition Rule, ODR). А именно, два определения класса, шаблона или встроенной функции приемлемы в качестве определения одной и той же сущности тогда и только тогда, когда:

  1. они находятся в различных единицах компиляции;
  2. они идентичны лексема за лексемой;
  3. значения лексем одинаковы в обеих единицах компиляции.

Приведём примеры трёх способов нарушения правила ODR.

// file1.cpp
struct S1 { int a; char b; };
struct S1 { int a; char b; };

// Ошибка – нельзя определить структуру дважды в одной единице компиляции
// file1.cpp
struct S2 { int a; char b; };
// file2.cpp
struct S2 { int a; char bb; };

// Ошибка – имена членов определениях различны
// file1.cpp
typedef int X; struct S3 { X a; char b; };
// file2.cpp
typedef char X; struct S3 { X a; char b; }; // Ошибка – смысл X в файлах различен

Проверка согласованности определений классов в различных единицах компиляции, как правило, находится вне пределов возможностей большинства реализаций C++. Как следствие, нарушение правила ODR может являться источником очень тонких ошибок. К сожалению, техника помещения совместно используемых определений в заголовочные файлы с последующим их включением в исходный файл не предохраняет от последней формы нарушения правила. Локальные typedef и макросы могут изменить смысл включаемых объявлений. Наилучшей защитой от подобных проблем является создание как можно более самодостаточных заголовочных файлов.

3.4. Стражи включения

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

Есть два возможных решения этой проблемы:

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

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

// file1.h #ifndef FILE1_H #define FILE1_H ... #endif

Содержимое файла между #ifndef и #endif игнорируется компилятором, если FILE1_H определено. В этом случае при первом просмотре файла во время компиляции его содержимое читается, и FILE1_H определяется. Если файл будет включён в единицу компиляции второй раз, его содержимое игнорируется. Метод не идеальный, но он работает и широко распространён в программах на С и C++. Все стандартные заголовочные файлы содержат стражей включения.

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

// file2.h #define FILE2_H ... // file2.cpp #if !defined(FILE2_H) #include "file2.h" #endif

Второй вариант немного быстрее, зато первый вариант является самодостаточным.