24 января 2024

🦫 Самоучитель по Go для начинающих. Часть 6. Функции и аргументы. Области видимости. Рекурсия. Defer

Энтузиаст-разработчик, автор статей по программированию.
В этом уроке рассмотрим функции, аргументы, области видимости, затронем тему указателей, узнаем про рекурсию и её применение в программировании, а также научимся использовать ключевое слово defer.
🦫 Самоучитель по Go для начинающих. Часть 6. Функции и аргументы. Области видимости. Рекурсия. Defer

Функции

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

Функции лежат в основе построения большого числа программ, так как позволяют:

  1. Структурировать код, разбивая его на управляемые фрагменты.
  2. Предотвратить дублирование кода.
  3. Скрыть подробности реализации за упрощенным интерфейсом.
  4. Улучшить читаемость кода.
  5. Тестировать программу.

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

        num := 123
str := strconv.Itoa(num)
fmt.Println("Результат преобразования числа в строку: ", str)

    
👨‍💻 Библиотека Go разработчика
Больше полезных материалов вы найдете на нашем телеграм-канале «Библиотека Go разработчика»
🎓 Библиотека Go для собеса
Подтянуть свои знания по Go вы можете на нашем телеграм-канале «Библиотека Go для собеса»
🧩 Библиотека задач по Go
Интересные задачи по Go для практики можно найти на нашем телеграм-канале «Библиотека задач по Go»

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

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

Сигнатура функции – это её формальное описание, включающее имена, типы параметров и возвращаемых значений.

Общий вид функции в Go выглядит следующим образом:

        // ключевое слово func
// |
func имя_функции (перечисление параметров с типами) типы_возвращаемых_значений {
	// тело функции
return возвращаемое_значение // возврат значения
}

    

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

        func sumDivisors(num int) int {
	var sum int
	for i := 1; i < num/2+1; i++ {
		if num%i == 0 {
			sum += i
		}
	}
	sum += num
	return sum
}

    

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

  • Функция может не иметь параметров и возвращаемого значения. Самый простой пример такой функции – main:
        func main() {
      // функция main не принимает параметров и не возвращает значений
}

    
  • Для несколько подряд идущих переменных одного типа можно указать его после последнего параметра:
        // функция, возвращающая максимальное из двух чисел
func max(a, b int) int {
	if a > b {
		return a
	}
	return b
}

    
  • Функции могут принимать переменное число параметров. Для этого перед типом параметров ставится многоточие:
        // Функция sumNums принимает ноль и более целых чисел и возвращает их сумму
func sumNums(nums ...int) int {
    sum := 0
    for _, num := range nums {
        sum += num
    }
    return sum
}

// тестируем функцию sumNums
func main() {
	fmt.Println(sumNums()) // 0
	fmt.Println(sumNums(1, 2, 3, 4)) // 10
	fmt.Println(sumNums(-1, 1, 100)) // 100
}

    
  • В Go функции часто возвращают два значения – тип и ошибку. К примеру, так выглядит сигнатура знакомой нам функции Atoi из пакета strconv, которая преобразует строку в число. Она принимает строку, а возвращает преобразованное число и ошибку в случае её возникновения.
        func Atoi(s string) (int, error)

    

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

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

Для примера вызовем ранее написанную функцию max и передадим в качестве аргументов два целых числа:

        func max(a, b int) int {
	if a > b {
		return a
	}
	return b
}

func main() {
	fmt.Println(max(6, 8)) // 8
}

    

Обратите внимание, что функция max возвращает целое число, но для его вывода на экран нужно воспользоваться функцией fmt.Println. Немного изменим эту функцию, чтобы она сразу печатала максимальное из двух чисел. Это позволит значительно сократить её вызов:

        func max(a, b int) {
	if a > b {
		fmt.Println(a)
	} else {
	fmt.Println(b)
	}
}

func main() {
	max(1, 2) // 2
}

    

Область видимости

Область видимости в Go
Область видимости в Go

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

Переменные, созданные в функции, являются локальными, то есть к ним нельзя обращаться за её пределами. К примеру, переменная res в примере ниже является локальной и доступна только в функции localVariable:

        func localVariable() {
	var res = "local"
	fmt.Println("Локальная переменная:", res)
}

func main() {
	res = "" // ошибка
	localVariable() // Локальная переменная: local
}

    

Глобальные переменные, в отличие от локальных, доступны из любого места программы. В приведенном ниже примере переменная res объявлена за пределами функции, поэтому является глобальной и может изменяться в произвольном участке кода:

        var res string

func editGlobalVariable() {
	res = "global"
	fmt.Println("Глобальная переменная:", res)
}

