Введение
Функция — это именованный блок кода, который принимает входные данные,
что-то с ними делает и (обычно) возвращает результат. Пока программа маленькая,
можно писать всё в main, но как только логика начинает
повторяться, код без функций превращается в неподдерживаемую простыню. Функции
дают три вещи сразу: переиспользование (написал один раз —
вызываешь много), изоляцию (у функции своя область видимости,
её внутренние переменные не текут наружу) и читаемость (имя
функции документирует намерение лучше любого комментария).
Go относится к функциям особенно серьёзно: здесь они — «граждане первого
класса». Это значит, что функцию можно положить в переменную, передать
аргументом в другую функцию, вернуть из функции и хранить в структуре данных.
Именно на этом строятся замыкания, callback-и и почти вся идиоматика обработки
ошибок. В этой главе мы пройдём путь от простейшего объявления до замыканий и
defer — и на каждом шаге разберём, зачем механизм нужен,
а не только как он пишется.
Ещё одна причина уделить функциям столько внимания — они задают границы ответственности в программе. Когда ты выделяешь кусок логики в функцию с говорящим именем и понятной сигнатурой, ты фактически формулируешь контракт: «дай мне такие-то входные данные, и я верну такой-то результат». Читателю (в том числе тебе через полгода) больше не нужно погружаться в детали реализации — достаточно имени и сигнатуры. Хорошо спроектированные функции — это как хорошо подписанные ящики: ты доверяешь ярлыку и не открываешь каждый, чтобы понять, что внутри. Плохие функции — те, что делают слишком много или имеют размытые входы-выходы, — наоборот, заставляют лезть внутрь снова и снова. Поэтому мышление «одна функция — одна задача» окупается быстрее всего.
Как объявляется функция
Объявление начинается с ключевого слова func, дальше имя,
в круглых скобках список параметров с типами, затем тип возвращаемого значения
и тело в фигурных скобках. Порядок «имя, потом тип» — общий для всего Go
(в объявлениях переменных так же), и поначалу он режет глаз тем, кто пришёл
из C или Java, но быстро становится привычным.
package main
import "fmt"
// greet принимает имя и возвращает строку-приветствие.
func greet(name string) string {
return "Привет, " + name + "!"
}
func main() {
fmt.Println(greet("Аня")) // Привет, Аня!
}
Если несколько параметров подряд имеют один тип, тип можно указать только у последнего — компилятор распространит его на всех предыдущих. Это чистая косметика, но код становится компактнее.
// Вместо (fname string, lname string) — короче:
func fullName(fname, lname string) string {
return fname + " " + lname
}
Разберём анатомию вызова. Когда ты пишешь greet("Аня"), строка
"Аня" — это аргумент (то, что передают
фактически), а name в объявлении — параметр
(имя, под которым функция видит переданное значение). Разница в терминах кажется
занудной, но она проясняет разговоры об ошибках: «неверный тип аргумента» и
«неиспользуемый параметр» — про разные вещи. Внутри функции параметры ведут себя
как обычные локальные переменные, инициализированные значениями аргументов.
Область видимости и время жизни
У каждой переменной есть область видимости — участок кода,
где к ней можно обратиться. В Go область определяется фигурными скобками: то, что
объявлено внутри блока, снаружи не видно. Функция создаёт свою область, а внутри
неё любой { ... } (в цикле, в if) создаёт вложенную.
Это и есть изоляция, о которой мы говорили: локальные переменные функции не
«протекают» наружу и не конфликтуют с одноимёнными переменными в других
функциях.
func main() {
x := 42
fmt.Println(x) // 42
{
y := "виден только внутри этого блока"
fmt.Println(x, y) // x доступен из внешнего блока
}
// fmt.Println(y) // ОШИБКА компиляции: y здесь уже не существует
}
Понимание областей видимости критично для замыканий, к которым мы придём ниже: замыкание «дотягивается» именно до переменных внешней области. И ещё одно следствие: Go не даёт объявить переменную и не использовать её — компилятор выдаст ошибку. Это дисциплинирует и ловит опечатки ещё до запуска.
Множественный возврат
В отличие от многих языков, функция в Go может вернуть несколько значений
сразу. Это не «кортеж как отдельный тип», а именно несколько значений через
запятую. Механизм используется буквально везде: почти любая функция, которая
может завершиться неуспехом, возвращает вторым значением error.
Это и есть основа обработки ошибок в Go — вместо исключений.
package main
import (
"fmt"
"strconv"
)
func main() {
// Atoi возвращает (int, error): число и ошибку разбора.
n, err := strconv.Atoi("42")
if err != nil {
fmt.Println("не число:", err)
return
}
fmt.Println("получили:", n) // получили: 42
}
Свою функцию с двумя возвращаемыми значениями пишут так — типы перечисляются в скобках после списка параметров:
// divide делит и сообщает об ошибке деления на ноль.
func divide(a, b int) (int, error) {
if b == 0 {
return 0, fmt.Errorf("деление на ноль")
}
return a / b, nil
}
Обрати внимание на порядок и на соглашение: error почти всегда
идёт последним в списке возвращаемых значений, и при успехе туда
возвращают nil. Это не требование языка, а устойчивая конвенция —
но следовать ей стоит неукоснительно, потому что весь остальной Go-код (и другие
разработчики) ожидают именно такой формы. Вызывающая сторона проверяет ошибку
сразу после вызова по шаблону if err != nil { ... }, и только
убедившись, что ошибки нет, работает с первым значением. При ошибке первое
значение обычно оставляют «пустым» (ноль, пустая строка, nil) —
опираться на него нельзя.
Почему в Go выбрали такой путь вместо исключений? Философия языка —
ошибки это обычные значения. Их нельзя случайно «проглотить»
незаметно: компилятор заставляет либо использовать возвращённое значение, либо
явно его отбросить через _. Поток управления при этом линеен и
предсказуем — нет невидимых прыжков, как при try/catch. Код чуть
многословнее, зато каждый путь обработки ошибки виден прямо в тексте.
Именованные возвращаемые значения
Возвращаемым значениям можно дать имена прямо в сигнатуре. Тогда они
работают как заранее объявленные переменные внутри функции, а «голый»
return без аргументов вернёт их текущие значения. Это удобно,
когда результат собирается по кусочкам, и особенно вместе с defer
(об этом ниже). Злоупотреблять не стоит: в длинной функции «голый»
return легко теряет читателя.
// split возвращает целую и дробную часть суммы в копейках.
func split(total int) (rubles, kopecks int) {
rubles = total / 100
kopecks = total % 100
return // возвращает rubles и kopecks
}
Именованные возвраты дают ещё и самодокументируемость: из сигнатуры
func split(total int) (rubles, kopecks int) сразу ясно, что за два
числа вернутся и в каком порядке. Без имён была бы просто (int, int),
и порядок пришлось бы держать в голове. Но есть и обратная сторона: в длинной
функции с несколькими путями выхода «голый» return заставляет
читателя прокручивать код вверх, чтобы понять, что именно возвращается. Разумное
правило — использовать именованные возвраты в коротких функциях и в связке с
defer (когда отложенная функция правит результат), а в длинных
писать явный return a, b.
Variadic-параметры
Иногда заранее неизвестно, сколько аргументов передадут. Троеточие перед
типом (...int) делает параметр «переменной длины»: внутри функции
он виден как обычный слайс, а вызывать можно с любым числом аргументов, включая
ноль. Классический пример из стандартной библиотеки — fmt.Println,
который принимает ...any.
package main
import "fmt"
// sum складывает любое количество чисел.
func sum(nums ...int) int {
total := 0
for _, n := range nums {
total += n
}
return total
}
func main() {
fmt.Println(sum(1, 2, 3)) // 6
fmt.Println(sum(10, 20)) // 30
fmt.Println(sum()) // 0 — ноль аргументов тоже валиден
}
Если у тебя уже есть готовый слайс, его можно «раскрыть» в variadic-параметр
тем же троеточием после аргумента. Без него компилятор будет ждать отдельные
int, а не один слайс.
nums := []int{4, 5, 6}
fmt.Println(sum(nums...)) // 15 — слайс раскрыт в аргументы
Функции как значения
Функцию можно присвоить переменной. Тип такой переменной — сигнатура
функции: func(int) int, func(string) (bool, error)
и так далее. Через переменную функцию потом вызывают как обычно. Это открывает
дверь к передаче поведения: мы можем хранить «что делать» в переменной и
подставлять разные реализации.
package main
import "fmt"
func main() {
// double — переменная типа func(int) int.
var double func(int) int = func(x int) int {
return x * 2
}
fmt.Println(double(21)) // 42
}
Анонимные функции и замыкания
Анонимная функция — это функция без имени, записанная прямо по месту. Её можно тут же вызвать (self-executing), а можно вернуть или передать дальше. Самое интересное начинается, когда анонимная функция ссылается на переменные из окружающей области видимости: она «захватывает» их и продолжает иметь к ним доступ даже после того, как внешняя функция завершилась. Такая связка функции с захваченным состоянием называется замыканием (closure).
package main
import "fmt"
// counter возвращает функцию-счётчик со своим приватным состоянием.
func counter() func() int {
count := 0
return func() int {
count++ // захватываем и меняем count из внешней функции
return count
}
}
func main() {
next := counter()
fmt.Println(next()) // 1
fmt.Println(next()) // 2
fmt.Println(next()) // 3
other := counter() // у нового счётчика — своё состояние
fmt.Println(other()) // 1
}
Обрати внимание: next и other не мешают друг другу,
у каждого свой захваченный count. Замыкание позволяет ограничить
область видимости переменной: без него, чтобы две функции делили состояние,
переменную пришлось бы делать глобальной (уровня пакета) — а это прямой путь
к трудноуловимым багам.
Как это работает «под капотом»? Обычно локальная переменная живёт на стеке
и исчезает, когда функция завершилась. Но если анонимная функция захватила
count и «уносит» его с собой (мы вернули замыкание наружу),
компилятор Go это замечает и размещает count в куче — так, чтобы
переменная пережила породившую её функцию. Тебе не нужно управлять этим вручную:
сборщик мусора освободит память, когда исчезнет последняя ссылка на замыкание.
Именно поэтому замыкания в Go безопасны — нет висячих указателей на уничтоженный
стек, как это было бы в C.
Замыкания повсюду в реальном коде: генераторы уникальных ID, middleware в веб-фреймворках (функция, оборачивающая обработчик и помнящая настройки), ленивая инициализация, обработчики с «запомненным» контекстом. Всякий раз, когда тебе нужна функция с собственным приватным состоянием, но не хочется заводить целую структуру, — замыкание подходит идеально.
// idGenerator выдаёт последовательные ID с заданным префиксом.
func idGenerator(prefix string) func() string {
n := 0
return func() string {
n++
return fmt.Sprintf("%s-%d", prefix, n)
}
}
func main() {
order := idGenerator("ORD")
fmt.Println(order()) // ORD-1
fmt.Println(order()) // ORD-2
}
Callback-и
Callback — это функция, которую передают аргументом в другую функцию, чтобы та вызвала её в нужный момент. Так мы отделяем «обход» от «действия»: одна функция знает, как перебрать элементы, а поведение для каждого элемента приходит снаружи. На этом принципе построены фильтры, обработчики событий, маршрутизаторы HTTP.
package main
import "fmt"
// filter оставляет только те числа, для которых keep вернула true.
func filter(nums []int, keep func(int) bool) []int {
var result []int
for _, n := range nums {
if keep(n) {
result = append(result, n)
}
}
return result
}
func main() {
nums := []int{1, 2, 3, 4, 5, 6}
even := filter(nums, func(n int) bool {
return n%2 == 0
})
fmt.Println(even) // [2 4 6]
}
defer
defer откладывает вызов функции до момента, когда текущая функция
завершится — неважно, штатно через return или из-за паники.
Отложенные вызовы выполняются в порядке LIFO (последний отложенный — первый
исполненный). Главное применение — гарантированное освобождение ресурсов:
закрыть файл, отпустить мьютекс, закрыть тело HTTP-ответа. Ты пишешь освобождение
сразу рядом с захватом ресурса и больше не боишься забыть его на одном
из путей выхода.
package main
import (
"fmt"
"os"
)
func readConfig(path string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close() // закроется при любом выходе из функции
// ... читаем и парсим файл ...
fmt.Println("файл открыт:", f.Name())
return nil
}
Порядок LIFO стоит прочувствовать на примере: отложенные вызовы кладутся как на стопку тарелок и снимаются с вершины. Это ровно то, что нужно для симметричного освобождения ресурсов: последний захваченный ресурс освобождается первым.
func demo() {
defer fmt.Println("1 — объявлен первым, выполнится последним")
defer fmt.Println("2")
defer fmt.Println("3 — объявлен последним, выполнится первым")
fmt.Println("тело функции")
}
// Вывод:
// тело функции
// 3 — объявлен последним, выполнится первым
// 2
// 1 — объявлен первым, выполнится последним
Важная тонкость: аргументы отложенного вызова вычисляются в момент
объявления defer, а не в момент выполнения. Это частый источник
недоумения: если ты отложил fmt.Println(x), а потом поменял
x, выведется старое значение, потому что аргумент
зафиксировался сразу. А вот именованные возвращаемые значения defer
может ещё и изменить — этим часто пользуются, чтобы завернуть ошибку.
// wrapErr дополняет ошибку контекстом операции.
func loadUser(id int) (err error) {
defer func() {
if err != nil {
err = fmt.Errorf("loadUser(%d): %w", id, err)
}
}()
// ... где-то ниже err присваивается ...
return err
}
Рекурсия
Рекурсивная функция вызывает саму себя. Каждый вызов должен приближать нас к базовому случаю — условию, при котором рекурсия останавливается без нового вызова. Забыл базовый случай — получишь бесконечную рекурсию и переполнение стека. Рекурсия естественна для задач с иерархической структурой: обход дерева каталогов, разбор вложенного JSON, вычисление факториала.
package main
import "fmt"
// factorial: базовый случай x == 0 возвращает 1.
func factorial(x uint64) uint64 {
if x == 0 {
return 1
}
return x * factorial(x-1)
}
func main() {
fmt.Println(factorial(5)) // 120
}
Логические выражения и короткое замыкание
Функции часто возвращают bool, и эти значения комбинируют
операторами && (И) и || (ИЛИ). Оба
вычисляются лениво: в a && b, если
a уже false, b вообще не вычисляется;
в a || b, если a уже true, пропускается
b. Это называется коротким замыканием и позволяет безопасно
ставить дешёвую или защитную проверку слева.
// Сначала проверяем, что указатель не nil, и только потом читаем поле.
// Если u == nil, правая часть НЕ вычисляется — паники не будет.
if u != nil && u.Active {
fmt.Println("активный пользователь")
}
Кейс из реального проекта
Представь HTTP-обработчик в веб-сервисе. Нам нужно: логировать каждый запрос
с временем выполнения, гарантированно закрывать тело запроса и применять к
списку пользователей разные фильтры, приходящие из query-параметров. Здесь
сходятся все механизмы главы: defer для логирования и закрытия,
callback для фильтрации, замыкание для конфигурируемого фильтра и множественный
возврат для ошибок.
package main
import (
"fmt"
"time"
)
type User struct {
Name string
Age int
Active bool
}
// byMinAge — фабрика фильтров: возвращает замыкание, помнящее порог возраста.
func byMinAge(min int) func(User) bool {
return func(u User) bool {
return u.Age >= min
}
}
// selectUsers применяет набор предикатов: пользователь проходит, если
// удовлетворяет всем.
func selectUsers(users []User, preds ...func(User) bool) []User {
var out []User
for _, u := range users {
ok := true
for _, p := range preds {
if !p(u) {
ok = false
break
}
}
if ok {
out = append(out, u)
}
}
return out
}
// handleReport имитирует обработчик: замеряет время через defer.
func handleReport(users []User) (result []User, err error) {
start := time.Now()
defer func() {
fmt.Printf("отчёт готов за %v, найдено %d\n", time.Since(start), len(result))
}()
if len(users) == 0 {
return nil, fmt.Errorf("пустой список пользователей")
}
active := func(u User) bool { return u.Active }
result = selectUsers(users, active, byMinAge(18))
return result, nil
}
func main() {
users := []User{
{"Аня", 25, true},
{"Боря", 16, true},
{"Витя", 40, false},
}
res, err := handleReport(users)
if err != nil {
fmt.Println("ошибка:", err)
return
}
fmt.Println(res) // [{Аня 25 true}]
}
Заметь, как чисто раскладывается логика: byMinAge порождает
фильтр с «зашитым» порогом, selectUsers не знает ничего о
конкретных правилах, а defer логирует результат независимо от того,
каким return мы вышли. Это и есть идиоматичный Go.
Типичные ошибки
defer внутри цикла
Как НЕ надо: ставить defer f.Close() в теле цикла, открывая
тысячи файлов. Отложенные вызовы копятся до конца функции, а не итерации,
поэтому все файлы останутся открытыми, и ты упрёшься в лимит дескрипторов.
Решение — выносить работу с одним ресурсом в отдельную функцию, где
defer сработает на каждой итерации.
// Плохо: дескрипторы копятся до конца обработки всех путей.
func processAll(paths []string) {
for _, p := range paths {
f, _ := os.Open(p)
defer f.Close() // сработает только в самом конце!
_ = f
}
}
// Хорошо: каждый файл закрывается в своей функции.
func processOne(p string) {
f, _ := os.Open(p)
defer f.Close()
_ = f
}
Игнорирование ошибки при множественном возврате
Как НЕ надо: писать n, _ := strconv.Atoi(s) и работать с
n, не проверив ошибку. При невалидном вводе n будет
нулём, и баг всплывёт где-то далеко от источника. Если функция вернула
error, его нужно либо обработать, либо осознанно проигнорировать
с комментарием — но не по инерции.
Захват переменной цикла в замыкании
Классическая ловушка (актуальна для Go до 1.22): замыкание захватывает переменную, а не её значение. Если внутри цикла запускать горутины, ссылающиеся на переменную цикла, до Go 1.22 все они видели последнее значение. В Go 1.22+ переменная цикла своя на каждой итерации, но привычка передавать значение параметром никогда не повредит.
// Надёжно на любой версии: передаём значение аргументом.
for _, id := range ids {
func(id int) {
fmt.Println("обрабатываю", id)
}(id)
}
Практика
- Напиши функцию
minMax(nums ...int) (int, int), которая возвращает минимум и максимум из переданных чисел. Подумай, что вернуть при пустом вызове. - Реализуй
mapInts(nums []int, f func(int) int) []int, которая применяетfк каждому элементу и возвращает новый слайс. - Сделай фабрику
multiplier(factor int) func(int) int, возвращающую замыкание-умножитель. Проверь, что два умножителя независимы. - Напиши функцию
withTiming(name string, fn func()), которая черезdeferпечатает, сколько выполнялась переданнаяfn. - Реализуй рекурсивную
sumDigits(n int) int— сумму цифр числа. Определи базовый случай.
Итог
Функции в Go — не просто способ сгруппировать код, а полноценные значения,
которыми можно оперировать. Мы разобрали объявление и параметры, множественный
и именованный возврат (основа обработки ошибок), variadic-параметры для гибких
API, функции-значения и замыкания для конфигурируемого поведения, callback-и
для разделения обхода и действия, defer для надёжного освобождения
ресурсов и рекурсию для иерархических задач. Эти кирпичики встречаются в каждом
реальном Go-проекте: в HTTP-обработчиках, парсерах, репозиториях и кэшах. В
следующей главе мы спустимся на уровень памяти и разберём указатели — без них
не понять, как функции меняют переданные данные.
Комментарии 0
Пока нет комментариев. Станьте первым!