Что мне не нравится в Go

Как человек, который любит Go

Я люблю Го. С первого дня, когда я начал использовать язык, я быстро влюбился в него. Он обеспечивает невероятную простоту, сохраняя при этом исключительную безопасность типов и молниеносную компиляцию. Скорость выполнения великолепна, параллелизм является первоклассным гражданином (и это преуменьшение), стандартная библиотека имеет ряд высокоуровневых интерфейсов, которые могут запустить любое приложение с очень небольшим количеством зависимостей, она компилируется непосредственно в исполняемый файл… Я мог бы продолжить. Хотя синтаксический буквализм Go требует некоторого привыкания по сравнению с другими языками стиля C, он кажется невероятно интуитивно понятным после небольшого использования.

У меня большой опыт работы с Java, но у меня большой опыт работы с C, C++, JavaScript, TypeScript и Python. Go — это первый язык, который я выучил, и я хочу использовать его везде и для всего. Несмотря на то, что я ненавижу клише, кликбейтную концепцию «убийцы продукта», как человек, который был профессиональным разработчиком Java, Go ощущается как убийца Java. Считаю ли я, что Java никуда не денется? Возможно нет. Считаю ли я, что Go когда-нибудь превзойдет Java по популярности? Вряд ли. Лично я, однако, не могу представить ни одной ситуации (кроме поддержки устаревших продуктов, слишком больших, чтобы их можно было переписать), в которой я предпочел бы использовать Java, а не Go.

В этот момент вы, вероятно, задаетесь вопросом: «Разве эта статья не должна была быть о вещах, которые вам не нравятся в Go?» Это совершенно справедливый вопрос, и я собираюсь ответить на него, но важно понять, насколько мне нравится Go, чтобы оценить, что мне нужно, чтобы жаловаться на него. Итак, без лишних слов, что именно можно критиковать?

Библиотечные функции изменяют свои параметры

Моя первая жалоба на Go — это то, что я заметил сразу: многие встроенные библиотечные функции изменяют свои параметры, а не возвращают новые результаты. Изменение параметров функции стирает границы между вводом и выводом и, в конечном счете, подрывает выразительность кода. Выразительность функции — это ее способность ясно передавать значение и намерение посредством своей подписи; чем яснее передан смысл, тем выразительнее функция. Выразительность — наиболее важный аспект поддерживаемого кода. Очевидно, что бывают случаи, когда производительность принесет пользу программе больше, чем выразительность, но с точки зрения удобства сопровождения выразительность всегда должна быть приоритетом.

Зачем же тогда Go это делает? Изменяя параметры, а не возвращая новые данные, компилятор Go может лучше отслеживать жизненный цикл данной переменной. Go — это язык со сборкой мусора (GC), поэтому каждый раз, когда функция возвращает указатель или тип, поддерживаемый указателем (например, срезом), это увеличивает вероятность того, что память потребуется выделить в куче, а не в стеке. Выделение кучи требует сборки мусора, а GC отнимает у вашей программы драгоценные циклы процессора.

Отказ от выделения кучи — и, следовательно, сокращение сбора мусора — может значительно улучшить производительность приложения. Эти оптимизации могут принести пользу программному обеспечению, отображающему графику с высоким FPS, но для большинства корпоративных приложений и повседневных услуг вполне возможно, что выгода для конечного пользователя будет практически незаметной. Можно привести разумный аргумент в пользу того, что язык должен свести к минимуму накладные расходы своих собственных библиотек, но когда скорость и простота использования являются конкурирующими целями, необходимо расставить приоритеты. Уже существует множество языков, обеспечивающих явное управление памятью для высокопроизводительных сценариев использования (C, C++, Rust и т. д.), поэтому Go действительно должен пойти на компромисс с одной из своих самых сильных сторон (простота использования), чтобы предоставить немного меньше ресурсов. GC циклы?

Как разработчик, я был бы признателен хотя бы за наличие более выразительных, интуитивно понятных альтернатив, функционально эквивалентных оптимизированным API. Несмотря на это раздражение, Go — далеко не единственный язык, виновный в этом, и на самом деле многие функции Go, вызывающие нарушение, имеют почти идентичные аналоги в Java и C++. Однако тот факт, что есть прецедент из других языков, не означает, что Go должен быть безупречным, и, в конечном счете, это то, что мне не нравится в этом языке.

Чтобы отыграть несколько очков, можно привести пример того, что требование указателя в качестве аргумента — это выразительный способ запросить модификацию в Go. Для этого я уступлю несколько моментов, хотя я до сих пор не нашел убедительного способа сделать это с помощью срезов (а именно срезы часто используются с этим шаблоном чаще всего).

Дженерики

