Как это началось
Случилось так, что мне пришлось участвовать в разработке на Go. До этого с Go я был знаком шапочно, большую часть времени работая с Rust. Изучить Go оказалось не сложно, но после того, как мы приступили к разработке, обнаружились неприятные моменты. Выяснилось, что по сравнению с Rust, в Go местами не хватает гибкости языка, местами есть способы выстрелить себе в ногу.
Так что спустя почти полтора года промышленной разработки на Go, я решил написать эту статью, где (без какого-то строгого порядка) перечислю моменты Go, которые вызывают боль у человека, пришедшего из другого языка (сравнивать я буду с Rust, так как это мой основной язык).
Понятно, что Go появился раньше Rust и многие удобные вещи, которые есть в Rust, тогда не были очевидны. Но тот факт, что Go всё ещё популярен, поддерживается и рекомендуется многими для написания новых программ, даёт повод его обоснованно критиковать.
Перечисления
Первый недостаток Go -- это отсутствие перечислений (enum).
Одно из основных применений перечислений -- описание типов с ограниченным множеством семантически разных значений (понятное дело, и у какого-нибудь int ограниченное множество значений, но семантически они друг от друга не отличаются).
Рассмотрим пример. Допустим, мы задаём настройки журналирования в конфигурации приложения. У нас есть разные цели журналирования (stdout, файл и т.д.), и у каждой цели задаётся фильтр уровня (если уровень записи ниже, чем фильтр цели, то запись игнорируется данной целью). Фильтр уровня может принимать значения: Trace, Debug, Info, Warn, Error.
Как эта задача решалась бы в Rust: сначала создаётся перечисление и поле в конфигурации:
enum LogLevel {
Trace,
Debug,
Info,
Warn,
Error,
}
struct StdoutConfig {
level: LogLevel,
// other fields
}
После этого можно в коде создавать конфиг, причём компилятор разрешит указывать только обозначенные 6 вариантов фильтра уровня:
// Корректное создание конфига
let config = StdoutConfig {
level: LogLevel::Debug,
// init other fields
};
// Компиляция не пройдёт, потому что варианта ABC не существует
let config = StdoutConfig {
level: LogLevel::ABC,
// init other fields
};
Кроме того, Rust позволяет делать сопоставление шаблонов (pattern matching). В нашем примере это просто будет выглядеть как switch. Важная особенность состоит в том, что компилятор принуждает программиста либо обработать все возможные варианты, либо создать ветку, которая будет обрабатывать все варианты, не подпавшие под какой-либо шаблон:
// Превращаем фильтр уровня в строку
// Корректное сопоставление шаблонов (все варианты рассмотрены)
match cfg.level {
LogLevel::Trace => return "Trace",
LogLevel::Debug => return "Debug",
LogLevel::Info => return "Info",
LogLevel::Warn => return "Warn",
LogLevel::Error => return "Error",
}
// Корректное сопоставление шаблонов (есть ветка, которая ловит все варианты)
match cfg.level {
LogLevel::Trace => return "Trace",
LogLevel::Debug => return "Debug",
_ => return "Other",
}
// Ошибка компиляции, сопоставление не покрывает все возможные варианты cfg.level
match cfg.level {
LogLevel::Trace => return "Trace",
LogLevel::Debug => return "Debug",
}
Это исключает ошибки, когда программист забыл обработать определённую ситуацию. Также, если в enum добавлен новый вариант, компилятор подсветит все места, где производится сопоставление шаблонов и скажет учесть там и новый вариант -- это сильно упрощает изменение кода.
Почему эта задача плохо решается с помощью Go. В Go для этой задачи придётся использовать обычный int (так например делает стандартный пакет slog):
type LogLevel int
const (
LevelTrace LogLevel = 0
LevelDebug LogLevel = 1
LevelInfo LogLevel = 2
LevelWarn LogLevel = 3
LevelError LogLevel = 4
)
type StdoutConfig struct {
level LogLevel
// other fields
}
После этого можно создавать конфиг, но компилятор не будет нам гарантировать, что задан корректный фильтр уровня:
// Можно так
config := StdoutConfig {
level: LevelDebug,
// init other fields
}
// А можно так
config = StdoutConfig {
level: 1337,
// init other fields
}
Отсюда следует, что придётся писать дополнительную функцию валидации, которая вернёт ошибку, если задан некорректный фильтр. Кроме этого, потенциально любая функция, которая принимает на вход LogLevel, не может гарантировать, что в параметре содержится допустимое значение, и должна его валидировать. Если вы так сделаете, вы будете в безопасности, но с большим количеством избыточных проверок. Если вы так не сделаете, появляется риск, что куда-то проскользнёт невалидированное недопустимое значение.
Кроме этого, если мы решим сделать switch по LogLevel, то компилятор промолчит в случае, когда мы забыли обработать один из вариантов:
str := ""
// Упс, мы забыли обработать Trace и Info, компилятор молчит
switch cfg.level {
case LevelDebug: str = "Debug"
case LevelWarn: str = "Warn"
case LevelError: str = "Error"
}
Управление памятью
Go и Rust реализуют два очень разных подхода к управлению памятью. Go -- язык со сборщиком мусора. Rust -- язык без сборщика мусора, но имеющий такие ограничения компилятора, которые в большинстве случаев делают невозможными ошибки при работе с памятью.
Главный неприятный момент при работе с памятью в Go -- это то, что не понятно, когда переменная хранится на стеке, а когда в куче. Переменная может быть неявно перемещена на кучу при взятии ссылки на неё, при конвертации в интерфейс.
С другой стороны, в Rust вы всегда явно перемещаете объект в кучу (помещая его в Vec, Box, и т.д.), это никогда не проиходит неявно. При этом программисту не нужно руками вызывать освобождение памяти, это делается автоматически в деструкторе, когда тип выходит из области видимости. В этом смысле Rust напоминает C++, только по сравнению с C++ в Rust есть дополнительные гарантии, которые исключают ошибки по типу use-after-free и dangling-pointer.
Можно было бы сказать, что это просто особенность Go -- что там более высокоуровневая модель памяти. Но так как он позиционируется как язык, на котором можно писать высоконагруженные системы, то это я считаю минусом. Да и само наличие инструментов для escape анализа (то есть анализа того, убежала ли переменная на кучу) говорит о том, что Go разработчикам приходится думать об этом моменте.
Генерики
К счастью, на данный момент в Go уже есть генерики, поэтому можно писать меньше повторяющегося кода. Однако их выразительность оставляет желать лучшего.
Генерики в методах
Много неудобств доставляет ограничение, запрещающее генерики в методах структур. Например, у нас есть структура, пишущая сообщения в консоль, и мы хотим передавать ей любой тип, который умеет форматировать себя в строку:
type ConsoleWriter struct {...}
// Мы не можем сделать такую функцию
func (cw *ConsoleWriter) write[T Stringer](val T) {
cw.writeInner(val.String())
}
// Приходится изворачиваться вот так
func write[T Stringer](cw *ConsoleWriter, val T) {
cw.writeInner(val.String())
}
// пример вызова
write(cw, 5)
// вместо cw.write(5)
Таким образом, Go подталкивает нас к использованию интерфейсов:
func (cw *ConsoleWriter) write(val Stringer) {
cw.writeInner(val.String())
}
// пример вызова
cw.write(5)
Плохо это тем, что, передавая значение в метод в виде интерфейса, есть шанс перенести это значение на кучу (если компилятор не догадается это соптимизировать). Такая предсказуемость не очень хороша с точки зрения производительности.
В Rust с этим не будет никаких проблем:
struct ConsoleWriter {...}
impl ConsoleWriter {
fn write<T: ToString>(&self, val: T) {
self.write_inner(val.to_string());
}
}
// пример вызова
cw.write(5);
Обобщение кортежей
В Go кортежи -- это прибитая гвоздями особая конструкция языка, которую можно использовать только в возвращаемом значении функции. Из-за того, что кортежи -- это не настоящий тип, мы не можем их использовать в генериках:
// Есть такая функция
func getStuffA() (string, string) {...}
// И такая функция
func getStuffB() string {...}
// И функция, которая принимает функцию
func doStuff[T any](f func() T) T {
return f()
}
// Пробуем вызвать doStuff
// Компилятор позволит сделать такой вызов
valB := doStuff(getStuffB)
// И не позволит такой
valA1, valA2 := doStuff(getStuffA)
getStuffA Обязательно надо обобщать типом func() (T1, T2). Это приводит к тому, что нам приходится добавлять второй вариант генерик функции, принимающий func() (T1, T2), что само по себе противоречит сути генериков.
В Rust такой проблемы не возникает:
fn get_stuff_a() -> (String, String) {...}
fn get_stuff_b() -> String {...}
fn do_stuff<T>(f fn() -> T) -> T {
return f();
}
// Пробуем вызвать doStuff
// Компилятор позволит сделать такой вызов
let valB = doStuff(getStuffB);
// И такой тоже
let (valA1, valA2) = doStuff(getStuffA);
Интерфейсы
Наличие интерфейсов в Go, как простой версии типажей из Rust, радует. Можно использовать полиморфизм, накладывать ограничения на генерики -- удобно.
Реализация интерфейса для структуры
Удивляет странный подход к имлементации интерфейсов на конкретных типах. Например, есть такой интерфейс:
type Sender interface {
Send(bytes []byte) (uint, error)
}
Он описывает тип, у которого есть метод Send для отправки куда-то массива байт. И чтобы реализовать этот интерфейс для какой-то структуры, нужно написать для неё метод Send:
type MySender struct {
// всякие поля
}
func (s *MySender) Send(bytes []byte) (uint, error) {
// отправка
}
То есть, мы явно не указываем, что реализуем Sender для MySender. Интерфейс считается реализованным для любого типа, у которого есть требуемые методы (в данном случае Send).
Для контраста как это было бы сделано в Rust:
// объявление типажа
trait Sender {
fn send(&self, bytes: &[u8]) -> Result<usize, SendError>;
}
// наша структура
struct MySender {
// всякие поля
}
// реализация типажа
impl Sender for MySender {
fn send(&self, bytes: &[u8]) -> Result<usize, SendError> {
// отправка
}
}
То есть мы явно указываем, что реализуем Sender для MySender.
Я считаю подход, использованный в Go, неудачным.
Во-первых, в сгенерированной по коду документации не будет указано, что структура реализует интерфейс. Представим, что есть какая-то генерик функция, которая принимает любой тип, реализующий определённый интерфейс, и я хочу понять, какие типы я могу передавать в эту функцию. Сделать я это могу только читая список функций у каждой структуры и сопоставляя со списком функций интерфейса, документация мне здесь не помощник.
Во-вторых, при реализации интерфейса на структуре легко допустить ошибку, например, опечатку в имени метода. И при этом об ошибке я узнаю не тогда, когда пишу реализацию, а когда попытаюсь использовать структуру в качестве интерфейса:
// пытаемся реализовать тот же интерфейс Sender
// с опечаткой в имени метода, на этом этапе ide будет молчать
func (s *MySender) Sennd(bytes []byte) (uint, error) {
// отправка
}
// пытаемся использовать структуру
// ошибка, так как MySender на самом деле не реализует интерфейс
var snd Sender = MySender{...}
Ошибка на этапе компиляции лучше ошибки в рантайме, но могло бы быть удобнее.
Злоупотребление any
Сделаю небольшую ремарку про злоупотребление интерфейсом any -- плохая практика, которая встречается не так уж редко. В некоторых библиотеках имеются функции, которые принимают аргументы типа interface{} (он же any) -- такие аргументы могут быть абсолютно любого типа. Понять, передал ли ты переменную правильного типа, можно только в рантайме. И даже если ты думаешь, что передал переменную правильного типа, скажем T, может оказаться, что там требуется *T. Это полный трэш и по сути игнорирование строгой типизации языка.
Исток этой проблемы, возможно, в том, что генерики были добавлены в язык не сразу, поэтому библиотеки, написанные до этого, используют такой костыль, позволяющий избежать дубликации кода.
Обработка ошибок
Проблема интерфейса error
В подходе, который в Go выбран для обработки ошибок, мне нравятся две вещи: передача ошибок через возвращаемое значение функций (по сравнению с исключениями в C++, Java) и возврат ошибки как отдельного значения (по сравнению с использованием "особых значений" как в C).
Что мне не нравится, так это использование интерфейса error. Этот интерфейс объявлен в стандартной библиотеке так:
type error interface {
// возвращает строку с описанием ошибки
Error() string
}
Он используется в Go повсеместно. Пример его использования:
func CheckedSqrt(x float64) (float64, error) {
if x <= 0.0 {
// возвращаем какое-то значение и ошибку
return 0.0, errors.New("x is less than zero")
}
// возвращаем результат и nil ошибку
return math.Sqrt(x), nil
}
То есть если возникла ошибка, возвращаем любой результат (который пользователь по-хорошему не должен использовать, но это на его совести) и ошибку. В качестве ошибки подойдёт любой тип, реализующий error. Если ошибок не возникло, возвращаем результат и nil значение в качестве ошибки.
Пример использования такой функции:
res, err := CheckedSqrt(x)
if err != nil {
fmt.Printf("Error occured: %v", err)
} else {
fmt.Printf("Square root of %v is %v", x, res)
}
Первая проблема с таким подходом в том, что по сигнатуре функции нельзя понять, какие ошибки внутри неё могут возникнуть. Это важно по той причине, что может быть потребность в разном поведении программы в зависимости от возникшей ошибки. Например, при отправке данных мы можем получить ошибку некорректных данных, а можем получить ошибку разрыва соединения -- в первом случае мы просто вернём ошибку наверх, во втором можем запросить переподключение.
Если повезёт, информация в видах ошибок будет содержаться в документации к функции, однако зачастую там не сказано, какие именно ошибки могут возникнуть в функции (в том числе в стандартной библиотеке, пример: https://pkg.go.dev/net#Dial). Поэтому приходится читать исходный код, на несколько уровней вглубь (если функция большая и вызывает другие функции). Если мне надо читать исходный код библиотеки для её использования, то это плохая библиотека, но подход с error делает эту проблему в Go повсеместной.
Вторая проблема состоит в том, что после того, как я узнал, какие типы ошибок возвращает функция, сложно написать условие, которое бы проверяло тип ошибки. Если вам повезло и библиотека возвращает разный тип ошибки в разных ситуациях (например, структуру FileNotFound в одном случае и структуру FileIsNotEmpty в другом), то можно возпользоваться преобразованием типов:
if _, ok := err.(FileNotFound); ok {
fmt.Println("File does not exist")
}
if _, ok := err.(FileIsNotEmpty); ok {
fmt.Println("Can't write to file with data")
}
Многословный и ненадёжный метод. Не дай Бог где-то вместо FileIsNotEmpty возвращается *FileIsNotEmpty и ваше преобразование типов никогда не сработает -- опять приходится читать исходный код, чтобы понять, что же там возвращается.
Если же ошибки, которые мы хотим проверить, создаются с помощью errors.New("File Is Not Empty"), то едиственный способ сделать проверку на такую ошибку -- это проверять текст ошибки:
if err.Error() == "File Is Not Empty" {
fmt.Println("Can't write to file with data")
}
Понятное дело, это очень хрупкий код, который сломается просто от обновления текста ошибки в библиотеке.
Для контраста приведу подход, который обычно используется в Rust. Функция возвращает перечисление Result, которое в стандартной библиотеке объявлено так:
enum Result<T, E> {
// вариант, который используется в случае успеха для возврата результата
Ok(T),
// вариант, который используется для возврата ошибки в случе неуспеха
Err(E),
}
В качестве типа ошибки обычно используется перечисление: можно зайти в документацию этого перечисления и увидеть, какие ошибки потенциально может вернуть функция. Пример функции, возвращающей ошибку:
// объявление типа ошибки
enum MathError {
LessThanZero,
}
fn checked_sqrt(x: f64) -> Result<f64, MathError> {
if x <= 0.0 {
// возврат ошибки
return Err(MathError::LessThanZero);
}
// возврат результата
return Ok(x.sqrt());
}
И пример её вызова:
let res = checked_sqrt(x);
match res {
Ok(res) => println!("square root of {x} is {res}"),
Err(MathError::LessThanZero) => println!("{x} is less than zero!"),
}
Проблема с возвратом ошибки отдельным значением
То, что в Go ошибка возвращается отдельным значением, создаёт свою проблему. Обычно ошибка кладётся в переменную с именем err и затем обрабатывается. И это приводит к проблемам -- так как ошибки в Go обычно имеют тип error, то каждая новая ошибка будет присваивать значение всё в ту же переменную:
res1, err := func1()
if err != nil {
...
}
res2, err := func2() // тут мы присвоили новое значение переменной err
if err != nil {
...
}
И у вас появляется переменная, существующая на протяжении всей функции, которую можно случайно использовать. Например:
res1, err := func1()
if err != nil {
return fmt.Errorf("Some error: %w", err)
}
res2, err := func2() // тут мы присвоили новое значение переменной err
if err != nil {
return fmt.Errorf("Some error1: %w", err)
}
if res2 != 10 {
// упс, при копипасте мы случайно оставили err
// и компилятор нам ничего не скажет
return fmt.Errorf("Must not be 10: %w", err)
}
И даже когда мы задаём своё имя каждой ошибке, мы можем случайно вернуть не ту ошибку:
res1, err1 := func1()
if err1 != nil {
return fmt.Errorf("Some error: %w", err1)
}
res2, err2 := func2()
if err2 != nil {
// упс, мы вернули пустую ошибку
return fmt.Errorf("Some error1: %w", err1)
}
В Rust такая ситуация невозможна, т.к. там ошибка хранится внутри результата функции, а у результата функции обычно какое-то своё уникальное имя:
let res1 = func1();
if let Err(e) = res1 {
...
}
let res2 = func2();
if let Err(e) = res2 {
...
}
Boilerplate возврата ошибок
Сложно не упомянуть о синтаксическом сахаре в виде ?. В Rust, если текущая функция возвращает Result и вызываемая функция возвращает Result, то проверить наличие ошибки в результате вызова можно через оператор ?. Представим у нас есть функция:
fn failable_inner() -> Result<i32, MyErr> {...}
И мы хотим получить её результат. Так это будет выглядеть с ?:
fn failable() -> Result<i32, MyErr> {
let res = failable_inner()?;
println!("{}", res);
}
Что будет аналогично такому коду:
fn failable() -> Result<i32, MyErr> {
let res = failable_inner();
if let Err(e) = res {
return res; // сразу возвращаем ошибку, если мы её получили
}
// если нет ошибки, то печатаем результат
println!("{}", res);
}
Это позволяет не засорять код повторяющимися проверками. В Go ваш код будет заполнен такими неизбежными конструкциями:
func failable() (int32, error) {
res, err = failableInner()
if err != nil {
return 0, err
}
fmt.Println("%v", res)
}
Конструкторы и инициализация
На уровне языка в Go и Rust ситуация с конструкторами одинаковая -- такой сущности нет. Для создания объекта пишется функция, принимающая нужные данные и возвращающая сконструированный объект. Пример на Go и Rust:
// структура, экземпляр которой мы создаём
type Foo struct {
bar int32
jar float64
}
// конструктор
func NewFoo(bar int32) Foo {
return Foo {
bar: bar,
jar: 0.0,
}
}
// ...
// пример вызова
foo := NewFoo(5)
// структура, экземпляр которой мы создаём
struct Foo {
bar: i32,
jar: f64,
}
impl Foo {
// конструктор
fn new(bar: i32) -> Foo {
return Foo {
bar,
jar: 0.
};
}
}
// ...
// пример вызова
// в Rust есть ассоциированные функции,
// поэтому нет необходимости добавлять Foo в имя конструктора
let foo = Foo::new(5);
В вопросе инициализации переменных языки отличаются.
В Go у любого типа есть "нулевое" значение, назначенное языком. У int это 0, у указателя это nil, у структуры это экземпляр, у которого все поля имеют нулевое значение, и так далее. Поэтому, даже если переменная при создании не была явно инициализирована значением, её использование допускается языком:
// создаём переменную без явной инициализации
var x int
// выведет "0"
fmt.Println(x)
В Rust компилятор запрещает использовать переменную, если ей не было присвоено значение:
// создаём переменную без инициализации
let x;
// ошибка, нельзя использовать неициализированную переменную
println!("{x}");
// инициализируем
x = 10;
// теперь ошибки нет, выведет "10"
println!("{x}");
Поэтому на практике культура использования конструкторов в языках разная. Рассмотрим пример из стандартных библиотек языков: допустим, у нас есть структура, поле которой atomic boolean. Мы хотим при создании этой структуры задавать значение этого поля в true.
Как это будет сделано в Go:
type Foo struct {
bar atomic.Bool
}
// конструктор структуры
// конструктор возвращает ссылку, потому что хранить атомик на стеке нельзя
func NewFoo() *Foo {
var foo = &Foo {
// инициализируем поле едиственным возможным способом
bar: atomic.Bool{},
}
// затем кладём туда true
foo.bar.Store(true)
return foo
}
Как это будет сделано в Rust:
struct Foo {
bar: AtomicBool,
}
impl Foo {
// конструктор структуры
fn new() -> Foo {
return Foo {
bar: AtomicBool::new(true),
};
}
}
В каком языке проще понять, какая причина стояла за каждой строкой кода?
Начнём с того, что в стандартной библиотеке Go решили, что для atomic.Bool не нужен конструктор и значение с этим типом не нужно инициализировать (или, как сделал я для явности, сделать инициализацию структуры без указания полей). При таком подходе сразу включается паранойя: "а я точно не должен там никакие поля инициализировать?". Сразу после этого ещё возникает вопрос: "а могу ли я при такой инициализации положить внутрь true?". Естественное следствие этих двух вопросов -- нырнуть в исходный код atomic.Bool. Документации насчёт того, как правильно создавать значение этого типа, конечно, нет. Сказано только, что нулевое значение -- это false.
Непонятно, почему в Rust есть конструктор, который может сразу создать atomic bool со значением true, а в Go нет.
И это частая ситуация в стандартной библиотеке Go -- для многих типов просто нет конструктора.
Если вынести за скобки конструкторы и говорить просто о задании структур, то Rust выдаст ошибку компиляции, если вы забыли иниицализировать одно из полей структуры:
struct User {
name: String,
pass_hash: String,
}
// ...
// ошибка компиляции, мы не задали поле pass_hash
let user = User {
name: "Петя".to_string(),
};
В Go компилятор промолчит и вам придётся использовать сторонние линтеры.
Деструкторы
Деструкторы (метод структуры, вызываемый, когда она покидает область видимости) удобны для повышения надёжности кода. Они уменьшают вред от человеческого фактора, когда программист забывает что-то сделать, и это делается автоматически при уничтожении переменной. Примеры таких случаев:
Мьютексы, rwlock'и. При их блокировке может возвращаться специальный объект, который в своём деструкторе автоматически освободит мьютекс или rwlock.
Всякие handler'ы. Например, мы запустили клиент, который создал фоновый поток с подключением к серверу и вернул нам handler, который позволяет что-то отправлять на сервер и производить другие действия с клиентом. Handler в своём деструкторе может автоматически закрывать подключение к серверу, если мы забудем это сделать.
В Rust присутствует возможность определять деструкторы на своих типах, и эта особенность, само собой, используется в стандартной библиотеке. Пример использования мьютекса:
let mutex = Mutex::new();
// Задаём скобками область видимости
{
// Получаем держатель блокировки
let guard = mutex.lock().unwrap();
// Что-то делаем
} // Здесь у guard автоматически вызовется деструктор, который освободит мьютекс
В Go концепции деструкторов не предусмотрено, поэтому там возможна такая ситуация:
mutex := Mutex{}
// Задаём скобками область видимости
{
mutex.Lock()
// Что-то делаем
}
// Упс, мы забыли разблокировать мьютекс
В Go в таких случаях принято использовать команду defer (она позволяет вызывать код при возврате из функции):
mutex := Mutex{}
mutex.Lock()
defer mutex.Unlock() // будет вызвано при возврате из функции
// Что-то делаем
Вариант, возможно, и более явный, но, мне кажется, возможность ошибки хуже неявности. Программист, который использует определённый тип, для которого нужно вызывать деструктор, должен это всегда держать в голове. И всем новым программистам в вашей команде это тоже придётся объяснять и следить за этим на ревью (особенно если это какой-то ваш кастомный тип, а не мьютекс, о котором все хотя бы слышали).
Стандартная библиотека
Стандартная библиотека Go местами удивляет. Чего только стоят несколько пакетов для одного и того же, существующих параллельно (например, https://pkg.go.dev/net и https://pkg.go.dev/net/netip).
При этом в языке, фишка которого якобы простота, едиственный способ добавить элемент в массив -- это такая конструкция:
arr = append(arr, newElement)
Это длинно, это сразу создаёт пространство для багов -- например, мы при копипасте случайно не поменяли первый аргумент append:
arr = append(arr1, newElement)
В языке есть методы, но почему-то работать со стандартными типами надо через специальные функции.
Непонятно, что мешало за столько лет существования языка добавить банальное:
arr.Push(newElement)
Или чего стоит создание массива -- один из лёгких способов получения багов. Например, мы хотим создать массив с предвыделенной памятью и добавить в него три числа:
myArr := make([]int, 10)
myArr = append(myArr, 1, 2, 3)
Видите баг? Мы создали массив, в котором уже есть 10 элементов. Должно быть так:
myArr := make([]int, 0, 10)
myArr = append(myArr, 1, 2, 3)
И такая ситуация не раз встретилась мне на практике. Непонятно, что мешало сделать для массива пустого и массива заполненного два отдельных конструктора.
И, конечно, стоит упомянуть об отсутствии перегрузки всяких хэш функций. Вот захотели вы явно задать, как будет считаться хэш для вашего типа, если он будет ключом в map . Если, например, в нём содержатся указатели и стандартный посчёт хэша работает некорректно, то вы не сможете переопределить хэш функцию, вот и выкручивайтесь.
И в целом в стандартной библиотеке генерики не используются никак, там повсеместно any.
Каналы
Каналы из Go я буду сравнивать с каналами из стандартной библиотеки Rust.
Каналы в Go сделаны неудобно. Начнём с того, что посылающая и принимающая сторона выражена одним и тем же типом:
mySender := make(chan int) // имеет тип chan int
myReceiver := mySender // имеет тип chan int
При этом в официальной документации грозят, чтобы вы ни в коем случае не закрывали канал с принимающей стороны, так как это может уронить программу. Возникает вопрос, а почему тогда не разделить интерфейсы посылающей и принимающей стороны? В Rust сделано именно так:
// my_sender имеет тип Sender, my_receiver имеет тип Receiver
let (my_sender, my_receiver) = mpsc::channel::<i32>();
Канал автоматически закроется при вызове деструктора my_sender. Если my_receiver в это время был заблокирован, ожидая сообщения из канала, то он разблокируется и получит ошибку.
P.S.
В комментариях заметили, что в Go всё же есть отдельные типы для принимающей стороны (<-chan) и отправляющей (chan<-). Это хорошо, но не понятно почему в туре по языку не рекомендуют ими пользоваться сразу.
В Go же вы можете читать из канала двумя способами -- с проверкой на закрытие и без:
// с проверкой, ok == true, если канал не закрыт
x, ok := <-myReceiver
// без проверки
x := <-myReceiver
Если вы используете чтение без проверки, то при закрытии канала вы прочитаете нулевое значение соообщения, что кажется не очень хорошим дизайном. Более короткий и удобный вариант должен быть более строгим, а не менее строгим.
Криво сделано и неблокирующее чтение из канала:
// блокирующее чтение
x := <-myReceiver
// неблокирующее чтение
select {
case x := <-myReceiver:
// ...
default:
}
Неочевидный и громоздкий код. В Rust всё тривиально:
// блокирующее чтение
let res = my_receiver.recv();
// неблокирующее чтение
let res = my_receiver.try_recv();
Макросы
Макросы в Rust печально известны сложностью написания, что сложно отрицать. Но это компенсируется их строгостью -- каждый макрос в Rust -- это, по сути, расширение компилятора, которое преобразует некоторый набор сходных символов в код. Благодаря этому, если вы вызвали макрос как-то не так, компилятор вам об этом сообщит.
В Go нет макросов как общей концепции, но есть пара костылей, придуманных для популярных юзкейсов.
Теги в структурах
В Go присутствует такая эзотерическая конструкция, как теги на полях структур. Представим, что мы хотим реализовать парсинг нашей структуры в json:
type Dto struct {
Count int `json:"user_count"`
}
Тег -- это арбитрарная строка, которую можно написать возле поля структуры. В случае некоторых опечаток встроенный линтер покажет предупреждение (например, если вы напишете json:"user_count), но компиляции это не помешает. В случае других опечаток никаких предупреждений показано не будет (например, jsn:"user_count").
Наличие тегов на структуре можно проверить через рефлексию (так работает, например, библотека json). То есть, это происходит в рантайме.
На контрасте, в Rust это будет выглядеть так:
#[derive(Serialize)]
struct Dto {
#[serde(rename = "user_count")]
pub count: i32,
}
Эти макросы будут превращены в код конвертации в json на этапе компиляции. Кроме того, правильность синтаксиса также будет проверена при компиляции (то есть, любая опечатка в вызове макроса будет ошибкой компиляции).
Кодогенерация
Для кодогенерации в Go вам надо написать специальный комментарий вида:
//go:generate команда
Тогда после вызова специальной команды go generate будет произведена генерация кода.
Это очень хрупкий подход, потому что корректность написанной вами команды не проверяется никем.
Также, из-за того, что генерация вызывается отдельной командой, её можно забыть сделать. В Rust макросы вычисляются при компиляции программы.
Модули
Циклы
Основную головную боль от модулей в Go вызывает то, что здесь запрещены циклические зависимости. То есть, модуль А не может использовать модуль Б, если модуль Б уже использует модуль А. Например, представим, что мы используем библиотеку errorx, и мы объявили в родительском модуле namespace ошибок и хотим в дочернем модуле задать дочерний namespace ошибок.
/module
- errors.go
/submodule
- errors.go
А мы не можем, потому что родительский модуль использует дочерний, поэтому дочерний модуль не может импортировать namespace ошибок из родительского. Это ограничение никак не помогает при разработке, вызывая только фрустрацию.
В Rust же такой проблемы нет вообще.
Именование
В Go выбран странный подход к именованию модулей. В начале каждого файла должно быть написано название модуля, к которому относится файл. При этом, если в рамках одной папки есть файлы с разными именами модулей, мы получим ошибку компиляции. Спрашивается, зачем эта бюрократия? Почему нельзя указать название модуля в одном месте? Например, это может быть само имя папки.
Итог
Про Go любят говорить, что его философия -- это простота (а также любят говорить, что каждая новая концепция, добавляемая в язык, лишает его простоты). Но в чём смысл этой простоты, если она делает инструмент негибким, неудобным для применения? Конечно, это зависит от области применения. Наверное, какие-нибудь небольшие программы и удобно писать на Go, но на практике его используют и предлагают для использования в том числе для больших и сложных проектов, где эта простота мешает.
Комментарии (42)

onokonem
26.12.2025 09:53Первая проблема решена (мной :) ): https://github.com/Djarvur/go-enumsafety

Kazurus
26.12.2025 09:53Теперь нужно пройти следующего босса :-). В Rust алгебраические типы данных (ADT). Это даёт очень мощные возможности.

d-enk
26.12.2025 09:53Каналы в Go сделаны неудобно. Начнём с того, что посылающая и принимающая сторона выражена одним и тем же типом
Есть
<-chanиchan<-
Boneyan Автор
26.12.2025 09:53Не знал. На этот счёт есть офф документация? Просто в tour of go этого точно нет

d-enk
26.12.2025 09:53
Boneyan Автор
26.12.2025 09:53Понятно, это действительно решает описанные мной проблемы. Жаль что в гайде по языку об этом ничего не сказано. Кстати, в какой версии языка это добавили? Просто интересно почему по умолчанию предлагают везде использовать менее безопасный вариант.
-----------------------------------
Не понимаю за что мне заминусили комментарий, ну да ладно
d-enk
26.12.2025 09:53Кстати, в какой версии языка это добавили?
В v1.0.0 было https://github.com/golang/go/blob/go1/doc/go_spec.html#L1256
Не понимаю за что мне заминусили комментарий
Наверное, не понравилось, что опираетесь, по сути, на ознакомительный тур.
В tourofrust каналы вообще не упоминаются, вроде.
Boneyan Автор
26.12.2025 09:53Ну, я не упираюсь в tour of go, просто это материал который в первую очередь будут изучать новички и в нём даётся плохой совет на счёт каналов (зачем писать "не закрывайте на читающей стороне", когда можно написать "используйте <-chan")

wmns
26.12.2025 09:53На самом деле это отличный совет. Кто канал создал, тот его и закрывает. Чем раньше новичок это будет знать, тем лучше

Boneyan Автор
26.12.2025 09:53Но всё таки лучше когда на уровне компилятора это отслеживается (как с <-chan и chan<-), тогда новичок такой ошибки вообще не сможет совершить

Octagon77
26.12.2025 09:53Первый недостаток Go -- это отсутствие перечислений (enum).
Поэтому объявляют специализированный тип и набор констант через iota и не парятся. Или используют кодогенератор и не парятся. Или используют пакет enum и его дженерики и снова не парятся.
Важная особенность состоит в том, что компилятор принуждает программиста либо обработать все возможные варианты, либо создать ветку, которая будет обрабатывать все варианты, не подпавшие под какой-либо шаблон:
А если ни то ни другое не нужно? Это, кстати, философски значимо - компилятор для программиста или программист для компилятора? Или даже так - что первично, процесс работы или её результат? Можно сказать, что Go помогает программисту автоматически создавая ветку по умолчанию которая не делает ничего.
А ситуацию когда есть набор вариантов из которых нужно выбрать ровно один на Go можно оформить массой способов - в Go функции are first class citizens.
Почему эта задача плохо решается с помощью Go. В Go для этой задачи придётся использовать обычный int
Не "придётся использовать", а "я не могу придумать ничего кроме".
Главный неприятный момент при работе с памятью в Go -- это то, что не понятно, когда переменная хранится на стеке, а когда в куче.
Согласен, то, что об этом можно не думать - большое достижение авторов языка.
Можно было бы сказать, что это просто особенность Go -- что там более высокоуровневая модель памяти. Но так как он позиционируется как язык, на котором можно писать высоконагруженные системы, то это я считаю минусом
Некоторе системы требуют писать без выделения и освобождения памяти, но это к слову. Сама же идея интересная - кем позиционируется? Где? Когда? Зачем? Я вижу что про языки программирования несут чушь на уровне торгашей базарных и пропагандистов продажных, но не понимаю кто заказывает эту музыку.
Писать высоконагруженные (то есть с фиксированным временем реакции, что-ли?) системы на Go точно можно, но что для Go, что для Rust точно найдутся такие, что их проще писать на другом языке. Elixir не даст соврать... И чё?
Много неудобств доставляет ограничение, запрещающее генерики в методах структур.
Это долгая история. Просто выясните когда в Go появились дженерики и почему не появлялись раньше. И осторожнее - Rust вообще весь состоит из неудобств, и ничего.
Таким образом, Go подталкивает нас к использованию интерфейсов
Ну вот... понимают если захотят.
Из-за того, что кортежи -- это не настоящий тип, мы не можем их использовать в генериках:
Не "не можем", а "не должны". Извращаться можно где угодно, но зачем? Кортеж - собранные вместе несколько значений. Ничего не напоминает? А их возвращение из функции - синтаксический сахар, полезность которого доказана обработкой ошибок. Как и := оператора...
Сделаю небольшую ремарку про злоупотребление интерфейсом any -- плохая практика, которая встречается не так уж редко.
Да, у Go есть история.
Это полный трэш и по сути игнорирование строгой типизации языка.
Философия языка для программиста или наоборот? Это хорошо понимали профессионалы которые делали Go, и весьма недооценённый Dart из той же конюшни, кстати. Поэтому any никогда не была трешем... разве что ловушкой для любителей.
В подходе, который в Go выбран для обработки ошибок, мне нравятся две вещи: передача ошибок через возвращаемое значение функций (по сравнению с исключениями в C++, Java) и возврат ошибки как отдельного значения (по сравнению с использованием "особых значений" как в C).
Ну не надо такое писать, дурно пахнет. Да, все так пишут, запах только усиливается. В Go перехватывается panic...
Первая проблема с таким подходом в том, что по сигнатуре функции нельзя понять, какие ошибки внутри неё могут возникнуть.
Это уже зашкаливает. Используйте другой тип и всё. Если Rust создаёт выученную беспомощность заставляющую использовать только то, что есть в стандартной библиотеке, без изменения, пусть и не всех (на меня вот не подействовало), то это проблема Rust.
И это приводит к проблемам -- так как ошибки в Go обычно имеют тип
error, то каждая новая ошибка будет присваивать значение всё в ту же переменную:То же самое опять. "Обычно" не значит "всегда".
И у вас появляется переменная, существующая на протяжении всей функции, которую можно случайно использовать.
Давно решено. См. := оператор.
Сложно не упомянуть о синтаксическом сахаре в виде
?.Спасибо что упомянули. Это как раз то, что было сознательно отвергеуто в Go. Упрощает и без того тривиальное ценой усложнения и так нетривиального.
Вариант, возможно, и более явный, но, мне кажется, возможность ошибки хуже неявности.
Отличное наблюдение. Действительно, в Go возможность ошибки - последний приоритет. Пытаюсь догадаться почему так - потому, что у каждой ошибки есть причина, и проще и лучше устранять причины, чем бороться со следствиями, особенно бороться запретами (вне программирования эту мысль прошу не думать). Но что думали авторы Go - не знаю.
Непонятно, что мешало за столько лет существования языка добавить банальное:
Да, в Go есть история и изначально в конструкциях языка была некоторая магия. Что лучше, append или .Push - не очевидно, это разные вещи потому, что append добавляет элемент не в массив, а в slice.
Видите баг? Мы создали массив, в котором уже есть 10 элементов.
Не вижу. Создали массив из 10 элементов. Молодцы. Не хватает фантазии сообразить когда это нужно?
Непонятно, что мешало сделать для массива пустого и массива заполненного два отдельных конструктора.
Я бы первым делом заподозрил array literal.
Начнём с того, что посылающая и принимающая сторона выражена одним и тем же типом:
Не понимаю. У канала нет принимающей и посылающей стороны, есть чтение из канала и запись в канал. И, как мне кажется и, конечно, не более того, именно каналы сделали все навороты Rust о которых любят петь как о fearless concurrency просто ненужными.
В Go присутствует такая эзотерическая конструкция, как теги на полях структур.
Да, и судя по предложениям по поводу в статье по соседству, её авторы переоценили уровень своих пользователей. Это весьма печально, особенно если задуматься о том, что рост популярности автоматом снижает тот самый уровень.
Тогда после вызова специальной команды
go generateбудет произведена генерация кода. Это очень хрупкий подход, потому что корректность написанной вами команды не проверяется никем.Нет, не хрупкий. Эта команда применяется для запуска генератора и больше ни для чего. Результат работы генератора проверяется наравне с остальным кодом. На макросы в Rust даже не похоже.
А мы не можем, потому что родительский модуль использует дочерний, поэтому дочерний модуль не может импортировать namespace ошибок из родительского.
И не должны мочь. По определению родительский модуль использует дочерний.
В Go выбран странный подход к именованию модулей. В начале каждого файла должно быть написано название модуля, к которому относится файл. При этом, если в рамках одной папки есть файлы с разными именами модулей, мы получим ошибку компиляции.
В лучшем случае - не "странный" а "избыточный". Возможно потому, что файлы с разными именами пакетов могут присутствовать в одной папке не вызывая ошибок компиляции.
Существуют //go:build тэги, возможность явно перечислить файлы в коммандах run и build, ссылки в файловой системе, файл go.work. Вместе вот это всё позволяет раскладывать файлы по папкам крайне кучерявым образом, но я не могу придумать когда такое действительно нужно. Может быть для генераторов кода? Может быть тогда эта избыточность понадобится. А может просто избыточность введена для того, чтобы было проще обмениваться файлами по почте? Не знаю...
Про Go любят говорить, что его философия -- это простота (а также любят говорить, что каждая новая концепция, добавляемая в язык, лишает его простоты). Но в чём смысл этой простоты, если она делает инструмент негибким, неудобным для применения?
Ещё раз, мало ли что и из каких соображений
врутговорят. Возьмём два slice (среза?) от одного массива и начнём их изменять - ничего от простоты Go не останется. Напишем горутины и сравним работу кода на десктопе и в Web Assembly - результат тот же. И далее везде.Я бы мог согласиться с тем, что Go прост, если бы из контекста следовало, что он идеологически прост. Это не означает ни низкого порога входа, ни удобства для не понимающих сути языка, что Вы и описали. Как пример - Scheme и того идеологически проще... И ещё более охотно я бы согласился с тем, что Go идеологически прост и практичен.
Смысл, как мне кажется, в том, что простота упрощает эволюцию языка, что исключительно важно если хотеть чтобы Go был надолго, и облегчает изучение языка. Да, я считаю что знание языка программирования бинарно - либо есть либо нет, если не знаешь чего-то, значит не знаешь ничего, а рассуждения про уровень джуниора и сеньора, базовые и продвинутые функции - тяжёлый бред.
Негибкости и неудобства я пока в Go не почуствовал. Наоборот, с Go приятней работать чем с чем-либо ещё. Следующий за Go вариант - сладкая парочка JavaScript + Dart. Rust мне тоже нравится, наличие архитектурных
ошибокстранностей не особо мешает, но пользовать его можно только если время ожидания окончания компиляции хорошо оплачивается.После некоторых колебаний решил таки отредактировать пост и добавить - Rust представляется мне хорошей заменой C++, к сожалению идущей дорогой C++.

Boneyan Автор
26.12.2025 09:53Не могу не ответить на ваш комментарий, раз на нём так много апвоутов (что для меня, впрочем, большая загадка, наверное выглядит значительно).
Сразу скажу что ваш комментарий очень абстрактный и "философский", конкретных примеров вы не приводите, поэтому понять что вы имели в виду сложно.через iota и не парятсяпишут склонный к багам код и "не парятся", окей
А если ни то ни другое не нужно? Это, кстати, философски значимо - компилятор для программиста или программист для компилятора? Или даже так - что первично, процесс работы или её результат????
Можно сказать, что Go помогает программисту автоматически создавая ветку по умолчанию которая не делает ничего.Вот ничерта не помогает. Представим что у нас в легаси коде есть енам и свич по нему такого вида
var x string switch myEnum { case A: x = "foo" case B: x = "bar" }Мы добавили новый вариант в этот енам и получили баг в легаси коде.
А ситуацию когда есть набор вариантов из которых нужно выбрать ровно один на Go можно оформить массой способов - в Go функции are first class citizens.Пример? Раз уж их масса
Не "придётся использовать", а "я не могу придумать ничего кроме".Пример, профессор
Согласен, то, что об этом можно не думать - большое достижение авторов языка.Во первых, вы не правы - существование инструментов для escape анализа доказывает что думать приходится. Во вторых, в чём достижение?
Сама же идея интересная - кем позиционируется? Где? Когда? Зачем?Ну вот мне приходится писать систему, обслуживающую десятки тысяч rps, значит кем-то всё таки позиционируется.
Это долгая история. Просто выясните когда в Go появились дженерики и почему не появлялись раньше.История это не оправдание.
Ну вот... понимают если захотят.Противное высокомерие. Да, вы в Go такие непонятые гении, никто не ценит ваш чудесный язык.
Не "не можем", а "не должны". Извращаться можно где угодно, но зачем?Назвать что-то "извращением" это не аргумент.
Кортеж - собранные вместе несколько значений. Ничего не напоминает? А их возвращение из функции - синтаксический сахар, полезность которого доказана обработкой ошибок.Ну я и говорю, волшебная конструкция прибитая гвоздями
Да, у Go есть история.История это не оправдание.
Философия языка для программиста или наоборот? Это хорошо понимали профессионалы которые делали Go, и весьма недооценённый Dart из той же конюшни, кстати. Поэтому any никогда не была трешем... разве что ловушкой для любителей.Не вижу аргумента, вижу ссылку на авторитет.
И вы как бы меня не поняли. Я написал что повсеместное использование any это треш, а не само его наличие (хотя впрочем, может и его наличие это треш, но не буду зарекаться).Ну не надо такое писать, дурно пахнет. Да, все так пишут, запах только усиливается. В Go перехватывается panic...Что вы вообще имели в виду?
В Go и в Rust можно перехватывать панику, но на практике это стоит делать Только в очень специфических случаях. В остальных случаях передача ошибки через возвращаемое значение всегда лучше.Это уже зашкаливает. Используйте другой тип и всё.Окей, профессор, покажите мне хоть одну популярную библиотеку, которая в качестве ошибки использует кастомный тип, а не
errorТо же самое опять. "Обычно" не значит "всегда".И? В Go принято называть ошибку
err, поэтому скорее всего ошибки от разных вызовов будут сидеть в одной переменнойДавно решено. См. := оператор.Вы о чём? Вы так пишете "см." как будто вы выше написали что-то про оператор
:=. Это оператор для создания новой переменной и что? Я додумывать за вас не собираюсьСпасибо что упомянули. Это как раз то, что было сознательно отвергеуто в Go. Упрощает и без того тривиальное ценой усложнения и так нетривиального.А, ну да, Go такой неудобный by design, тогда претензии сняты
Отличное наблюдение. Действительно, в Go возможность ошибки - последний приоритет. Пытаюсь догадаться почему так - потому, что у каждой ошибки есть причина, и проще и лучше устранять причины, чем бороться со следствиями, особенно бороться запретами (вне программирования эту мысль прошу не думать). Но что думали авторы Go - не знаю.Что вы имели в виду здесь я тоже не знаю
Что лучше, append или .Push - не очевидно, это разные вещи потому, что append добавляет элемент не в массив, а в slice.По моему очевидно что Push лучше. Если вы хотите отдельный синтаксис для добавления элемента в массив, то сделайте и Push и append.
Не вижу. Создали массив из 10 элементов. Молодцы.Этот дурацкий синтаксис приводил к багам много раз, я видел это сам. Я рад что вы такой умный и не делаете этой ошибки.
Я бы первым делом заподозрил array literal.Что вы имели в виду
Не понимаю. У канала нет принимающей и посылающей стороны, есть чтение из канала и запись в канал.Очевидно у канала есть принимающая и посылающая сторона. И создатели языка тоже так считают, ведь добавили
<-chanиchan<-.Да, и судя по предложениям по поводу в статье по соседству, её авторы переоценили уровень своих пользователей. Это весьма печально, особенно если задуматься о том, что рост популярности автоматом снижает тот самый уровень.Теги на структурах больше выглядят как костыль на быструю руку, чем какое-то гениальное инженерное решение, которым вы его хотите выставить.
Нет, не хрупкий. Эта команда применяется для запуска генератора и больше ни для чего.Ну так в этом же проблема, что генератор надо запускать отдельно от компиляции.
И не должны мочь. По определению родительский модуль использует дочерний.Плевать на определения, на практике возникает такая необходимость.
Далее у вас абстрактное растекание мыслью по древу, сказать мне про это нечего.

RulenBagdasis
26.12.2025 09:53Я не пишу ни на го ни на расте, игрался только, но заголовок статьи я бы переформулировал так: "Я тут попытался программировать на го, как на расте и внезапно выяснил, что го, это не раст, оказывается, это другой язык и сейчас я вам расскажу почему".

Boneyan Автор
26.12.2025 09:53А можете указать конкретные места где я пытаюсь делать "rust в go"? Не отрицаю что такое может быть, но мне кажется что у меня по большей части просто претензии к дизайну Go (по крайней мере в тех частях статьи, где я показываю как это приводит к багам).

RulenBagdasis
26.12.2025 09:53у меня по большей части просто претензии к дизайну Go
Я так и написал, да.

QtRoS
26.12.2025 09:53Сравнение на удивление неплохое, глубже чем среднее видео или статья по теме. Но давайте будем честными, Go и Rust уже давно "here to stay" - плотно вошли в индустрию, много нужного софта на них написано, и выяснять, что лучше для ты или иной задачи, уже не так важно. Все равно это будет территория компромиссов.

Gorthauer87
26.12.2025 09:53Не хватает ещё абзаца про слайсы и юникод, там тоже можно знатно по ногам пострелять себе.

Boneyan Автор
26.12.2025 09:53Глубоко не думал об этом, потому что только один раз сталкивался. Но вообще согласен, из-за того что в Go по строке можно свободно брать срез, легко получить невалидную utf-8 строку (для безопасности надо обмазываться всякими DecodeLastRuneInString).

amsokol
26.12.2025 09:53Хорошая статья. Спасибо автору.
У меня обратный опыт - использовал Rust после Go.Могу добавить от себя еще пару моментов:
1) Nil в Go. Option в Rust елегантнее.
2) Cargo сильно лучше чем go.mod
3) Cargo features тоже очень удобная штука.
С другой стороны компилятор у Rust конечно сильно медленне чем у Go.
А еще в Go в стандарной библиотеке (например в reflect) во многих местах понатыкано panic.В Go мне очень нравится sync.Pool. Есть ли подобное в Rust? Подскажите.

