Create your own
Lesson illustration

Функции с несколькими возвращаемыми значениями и функции высшего порядка

Здравствуйте. В прошлом уроке вы восстановили базовые конструкции Go: переменные, типы, условия и циклы. Теперь пора вынести часть логики из main в функции. Это делает код короче, проверяемее и ближе к тому, как устроен backend-сервис: одна функция вычисляет результат, другая проверяет правило, третья выполняет переданное ей действие.

Сегодня вы научитесь:

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

Функция как именованный фрагмент логики

Функция объявляется с помощью func. У неё есть имя, параметры, необязательные возвращаемые значения и тело:

func functionName(parameter type) returnType {
	// logic
}

Например, вынесем вычисление числа незавершённых задач:

func remainingTasks(completed int, total int) int {
	return total - completed
}

В main её можно вызвать так:

remaining := remainingTasks(2, 5)
fmt.Println(remaining) // 3

Параметры — это локальные переменные функции. Значения 2 и 5 передаются в параметры completed и total, а затем функция возвращает результат.

Когда несколько соседних параметров имеют один тип, Go позволяет записать их компактнее:

func remainingTasks(completed, total int) int {
	return total - completed
}

Это эквивалентно предыдущей версии.

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


Несколько результатов: нормальная модель Go

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

func taskSummary(completed, total int) (int, bool) {
	if completed < 0 || total < 0 || completed > total {
		return 0, false
	}

	return total - completed, true
}

Здесь функция возвращает:

  1. число незавершённых задач;
  2. признак, корректны ли исходные данные.

Вызов также использует несколько переменных слева:

remaining, valid := taskSummary(2, 5)

if !valid {
	fmt.Println("Invalid task counts")
	return
}

fmt.Printf("Remaining tasks: %d\n", remaining)

Порядок значений важен. Вызов ожидает, что первым придёт int, а вторым — bool, потому что именно такой порядок задан в сигнатуре:

func taskSummary(completed, total int) (int, bool)

У функции нет «объекта с двумя полями» или скрытого кортежа: выражение taskSummary(2, 5) производит два отдельных значения. Поэтому следующий код не скомпилируется:

// summary := taskSummary(2, 5)

Слева должна быть подходящая пара переменных:

remaining, valid := taskSummary(2, 5)

Если переменные уже существуют, используйте обычное присваивание:

var remaining int
var valid bool

remaining, valid = taskSummary(2, 5)

Можно смешать старую и новую переменную в коротком объявлении, но хотя бы одна переменная должна быть новой в текущей области видимости:

remaining := 0
remaining, valid := taskSummary(2, 5) // valid — новая переменная

Возвращайте значения с разными ролями

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

  • разобрать вход и вернуть значение вместе с новой позицией чтения;
  • вычислить результат и сообщить, валиден ли вход;
  • получить значение из хранилища и указать, найдено ли оно;
  • выполнить операцию и вернуть результат вместе с ошибкой.

Последний вариант — один из самых важных Go-идиомов:

func loadProject(id int64) (Project, error)

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

Effective Go

Прочитайте раздел “Functions” из официального руководства Effective Go. Он показывает, почему несколько результатов — не экзотика, а обычный способ выразить результат операции и её дополнительное состояние.

В подразделе “Multiple return values” прочитайте основной пример с записью числа и функцией nextInt. Обратите внимание: второе возвращаемое значение позволяет вызывающему коду продолжить обработку с нужной позиции, не меняя аргумент через указатель. Затем в подразделе “Named result parameters” прочитайте объяснение именованных результатов. Сравните безымянную сигнатуру (int, int) и сигнатуру (value, nextPos int): типы те же, но назначение результатов становится очевиднее.


Именованные результаты: документация в сигнатуре

У результатов можно указать имена:

func taskSummary(completed, total int) (remaining int, valid bool) {
	if completed < 0 || total < 0 || completed > total {
		return 0, false
	}

	return total - completed, true
}

Для вызывающего кода ничего не меняется:

remaining, valid := taskSummary(2, 5)

Имена remaining и valid существуют внутри функции. Они не навязывают имена переменных снаружи. Их основная задача — сделать контракт функции понятнее.

Именованные результаты также создаются как переменные с нулевыми значениями. Поэтому допустим так называемый «голый» return:

func taskSummary(completed, total int) (remaining int, valid bool) {
	if completed < 0 || total < 0 || completed > total {
		return
	}

	remaining = total - completed
	valid = true
	return
}

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

Для большинства функций в начале пути лучше явный вариант:

func taskSummary(completed, total int) (remaining int, valid bool) {
	if completed < 0 || total < 0 || completed > total {
		return 0, false
	}

	return total - completed, true
}

Здесь имена результатов документируют смысл, а явный return total - completed, true показывает итог без необходимости отслеживать состояние переменных.


Функции — это значения

В Go функцию можно сохранить в переменной, присвоить другой переменной или передать как аргумент. У функции есть тип, который определяется её параметрами и результатами.

Например, эта функция принимает int и ничего не возвращает:

func printTaskNumber(number int) {
	fmt.Printf("Task %d needs attention\n", number)
}

Её тип можно записать так:

func(int)

То есть «функция, которая принимает один int и не возвращает значений».

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

func forEachPendingTask(completed, total int, visit func(int)) {
	for taskNumber := completed + 1; taskNumber <= total; taskNumber++ {
		visit(taskNumber)
	}
}

Параметр visit имеет тип func(int). Он хранит функцию, которую можно вызвать внутри forEachPendingTask:

visit(taskNumber)

Передадим printTaskNumber как аргумент:

forEachPendingTask(2, 5, printTaskNumber)

Важно различать две записи:

printTaskNumber   // передаём саму функцию

