Перейти к основному содержимому

Основы синтаксиса

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

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

Комментарии

Комментарии - это текст, который не выполняется при запуске программы. Они используются для объяснения кода и документирования его функциональности.

// Однострочный комментарий — объясняем одну строку

/*
Многострочный комментарий.
Полезен для описания сложной логики
или временного отключения кода.
*/

// TODO: реализовать кэширование результатов
// FIXME: исправить утечку памяти при большом трафике
// HACK: временное решение до обновления библиотеки


Go имеет инструмент godoc — он генерирует документацию из комментариев перед функциями/типами.

Переменные

Переменные — это именованные ячейки памяти, куда мы кладём данные. Переменные могут быть объявлены несколькими способами:

  1. Полное объявление с типом
  2. Без типа — Go сам догадается (type inference)
  3. Короткое объявление := (самый популярный способ внутри функций)
  4. Множественное объявление — полезно, когда нужно объявить несколько переменных в функции
  5. Групповое объявление — удобно для объявления нескольких переменных в одной строке
// 1. Полное объявление с типом
var name string = "Алексей"
var age int = 27

// 2. Без типа — Go сам догадается (type inference)
var city = "Санкт-Петербург"
var height = 178.5

// 3. Короткое объявление := (самый популярный способ внутри функций)
name := "Алексей" // string
age := 27 // int
isDeveloper := true // bool

// 4. Множественное объявление
var (
firstName string = "Мария"
lastName string = "Иванова"
salary float64 = 150000.0
employed bool = true
)

// 5. Групповое короткое объявление
product, price, inStock := "Ноутбук", 89990.0, true

Важно: := работает только внутри функций. Снаружи используйте var.

Константы

Константы — значения, которые нельзя изменить. Объявляются через const. Хорошая практика — использовать определение групповых констант в начале файла для дальнейшего обращения к ним в этом же файле (Пример с HTTP статусами).

const Pi = 3.14159
const AppName = "Мой калькулятор"

// Константы HTTP статусов
const (
HTTPStatusOK = 200
HTTPStatusNotFound = 404
MaxRetries = 5
)

Константы вычисляются на этапе компиляции — это делает код быстрее и безопаснее!

Строки

Строки в Go — это последовательность байтов (UTF-8).

greeting := "Привет, Go! 🚀"
multiline := `
Это многострочная строка.
Очень удобно для SQL-запросов
или длинных сообщений.
`

// Длина строки в байтах
length := len(greeting) // посчитает байты, не символы!

Unicode и UTF-8

Unicode — это международный стандарт, который присваивает уникальный номер (code point) каждому символу из всех письменностей мира: буквы, цифры, эмодзи, иероглифы, математические знаки и т.д.

  • Примеры кодов:
    • 'A' → U+0041
    • 'Я' → U+042F
    • '👋' → U+1F44B
  • На сегодняшний день Unicode содержит более 149 000 символов (и продолжает расти).
  • Код поинт — это просто число (обычно записывается как U+XXXXXX).

Unicode отвечает только на вопрос: какой номер у символа? Он не говорит, как эти номера хранить в памяти или на диске.

UTF-8 — это способ кодирования символов Unicode в байты (самый популярный в мире).

Как работает UTF-8

UTF-8 кодирует один символ переменным количеством байт (от 1 до 4):

Диапазон символовКоличество байтПример
ASCII (0–127)1 байт'A' → 1 байт (0x41)
Русские, европейские буквы2 байта'Я' → 2 байта
Основные эмодзи, китайские3 байта'😊' → 3 байта
Редкие символы, сложные эмодзи4 байта'👋' → 4 байта

Главные преимущества UTF-8:

  • Обратная совместимость с ASCII: первые 128 символов — это обычный ASCII (1 байт).
  • Экономия памяти: английский текст занимает столько же места, сколько в старых кодировках.
  • Безопасность: нет проблем с "битыми" символами при обрезке строки.
  • Универсальность: используется в интернете, файлах, базах данных (более 98% веб-сайтов на UTF-8).