denisgrigoriev04
26.12.2025 09:53Зато в go в стандартной библиотеке есть https клиент и сервер, криптография и многое другое

amsokol
26.12.2025 09:53Я совсем не ругаю Go. Наоборот. У него много плюсов в том числе достаточно хорошая (в целом) std lib.
Планировщик goroutines по моему личному мнению вообще artwork. Даже ребята из tokio брали его за основу, когда делали свой.
За несколько лет использования, как мне кажется, у Go очень четко выделились удачные и неудачные решения. Мне кажется, что большинство согласны с тем, что обработка ошибок, nil, generics, управление памятью и другие моменты могли быть бы лучше. Но изменить это невозможно без нарушения гарантии совместимости v1.0.
Что печально - это то что похоже инициатива Go v2.0 умерла.
Я читал ребят из команды Go, что они не хотят повторить опыт перехода Python v2->v3. Да переход действительно был очень болезненным. Но по прошествие времени мы видим, что это было тяжелое, но правильное решение.
Roman_Cherkasov
26.12.2025 09:53Из интересного, я начинал писать код уже на Python 3.7, в году наверное 18 и ещё тогда (спустя 8 лет после выхода 3й), встречались вакансии с требованиями Python 2.7, но их было на общем фоне не очень то и много. И какого - же было мое удивление, когда знакомый мне рассказал про свой собес в компанию разработчика, толи Вартандер, толи каких-то из танков, в начале 2024. Собес проходил на 2.7
От предложения знакомый отказался, что не удивительно.
Так что переход на тройку, в общем-то, ещё не закончен)С другой стороны, при наличии LLM, подобный переход можно совершить сильно быстрее и куда менее болезненно, чем это было с Python. Больше того, в Go очень любят кодогенерацию, он довольно строго типизируется, да и даже 10ки лет назад 2to3 справлялся с тем, чтобы оградить разрабов от самой рутинной и простой части.
Так что, как мне кажется, лучше времени для возрождения Go v2 инициативы - уже не будет.

