golang

Go и искусство ставить подножку разработчику: разоблачение

  • четверг, 26 марта 2026 г. в 00:00:10
https://habr.com/ru/articles/1014664/

Язык проектировался простым, лёгким в освоении, готовым для написания веб-сервисов с первого дня. Он мог бы таким и остаться, если бы не одна проблема. Проблема отбора.

Инженеры 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

Увеличиваем capacity и находим неожиданных друзей

Из рассекреченного протокола комитета 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"}

String тоже может быть сложным

Уверен, на каком бы языке программирования вы ни писали, проблем со строками не было. Добро пожаловать в Go.

Index out of common sense

Может быть, я придираюсь, но во всех языках, на которых я писал до этого, подобной проблемы не было.

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 год:

Ключевых слов будет немного. Остальное объявим заранее и посмотрим, кто осмелится переопределить истину.


Каналы: неявность by design

У каналов в 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

Типизированный nil: пустота с синдромом вахтёра

Никто: `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
}

Порочный for

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

картинка с Reddit, пост удалили
картинка с Reddit, пост удалили

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

Тем не менее, у Google получился отличный язык программирования, который не просто так набирает всё большую популярность и становится стандартом для разработки микросервисов. Его легко освоить, на нём сложно уничтожать кодовые базы разрушителям корпораций, он быстро компилируется и достаточно быстро работает.