Как это началось
Случилось так, что мне пришлось участвовать в разработке на 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, но на практике его используют и предлагают для использования в том числе для больших и сложных проектов, где эта простота мешает.