MaNaXname
26.12.2025 09:53толи Вартандер, толи каких-то из танков, в начале 2024. Собес проходил на 2.7
Ну вообще не удивительно. Если это не бэкенд то там как вкрутили скриптовый язык в движок, так и больше не парились. Ну а если бэк - ну тогда да... пичалька....

Roman_Cherkasov
26.12.2025 09:53Со слов коллеги, его спрашивали о том как устроена память в 2.7, как работает GC, потоки, процессы, вытесняющая многозадачность и ещё какое-то количество алгоритмических и низкоуровневых штук.
На вопрос - "А зачем вам это все". Ответ - "Часть физики у нас так считается".А вообще - основная функциональность на Python там, как я понял со слов - в прегейм лобби. Подбор игроков, магазин, таблички, базы.

Dominux
26.12.2025 09:53То чувство, когда сравниваешь самый приближенный к идеалу ЯП с чем угодно, тут даже го будет в пролете. Сравните его с чем угодно другим: с джаваскрепт, плюсами или пыхой - и вы офигеете, какой го отличный ЯП

MaNaXname
26.12.2025 09:53сорян, но мне джаваскрипт даже лучше чем го. на одну строчку в моем рейтинге но выше)

ITPrince
26.12.2025 09:53Браво автору статьи! Действительно раскрыл большинство больных мест Go, которые делают язык менее очевидным и безопасным ради мнимой простоты, ибо вся ответственность в данном случае ложится на программиста, а в больших проектах — это огромная головная боль.
Конечно, нельзя сказать, что язык плохой. Он решает свою задачу — его быстро изучить, на нем быстро писать, а код более-менее шустрый.
Но, как по мне, проще уж MVP писать на Python с высокопроизводительными библиотеками, такими как pydantic, orjson, uvloop, granian, и при этом делать это более выразительно, быстро, чем на Go, либо же сразу писать на Rust, когда действительно нужна максимальная производительность и отказоустойчивость. И между ними Go в данном случае — ни рыба ни мясо.
Go пытается быть быстрым и простым, а в итоге получается быть просто неудобным для разработки, по крайней мере для маленьких и средних бизнес-сервисов, где нет большой текучки кадров и десятков крупных команд.

