Как стать автором
Обновить

Почему Go — это плохо продуманный язык программирования

Время на прочтение9 мин
Количество просмотров87K
Автор оригинала: Ilya Kowalewski
Это перевод статьи юзернейма tucnak с Medium, которая получила обширное обсуждение на reddit.com/r/programming.

image
Окей, заголовок действительно несколько громкий, признаю. Cкажу больше: я прусь от громких заголовков, все из-за внимания. В этой блогозаписи я постараюсь доказать тот факт, что Go это ужасно продуманный язык (спойлер: это так). Я уже играюсь с Go уже на протяжении нескольких месяцев, первый helloworld собрал, кажется, в июне. Математик из меня никакой, но с тех пор прошло уже что-то около 4 месяцев и я даже успел залить на Github несколько репозиториев и собрать немного звезд! Стоит также упомянуть, что у меня совершенно нет опыта применения Go в продакшне, так что любые мои слова о «поддержке кода» или «деплое» не стоит принимать за единственноверную истину.

Я люблю Go, я полюбил его как только впервые попробовал его. Я потратил несколько дней на то, чтобы принять идиоматику, смириться с отсутствием дженериков, разобраться с откровенно странным способом обработки ошибок и вы знаете, всеми этими классическими проблемами, так или иначе связанными с Go. Я прочел Effective Go, много статеек из блога Dave Cheney, следил за всеми новостями из мира Go. Я даже могу сказать, что я достаточно активный участник сообщетсва! Я люблю Go и ничего не могу с этим поделать — Go просто замечательный. Тем не менее, я считаю, что Go это ужасный плохо продуманный язык, который делает совершенно не то, что «продает».

Go считается простым языком программирования. Как сказал Rob Pike, они «убрали из языка все, что только можно было убрать», что сделало его спецификацию просто тривиальной. Эта сторона языка просто удивительна: ты можешь выучить основы в течении минут, сразу начать писать реальный код и и в большинстве случаев Go будет себя вести ровно так, как ты ожидаешь. Ты будешь много беситься, но к счастью, все будет работать круто. В реальности все немного иначе, Go это далеко не простой язык, скорее просто плохой. А теперь взгляните на несколько аргументов, которые подтверждают мои слова.

Причина №1. Манипуляции со слайсами просто отвратительны

Cлайсы (это такие навороченные массивы) очень классные, мне безумно нравится концепт и непосредственно реализация. Но давайте на одну секундочку представим, что нам какой-то момент захочет написать с ними немного исходного кода, может быть совсем чуть-чуть. Слайсы это сердце языка, это один из тех концептов, который делает Go крутым. Но все же, давайте все-таки представим, что как-то вовсе вдруг, в перерывах между разговорами о «концептах», мы захотим написать чуток реального кода. Вот то, что нам предлагает в данном случае язык Go:

// Давайте сделаем немного чисел...
numbers := []int{1, 2, 3, 4, 5}

log(numbers)         // 1. [1 2 3 4 5]
log(numbers[2:])     // 2. [3 4 5]
log(numbers[1:3])    // 3. [2 3]

// Интересный факт: отрицательные индексы не работают!
//
// numbers[:-1] из Python не прокатит. Взамен нам предлагают
// делать что-то вроде этой циркопляски с длиной контейнера:
//
log(numbers[:len(numbers)-1])    // 4. [1 2 3 4]

// “Потрясная” читабельность, Мистер Пайк! Хорош!
//
// А теперь давайте допишем шестерку:
//
numbers = append(numbers, 6)

log(numbers) // 5. [1 2 3 4 5 6]

// Самое время удалить из слайса тройку:
//
numbers = append(numbers[:2], numbers[3:]...)

log(numbers)    // 6. [1 2 4 5 6]

// Хочется вставить какое-то число? Ничего страшного,
// в Go есть общепринятая best practice!
//
// Мне особенно нравится это шапито с троеточиями ...
//
numbers = append(numbers[:2], append([]int{3}, numbers[2:]...)...)

log(numbers)    // 7. [1 2 3 4 5 6]