printTaskNumber(3) // вызываем функцию прямо сейчас

В первом случае скобок нет: мы передаём поведение, которое другая функция выполнит позже. Во втором случае мы немедленно вызываем функцию.

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


Функциональные литералы: передать действие без отдельного имени

Необязательно заранее объявлять именованную функцию. Можно передать функцию прямо в месте вызова. Это называется функциональным литералом:

forEachPendingTask(2, 5, func(taskNumber int) {
	fmt.Printf("Task %d needs attention\n", taskNumber)
})

Фрагмент

func(taskNumber int) {
	fmt.Printf("Task %d needs attention\n", taskNumber)
}

создаёт значение-функцию и передаёт его в forEachPendingTask.

Выбирайте именованную функцию, если действие:

  • имеет самостоятельный смысл;
  • используется в нескольких местах;
  • достаточно объёмное;
  • удобно проверять отдельно.

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

#85 Golang - Functional Programming: First-Class Functions, Closures & More!

Посмотрите начало видео “Golang - Functional Programming: First-Class Functions, Closures & More!” от codeHeim. Оно наглядно показывает, что функция в Go может быть аргументом и что её сигнатура является контрактом между вызывающим кодом и переданным действием.

Посмотрите функции как значения. Сосредоточьтесь на параметре formatter: это функция, принимающая строку и возвращающая строку. Сравните с нашим visit func(int): оба параметра задают не абстрактную «функцию вообще», а точную ожидаемую сигнатуру.


Собираем программу: проверка данных, несколько результатов и callback

Замените предыдущую учебную программу в main.go этим вариантом:

package main

import "fmt"

const maxTasksPerProject = 100

func taskSummary(completed, total int) (remaining int, valid bool) {
	if completed < 0 || total < 0 || completed > total {
		return 0, false
	}

	return total - completed, true
}

func forEachPendingTask(completed, total int, visit func(int)) {
	for taskNumber := completed + 1; taskNumber <= total; taskNumber++ {
		visit(taskNumber)
	}
}

func printTaskNumber(taskNumber int) {
	fmt.Printf("Task %d needs attention\n", taskNumber)
}

func main() {
	title := "Interview preparation"
	ownerID := int64(42)
	completed := 2
	total := 5
	isArchived := false

	if title == "" {
		fmt.Println("Project title is required")
		return
	}

	if total > maxTasksPerProject {
		fmt.Println("Too many tasks in project")
		return
	}

	if isArchived {
		fmt.Println("Project is archived")
		return
	}

	remaining, valid := taskSummary(completed, total)
	if !valid {
		fmt.Println("Invalid task counts")
		return
	}

	fmt.Printf("Project %q, owner %d\n", title, ownerID)
	fmt.Printf("Remaining tasks: %d\n", remaining)

	if remaining == 0 {
		fmt.Println("Project is complete")
		return
	}

	forEachPendingTask(completed, total, printTaskNumber)
}

Запустите привычную проверку из корня модуля:

go fmt ./...
go test ./...
go run .

Ожидаемый вывод:

Project "Interview preparation", owner 42
Remaining tasks: 3
Task 3 needs attention
Task 4 needs attention
Task 5 needs attention

Обратите внимание на границы ответственности:

ФункцияОтветственность
taskSummaryПроверяет счётчики и вычисляет число оставшихся задач
forEachPendingTaskУправляет циклом по незавершённым задачам
printTaskNumberОпределяет конкретное действие для одного номера
mainСобирает входные значения и определяет общий порядок работы

Попробуйте сделать две безопасные модификации:

  1. Передайте в forEachPendingTask функциональный литерал вместо printTaskNumber, добавив к каждой строке текст "Manual review:".
  2. Измените completed на 5 и убедитесь, что функция forEachPendingTask не вызывается: main завершится после сообщения о полном завершении проекта.

Так вы увидите, что callback не меняет цикл сам по себе: он лишь задаёт действие, которое цикл применяет к каждому подходящему значению.


Как распознавать подходящий момент для callback

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

В нашем примере стабилен обход номеров задач. Меняться может действие над номером. В будущем похожий приём встретится в стандартной библиотеке:

  • HTTP-сервер получает функцию-обработчик запроса;
  • сортировка получает функцию сравнения;
  • middleware получает следующий HTTP-обработчик;
  • тесты нередко передают функцию, которая описывает проверяемое действие.

Но не стоит создавать callback «на всякий случай». Если действие всегда одно и не предполагает вариаций, обычная функция проще:

func printPendingTasks(completed, total int) {
	for taskNumber := completed + 1; taskNumber <= total; taskNumber++ {
		fmt.Printf("Task %d needs attention\n", taskNumber)
	}
}

Вариант с visit func(int) оправдан, когда вызывающий код действительно должен выбирать действие. Хорошая архитектура не максимизирует число абстракций, а оставляет изменяемые решения на границе кода, которому они принадлежат.


Итоги

Теперь вы умеете выделять прикладную логику в функции и формулировать их контракт через параметры и результаты.

Главные идеи урока:

  • функция объявляется через func, принимает параметры и может возвращать результаты;
  • несколько результатов записываются в сигнатуре в скобках и принимаются множественным присваиванием;
  • результаты должны быть связаны по смыслу, например remaining и valid;
  • именованные результаты улучшают читаемость сигнатуры, но «голый» return лучше оставлять только для коротких прозрачных функций;
  • функция в Go является значением: её можно передать другой функции;
  • запись handler передаёт функцию, а handler() вызывает её;
  • callback полезен, когда алгоритм остаётся стабильным, а действие должно задаваться вызывающим кодом.

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

Can't find a good explanation? Sign up and we'll make it for you

Sign up