krendelbok
26.12.2025 09:53Но, как по мне, проще уж MVP писать на Python с высокопроизводительными библиотеками, такими как pydantic, orjson, uvloop, granian, и при этом делать это более выразительно, быстро, чем на Go, либо же сразу писать на Rust, когда действительно нужна максимальная производительность и отказоустойчивость. И между ними Go в данном случае — ни рыба ни мясо.
Это очень спорное утверждение. Не везде нужно выжимать все капли с железа, а вот скорость разработки в Го на порядок выше. И гарантий по сравнению с питоном тоже на голову выше. Получается как раз золотая середина. У вас однобокий взгляд разраба-перфекциониста, но в жизни нету ничего идеального, всегда нужны компромиссы.

a_cid
26.12.2025 09:53Некоторые утверждения звучат так, будто автор не пользуется линтером и/или пишет код в обычном блокноте.
Реализация интерфейсов в нормальной IDE видна ещё на уровне редактора. Никогда не ошибался в написании реализации.
Забыл поставить defer? IDE и линтер тебе любезно скажут, что ты нехороший человек. А после определенного времени разработки на Go, рука уже сама пишет этот defer. У каждого языка есть свои особенности, к которым разработчик привыкает со временем.
Я не говорю, что Go лучше Rust. Каждому языку свое применение. И они оба нашли его и достаточно распространены. Наверное, это хороший признак того, что с помощью языка можно писать хорошие программы.

