Пакеты: спецификация и тело

Пакет — единица модульности в Ada: он отделяет «что» (видимый интерфейс) от «как» (скрытая реализация).

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

Если процедуры и функции — это глаголы программы, то пакеты — её существительные: модули, библиотеки, абстрактные типы данных. Когда в 1977–1983 годах формировался язык, требование Министерства обороны США звучало почти как инженерный манифест: программы должны собираться из переиспользуемых, независимо компилируемых, проверяемых на стыках компонентов. Ответом стал пакет — конструкция, опередившая своё время. Пространства имён C++, пакеты Java, модули C++20 — всё это позднейшие воплощения идеи, которую Ada выразила ещё в 1983 году с почти окончательной ясностью.

Зачем разделять спецификацию и тело

Главная мысль авторов языка: пользователь компонента не должен зависеть от того, как компонент устроен внутри. Он должен зависеть только от контракта — набора имён, типов параметров и обещаний о поведении. Если завтра внутреннюю реализацию полностью перепишут — заменят массив на хеш-таблицу, рекурсию на цикл, — код пользователя не должен ни сломаться, ни даже перекомпилироваться иначе.

В Ada это разделение возведено в ранг синтаксиса. Спецификация пакета (расширение .ads в GNAT, Ada specification) содержит только объявления, видимые снаружи. Тело (.adb, Ada body) содержит реализацию. Компилятор проверяет, что тело в точности реализует обещанное спецификацией: каждая объявленная подпрограмма обязана получить тело, а сигнатуры обязаны совпасть до последнего параметра. Это не соглашение и не линтер — это закон языка.

-- Файл stack.ads — СПЕЦИФИКАЦИЯ (контракт)
package Stack is
   procedure Push (Item : in Integer);
   function  Pop return Integer;
   function  Is_Empty return Boolean;
   Overflow  : exception;
   Underflow : exception;
end Stack;

Тот, кто пишет with Stack;, видит ровно эти четыре имени и два исключения. Он не знает и не должен знать, чем хранится содержимое — массивом фиксированного размера, динамическим вектором или связным списком. Тело даёт ответ, но прячет его:

-- Файл stack.adb — ТЕЛО (реализация)
package body Stack is
   Max  : constant := 100;
   Data : array (1 .. Max) of Integer;
   Top  : Natural := 0;   -- индекс вершины, 0 = пусто

   procedure Push (Item : in Integer) is
   begin
      if Top >= Max then
         raise Overflow;
      end if;
      Top := Top + 1;
      Data (Top) := Item;
   end Push;

   function Pop return Integer is
      Result : Integer;
   begin
      if Top = 0 then
         raise Underflow;
      end if;
      Result := Data (Top);
      Top := Top - 1;
      return Result;
   end Pop;

   function Is_Empty return Boolean is
   begin
      return Top = 0;
   end Is_Empty;
end Stack;

Обратите внимание: переменные Max, Data и Top объявлены в теле, а не в спецификации. Поэтому из внешнего кода к ним нет доступа вообще. Это не «приватные поля, к которым можно подобраться рефлексией» — снаружи этих имён попросту не существует в области видимости. Инкапсуляция здесь физическая, а не декоративная.

Зависимость от интерфейса, а не от реализации

Раздельная компиляция в Ada устроена так, чтобы зависимости шли по самому тонкому каналу. Когда модуль A пишет with B;, он зависит от спецификации B, но не от её тела. Практическое следствие огромно: вы можете изменить тело Stack (увеличить Max, переписать Push на другой алгоритм), перекомпилировать только stack.adb — и ни один клиентский модуль не потребует перекомпиляции, потому что его представление о Stack не изменилось.

Это прямо противоположно подходу заголовочных файлов C, где #include механически вставляет текст, и любая правка приватных полей структуры в заголовке вынуждает пересобрать всех, кто заголовок включил. Ada отделяет «контракт компиляции» от «реализации» на уровне самой модели сборки, и менеджеры сборки (gprbuild) опираются на это, пересобирая ровно необходимый минимум.

Видимость и оператор use

По умолчанию обращение к содержимому пакета — квалифицированное: Stack.Push (10). Это многословно, зато читателю всегда ясно, откуда взялось имя. Директива use Stack; делает имена пакета непосредственно видимыми, позволяя писать просто Push (10). Опытные инженеры применяют use сдержанно: глобальный use на большой пакет возвращает проблему «откуда это имя?». Компромисс — use type (открыть только операторы конкретного типа, например + и =) и локальный use внутри одной подпрограммы.

