Компоненты — это единицы развертывания. Они представляют наименьшие сущности, которые можно развертывать в составе системы. В Java — это jar-файлы. В Ruby — gem-файлы. В .Net — библиотеки DLL. В компилирующих языках — комплексы двоичных файлов. В интерпретирующих языках — комплексы файлов с исходным кодом. Во всех языках — элементарная единица развертывания.
Компоненты могут объединяться в один выполняемый файл, собираться в один архив, например файл .war, или развертываться независимо, как отдельные плагины, загружаемые динамически, такие как файлы .jar, .dll или .exe. Но независимо от способа развертывания, правильно спроектированные компоненты всегда сохраняют возможность независимого развертывания и, соответственно, могут разрабатываться независимо.
На заре разработки программного обеспечения программисты сами определяли организацию памяти в своих программах. В первых строках кода часто присутствовала инструкция origin, объявлявшая начальный адрес в памяти для загрузки программы.
Взгляните не следующую простую программу для PDP-8. Она состоит из подпрограммы с именем GETSTR, которая принимает ввод с клавиатуры в виде строки и сохраняет его в буфер. В ней также имеется короткий модульный тест для проверки GETSTR.
*200
TLS
START, CLA
TAD BUFR
JMS GETSTR
CLA
TAD BUFR
JMS PUTSTR
JMP START
BUFR, 3000
GETSTR, 0
DCA PTR
NXTCH, KSF
JMP -1
KRB
DCA I PTR
TAD I PTR
AND K177
ISZ PTR
TAD MCR
SZA
JMP NXTCH
K177, 177
MCR, -15
Обратите внимание на команду *200 в начале программы. Она сообщает компилятору, что сгенерированный им код будет загружаться в память, начиная с адреса 2008 (в восьмеричной системе счисления).
Такой способ программирования чужд современным программистам. Они редко задумываются, в какую область памяти будет загружаться программа. Но давным-давно это было одним из первых решений, которые программист должен был принять. В ту пору программы были неперемещаемыми.
Как осуществлялся доступ к библиотечным функциям в те дни? Это иллюстрирует предыдущий пример. Программисты включали исходный код библиотек в свои программы и компилировали их как одно целое. Библиотеки хранились в исходном коде, а не в двоичном.
Проблема такого подхода в ту эпоху состояла в том, что устройства были медленными, а память стоила дорого, и поэтому ее объем был ограничен. Компиляторам требовалось выполнить несколько проходов по исходному коду, но памяти было недостаточно, чтобы уместить в ней весь исходный код. Как следствие, компилятору приходилось несколько раз читать исходный код, используя медленные устройства.
Это требовало много времени, и чем больше была библиотека, тем дольше работал компилятор. Компиляция большой программы могла длиться часами.
Чтобы сократить время компиляции, программисты отделяли исходный код библиотек от приложений. Компилировали эти библиотеки отдельно и загружали готовый двоичный код в известный адрес — например, 20008. Они создавали таблицу символов для библиотеки и компилировали ее со своим прикладным кодом. Когда им требовалось запустить приложение, они загружали двоичный код библиотеки, а затем загружали приложение. Память была организована, как показано на рис. 12.1.
Рис. 12.1. Организация памяти на заре программирования
Такой прием прекрасно работал, пока приложение умещалось в объем между адресами 00008 и 17778. Но если размер приложения оказывался больше отведенного адресного пространства, программисту приходилось разбивать программу на два сегмента, располагавшихся по обеим сторонам сегмента с библиотекой (рис. 12.2).
Рис. 12.2. Деление приложения на два сегмента
Очевидно, что так не могло продолжаться вечно. Добавляя новые функции в библиотеку, программисты выходили за границы объема, отведенного для нее, и были вынуждены выделять дополнительный сегмент (в этом примере начинающийся с адреса 70008). Такое фрагментирование программ и библиотек продолжалось с увеличением объемов памяти в компьютерах.
Совершенно понятно, что с этим нужно было что-то делать.
Решение проблемы было найдено в создании перемещаемого двоичного кода. Идея была проста. Компилятор изменили так, чтобы он производил двоичный код, который мог перемещаться в памяти специальным загрузчиком. Этому загрузчику можно было сообщить, с какого адреса тот должен загрузить перемещаемый код. Код снабжался флагами, сообщающими загрузчику, какие части загружаемых данных нужно изменить, чтобы загрузить в выбранный адрес. Обычно это означало простое добавление начального адреса к любым ссылкам в двоичном коде.
Теперь программист мог указать загрузчику начальный адрес для загрузки библиотеки и адрес для загрузки приложения. Фактически загрузчик мог принять несколько фрагментов двоичного кода и просто загрузить их в память друг за другом, корректируя адреса ссылок в них. Это позволило программистам загружать только необходимые функции.
В компилятор также была встроена возможность, позволяющая генерировать имена функций в виде метаданных. Если программа вызывала библиотечную функцию, компилятор определял ее имя как внешнюю ссылку. Если программа объявляла библиотечную функцию, компилятор определял ее имя как внешнее определение. После этого, зная адреса загрузки функций, загрузчик связывал внешние ссылки с внешними определениями.
Так родился связывающий загрузчик.
Связывающий загрузчик дал программистам возможность делить свои программы на сегменты, компилируемые и загружаемые по отдельности. Такое решение оставалось жизнеспособным, пока относительно небольшие программы компоновались с относительно небольшими библиотеками. Однако в конце 1960-х — начале 1970-х годов программисты стали более честолюбивыми и их программы значительно выросли в размерах.
В результате связывающий загрузчик оказался слишком медлительным. Библиотеки хранились на устройствах с медленным доступом, таких как накопители на магнитной ленте. Даже дисковые устройства в ту пору были слишком медленными. Связывающие загрузчики должны были читать с этих медленных устройств десятки, а то и сотни библиотек в двоичном коде и корректировать внешние ссылки. С увеличением размеров программ и количества функций в библиотеках связывающему загрузчику часто требовалось больше часа, чтобы загрузить программу.
В конечном итоге загрузка и компоновка (связывание) были разделены на два отдельных этапа. Программисты выделили самый медленный этап — этап компоновки (связывания) — в отдельное приложение, получившее название компоновщик, или редактор связей (linker). В результате работы компоновщика получался скомпонованный и перемещаемый двоичный код, который загружался загрузчиком очень быстро. Это позволило программистам создавать выполняемый код, используя медленный компоновщик, который можно было быстро загрузить в любой момент.
Затем наступили 1980-е годы. Программисты писали программы на высокоуровневых языках, таких как C. С ростом их амбиций росли и создаваемые ими программы. Программы с сотнями и тысячами строк кода стали обычным делом.
Модули с исходным кодом в файлах .c компилировались в файлы .o и затем передавались компоновщику для создания выполняемых файлов, которые можно было быстро загрузить. Компиляция каждого отдельного модуля выполнялась относительно быстро, но компиляция всех модулей порой требовала значительного времени. Для корректировки связей компоновщику могло потребоваться еще больше времени. Во многих случаях длительность этого процесса снова стала достигать часа и даже больше.
Казалось, что программисты обречены всю жизнь ходить по кругу. Все усилия, сделанные в 1960-х, 1970-х и 1980-х годах для достижения высокой скорости рабочего процесса, пошли прахом из-за амбиций программистов, создававших большие программы. Казалось, нет выхода из замкнутого круга многочасового ожидания. Этап загрузки выполнялся быстро, но этап компиляции/компоновки оставался узким местом.
Мы на практике испытывали действие закона Мерфи о размере программы:
Любая программа растет, пока не заполнит все доступное время на компиляцию и компоновку.
Но Мерфи не был единственным на этом ринге. Вскоре появился Мур, и в конце 1980-х годов они схлестнулись. Мур вышел из этой схватки победителем. Диски начали уменьшаться в размерах и стали намного быстрее. Компьютерная память стала настолько дешевой, что большую часть данных на диске можно было уместить в кэше, в оперативной памяти. Тактовая частота процессоров выросла с 1 до 100 МГц.
К середине 1990-х годов время, необходимое на компоновку, сокращалось быстрее, чем росли наши программы. Во многих случаях время на компоновку сократилось до нескольких секунд. Для маленьких заданий вновь обрела жизнеспособность идея связывающего загрузчика.
Это была эпоха Active-X, динамических библиотек и первых файлов .jar. Компьютеры и периферийные устройства стали настолько быстрыми, что мы снова получили возможность выполнять компоновку (связывание) во время загрузки. Мы можем связать несколько файлов .jar или несколько динамических библиотек за секунды и выполнить получившуюся программу. Так родилась архитектура сменных компонентов (плагинов).
Сегодня мы обычно поставляем файлы .jar или динамические библиотеки как плагины к существующим приложениям. Чтобы создать новую модель для Minecraft, достаточно просто включить свои файлы .jar в определенную папку. Чтобы подключить плагин Resharper к среде разработки Visual Studio, достаточно добавить соответствующие библиотеки DLL.
Динамически связываемые файлы, которые можно подключать во время выполнения, являются программными компонентами в наших архитектурах. Потребовалось 50 лет, но мы достигли такой степени развития, когда архитектура сменных плагинов стала применяться по умолчанию и без титанических усилий, как когда-то.
Мой первый работодатель хранил в шкафу несколько десятков колод перфокарт с исходным кодом библиотек подпрограмм. Когда кто-то писал новую программу, он просто брал требуемую колоду и добавлял ее в конец колоды со своей программой.
В действительности на многих старых ЭВМ использовалась энергонезависимая оперативная память, которая не очищалась при выключении питания. Поэтому мы часто в течение нескольких дней использовали библиотеку, загруженную однажды.
Закон Мура: быстродействие компьютеров, объем памяти и компактность удваиваются каждые 18 месяцев. Этот закон удерживался с 1950-х до 2000 годов, но затем утратил силу, по крайней мере в отношении роста тактовой частоты.