В предыдущей статье мы разобрали C++ — мощный, но сложный инструмент, требующий понимания множества концепций от RAII до шаблонов. Сегодня поговорим о языке, рождённом из усталости от этой сложности.
Go создавался инженерами Google, которые устали ждать по полчаса компиляции C++-кода и разбираться в десяти способах сделать одно и то же. Для вайбкодинга Go представляет особый интерес: его намеренная простота и единообразие означают, что LLM реже «галлюцинирует» экзотические конструкции, а сгенерированный код легче проверить и понять даже без глубокой экспертизы.
В середине двухтысячных годов в калифорнийском Маунтин-Вью, в кампусе компании Google, назревало нечто похожее на тихий переворот — не политический и не корпоративный, а инструментальный, связанный с орудиями труда, которыми пользовались инженеры. Компания к тому времени превратилась в вычислительного левиафана: сотни миллионов поисковых запросов ежедневно перетекали через её серверы, значительная доля текстового содержимого всего интернета подвергалась индексации, а сервисы требовали такой координации вычислительных мощностей, какой человечество прежде не предпринимало. Программное обеспечение, приводившее в движение эту махину, писалось преимущественно на языке C++ — инструменте мощном, зрелом, позволявшем извлекать из аппаратуры предельную производительность. Однако та самая мощь и зрелость постепенно превращались в источник специфического страдания, знакомого каждому, кто имел дело с инструментом, переросшим границы своего первоначального замысла и обросшим напластованиями десятилетий.
История C++ восходит к началу восьмидесятых годов, когда датский программист Бьёрн Страуструп задумал расширить язык C средствами, облегчающими организацию крупных программных проектов. C, созданный десятилетием раньше Деннисом Ритчи и Кеном Томпсоном для написания операционной системы Unix, отличался спартанской лаконичностью: минимум концепций, максимум контроля над машиной, никаких излишеств. Страуструп добавил к этой аскетической основе классы, наследование, позднее — шаблоны и множество других конструкций, каждая из которых отвечала на реальную потребность какой-либо группы разработчиков. Язык развивался органически, почти демократически: если достаточное число голосов требовало новой возможности, возможность в конечном счёте появлялась. За три десятилетия такой эволюции C++ стал напоминать не столько спроектированный инструмент, сколько геологическое образование — слой поверх слоя, эпоха поверх эпохи, причём каждый слой оставался доступным, потому что убрать что-либо означало бы обрушить код, написанный в расчёте на его присутствие. Справочник по современному C++ производит впечатление музея, выросшего из частной коллекции: экспонаты бесспорно ценны, но их столько и они столь разнородны, что посетителю требуется путеводитель к путеводителю, чтобы не заблудиться между залами.
Практические последствия этой археологической сложности в масштабах Google проступали в двух измерениях с особенной остротой. Первое касалось времени компиляции. Компиляция — процедура перевода программы с языка, понятного человеку, на язык, понятный машине. Компьютер не постигает человеческих наречий, даже таких формализованных, как языки программирования; он оперирует исключительно последовательностями элементарных операций, закодированных в виде чисел: переместить содержимое одной ячейки памяти в другую, сложить два числа, сравнить результат с третьим и в зависимости от исхода перескочить к иному месту программы. Компилятор выступает переводчиком, превращающим относительно членораздельный текст программы в эту машинную скоропись. Для C++ перевод требовал колоссальных усилий: язык изобиловал конструкциями, допускавшими многообразные интерпретации, и компилятору приходилось проделывать обширную аналитическую работу, прежде чем породить исполняемый код. Инженер, внёсший скромное исправление в проект, мог затем наблюдать, как минуты складываются в десятки минут ожидания, пока компилятор переварит результат. Полчаса — достаточный срок, чтобы мысль успела остыть, контекст задачи выветрился из оперативной памяти головы, а сам инженер переключился на постороннее и потом с ощутимым трением возвращался к прерванному занятию.
Второе измерение затрагивало когнитивную нагрузку — то умственное усилие, которое программист тратит не на решение собственно задачи, а на навигацию по лабиринту возможностей, предоставляемых инструментом. C++ предлагал несколько способов достичь почти любой цели, и каждый способ нёс собственный багаж достоинств, недостатков и неочевидных подводных камней. Выбор между ними требовал экспертизы, которая накапливалась годами практики, и даже признанные эксперты нередко расходились во мнениях о предпочтительном подходе в той или иной ситуации. Код, написанный одним мастером, мог оказаться загадкой для другого — не потому, что был дурно написан, а потому, что пользовался иным диалектом того же языка, иным набором идиом из необъятного арсенала допустимых приёмов. Когда над проектом трудятся тысячи инженеров, когда код живёт десятилетиями и его читают несравненно чаще, чем пишут, такая вариативность из достоинства превращается в помеху, в источник трения и недоразумений.
Три инженера, которые в 2007 году начали размышлять о том, каким мог бы выглядеть язык, свободный от этих напластований, принадлежали к редкой породе людей, умеющих не только пользоваться инструментами, но и создавать их. Кен Томпсон обладал биографией, придававшей его суждениям об устройстве языков программирования вес исторического прецедента: в начале семидесятых он вместе с Деннисом Ритчи создал операционную систему Unix и язык C — два изобретения, определившие траекторию развития вычислительной техники на полстолетия вперёд. C был радикально прост для своего времени: он давал программисту прямой доступ к памяти и периферии, но делал это посредством минимального набора концепций, который можно было удержать в голове целиком, не прибегая к справочникам на каждом шагу. Эта простота являлась не признаком ограниченности, а плодом дисциплины — и она оказалась поразительно продуктивной, породив экосистему программного обеспечения, на которой до сих пор держится изрядная часть цифровой инфраструктуры мира. Роб Пайк работал над Plan 9 — экспериментальной операционной системой, пытавшейся переосмыслить архитектуру Unix для эры повсеместных сетей, — и вынес из этого опыта острое понимание того, как программные компоненты могут сообщаться друг с другом в распределённой среде, не превращая систему в клубок взаимозависимостей. Роберт Гризмер привнёс опыт создания виртуальных машин и компиляторов — техническое знание того, как воплотить язык не только выразительный, но и быстрый в трансляции и исполнении.
Язык, который они начали проектировать и который получил короткое имя Go, строился на принципе, который можно было бы назвать программируемой сдержанностью. Там, где C++ щедро предлагал десять путей к одной цели, Go предлагал один — тот, который создатели сочли достаточно хорошим для большинства случаев и достаточно очевидным, чтобы не требовать долгих раздумий. Там, где другие языки наращивали арсенал возможностей в ответ на каждую артикулированную потребность, Go сознательно воздерживался от добавлений, грозивших усложнить язык сверх определённого порога. Решение кажется контринтуитивным: разве обширный выбор — не благо? Разве инструмент, способный на десять действий, не превосходит инструмент, способный лишь на пять? Ответ зависит от того, что именно оптимизируется. Если оптимизировать удобство написания отдельной программы отдельным программистом здесь и сейчас, то изобилие возможностей действительно предпочтительнее. Но если оптимизировать читаемость кода, который будут поддерживать сотни разных людей на протяжении десятилетия, — тогда единообразие перевешивает гибкость, а предсказуемость ценнее виртуозной выразительности.
Аналогия с естественным языком помогает прояснить эту логику. Русский язык — как, впрочем, любой живой язык — мог бы теоретически обогащаться новыми грамматическими конструкциями, позволяющими выражать некоторые мысли компактнее или точнее. Но ценность общего языка коренится именно в его устойчивости: носители владеют одним и тем же сводом правил и потому способны понимать друг друга без предварительных переговоров о том, какой версией грамматики пользоваться сегодня. Язык, который непрерывно усложняется, рискует расслоиться на диалекты и в конечном счёте утратить свою коммуникативную функцию — перестать быть средством взаимопонимания и сделаться барьером. Языки программирования сталкиваются с аналогичной дилеммой: гибкость для индивидуального автора против связности профессионального сообщества в целом. Go сделал выбор в пользу связности.
Программа на Go существует как текстовый файл — или совокупность файлов — содержащий инструкции, записанные по правилам, которые определяет язык. Файлы группируются в единицы, называемые пакетами: пакет объединяет логически родственный код и может использоваться другими частями программы как готовый строительный блок. Один особый пакет носит имя main и служит точкой входа: при запуске программы выполнение начинается с функции main внутри этого пакета. Функция — именованный фрагмент кода, выполняющий определённую задачу; её можно вызывать из разных мест программы, избегая повторения одних и тех же инструкций.
Чтобы текст программы превратился в нечто, способное действовать, его обрабатывает компилятор Go. Результатом становится бинарный файл — цепочка машинных команд, готовая к непосредственному исполнению процессором без дополнительных посредников. Существенная особенность Go состоит в самодостаточности этого файла: он вмещает всё необходимое для работы и не требует установки вспомогательных библиотек или виртуальных машин на компьютере, где будет запущен. Программу, скомпилированную на одной машине, можно перенести на другую машину той же архитектуры и запустить немедленно, без ритуалов подготовки. Эта портативность приобрела особую ценность в эпоху облачных вычислений, где программы нередко исполняются в аскетичных средах, очищенных от всего постороннего ради экономии ресурсов и безопасности.
Синтаксис Go спроектирован с прицелом на лаконичность, граничащую со скупостью. Небольшой пример — программа, складывающая числа в списке и выводящая результат, — позволяет ощутить эту эстетику:
nums := []int{1, 2, 3, 4}
Первая строка объявляет принадлежность файла к пакету main — тому самому, с которого начинается жизнь программы. Вторая строка импортирует пакет fmt, предоставляющий функции для форматированного вывода; название — сокращение от слова format. Третья строка открывает определение функции main, служащей точкой входа.
Внутри функции первая инструкция создаёт переменную nums и присваивает ей срез целых чисел со значениями 1, 2, 3, 4. Срез — структура данных, представляющая последовательность элементов одного типа; int указывает, что элементы являются целыми числами. Оператор := совмещает две операции: объявляет новую переменную и присваивает ей значение, причём тип переменной выводится автоматически из того, что ей присваивается. Компилятор сам догадывается, что nums должна быть срезом целых чисел, потому что именно такой срез стоит справа от знака присваивания. Этот механизм называется выводом типов и позволяет писать компактный код, сохраняя при этом строгую типизацию.
Следующая строка создаёт переменную sum с начальным значением ноль — в ней будет накапливаться сумма. Затем начинается цикл: конструкция for с ключевым словом range означает перебор всех элементов коллекции. Запись `_, n := range nums` расшифровывается так: для каждого элемента среза nums присвоить этот элемент переменной n и выполнить тело цикла. Символ подчёркивания занимает место, предназначенное для индекса элемента, — range возвращает и индекс, и значение, но индекс здесь не нужен, и Go требует явно показать, что мы его игнорируем, а не забыли по недосмотру. Внутри цикла инструкция `sum += n` прибавляет текущее число к накопленной сумме. Последняя строка вызывает функцию Println из пакета fmt, которая выводит свои аргументы — строку «Sum:» и значение переменной sum — и переходит на новую строку.
Даже читатель, никогда прежде не заглядывавший в программный код, может заметить своеобразную прозрачность этого текста. Здесь нет многословных деклараций, которыми славятся иные языки; нет обилия специальных символов с неочевидным смыслом; нет нагромождения абстракций, заслоняющих суть происходящего. Программа совершает ровно то, что написано, и написано почти так, как могло бы быть произнесено вслух, — разумеется, с поправкой на формальный синтаксис.
Go принадлежит к семейству статически типизированных языков: тип каждой переменной известен уже на этапе компиляции и не может измениться во время исполнения. Переменная, объявленная как вместилище целого числа, не способна внезапно начать хранить текстовую строку или дату. Компилятор проверяет согласованность типов и отказывается создавать программу, если обнаруживает нестыковку — скажем, попытку сложить число с текстом или передать функции аргумент неподходящего сорта. Подобная строгость воспринимается иногда как докучливое ограничение, но на практике она перехватывает множество ошибок прежде, чем программа будет запущена, превращая компилятор в первую линию обороны против определённых классов недоразумений. Вывод типов смягчает эту строгость с точки зрения удобства записи: программисту не приходится педантично указывать тип каждой переменной, поскольку компилятор способен вычислить его из контекста.
Одна из ключевых инноваций Go связана с понятием конкурентного выполнения — способностью программы заниматься несколькими делами одновременно или, точнее, создавать убедительную иллюзию одновременности. Современные программы почти никогда не являются линейными цепочками действий, терпеливо ожидающими завершения каждого шага перед переходом к следующему. Веб-сервер обрабатывает запросы от множества пользователей параллельно; браузер одновременно загружает изображения, исполняет скрипты и реагирует на щелчки мыши; программа для монтажа видео декодирует кадры, накладывает эффекты и отображает результат на экране, оставаясь при этом отзывчивой к командам пользователя. Конкурентность — способ организации программы так, чтобы эти множественные активности сосуществовали, не блокируя друг друга и не превращая систему в очередь из ожидающих.
Традиционный подход к конкурентности опирается на потоки выполнения — независимые последовательности инструкций, которыми управляет операционная система. Создание потока сопряжено с выделением памяти и регистрацией в планировщике операционной системы; переключение между потоками требует сохранения и восстановления состояния, что тоже отнимает ресурсы. Накладные расходы достаточно ощутимы, чтобы программы обычно ограничивались умеренным числом потоков — десятками, сотнями, в напряжённых случаях тысячами — и тщательно распределяли работу между ними, словно драгоценный ресурс.
Go предлагает иную модель посредством горутин — легковесных единиц конкурентного выполнения, которыми распоряжается не операционная система, а собственная среда исполнения языка. Порождение горутины обходится несопоставимо дешевле, чем порождение потока: программа способна создавать сотни тысяч, а при необходимости и миллионы горутин без катастрофического роста накладных расходов. Это меняет саму парадигму проектирования: вместо того чтобы экономно делить скудный запас потоков между задачами, программист волен выделить каждой самостоятельной задаче собственную горутину и доверить среде исполнения заботу о планировании и распределении процессорного времени.
Конкурентное выполнение, однако, порождает проблему координации: когда несколько горутин работают бок о бок, как им обмениваться сведениями, не создавая хаоса? Вообразите двух поваров, одновременно тянущихся к одной кастрюле, чтобы добавить каждый свой ингредиент, — исход непредсказуем и потенциально плачевен. Классическое решение использует механизмы блокировки, позволяющие лишь одному участнику в каждый момент времени обращаться к разделяемому ресурсу, но корректное применение блокировок требует немалого искусства и остаётся благодатной почвой для трудноуловимых ошибок, проявляющихся лишь при редком стечении обстоятельств.
Go поощряет альтернативный подход через механизм каналов — выделенных путей передачи сообщений между горутинами. Канал можно уподобить трубе строго определённого диаметра: одна горутина опускает значение в один конец, другая горутина извлекает его из противоположного конца. Пока значение движется по каналу, оно не принадлежит ни отправителю, ни получателю; сам акт передачи гарантирует, что в каждый момент времени данными владеет ровно один участник. Эта модель — передача сообщений вместо разделяемой памяти — делает координацию явной и зримой: программист не полагается на неявные допущения о том, что разные части программы не столкнутся при доступе к общим данным, а прямо определяет, какая информация, когда и от кого к кому перетекает.
Образ фабричного конвейера помогает закрепить интуицию. Представьте производственную линию, где каждый рабочий выполняет одну операцию и передаёт изделие дальше. Между соседними постами установлены лотки: рабочий А, завершив свою часть, кладёт деталь в лоток; рабочий Б, освободившись, забирает деталь из лотка и приступает к следующей операции. Двое никогда не держат одну деталь одновременно; не требуется сложных договорённостей о том, кто когда к чему прикасается. Лоток — это канал, деталь — это данные, рабочие — это горутины. Система самоорганизуется через архитектуру соединений, а не через централизованный диспетчер, раздающий разрешения.
Ещё одна черта Go, имеющая далеко идущие практические последствия, — автоматическое управление памятью, именуемое сборкой мусора. Когда программа создаёт данные — переменные, структуры, массивы, — эти данные размещаются в оперативной памяти компьютера, занимая там определённое пространство. Когда данные становятся ненужными, занятое ими пространство должно быть высвобождено для повторного использования, иначе память рано или поздно исчерпается. В языках, подобных C, эта обязанность целиком лежит на программисте: он явно запрашивает память, когда она требуется, и явно возвращает её системе, когда надобность отпадает. Такой режим даёт полный контроль, но взамен требует неусыпной бдительности. Забыть освободить память — значит допустить утечку: программа постепенно разбухает, поглощая всё больше ресурсов, пока не упрётся в потолок. Освободить память преждевременно, пока на неё ещё ссылаются другие части программы, — значит заложить мину замедленного действия: рано или поздно программа обратится по адресу, где прежде лежали её данные, и обнаружит там нечто постороннее, что приведёт либо к немедленному краху, либо — что хуже — к тихому искажению результатов.
Go избавляет программиста от этого бремени: среда исполнения сама отслеживает, какие данные ещё достижимы из работающей программы, а какие превратились в сирот, утративших связь с остальным кодом, и автоматически возвращает память, занятую сиротами. Программист просто создаёт значения; об их своевременной кончине и погребении заботится система. За это удобство приходится расплачиваться частью производительности — сборщик мусора сам потребляет процессорное время и может вызывать краткие паузы, — но для подавляющего большинства задач эта плата пренебрежимо мала в сравнении с выигрышем в надёжности и скорости разработки.
Положение Go в ландшафте языков программирования удобно описывать как срединное. Он абстрактнее, чем языки наподобие C, обнажающие аппаратные детали и требующие от программиста самому следить за каждым байтом, но конкретнее, чем языки наподобие Python, укутывающие всё в многослойные абстракции ценой производительности. Эта срединность делает Go особенно пригодным для определённого класса задач: построения серверов, принимающих и обрабатывающих сетевые запросы; написания утилит командной строки, автоматизирующих системные операции; создания инфраструктурного программного обеспечения, на котором, как на фундаменте, возводятся приложения более высокого уровня.
Два инструмента, сделавшихся несущими конструкциями современной облачной инфраструктуры, написаны именно на Go: Docker и Kubernetes. Docker позволяет упаковывать приложение вместе со всем необходимым ему окружением в стандартизированный контейнер — нечто вроде герметичной капсулы, которую можно перемещать между машинами с гарантией, что содержимое будет работать одинаково независимо от особенностей конкретного сервера. Kubernetes занимается оркестрацией таких контейнеров в масштабе от нескольких серверов до десятков тысяч: управляет развёртыванием, масштабированием, распределением нагрузки и восстановлением после сбоев. Оба проекта определяют облик современных вычислений для огромного числа компаний и сервисов, и тот факт, что оба написаны на Go, не является случайным совпадением. Характеристики языка — стремительная компиляция, эффективное исполнение, мощная поддержка конкурентности, простота развёртывания самодостаточных бинарных файлов — делают его естественным выбором для инфраструктурного слоя, где эти качества критически значимы.
Философия проектирования Go находит концентрированное выражение в максиме, которую иногда формулируют как предпочтение композиции наследованию. В ряде языков программирования код организуется через иерархии наследования: определяется общее понятие, затем более частные понятия вводятся как его специализации, автоматически перенимающие свойства и поведение родительского понятия. Так выстраиваются древовидные структуры, где конкретное располагается на листьях, а абстрактное — ближе к стволу. Go вместо этого поощряет композицию: сборку сложного поведения из простых, независимых компонентов, а не надстраивание над существующими конструкциями. Вместо утверждения «утка есть разновидность птицы, которая есть разновидность животного» идиоматический Go предпочитает формулировку «утка обладает способностью летать, способностью плавать и способностью издавать звуки» — и каждая способность определяется отдельно, как самостоятельный кирпичик, пригодный для использования в разных контекстах. Такой подход порождает более плоские, более податливые структуры: кирпичики компактны и слабо связаны друг с другом; их можно сочетать способами, которые не предвиделись при их создании; код легче понять, потому что не приходится подниматься по лестнице наследования, чтобы выяснить происхождение того или иного поведения.
Язык Go, стало быть, представляет собой не просто очередной экспонат в и без того переполненной витрине программистских инструментов, но воплощение определённой философии — убеждённости в том, что осмысленное ограничение способно стать источником свободы, что меньшее при известных условиях оказывается большим, что инструмент, заточенный под конкретный сценарий, может превзойти инструмент, притязающий на универсальность. Создатели Go сделали ставку на то, что разработчикам нужен не язык безбрежных возможностей, а язык продуманных границ — язык, который направляет к добротным решениям не запретами и карами, а тем, что добротные решения оказываются естественными, а сомнительные требуют дополнительных ухищрений. Прошедшие годы в значительной мере подтвердили справедливость этой ставки: Go обрёл собственную нишу и процветает в ней, сделавшись языком выбора для целого поколения инфраструктурного программного обеспечения, на котором зиждется повседневное функционирование цифрового мира.