Go и искусство ставить подножку разработчику: разоблачение
- четверг, 26 марта 2026 г. в 00:00:10
Язык проектировался простым, лёгким в освоении, готовым для написания веб-сервисов с первого дня. Он мог бы таким и остаться, если бы не одна проблема. Проблема отбора.
Инженеры Google понимали, что без подводных камней, необходимости знать детали реализации языка и неконсистентного синтаксиса не о чем будет спрашивать на собеседовании.
Явно ставилась задача — сделать язык достаточно простым, но не настолько, чтобы собеседование мог пройти любой новичок.
Это первая статья из серии «Альтернативная история языков программирования». Если материал понравится Хабру, раскрою секреты других языков.
Ходят слухи, что в первых версиях slice назывался «Cutyourself». Затем разработчики удалили коммиты через git push --force, чтобы скрыть задумку, и выбрали более подходящее название, дающее пространство для манёвра. Всегда можно сказать, что slice переводится как «ломтик», «кусочек» или «долька», а не «резать» или «порезаться».
Кто недавно пишет на Go (и не плюсовик), тот не даст соврать: использовать slice невозможно, не зная деталей реализации.
a := []int{1, 2, 3, 4} b := a[1:3] // b = [2, 3] b[0] = 99 fmt.Println(a)
[1 99 3 4], потому что слайс b переиспользует массив слайса a.
Всё как в прошлом примере, только добавилась одна новая строка:
a := []int{1, 2, 3, 4} b := a[1:3] a = append(a, 5) // новая строка b[0] = 99 fmt.Println(a)
[1 2 3 4 5], потому что где-то глубже в деталях реализации слайсов создался новый массив для a.
func main() { a := []int{1, 2, 3, 4} _ = append(a[:3], 5) fmt.Println(a) }
[1 2 3 5], потому что capacity переиспользуемого массива всё еще достаточно.
Вот так уже выведется [1 2 3 4]:
func main() { a := []int{1, 2, 3, 4} _ = append(a[:3:3], 5) fmt.Println(a) }
Со слайсом легко устроить утечку памяти: достаточно взять маленький кусок от большого массива и тем самым удержать в памяти весь исходный буфер.
bigArray := make([]int, 1e6) smallSlice := bigArray[:10] // удерживается ссылка на bigArray
Из рассекреченного протокола комитета Go, 2008 год:
Слайсы должны выглядеть просто. Подробности владения памятью кандидат должен будет выяснить при подготовке.
func main() { s := []int{1, 2, 3, 4, 5}[1:3] fmt.Println(s) extendedSlice := s[:4] // расширяем слайс fmt.Println(extendedSlice) }
[2 3]
[2 3 4 5]
Всё это те же проблемы, связанные с неявным использованием массива внутри.
Прошу простить мне еще парочку примеров, уж очень опасны Slice и Pike.
func modifySlice(s []int) { s[0] = 99 s = append(s, 100) } func main() { s := []int{1, 2, 3} modifySlice(s) fmt.Println(s) }
[99 2 3]
Выполняя (s[0] = 99), мы меняем «подкапотный» массив, а в (s = append(s, 100)) нам уже не хватает capacity, и там создаётся новый массив.
func main() { s := []int{} refs := []*int{} for i := 0; i < 5; i++ { s = append(s, i) refs = append(refs, &s[0]) // сохраняем ссылку на первый элемент } *refs[4] = 4 *refs[0] = 99999 fmt.Println(s) }
[4 1 2 3 4] — append создаёт новый массив под капотом, когда capacity недостаточно.
Лингвистические метафоры, как и говорящие фамилии, никогда не возникают на ровном месте. Попробуйте вспомнить, как звали основного разработчика Go? Robert C. Pike. Ничего не напоминает? Прям как Роберт С. Мартин. Сходство заставляет нас вспомнить доброго дедушку, написавшего «Чистый код». Но не стоит раньше времени расслабляться — Pike переводится как «Копьё», «Пика». То, на что хотят насадить Go-разработчика.
func main() { var s []int fmt.Println(len(s)) // печатаем 0 s = append(s, 1) // спокойно добавляем в nil slice var m map[string]string fmt.Println(len(m)) // печатаем 0 m["key"] = "value" // panic, не можем добавить в nil map }
Если бы не заказ на внедрение дополнительной сложности, разработчики легко сделали бы работу с nil консистентной, как, например, в Clojure:
(def s nil) ;-> s (first s) ;-> nil (last s) ;-> nil (nth s 0) ;-> nil (nth s 10) ;-> nil (count s) ;-> 0 (conj s 1 2) ;-> (1 2) (def m nil) ;-> m (get m :key) ;-> nil (count m) ;-> 0 (merge m m) ;-> nil (merge m {:a 1}) ;-> {:a 1} (assoc m :key "value") ;-> {:key "value"}
Уверен, на каком бы языке программирования вы ни писали, проблем со строками не было. Добро пожаловать в Go.
Может быть, я придираюсь, но во всех языках, на которых я писал до этого, подобной проблемы не было.
func main() { str := "å" fmt.Println(str[1]) // index out of range? }
165. Строки хранят байты, а не символы. Иначе бы ими было легко пользоваться, что противоречило целям дизайна языка.
В JVM-языках строка — это обертка над массивом из char, так что там проблемы не будет. В Rust всё явно и очевидно:
fn main() { let s = "å"; // println!("{}", s[1]); // не компилируется println!("{}", s.as_bytes()[1]); // 165 }
func main() { str := "Три" fmt.Println(len(str)) // 3? }
6. Всё те же байты, а не символы.
Скажем так: а почему бы и нет? Надо же было в чём-то обойти C++.
func main() { true := false uint := "bob" string := 0 fmt.Printf("%v, %v, %v", true, uint, string) // false, bob, 0 }
Наверное, go-разработчики возразят, что это не ключевые слова, а “predeclared identifiers“, но какая разница? Зачем разрешать переопределять true?
Из рассекреченного протокола комитета Go, 2008 год:
Ключевых слов будет немного. Остальное объявим заранее и посмотрим, кто осмелится переопределить истину.
У каналов в Go достойная родословная. Один из их предков назывался Newsqueak и выходил с подзаголовком «A Language for Communicating with Mice». Уже по названию видно, что в Лаборатории Колокольчика, откуда вышел Боб Копьё, любили довольно своеобразные идеи. Кстати, если хотите больше подобных переводов, приглашаю посмотреть статью: Законъ о запрете иностранных словъ… в разработке.
Посмотрите на этот код на Kotlin:
val channel = getCountChannel<Int>() val resultFromChannel = channel.receiveCatching().getOrNull() println(resultFromChannel)
Что напечатается? Даже не зная язык, можно догадаться, что будет или число, или null. Тут так и написано: getOrNull.
А теперь давайте глянем Go:
ch := getCountChannel[int]() if v, ok := <-ch; ok { fmt.Println(v) } else { fmt.Println("channel closed") }
Даже в блок if обернули и проверили, что всё ok. Но мы не можем сказать, что напечатается, потому что если getCountChannel вернёт nil, то чтение из канала зависнет навсегда.
Зачем так сделано? Чтобы задать дополнительный вопрос на собеседовании. Ожидается, что кандидат знает, что зануление канала позволяет выключить ветку в select, как в коде ниже:
var in <-chan int = ch if paused { in = nil } select { case v := <-in: fmt.Println("got", v) case <-ctx.Done(): return }
Можно ли придумать синтаксис, который позволит выключать ветку явно? Конечно. Ниже пример на том же Kotlin:
val ch: ReceiveChannel<Int> = getChannel() select<Unit> { if (!paused) { // or `ch != null`, or `!ch.isClosedForReceive` ch.onReceive { println("got $it") } } currentCoroutineContext().job.onJoin { return } }
Ниже продемонстрируем, как проявляется инженерный минимализм в Go:
func main() { ch := make(chan int, 1) ch <- 0 close(ch) fmt.Println(<-ch) fmt.Println(<-ch) fmt.Println(<-ch) }
Один ноль настоящий, остальные служебные:
// 0 (значение 0, как мы и передали) // 0 (канал закрыт, но чтение "успешно") // 0 (и дальше тоже)
Дело в том, что из закрытого канала читаются zero value для типа. Для чисел это 0.
Закрытый канал — как корпоративный чат после увольнения. Смысла уже нет, а сообщения всё ещё приходят.
А вот пример на Kotlin:
suspend fun main() { val ch = Channel<Int>(capacity = 1) ch.send(0) ch.close() println(ch.receiveCatching().getOrNull()) // 0 println(ch.receiveCatching().getOrNull()) // null println(ch.receiveCatching()) // Closed(null) }
На Go, конечно, задача тоже решается, но для этого опять нужно знать детали:
v, ok := <-ch fmt.Println(v, ok) // 0 true -- это тот 0, который был отправлен v, ok = <-ch fmt.Println(v, ok) // 0 false -- это уже zero value из закрытого канала v, ok = <-ch fmt.Println(v, ok) // 0 false
Никто: `true, null`,
Абсолютно никто: `true, null`,
Go: `false, *main.MyErr `.
Go:
package main import "fmt" type MyErr struct{} func (MyErr) Error() string { return "boom" } func f() error { var e *MyErr = nil return e // в интерфейсе лежит (type=*MyErr, value=nil) => это НЕ nil } func main() { err := f() fmt.Println(err == nil) // false fmt.Printf("%T %#v\n", err, err) // *main.MyErr <nil> }
Kotlin:
interface MyErr { fun message(): String } object BoomErr : MyErr { override fun message() = "boom" } fun f(): MyErr? { val e: MyErr? = null return e } fun main() { val err = f() println(err == null) // true println(err) // null }
Rust:
use std::error::Error; use std::fmt; #[derive(Debug)] struct MyErr; impl fmt::Display for MyErr { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "boom") } } impl Error for MyErr {} fn f() -> Option<Box<dyn Error>> { let e: Option<Box<dyn Error>> = None; e } fn main() { let err = f(); println!("{}", err.is_none()); // true println!("{:?}", err); // None }
TypeScript:
interface MyErr { error(): string; } function f(): MyErr | null { const e: MyErr | null = null; return e; } function main() { const err = f(); console.log(err === null); // true console.log(err); // null }
Почему так?
Интерфейс в Go — это пара «тип + значение». Если значение nil, а тип не nil, то весь интерфейс тоже не nil. Поэтому, если хочется поведения как в других языках, придётся опять учесть детали:
func f() error { var e *MyErr = nil if e == nil { return nil } return e }
Все, кто начинает изучать Go, сталкиваются с удивительным фактом: порядок обхода мапы специально случайный. Видимо, это решение было принято еще до того, как инженеры Гугла поставили себе целью усложнить язык для собеседований. Боялись, что типичный разработчик Go будет настолько неопытным, что хотелось уберечь его от попытки завязаться на порядок обхода мапы.
Есть и более каверзные моменты для отлова новичков на собеседованиях. Что выведет Go 1.21?
vals := []int{1, 2, 3} ptrs := []*int{} for _, v := range vals { ptrs = append(ptrs, &v) } fmt.Println(*ptrs[0], *ptrs[1], *ptrs[2])
3 3 3. Потому что v здесь — переменная цикла, причём одна и та же на каждом шаге.
Да, можно было сделать лучше. Ничего подобного не случится ни в Rust, ни в Java, ни в Kotlin, ни в TS, ни в Dart, ни в одном языке, на котором я писал. Примеры на Rust:
fn main() { let vals = vec![1, 2, 3]; let mut ptrs: Vec<&i32> = Vec::new(); for x in vals { ptrs.push(&x); // не скомпилится } }
fn main() { let vals = vec![1, 2, 3]; let mut ptrs: Vec<&i32> = Vec::new(); for v in &vals { ptrs.push(v); } println!("{} {} {}", ptrs[0], ptrs[1], ptrs[2]); // выведет 1, 2, 3 }
Ловушка оказалась настолько удачной, что в Go 1.22 её всё-таки убрали. Видимо, рынок собеседований к тому моменту уже насытился.
Официальная история названия у Go подозрительно тематическая. Язык называется «Go», а слово «golang» прилипло из-за сайта golang.org. Очень в духе языка: чтобы правильно его назвать, надо знать отдельную историческую деталь.

Перечисленные в статье проблемы — это небольшая плата по сравнению с тем, что требует тот же C++. Кроме того, интернет уже переполнен гайдами по подготовке, и снова начали проходить отбор новички. У инженеров Google есть на это ответ — Go 2.
Тем не менее, у Google получился отличный язык программирования, который не просто так набирает всё большую популярность и становится стандартом для разработки микросервисов. Его легко освоить, на нём сложно уничтожать кодовые базы разрушителям корпораций, он быстро компилируется и достаточно быстро работает.