$ sudo teach IT
МОДУЛЬ 2 · УРОК 2.6

Функции

Учимся создавать собственные функции: параметры, возврат значений, множественный возврат, обработка ошибок, defer и практика

До сих пор весь наш код жил внутри одной-единственной функции main. Мы объявляли переменные, писали условия, крутили циклы — и всё в одном месте. Но представьте себе кухню, где один повар одновременно месит тесто, варит суп, жарит мясо и моет посуду. Хаос! В реальных программах мы разбиваем код на маленькие самостоятельные блоки — функции. Каждая функция делает что-то одно, и делает это хорошо. Сегодня мы научимся создавать собственные функции, возвращать из них значения, обрабатывать ошибки по-гошному и познакомимся с уникальным механизмом defer. Это последний урок модуля «Основы языка» — после него у вас будет полный набор базовых инструментов Go.

📦 Объявление функции

Функция — это именованный блок кода, который можно вызывать сколько угодно раз. Вы уже знакомы с одной функцией — main. Она особенная: именно с неё Go начинает выполнение программы. Но мы можем создавать свои функции. Вот самый простой пример:

package main

import "fmt"

func sayHello() {
    fmt.Println("Привет, мир!")
}

func main() {
    sayHello()
    sayHello()
    sayHello()
}

Мы объявили функцию sayHello с помощью ключевого слова func. После имени идут круглые скобки (пока пустые — параметров нет), а затем тело функции в фигурных скобках. В main мы вызываем sayHello() три раза — и три раза на экране появляется «Привет, мир!».

Зачем это нужно? Во-первых, чтобы не повторять один и тот же код. Во-вторых, чтобы давать блокам кода понятные имена. Вместо десяти строк вычислений вы пишете calculateTotal() — и сразу ясно, что происходит.

💡 Подсказка: в Go принято называть функции в стиле camelCase: первое слово с маленькой буквы, каждое следующее — с большой. Например: calculateSum, getUserName, printReport. Функции с заглавной буквы (CalculateSum) имеют особый смысл — они доступны из других пакетов. Об этом мы поговорим позже.

📥 Параметры функции

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

package main

import "fmt"

func greet(name string) {
    fmt.Println("Привет,", name, "!")
}

func main() {
    greet("Алиса")
    greet("Боб")
    greet("Чарли")
}

Функция greet принимает один параметр name типа string. Когда мы вызываем greet("Алиса"), значение «Алиса» попадает в переменную name внутри функции.

Функция может принимать несколько параметров:

package main

import "fmt"

func add(a int, b int) {
    fmt.Println(a, "+", b, "=", a+b)
}

func main() {
    add(3, 5)   // 3 + 5 = 8
    add(10, 20) // 10 + 20 = 30
}

Если несколько параметров подряд имеют одинаковый тип, можно указать тип только один раз:

// Вместо:
func add(a int, b int) { ... }

// Можно написать короче:
func add(a, b int) { ... }

Оба варианта абсолютно идентичны. Сокращённая запись удобна, когда параметров одного типа много. Вот пример с разными типами:

func describe(name string, age int, isStudent bool) {
    fmt.Printf("%s, возраст: %d, студент: %t\n", name, age, isStudent)
}

📤 Возвращаемые значения

Функции из примеров выше только выводят текст на экран. Но часто нужно, чтобы функция вычислила что-то и вернула результат обратно. Для этого после списка параметров указывается тип возвращаемого значения, а внутри функции используется return:

package main

import "fmt"

func add(a, b int) int {
    return a + b
}

func main() {
    result := add(3, 5)
    fmt.Println("Сумма:", result) // Сумма: 8

    // Можно использовать прямо в выражении:
    fmt.Println("Двойная сумма:", add(3, 5) * 2) // Двойная сумма: 16
}

Разберём объявление func add(a, b int) int. Слово func говорит, что это функция. add — её имя. (a, b int) — два параметра типа int. А последний int после скобок — это тип возвращаемого значения. Оператор return a + b вычисляет сумму и возвращает её туда, откуда функция была вызвана.

Ещё пример — функция, которая проверяет, чётное ли число:

func isEven(n int) bool {
    return n%2 == 0
}

func main() {
    fmt.Println(isEven(4))  // true
    fmt.Println(isEven(7))  // false
}

Возвращаемый тип — bool. Выражение n%2 == 0 само по себе уже является булевым значением, поэтому его можно сразу вернуть через return.

🎯 Множественный возврат значений

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

package main

import "fmt"

func divide(a, b float64) (float64, float64) {
    quotient := a / b          // частное
    remainder := float64(int(a) % int(b)) // остаток
    return quotient, remainder
}