Почему это важно для Go

В Go строки — это байты в кодировке UTF-8 по умолчанию:

  • len("Привет") → 12 (байт, а не символов!)
  • for i, r := range "Привет" → правильно перебирает 6 рун (символов)

Unicode — это огромный каталог всех символов мира с уникальными номерами. UTF-8 — умный и экономный способ сохранить эти символы в байтах, который стал стандартом современного мира.

Конкатенация строк

В Go строки — неизменяемые (immutable). Каждый раз, когда ты "склеиваешь" строки, создаётся новая строка в памяти. Это важно понимать, потому что от выбора метода зависит производительность, особенно при большом количестве операций.

Вот все основные способы конкатенации — от простых до самых эффективных.

1. Оператор + (самый простой, но не самый быстрый)

s := "Привет" + ", " + "мир!" + " 🌍"
fmt.Println(s) // Привет, мир! 🌍

Когда использовать:

  • Для 2–5 строк — удобно и читаемо.
  • В большинстве случаев в обычном коде.

Проблема: При большом количестве конкатенаций создаётся много промежуточных строк → много аллокаций памяти и копирований.

2. fmt.Sprintf — форматирование

name := "Алексей"
age := 30
s := fmt.Sprintf("Меня зовут %s, мне %d лет", name, age)
fmt.Println(s) // Меня зовут Алексей, мне 30 лет

Плюсы:

  • Очень читаемо.
  • Безопасно для разных типов.

Минусы:

  • Медленнее + при простых случаях.
  • Выделяет память под буфер.

Когда использовать: когда смешиваешь строки с другими типами (int, float и т.д.).

3. strings.Join — склеивание слайса строк

parts := []string{"Go", "—", "это", "круто!"}
s := strings.Join(parts, " ")
fmt.Println(s) // Go — это круто!

Плюсы:

  • Эффективнее, чем многократный + в цикле.
  • Один проход по слайсу.

Когда использовать: когда у тебя уже есть слайс строк.

4. strings.Builder — САМЫЙ ЭФФЕКТИВНЫЙ для большого количества операций

Это рекомендуемый способ в Go для конкатенации в цикле.

import "strings"

var builder strings.Builder

for i := 1; i <= 5; i++ {
builder.WriteString("Строка ")
builder.WriteString(strconv.Itoa(i))
builder.WriteString("\n")
}

s := builder.String()
fmt.Println(s)
// Вывод:
// Строка 1
// Строка 2
// Строка 3
// Строка 4
// Строка 5

Почему Builder самый быстрый?

  • Выделяет буфер один раз и растит его по мере необходимости.
  • Ноль лишних аллокаций (в отличие от +).
  • Минимальное копирование.

5. bytes.Buffer — альтернатива Builder (почти то же самое)

import "bytes"

var buf bytes.Buffer
buf.WriteString("Привет")
buf.WriteString(" от Go!")
s := buf.String()

strings.Builder — это упрощённая версия bytes.Buffer, оптимизированная именно для строк.

Сравнение производительности (пример для 1000 конкатенаций)

МетодВремя (примерно)Аллокаций памятиРекомендация
+ в циклеОчень медленно~1000Не использовать в циклах!
fmt.SprintfМедленноМногоТолько для простых случаев
strings.JoinХорошоНесколькоКогда есть готовый слайс
strings.Builder⚡ Очень быстро1–2Лучший выбор для циклов

Итог: что использовать и когда?

  • 2–3 строки → просто +
  • Смешивание типовfmt.Sprintf
  • Слайс строкstrings.Join
  • Цикл или много операцийвсегда strings.Builder

Пример "правильного" кода в цикле:

var builder strings.Builder
for _, word := range words {
builder.WriteString(word)
builder.WriteString(" ")
}
result := builder.String()

byte и rune

В Go строки — это не просто массив символов, как в многих других языках. Чтобы правильно работать с текстом (особенно с русским, китайским, эмодзи и т.д.), нужно понимать разницу между byte и rune.

