FAIL (the browser should render some flash content, not this).
  • Главная
  • ИТ-аутсорсинг
  • Сайтостроение
  • Портфолио
  • Контакты
  • Информация



Что плохого в глобальных переменных?

  Переменные в программе бывают локальными (это когда они объявлены в «чём-то»: процедура, метод, объект и т.п.) и глобальными (это когда они объявлены в самой программе, модуле — на самом верхнем уровне без вложения во «что-то»). Параметры процедур, функций и методов также относятся к локальным переменным. Что лучше использовать? Стандартный совет при использовании глобальных переменных: держитесь от них подальше, используя их только тогда, когда без них не обойтись. Почему? Казалось бы, «ведь это действительно иногда необходимо или же просто удобней, а кроме того — быстрее в работе». Давайте посмотрим!

  Передача параметров

  Вот два варианта:

1
2
3
4
5
// Локальные переменные:
procedure Calc(const Matrix: TMatrix; out Result: TMatrix);
begin
  // работаем с Matrix и Result, к примеру, делаем какое-то матричное преобразование
end;

  и:

1
2
3
4
5
6
7
8
9
// Глобальные переменные:
var
  Matrix: TMatrix;
  Result: TMatrix;
 
procedure Calc;
begin
  // работаем с Matrix и Result
end;

  Что лучше? Тут даже думать не надо. Попробуйте написать штук десять разных подпрограмм вроде этой Calc — и не запутаться при этом в параметрах! Ведь вам придётся заводить переменные, которые передаются только в Calc, и не перепутать их с теми, которые передаются в Calc2. А когда вы создаёте новую функцию Funcenstein — вам лучше бы создать новые переменные для неё, иначе Funcenstein не сможет вызвать Calc или Calc2 — ведь они используют переменные с одинаковыми именами! Код вроде второго способен написать только человек, которому «сказали сделать процедуру», а передавать параметры он не умеет — вот он и написал «как сумел», по старинке: глобальными переменными. Ведь это работает.

  Вычисления внутри подпрограммы

  Тут тоже не так уж сложно (если подумать). Согласитесь, что код:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
procedure TForm1.Button1Click(Sender: TObject);
var
  X: Integer;
  Y: Integer;
  S: String;
begin
  // Работаем с X, Y, S
end;
 
procedure TForm1.Button2Click(Sender: TObject);
var
  X: Integer;
  S: String;
  H: String;
begin
  // Работаем с X, S, H
end;

  Намного лучше чем:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var
  X: Integer;
  Y: Integer;
  S: String;
  H: String;
 
procedure TForm1.Button1Click(Sender: TObject);
begin
  // Работаем с X, Y, S
end;
 
procedure TForm1.Button2Click(Sender: TObject);
begin
  // Работаем с X, S, H
end;

  Второй код способен написать лишь школьник/студент, только-только начавший программировать — просто потому, что он вообще не различает эти два случая, и вставил объявление переменных «в первое попавшееся место». Почему первый код лучше? Даже в таком небольшом примере уже видно, что локальные переменные лучше изолированы, чем глобальные. Что это значит? Это значит, что изменения в переменной X в методе Button1Click не влияют на переменную X в методе Button2Click. Хорошо это или плохо? Неопытный программист может сказать, что общедоступность глобальных переменных — это хорошо: «ведь это простой способ передать данные». Но если чуть подумать, то это оказывается не так уж здорово — и вот почему:

  • Масштабирование

  • Побочные эффекты

  • Проблемы инициализации

  1. Масштабирование

  Это просто. Когда вы используете глобальные переменные для чего бы то ни было, вы тем самым неявно предполагаете, что это «что-то» может быть только в одном экземпляре. К примеру, использовав глобальные переменные для передачи данных в подпрограмму, вы подразумеваете, что эту подпрограмму можно вызвать только один раз.

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

  2. Побочные эффекты

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

  Предположим, у вас есть функция, результат которой зависит от глобальной переменной. Вы вызываете её, вызываете — но через 10 минут функция начинает возвращать неверные результаты. Что случилось? Ведь на вход вы передаёте ей всё тот же набор параметров? Гм, кто-то поменял значение глобальной переменной… Кто это мог быть? Да кто угодно — ведь глобальная переменная доступна всем. Лучший рецепт при проектировании подпрограмм: сделать так, чтобы результат вашей функции зависел бы только от аргументов. Это идеал, к которому нужно стремиться.

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

  Тут тоже есть простое правило: если функция устанавливает значение глобальной переменной, то это должно быть её единственной задачей, а её название должно явно отражать этот факт (к примеру, там может быть слово Set или Setup). Если вам нужно, скажем, вычислить что-то и сохранить в глобальную переменную — нужно сделать это двумя отдельными действиями.

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

  3. Проблемы инициализации

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

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

  Тестирование

  Вы можете подумать, что это может не иметь к вам никакого отношения — ведь вы пишете очень простую программу, где «даже нет подпрограмм». Почему бы не сделать всё глобальным? Ну, я всегда говорю, что программы очень быстро развиваются, и если сегодня у вас одна ситуация, то это не значит, что завтра она будет такой же. Но я не буду этого говорить. Вместо этого, я рассмотрю случай, про который вы не подумали — и он не включает в себя эволюцию/изменение программы.

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

  В чём же здесь затык? Посмотрите, положим у вас есть код:

