Подпрограммы и режимы параметров in/out/in out

Подпрограммы Ada: процедуры и функции, и самое важное — режимы параметров in, out, in out, которые делают направление передачи данных видимым прямо в объявлении. Почему это документация, которую невозможно проигнорировать.

Режим параметра (in / out / in out) в Ada явно указывает, читается ли аргумент подпрограммой, записывается ли он, или и то и другое — направление потока данных становится частью контракта.

Две разновидности подпрограмм

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

  • Процедура (procedure) — выполняет действия, не возвращает значения. Вызывается как самостоятельный оператор.
  • Функция (function) — вычисляет и возвращает значение. Вызывается внутри выражения.

Это разделение отражает разные намерения. Процедура — это «сделай что-то» (напечатай, измени состояние). Функция — это «вычисли и верни» (посчитай площадь, найди максимум). Вот простая функция:

function Square (X : Integer) return Integer is
begin
   return X * X;
end Square;

В заголовке после параметров стоит return Integer — тип возвращаемого значения, обязательный для функции. Внутри тела оператор return возвращает результат. Вызывается функция в выражении: Y := Square (5) + 1; даст 26. Процедура же объявляется без return-типа и вызывается отдельно: Print_Report (Data);.

Режимы параметров: сердце темы

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

РежимСмысл
inтолько чтение: подпрограмма читает значение, не меняет его (режим по умолчанию)
outтолько запись: подпрограмма устанавливает значение для вызывающего
in outчтение и запись: подпрограмма и читает, и изменяет

Режим пишется перед типом параметра. Посмотрим на процедуру, использующую все три:

procedure Adjust (Base   : in     Integer;     -- читаем
                  Result :    out Integer;     -- пишем результат
                  Counter : in out Integer) is  -- читаем и увеличиваем
begin
   Result  := Base * 2;       -- устанавливаем выходной параметр
   Counter := Counter + 1;    -- читаем старое и пишем новое
end Adjust;

Прелесть в том, что, читая только заголовок, вы сразу видите контракт: Base подпрограмма лишь читает, Result — только устанавливает (его прежнее значение не важно), а Counter и читает, и меняет. Эта информация — не комментарий, который может устареть, а часть синтаксиса, которую компилятор проверяет. Если внутри попытаться записать в параметр режима in — например, Base := 5; — это ошибка компиляции: in-параметр доступен только для чтения. Аналогично, забыв установить out-параметр, вы получите предупреждение, ведь вызывающий ждёт от него значение.

Почему режимы — это документация, которую нельзя проигнорировать

Вдумаемся, какую проблему это решает. В языках, где параметры передаются по ссылке или через указатели, глядя на вызов Foo (X), вы не знаете, изменит ли Foo вашу переменную X. Придётся читать тело Foo или надеяться на комментарии. Это источник неожиданностей: функция «втихую» поменяла то, что вы считали неизменным. В Ada такого не бывает. Режим параметра делает направление данных обязательной частью интерфейса. Видя procedure Sort (A : in out Array_Type), вы точно знаете: массив будет изменён. Видя function Length (S : in String) return Natural, вы уверены: строка останется нетронутой. Намерение автора зафиксировано в сигнатуре и проверено компилятором — оно не может разойтись с реальностью. Для больших систем, где подпрограмму вызывают из десятков мест люди, не читавшие её реализацию, это огромный вклад в надёжность.

Параметры функций: традиция чистоты

Исторически в Ada функции принимали параметры только в режиме in — идея в том, что функция должна вычислять значение, а не менять окружение, оставаясь близкой к математической функции (без побочных эффектов). Это поощряет чистый стиль: функция зависит только от входов и возвращает результат, что делает её предсказуемой и легко тестируемой. Современные ревизии языка позволили функциям и другие режимы, но добрый стиль — держать функции «чистыми», а изменения состояния выражать процедурами с явными out/in out. Разделение «функция вычисляет, процедура действует» — полезная дисциплина мышления.

Выражения-функции: краткая форма

Для совсем коротких функций Ada 2012 ввела компактную форму — выражение-функцию, где тело умещается в одно выражение в скобках без begin/end:

function Double (X : Integer) return Integer is (X * 2);
function Is_Even (X : Integer) return Boolean is (X mod 2 = 0);

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

Как работает под капотом передача параметров

Интересный момент: режимы in/out/in out описывают семантику (что можно делать с параметром), но не диктуют жёстко механизм передачи (по значению или по ссылке) — компилятор волен выбрать эффективный способ. Для маленьких скалярных in-параметров обычно передаётся копия (по значению), для больших структур — ссылка (чтобы не копировать гигабайты), но при этом in-параметр всё равно защищён от изменения. Эта свобода позволяет Ada быть и безопасной, и быстрой: вы думаете в терминах направления данных, а компилятор подбирает оптимальную реализацию. Важно, что гарантии семантики при этом не нарушаются: in всегда только чтение с точки зрения программиста, чем бы это ни было реализовано внутри. Программист рассуждает о потоке данных абстрактно и надёжно, а низкоуровневые решения остаются за компилятором — снова характерное для Ada сочетание высокоуровневой ясности и эффективности.