1. byte — это 8-битный байт (uint8)

  • byte — это псевдоним для uint8 (беззнаковое целое от 0 до 255).
  • Строка в Go внутри — это слайс байт ([]byte).
  • Когда ты пишешь:
    s := "Hello"
    fmt.Println(s[0]) // 72 — это код буквы 'H' в ASCII
    s[0] возвращает byte, то есть один байт строки.

Проблема с byte: Многие символы (русские буквы, эмодзи, китайские иероглифы) не помещаются в один байт. Они кодируются в UTF-8 несколькими байтами (от 2 до 4).

Пример:

s := "Привет 👋"
fmt.Println(len(s)) // 13 — длина в байтах!
fmt.Println(s[0]) // 208 — первый байт буквы 'П'
fmt.Println(s[1]) // 159 — второй байт буквы 'П'

Буква «П» занимает 2 байта, а эмодзи 👋 — 4 байта. Если перебирать строку по байтам — получишь "мусор".

2. rune — это один символ Unicode (int32)

  • rune — это псевдоним для int32.
  • Один rune представляет один юникод-символ (code point).
  • Чтобы правильно работать с символами — преобразуй строку в слайс рун:
    s := "Привет 👋"
    runes := []rune(s)
    fmt.Println(len(runes)) // 8 — реальное количество символов!
    fmt.Println(runes[0]) // 1055 — код буквы 'П'
    fmt.Println(runes[6]) // 128075 — код эмодзи 👋

Сравнение byte и rune

Характеристикаbyterune
Типuint8int32
Размер1 байт4 байта
Что представляетОдин байт строки (в UTF-8)Один символ Unicode
Длина строки len(s)Количество байт
Правильная длина символовНетlen([]rune(s))
Подходит дляБинарные данные, ASCIIТекст с любыми символами (русский, эмодзи)

Когда использовать что?

  • byte и []byte:

    • Работа с бинарными данными (файлы, сеть, хэши).
    • Когда точно знаешь, что текст только ASCII (например, имена файлов, URL).
    • Для максимальной эффективности (меньше памяти).
  • rune и []rune:

    • Когда нужно считать символы правильно.
    • Перебор строки посимвольно.
    • Работа с текстом пользователя (имена, сообщения, эмодзи).

Полезные примеры

  1. Правильный перебор строки:

    s := "Привет, world! 👋"
    for i, r := range s { // range работает по рунам!
    fmt.Printf("Позиция %d: %c (код %d)\n", i, r, r)
    }

    range по строке автоматически даёт руны — это самый удобный способ!

  2. Получение символа по индексу:

    s := "Hello 👋"
    runes := []rune(s)
    fmt.Println(string(runes[6])) // 👋 — правильно!
    // fmt.Println(string(s[6])) // ошибка! s[6] — байт
  3. Проверка длины в символах:

    import "unicode/utf8"

    s := "Hello 👋"
    fmt.Println(len(s)) // 10 байт
    fmt.Println(utf8.RuneCountInString(s)) // 7 символов — правильно!

Числовые типы

ТипРазмерДиапазонКогда использовать
int32/64 битаЗависит от платформыОбычные целые числа
int88 бит-128..127Когда точно знаете маленький диапазон
int1616 бит-32768..32767
int3232 бита-2.1 млрд..2.1 млрд
int6464 битаОчень большой диапазонБольшие числа, время в наносекундах
uint*Без знакаТолько положительныеСчётчики, биты
float3232 бита~7 знаков после запятойКогда важна экономия памяти
float6464 бита~15 знаков после запятой (рекомендуется)Большинство расчётов с дробями

Пример:

temperature := 23.5     // float64 по умолчанию
balance := 1000000 // int
byteValue := byte('A') // uint8, равно 65

Булевый тип

isActive := true
hasError := false
canProceed := isActive && !hasError

Нулевое значение (zero value)