В Go 1.18 появились дженерики, поэтому я несколько испорчен тем фактом, что мне пришлось ждать этой функции всего несколько месяцев¹, в то время как многие ветераны-разработчики Go ждали годами. Общая реализация Go немного похожа на TypeScript, и по большей части это хорошо. Как и в случае с TS, разработчик может легко ограничить универсальный тип, чтобы он соответствовал нескольким возможным известным типам или интерфейсам. Однако, в отличие от TS, Go не приходится иметь дело с багажом JavaScript, особенно в отношении undefined и null.

Чтобы было ясно, мне нравятся дженерики как языковая функция. При правильном использовании они могут повысить возможность повторного использования кода, что, в свою очередь, повышает согласованность и снижает риск ошибок. Когда я говорю, что мне не нравятся дженерики в Go, я имею в виду два аспекта: во-первых, реализация дженериков в Go оставляет желать лучшего, а во-вторых, отсутствие дженериков в языке в течение столь долгого времени привело ко многим уродливым анти- паттерны скрыты под поверхностью многочисленных библиотек, включая стандартную библиотеку Go.

Чтобы распаковать первый пункт, Go в настоящее время не поддерживает дженерики для методов или полей структуры. Отсутствие поддержки методов немного сбивает с толку, так как под капотом Go рассматривает методы как функции с получателем в качестве первого параметра. Если функции поддерживают дженерики, то почему не методы? Поддержка встраивания в Go снижает критичность универсальных полей структур, поскольку многое из того, для чего используются дженерики, можно прилично имитировать с помощью встраивания: просто держите «неуниверсальные» поля в отдельной структуре и встраивайте одну и ту же структуру пару раз. Тем не менее, встраивание не является идеальной заменой, поскольку операции и методы требуют повторной реализации для каждого варианта внешней структуры. Go мог бы переложить эту ответственность на компилятор, а не на разработчика, разрешив универсальные поля структур, но пока мы застряли на копировании и вставке.

Из-за того, что в Go в настоящее время отсутствуют дженерики, многие реализации структур данных должны использовать хакерские обходные пути с отражением, проверкой типов и приведением типов, чтобы обеспечить широкую поддержку различных типов. Это подводит меня ко второй жалобе. Go обещает безопасность типов, а затем сразу же ломает ее повсюду в своей стандартной библиотеке, используя псевдоуниверсальный обходной путь: interface{}. Использование пустого интерфейса Go не только олицетворяет собой анти-шаблон, но и проверка типов и отражение часто являются более медленными операциями (что по иронии судьбы несовместимо с его компромиссом между выразительностью и скоростью в моей предыдущей жалобе). Одним из худших моментов в этом является то, что сторонние библиотеки также в значительной степени приняли анти-шаблон пустого интерфейса, поэтому даже если Go в конечном итоге перенесет все свои библиотеки на дженерики, этот шаблон, вероятно, будет жить довольно долго во многих приложениях. кодовая база.

Функция make()

Функция make() — это решение Go для инициализации «примитивного типа». Большинство примитивов имеют разумное нулевое значение, но в Go карты, срезы и каналы — это все типы примитивов, которые выигрывают от динамической инициализации. Вполне возможно, а иногда даже разумно использовать нулевое значение карт и срезов (например, операции JSON и избежание нулевых возвратов), но в большинстве случаев make() — лучший выбор. В чем я не согласен с make(), так это в том, что он страдает от двух проблем, о которых я уже говорил.

Во-первых, make() не выразительно. Его полная сигнатура — func make(t Type, size …IntegerSize) Type, что очень мало говорит мне о том, как его правильно использовать. Несмотря на то, что технически это просто функция, между специальной обработкой, которую она получает от компилятора Go, и ее необходимостью для создания каналов, make() является такой же неотъемлемой частью Go, как и циклы for. Такой ход мыслей частично оправдывает его сигнатуру, но было бы так же просто — если не проще — предоставить функции NewMap(), NewSlice() и NewChan(), которые не имели бы двусмысленности. Я не собираюсь углубляться в эти альтернативы, так как уверен, что существует множество убежденных мнений о том, почему эти варианты могут быть проблематичными. Где я попаду в сорняки, так это в том, насколько легко make() ошибок (посмотрите, что я там сделал?).

Давайте посмотрим на make() в действии. m := make(map[int]int, 10) создает пустую карту с достаточным пространством для хранения десяти записей; len(m) возвращает 0. Вызов c := make(chan int, 10) создает канал с буфером на десять записей; len(c) возвращает 0. Вызов s := make([]int, 10) создает срез с десятью элементами, инициализированными их нулевым значением; len(s) возвращает 10. Видите проблему? Слишком легко случайно не заметить это важное различие, будь то при написании кода или при его просмотре. Чтобы получить поведение, которое вы ожидаете от срезов, требуется дополнительный аргумент: s := make([]int, 0, 10). len(s) в этом случае фактически вернет 0. Таким образом, вместо того, чтобы иметь более выразительные, отдельные инициализаторы для этих структур данных, Go предоставляет одну функцию с большей неоднозначностью и, следовательно, с большим риском неправильного использования.

