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