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

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

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

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

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

Если взять простой скрипт, в котором Мастер MQL создал единственную пустую функцию OnStart, то в нем будет только 2 области: глобальная и локальная (внутри тела функции OnStart, хоть она и пуста). Это иллюстрирует следующий скрипт с помощью комментариев.

// GLOBAL SCOPE
void OnStart()
{
  // LOCAL SCOPE "OnStart"
}
// GLOBAL SCOPE

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

Мы можем описать в начале файла переменные (например, i, j, k), и они станут глобальными.

// GLOBAL SCOPE
int ijk;
void OnStart()
{
  // LOCAL SCOPE "OnStart"
}
// GLOBAL SCOPE

Глобальные переменные создаются сразу после запуска MQL-программы в терминале и существуют на протяжении всего периода, пока программа выполняется.

Программист может записывать и считывать содержимое глобальных переменных из любого места программы.

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

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

Локальная область внутри каждой функции принадлежит только ей: внутри OnStart — одна локальная область, внутри Greeting — другая, её собственная, отличная и от локальной области OnStart, и от глобальной.

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

Пример описания локальных переменных x, y, z внутри функции OnStart:

// GLOBAL SCOPE
int ijk;
void OnStart()
{
  // LOCAL SCOPE "OnStart"
  int xyz;
}
// GLOBAL SCOPE

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

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

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

void OnStart()
{
  // LOCAL SCOPE "OnStart"
  int xyz;
  
  { 
    // LOCAL SUBSCOPE 1
    int p;
    // ... use p for task 1
  }
  
  { 
    // LOCAL SUBSCOPE 2
    // y = p; // error: 'p' - undeclared identifier
    int p;    // from now 'p' is declared
    // ... use p for task 2
  }
  
  // p = x; // error: 'p' - undeclared identifier
}

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

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

Вне любого из двух вложенных блоков переменная p неизвестна и потому попытка обратиться к ней из общего блока функции приводит к ошибке компиляции ("неизвестный идентификатор" — "undeclared identifier").

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

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

Оба варианта проблемы проиллюстрированы в примере: попробуйте включить любую из строк с инструкциями p = x и y = p и откомпилировать исходный код.

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