Соберём всё в маленькой осмысленной программе с процедурой и функцией:

with Ada.Text_IO;  use Ada.Text_IO;

procedure Stats_Demo is

   function Average (A, B : in Integer) return Integer is (
      (A + B) / 2);

   procedure Describe (Value : in Integer; Label : in String) is
   begin
      Put_Line (Label & ":" & Integer'Image (Value));
   end Describe;

begin
   Describe (Average (10, 20), "Среднее");
end Stats_Demo;

Вывод:

Среднее: 15

Контракты подпрограмм: предусловия и постусловия как часть сигнатуры

Режимы параметров — это лишь начало того, как Ada документирует контракт подпрограммы прямо в её объявлении. Ada 2012 пошла дальше и позволила записать в сигнатуре что подпрограмма ожидает на входе и что гарантирует на выходе — через предусловия (Pre) и постусловия (Post). Это превращает заголовок из простого описания параметров в полноценный проверяемый договор:

function Divide (Numerator, Denominator : Integer) return Integer
   with Pre  => Denominator /= 0,
        Post => Divide'Result * Denominator <= Numerator + Denominator;

procedure Push (S : in out Stack; Item : Integer)
   with Pre  => not Is_Full (S),
        Post => not Is_Empty (S);

Предусловие Pre => Denominator /= 0 заявляет: вызывать Divide с нулевым знаменателем запрещено — это ответственность вызывающего. Постусловие через атрибут 'Result описывает свойство результата. (Стрелки => и оператор <= в HTML экранируются.) У процедуры Push контракт читается как спецификация структуры данных: «нельзя добавлять в полный стек, после добавления стек непуст». Если включена проверка контрактов, нарушение предусловия или постусловия в рантайме поднимает исключение Assertion_Error точно в момент нарушения — а в SPARK эти же контракты доказываются статически для всех входов.

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

Это кульминация идеи, к которой Ada шла с самого начала: максимум знания о подпрограмме — в её интерфейсе, проверяемо и формально. Контракты — это исполняемая документация и одновременно первый шаг к формальной верификации. Именно они делают переход от обычной Ada к SPARK естественным: вы уже написали Pre/Post для ясности, а инструмент gnatprove превращает их в математически доказанные гарантии. Для систем, где ошибка недопустима, возможность доказать, что подпрограмма ведёт себя строго по контракту при любых допустимых входах, бесценна — и начинается она здесь, с привычки точно описывать ожидания и обещания прямо в сигнатуре.

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

Частые ошибки и заблуждения

  • Пытаться изменить in-параметр. Он только для чтения; запись в него — ошибка компиляции. Для изменения нужен in out или out.
  • Читать out-параметр до записи. Его входное значение не определено; сначала установите, потом, если надо, читайте — либо используйте in out.
  • Путать процедуру и функцию. Функция обязана возвращать значение и вызывается в выражении; процедура не возвращает и вызывается как оператор.
  • Считать режим комментарием. Режим — проверяемая часть сигнатуры, документирующая направление данных; он не может «соврать», в отличие от комментария.
  • Менять состояние внутри функции «по привычке». Хороший стиль — держать функции чистыми (только in), а изменения выражать процедурами с out/in out.

Итоги

  • Процедуры выполняют действия и не возвращают значения; функции вычисляют и возвращают значение через return.
  • Каждый параметр имеет режим: in (чтение, по умолчанию), out (запись), in out (и то и другое).
  • Режим делает направление потока данных видимым в сигнатуре и проверяемым компилятором — это документация, которую нельзя проигнорировать.
  • Хороший стиль — держать функции чистыми (только in), а изменения состояния выражать процедурами; выражения-функции дают краткую форму.
  • Режимы описывают семантику, а механизм передачи (значение/ссылка) выбирает компилятор ради эффективности, не нарушая гарантий.
Проверьте себя
1. Что гарантирует режим параметра in?
AПараметр можно менять
BПараметр доступен только для чтения; запись в него — ошибка компиляции
CПараметр всегда копируется
DПараметр обязателен для функции
2. Чем процедура отличается от функции в Ada?
AНичем
BФункция возвращает значение и вызывается в выражении; процедура не возвращает и вызывается как оператор
CПроцедура быстрее
DФункция не имеет параметров
3. Почему режим параметра называют документацией, которую нельзя проигнорировать?
AЕго пишут в комментарии
BОн делает направление данных видимым в сигнатуре и проверяется компилятором, поэтому не может разойтись с реальностью
CЕго можно отключить
DОн не влияет на код