func main() {
    q, r := divide(17, 5)
    fmt.Println("Частное:", q)  // Частное: 3.4
    fmt.Println("Остаток:", r)  // Остаток: 2
}

Функция divide возвращает два значения типа float64. При вызове мы используем краткое присваивание с двумя переменными: q, r := divide(17, 5). Каждое возвращённое значение попадает в свою переменную.

Ещё один классический пример — функция, возвращающая минимум и максимум:

func minMax(a, b int) (int, int) {
    if a < b {
        return a, b
    }
    return b, a
}

func main() {
    min, max := minMax(42, 17)
    fmt.Println("Мин:", min, "Макс:", max) // Мин: 17 Макс: 42
}

Если вам не нужно одно из возвращаемых значений, используйте символ подчёркивания _ — так называемый «пустой идентификатор»:

// Нам нужен только максимум, минимум игнорируем:
_, max := minMax(42, 17)
fmt.Println("Макс:", max)

💡 Подсказка: в Go нельзя просто проигнорировать возвращаемое значение, записав его в обычную переменную и не используя — компилятор выдаст ошибку. Подчёркивание _ — единственный способ явно сказать: «Я знаю, что здесь есть значение, но оно мне не нужно».

⚠️ Возврат ошибки — идиома Go

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

package main

import (
    "errors"
    "fmt"
)

func divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, errors.New("деление на ноль невозможно")
    }
    return a / b, nil
}

func main() {
    result, err := divide(10, 3)
    if err != nil {
        fmt.Println("Ошибка:", err)
    } else {
        fmt.Println("Результат:", result)
    }

    result2, err2 := divide(10, 0)
    if err2 != nil {
        fmt.Println("Ошибка:", err2)
    } else {
        fmt.Println("Результат:", result2)
    }
}

Разберём подробно. Функция divide возвращает два значения: (float64, error). Тип error — это встроенный интерфейс Go для ошибок. Если всё хорошо, мы возвращаем результат и nil (nil означает «ничего», то есть ошибки нет). Если что-то пошло не так (деление на ноль), мы возвращаем нулевое значение и объект ошибки, созданный через errors.New().

Запомните этот паттерн — вы будете видеть его в Go повсюду:

Паттерн обработки ошибок в Go:

1. Вызываем функцию и получаем результат и ошибку: result, err := someFunction()

2. Проверяем ошибку: if err != nil { ... }

3. Если ошибки нет (err == nil), используем результат

Вот ещё один пример — функция, которая вычисляет квадратный корень (упрощённо):

package main

import (
    "errors"
    "fmt"
    "math"
)

func sqrt(n float64) (float64, error) {
    if n < 0 {
        return 0, errors.New("нельзя вычислить корень отрицательного числа")
    }
    return math.Sqrt(n), nil
}

func main() {
    val, err := sqrt(25)
    if err != nil {
        fmt.Println("Ошибка:", err)
        return
    }
    fmt.Println("Корень из 25 =", val) // Корень из 25 = 5

    val2, err2 := sqrt(-4)
    if err2 != nil {
        fmt.Println("Ошибка:", err2) // Ошибка: нельзя вычислить корень отрицательного числа
        return
    }
    fmt.Println("Корень из -4 =", val2)
}

💡 Подсказка: в Go считается хорошим тоном обрабатывать ошибку сразу после вызова функции. Не откладывайте проверку err != nil — делайте это в следующей же строке. Такой стиль делает код надёжным и предсказуемым.

🏷 Именованные возвращаемые значения

В Go можно дать имена возвращаемым значениям прямо в объявлении функции. Они работают как локальные переменные, инициализированные нулевыми значениями для своего типа. А return без аргументов автоматически вернёт их текущие значения — это называется «голый return» (naked return):

package main

import "fmt"

func rectangleInfo(width, height float64) (area, perimeter float64) {
    area = width * height
    perimeter = 2 * (width + height)
    return // возвращает area и perimeter
}

func main() {
    a, p := rectangleInfo(5, 3)
    fmt.Println("Площадь:", a)    // Площадь: 15
    fmt.Println("Периметр:", p)   // Периметр: 16
}

Обратите внимание: (area, perimeter float64) — оба возвращаемых значения имеют имена и тип float64. Внутри функции мы просто присваиваем им значения (area = ...), а return без аргументов возвращает оба.

Именованные возвращаемые значения особенно удобны, когда они улучшают читаемость. Сравните:

// Без имён — непонятно, что именно возвращается:
func calculate(x float64) (float64, float64, float64) { ... }

// С именами — сразу ясно:
func calculate(x float64) (sin, cos, tan float64) { ... }

