Инструкции объявления/определения

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

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

Инструкцию объявления переменной можно в обобщенном виде представить следующим образом:

[модификаторы] тип идентификатор
   [= выражение_инициализации];

Для массива она выглядит так:

[модификаторы] тип идентификатор [ [размер_1]] [ [размер_N] ](3)
   [ = { список_инициализации } ] ;

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

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

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

тип идентификатор ( [список_аргументов] )
{
   [инструкции]
}

Тип, идентификатор и список аргументов составляют заголовок функции.

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

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

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

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

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

Например, следующее описание переменной i в начале функции OnStart гарантирует, что такая переменная будет создана с указанным начальным значением (0), как только функция получит управление (т.е. терминал вызовет её, потому что это главная функция скрипта).

void OnStart()
{
   int i = 0;
   Print(i);
   
   // error: 'j' - undeclared identifier
   // Print(j); 
   int j = 1;
}

Благодаря декларации в первой инструкции, переменная i известна и доступна в последующих строках функции, в частности, во второй строке с вызовом функции Print, которая выводит содержимое переменной в журнал.

Переменная j, описанная в последней строке функции, будет создана непосредственно перед завершением функции (это, конечно, бессмысленно, но наглядно). Поэтому эта переменная не известна во всех более ранних строках этой функции. Попытка вывести j в журнал с помощью закомментированного вызова Print закончится ошибкой компиляции "неизвестный идентификатор" ("undeclared identifier").

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

Создание автоматических элементов происходит в порядке исполнения инструкций объявления (сначала i, потом j). Уничтожение выполняется в обратном порядке (сначала j, потом i).

Если переменная описана без инициализации и начинает использоваться в последующих инструкция (например, справа от знака '=') без предварительной записи в неё осмысленного значения, компилятор выдает предупреждение: "вероятно используется неинициализированная переменная" ("possible use of uninitialized variable").

void OnStart()
{
   int ip;
   i = p// warning: possible use of uninitialized variable 'p'
}

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

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

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

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

int Init(const int v)
{
   Print("Init: "v);
   return v;
}

Функция Init принимает единственный параметр v целого типа int, значение которого возвращает в вызывающий код (инструкция return).

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

int k = Init(-1);
int m = Init(-2);

Значение переданного аргумента попадает в переменные k и m посредством вызова функции и возврата из неё. Однако внутри Init мы дополнительно выводим значение с помощью Print, и таким образом сможем отследить, как создаются переменные.

Обратите внимание, что мы не можем использовать функцию Init в инициализации глобальных переменных выше её определения. Если попробовать перенести декларацию переменной k над описание Init, получим ошибку "'Init' - неизвестный идентификатор". Это ограничение срабатывает только для инициализации глобальных переменных, потому что функции тоже определяются глобально, а компилятор строит список таких идентификаторов за один проход. Во всех остальных случаях порядок определения функций в коде не важен, потому что компилятор сначала их все регистрирует во внутреннем списке, а потом взаимно увязывает их вызовы из блоков. В частности, вы можете перенести всю функцию Init и декларацию глобальных переменных k и m ниже функции OnStart — это ничего не сломает.

Внутри функции OnStart опишем с использованием Init еще несколько переменных: локальные i и j, а также статическую n. Для простоты все переменные получают уникальные значения, чтобы их можно было отличить.

void OnStart()
{
   Print(k);
   
   int i = Init(1);
   Print(i);
   // error: 'n' - undeclared identifier
   // Print(n);
   static int n = Init(0);
   // error: 'j' - undeclared identifier
   // Print(j);
   int j = Init(2);
   Print(j);
   Print(n);
}

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

Запустим скрипт и получим следующий журнал:

Init: -1
Init: -2
-1
Init: 1
1
Init: 0
Init: 2
2
0

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

Если переменная определена, но нигде не используется, компилятор выдаст предупреждение "переменная 'имя' не используется" ("variable 'name' not used"). Это признак потенциальной ошибки программиста.

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

Определить функцию внутри функции также нельзя. Следующий код не скомпилируется:

void OnStart()
{
   int Init(const int v)
   {
      Print("Init: "v);
      return v;
   }
   int i = 0;
}

Компилятор сгенерирует ошибку: "определение функций разрешено только на глобальном уровне, в классах или пространствах имен" ("function declarations are allowed on global, namespace or class scope only").