1
2
3
4
5
6
7
8
9
10
11
12
unit MatrixCalculations;
 
interface
 
uses
  MyTypes;
 
function DoSomething(const ASource: TMatrix): TMatrix;
 
implementation
 
...

  Как бы вы тестировали этот код? «Ну, я создам новое приложение — это будет мой тест, подключу к нему этот модуль, а потом просто передам массив ASource и проверю результат. Это же просто, да?».

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

  Если говорить научно, то глобальные переменные увеличивают число зависимостей между компонентами. Модульный тест — это просто один из примеров на практике, где этот момент хорошо виден. Искусство написания программ заключается в управлении сложностью — т.е. «делаем вещи максимально простыми». Рост зависимостей этой задаче не способствует.

  Синглтоны

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

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

  • Нет проблем с именами (конфликты имён). Два разных синглтона не пересекаются и не конфликтуют.

  • Нет проблем с инициализацией и перекрытием времени жизни. Синглтон — он всегда один и доступ к своей инициализация контролируются им же.

  • Нет проблем с многопоточным взаимодействием (при дополнительных усилиях).

  • Нужно поддерживать только интерфейс. При глобальной переменной нужно отслеживать весь код по всей программе, который с ней работает. Синглтон инкапсулирует часть работы в себя, предоставляя наружу только ограниченный интерфейс.

  К реализации синглтона есть два основных подхода:

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

  • Наследованием класса от шаблонного. Это уже сложнее.

  Итак, в нашем примере с начинающим разработчиком игр: настройки программы — это должен быть синглтон, а не разрознённый ворох глобальных переменных. Т.е. было:

1
2
3
4
5
6
7
var
  FileName: String;
begin
  ...
  // SaveFolder - это настройка. Скажем, папка для сейвов в играх
  Save(SaveFolder + FileName);
end;

  стало:

1
2
3
4
5
6
7
var
  FileName: String;
begin
  ...
  // Settigns - это синглтон, хранящий настройки программы
  Save(Settings.SaveFolder + FileName);
end;

  Здесь Settings — это функция, реализованная так:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
unit AppSettings;
 
interface
 
uses
  SingletonTemplate; // модуль, содержащий TSingleton от Ins-а
 
type
  TSettings = class(TSingleton)
  protected
    constructor Create; override;
  public
    destructor Destroy; override;
  private
    FSaveFolder: String;
    // ...
  public
    property SaveFolder: String read FSaveFolder;
    // ...и другие настройки программы
 
    // Можно добавить и методы загрузки/сохранения настроек:
    // procedure Save;
    // procedure Load;
    // А можно и не добавлять - тогда вы сделаете это в Create/Destroy объекта TSettings
 
    // ...и другие методы TSettings
  end;
 
function Settings: TSettings;
 
implementation
 
function Settings: TSettings;
begin
  Result := TSettings.GetInstance;
end;
 
constructor TSettings.Create;
begin
  inherited Create;
  // Сюда можно поместить загрузку настроек программы
end;
 
destructor TSettings.Destroy;
begin
  // Сюда можно поместить сохранение настроек программы
  inherited Destroy;
end;
 
end.

  Сделаю ещё замечание — для сильно «нелюбителей» объектного подхода: пусть вы забили на синглтоны и сделали профиль игрока просто глобальными переменными. А потом… потом в вашей игре появляется сплит-скрин (split-screen). Или мультиплейер. Ой. Теперь у вас может быть два или даже больше профилей. Да, только первый профиль содержит полный набор данных, второй и последующих ограничены: там только имя, настройка клавиатуры (для сплит-скрина) и вещи вроде сетевых идентификаторов (для мультиплеера) — тем не менее, это профиль. А весь ваш код, работающий с настройками игрока, теперь нужно переписать. И простой Search&Replace по коду, скорее всего, не поможет. Вам придётся пройтись по всей написанной программе и руками отследить все обращения к профилю.

  А если бы вы писали на объектах? Всё просто: профиль игрока был синглтоном — стал обычным объектом. Вы можете оставить синглтон главного игрока. А все остальные заносятся в массив профилей. Весь ваш код был на объектах и работал, скажем, со синглтоном Profile. Тогда вы просто делаете Profile свойством объекта. К примеру, было:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
type
  TPlayer = class(...)
  ...
    procedure Move;
  ...
  end;
 
...
 
procedure TPlayer.Move;
begin
  // Здесь: Profile - это глобальный синглтон
  case KeyPressed of
    Profile.KeyLeft:
      MoveLeft;
    Profile.KeyRight:
      MoveRight;
    Profile.KeyFire:
      Fire;
  end;
end;

  стало:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