💡 Подсказка: «голый return» удобен в коротких функциях, но в длинных может запутать — непонятно, какие именно значения возвращаются. Поэтому в Go-сообществе рекомендуют использовать голый return только в функциях длиной до 10–15 строк. В длинных функциях лучше явно указывать, что возвращаете: return area, perimeter.

⏳ Отложенный вызов: defer

В Go есть уникальный механизм — defer. Он откладывает выполнение функции до того момента, когда окружающая функция завершится. Звучит странно? Давайте посмотрим:

package main

import "fmt"

func main() {
    fmt.Println("Начало")
    defer fmt.Println("Это выполнится последним")
    fmt.Println("Середина")
    fmt.Println("Конец")
}

Результат:

Начало
Середина
Конец
Это выполнится последним

Строка с defer не выполняется в момент, когда программа до неё доходит. Вместо этого Go запоминает: «Когда функция main закончится, нужно будет вызвать fmt.Println("Это выполнится последним")». Поэтому эта строка печатается самой последней.

Зачем это нужно? Главное применение — гарантированная очистка ресурсов. Когда вы открываете файл, подключаетесь к базе данных или захватываете блокировку, нужно не забыть это закрыть/освободить. defer гарантирует, что закрытие произойдёт, даже если дальше в функции случится ошибка:

package main

import (
    "fmt"
    "os"
)

func readFile() {
    file, err := os.Open("data.txt")
    if err != nil {
        fmt.Println("Ошибка:", err)
        return
    }
    defer file.Close() // Файл закроется, когда readFile завершится

    // Дальше работаем с файлом...
    fmt.Println("Файл успешно открыт")
    // Даже если здесь произойдёт ошибка,
    // file.Close() всё равно выполнится!
}

Мы пишем defer file.Close() сразу после открытия файла. Теперь мы можем быть спокойны — файл закроется в любом случае, когда функция завершится. Без defer нам пришлось бы помнить о вызове file.Close() перед каждым return в функции.

Если в функции несколько defer, они выполняются в обратном порядке — как стопка тарелок (последний положенный снимается первым). Это называется LIFO (Last In, First Out):

package main

import "fmt"

func main() {
    fmt.Println("Считаем:")

    for i := 1; i <= 5; i++ {
        defer fmt.Println(i)
    }

    fmt.Println("Готово!")
}

Результат:

Считаем:
Готово!
5
4
3
2
1

Числа печатаются в обратном порядке: 5, 4, 3, 2, 1. Каждый defer добавлялся в стек, и при завершении функции они извлекаются оттуда в обратном порядке.

Запомните три правила defer:

1. Отложенная функция выполняется при выходе из окружающей функции

2. Аргументы отложенной функции вычисляются сразу (в момент defer), а не при выполнении

3. Несколько defer выполняются в обратном порядке (LIFO)

🔍 Важный нюанс: аргументы defer

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

package main

import "fmt"

func main() {
    x := 10
    defer fmt.Println("Значение x:", x) // x запоминается как 10
    x = 20
    fmt.Println("Текущее x:", x)
}

Результат:

Текущее x: 20
Значение x: 10

Хотя x изменился на 20 до того, как отложенный вызов выполнился, defer запомнил значение x в момент своего объявления — то есть 10. Это важно учитывать при написании кода с defer.

🧱 Функции как строительные блоки

Функции можно вызывать из других функций, комбинировать и выстраивать логику программы как из кирпичиков:

package main

import "fmt"

func square(n int) int {
    return n * n
}

func sumOfSquares(a, b int) int {
    return square(a) + square(b)
}

func main() {
    fmt.Println(sumOfSquares(3, 4)) // 9 + 16 = 25
}

Функция sumOfSquares вызывает square дважды и складывает результаты. Каждая функция решает одну маленькую задачу, а вместе они решают задачу побольше. Это и есть суть декомпозиции — разбиения сложной задачи на простые части.

🛠 Практика: пакет математических утилит

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

package main

import (
    "errors"
    "fmt"
    "math"
)

// abs возвращает модуль числа
func abs(n float64) float64 {
    if n < 0 {
        return -n
    }
    return n
}

// power возводит число в степень
func power(base, exp int) int {
    result := 1
    for i := 0; i < exp; i++ {
        result *= base
    }
    return result
}

// safeDivide делит a на b с проверкой
func safeDivide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, errors.New("деление на ноль")
    }
    return a / b, nil
}

// circleInfo возвращает площадь и длину окружности
func circleInfo(radius float64) (area, circumference float64, err error) {
    if radius < 0 {
        return 0, 0, errors.New("радиус не может быть отрицательным")
    }
    area = math.Pi * radius * radius
    circumference = 2 * math.Pi * radius
    return area, circumference, nil
}

