golang

Практика Go — Основы

  • пятница, 8 сентября 2023 г. в 00:00:23
https://habr.com/ru/articles/759378/

Сборник реальных советов по написанию сопровождаемых программ на языке Go. Автор - Dave Cheney, опытный разработчик на Go и один из его ведущих пропагандистов.

Что такое нулевое значение и почему оно полезно?

Начнём со спецификации языка Go на нулевое значение.

Когда память выделяется для хранения значения либо через объявление, либо через вызов make или new, а явная инициализация не предусмотрена, память инициализируется по умолчанию. Каждый элемент такого значения устанавливается в нулевое значение для своего типа: false для булевых чисел, 0 для целых чисел, 0.0 для плавающих, "" для строк и nil для указателей, функций, интерфейсов, срезов, каналов и карт. Эта инициализация выполняется рекурсивно, поэтому, например, у каждого элемента массива структур его поля будут обнулены, если значение не указано.

Это свойство всегда устанавливать значение по известному умолчанию важно для безопасности и корректности программы, но также может сделать ваши программы на Go более простыми и компактными. Именно это имеют в виду погромисты Go, когда говорят "дайте вашим структурам полезное нулевое значение".

Приведём пример с использованием sync.Mutex, который предназначен для использования без явной инициализации. Структура sync.Mutex содержит два неэкспортируемых целочисленных поля. Благодаря нулевому значению эти поля будут устанавливаться в 0 всякий раз, когда объявляется sync.Mutex.

package main

import "sync"

var mu sync.Mutex

func main() {
	val := 1
	// mu можно использовать без явной инициализации.
	mu.Lock()
	val++
	mu.Unlock()
	println(val) // 2
}

Другим примером типа с полезным нулевым значением является bytes.Buffer. Вы можете объявить bytes.Buffer и начать чтение или запись без явной инициализации. Обратите внимание, что io.Copy принимает в качестве второго аргумента io.Reader, поэтому нам необходимо передать указатель на b.

package main

import (
	"bytes"
	"io"
	"os"
)

func main() {
	var b bytes.Buffer
	b.Write([]byte("Hello world\n"))
	io.Copy(os.Stdout, &b)
}

Полезным свойством срезов является их нулевое значение - nil. Это означает, что вам не нужно явно создавать срез, а можно просто объявить его.

package main

import (
	"fmt"
	"strings"
)

func main() {
	// s := make([]string, 0) // []string{}
	// s := []string{} // []string{}
	var s []string // []string(nil)
	fmt.Printf("%#v\n", s)
	s = append(s, "Hello")
	s = append(s, "world")
	fmt.Println(strings.Join(s, " "))
}

Примечание: var s []string аналогична двум закомментированным строкам выше, но не идентична им. Можно определить разницу между значением среза, равным nil, и значением среза, имеющим нулевую длину. Следующий код выведет false.

package main

import (
	"fmt"
	"reflect"
)

func main() {
	var s1 = []string{} // []string{}
	var s2 []string // []string(nil)
	fmt.Println(reflect.DeepEqual(s1, s2))
}

Неожиданным, но полезным свойством указателей nil является возможность вызова методов на типах, имеющих значение nil. Это может быть использовано для простого предоставления значений по умолчанию.

package main

import "fmt"

type Config struct {
	path string
}

func (c *Config) Path() string {
	if c == nil {
		return "/usr/home"
	}
	return c.path
}

func main() {
	var c1 *Config
	var c2 = &Config{
		path: "/export",
	}
	fmt.Println(c1.Path(), c2.Path())
}

Пустая структура

Введение

В этой заметке рассматриваются свойства моего любимого типа данных в Go - пустой структуры.

Пустая структура - это тип структуры, не имеющий полей. Приведем несколько примеров в именованной и анонимной формах

  type Q struct{}
  var q struct{}

Итак, если пустая структура не содержит полей и не содержит данных, то зачем она нужна? Что мы можем с ней делать? Есть одно важное практическое применение пустых структур, и это конструкция chan struct{}, используемая для передачи сигналов между процедурами go.

Ширина

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

Термин ширина (width), как и большинство других терминов, пришел из компилятора gc, хотя его этимология, вероятно, насчитывает несколько десятилетий.