Если переменную объявили, но не присвоили значение — Go даст "нулевое":

  • int → 0
  • float → 0.0
  • bool → false
  • string → ""

Это спасает от случайных ошибок!

Преобразование типов

В Go типизация строгая и статическая — компилятор знает тип каждой переменной на этапе компиляции. В отличие от JavaScript или Python, автоматического (неявного) преобразования типов нет. Если хочешь использовать значение одного типа как другого — нужно явно преобразовать. Это делает код предсказуемым и защищает от скрытых ошибок.

Основные числовые преобразования

Между численными типами (int, float64, int32 и т.д.) преобразование простое — указываешь новый тип в скобках.

age := 25                // int
height := 1.75 // float64

// int → float64
total := float64(age) + height
fmt.Println(total) // 26.75

// float64 → int (отбрасывает дробную часть!)
truncated := int(height)
fmt.Println(truncated) // 1 (не округляет! просто отрезает)

// int32 → int64
var small int32 = 100
big := int64(small) // безопасно

Важно:

  • При преобразовании из большего типа в меньший (например, int64 → int32) возможна потеря данных или переполнение.
  • Из float в int — всегда отбрасывается дробная часть (не округляется).

Число → строка

Для этого используем пакет strconv (standard convert).

import "strconv"

age := 25
ageStr := strconv.Itoa(age) // Itoa = "Integer to ASCII"
fmt.Println(ageStr) // "25"

// Для float
price := 99.90
priceStr := strconv.FormatFloat(price, 'f', 2, 64)
// 'f' — формат без экспоненты
// 2 — количество знаков после запятой
// 64 — битность float64
fmt.Println(priceStr) // "99.90"

// Другие форматы
scientific := strconv.FormatFloat(1234.56, 'e', 2, 64) // "1.23e+03"
withPlus := strconv.FormatFloat(-12.34, 'f', 2, 64) // "-12.34"

Строка → число

Тоже через strconv, но с обработкой ошибок — потому что строка может быть некорректной.

import "strconv"

// Строка → int
num, err := strconv.Atoi("42")
if err != nil {
fmt.Println("Ошибка преобразования:", err)
} else {
fmt.Println("Число:", num) // 42
}

// Ошибки
_, err = strconv.Atoi("abc")
fmt.Println(err) // strconv.Atoi: parsing "abc": invalid syntax

// Строка → float64
f, err := strconv.ParseFloat("3.14159", 64)
if err != nil {
fmt.Println("Ошибка:", err)
} else {
fmt.Println("Float:", f) // 3.14159
}

// ParseFloat умеет и с экспонентой
f, _ = strconv.ParseFloat("1.23e-4", 64) // 0.000123

Строка → bool

b, err := strconv.ParseBool("true")
fmt.Println(b) // true

// Поддерживает: "1", "t", "True", "TRUE", "true" и аналогично для false

Преобразование между []byte и string

Это особый случай — очень эффективный и без копирования (в большинстве случаев).

s := "Привет"
bytes := []byte(s) // string → []byte
back := string(bytes) // []byte → string

fmt.Println(bytes) // [208 159 209 128 208 184 208 178 208 181 209 130]
fmt.Println(back) // Привет

Полезно при работе с сетью, файлами, хэшами.

Когда преобразование невозможно

Go не позволит преобразовать несовместимые типы:

var i int = 42
var s string = string(i) // ошибка компиляции!

Это не символ, а число — прямое преобразование запрещено. Нужно через strconv.

Правила преобразования

Что → Во чтоКак делатьПримечание
Число → другой численныйnewType(oldValue)Может обрезать или переполниться
Число → строкаstrconv.Itoa(), strconv.FormatFloat()
Строка → числоstrconv.Atoi(), strconv.ParseFloat()Всегда проверяй ошибку!
Строка → []byte и обратно[]byte(s), string(bytes)Очень быстро, часто без копии
Структура → другой типНевозможно напрямуюНужно писать вручную или использовать encoding/json и т.п.