func main() {
	editGlobalVariable() // Глобальная переменная: global
	res = "changed global"
	fmt.Println(res) // changed global
}

    

Передача по значению и по указателю

Передача по значению и по указателю в Go
Передача по значению и по указателю в Go

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

Указатель – это переменная, которая в качестве значений хранит адреса других объектов или функций.

Давайте поймем разницу между этими двумя подходами на конкретном примере. Пусть у нас есть функция incrementValue, увеличивающая переменную на единицу. Рассмотрим две её реализации, сначала с передачей по значению:

        func incrementValue(val int) {
	fmt.Println("Значение val до увеличения:", val) // 0 
	val += 10
	fmt.Println("Значение val после увеличения:", val) // 10
}

func main() {
	var num int
	fmt.Println("Значение num до увеличения:", num) // 0
	incrementValue(num)
	fmt.Println("Значение num после увеличения:", num) // 0
}

    

Нетрудно заметить, что переменная num не увеличила своего значения после применения функции incrementValue, а вот к локальной переменной val прибавилось 10.

Следующий пример демонстрирует передачу значения по указателю:

        func incrementValue(val *int) {
	fmt.Println("Значение val до увеличения:", val) // 0x... (адрес переменной num)
	*val += 10
	fmt.Println("Значение val после увеличения:", val) // 0x... (адрес переменной num)
}

func main() {
	var num int
	fmt.Println("Значение num до увеличения:", num) // 0
	incrementValue(&num) // оператор & используется для взятия адреса
	fmt.Println("Значение num после увеличения:", num) // 10
}

    

В примере выше переменная num была передана по указателю в функцию incrementValue, поэтому в результате все операции происходили непосредственно с num, а не с val. В переменной var, в свою очередь, сохранился адрес переменной num.

Именование функций

Названия функций в Go рекомендуется писать в стиле camelCase, например: incrementValue, getUserByID, fetchConfig и так далее.

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

Defer или отложенное выполнение

Defer или отложенное выполнение в Go
Defer или отложенное выполнение в Go

Ключевое слово defer является особенностью Go и используется для отложенного выполнения функций. Defer добавляет вызов новой функции в специальный стек, который обрабатывается в порядке LIFO (Last In First Out – Последним пришел, первым вышел), и после этого продолжает выполнение текущей функции.

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

Рассмотрим несколько блоков кода, отражающих поведение defer.

  • Пример вызова defer для отложенного закрытия файла:
        func main() {
	f, err := os.Open("filename.txt") // открытие файла
	if err != nil {
		return ""
	}
	defer f.Close() // отложенное закрытие файла
}

    
  • Следующий код наглядно показывает важное правило defer, которое заключается в том, что его аргументы обрабатываются в момент вызова, а не в момент фактического выполнения:
        func incrementNumber() {
	num := 0
	defer fmt.Println(num)
	num++
}

func main() {
	incrementNumber() // 0
}

    

В этом примере переменная num в момент вызова defer fmt.Println(num) имеет значение 0, которое и передаются в отложенную функцию Println. Далее num увеличивается на единицу, но, так как в Println она была передана со значением 0, в итоге на экран будет выведен именно 0, а не 1.

  • При использовании defer нужно помнить, что он работает по принципу LIFO. По этой причине код ниже напечатает значения в обратном порядке, то есть 54321:
        func main() {
    for i := 1; i < 6; i++ {
        defer fmt.Print(i)
    }
}

    

Рекурсия

Рекурсия в Go
Рекурсия в Go

Рекурсия в программировании – это процесс, при котором функция вызывает саму себя.

Выделяют три вида рекурсии:

  • Прямая: функция вызывает саму себя.
  • Косвенная: цепочка вызовов включает более одной функции.
  • Хвостовая: рекурсивный вызов стоит последней операцией в функции.

Рекурсия широко используется в программировании для решения целого ряда задач:

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

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

Давайте рассмотрим классический пример рекурсивной функции, вычисляющей факториал неотрицательного целого числа:

        func factorial(n int) int {
	if n <= 1 {
		return 1
	}

	return n * factorial(n-1)
}

func main() {
	fmt.Println(factorial(5)) // 120
}

    

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

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

        func factorial(n int) int {
	return n * factorial(n-1) // бесконечная рекурсия
}

func main() {
	fmt.Println(factorial(5))
}

    

Для предотвращения бесконечной рекурсии следует внимательно следить за рекурсивными вызовами и условиями выхода из функций, а также пользоваться линтерами – программами для анализа кода на наличие потенциальных ошибок. Например, линтер go-staticcheck при подозрении на бесконечную рекурсию выведет предупреждение infinite recursive call (SA5007).

Практическая часть