Ширина описывает количество байт памяти, занимаемое экземпляром типа. Поскольку адресное пространство процесса одномерно, я считаю, что ширина - более подходящий термин, чем размер (size).

Ширина - это свойство типа. Поскольку каждое значение в программе на языке Go имеет тип, ширина значения определяется его типом и всегда кратна 8 битам.

Мы можем узнать ширину любого значения, а значит, и ширину его типа с помощью функции unsafe.Sizeof().

  var s string
  var c complex128
  fmt.Println(unsafe.Sizeof(s)) // 8
  fmt.Println(unsafe.Sizeof(c)) // 16

Ширина типа массива кратна типу его элемента.

	var a uint32
	var b [3]uint32
	fmt.Println(unsafe.Sizeof(a)) // 4
	fmt.Println(unsafe.Sizeof(b)) // 12

Структуры обеспечивают более гибкий способ определения составных типов, ширина которых равна сумме ширин составляющих их типов плюс подгонка

	var a uint16
	var b uint32
	type S struct {
		a uint16
		b uint32
	}
	var s S
	fmt.Println(unsafe.Sizeof(a)) // 2
	fmt.Println(unsafe.Sizeof(b)) // 4
	fmt.Println(unsafe.Sizeof(s)) // 8, а не 6

Приведенный пример демонстрирует один из аспектов подгонки: значение должно быть выровнено в памяти на величину, кратную его ширине. В данном случае между a и b компилятором добавлено два байта.

Ширина не связана с выравниванием. У каждого типа есть еще одно свойство - выравнивание (alignment). Выравнивание всегда является степенью двойки. Выравнивание базового типа обычно равно его ширине, но выравнивание структуры - это максимальное выравнивание любого поля, а выравнивание массива - это выравнивание элемента массива. Поэтому максимальное выравнивание любого значения равно максимальному выравниванию любого базового типа. Даже на 32-битных системах это часто 8 байт, поскольку атомарные операции над 64-битными значениями обычно требуют 64-битного выравнивания.

Конкретно, структура, содержащая 3 поля int32, имеет выравнивание 4, а ширину 12.

Верно, что ширина значения всегда кратна его выравниванию. Из этого следует, что между элементами массива нет прокладок.

Кроме того, адрес 0x1beeb0 в примере действительно меняется. Это адрес (ненулевой ширины) глобальной переменной runtime.zerobase, которую можно увидеть, если выполнить команду go tool nm для своего двоичного файла.

Пустая структура

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

  var s struct{}
  fmt.Println(unsafe.Sizeof(s)) // 0

Поскольку пустая структура занимает ноль байт, то, следовательно, она не нуждается в выравнивании. Таким образом, структура, состоящая из пустых структур, также не занимает места.

  type S struct {
    A struct{}
    B struct{}
  }
  var s S
  fmt.Println(unsafe.Sizeof(s)) // 0

Что можно сделать с пустой структурой

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

Можно объявить массив пустых структур, но они, конечно, не занимают места в памяти.

  var x [1000000000]struct{}
  fmt.Println(unsafe.Sizeof(x)) // 0

Срезы из пустых структур занимают только место для заголовка своего среза. Как показано выше, их опорный массив не занимает места.

  var x = make([]struct{}, 1000000000)
  fmt.Println(unsafe.Sizeof(x)) // 24

Разумеется, обычные суб-срезы, len и cap работают как положено.

  var x = make([]struct{}, 100)
  var y = x[:50]
  fmt.Println(len(y), cap(y)) // печатает 50 100

Адрес значения struct{}, если оно является адресуемым, можно получить так же, как и любое другое значение.

  var a struct{}
  var b = &a

Интересно, что адрес двух значений struct{} может быть одинаковым для []struct{}.

  a := make([]struct{}, 10)
  b := make([]struct{}, 20)
  fmt.Println(&a == &b) // false, a и b - разные срезы
  fmt.Println(&a[0] == &b[0]) // true, их опорные массивы одинаковы