Главное правило Go: «Явное лучше неявного». Преобразование типов должно быть очевидным и под контролем программиста.

Операторы

Арифметические

a, b := 10, 3

fmt.Println(a + b) // 13
fmt.Println(a - b) // 7
fmt.Println(a * b) // 30
fmt.Println(a / b) // 3 (целочисленное деление)
fmt.Println(a % b) // 1 (остаток)

a++ // a становится 11
b-- // b становится 2

Сравнения

x, y := 5, 10

x == y // false
x != y // true
x < y // true
x <= y // true
x > y // false
x >= y // false

Логические

hasTicket := true
hasPassport := false

canTravel := hasTicket && hasPassport // false (И)
canEnter := hasTicket || hasPassport // true (ИЛИ)
notAllowed := !hasTicket // false (НЕ)

Присваивания

score := 100
score += 20 // 120
score -= 10 // 110
score *= 2 // 220
score /= 5 // 44
score %= 7 // 2

Указатели

В Go есть два способа передавать данные: по значению (value) и по ссылке (через указатель). Указатели — это одна из самых важных тем, потому что они позволяют экономить память, изменять данные "на месте" и работать с большими структурами эффективно.

Что такое указатель?

Указатель — это переменная, которая хранит адрес в памяти другой переменной.

var a int = 10
var p *int = &a // p — указатель на a
  • &a — оператор "взять адрес" → возвращает указатель на a.
  • *int — тип "указатель на int".
  • *p — оператор "разыменование" → получить значение, на которое указывает p.

Pointer Diagram

Если вы хотите увидеть, как работает указатель, то вот

Pointer Diagram

Зачем нужны указатели?

  1. Изменение значения внутри функции По умолчанию Go передаёт аргументы по значению — функция получает копию.

    func zero(x int) {
    x = 0 // меняем только копию
    }

    func main() {
    a := 5
    zero(a)
    fmt.Println(a) // всё ещё 5
    }

    С указателем — можно изменить оригинал:

    func zeroPtr(x *int) {
    *x = 0 // меняем значение по адресу
    }

    func main() {
    a := 5
    zeroPtr(&a)
    fmt.Println(a) // 0
    }
  2. Экономия памяти при больших структурах Если структура большая (много полей), передача по значению создаёт полную копию — медленно и ест память.

    type BigStruct struct {
    Data [1000]int
    Name string
    // ...
    }

    func process(s BigStruct) { ... } // копируется вся структура — плохо!
    func processPtr(s *BigStruct) { ... } // передаётся только адрес (8 байт) — быстро!
  3. Методы с изменением структуры Часто методы-ресиверы делают указателями, чтобы менять сам объект:

    type Counter struct {
    value int
    }

    func (c *Counter) Increment() { // указатель!
    c.value++
    }

    func main() {
    c := Counter{value: 5}
    c.Increment()
    fmt.Println(c.value) // 6
    }

    Если бы ресивер был по значению (c Counter) — изменение не сохранилось бы.

  4. nil как "пустое" значение Обычные переменные всегда имеют нулевое значение (0, "", false). Указатели могут быть nil — это удобно для "опциональных" значений.

    var p *int  // nil
    if p == nil {
    fmt.Println("указатель пустой")
    }

Когда использовать указатели, а когда — нет?

СитуацияРекомендацияПочему
Маленькие типы (int, bool, float64)По значениюКопия дешёвая
Большие структуры (> ~100 байт)УказательЭкономия памяти и времени
Нужно изменить аргумент в функцииУказательИначе изменится только копия
Метод должен изменить состояние объектаУказатель-ресивер (*T)Стандартная практика
Возврат структуры из функцииПо значению (Go оптимизирует)Компилятор часто избегает копии (escape analysis)
Возврат интерфейса или указателяУказательИнтерфейсы и так содержат указатель внутри

Кастомные типы и type alias