// Чтобы скопировать слайс, ты должен будешь написать это:
//
copiedNumbers := make([]int, len(numbers))
copy(copiedNumbers, numbers)

log(copiedNumbers)    // 8. [1 2 3 4 5 6]

// И это еще не все.

Хотите верьте, хотите — нет, но именно так гоферы каждый день трансформируют слайсы. А так как у нас нет никаких этих ваших дженериков, то написать красивую функцию insert(), которая будет прятать весь этот ужас просто не получится. Я залил этот рабочий пример нa playground, так что не стоит мне верить: можешь проверить своими руками.

Причина №2. Нулевые интерфейсы не всегда нулевые :)

Они говорят нам, что «ошибки в Go это больше, чем просто строки» и что мы не должны обращаться с ними, как со строками. Например, spf13 из Docker сказал об этом во время превосходного выступления на тему «7 common mistakes in Go and when to avoid them».
Они также говорят, что нам стоит всегда возвращать ошибки через интерфейс error (однородность, читабельность, и так далее). Это в точности то, что я делаю в сниппите кода ниже. Ты наверняка будешь удивлен, но эта программа действительно поздоровается с Мистером Пайком, но все ли так, как должно быть?

package main

import "fmt"

type MagicError struct{}

func (MagicError) Error() string {
	return "[Magic]"
}

func Generate() *MagicError {
	return nil
}

func Test() error {
	return Generate()
}

func main() {
	if Test() != nil {
		fmt.Println("Hello, Mr. Pike!")
	}
}

Да, я в курсе, почему это происходит, так как я прочел достаточное количество профильной литературы про устройство интерфейсов в Go. Но согласитесь, для начинающего гофера подобное шапито будет как обухом по голове. В действительности это известная западня. Как вы видите, Go это настолько прямолинейный и простой для изучения язык без плохих отвлекающих фич, что иногда он считает, что нулевые интерфейсы не очень-то и нулевые ;)

Причина №3. Забавное сокрытие переменных

В случае если вы не в курсе, что это такое, то давайте я все-таки процитирую Википедию: “сокрытие переменных происходит тогда, когда переменная определенная в определенном контексте (условный блок, метод, внутренний класс) имеет такое же имя, как и переменная во внешнем контексте”. Звучит толково, вроде как довольно известная практика, большинство языков программирования поддерживают сокрытие и вс окей. Интересно, но Go не исключение, но тут дела состоят несколько иначе: у нас есть оператор :=, который добавляет веселья. Вот как работает сокрытие переменных в Go:

package main

import "fmt"

func Secret() (int, error) {
	return 42, nil
}

func main() {
	number := 0

	fmt.Println("before", number) // 0

	{
		// meet the shadowing
		number, err := Secret()
		if err != nil {
			panic(err)
		}

		fmt.Println("inside", number) // 42
	}

	fmt.Println("after", number) // 0
}

Да, я в курсе, что оператор := создает новую переменную и задает ей значение по типу справа и то, что в соотв. со спецификацией языка это абсолютно корректное поведение. Но понимаете ли, стоит убрать внутренний контекст (фигурные скобки) и код заработает ровно так, как мы ожидаем («after 42»). Интересно девки пляшут, не так ли? В другом случае вам придется играться с замечательными сокрытием переменных.
Следует отметить, что это все не просто забавный пример, который я придумал за обедом, это реальная ловушка, в которую люди время от времени попадают далеко не по своей вине. На этой неделе я рефакторил немного кода на Go и встретил эту проблему дважды. Компиляторам все равно, линтерам все равно, всем все равно, но код работает неправильно.

Причина №4. Ты не можешь передать []struct как []interface