Почему так? Если задуматься, то пустые структуры не содержат полей, а значит, не могут содержать данных. Если в пустых структурах нет данных, то невозможно определить, отличаются ли два значения struct{}. По сути, они являются взаимозаменяемыми.

  a := struct{}{} // не нулевое значение, а реальный новый экземпляр struct{}
  b := struct{}{}
  fmt.Println(a == b) // true

Примечание: это свойство не является обязательным в спецификации, но в ней отмечается, что две разные переменные нулевого размера могут иметь один и тот же адрес в памяти. Например, адреса двух значений struct{} для переменных разные.

  var a, b struct{}
  fmt.Println(&a == &b) // false

struct{} как метод-рессивер

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

type S struct{}

func (s *S) addr() { fmt.Printf("%p\n", s) }

func main() {
	var a, b S
	a.addr() // 0x1171fa0
	b.addr() // 0x1171fa0
}

В этом примере показано, что адрес всех значений нулевого размера равен 0x1171fa0. Точный адрес, вероятно, будет отличаться для разных версий Go.

Про объявление переменных

В Go существует несколько способов объявления переменной. Возможно, их больше, чем требуется, но с учётом действующего контракта Go 1 это не изменится.

Далее приведены примеры того, как я решаю, какой синтаксис объявления переменной использовать. Это просто предложения, они имеют смысл для меня, но я уверен, что у других есть не менее веские обоснования для альтернативных вариантов.

При объявлении (но не инициализации) переменной следует использовать следующий синтаксис

  var num int

Поскольку Go не допускает неинициализированных переменных, num будет инициализирована нулевым значением.

Другими примерами такой формы могут быть

  var things []Thing // пустой срез Things
  for t := range ThingCreator() {
    things = append(things, t)
  }

  var thing Thing // пустая структура Thing
  json.Unmarshall(reader, &thing)

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

При объявлении и инициализации следует использовать короткий синтаксис объявления. Например

  num := rand.Int()

Отсутствие var является сигналом того, что эта переменная была инициализирована. Я также обнаружил, что отказ от объявления типа переменной и вывод его из правой части присваивания облегчает рефакторинг в будущем.

Конечно, в любом правиле есть исключения.

  min, max := 0, 1000

Но, возможно, в данном случае min и max действительно являются константами.

  var length uint32 = 0x80

Здесь length может использоваться с библиотекой, требующей определенного числового типа, и это, вероятно, более читабельно, чем

  golength := uint32(0x80)

В Go есть и make и new функции, что происходит?

Как отметил Роб Пайк на одной из GopherCon, в Go существует множество способов инициализации переменных. Среди них - возможность взять адрес литерала структуры, что приводит к множеству способов сделать одно и то же.

  s := &SomeStruct{}
  v := SomeStruct{}
  s := &v // идентично
  s := new(SomeStruct) // также идентично

Справедливо, что комментаторы отмечают эту избыточность в языке, и это иногда заставляет их искать другие несоответствия, в первую очередь избыточность между make и new.

На первый взгляд кажется, что make и new делают очень похожие вещи, так в чём же смысл их наличия?

Почему мы не можем использовать make для всего?

В Go нет общих типов, определяемых пользователем, но есть несколько встроенных типов, которые могут работать как общие списки, карты, множества и очереди; срезы, карты и каналы.

Поскольку make предназначен для создания этих трёх встроенных общих типов, он должен предоставляться средой выполнения, так как в Go нет возможности выразить сигнатуру функции make непосредственно.

Хотя make создаёт общие значения slice, map и channel, они всё равно являются обычными значениями; make не возвращает значения указателей.

Если бы new была удалена в пользу make, то как бы вы сконструировали указатель на инициализированное значение?

  var x1 *int
  var x2 = new(int)

x1 и x2 имеют один и тот же тип, *int, x2 указывает на инициализированную память и может быть безопасно разыменован, что не верно для x1.

Почему мы не можем использовать new для всего?

Хотя new используется редко, его поведение хорошо определено.

new(T) всегда возвращает *T, указывающий на инициализированный T. Поскольку в Go нет конструкторов, значение будет инициализировано нулевым значением T.