В Go ты можешь создавать свои собственные типы на основе существующих. Это мощный инструмент для:

  • Делать код читаемым и выразительным.
  • Добавлять типобезопасность (компилятор проверяет правильность использования).
  • Упрощать поддержку и рефакторинг.

Есть два способа: кастомный тип (type definition) и type alias (type alias, с Go 1.9).

Кастомный тип (type definition)

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

type Age int          // новый тип Age на основе int
type UserID int64 // новый тип UserID на основе int64
type Email string // новый тип Email на основе string

func celebrateBirthday(a Age) Age {
return a + 1
}

func main() {
var myAge Age = 25
var plainInt int = 30

myAge = celebrateBirthday(myAge) // OK
// myAge = plainInt // ОШИБКА! типы разные
// plainInt = myAge // ОШИБКА!

// Нужно явно преобразовать:
myAge = Age(plainInt)
plainInt = int(myAge)
}

Зачем это нужно?

  • Типобезопасность: нельзя случайно передать обычный int туда, где нужен Age или UserID.
  • Самодокументирующийся код: читаешь func createUser(id UserID, email Email) — сразу понятно, что это за данные.
  • Можно добавлять методы к новому типу:
    type Celsius float64

    func (c Celsius) ToFahrenheit() float64 {
    return float64(c)*9/5 + 32
    }

    temp := Celsius(25)
    fmt.Println(temp.ToFahrenheit()) // 77

Псевдоним типа (Type alias)

Это полный синоним существующего типа. Компилятор считает их одним и тем же типом.

type Second = int      // = значит alias
type Meter = float64

func main() {
var s Second = 10
var i int = 20

s = i // OK — Second и int это одно и то же
i = s // OK
}

Зачем alias?

  • Рефакторинг и миграция: хочешь поменять базовый тип в большом проекте — сначала делаешь alias, потом постепенно меняешь.
  • Упрощение длинных типов:
    type HandlerFunc = func(http.ResponseWriter, *http.Request)
    type ConfigMap = map[string]interface{}
  • Совместимость пакетов: когда импортируешь тип из другого пакета и хочешь дать ему короткое имя.

Сравнение: кастомный тип vs alias

ХарактеристикаКастомный тип (type T Underlying)Alias (type T = Underlying)
Это новый тип?ДаНет (полный синоним)
Совместимость с базовым типомНет (нужно преобразование)Да (полная)
Можно добавлять методы?ДаДа
Когда использоватьДля типобезопасности, доменных типовДля рефакторинга, упрощения имен

Реальные примеры из жизни

  1. Домен-driven дизайн:

    type UserID int64
    type OrderID string
    type Money float64 // с методами Round(), Add() и т.д.
  2. Известные пакеты:

    • time.Duration — это alias для int64.
    • sql.NullString — кастомный тип на основе string с полем Valid.
  3. Ошибка новичков:

    type Count int
    func increment(c Count) { c++ } // не сработает! передаётся копия

    Нужно указатель или метод с ресивером-указателем.

Заключение

  • Кастомные типы (type Age int) — используй всегда для доменных сущностей (ID, Email, Money, Temperature), чтобы код был безопасным и понятным.
  • Alias (type Meter = float64) — используй для временных упрощений или плавного рефакторинга.

Пример

package main

import (
"fmt"
"strconv"
)

func main() {
// Информация о человеке
name := "Виктория"
age := 22
height := 168.3
isStudent := true

fmt.Printf("Имя: %s\n", name)
fmt.Printf("Возраст: %d лет\n", age)
fmt.Printf("Рост: %.1f см\n", height)
fmt.Printf("Студент: %t\n", isStudent)

// Небольшой расчёт
nextYear := age + 1
ageString := strconv.Itoa(nextYear)

fmt.Println("В следующем году будет:", nextYear)
fmt.Println("Возраст строкой:", ageString)

// Проверка совершеннолетия
isAdult := age >= 18
fmt.Printf("Совершеннолетний: %t\n", isAdult)
}

Запустите этот код — увидите красивый вывод.