По традиции решим несколько занимательных задач для закрепления материала.

Гоша считает итоговые оценки

В школе Гоше объяснили, что итоговая оценка за предмет есть не что иное, как среднее арифметическое всех полученных ранее отметок. За последнее время он получил много отметок и совсем не хочет считать итоговые оценки вручную. Ваша задача помочь Гоше, создав функцию averageValue для подсчета среднего арифметического переданных чисел.

Пример вызовов функции и ожидаемых результатов:

        func main() {
	fmt.Println(averageValue(1, 2, 3, 5)) // 2.5
	fmt.Println(averageValue(1, -1))      // 0
	fmt.Println(averageValue(1, -1, -1))  // -0.3333333333333333
	fmt.Println(averageValue())           // NaN
}

    

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

Решение:

        func averageValue(nums ...float64) float64 {
	var sum float64
	for _, v := range nums {
		sum += float64(v)
	}

	return sum / float64(len(nums))
}

func main() {
	fmt.Println(averageValue(1, 2, 3, 4)) // 2.5
}

    

Просто проверить на простоту

Напишите функцию isPrime, принимающую целое число и возвращающее true, если оно простое, то есть делится только на себя и на единицу, и false в противном случае.

Пример вызовов функции и ожидаемых результатов:

        func main() {
	fmt.Println(isPrime(100)) // false
	fmt.Println(isPrime(1)) // false
	fmt.Println(isPrime(23)) // true
	fmt.Println(isPrime(17)) // true
}

    

Подсказка: значения в цикле достаточно перебирать до квадратного корня из числа, для его взятия используйте функцию math.Sqrt из пакета math, но не забудьте преобразовать это значение к типу int.

Решение: в цикле проверяем делимость числа на каждое значение от 2 до квадратного корня из числа.

        func isPrime(num int) bool {
	if num <= 1 {
		return false
	}

	sqrt := int(math.Sqrt(float64(num)))
	for i := 2; i < sqrt; i++ {
		if num%i == 0 {
			return false
		}
	}

	return true
}

    

n-е число Фибоначчи

Напишите функцию fibonacci(n int), которая по введенному числу n возвращает n-е по номеру число Фибоначчи. Если введено некорректное значение n, то функция должна вернуть -1.

Справка: числа Фибоначчи – это такая последовательность, в которой первые два числа равны 0 и 1, а каждое последующее число получается как сумма двух предыдущих.

Пример вызовов функции и ожидаемых результатов:

        func main() {
	fmt.Println(fibonacci(1)) // 0
	fmt.Println(fibonacci(2)) // 1
	fmt.Println(fibonacci(10)) // 34
	fmt.Println(fibonacci(-2)) // -1
}

    

Решение: воспользуемся рекурсией, указав корректные условия для значений n.

        func fibonacci(n int) int {
	if n <= 0 {
		return -1
	} else if n > 2 {
		return fibonacci(n-2) + fibonacci(n-1)
	}

	return n - 1
}

    

Подведем итоги

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

В следующей статье цикла изучим основные структуры данных для хранения элементов – массивы и слайсы. Разберем их внутреннее устройство, познакомимся с основными функциями и рассмотрим пакет slices стандартной библиотеки.

***

Содержание самоучителя

  1. Особенности и сфера применения Go, установка, настройка
  2. Ресурсы для изучения Go с нуля
  3. Организация кода. Пакеты, импорты, модули. Ввод-вывод текста.
  4. Переменные. Типы данных и их преобразования. Основные операторы
  5. Условные конструкции if-else и switch-case. Цикл for. Вложенные и бесконечные циклы
  6. Функции и аргументы. Области видимости. Рекурсия. Defer
  7. Массивы и слайсы. Append и сopy. Пакет slices
  8. Строки, руны, байты. Пакет strings. Хеш-таблица (map)
  9. Структуры и методы. Интерфейсы. Указатели. Основы ООП
  10. Наследование, абстракция, полиморфизм, инкапсуляция
  11. Обработка ошибок. Паника. Восстановление. Логирование
  12. Обобщенное программирование. Дженерики
  13. Работа с датой и временем. Пакет time
  14. Интерфейсы ввода-вывода. Буферизация. Работа с файлами. Пакеты io, bufio, os
  15. Конкурентность. Горутины. Каналы
  16. Тестирование кода и его виды. Table-driven подход. Параллельные тесты
  17. Основы сетевого программирования. Стек TCP/IP. Сокеты. Пакет net
  18. Протокол HTTP. Создание HTTP-сервера и клиента. Пакет net/http

Комментарии

ВАКАНСИИ

Добавить вакансию
Разработчик C++
Москва, по итогам собеседования

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