Использование new для построения указателя на нулевое значение среза, карты или канала работает сегодня и соответствует поведению new.

  s := new([]string)
  fmt.Println(len(*s))  // 0
  fmt.Println(*s == nil) // true

  m := new(map[string]int)
  fmt.Println(m == nil) // false
  fmt.Println(*m == nil) // true

  c := new(chan int)
  fmt.Println(c == nil) // false
  fmt.Println(*c == nil) // true

Конечно, но ведь это всего лишь правила, мы можем их изменить, не так ли?

Если говорить о путанице, которую они могут вызвать, то make и new соответствуют друг другу; make создает только срезы, карты и каналы, new возвращает только указатели на инициализированную память.

Да, new можно было бы расширить, чтобы он работал как make для срезов, карт и каналов, но это внесло бы свои собственные несоответствия.

  • new будет иметь особое поведение, если тип, переданный в new, является срезом, картой или каналом. Это правило должен помнить каждый погромист Go.

  • Для срезов и каналов new должен был бы стать переменным, принимая возможную длину, размер буфера или ёмкость, в зависимости от необходимости. Опять же, придётся запомнить ещё несколько особых случаев, в то время как раньше new принимал только один аргумент - тип.

  • new всегда возвращает *T для переданного ему T. Это означает, что код типа

  func Read(buf []byte) []byte
  // предполагается, что new принимает необязательную длину
  buf := Read(new([]byte, 4096))

будет уже невозможен, что потребует дополнительных специальных случаев в грамматике для разрешения *new([]byte, length).

В целом

make и new делают разные вещи.

Если вы переходите с другого языка, особенно с языка, использующего конструкторы, может показаться, что new - это всё, что вам нужно, но Go - не такой язык, и в нём нет конструкторов.

Мой совет - используйте new редко, почти всегда есть более простые или чистые способы написать программу без него.

Для рецензента кода, использование new, как и использование именованных аргументов возврата, является сигналом того, что код пытается сделать что-то умное, и мне следует обратить на это особое внимание. Возможно, код действительно умный, но, скорее всего, его можно переписать, сделав более понятным и идиоматичным.

Какие методы должны быть объявлены на T или *T

В Go для любого типа T существует тип *T, который является результатом выражения, принимающего адрес переменной типа T (мы говорим T, но это всего лишь обозначение типа, который вы объявляете). Например:

  type T struct { a int; b bool }
  var t T    // тип для t - T
  var p = &t // тип для p - *T