Интерфейсы классные, Pike&Co. продолжают говорить, что они это то, чем является Go: интерфейсы решают проблему дженериков, мы используем интерфейсы для тестов, через них в Go построен полиморфизм. Говорю вам, я полюбил интерфейсы прямо во время прочтения «Effective Go» и продолжаю их любить. Тем не менее, помимо проблемы «этот нулевой интерфейс не совсем нулевой», о которой я говорил чуть ранее, существует другая неприятная проблема, которая сконяет меня к мысли, что в Go нет полноценной поддержки интерфейсов. Простыми словами, ты просто не можешь передать слайс структур (которые удовлетворяют определенный интерфейс) в функцию, которая принимает слайс этого интерфейса:

package main

import (
	"fmt"
	"strconv"
)

type FancyInt int

func (x FancyInt) String() string {
	return strconv.Itoa(int(x))
}

type FancyRune rune

func (x FancyRune) String() string {
	return string(x)
}

// Удовлетворяет любой структуре с методом String().
type Stringy interface {
	String() string
}

// Строка, состоящая из строковых представлений всех элементов.
func Join(items []Stringy) (joined string) {
	for _, item := range items {
		joined += item.String()
	}

	return
}

func main() {
	numbers := []FancyInt{1, 2, 3, 4, 5}
	runes := []FancyRune{'a', 'b', 'c'}

	// Такое не прокатит!
	//
	// fmt.Println(Join(numbers))
	// fmt.Println(Join(runes))
	//
	// prog.go:40: cannot use numbers (type []FancyInt) as type []Stringy in argument to Join
	// prog.go:41: cannot use runes (type []FancyRune) as type []Stringy in argument to Join
	//
	// Взамен они предлагают нам заниматься вот такой циркопляской:
	//

	properNumbers := make([]Stringy, len(numbers))
	for i, number := range numbers {
		properNumbers[i] = number
	}

	properRunes := make([]Stringy, len(runes))
	for i, r := range runes {
		properRunes[i] = r
	}

	fmt.Println(Join(properNumbers))
	fmt.Println(Join(properRunes))
}

Совершенно неудивительно, что это известная проблема, которая даже проблемой-то не считается. Это просто очередная забавная штука в Go, окей? Я очень рекоммендую прочесть соотв статью в Wiki по теме, станет ясно, почему трюк с передаванием []struct как []interface не прокатит. С другой стороны, подумайте об этом! Мы можем это делать, тут нет никакой магии, это просто проблема компилятора. Посмотрите, прямо здесь, чуть выше в коде я это сделал вручную. Почему компилятор Go не может заниматься этим шапито вместо меня? Да-да, явное лучше чем неявное, но все же?

Я просто не могу терпеть то, как люди смотрят на дерьмо вроде этого, которого в языке просто полно и продолжают говорить «ну да, это норм». Это не норм. Это то, что делает Go ужасным языком программирования.

Причина №5. Неочевидные циклы «по значению»

UPD: Это несколько надуманная проблема, если смотреть со стороны языка. А если не смотреть, то все равно, т.к. мы же говорим о косяках непосредственно языка, а не документации и экосистемы.

Это вообще первая проблема, с которой я когда-либо столкнулся в Go. Окей, у нас тут есть «for-range» цикл, который позволяет ходить по слайсам и слушать каналы. Его используют повсюду и это окей. Вот вам еще одна незначительная, но не очень приятная проблема, с которой сталкивается большинство неопытных неофитов: range-цикл поддерживает итерирование только «по значению», он просто копирует значения и ты ничего не можешь с этим поделать — это тебе не foreach из С++.

package main

import "fmt"

func main() {
	numbers := []int{0, 1, 2, 3, 4}

	for _, number := range numbers {
		number++
	}

	fmt.Println(numbers) // [0 1 2 3 4]

	for i, _ := range numbers {
		numbers[i]++
	}

	fmt.Println(numbers) // [1 2 3 4 5]
}

Прошу отметить, я не ругаюсь на то, что в Go нет range-циклов «по ссылке», я ругаюсь на то, что эти range-циклы довольно неочевидны. Глагол “range” будто говорит “пройтись по элементам”, но не очень говорит “пройтись по копиям элементов”. Давайте посмотрим на секцию For из “Effective Go”, в ней ни слова про то, что range-циклы копируют значения слайса, там просто об этом не написано. Я согласен, что это очень незначительная проблема, к тому же я в свое время с ней очень быстро совладал (в течении минут), но неопытный гофер может потратить некоторое время на отладку кучка кода, не понимая, почему значения в массиве не меняются. Нужно было бы хоть немного, но обрисовать это дело в “Effective Go”.

