Глава 4. Функции

379 просмотров
0 лайков
0 в избранном

Введение

Функция — это именованный блок кода, который принимает входные данные, что-то с ними делает и (обычно) возвращает результат. Пока программа маленькая, можно писать всё в 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)
}

Практика

  1. Напиши функцию minMax(nums ...int) (int, int), которая возвращает минимум и максимум из переданных чисел. Подумай, что вернуть при пустом вызове.
  2. Реализуй mapInts(nums []int, f func(int) int) []int, которая применяет f к каждому элементу и возвращает новый слайс.
  3. Сделай фабрику multiplier(factor int) func(int) int, возвращающую замыкание-умножитель. Проверь, что два умножителя независимы.
  4. Напиши функцию withTiming(name string, fn func()), которая через defer печатает, сколько выполнялась переданная fn.
  5. Реализуй рекурсивную sumDigits(n int) int — сумму цифр числа. Определи базовый случай.

Итог

Функции в Go — не просто способ сгруппировать код, а полноценные значения, которыми можно оперировать. Мы разобрали объявление и параметры, множественный и именованный возврат (основа обработки ошибок), variadic-параметры для гибких API, функции-значения и замыкания для конфигурируемого поведения, callback-и для разделения обхода и действия, defer для надёжного освобождения ресурсов и рекурсию для иерархических задач. Эти кирпичики встречаются в каждом реальном Go-проекте: в HTTP-обработчиках, парсерах, репозиториях и кэшах. В следующей главе мы спустимся на уровень памяти и разберём указатели — без них не понять, как функции меняют переданные данные.

Комментарии 0

Для добавления комментариев необходимо войти или зарегистрироваться.

Пока нет комментариев. Станьте первым!