// stats вычисляет сумму, среднее, мин и макс среза чисел
func stats(numbers []float64) (sum, avg, min, max float64, err error) {
    if len(numbers) == 0 {
        return 0, 0, 0, 0, errors.New("пустой список чисел")
    }

    min = numbers[0]
    max = numbers[0]

    for _, n := range numbers {
        sum += n
        if n < min {
            min = n
        }
        if n > max {
            max = n
        }
    }

    avg = sum / float64(len(numbers))
    return sum, avg, min, max, nil
}

func main() {
    fmt.Println("=== Модуль числа ===")
    fmt.Println("abs(-7.5) =", abs(-7.5))
    fmt.Println("abs(3.2) =", abs(3.2))

    fmt.Println("\n=== Возведение в степень ===")
    fmt.Println("2^10 =", power(2, 10))
    fmt.Println("3^4 =", power(3, 4))

    fmt.Println("\n=== Безопасное деление ===")
    if result, err := safeDivide(10, 3); err != nil {
        fmt.Println("Ошибка:", err)
    } else {
        fmt.Printf("10 / 3 = %.2f\n", result)
    }

    if result, err := safeDivide(10, 0); err != nil {
        fmt.Println("Ошибка:", err)
    } else {
        fmt.Printf("10 / 0 = %.2f\n", result)
    }

    fmt.Println("\n=== Информация об окружности ===")
    area, circ, err := circleInfo(5)
    if err != nil {
        fmt.Println("Ошибка:", err)
    } else {
        fmt.Printf("Радиус 5: площадь = %.2f, длина окружности = %.2f\n", area, circ)
    }

    fmt.Println("\n=== Статистика ===")
    data := []float64{4.5, 2.1, 8.7, 1.3, 6.9}
    s, a, mn, mx, err := stats(data)
    if err != nil {
        fmt.Println("Ошибка:", err)
    } else {
        fmt.Printf("Данные: %v\n", data)
        fmt.Printf("Сумма: %.1f, Среднее: %.2f, Мин: %.1f, Макс: %.1f\n", s, a, mn, mx)
    }
}

Запустите эту программу командой go run main.go. Вот что вы увидите:

=== Модуль числа ===
abs(-7.5) = 7.5
abs(3.2) = 3.2

=== Возведение в степень ===
2^10 = 1024
3^4 = 81

=== Безопасное деление ===
10 / 3 = 3.33
Ошибка: деление на ноль

=== Информация об окружности ===
Радиус 5: площадь = 78.54, длина окружности = 31.42

=== Статистика ===
Данные: [4.5 2.1 8.7 1.3 6.9]
Сумма: 23.5, Среднее: 4.70, Мин: 1.3, Макс: 8.7

Давайте разберём, что здесь демонстрируется:

abs — простая функция с одним параметром и одним возвращаемым значением

power — использование цикла внутри функции для вычислений

safeDivide — возврат (float64, error), классическая обработка ошибок Go

circleInfo — именованные возвращаемые значения + ошибка (три возврата)

stats — множественный возврат пяти значений. Использует срезы (slices), которые мы подробно изучим в следующем уроке — пока просто примите, что []float64 это список чисел

Попробуйте модифицировать программу: добавьте функцию factorial, которая вычисляет факториал числа (5! = 120), или функцию isPrime, которая проверяет, простое ли число. Это отличная тренировка.

📋 Итоги урока

  • Функция объявляется через func имя(параметры) тип_возврата { тело }
  • Параметры одного типа можно сокращать: (a, b int) вместо (a int, b int)
  • Go поддерживает множественный возврат значений — это фундаментальная особенность языка
  • Ошибки возвращаются как второе значение: return result, nil или return 0, err
  • Паттерн обработки ошибок: result, err := f() затем if err != nil { ... }
  • Именованные возвращаемые значения улучшают читаемость и позволяют «голый return»
  • defer откладывает вызов функции до завершения окружающей функции
  • Несколько defer выполняются в обратном порядке (LIFO)
  • Аргументы defer вычисляются в момент объявления, а не при выполнении
  • Мы написали пакет математических утилит, применив все изученные концепции

🎉 Поздравляем! Вы завершили Модуль 2 — «Основы языка Go»

Вы изучили переменные и типы, операторы, ввод/вывод, условия, циклы и функции. Теперь у вас есть всё необходимое для написания настоящих программ. В следующем модуле мы перейдём к структурам данных.

В следующем уроке (3.1) мы начнём Модуль 3 и изучим массивы и срезы (slices) — основные коллекции данных в Go. Вы узнаете, как хранить список значений в одной переменной, добавлять и удалять элементы, нарезать части коллекции и перебирать их в циклах. Срезы — это одна из самых используемых структур данных в Go, и вы будете работать с ними каждый день.