Boneyan Автор
26.12.2025 09:53Ну да, линтеры часть проблем решают, но это всё таки внешняя надстройка. Программистам надо перед началом проекта посмотреть список линтеров и решить что они будут использовать.

Dhwtj
26.12.2025 09:53IDE и линтер тебе любезно скажут, что ты нехороший человек
Ну да. А можно и через LLM писать и не думать, правда?
IDE это плохие гарантии
А как на бумажке писать на ливкодинге вообще не понятно

unicorn_style
26.12.2025 09:53С чего вдруг IDE - плохие гарантии, когда практически любой IDE работает в паре с интерпретатором Go

aywan
26.12.2025 09:53Я считаю прекрасно иметь возможность пойти посмотреть исходники стандартной библиотеки, а главное уметь их понять пройдя несколько уроков в gotour. Конечно не хватает "хитрых" типов, банального enum-а. Это создаёт проблемы, но совсем далеко не настолько часто как ошибки в бизнес-логике.
В моей практике не припомню проблемы чтобы кто-то тут добавил энамов, а потом забыл что их добавил. Или решил вместо константы написать просто что `какой-то альяс над int равно 5`. Могут конечно, но врятли такой код переживет ревью, как глазами, так и средствами ИИ.
А вот то что люди могли забыть закрыть какой-то дескриптор при выходе... Ну это может быть и не настолько страшно, поскольку есть литер. А вот если они даже не понимают что этот дескриптор существует и закрывается автоматически при выходе из области видимости... Мне кажется важно понимать что там происходит на низком уровне, понимать как это все работает на уровне ОС. Вероятно далеко не на каждом уровне, и далеко не всем. Го достаточно высокоуровневый по сравнению с Си, но при этом довольно прост и "близок к нему по духу" (очень условно, конечно)).
Вобщем я про "явность" и "очевидность" поведения, которое кажется было туда положено на фундаментальном уровне. От необходимости прочтения "effective go" и "go memory model", помимо го-тура вас ничего не спасет. Когнитивная нагрузка с пониманием эскейп анализа очень быстро исчезает с практикой. Да и потом есть утилита для проверки. Но опять же не помню кейсов где это было критически необходимо. Ну и пример с инициализацией слайса определенной длины - это опять же просто такая конструкция языка которую ты начинаешь видеть сходу, и уже не обращаешь внимание как на этот немного раздражающий ! после println))

inetstar
26.12.2025 09:53Я вот размышляю над Растом. Чтобы перейти на него. Но после Го отпугивает медленная скорость итераций в связи с медленной компиляцией (от 50 секунд). Как вы с этим справлялись?
Dhwtj