Причина №6. Сомнительная строгость компилятора

Как я уже мог говорить ранее, Go считается чистым, простым и читабельным языком программирования со строгим компилятором. Например, у тебя не выйдет скомпилировать программу с unused import. Почему? Просто потому что Мистер Пайк считает, что так должно быть. Можешь верить, а можешь и нет, но unused import это далеко не конец Света и люди могут его пережить, особенно во время активной отладки. Я полностью согласен, что это не очень правильно и компилятор просто должно выводить какое-то предупреждение, но ради всего святого, какой смысл прекращать компиляцию из-за такой мелочи? Неиспользованный импорт, серьезно?

К Go1.5 нам подогнали классное изменение языка: наконец-то можно указывать элементы словаря без явного указания типа хранимого значения. Мальчикам из Go team понадобилось чуть более пяти лет, чтобы сообразить, что явное указание типа может быть чуток избыточным.

Другая замечательная штука, от которой я прусь в Go — это коммы. Видете ли, в Go у нас есть так называемые многострочные блоки объявления (import / var / const):
import (
    "fmt"
    "math"
    "github.com/some_guy/fancy"
)
const (
    One int = iota
    Two
    Three
)
var (
    VarName int = 35
)

Окей, но как только дело доходит до «читабельности», Роб Пайк решает, что надо ВНЕЗАПНО добавить запятые. В какой-то момент после добавления запятых, он решает, что стоит оставлять запятую на последней строчке списка. Таким образом, вместо того, чтобы писать так:
numbers := []Object{
    Object{"bla bla", 42}
    Object("hahauha", 69}
}

Мы вынуждены писать так:
numbers := []Object{
    Object{"bla bla", 42},
    Object("hahauha", 69},
}

Я до сих пор задаюсь вопросом, почему мы не можем просто обойтись без запятых на блоках import / var / const и уж никак не можем на списках или словарях. В любом случае, Роб Пайк точно шарит лучше меня, так что все окей. Да здравствует Читабельность!

Причина №7. Кодогенерация в Go это просто костыль

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

func Blabla() {
    // code...
}

//go:generate toolname -params -blabla

// code...

Комментарии должны объяснять код, не генерировать его. В любом случае, магические комментарии это обыкновенная рабочая практика в современном Go. Интересно, но всем все равно, всех все устраивает, это окей. Мне лично кажется, что это намного большее зло, чем чертовы unused imports, которые валят компиляцию.

Эпилог


Как вы видите, я не ругался по поводу обобщений / обработки ошибок / сахара и других классических проблемах, о которых говорят в контексте Go. Я согласен, что дженерики это вовсе не мастхев, но тогда дайте нам нормальную генерацию, а не волшебные циркопляски в комментариях, которые типа как генерируют код. Если в забираете у нас исключения, то пожалуйста, дайте нам возможность нормально сверять нулевые интерфейсы с нулем! А если вы у нас «неоднозначный в плане читабельности» сахар забираете, то дайте возможность писать код, который работает ожидаемо, без «гоп ца ца» сокрытия переменных.

Так или иначе, я продолжу использовать Go. На это есть веская причина: я люблю его. Я ненавижу язык: он просто отвратителен, но я обожаю сообщество, инструментарий и львиную долю концепций, которые предлагает язык, вообще всю экосистему.

Пссс, парень, не хочешь форкнуть Go?
Теги:
Хабы:
+88
Комментарии289

Публикации

Истории

Работа

Go разработчик
132 вакансии

Ближайшие события

Weekend Offer в AliExpress
Дата20 – 21 апреля
Время10:00 – 20:00
Место
Онлайн
Конференция «Я.Железо»
Дата18 мая
Время14:00 – 23:59
Место
МоскваОнлайн