with Stack;
with Ada.Text_IO;
procedure Demo is
begin
   Stack.Push (10);            -- квалифицированно: видно происхождение
   Stack.Push (20);
   Ada.Text_IO.Put_Line (Integer'Image (Stack.Pop));  -- 20
end Demo;

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

Спецификация пакета — это, по сути, отдельная единица компиляции со своей семантической проверкой. Когда компилятор обрабатывает with Stack;, он читает заранее построенное «дерево» спецификации (в GNAT — из файлов .ali, Ada library information) и сверяет каждое обращение клиента с объявленным интерфейсом. Тело компилируется независимо и обязано совпасть со спецификацией: это проверяется при компиляции тела, а не при сборке клиента.

Важная тонкость — состояние пакета. Переменные Data и Top существуют в единственном экземпляре на всю программу: это глобальное состояние, спрятанное за интерфейсом. Такой пакет с состоянием иногда называют «пакет-синглтон» или «абстрактный объект» (в отличие от «абстрактного типа», который мы изучим с private-типами). У него есть плюсы (простота) и минус: экземпляр ровно один, второй стек так не создать. Когда нужно много стеков, переходят к приватным типам — тема следующего урока.

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

Чтобы оценить, насколько дальновидным было решение Ada, полезно посмотреть на историю модульности в других языках. В начале 1980-х доминировал C, где «модулем» служила пара из заголовочного файла (.h) и файла реализации (.c), связанных не языком, а текстовым препроцессором и соглашениями. Компилятор C не понимал понятия «модуль» вовсе: #include механически вклеивал текст, а согласованность объявлений и определений никто не гарантировал — рассогласование всплывало в лучшем случае ошибкой компоновки, в худшем — неопределённым поведением. Pascal в исходном виде вообще не имел отдельной компиляции, и каждая реализация изобретала свои «units» по-своему.

На этом фоне пакет Ada выглядел как явление из будущего: компилятор знал о модуле, проверял стыки, гарантировал реализацию интерфейса. Лишь спустя десятилетия мейнстрим начал догонять. Пространства имён C++ (1998) решали проблему конфликта имён, но не отделяли интерфейс от реализации — заголовки по-прежнему раскрывали приватные поля. Пакеты Java (1995) дали иерархию имён, но смешали её с механизмом загрузки классов. И только модули C++20 — спустя сорок лет после Ada — принесли в мир C++ настоящее разделение интерфейса и реализации на уровне языка. Изучая пакеты Ada, вы изучаете не архаику, а зрелую форму идеи, к которой индустрия шла полвека.

Практическое следствие для архитектуры

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

Стиль: как организуют пакеты в реальных проектах

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

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

Частые ошибки

  • Объявить переменную в спецификации «чтобы было видно». Любой объект из видимой части спецификации становится глобально доступным для записи всем клиентам — это пробивает инкапсуляцию насквозь. Состояние держите в теле.
  • Забыть тело объявленной подпрограммы. Если объявлен function Pop return Integer;, а в теле его нет, компилятор откажется собирать тело с ошибкой о неполной реализации — это ошибка компиляции, а не runtime «метод не найден».
  • Несовпадение сигнатур. Если в теле написать procedure Push (X : in Integer) вместо Item, имена формальных параметров обязаны совпасть со спецификацией — иначе ошибка.
  • Глобальный use на всё подряд. Технически работает, но в крупном проекте превращает чтение в детектив. Предпочитайте квалификацию или use type.

Итоги

  • Пакет состоит из спецификации (видимый контракт, .ads) и тела (скрытая реализация, .adb).
  • Клиент зависит только от спецификации: изменение тела не вынуждает перекомпиляцию клиентов.
  • Объекты, объявленные в теле, физически недоступны снаружи — это настоящая инкапсуляция.
  • Компилятор гарантирует, что тело реализует спецификацию точно: пропуск подпрограммы — ошибка компиляции.
  • use сокращает запись, но применяйте его сдержанно; use type — разумный компромисс.
Проверьте себя
1. Что произойдёт, если в спецификации пакета объявлена функция, но в теле её реализация отсутствует?
AНичего, функция просто будет недоступна
BОшибка компиляции тела: тело обязано реализовать весь интерфейс спецификации
CОшибка только при вызове функции во время выполнения
DКомпилятор сам сгенерирует пустую заглушку
2. Почему переменные состояния стека объявлены в теле пакета, а не в спецификации?
AТак быстрее компилируется
BОбъявление в спецификации сделало бы их глобально доступными клиентам, нарушив инкапсуляцию
CВ спецификации вообще нельзя объявлять переменные
DЭто лишь стилевое предпочтение без последствий