Эти два типа, T и *T, различны, но *T не может быть заменен на T (это правило рекурсивно: взятие адреса переменной типа *T возвращает результат типа **T.

Вы можете объявить метод на любом типе, который вам принадлежит, то есть на типе, который вы объявили в своём пакете (именно поэтому никто не может объявлять методы на примитивных типах, например int). Отсюда следует, что объявлять методы можно как на объявленном типе T, так и на соответствующем ему производном типе-указателе *T. По-другому можно сказать, что методы на типе объявляются так: они принимают копию значения своего рессивера или указатель на значение своего рессивера (методы в Go - это просто синтаксический сахар для функции, которая передаёт рессивер в качестве первого формального параметра). Возникает вопрос, какую форму лучше использовать?

Очевидно, что если метод мутирует свой рессивер, то он должен быть объявлен на *T. Однако если метод не мутирует свой рессивер, можно ли объявить его на T? Если метод не мутирует свой рессивер, то нужно ли ему быть методом?

Оказывается, что случаев, когда это безопасно, очень мало. Например, хорошо известно, что нельзя копировать значение sync.Mutex, так как это нарушает инварианты мьютекса. Поскольку мьютексы управляют доступом к другим объектам, их часто оборачивают в структуру с управляемым значением:

package counter

import "sync"

type Val struct {
	mu  sync.Mutex
	val int
}

func (v *Val) Get() int {
	v.mu.Lock()
	defer v.mu.Unlock()
	return v.val
}

func (v *Val) Add(n int) {
	v.mu.Lock()
	defer v.mu.Unlock()
	v.val += n
}

Большинство погромистов на языке Go знают, что ошибкой будет забыть объявить методы Get или Add на рессивере указателя *Val. Однако любой тип, который встраивает Val для использования его нулевого значения, также должен объявлять методы только на своём рессивере-указателе, иначе он может случайно скопировать содержимое значений своего встроенного типа.

type Stats struct {
  a, b, c counter.Val
}

func (s Stats) Sum() int {
  return s.a.Get() + s.b.Get() + s.c.Get() // whoops
}

Аналогичная ситуация может возникнуть и с типами, которые хранят срезы значений, и, конечно же, существует возможность непреднамеренной гонки данных.

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

Срезы с нуля

Массивы

Любое обсуждение типа среза в Go начинается с разговора о том, что не является срезом, а именно о типе массива в Go. Массивы в Go обладают двумя важными свойствами:

  • Они имеют фиксированный размер; [5]int одновременно является массивом из 5 элементов типа int и отличается от [3]int.

  • Они являются типами значений. Рассмотрим следующий пример:

package main

import "fmt"

func main() {
	var a [5]int
	b := a
	b[2] = 7
	fmt.Println(a, b) // prints [0 0 0 0 0] [0 0 7 0 0]
}

Оператор b := a объявляет новую переменную b типа [5]int и копирует содержимое a в b. Обновление b не влияет на содержимое a, поскольку a и b - независимые величины (это не уникальное свойство массивов, в Go каждое присваивание является копией).

Срезы

Тип срез в Go отличается от своего аналога массива двумя важными особенностями:

  • Срезы не имеют фиксированной длины. Длина среза не объявляется как часть его типа, а хранится в самом срезе и может быть восстановлена с помощью встроенной функции len (вы также можете использовать len для значений массивов, но результат будет заведомо ложным).

  • Присвоение одной переменной среза другой не приводит к созданию копии содержимого среза. Это связано с тем, что срез не хранит непосредственно своё содержимое. Вместо этого срез содержит указатель на нижележащий массив, в котором хранится содержимое среза (это свойство также известно как резервный массив или иногда, менее корректно, как резервный срез).

В результате второй особенности два среза могут использовать один и тот же базовый массив. Рассмотрим следующие примеры:

  • Нарезка среза:

package main

import "fmt"

func main() {
	var a = []int{1, 2, 3, 4, 5}
	b := a[2:]
	b[0] = 0
	fmt.Println(a, b) // prints [1 2 0 4 5] [0 4 5]
}

В этом примере a и b используют один и тот же базовый массив, несмотря на то, что b начинается с другого смещения в этом массиве и имеет другую длину. Таким образом, изменения базового массива через b видны a.

  • Передача среза в функцию:

package main

import "fmt"

func negate(s []int) {
	for i := range s {
		s[i] = -s[i]
	}
}

func main() {
	var a = []int{1, 2, 3, 4, 5}
	negate(a)
	fmt.Println(a) // prints [-1 -2 -3 -4 -5]
}

В этом примере a передаётся в negate как формальный параметр s. negate перебирает элементы s, отрицая их знак. Несмотря на то, что negate не возвращает значения и не имеет возможности получить доступ к объявлению a в main, содержимое a изменяется при передаче negate.

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

Python 2.7.10
>>> a = [1,2,3,4,5]
>>> b = a
>>> b[2] = 0
>>> a
[1, 2, 0, 4, 5]

А также в Ruby:

irb(main):001:0> a = [1,2,3,4,5]
=> [1, 2, 3, 4, 5]
irb(main):002:0> b = a
=> [1, 2, 3, 4, 5]
irb(main):003:0> b[2] = 0
=> 0
irb(main):004:0> a
=> [1, 2, 0, 4, 5]

То же самое относится и к большинству языков, в которых массивы рассматриваются как объекты или ссылочные типы (в Go мы склонны говорить тип значения и тип указателя из-за путаницы, вызванной ссылочным типом C++, но в данном случае я думаю, что называть массивы как объекты ссылочными типами вполне уместно).

Значение заголовка среза

Магия, которая заставляет срез вести себя и как значение, и как указатель, заключается в том, что срез - это фактически тип struct. Его принято называть срез-заголовком, по аналогии с его аналогом в пакете reflect. Определение заголовка slice выглядит примерно так:

package runtime

import "unsafe"

type slice struct {
	ptr unsafe.Pointer
	len int
	cap int
}

Это важно, поскольку, в отличие от типов map и chan, срезы являются типами значений и копируются при присваивании или передаче в качестве аргументов в функции.

Чтобы проиллюстрировать это, погромисты инстинктивно понимают, что формальный параметр v из square является независимой копией v, объявленного в main.

package main

import "fmt"

func square(v int) {
	v = v * v
}

func main() {
	v := 3
	square(v)
	fmt.Println(v) // prints 3, not 9
}

Таким образом, функция square над своим v никак не влияет на v в main. Так же и формальный параметр s из double является независимой копией среза s, объявленного в main, а не указателем на значение s, объявленного в main.

package main

import "fmt"

func double(s []int) {
	s = append(s, s...)
}

func main() {
	s := []int{1, 2, 3}
	double(s)
	fmt.Println(s, len(s)) // prints [1 2 3] 3
}

Несколько необычная природа переменной среза заключается в том, что она передаётся как значение, а не как указатель. В 90% случаев, когда вы объявляете структуру в Go, вы передаёте указатель на значения этой структуры (я бы утверждал, что если у этой структуры есть метод, определенный на ней, и/или она используется для удовлетворения интерфейса, то процент того, что вы передадите указатель на вашу структуру, возрастает почти до 100%). Это довольно редкий случай, единственный пример передачи структуры в качестве значения, который я могу вспомнить, - это time.Time.

Именно такое исключительное поведение срезов в качестве значений, а не указателей на значения, может запутать погромиста Go в понимании того, как работают срезы. Просто помните, что каждый раз, когда вы присваиваете, разбиваете на подмножества, передаете или возвращаете срез, вы делаете копию трёх полей в заголовке среза: указателя на базовый массив, текущей длины и ёмкости.

Собираем всё вместе

В завершение я рассмотрю пример со срезом как стеком:

package main

import "fmt"

func f(s []string, level int) {
	if level > 5 {
		return
	}
	s = append(s, fmt.Sprint(level))
	f(s, level+1)
	fmt.Println("level:", level, "slice:", s)
}

func main() {
	f(nil, 0)
}

Начиная с main, мы передаём в f срез nil в качестве уровня 0. Внутри f мы добавляем к s текущий уровень, затем увеличиваем уровень и выполняем рекурсию. Как только уровень превысит 5, вызовы f возвращаются, печатая свой текущий уровень и содержимое своей копии s.

level: 5 slice: [0 1 2 3 4 5]
level: 4 slice: [0 1 2 3 4]
level: 3 slice: [0 1 2 3]
level: 2 slice: [0 1 2]
level: 1 slice: [0 1]
level: 0 slice: [0]

Видно, что на каждом уровне значение s не зависит от действий других вызывающих f, и что, хотя было создано четыре базовых массива (доказательство этого оставляю на усмотрение читателя), более высоких уровней f в стеке вызовов не затронуты копированием и перераспределением новых базовых массивов в качестве побочного продукта append.

Дополнительная информация

Если вы хотите узнать больше о том, как работают срезы в Go, я рекомендую эти статьи из блога Go:

Если карта не является ссылочной переменной, то что это такое?

Выше я показал, что карты в Go не являются ссылочными переменными и не передаются по ссылке. В связи с этим возникает вопрос.

Для самых нетерпеливых отвечу так:

Значение карты - это указатель на структуру runtime.hmap.

Если вас не удовлетворило это объяснение, читайте дальше.

Что такое тип значения карты?

При написании оператора

  m := make(map[int]int)

компилятор заменяет его вызовом runtime.makemap, который имеет сигнатуру

// makemap реализует создание карты Go make(map[k]v, hint)
// Если компилятор определил, что карта или первый bucket
// могут быть созданы на стеке, то h и/или bucket могут быть не nil.
// Если h != nil, то карта может быть создана непосредственно в h.
// Если bucket != nil, то bucket может быть использован в качестве первого bucket.
func makemap(t *maptype, hint int64, h *hmap, bucket unsafe.Pointer) *hmap

Как видите, тип значения, возвращаемого из runtime.makemap, - это указатель на структуру runtime.hmap. Из обычного кода Go это не видно, но мы можем подтвердить, что значение карты имеет тот же размер, что и uintptr - одно машинное слово.

package main

import (
	"fmt"
	"unsafe"
)

func main() {
	var m map[int]int
	var p uintptr
	fmt.Println(unsafe.Sizeof(m), unsafe.Sizeof(p)) // 8 8 (linux/amd64)
}

Если карты являются указателями, то не должны ли они иметь вид *map[key]value?

Хороший вопрос: если карты являются указателями, то почему выражение make(map[int]int) возвращает значение с типом map[int]int. Разве оно не должно возвращать *map[int]int? На этот вопрос недавно ответил Ян Тейлор в теме golang-nuts (если заглянуть достаточно далеко в историю репозитория Go, то можно найти примеры карт, созданных с помощью нового оператора).

В самые ранние времена то, что мы сейчас называем картами, записывалось как указатели, поэтому вы писали *map[int]int. Мы отошли от этого, когда поняли, что никто никогда не напишет map, не написав *map.

Возможно, переименование типа из *map[int]int в map[int]int, хотя и сбивает с толку, поскольку тип не похож на указатель, но менее запутан, чем значение в форме указателя, которое нельзя разыменовать.

Заключение

Карты, как и каналы (но в отличие от срезов), являются просто указателями на типы времени выполнения. Как вы видели выше, карта - это просто указатель на структуру runtime.hmap.

Карты имеют ту же семантику указателя, что и любое другое значение указателя в программе на Go. Никакой магии не существует, кроме переписывания компилятором синтаксиса map в вызовы функций runtime/hmap.go.

Не следует называть переменные именами их типов по той же причине, по которой вы не стали бы называть своих домашних животных "собака" или "кошка".

Имя переменной должно описывать её содержимое, а не тип содержимого. Рассмотрим этот пример:

var usersMap map[string]*User

Каковы некоторые хорошие свойства этого объявления? Мы видим, что это карта, и она имеет отношение к типу *User, так что это, вероятно, хорошо. Но usersMap - это карта, а Go, будучи статически типизированным языком, не позволит нам случайно использовать карту там, где требуется другой тип, поэтому суффикс Map как мера предосторожности является излишним.

Теперь рассмотрим, что произойдёт, если мы объявим другие переменные с использованием этого шаблона:

var (
  companiesMap map[string]*Company
  productsMap map[string]*Products
)

Теперь у нас есть три переменные типа map в области видимости: usersMap, companiesMap и productsMap, все они отображают строки на различные типы структур. Мы знаем, что это карты, и знаем, что их объявления не позволяют нам использовать одну вместо другой - компилятор выдаст ошибку, если мы попытаемся использовать companiesMap там, где код ожидает map[string]*User. В данной ситуации очевидно, что суффикс Map не улучшает ясность кода, а просто является лишним.

Я предлагаю избегать любых суффиксов, напоминающих тип переменной. Другими словами, если users недостаточно описательна, то usersMap тоже не подойдёт.

Этот совет применим и к параметрам функций. Например:

type Config struct {
  //
}

func WriteConfig(w io.Writer, config *Config)

Называть параметр *Config как config излишне. Мы знаем, что это указатель на Config, об этом говорится прямо в объявлении. Вместо этого подумайте, подойдёт ли conf, или, может быть, просто c, если время жизни переменной достаточно мало.

Этот совет объясняется не только стремлением к краткости. Если в один момент времени в области видимости находится более одной *Config, то называть их config1 и config2 менее описательно, чем называть их original и updated. В последнем случае меньше вероятность случайной перестановки, которую компилятор не заметит, в то время как первые отличаются только суффиксом из одного символа.

Наконец, не позволяйте именам пакетов красть хорошие имена переменных. Имя импортируемого идентификатора включает в себя имя его пакета. Например, тип Context из пакета context при импорте в другой пакет будет называться context.Context. Это делает невозможным использование контекста в качестве переменной или типа, если, конечно, не переименовать импорт, но это уже лишнее. Поэтому локальным объявлением для типов context.Context традиционно является ctx. Например:

func WriteLog(ctx context.Context, message string)

Имя переменной должно быть независимым от её типа. Не следует называть переменные в соответствии с их типами по той же причине, по которой вы не стали бы называть своих домашних животных "собака" или "кошка". По той же причине не следует включать имя типа в имя переменной.