В дополнение к моим мыслям о make(), вторая проблема, с которой я столкнулся, это его псевдогенеризм. Go обычно не допускает перегрузку функций, но make() получает специальный пропуск, чтобы притвориться перегруженным. Из-за этого специального прохода первый параметр make() может быть одного из нескольких типов. То же самое касается его возвращаемого типа. Для языка, который десять лет утверждал, что не нуждается в дженериках, Go пришлось нарушить множество собственных правил, чтобы заставить одну из самых важных функций работать без них. Мне это кажется неряшливым.

Плоская структура упаковки

Я родом из мира Java. Java-приложения, как правило, имеют множество пакетов. В этом мире родительские пакеты часто так же важны для контекста класса, как и само имя класса, поэтому для Go — «Java Killer» — было немного неприятно иметь такую ​​плоскую структуру пакета. Это не уникально для Go. Многие языки, в большей степени ориентированные на сценарии, такие как Python, имеют тенденцию использовать больше широты, чем глубины. Несмотря на то, что это относительно распространенная практика, мои мечты о том, что Go станет «заменой» Java, оказались разбиты вдребезги.

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

Простой подход Go к экспорту идентификаторов через заглавные буквы — это фантастика. На одну вещь, о которой мне приходится спорить на совещаниях по стилю моей команды, меньше. Шутки в сторону, как бы мне ни нравился этот выбор разработчиков Go, семантика пакетов и соглашение о плоской структуре уменьшают потенциальную ценность этой функции для кода приложения. В библиотечном коде простая концепция «экспортировать или нет» идеально подходит для определения общедоступного API. Для больших приложений, особенно веб-серверов, этого обычно недостаточно.

Веб-серверы обязательно будут иметь пакеты, которые никогда явно не используются другим кодом (кроме тестов), вместо этого они вызываются внешними клиентами и другими серверами по протоколам, таким как HTTP. Код в этих пакетах выиграет от абстракции так же, как и любой другой код, но при плоской структуре пакета неэкспортированные абстракции неизбежно будут видны другим областям пакета, которые не имеют права их использовать. Это приводит к загадке: следует ли нарушать соглашение о плоской структуре пакета, жертвовать удобочитаемостью и повторным использованием ради меньшей абстракции или просто позволить неэкспортируемым идентификаторам быть доступными в тех местах, где их быть не должно? Наличие этого вопроса означает признание того, что у Go есть проблема. Конечно, плоская структура — это условность, а не закон, но условность оказала огромное влияние на эволюцию Go, продиктовав множество новых функций, которые вошли в язык. Так что да, это может не быть явной особенностью Go, но, тем не менее, мне это не нравится из-за того, как сильно сообщество Go настаивает на этом как на лучшей практике.

Отсутствие сокращенных лямбд

Это определенно придирчиво, поэтому я сразу перейду к делу: в Go нет сокращения для лямбда-функций. Я знаю, что были предложения по ним и споры о том, почему они не нужны, но, несмотря на эти соображения, факт остается фактом: мне нравятся сокращенные лямбда-выражения, а в Go их нет.

Синтаксис функций Go короткий и лаконичный. Более того, функции в Go являются типами и могут быть назначены переменным, что мне знакомо, как человеку, который любил злоупотреблять «ссылками на методы», появившимися в Java 8. Тем не менее, когда я пишу в Go, бывают моменты, когда встраивание функции является наиболее подходящим решением проблемы, но даже для однострочников результирующий код часто бывает неуклюжим, особенно когда требуется оператор возврата. Я не думаю, что кто-нибудь сможет убедить меня, что func(x, y int) int { return x+y } красивее или читабельнее, чем (x, y) => (x+y). Спорьте о строгой типизации или явности сколько угодно, но мне все равно будет не хватать сокращенных лямбда-выражений².

Ни один язык программирования не безошибочен — я очень надеюсь, что люди, использующие Haskell, (все еще?) не читают это — и Go далеко не исключение. При этом, как и любое хорошее программное обеспечение, Go продолжает развиваться и улучшаться, со временем устраняя некоторые из этих проблем. Несмотря на эти проблемы, я по-прежнему люблю Go, и он по-прежнему остается моим предпочтительным языком для большинства проектов. Как программисты, мы часто закрываем глаза на многие проблемы наших любимых языков, но размышление даже о наших самых педантичных взглядах на дизайн — это то, что делает наше программное обеспечение лучше.

Для тех из вас, кто не пробовал Go, но подумывает об этом, не позволяйте этому разубедить вас; это фантастический инструмент, который почти наверняка улучшит вашу жизнь в процессе разработки. Что касается существующих Gophers, я надеюсь, что вы сочувствуете моим жалобам, но по-прежнему наслаждаетесь языком так же, как и я.

Сноски

¹ Эта статья была написана, когда Go 1.18 была последней стабильной версией.
² 21.06.2022 для ясности «лямбды» заменены на «сокращенные лямбды».