Как это началось

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