Официальный FAQ по языку Go, переведённый на русский язык.
📖 Удобная версия для чтения доступна на GitHub Pages (подходит и для мобильных устройств).
- Истоки
- Использование
- Дизайн
- Есть ли у Go рантайм?
- Что насчёт идентификаторов с Unicode?
- Почему в Go нет возможности X?
- Когда в Go появились обобщённые типы?
- Почему Go изначально вышел без обобщённых типов?
- Почему в Go нет исключений?
- Почему в Go нет assert?
- Почему конкурентность в Go построена на идеях CSP?
- Почему горутины, а не потоки?
- Почему операции с map не являются атомарными?
- Примете ли вы мои изменения в язык?
- Типы
- Является ли Go объектно-ориентированным языком?
- Как в Go получить динамическую диспетчеризацию методов?
- Почему в Go нет наследования типов?
- Почему
len
— это функция, а не метод? - Почему в Go нет перегрузки методов и операторов?
- Почему в Go нет ключевого слова
implements
? - Как гарантировать, что мой тип реализует интерфейс?
- Почему тип
T
не реализует интерфейсEqual
? - Можно ли преобразовать
[]T
в[]interface{}
? - Можно ли преобразовать
[]T1
в[]T2
, если уT1
иT2
одинаковый базовый тип? - Почему моё значение ошибки
nil
не равноnil
? - Почему нулевые типы (zero-size types) ведут себя странно?
- Почему в Go нет нетегированных объединений (unions), как в C?
- Почему в Go нет вариантных типов?
- Почему в Go нет ковариантных возвращаемых типов?
- Значения
- Написание кода
- Указатели и выделение памяти
- Когда параметры функций передаются по значению?
- Когда стоит использовать указатель на интерфейс?
- Следует ли определять методы для значений или для указателей?
- В чём разница между
new
иmake
? - Каков размер
int
на 64-битной машине? - Как узнать, выделена ли переменная в куче или на стеке?
- Почему мой Go-процесс использует так много виртуальной памяти?
- Конкурентность
- Функции и методы
- Управление потоком выполнения
- Параметры типов
- Зачем Go нужны параметры типов?
- Как дженерики реализованы в Go?
- Как дженерики в Go сравниваются с дженериками в других языках?
- Почему в Go для списка параметров типов используются квадратные скобки?
- Почему Go не поддерживает методы с параметрами типов?
- Почему нельзя использовать более специфичный тип для получателя параметризованного типа?
- Почему компилятор не может вывести аргумент типа в моей программе?
- Пакеты и тестирование
- Имплементация
- Какая технология компилятора используется для сборки компиляторов?
- Как реализована поддержка времени выполнения?
- Почему мой тривиальный код компилируется в такой большой бинарник?
- Могу ли я отключить жалобы на неиспользуемые переменные/импорты?
- Почему мой антивирус считает, что дистрибутив Go или скомпилированный бинарник заражён?
- Производительность
- Отличия от C
- Почему синтаксис так отличается от C?
- Почему объявления «наоборот»?
- Почему в Go нет арифметики указателей?
- Почему
++
и--
— это инструкции, а не выражения? И почему только постфиксная форма? - Почему используются фигурные скобки, но нет точек с запятой? И почему нельзя ставить открывающую скобку на новой строке?
- Зачем нужна сборка мусора? Разве она не слишком дорогая?
Когда в 2007 году появился Go, мир программирования выглядел иначе. Продакшен системы чаще всего писали на C++ или Java, GitHub ещё не существовал, большинство компьютеров были однопроцессорными, а кроме Visual Studio и Eclipse почти не было IDE или других высокоуровневых инструментов, тем более бесплатных и доступных в Интернете.
Мы были разочарованы излишней сложностью, с которой приходилось сталкиваться при разработке больших проектов на тех языках и их системах сборки. С тех пор, как появились C, C++ и Java, компьютеры стали значительно быстрее, но сам процесс программирования почти не изменился. Кроме того, было ясно, что многопроцессорные системы становятся стандартом, но большинство языков не предоставляли удобных и безопасных средств для эффективного использования этой мощности.
Мы решили сделать шаг назад и подумать, какие ключевые задачи будут определять развитие разработки программного обеспечения в будущем и как новый язык может помочь их решить. Например, рост количества многоядерных процессоров (multicore CPUs) означал, что язык должен предоставлять встроенную (first-class) поддержку какой-то формы конкурентности (concurrency) или параллелизма. А чтобы управление ресурсами оставалось посильной задачей в больших конкурентных программах, нужен был сборщик мусора (garbage collection) или хотя бы безопасное автоматическое управление памятью.
Эти соображения привели к серии обсуждений, из которых родился Go — сначала как набор идей и требований, а затем как язык. Главная цель заключалась в том, чтобы Go помогал практикующему разработчику: давал хорошие инструменты, автоматизировал рутинные задачи вроде форматирования кода и убирал препятствия при работе с большими кодовыми базами.
Более развёрнутое описание целей Go и того, как они достигаются (или к чему стремятся), доступно в статье: Go at Google: Language Design in the Service of Software Engineering.
21 сентября 2007 года Роберт Гриземер (Robert Griesemer), Роб Пайк (Rob Pike) и Кен Томпсон (Ken Thompson) начали набрасывать на доске цели для нового языка. Спустя несколько дней цели оформились в план действий и общее понимание того, каким будет язык. Работа над дизайном языка продолжалась в свободное время параллельно с другими задачами.
К январю 2008 года Кен начал работу над компилятором для проверки идей — на выходе он генерировал код на C. К середине года язык стал полноценным проектом и достаточно устоялся, чтобы попробовать создать промышленный компилятор. В мае 2008 года Иэн Тейлор (Ian Taylor) независимо начал разработку фронтенда для GCC по черновой спецификации. В конце 2008 года к проекту присоединился Расс Кокс (Russ Cox) и помог перевести язык и библиотеки из прототипа в реальность.
Go стал публичным open source-проектом 10 ноября 2009 года. Огромное количество людей из сообщества внесли идеи, участвовали в обсуждениях и писали код.
Сегодня в мире уже миллионы разработчиков на Go — «gophers» — и их число постоянно растёт. Успех Go намного превзошёл наши ожидания.
Талисман (маскот) и логотип были созданы Рене Френч (Renée French), которая также придумала Гленду (Glenda) — кролика из Plan 9. Пост в блоге про гофера объясняет, что образ был основан на персонаже, использованном ею несколько лет назад для дизайна футболки радиостанции WFMU.
Логотип и талисман распространяются по лицензии Creative Commons Attribution 4.0.
У гофера есть model sheet, который показывает его особенности и то, как правильно их изображать. Впервые он был представлен Рене на докладе на Gophercon 2016. У него есть уникальные черты — это именно гофер Go, а не просто какой-то суслик.
Язык называется Go. Название «golang» появилось из-за того, что сайт изначально был golang.org (тогда ещё не существовало домена .dev). Многие до сих пор используют слово golang, и оно удобно как метка. Например, хэштег языка в соцсетях — #golang. Но официальное название языка — просто Go.
К слову: хотя официальный логотип написан заглавными буквами, название языка пишется как Go, а не GO.
Go появился как реакция на неудобство существующих языков и сред, с которыми мы работали в Google. Программирование стало слишком сложным, и выбор языка отчасти был в этом виноват. Нужно было выбирать: либо быстрая компиляция, либо быстрая работа программы, либо простота разработки — все три свойства одновременно не были доступны ни в одном из популярных языков. Многие разработчики предпочитали простоту безопасности и эффективности, переходя на динамически типизированные языки вроде Python и JavaScript, вместо C++ или, в меньшей степени, Java.
Мы были не единственными, кого это беспокоило. После многих лет относительного затишья в мире языков программирования, Go стал одним из первых в новой волне языков — вместе с Rust, Elixir, Swift и другими, — которые снова сделали разработку языков активной и почти мейнстримной областью.
Go решал эти проблемы, пытаясь объединить простоту разработки на интерпретируемом динамически типизированном языке с эффективностью и безопасностью статически типизированного компилируемого языка. Он также был ориентирован на современное оборудование, с поддержкой сетевых и многопроцессорных (multicore) вычислений. Наконец, работа с Go должна быть быстрой: сборка большого исполняемого файла на одном компьютере должна занимать не больше нескольких секунд.
Чтобы достичь этих целей, пришлось пересмотреть подходы, унаследованные от существующих языков, и реализовать:
- композиционную, а не иерархическую систему типов
- поддержку конкурентности (concurrency) и сборку мусора (garbage collection)
- строгую спецификацию зависимостей
- и многое другое
Эти задачи невозможно было решить просто с помощью библиотек или инструментов — нужен был новый язык.
Подробнее о предпосылках и мотивации создания Go, а также о многих аспектах его дизайна, можно прочитать в статье Go at Google.
Go в основном относится к семейству C (базовый синтаксис), но также унаследовал многое от семейства Pascal/Modula/Oberon (объявления, пакеты) и заимствовал идеи из языков, вдохновлённых CSP Тони Хоара (Tony Hoare), таких как Newsqueak и Limbo (конкурентность).
Однако в целом Go — это новый язык. Во всех аспектах он был спроектирован, исходя из того, что делают программисты и как сделать программирование, по крайней мере того типа, которым занимаемся мы, более эффективным, а значит — более увлекательным.
Когда проектировался Go, наиболее распространёнными языками для написания серверных программ (по крайней мере в Google) были Java и C++. Мы считали, что эти языки требуют слишком много рутины и повторений. Некоторые разработчики в ответ переходили на более динамичные и гибкие языки вроде Python, жертвуя при этом эффективностью и безопасностью типов. Мы же верили, что можно совместить эффективность, безопасность и гибкость в одном языке.
Go стремится уменьшить количество typing — и в смысле набора текста, и в смысле работы с типами. На протяжении всего
дизайна мы старались сократить загромождённость и сложность. Нет ни предварительных объявлений, ни заголовочных файлов:
всё объявляется ровно один раз. Инициализация выразительная, автоматическая и простая в использовании. Синтаксис чистый
и небогатый ключевыми словами. Повторения (например, foo.Foo* myFoo = new(foo.Foo)
) устраняются с помощью простого
вывода типа при объявлении и инициализации через конструкцию :=
. И, пожалуй, наиболее радикально: в Go нет иерархии
типов — типы просто есть, им не нужно явно описывать свои отношения. Эти упрощения делают Go одновременно
выразительным и понятным, без потери продуктивности.
Другой важный принцип — сохранение ортогональности концепций. Методы можно реализовать для любого типа; структуры описывают данные, а интерфейсы задают абстракцию и т. д. Ортогональность упрощает понимание того, как разные элементы работают вместе.
Да. Go широко применяется в продакшене внутри Google. Один из примеров — сервер загрузок dl.google.com, который раздаёт бинарные файлы Chrome и другие крупные установочные пакеты, например пакеты для apt-get.
Go, конечно, не единственный язык, используемый в Google, но он является ключевым для ряда направлений, включая site reliability engineering (SRE) и обработку данных в крупном масштабе. Кроме того, Go — важная часть программного обеспечения, на котором работает Google Cloud.
Использование Go растёт во всём мире, особенно — но вовсе не исключительно — в сфере облачных вычислений. Несколько крупных проектов облачной инфраструктуры, написанных на Go, — это Docker и Kubernetes, но их гораздо больше.
При этом Go применяется не только в облаке. На сайте go.dev есть список компаний и истории успеха. Кроме того, в Go Wiki есть страница GoUsers, которая регулярно обновляется и перечисляет многие компании, использующие Go.
В Wiki также есть страница с дополнительными историями успеха о компаниях и проектах, которые применяют этот язык.
Да, использовать C и Go в одном адресном пространстве возможно, но это неестественная связка и часто требует дополнительного интерфейсного кода. Кроме того, при линковке C с кодом Go теряются свойства безопасности работы с памятью и управления стеком, которые предоставляет Go. Иногда использование библиотек на C действительно необходимо, но всегда нужно помнить, что это добавляет риски, отсутствующие в чистом Go-коде, — поэтому применять такой подход стоит осторожно.
Если всё же нужно использовать C вместе с Go, то способ зависит от реализации компилятора Go. «Стандартный» компилятор, входящий в официальный тулчейн Go и поддерживаемый командой Google, называется gc. Кроме него существуют компилятор на базе GCC (gccgo) и компилятор на базе LLVM (gollvm), а также всё большее количество специализированных компиляторов для разных целей (иногда реализующих только подмножество языка), например TinyGo.
Компилятор gc использует собственный calling convention и линковщик, поэтому его код нельзя напрямую вызывать из C-программ (и наоборот). Для этого существует утилита cgo, которая реализует foreign function interface и позволяет безопасно вызывать C-библиотеки из Go-кода. Для работы с C++ библиотеками возможности cgo расширяет SWIG.
cgo и SWIG можно использовать также с gccgo и gollvm. Так как они применяют традиционный ABI, теоретически (с большой осторожностью) можно напрямую линковать код этих компиляторов с программами на C или C++, собранными GCC/LLVM. Однако делать это безопасно можно только при полном понимании calling convention всех участвующих языков, а также с учётом ограничений стека при вызове C или C++ кода из Go.
Проект Go не включает собственную IDE, но язык и стандартные библиотеки были спроектированы так, чтобы упрощать анализ исходного кода. Благодаря этому большинство известных редакторов и IDE хорошо поддерживают Go — напрямую или через плагины.
Команда Go также поддерживает язык-сервер для протокола LSP под названием gopls. Любые инструменты, работающие с LSP, могут использовать gopls для интеграции поддержки Go.
В список популярных IDE и редакторов с хорошей поддержкой Go входят Emacs, Vim, VS Code, Atom, Eclipse, Sublime, IntelliJ (через отдельный продукт GoLand) и многие другие. Скорее всего, ваша любимая среда разработки также отлично подходит для программирования на Go.
Необходимый плагин для компилятора и библиотека доступны в отдельном open source-проекте: github.com/golang/protobuf.
У Go есть обширная библиотека времени выполнения, часто называемая просто runtime, которая включается в каждую программу на Go. Эта библиотека реализует сборку мусора (garbage collection), конкурентность (concurrency), управление стеком и другие критически важные возможности языка Go. Хотя она играет более центральную роль, чем в C, рантайм Go можно сравнить с libc — стандартной библиотекой C.
Важно понимать, что рантайм Go не включает виртуальную машину, подобную той, что используется в Java. Программы на Go компилируются заранее в нативный машинный код (или в JavaScript/WebAssembly для некоторых реализаций). Поэтому, хотя термин runtime часто используют для описания виртуальной среды выполнения, в Go он обозначает библиотеку, которая предоставляет ключевые сервисы языка.
При проектировании Go мы хотели избежать чрезмерной привязки к ASCII, поэтому расширили пространство идентификаторов за пределы 7-битного ASCII. Правило в Go простое: символы идентификаторов должны быть буквами или цифрами в определении Unicode. Оно легко понимается и реализуется, но имеет ограничения. Например, комбинирующие символы исключены изначально, а это означает, что некоторые языки (например, деванагари) нельзя использовать в идентификаторах.
У этого правила есть ещё одно неприятное следствие. Так как экспортируемый идентификатор должен начинаться с заглавной
буквы, идентификаторы, созданные из символов некоторых языков, по определению не могут быть экспортируемыми. На данный
момент единственное решение — использовать что-то вроде X日本語
, что явно неудовлетворительно.
С самого раннего этапа разработки языка мы много думали о том, как лучше расширить пространство идентификаторов для программистов, использующих другие родные языки. Вопрос до сих пор активно обсуждается, и будущие версии языка, возможно, будут более гибкими в определении идентификатора. Например, можно принять некоторые идеи из рекомендаций Unicode для идентификаторов. Какой бы путь ни был выбран, он должен быть совместимым и при этом сохранять (или даже расширять) один из любимых принципов Go — управление видимостью идентификаторов через регистр букв.
На данный момент у нас есть простое правило, которое можно будет расширить в будущем без ломки существующих программ, и которое помогает избежать ошибок, неизбежных при более двусмысленных правилах.
Каждый язык содержит новые возможности и при этом не имеет каких-то «любимых» фич у отдельных разработчиков. Go проектировался с акцентом на удобство программирования, скорость компиляции, ортогональность концепций и необходимость поддержки таких возможностей, как конкурентность (concurrency) и сборка мусора (garbage collection). Возможно, вашей любимой возможности нет потому, что она плохо вписывается в язык, замедляет компиляцию, ухудшает ясность дизайна или делает фундаментальную модель системы слишком сложной.
Если вас расстраивает отсутствие возможности X в Go — простите нас 🙂 Попробуйте изучить те возможности, которые в Go есть: возможно, они компенсируют отсутствие X неожиданным и интересным образом.
В релизе Go 1.18 в язык были добавлены type parameters (параметры типов). Это позволило использовать форму полиморфного или обобщённого программирования (generic programming). Подробнее см. в спецификации языка и предложении.
Go задумывался как язык для написания серверных программ, которые должны быть простыми в сопровождении со временем. (См. эту статью для подробностей.) Дизайн был сосредоточен на таких вещах, как масштабируемость, читаемость и конкурентность (concurrency). Полиморфное программирование тогда не казалось важным для целей языка, поэтому для простоты оно было исключено.
Обобщения удобны, но вносят дополнительную сложность в систему типов и рантайм. Понадобилось время, чтобы разработать дизайн, который, на наш взгляд, даёт пользу, соразмерную этой сложности.
Мы считаем, что связывание исключений с управляющими конструкциями (как в идиоме try-catch-finally
) приводит к
запутанному коду. Кроме того, это часто побуждает разработчиков помечать как «исключительные» слишком многие обычные
ошибки, например, невозможность открыть файл.
Go использует другой подход. Для обычной обработки ошибок многозначные возвращаемые значения позволяют легко сообщить об ошибке, не перегружая основное возвращаемое значение. Канонический тип ошибки вместе с другими возможностями Go делает обработку ошибок удобной, но сильно отличающейся от других языков.
В Go также есть несколько встроенных функций для генерации и обработки действительно исключительных ситуаций. Механизм восстановления выполняется только при сворачивании состояния функции после ошибки. Этого достаточно для обработки катастрофических случаев, при этом не нужны дополнительные управляющие конструкции, а при правильном использовании код обработки ошибок остаётся чистым.
Подробнее см. статью Defer, Panic, and Recover. Также пост в блоге «Errors are values» описывает подход к чистой обработке ошибок в Go, показывая, что раз ошибки — это такие же значения, то для их обработки можно использовать всю мощь языка.
В Go нет механизма assert. Он, безусловно, удобен, но наш опыт показывает, что разработчики часто используют его как костыль, чтобы не продумывать корректную обработку и сообщение об ошибках. Правильная обработка ошибок позволяет серверным программам продолжать работу вместо того, чтобы падать из-за нефатальной ошибки. А корректное сообщение об ошибках делает их прямыми и понятными, избавляя разработчика от необходимости разбирать длинный трейс аварийного завершения. Точные сообщения особенно важны, когда ошибки видит программист, не знакомый с кодом.
Мы понимаем, что это спорный момент. В языке Go и его библиотеках есть немало вещей, которые отличаются от современных практик — просто потому, что мы считаем, что иногда стоит попробовать иной подход.
Со временем конкурентное и многопоточное программирование приобрели репутацию сложной области. Мы считаем, что отчасти это связано со слишком запутанными моделями вроде pthreads, а отчасти — с излишним вниманием к низкоуровневым деталям вроде мьютексов, условных переменных и барьеров памяти. Более высокоуровневые интерфейсы позволяют писать куда более простой код, даже если «под капотом» всё равно есть мьютексы и прочее.
Одной из самых успешных моделей высокоуровневой поддержки конкурентности стал подход Хоара — Communicating Sequential Processes (CSP). Языки Occam и Erlang — известные примеры, выросшие из CSP.
Примитивы конкурентности в Go происходят от другой ветви этой идеи, главной ценностью которой стало мощное понятие каналов (channels) как объектов первого класса. Опыт работы с несколькими предыдущими языками показал, что модель CSP хорошо вписывается в процедурную парадигму.
Горутины сделаны для того, чтобы конкурентность была простой в использовании. Идея, существующая уже давно, состоит в мультиплексировании независимо выполняющихся функций — корутин (coroutines) — поверх набора потоков. Когда корутина блокируется, например при вызове блокирующего системного вызова, рантайм автоматически переносит другие корутины, работающие в том же системном потоке, на другой поток, готовый к выполнению, чтобы они не простаивали. Программист всего этого не видит — и в этом суть. В результате горутины получаются очень «лёгкими»: они требуют лишь память под стек размером всего несколько килобайт.
Чтобы стеки были маленькими, рантайм Go использует динамически изменяемые ограниченные стеки. Новая горутина получает всего несколько килобайт — и этого почти всегда достаточно. Если нет, рантайм автоматически увеличивает (и уменьшает) память под стек. Это позволяет сотням тысяч горутины сосуществовать в одном адресном пространстве, занимая скромный объём памяти.
Средние накладные расходы на CPU — примерно три недорогие инструкции на каждый вызов функции. На практике можно запускать сотни тысяч горутины в одном процессе. Если бы горутины были обычными потоками, системные ресурсы закончились бы намного раньше.
После долгих обсуждений было решено, что типичное использование map
не требует безопасного доступа из нескольких
горутин. А в тех случаях, когда это действительно необходимо, map
обычно является частью более крупной структуры
данных или вычисления, которые уже синхронизированы. Поэтому требование блокировать мьютекс при каждой операции с map
замедлило бы большинство программ и дало бы безопасность лишь в немногих случаях. Это решение далось нелегко, ведь
неконтролируемый доступ к map
может привести к краху программы.
Язык не исключает атомарные обновления map
. Когда это нужно (например, при запуске недоверенного кода), реализация
может синхронизировать доступ к map
.
Доступ к map
небезопасен только тогда, когда происходят изменения. Если все горутины только читают данные — ищут
элементы или обходят map
с помощью цикла for range
— и не изменяют его (не присваивают элементы и не удаляют их),
то такой доступ безопасен даже без синхронизации.
Чтобы помочь корректному использованию map
, некоторые реализации языка содержат специальную проверку, которая в
рантайме автоматически сообщает, если map
был небезопасно изменён конкурентно. Кроме того, в пакете sync есть
тип sync.Map
, который хорошо подходит для некоторых сценариев
(например, для статических кэшей), хотя и не является полноценной заменой встроенному типу map
.
Разработчики часто предлагают улучшения языка — список рассылки хранит богатую историю таких обсуждений, — но лишь немногие из этих изменений были приняты.
Хотя Go — это open source-проект, язык и стандартные библиотеки защищены обещанием совместимости, которое запрещает изменения, ломающие существующие программы (по крайней мере на уровне исходного кода; иногда программы нужно просто перекомпилировать, чтобы они продолжали работать). Если ваше предложение нарушает спецификацию Go 1, мы даже не можем его рассматривать, каким бы ценным оно ни было. В будущем может выйти крупная версия Go, несовместимая с Go 1, но обсуждения на эту тему только начались, и одно ясно точно: таких несовместимостей будет крайне мало. Более того, обещание совместимости обязывает нас предоставить автоматический путь миграции старых программ, если такая ситуация возникнет.
Даже если ваше предложение совместимо со спецификацией Go 1, оно может противоречить целям дизайна языка. Статья Go at Google: Language Design in the Service of Software Engineering объясняет происхождение Go и мотивацию его архитектурных решений.
И да, и нет. В Go есть типы и методы, и он позволяет писать в объектно-ориентированном стиле, но в языке нет иерархии типов. Концепция interface в Go предлагает иной подход, который, на наш взгляд, проще в использовании и в некоторых отношениях более общий. Также есть возможность встраивать одни типы в другие, что даёт аналог, но не идентичный, наследованию.
Кроме того, методы в Go более универсальны, чем в C++ или Java: их можно определять для любых данных, даже для встроенных типов, таких как обычные «неупакованные» (unboxed) целые числа. Методы не ограничены только структурами (аналогами классов).
К тому же отсутствие иерархии типов делает «объекты» в Go гораздо более лёгкими, чем в языках вроде C++ или Java.
Единственный способ динамической диспетчеризации методов в Go — через interface. Методы, определённые у структуры или любого другого конкретного типа, всегда разрешаются статически.
Объектно-ориентированное программирование, по крайней мере в самых известных языках, уделяет слишком много внимания связям между типами — связям, которые во многих случаях могли бы выводиться автоматически. Go выбирает другой путь.
Вместо того чтобы заставлять программиста заранее объявлять, что два типа связаны, в Go любой тип автоматически удовлетворяет интерфейсу (interface), если реализует подмножество его методов. Помимо снижения «бумажной работы», этот подход даёт реальные преимущества. Тип может реализовывать несколько интерфейсов сразу, без сложностей традиционного множественного наследования. Интерфейсы могут быть очень лёгкими — даже интерфейс с одним методом или вовсе без методов может выражать полезную концепцию. Интерфейсы можно добавлять задним числом — если появилась новая идея или для целей тестирования — без модификации исходных типов. Так как между типами и интерфейсами нет явных связей, в Go нет иерархии типов, которую нужно поддерживать или обсуждать.
Эти идеи можно использовать для построения конструкций, аналогичных безопасным по типам пайпам Unix. Например,
fmt.Fprintf
позволяет форматированно печатать в любой вывод, а не только в файл; пакет bufio
может существовать
совершенно отдельно от файлового ввода-вывода; пакеты image
умеют генерировать сжатые изображения.
Все эти возможности опираются на один интерфейс — io.Writer
, который описывает всего один метод — Write
.
И это лишь вершина айсберга: интерфейсы Go глубоко влияют на то, как структурируются программы.
К этому стилю неявных зависимостей типов нужно привыкнуть, но именно он делает Go столь продуктивным языком.
Мы обсуждали этот вопрос, но решили, что реализация len
и других подобных операций в виде функций вполне подходит
на практике и не усложняет вопросы интерфейсов (в смысле Go-типов) для базовых типов.
Диспетчеризация методов упрощается, если ей не нужно дополнительно сопоставлять типы. Опыт работы с другими языками показал, что наличие множества методов с одинаковым именем, но разными сигнатурами, иногда бывает полезным, но на практике часто оказывается запутанным и хрупким решением. Поэтому решение в Go — сопоставлять только по имени и требовать согласованности в типах — стало важным упрощением системы типов.
Что касается перегрузки операторов, она скорее даёт удобство, чем является необходимостью. И снова — без неё всё проще.
Тип в Go реализует интерфейс автоматически — просто реализуя все методы этого интерфейса, и ничего больше. Это свойство позволяет определять и использовать интерфейсы без необходимости изменять существующий код. Такой подход реализует структурную типизацию, которая способствует разделению ответственности, улучшает повторное использование кода и облегчает построение новых паттернов по мере развития программы.
Семантика интерфейсов — одна из главных причин того, что Go ощущается таким простым и лёгким языком.
См. также вопрос о наследовании типов для подробностей.
Можно попросить компилятор проверить, что тип T
реализует интерфейс I
, попытавшись выполнить присваивание с
использованием нулевого значения для T
или указателя на T
(в зависимости от ситуации):
type T struct{}
var _ I = T{} // Проверка, что T реализует I.
var _ I = (*T)(nil) // Проверка, что *T реализует I.
Если T
(или *T
) не реализует интерфейс I
, ошибка будет обнаружена на этапе компиляции.
Если вы хотите, чтобы пользователи интерфейса явно указывали, что они его реализуют, можно добавить в методный набор интерфейса специальный метод с описательным именем. Например:
type Fooer interface {
Foo()
ImplementsFooer()
}
Теперь тип обязан реализовать метод ImplementsFooer
, чтобы считаться Fooer
. Это явно документирует факт реализации
и отображается в выводе команды go doc
.
type Bar struct{}
func (b Bar) ImplementsFooer() {}
func (b Bar) Foo() {}
Большинство кода в Go не использует такие ограничения, так как они уменьшают гибкость интерфейсов. Но иногда они необходимы, чтобы устранить неоднозначность между похожими интерфейсами.
Рассмотрим простой интерфейс, представляющий объект, который может сравнивать себя с другим значением:
type Equaler interface {
Equal(Equaler) bool
}
и тип T
:
type T int
func (t T) Equal(u T) bool { return t == u } // не реализует Equaler
В отличие от аналогичной ситуации в некоторых полиморфных системах типов, T
не реализует Equaler
.
Аргумент метода T.Equal
имеет тип T
, а не буквально требуемый тип Equaler
.
В Go система типов не делает автоматического «повышения» аргумента метода — это обязанность программиста.
Например, тип T2
действительно реализует Equaler
:
type T2 int
func (t T2) Equal(u Equaler) bool { return t == u.(T2) } // реализует Equaler
Однако и это отличается от других систем типов, потому что в Go любой тип, удовлетворяющий интерфейсу Equaler
,
может быть передан в аргумент метода T2.Equal
. Поэтому во время выполнения нужно проверять, что аргумент имеет
именно тип T2
. В некоторых языках такая проверка гарантируется на этапе компиляции.
Есть и обратный пример:
type Opener interface {
Open() Reader
}
func (t T3) Open() *os.File
В Go тип T3
не реализует Opener
, хотя в другом языке это могло бы быть допустимо.
Хотя система типов Go делает меньше за программиста в таких случаях, отсутствие подтипов делает правила соответствия интерфейсам очень простыми: имена функций и их сигнатуры должны в точности совпадать с определёнными в интерфейсе. Это правило также легко и эффективно реализовать. Мы считаем, что эти преимущества компенсируют отсутствие автоматического повышения типов.
Напрямую — нет. Это запрещено спецификацией языка, потому что у этих двух типов разное представление в памяти. Нужно копировать элементы по одному в новый срез.
Пример: преобразование среза []int
в срез []interface{}
:
t := []int{1, 2, 3, 4}
s := make([]interface{}, len(t))
for i, v := range t {
s[i] = v
}
Последняя строка в этом примере кода не компилируется:
type T1 int
type T2 int
var t1 T1
var x = T2(t1) // OK
var st1 []T1
var sx = ([]T2)(st1) // NOT OK
В Go типы тесно связаны с методами: каждый именованный тип имеет (возможно, пустой) набор методов. Общее правило такое: можно менять имя самого типа при преобразовании (и вместе с этим — его набор методов), но нельзя менять имя (и набор методов) элементов составного типа. В Go требуется явное указание при преобразованиях типов.
Под капотом интерфейсы реализованы как пара: тип T
и значение V
.
V
— это конкретное значение (например, int
, struct
или указатель), никогда не интерфейс, и оно имеет тип T
.
Например, если сохранить значение int
равное 3 в интерфейсе, результатом будет интерфейсное значение вида:
(T=int
, V=3
). Значение V
также называют динамическим значением интерфейса, так как в процессе выполнения
программа может помещать в интерфейс разные значения V
(и соответствующие им типы T
).
Интерфейсное значение считается nil
только если и T
, и V
не заданы (T=nil
, V
не установлено). В частности,
«пустой» интерфейс всегда хранит nil
-тип. Если же мы сохраним внутри интерфейса нулевой указатель типа *int
,
то тип будет *int
независимо от значения указателя: (T=*int
, V=nil
). Такое интерфейсное значение не будет равно
nil
, даже если указатель внутри равен nil
.
Эта ситуация часто сбивает с толку и возникает, когда в интерфейсное значение, например error
, сохраняется nil
-указатель:
func returnsError() error {
var p *MyError = nil
if bad() {
p = ErrBad
}
return p // всегда вернёт ненулевой error
}
Если всё прошло хорошо, функция вернёт nil
-указатель p
. Но результат будет интерфейс error
, содержащий (T=*MyError
, V=nil
).
Поэтому при сравнении возвращённого значения с nil
вызовущий код решит, что ошибка есть, хотя её на самом деле не было.
Чтобы вернуть настоящий nil
-интерфейс, функция должна явно вернуть nil
:
func returnsError() error {
if bad() {
return ErrBad
}
return nil
}
Хорошая практика для функций, возвращающих ошибки, — всегда использовать тип error
в сигнатуре (как в примере выше),
а не конкретный тип вроде *MyError
. Это гарантирует корректное создание значения ошибки. Например,
os.Open
возвращает error
, хотя если оно не равно nil
, то всегда имеет конкретный
тип *os.PathError
.
Похожие ситуации могут возникать всякий раз, когда используются интерфейсы. Нужно помнить: если в интерфейсе сохранено
какое-либо конкретное значение, сам интерфейс не будет nil
. Подробнее см. статью
The Laws of Reflection.
Go поддерживает нулевые типы, такие как структура без полей (struct{}
) или массив без элементов ([0]byte
).
В нулевой тип нельзя записать никакого значения, но они бывают полезны, когда само значение не нужно.
Например, map[int]struct{}
или тип, у которого есть методы, но нет данных.
Разные переменные нулевого типа могут располагаться в одной и той же области памяти. Это безопасно, так как хранить в них всё равно нечего.
Кроме того, язык не гарантирует, будут ли указатели на разные переменные нулевого типа равны или нет.
Такое сравнение может вернуть true
в одном месте программы и false
в другом — в зависимости от того,
как именно программа скомпилирована и выполнена.
Есть и другая особенность: указатель на поле нулевого типа в структуре не должен пересекаться с указателем на другой объект в памяти. Это могло бы вызвать путаницу в работе сборщика мусора. Поэтому если последним полем в структуре идёт нулевой тип, структура будет дополнена (padded), чтобы указатель на это поле не пересекался с памятью, расположенной сразу после структуры.
Таким образом, программа:
func main() {
type S struct {
f1 byte
f2 struct{}
}
fmt.Println(unsafe.Sizeof(S{}))
}
в большинстве реализаций Go выведет 2
, а не 1
.
Нетегированные объединения нарушили бы гарантии безопасности памяти в Go.
Вариантные типы (также известные как алгебраические типы) позволяют задать, что значение может принимать один из набора других типов, но только из этого набора. Классический пример в системном программировании: ошибка может быть сетевой, связанной с безопасностью или прикладной, и вызывающий код может определить источник проблемы по типу ошибки. Другой пример — синтаксическое дерево, где каждый узел может быть разного типа: объявление, выражение, присваивание и т. д.
Мы рассматривали возможность добавить вариантные типы в Go, но после обсуждения решили отказаться, так как они пересекаются с интерфейсами и создают путаницу. Например, что делать, если элементы вариантного типа сами являются интерфейсами?
Кроме того, часть задач, решаемых вариантными типами, уже покрыта самим языком. Пример с ошибками легко выразить через
значение интерфейса, хранящее ошибку, и type switch
для различения случаев. Пример с синтаксическим деревом также
можно реализовать, хотя и не так элегантно.
Ковариантные возвращаемые типы означали бы, что интерфейс
type Copyable interface {
Copy() interface{}
}
считается реализованным методом
func (v Value) Copy() Value
потому что Value
реализует пустой интерфейс. В Go же сигнатуры методов должны совпадать точно,
поэтому Value
не реализует Copyable
.
Go разделяет понятие того, что делает тип (его методы), и его реализацию. Если два метода возвращают разные типы, это значит, что они делают разные вещи.
Разработчики, которые хотят ковариантные возвращаемые типы, часто пытаются выразить иерархию типов через интерфейсы. В Go же более естественно сохранять чёткое разделение между интерфейсом и реализацией.
Удобство автоматических преобразований числовых типов в C перевешивается тем замешательством, которое они вызывают. Когда выражение становится беззнаковым? Каков его размер? Происходит ли переполнение? Является ли результат переносимым, независимо от машины, на которой он выполняется?
Кроме того, такие преобразования усложняют работу компилятора: «обычные арифметические преобразования» в C трудно реализовать, и они ведут себя непоследовательно на разных архитектурах.
Из соображений переносимости мы решили сделать всё ясным и однозначным, ценой необходимости явных преобразований в коде. Определение констант в Go — это значения произвольной точности, свободные от знака и ограничения по размеру — значительно смягчает этот недостаток.
Отдельная деталь: в отличие от C, типы int
и int64
в Go — это разные типы, даже если int
реализован как 64-битный.
Тип int
считается «общим», а если важен точный размер целого числа, Go поощряет явное указание.
Хотя Go строго относится к преобразованиям между переменными разных числовых типов, с константами язык гораздо гибче.
Литералы вроде 23
, 3.14159
и math.Pi
принадлежат к некоему «идеальному
числовому пространству»: они имеют произвольную точность и не подвержены переполнению или потере точности.
Например, значение math.Pi
в исходниках задано с точностью до 63 знаков после запятой, и выражения с этой константой
сохраняют точность выше, чем способен хранить float64
. Только при присваивании константы (или выражения с ней)
переменной — то есть размещении в памяти — она становится «обычным» числом с привычными свойствами и ограничениями
точности для данного типа.
Кроме того, поскольку константы — это просто числа, а не значения конкретного типа, их можно использовать свободнее, чем переменные. Это смягчает строгие правила преобразования типов. Например, выражение
sqrt2 := math.Sqrt(2)
не вызывает жалоб компилятора, потому что идеальное число 2 может быть безопасно и точно преобразовано в float64
для
вызова math.Sqrt
.
Подробнее о константах см. в блог-посте Constants.
По той же причине, что и строки: это настолько мощная и важная структура данных, что предоставление одной качественной реализации с синтаксической поддержкой делает программирование проще и приятнее.
Мы считаем, что реализация map
в Go достаточно сильна, чтобы покрыть подавляющее большинство случаев. Если конкретное
приложение может выиграть от собственной реализации, её всегда можно написать, но синтаксически это будет менее удобно.
Такой компромисс нам кажется разумным.
Поиск в map
требует оператора равенства, которого у срезов нет.
Равенство для срезов не реализовано, потому что оно не имеет чёткой семантики: возникают вопросы поверхностного или
глубокого сравнения, сравнения по указателю или по значению, обработки рекурсивных типов и т. д.
Мы можем вернуться к этой теме позже (и добавление равенства для срезов не сломает существующие программы), но пока, без ясного понимания того, что именно должно означать равенство срезов, проще было оставить это поведение как есть.
Для структур и массивов равенство определено, поэтому их можно использовать как ключи в map
.
У этого решения длинная история. Изначально map
и каналы синтаксически были указателями, и было невозможно объявить
или использовать их без указателя. Также долго обсуждалось, как именно должны работать массивы.
В итоге мы решили, что строгое разделение указателей и значений делает язык менее удобным. Изменение этих типов так, чтобы они вели себя как ссылки на связанные общие структуры данных, решило проблему. Да, это добавило немного лишней сложности в язык, но сильно повысило удобство: Go стал более продуктивным и комфортным языком.
Для доступа к документации из командной строки инструмент go имеет подкоманду doc, которая выводит текстовую документацию для объявлений, файлов, пакетов и т. д.
Глобальная страница поиска пакетов — pkg.go.dev/pkg/ — запускает сервер, который извлекает документацию пакетов из исходного кода Go в интернете и отображает её в виде HTML со ссылками на объявления и связанные элементы. Это самый простой способ узнать о существующих библиотеках Go.
В ранние дни проекта существовала похожая программа — godoc
, которую можно было запускать для извлечения документации
из файлов на локальной машине; pkg.go.dev/pkg/ по сути является её потомком.
Другой потомок — команда pkgsite
, которая, как и godoc
,
может работать локально, хотя пока не интегрирована в результаты, которые показывает go doc
.
Явного «гайдлайна» по стилю нет, хотя существует узнаваемый «стиль Go».
В Go установились соглашения, которые направляют решения по именованию, форматированию и организации файлов. Документ Effective Go содержит советы по этим темам.
Более того, программа gofmt
— это pretty-printer, созданный для того, чтобы автоматически применять правила
оформления кода. Она заменила собой традиционные своды правил и оговорок, которые оставляли место для интерпретаций.
Весь код в репозитории Go, а также подавляющее большинство open source-кода, проходит через gofmt
.
Также полезен документ Go Code Review Comments — это сборник коротких заметок об идиомах Go, которые часто упускают программисты. Это удобный справочник для тех, кто делает code review Go-проектов.
Исходный код библиотек находится в каталоге src
репозитория.
Если вы хотите внести значительное изменение, пожалуйста, обсудите его в списке рассылки перед началом работы.
Подробнее о том, как участвовать в развитии проекта, см. в документе Contributing to the Go project.
Во многих компаниях исходящий трафик разрешён только через стандартные TCP-порты 80 (HTTP) и 443 (HTTPS), а трафик на другие порты блокируется, включая TCP-порт 9418 (git) и TCP-порт 22 (SSH).
При использовании HTTPS вместо HTTP, git
по умолчанию проверяет сертификаты, обеспечивая защиту от атак
«man-in-the-middle», подслушивания и подмены данных. Поэтому команда go get
использует HTTPS — это безопаснее.
git
можно настроить так, чтобы он аутентифицировался через HTTPS или использовал SSH вместо HTTPS.
Для аутентификации через HTTPS можно добавить строку в файл $HOME/.netrc
, который читает git:
machine github.com login *USERNAME* password *APIKEY*
Для аккаунтов GitHub в качестве пароля можно использовать personal access token.
Также git
можно настроить так, чтобы он использовал SSH вместо HTTPS для всех URL с определённым префиксом.
Например, для GitHub добавьте в ~/.gitconfig
:
[url “ssh://[email protected]/”]
insteadOf = https://github.com/
При работе с приватными модулями, но с использованием публичного прокси для зависимостей, может потребоваться задать
переменную окружения GOPRIVATE
. Подробнее см. в разделе private modules.
В тулчейн Go встроена система управления версиями наборов связанных пакетов — модули. Модули появились в Go 1.11 и считаются готовыми к продакшену начиная с Go 1.14.
Чтобы создать проект с модулями, выполните команду go mod init
.
Она создаст файл go.mod
, в котором будут отслеживаться версии зависимостей:
go mod init example/project
Чтобы добавить, обновить или откатить зависимость, используйте go get
:
go get golang.org/x/[email protected]
Подробнее см. в учебнике «Create a module». См. также раздел Developing modules — он посвящён управлению зависимостями с помощью модулей.
Пакеты внутри модулей должны сохранять обратную совместимость по мере развития, следуя правилу совместимости импортов:
Если старый и новый пакет имеют один и тот же путь импорта, новый пакет обязан быть обратно совместимым со старым.
Хорошая справка по этому поводу — руководство по совместимости Go 1: не удаляйте экспортируемые имена, используйте именованные литералы композитных типов и т. д. Если нужна новая функциональность, лучше добавить новое имя, а не изменять старое.
Модули закрепляют это правило через семантическое версионирование и
semantic import versioning. Если необходима несовместимая смена API, следует выпускать новый мажорный релиз модуля.
Для модулей с мажорной версией 2 и выше требуется суффикс версии
в пути импорта (например, /v2
). Это сохраняет правило совместимости импортов: пакеты разных мажорных версий одного
модуля имеют разные пути импорта.
Как и во всех языках семейства C, в Go всё передаётся по значению. То есть функция всегда получает копию передаваемого объекта, как если бы выполнялось присваивание значения параметру.
Например, при передаче значения int
функция получает копию этого числа. При передаче указателя копируется сам
указатель, но не данные, на которые он ссылается.
(См. раздел о методах со значениями и указателями,
где обсуждается, как это влияет на получателей методов.)
Значения map
и срезов ведут себя как указатели: это дескрипторы, которые содержат ссылки на данные.
Копирование map
или среза не копирует данные, на которые они ссылаются. Копирование интерфейсного значения создаёт
копию объекта, хранящегося в интерфейсе:
- если внутри интерфейса структура, копируется сама структура
- если внутри интерфейса указатель, копируется указатель, но не данные, на которые он указывает
Важно понимать: речь идёт о семантике операций. Конкретные реализации компилятора могут применять оптимизации, чтобы избежать лишних копирований, но только если они не меняют семантику.
Практически никогда. Указатели на интерфейсные значения возникают только в редких, хитрых ситуациях, например когда нужно скрыть тип интерфейсного значения для отложенной обработки.
Распространённая ошибка — передавать в функцию, ожидающую интерфейс, указатель на интерфейсное значение. Компилятор выдаст ошибку, но ситуация может быть запутанной, потому что иногда указатель действительно нужен, чтобы удовлетворить интерфейсу.
Важно понять: хотя указатель на конкретный тип может удовлетворять интерфейсу, за одним исключением указатель на интерфейс никогда не может удовлетворить интерфейсу.
Рассмотрим пример:
var w io.Writer
Функция fmt.Fprintf принимает первым аргументом значение, реализующее io.Writer — то есть что-то с методом Write. Поэтому корректный вызов выглядит так:
fmt.Fprintf(w, "hello, world\n")
Но если мы передадим адрес w, программа не скомпилируется:
fmt.Fprintf(&w, "hello, world\n") // Ошибка компиляции
Единственное исключение: любое значение, даже указатель на интерфейс, можно присвоить переменной пустого
интерфейсного типа – interface{}
. Но даже в этом случае это почти наверняка ошибка: результат будет сбивать с толку.
func (s *MyStruct) pointerMethod() { } // метод для указателя
func (s MyStruct) valueMethod() { } // метод для значения
Для программистов, не привыкших к указателям, разница между этими примерами может быть запутанной,
но на самом деле всё просто. При определении метода для типа его получатель (s
в примере) ведёт себя точно так же,
как если бы это был аргумент функции. Таким образом, вопрос «делать получатель значением или указателем» равнозначен
вопросу «делать аргумент функции значением или указателем».
Есть несколько критериев выбора:
-
Необходимость изменять получатель. Если метод должен модифицировать получатель, то он обязан быть указателем (Срезы и
map
ведут себя как ссылки, но, например, чтобы изменить длину среза внутри метода, получатель всё равно должен быть указателем.) В примере выше, еслиpointerMethod
изменяет поляs
, эти изменения будут видны вызывающему коду. АvalueMethod
получает копию аргумента (так определяется передача по значению), поэтому изменения останутся невидимыми.Кстати, в Java получатели методов всегда являются указателями, хотя эта особенность несколько скрыта (и в последнее время в язык добавляют методы со значениями). В Go же именно получатели-значения — это необычное явление.
-
Эффективность. Если получатель — это крупная структура (
struct
), то дешевле использовать указатель. -
Согласованность. Если часть методов типа должна иметь указатель-получатель, остальные тоже стоит сделать такими — чтобы набор методов был единообразным вне зависимости от того, как используется тип. Подробнее см. раздел о наборах методов.
-
Простота для мелких типов. Для базовых типов, срезов и небольших структур передача значения очень дёшева. Если семантика метода не требует указателя, то получатель-значение будет и эффективен, и ясен.
Кратко: new
просто выделяет память, а make
инициализирует срезы, map
и каналы.
Подробнее см. в соответствующем разделе Effective Go.
Размеры типов int
и uint
зависят от реализации, но на одной платформе они всегда одинаковы.
Для переносимости кода, который зависит от конкретного размера, следует использовать типы с явным указанием размера,
например int64
.
На 32-битных машинах компиляторы по умолчанию используют 32-битные целые числа, а на 64-битных — 64-битные. (Исторически это было не всегда так.)
С другой стороны, скаляры с плавающей точкой и комплексные типы всегда имеют фиксированный размер
(в Go нет базовых типов float
или complex
), потому что программист должен осознавать точность при работе
с числами с плавающей точкой.
Типом по умолчанию для (нетипизированной) вещественной константы является float64
.
Таким образом, запись
foo := 3.0
объявит переменную foo типа float64.
Если же нужна переменная float32, то её тип должен быть указан явно:
var foo float32 = 3.0
Или константе можно задать тип с помощью приведения:
foo := float32(3.0)
С точки зрения корректности программы это знать не нужно. Каждая переменная в Go существует, пока на неё есть ссылки. Где именно она хранится — не имеет значения для семантики языка.
Однако место хранения влияет на эффективность. Когда возможно, компилятор Go размещает локальные переменные в стековом фрейме функции. Но если компилятор не может доказать, что переменная не будет использоваться после возврата из функции, он обязан выделить её в куче, управляемой сборщиком мусора, чтобы избежать ошибок с висячими указателями. Кроме того, если локальная переменная очень большая, её также может быть разумнее хранить в куче, а не на стеке.
В текущих компиляторах переменные, у которых берут адрес, считаются кандидатами на размещение в куче. Однако базовый механизм escape analysis умеет распознавать случаи, когда такие переменные не «живут» дольше функции, и позволяет хранить их в стеке.
Аллокатор памяти Go резервирует большую область виртуальной памяти как арену для выделений. Эта виртуальная память локальна для конкретного процесса Go; её резервирование не отбирает память у других процессов.
Чтобы узнать фактический объём памяти, выделенный процессу Go, используйте команду Unix top
и смотрите колонку
RES
(Linux) или RSIZE
(macOS).
Описание атомарности операций в Go можно найти в документе Модель памяти Go.
Низкоуровневые средства синхронизации и атомарные примитивы доступны в пакетах sync
и
sync/atomic
. Они хорошо подходят для простых задач — например, увеличения счётчиков ссылок или
организации мелкомасштабного взаимного исключения.
Для более высокоуровневых задач, таких как координация работы множества конкурентных серверов, лучше использовать более выразительные приёмы. Go поддерживает такой подход через горутины и каналы. Например, можно структурировать программу так, чтобы только одна горутина в каждый момент времени отвечала за конкретный участок данных.
Этот подход отражён в оригинальной Go-притче:
Не общайтесь через общую память. Вместо этого разделяйте память через общение.
См. также код-пример Share Memory By Communicating и связанный с ним блог-пост для подробного разбора этой идеи.
Крупные конкурентные программы, как правило, комбинируют оба подхода.
То, будет ли программа работать быстрее на нескольких CPU, зависит от решаемой задачи. Язык Go предоставляет примитивы конкурентности — горутины и каналы, но конкурентность даёт параллелизм только тогда, когда сама задача по своей природе параллельна.
Задачи, которые являются строго последовательными, не могут быть ускорены добавлением CPU. А вот задачи, которые можно разбить на независимые части, способные выполняться параллельно, могут заметно выиграть в производительности.
Иногда добавление CPU может даже замедлить программу. На практике это происходит, если программа тратит больше времени на синхронизацию или обмен данными, чем на полезные вычисления. В таких случаях использование нескольких потоков ОС может ухудшить производительность, так как передача данных между потоками требует переключения контекста — достаточно дорогой операции, стоимость которой растёт при увеличении числа CPU.
Например, пример решета Эратосфена из спецификации Go запускает множество горутин, но реального параллелизма там нет; увеличение числа потоков (CPU) скорее замедлит выполнение, чем ускорит его.
Подробнее об этом см. доклад Concurrency is not Parallelism.
Количество CPU, доступных одновременно для выполнения горутин, контролируется переменной окружения GOMAXPROCS
.
По умолчанию её значение равно числу доступных ядер CPU. Поэтому программы, способные выполняться параллельно,
будут использовать все ядра автоматически.
Чтобы изменить количество CPU для параллельного выполнения, нужно задать переменную окружения или вызвать функцию
runtime.GOMAXPROCS
, которая настраивает рантайм на использование другого числа потоков.
Установка значения 1
полностью исключает параллелизм, заставляя горутины выполняться по очереди.
При этом рантайм может выделять больше потоков, чем указано в GOMAXPROCS
, чтобы обрабатывать несколько
одновременных I/O-запросов. GOMAXPROCS
определяет только количество горутин, которые могут выполняться одновременно;
однако заблокированных в системных вызовах горутин может быть гораздо больше.
Планировщик горутин в Go достаточно хорошо балансирует горутины и потоки и даже умеет прерывать выполнение горутины,
чтобы другие на том же потоке не оставались без внимания. Тем не менее он не идеален. Если вы наблюдаете проблемы с
производительностью, попробуйте настраивать GOMAXPROCS
для конкретного приложения.
Горутины не имеют имён — это просто анонимные рабочие. Они не предоставляют программисту уникального идентификатора,
имени или структуры данных. Многие ожидают, что оператор go
вернёт какой-то объект, с помощью которого можно будет
управлять горутиной позже, но это не так.
Основная причина анонимности горутин в том, чтобы при написании конкурентного кода был доступен весь язык Go, без ограничений. Когда у потоков или горутин есть имена, возникают шаблоны использования, которые могут ограничить то, что может сделать библиотека, работающая с ними.
Например, если дать горутине имя и построить вокруг неё модель, она становится «особенной», и появляется соблазн
связывать всю обработку именно с этой горутиной. Это мешает использовать несколько горутин (возможно, общих)
для выполнения задачи. Если бы пакет net/http
связывал состояние запроса с конкретной горутиной, клиенты не могли бы
использовать больше горутин при обработке запроса.
Опыт с библиотеками — например, для графических систем, где всё выполнение жёстко привязано к «главному потоку», — показывает, насколько неудобен и ограничивающ такой подход в языке с поддержкой конкурентности. Сам факт существования «особого» потока или горутины заставляет искажать архитектуру программы, чтобы избежать падений и других проблем, связанных с выполнением кода «не в том» потоке.
В тех случаях, когда горутина действительно особенная, в языке есть механизмы, такие как каналы, которые позволяют гибко взаимодействовать с ней.
Согласно спецификации Go, набор методов типа T
включает все методы с получателем (receiver
) типа T
,
а набор методов указателя *T
— все методы с получателем *T
или T
. То есть набор методов *T
включает методы
T
, но не наоборот.
Эта разница возникает потому, что если интерфейсное значение содержит указатель *T
, вызов метода может получить
значение через разыменование указателя. Но если интерфейсное значение содержит саму структуру T
, безопасного способа
получить её указатель нет. (Это позволило бы методу изменять содержимое значения внутри интерфейса,
что запрещено спецификацией языка.)
Даже в случаях, когда компилятор мог бы взять адрес значения и передать его в метод, изменения были бы потеряны для вызывающего кода, если метод меняет данные.
Например, если бы следующий код был допустим:
var buf bytes.Buffer
io.Copy(buf, os.Stdin)
он скопировал бы стандартный ввод во временную копию buf
, а не в сам buf
.
Такое поведение почти никогда не является ожидаемым и поэтому запрещено в Go.
Из-за особенностей работы переменных цикла до версии Go 1.22 (см. обновление в конце) возникала путаница при использовании замыканий вместе с конкурентностью.
Рассмотрим пример:
func main() {
done := make(chan bool)
values := []string{"a", "b", "c"}
for _, v := range values {
go func() {
fmt.Println(v)
done <- true
}()
}
// ждём завершения всех горутин перед выходом
for _ = range values {
<-done
}
}
Можно было ожидать вывод a, b, c
. Но на деле чаще всего выводился c, c, c
. Причина в том, что на каждой итерации
цикла используется одна и та же переменная v
. Все замыкания разделяют её, и когда горутина выполняется, значение v
уже могло измениться с момента запуска.
Чтобы обнаруживать такие и похожие проблемы заранее, используйте go vet
.
Чтобы «привязать» текущее значение v
к замыканию в каждой итерации, нужно создать новую переменную. Есть два способа.
Первый — передать её как аргумент в анонимную функцию:
for _, v := range values {
go func(u string) {
fmt.Println(u)
done <- true
}(v)
}
Здесь значение v
передаётся в функцию и становится доступным внутри как u
.
Второй способ — явно объявить новую переменную:
for _, v := range values {
v := v // создаём новую 'v'
go func() {
fmt.Println(v)
done <- true
}()
}
Такое поведение языка — не создавать новую переменную на каждой итерации — впоследствии было признано ошибкой. Начиная с Go 1.22, для каждой итерации действительно создаётся новая переменная, и эта проблема устранена.
В Go нет тернарного оператора. Того же результата можно добиться с помощью конструкции if-else
:
if expr {
n = trueVal
} else {
n = falseVal
}
Причина отсутствия ?:
в Go в том, что его часто используют для создания чрезмерно сложных и трудночитаемых выражений.
Форма с if-else
, хоть и длиннее, но однозначно яснее. Языку достаточно одной конструкции условного управления потоком.
Параметры типов позволяют использовать обобщённое программирование (generic programming), когда функции и структуры данных определяются через типы, которые указываются позже — в момент их использования.
Например, можно написать функцию, возвращающую минимум из двух значений любого упорядоченного типа, без необходимости писать отдельную реализацию для каждого типа.
Подробное объяснение с примерами см. в статье Why Generics?.
Компилятор может выбирать:
- компилировать каждую конкретную инстанциацию отдельно
- или объединять похожие инстанциации в одну реализацию
Второй подход похож на функцию с параметром-интерфейсом. Разные компиляторы могут принимать разные решения в зависимости от ситуации.
Стандартный компилятор Go обычно создаёт одну инстанциацию для всех аргументов типов с одинаковой формой. Форма определяется свойствами типа, такими как размер и расположение указателей.
В будущих версиях возможны эксперименты с балансом между временем компиляции, эффективностью выполнения и размером кода.
Базовая функциональность во всех языках схожа – можно писать типы и функции, использующие типы, которые указываются позже. Однако есть различия.
В Java компилятор проверяет дженерики на этапе компиляции, но удаляет информацию о типах во время выполнения.
Это называется type erasure.
Например, тип List<Integer>
на этапе компиляции превращается в недженерик List
во время выполнения.
Поэтому при использовании рефлексии в Java невозможно отличить List<Integer>
от List<Float>
.
В Go же информация о полном типе сохраняется и доступна через рефлексию.
Java также использует подстановочные знаки (List<? extends Number>
, List<? super Number>
) для реализации
ковариантности и контравариантности. В Go таких концепций нет, что делает дженерики проще.
Традиционно шаблоны в C++ не накладывали ограничений на типовые аргументы, но начиная с C++20 появились concepts, позволяющие задавать ограничения. В Go ограничения обязательны для всех параметров типов. В C++ concepts описываются как маленькие фрагменты кода, которые должны компилироваться с типовыми аргументами. В Go же ограничения — это интерфейсы, задающие допустимые типы.
Кроме того, C++ поддерживает метапрограммирование шаблонов, а Go — нет. На практике все компиляторы C++ компилируют шаблон при каждой инстанциации. Go может использовать разные подходы в зависимости от ситуации.
В Rust ограничения называются trait bounds. Ассоциация между trait и типом должна быть явно определена либо в crate с trait, либо в crate с самим типом. В Go же типы удовлетворяют ограничениям неявно, как и при реализации интерфейсов.
В стандартной библиотеке Rust есть traits для операций вроде сравнения или сложения. В Go стандартная библиотека
таких интерфейсов не содержит — их можно описать в пользовательском коде. Единственное исключение — встроенный
интерфейс comparable
, описывающий свойство, которое нельзя выразить в системе типов.
Python не является статически типизированным языком, поэтому можно сказать, что все его функции всегда дженерики: их можно вызвать с любыми типами, а ошибки будут выявлены только во время выполнения.
В Java и C++ для списка параметров типов применяются угловые скобки – List<Integer>
в Java или
std::vector<int>
в C++.
Для Go этот вариант был недоступен из-за синтаксической неоднозначности. При разборе кода внутри функции, например:
v := F<T>
в момент, когда парсер встречает символ <
, непонятно — это инстанциация обобщённой функции или выражение с оператором <
.
Разрешить это без информации о типах крайне сложно.
Рассмотрим пример:
a, b = w < x, y > (z)
Без информации о типах невозможно понять, что находится справа:
- пара выражений (
w < x
иy > z
), - или вызов обобщённой функции (
(w<x, y>)(z)
), возвращающей два значения.
Ключевое решение в дизайне Go — возможность разбора синтаксиса без знания типов. С угловыми скобками этого достичь нельзя.
Go не единственный язык, использующий квадратные скобки для дженериков – например, в Scala тоже применяются квадратные скобки.
Go допускает, что обобщённый (generic) тип может иметь методы, но кроме получателя (receiver) аргументы этих методов не могут использовать параметризованные типы. Мы не предполагаем, что в Go когда-либо появятся обобщённые методы.
Основная сложность — как реализовать их на практике. Например, как проверить, реализует ли значение в интерфейсе другой интерфейс с дополнительными методами?
Рассмотрим этот тип — пустую структуру с обобщённым методом Nop
, который возвращает свой аргумент для любого типа:
type Empty struct{}
func (Empty) Nop[T any](x T) T {
return x
}
Теперь представим, что значение Empty
сохранено в any
и передано в функцию, которая проверяет, что оно может делать:
func TryNops(x any) {
if x, ok := x.(interface{ Nop(string) string }); ok {
fmt.Printf("string %s\n", x.Nop("hello"))
}
if x, ok := x.(interface{ Nop(int) int }); ok {
fmt.Printf("int %d\n", x.Nop(42))
}
if x, ok := x.(interface{ Nop(io.Reader) io.Reader }); ok {
data, err := io.ReadAll(x.Nop(strings.NewReader("hello world")))
fmt.Printf("reader %q %v\n", data, err)
}
}
Как должен работать этот код, если x
имеет тип Empty
? Получается, что x
должен удовлетворять всем трём проверкам,
а также любым другим возможным вариантам.
- Для необобщённых методов компилятор генерирует код всех реализаций и включает их в итоговую программу
- Для обобщённых методов количество реализаций может быть бесконечным, поэтому нужен другой подход
-
Компиляция на этапе линковки После линковки собрать список всех возможных проверок интерфейсов, найти типы, которые могли бы их удовлетворить, но у которых нет скомпилированных методов, и снова вызвать компилятор для генерации кода. Это сильно замедлит сборку (особенно инкрементальную) и может даже привести к бесконечному циклу перекомпиляций.
-
JIT-компиляция во время выполнения Реализовать JIT, чтобы компилировать необходимые методы на лету. Но Go выигрывает за счёт простоты и предсказуемости AOT-компиляции, и внедрение JIT ради одной возможности слишком усложнит язык.
-
Медленный fallback Сгенерировать медленную реализацию для каждого обобщённого метода с таблицей функций для всех возможных операций над параметрами типов и использовать её при динамических проверках. Это сделает производительность непредсказуемой: методы с неожиданными типами будут сильно медленнее.
-
Запретить обобщённым методам удовлетворять интерфейсам Но интерфейсы — ключевая часть Go. Такой запрет недопустим с точки зрения дизайна.
Ни один из вариантов не является хорошим, поэтому было выбрано решение «ни один из вышеперечисленных».
Вместо методов с параметрами типов используйте функции верхнего уровня с параметрами типов или добавляйте параметры типов к самому типу-получателю.
Подробнее (с примерами) см. proposal.
Объявления методов обобщённого типа пишутся с получателем, который включает имена параметров типа.
Возможно, из-за схожести синтаксиса со спецификацией типов в месте вызова, некоторые считают, что это позволяет
создавать метод, специализированный для определённых аргументов типа, указывая конкретный тип в получателе, например string
:
type S[T any] struct { f T }
func (s S[string]) Add(t string) string {
return s.f + t
}
Однако это не работает, потому что слово string
воспринимается компилятором как имя параметра типа в методе.
Сообщение об ошибке компилятора будет примерно таким:
operator + not defined on s.f (variable of type string)
Это может сбивать с толку, потому что оператор +
корректно работает с предопределённым типом string
.
Но объявление переопределило, для этого метода, определение string
, и оператор не работает с этой новой,
не связанной версией string
.
Переопределять предопределённые имена допустимо, но это странная практика и часто является ошибкой.
Существует много случаев, когда программисту легко понять, какой аргумент типа для обобщённого типа или функции должен быть использован, но язык не позволяет компилятору вывести его автоматически.
Вывод типов намеренно ограничен, чтобы гарантировать, что никогда не будет неоднозначности в том, какой тип был выведен. Опыт работы с другими языками показывает, что неожиданный вывод типов может привести к значительной путанице при чтении и отладке программы.
Всегда можно явно указать аргумент типа, который должен использоваться в вызове.
В будущем могут быть добавлены новые формы вывода типов, при условии, что правила останутся простыми и понятными.
Поместите все файлы исходного кода пакета в отдельный каталог. Файлы исходного кода могут свободно обращаться к элементам из других файлов; никаких предварительных объявлений или заголовочных файлов не требуется.
Кроме того, что код разбит на несколько файлов, пакет будет компилироваться и тестироваться точно так же, как и пакет из одного файла.
Создайте новый файл, имя которого оканчивается на _test.go
, в том же каталоге, что и исходные файлы пакета.
Внутри этого файла подключите пакет testing
:
import "testing"
и напишите функции следующего вида:
func TestFoo(t *testing.T) {
...
}
Запустите go test
в этом каталоге. Эта команда находит функции, начинающиеся с Test
, собирает тестовый бинарник
и выполняет его.
Подробнее см. в документации:
Стандартный пакет Go testing
упрощает написание модульных тестов, но в нём отсутствуют
функции, предоставляемые фреймворками тестирования других языков, например функции утверждений (assertion functions).
В раннем разделе этого документа уже объяснялось, почему в Go нет утверждений,
и те же аргументы применимы к использованию assert
в тестах.
Правильная обработка ошибок означает, что после сбоя одного теста остальные тесты всё равно выполняются, чтобы
разработчик получил полную картину того, что именно пошло не так. Гораздо полезнее, если тест сообщит, что isPrime
даёт неверный результат для 2, 3, 5 и 7 (или для 2, 4, 8 и 16), чем если он сообщит только про ошибку на 2
и прекратит работу. Программист, запустивший тест, может вовсе не знать код, в котором произошла ошибка.
Время, потраченное сейчас на хорошее сообщение об ошибке, потом окупится, когда тест снова «сломается».
Связанная мысль: фреймворки тестирования часто превращаются в мини-языки со своими условными конструкциями, управляющими структурами и механизмами вывода. Но в Go всё это уже есть. Зачем изобретать заново? Мы предпочитаем писать тесты на Go: это один язык вместо двух, и тесты остаются простыми и понятными.
Если кажется, что дополнительный код для хороших сообщений об ошибках избыточен, можно использовать табличные тесты — запуск теста по списку входных и выходных значений, заданных в структуре данных (Go отлично поддерживает литералы структур данных). Тогда работа по написанию одного теста и сообщений об ошибках «распределяется» на множество кейсов.
Стандартная библиотека Go полна наглядных примеров, например
тесты форматирования для пакета fmt
.
Назначение стандартной библиотеки — поддерживать рантайм, обеспечивать связь с операционной системой и предоставлять ключевую функциональность, которая требуется многим Go-программам, например форматированный ввод/вывод и сетевые возможности. В неё также входят элементы, важные для веб-разработки, включая криптографию и поддержку стандартов вроде HTTP, JSON и XML.
Нет чётких критериев, определяющих, что именно включается в стандартную библиотеку, так как долгое время это была единственная библиотека Go. Однако сегодня существуют критерии, определяющие добавление новых пакетов.
Новые дополнения в стандартную библиотеку редки, и планка для включения очень высока. Код в стандартной библиотеке несёт значительные расходы на поддержку (часто за счёт тех, кто не был его автором), подпадает под обещание совместимости Go 1 (что блокирует исправления любых изъянов в API), и следует графику релизов Go, что мешает быстро доставлять исправления пользователям.
Большинство нового кода должно жить за пределами стандартной библиотеки и быть доступным через команду
go get
. Такой код может иметь собственных мейнтейнеров, цикл релизов
и собственные гарантии совместимости. Найти пакеты и прочитать их документацию можно на pkg.go.dev.
Хотя в стандартной библиотеке есть элементы, которые туда не совсем вписываются (например, log/syslog
),
мы продолжаем их поддерживать из-за обещания совместимости Go 1. Но при этом мы поощряем, чтобы новый код
публиковался вне стандартной библиотеки.
Существует несколько промышленных компиляторов для Go, а также ряд других в разработке для различных платформ.
Компилятор по умолчанию — gc
, он входит в дистрибутив Go как часть поддержки команды go
.
Изначально gc
был написан на C из-за сложностей с бутстраппингом — нужен был бы компилятор Go, чтобы поднять среду Go.
Но с релиза Go 1.5 компилятор стал программой на Go. Переписывание из C в Go было выполнено с помощью инструментов
автоматического преобразования, что описано в дизайн-документе и в
докладе.
Таким образом, компилятор теперь "сам себя компилирует" (self-hosting), что возвращает нас к проблеме бутстраппинга. Решение — иметь рабочую установку Go уже заранее, как обычно бывает с рабочим C-компилятором. История того, как поднять новую среду Go из исходников, описана здесь и здесь.
gc
написан на Go, использует рекурсивный спускающийся парсер и собственный загрузчик (loader),
также написанный на Go, но основанный на загрузчике Plan 9, для генерации ELF/Mach-O/PE бинарников.
Компилятор Gccgo — это фронтенд, написанный на C++ с рекурсивным спускающимся парсером, подключённый к стандартному бэкенду GCC. Экспериментальный LLVM-бэкенд использует тот же фронтенд.
В начале проекта рассматривалась возможность использования LLVM для gc
, но было решено, что он слишком громоздкий
и медленный, чтобы достичь целей по производительности. Более того, начиная с LLVM, было бы сложнее внедрить
изменения ABI и связанные вещи (например, управление стеком), которые нужны Go, но отсутствуют в стандартной C-среде.
Go оказался отличным языком для написания компилятора Go, хотя изначально это не планировалось. То, что проект не был self-hosting с самого начала, позволило Go сосредоточиться на своём первоначальном назначении — сетевых серверах. Если бы мы решили, что Go должен компилировать сам себя ещё на ранней стадии, мы могли бы получить язык, больше ориентированный на построение компиляторов — достойная цель, но не та, которую мы ставили изначально.
Хотя у gc
есть собственная реализация, в стандартной библиотеке доступны лексер и парсер,
а также проверка типов. Компилятор gc
использует модифицированные версии этих библиотек.
Снова из-за проблем бутстраппинга код рантайма изначально был написан в основном на C (с небольшим количеством ассемблера), но позже был переведён на Go (за исключением некоторых ассемблерных частей).
Рантайм Gccgo использует glibc. Компилятор gccgo реализует горутины с помощью техники сегментированных стеков (segmented stacks), поддержка которых была добавлена в результате недавних изменений в компоновщике gold. Gollvm аналогично построен на соответствующей инфраструктуре LLVM.
Линкер в инструментальной цепочке gc по умолчанию создаёт статически слинкованные бинарники.
Поэтому все Go-бинарники включают в себя Go runtime, а также информацию о типах времени выполнения,
необходимую для поддержки динамических проверок типов, рефлексии и даже трассировки стека при panic
.
Для сравнения: простой C-пример "hello, world", скомпилированный и статически слинкованный с помощью gcc на Linux,
весит примерно 750 КБ, включая реализацию printf
.
Эквивалентная Go-программа, использующая fmt.Printf
, весит пару мегабайт,
но при этом включает куда более мощную поддержку времени выполнения, а также информацию о типах и отладке.
Go-программу, скомпилированную gc, можно слинковать с флагом:
-ldflags=-w
Этот флаг отключает генерацию DWARF, удаляя отладочную информацию из бинарника без потери функциональности. Это может существенно уменьшить размер итогового файла.
Наличие неиспользуемой переменной может указывать на баг, а лишние импорты замедляют компиляцию, и это замедление может стать существенным по мере роста проекта и команды. Поэтому Go отказывается компилировать программы с неиспользуемыми переменными или импортами, жертвуя краткосрочным удобством ради долгосрочной скорости сборки и ясности кода.
Тем не менее, при разработке такие ситуации часто возникают временно, и бывает раздражающе удалять их вручную перед компиляцией.
Некоторые просили добавить опцию компилятора, чтобы отключить эти проверки или хотя бы превратить их в предупреждения. Однако этого не сделали, потому что флаги компилятора не должны менять семантику языка, а компилятор Go не выдаёт предупреждений — только ошибки, которые останавливают сборку.
Причины отсутствия предупреждений:
- Если о чём-то стоит пожаловаться, это стоит исправить в коде. (И наоборот: если это не стоит исправлять, то и не стоит упоминать.)
- Предупреждения создают «шум» — множество малозначимых сообщений могут замаскировать настоящие ошибки.
Решение: используйте пустой идентификатор _
,
чтобы временно заглушить неиспользуемые элементы при разработке.
import "unused"
// Эта декларация помечает импорт как использованный.
var _ = unused.Item // TODO: удалить перед коммитом!
func main() {
debugData := debug.Profile()
_ = debugData // Используется только во время отладки.
....
}
Сегодня большинство Go-разработчиков используют утилиту goimports.
Она автоматически правит импорты, устраняя проблему неиспользуемых импортов. Её можно легко подключить к редактору или IDE для автозапуска при сохранении файла. Такая функциональность также встроена в gopls.
Это довольно распространённая ситуация, особенно на Windows, и почти всегда это ложное срабатывание. Коммерческие антивирусные программы часто «путаются» в структуре Go-бинарников, так как встречают их гораздо реже, чем программы, собранные на других языках.
Если вы только что установили дистрибутив Go и система сообщает об инфекции — это наверняка ошибка. Для полной уверенности можно проверить загрузку, сравнив контрольную сумму с указанной на странице загрузок: https://go.dev/dl/
В любом случае, если вы считаете, что срабатывание ошибочное, сообщите об этом производителю антивируса. Возможно, со временем антивирусные системы научатся правильно обрабатывать Go-программы.
Одной из целей дизайна Go было приближение к производительности C для сопоставимых программ. Однако на некоторых бенчмарках результаты Go заметно хуже, включая несколько из набора: https://go.googlesource.com/exp/+/master/shootout/
Самые медленные тесты зависят от библиотек, для которых в Go нет равноценных по скорости реализаций. Например, pidigits.go использует пакет для вычислений с произвольной точностью, а C-версии применяют GMP, написанную на оптимизированном ассемблере.
Бенчмарки, завязанные на регулярные выражения
(например, regex-dna.go) фактически сравнивают
Go-пакет regexp
с зрелыми и сильно оптимизированными библиотеками вроде PCRE.
В «играх с бенчмарками» обычно выигрывают за счёт тонкой оптимизации, а версии большинства тестов на Go ещё требуют доработки. Если сравнивать действительно сопоставимые программы на C и Go (пример: reverse-complement.go), то разрыв в производительности будет значительно меньше, чем показывает этот набор тестов.
Тем не менее, есть куда расти:
- компиляторы хорошие, но могут быть лучше
- многие библиотеки требуют серьёзной оптимизации
- сборщик мусора пока недостаточно быстрый
(Причём даже если бы он был мгновенным, избежание лишнего мусора всё равно давало бы большой выигрыш.)
В целом Go часто может быть весьма конкурентоспособным. С развитием языка и инструментов производительность многих программ значительно улучшилась.
См. блог-пост о профилировании Go-программ. Он довольно старый, но до сих пор содержит полезную информацию.
Кроме синтаксиса объявлений, различия незначительны и продиктованы двумя целями:
- Синтаксис должен быть «лёгким» — без множества обязательных ключевых слов, избыточности и сложных конструкций
- Язык должен быть простым для анализа и парсинга без таблицы символов Это значительно упрощает создание инструментов вроде отладчиков, анализаторов зависимостей, генераторов документации, плагинов для IDE и т. д. C и его потомки известны как крайне сложные в этом плане.
Они выглядят «наоборот» только для тех, кто привык к C.
В C идея в том, что переменная объявляется так, будто это выражение, описывающее её тип. Это изящно, но грамматики типов и выражений плохо сочетаются, что делает объявления запутанными (особенно с указателями на функции).
Go в основном разделяет синтаксис типов и выражений, и это упрощает ситуацию
(префикс *
для указателей — редкое исключение).
Пример на C:
int* a, b;
Здесь a
— указатель, а b
— нет.
Пример на Go:
var a, b *int
Здесь и a
, и b
— указатели. Это понятнее и логичнее.
Кроме того, краткая форма объявления :=
диктует, что полное объявление переменной должно следовать той же логике:
var a uint64 = 1
эквивалентно
a := uint64(1)
Также парсинг упрощается за счёт отдельной грамматики для типов, которая не совпадает с грамматикой выражений;
ключевые слова вроде func
и chan
сохраняют ясность.
См. статью: Go’s Declaration Syntax
Безопасность. Без арифметики указателей можно создать язык, в котором невозможно получить неправильный адрес, который случайно будет работать «корректно». Современные компиляторы и процессоры умеют оптимизировать циклы с индексами массивов так же эффективно, как и циклы с арифметикой указателей. Кроме того, отсутствие арифметики указателей упрощает реализацию сборщика мусора.
Без арифметики указателей ценность префиксной и постфиксной формы операторов инкремента снижается.
Вынеся их из иерархии выражений, мы упростили синтаксис языка и устранили двусмысленности с порядком вычислений
(например, в случаях f(i++)
или p[i] = q[++i]
). Упрощение здесь оказалось существенным.
Что касается постфиксной формы, то теоретически можно было выбрать любую, но постфикс традиционно используется дольше. Интересно, что настойчивость в использовании префикса появилась с STL — библиотекой для языка, в названии которого сам по себе используется постфиксный инкремент.
Почему используются фигурные скобки, но нет точек с запятой? И почему нельзя ставить открывающую скобку на новой строке?
Go использует фигурные скобки для группировки операторов, что привычно программистам из мира C-подобных языков.
Точки с запятой нужны парсерам, но не людям. Поэтому Go максимально избавился от них, позаимствовав приём из BCPL: в формальной грамматике точки с запятой присутствуют, но лексер вставляет их автоматически в конце каждой строки, которая может завершать оператор. Это отлично работает на практике, но накладывает ограничение на стиль скобок — открывающая фигурная скобка не может стоять на отдельной строке.
Иногда предлагают разрешить лексеру делать lookahead и позволить скобку на следующей строке. Мы с этим не согласны.
Так как код Go форматируется автоматически с помощью gofmt
, стиль должен быть единым.
Возможно, он отличается от привычного в C или Java, но Go — другой язык, и стиль gofmt
не хуже других.
Гораздо важнее то, что единый и машинно навязанный стиль для всех программ на Go приносит огромные преимущества.
Кроме того, выбранный стиль позволяет легко использовать стандартный синтаксис построчно в интерактивных реализациях Go, без особых правил.
Одна из самых трудоёмких частей системного программирования — управление временем жизни объектов в памяти. В C это делается вручную, что часто становится источником сложных ошибок. Даже в C++ и Rust, где есть вспомогательные механизмы, они сильно влияют на проектирование программ и создают дополнительную нагрузку на программиста.
Мы посчитали критически важным убрать эти накладные расходы, и развитие технологий сборки мусора в последние годы показало, что это можно сделать достаточно дёшево и с низкими задержками, чтобы быть жизнеспособным решением для сетевых систем.
Многие трудности параллельного программирования связаны именно с проблемой управления временем жизни объектов: когда они передаются между потоками, становится сложно гарантировать корректное освобождение. Автоматическая сборка мусора сильно упрощает написание конкурентного кода. Конечно, реализация сборки мусора в многопоточной среде — сама по себе непростая задача, но решить её один раз в языке лучше, чем решать её в каждой программе отдельно.
Кроме того, сборка мусора делает интерфейсы проще — им не нужно описывать, как управлять памятью.
Это не значит, что работа в других языках (например, Rust) по новым способам управления ресурсами не имеет смысла. Мы поддерживаем эти исследования и с интересом следим за их развитием. Но Go выбрал более традиционный путь — управление временем жизни исключительно через сборку мусора.
Текущая реализация — это mark-and-sweep. На многопроцессорных машинах сборщик работает параллельно с программой на отдельном ядре. В последние годы он был сильно улучшен: задержки сократились до субмиллисекунд даже для больших куч, что устранило один из главных доводов против GC в серверных приложениях. Работа над улучшением алгоритмов, снижением накладных расходов и задержек продолжается. Подробнее об этом рассказывает доклад ISMM 2018 Рика Хадсона из команды Go.
Важно отметить, что Go даёт программисту больше контроля над расположением данных в памяти и выделением объектов, чем большинство языков со сборкой мусора. Аккуратное использование языка позволяет существенно снизить нагрузку на GC. Подробнее см. статью Profiling Go programs.
(с) 2025, Андрей Крисанов (автор перевода)