type
  TPlayer = class(...)
  private
    FProfile: TProfile;
  public
  ...
    procedure Move;
  ...
    constructor Create(const AProfile: TProfile);
    property Profile: TProfile read FProfile;
  end;
 
...
 
constructor TPlayer.Create(const AProfile: TProfile);
begin
  ...
  FProfile := AProfile;
end;
 
procedure TPlayer.Move;
begin
  // Здесь: Profile - это свойство TPlayer
  case KeyPressed of
    Profile.KeyLeft:
      MoveLeft;
    Profile.KeyRight:
      MoveRight;
    Profile.KeyFire:
      Fire;
  end;
end;

  Волшебным образом уже написанный код (Move и другие методы объекта «игрок») совершенно не изменился!

  Глобальные константы

  Ещё один случай, когда глобальные переменные обычно оправданы — это, так называемые, переменные-константы. К примеру, всем очевидно, что число Пи должно быть глобальной константой. Вот и переменная, которая инициализируется при запуске программы и остаётся неизменной на протяжении всей жизни программы, вполне может быть сделана глобальной (раз уж основная претензия к глобальным переменным — неконтролируемые изменения). А чтобы не было соблазна её поменять, можно сделать к ней непрямой доступ. Например, было:

1
2
3
4
5
6
7
8
9
10
var
  X: String;
 
implementation
 
...
 
initialization
  X := ...;
end.

  стало:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function X: String;
 
implementation
 
...
 
var
  fX: String;
 
function X: String;
begin
  if fX = '' then
    fX := ...;
  Result := fX;
end;
 
end.

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

  Историческая справка

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

  Замаскированные глобальные переменные

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

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

  Если трактовать заголовок этой секции немного в другом направлении, то можно получить и такой смысл: локальные переменные, притворяющиеся глобальными. Это когда весь ваш (основной) код программы написан в одной большой-большой процедуре — так называемая «волшебная кнопка». В этом случае, хотя формально все ваши переменные — локальны, но работают они как глобальные, со всеми вытекающими из этого минусами. Вариантом этого является случай, когда весь код программы заключён в единственном объекте — т.н. объект-Бог. В обоих случаях решение заключается в реструктуризации кода: выделения подпрограмм, разбивки на классы и т.п.

  Как Delphi учит нас плохому

  Что я действительно ненавижу в Delphi (это полусерьёзно) — так это этот код:

1
2
var
  Form1: TForm1;

  Из-за него каждый начинающий считает, что делать так — это нормально:

1
Form1.Edit1.Text := 'gg';

  Но что не так с переменной? Ведь главная форма всегда одна? Верно. И для этого у нас есть Application.MainForm. И если для главной формы у вас был слабенький аргумент (это же глобальный объект!), то для всех прочих форм у вас нет даже такого аргумента. Более того, этих форм может быть и много. Поэтому, первое, что необходимо сделать в программе — удалить все глобальные переменные форм, оставив, быть может, только главную. Нужна ссылка на форму? Объяви локально. Несколько форм? Используй Screen.Forms или веди учёт форм в списке (пример: многооконный редактор). Примечание: кстати, если в приложении используется MDI, то такой список уже есть — это TForm.MDIChildren.

  Консольные программы и им подобные: быть или не быть?

  Ещё один пример, где часто наблюдаются глобальные переменные — консольные приложения. Этот тип приложений часто представляет собой подход «пишем код прямо в begin/end и всё делаем глобально» — со всеми вытекающими: ни потестировать код, ни запустить в двух экземплярах и так далее. Действовать надо так же, как мы обсуждали выше — реструктуризацией кода: введением подпрограмм, созданием классов.

  Итого

  Изолирование — это одна из ключевых концепций в программировании. Это — один из инструментов уменьшения и контроля сложности программы. Вы всегда должны стараться делать код максимально независящим от другого, а имеющиеся зависимости должны быть выражены как можно более очевидно. Это — один из необходимых компонентов в «сделать код максимально простым». В ООП это называется инкапсуляцией и обычно ассоциируется с private/protected/public. Использование глобальных переменных — это равносильно использованию объектов с одной только секцией public.

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

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

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

  P.S. Эта статья говорит про то, как правильно делать. Понятно, что не всегда есть возможность делать правильно. Не всегда это бывает и нужно. Если надо по быстрому набросать прототип, написать код для проверки гипотезы, либо же код совсем простой и пустяковый — используйте глобальные переменные, вас за это не съедят. В общем, смотреть надо по ситуации. Заранее говорить, что глобальные переменные — зло, в абстрактном вакууме не имеет большого смысла. А когда так говорят — имеют ввиду вполне конкретные ситуации, которых просто большинство: средние и большие программы с прицелом на дальнейшее сопровождение.

  P.P.S. Замечание для тех, кто не умеет вычленять контекст: это статья говорит про мир Windows, Delphi и типичных программ, которые пишутся на Delphi. Я полагаю вас достаточно сообразительными, чтобы «не проецировать на микроконтроллеры речь пиджака о преимуществах XML».