Автор статьи: Рустем Галиев

IBM Senior DevOps Engineer & Integration Architect. Официальный DevOps ментор и коуч в IBM

Привет, Хабр!

Сегодня мы рассмотрим, как писать и вызывать функции в Go. Мы также изучим, как правильно обрабатывать ошибки в функциях, и узнаем об использовании функций в качестве типов данных. Попутно мы будем использовать структуры управления if, for и switch в Go.

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

Декларирование и вызов функций

Хотя вы можете поместить весь свой код в функцию main, это не очень хорошая практика в Go (или в любом другом языке программирования). Проще тестировать и обновлять программы, которые разбиты на несколько функций, а код в функции можно вызывать из нескольких мест, что уменьшает дублирование.

Начнем с написания нескольких простых функций.

Скопируйте следующий код в новый файл с именем calculate.go:

package main

import (
    "fmt"
    //more1
)

func main() {
    a := 20
    b := 10
    c := adder(a, b)
    fmt.Println(c)
    d := subtractor(a, b)
    fmt.Println(d)
    e := multiplier(a, b)
    fmt.Println(e)
    f := divider(a, b)
    fmt.Println(f)
}

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

func subtractor(a, b int) int {
    return a - b
}

func multiplier(a, b int) int {
    return a * b
}

func divider(a, b int) int {
    return a / b
}

Запустим go run calculator.go

Сделаем нашу программу интерактивной. Вместо того, чтобы полагаться на жестко запрограммированные значения, мы будем передавать значения из командной строки. Читаем командную строку со слайсом os.Args (это слайс в пакете os в стандартной библиотеке). Первое значение в слайсе — это имя программы, а остальные значения — это параметры, указанные в командной строке. Все эти значения имеют строковый тип. Мы собираемся использовать функцию strconv.Atoi, чтобы преобразовать каждый из первых двух параметров в числа, а затем передать их нашим простым математическим функциям.

Чтобы использовать os.Args и strconv.Atoi, нам нужно импортировать пакеты os и strconv из стандартной библиотеки. Обновим раздел импорта в calculate.go следующим образом:

"os"
    "strconv"
    //more1

Затем заменим содержимое main в calculate.go на следующее:

func main() {
	//more2
	a, _ := strconv.Atoi(os.Args[1])
	b, _ := strconv.Atoi(os.Args[2])
	c := adder(a, b)
	fmt.Println(c)
	d := subtractor(a, b)
	fmt.Println(d)
	e := multiplier(a, b)
	fmt.Println(e)
	f := divider(a, b)
	fmt.Println(f)
}

В первых двух строках нашей основной функции есть кое-что новое — символ подчеркивания (_). Если у вас есть значение, возвращаемое функцией, которую вы хотите игнорировать, присвойте ему символ подчеркивания. В этом случае функция strconv.Atoi возвращает два значения: int и ошибку. Мы обработаем ошибку чуть позже, пока мы собираемся ее игнорировать.

Запустим код go run calculator.go 20 10

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

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

Вы видели, что наш калькулятор хрупок; передайте ему что-то отличное от int для его первых двух параметров, и он падает. Он также падает, если передается слишком мало параметров. И это, вероятно, должно сообщить пользователю, что должны быть предоставлены только два параметра. Давайте сделаем наш код чуть более надежным

Первое, что мы сделаем, это проверим длину среза os.Args. Замените //more2 в начале main следующим кодом:

if len(os.Args) != 3 {
        fmt.Println("Two integer parameters expected")
        os.Exit(1)
    }

Вызов os.Exit преждевременно завершает программу с кодом ошибки.

Если вы запустите калькулятор с помощью команды go run calculate.go или go run calculate.go 1 2 3, вы получите следующий вывод:

Заменим строку a, _ := strconv.Atoi(os.Args[1]) на:

a, err := strconv.Atoi(os.Args[1])
    if err != nil {
        fmt.Println("invalid first argument:", err)
        os.Exit(1)
    }

Затем заменим строку b, _ := strconv.Atoi(os.Args[2]) на:

b, err := strconv.Atoi(os.Args[2])
    if err != nil {
        fmt.Println("invalid second argument:", err)
        os.Exit(1)
    }

Если мы запустим код с помощью go run calculate.go 10 c, мы получим сообщение:

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

func adder(a, b int) (int, error) {
	return a + b, nil
}

А subtractor изменим на

func subtractor(a, b int) (int, error) {
    return a - b, nil
}

Еще заменим multiplier на

func multiplier(a, b int) (int, error) {
    return a * b, nil
}

Ну и в divider добавим проверку параметра, если он равен нулю, то получим ошибку

func divider(a, b int) (int, error) {
    if b == 0 {
        return 0, errors.New("cannot divide by zero")
    }
    return a / b, nil
}

Мы используем функцию New в пакете ошибок для генерации нашей ошибки, поэтому нам нужно добавить импорт ошибок в раздел импорта. Заменим //more1 на "errors"

Теперь, когда у нас есть измененные функции, мы обновим main, чтобы проверить, вернула ли функция ошибку. Если это так, мы распечатаем это:

func main() {
	if len(os.Args) != 3 {
    	fmt.Println("Two integer parameters expected")
    	os.Exit(1)
	}
	a, err := strconv.Atoi(os.Args[1])
	if err != nil {
    	fmt.Println("invalid first argument:", err)
    	os.Exit(1)
	}
	b, err := strconv.Atoi(os.Args[2])
	if err != nil {
    	fmt.Println("invalid second argument:", err)
    	os.Exit(1)
	}
	c, err := adder(a, b)
	if err != nil {
    	fmt.Println("adding failed:", err)
	} else {
    	fmt.Println(c)
	}
	d, err := subtractor(a, b)
	if err != nil {
    	fmt.Println("subtracting failed:", err)
	} else {
    	fmt.Println(d)
	}
	e, err := multiplier(a, b)
	if err != nil {
    	fmt.Println("multiplying failed:", err)
	} else {
    	fmt.Println(e)
	}
	f, err := divider(a, b)
	if err != nil {
    	fmt.Println("dividing failed:", err)
	} else {
    	fmt.Println(f)
	}
}

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

Теперь мы можем видеть, что наша программа больше не вылетает. Попробуем это с помощью go run calculate.go 1000 0

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

go run calculator.go 9223372036854775807 9223372036854775807

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

Начнем с adder:

func adder(a, b int) (int, error) {
	x := a + b
	if (x > a) != (b > 0) {
    	return x, errors.New("addition out of bounds")
	}
	return x, nil
}

Логическое условие (x > a) != (b > 0) — очень умный способ проверить, верно ли одно из двух условий:

  • Если сумма двух чисел больше одного из них, то другое число должно быть положительным.

  • Если сумма двух чисел меньше одного из них, то второе должно быть отрицательным.

Если какое-либо из этих условий ложно, то мы получим ошибку.

Добавим аналогичную проверку в subtractor:

func subtractor(a, b int) (int, error) {
	x := a - b
	if (x < a) != (b > 0) {
    	return x, errors.New("subtraction out of bounds")
	}
	return x, nil
}

Логическое условие (x < a) != (b > 0) аналогично проверке, которую мы добавили для добавления. В этом случае мы проверяем:

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

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

Если какое-либо из этих условий ложно, то результатом будет ошибка.

Наконец, мы добавим проверку в multiplier

func multiplier(a, b int) (int, error) {
	if a == 0 || b == 0 {
    	return 0, nil
	}
	x := a * b
	if x/b != a {
    	return x, errors.New("multiplication out of bounds")
	}
	return x, nil
}

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

Затем мы умножаем a и b, сохраняя результат в x. Мы проверяем переполнение, проверяя, работает ли обратная операция; мы вернем a, если мы разделим x на b? Если нет, умножение выходит за пределы, поэтому мы сообщаем об ошибке.

go run calculator.go 9223372036854775807 9223372036854775807

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

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

func main() {
	if len(os.Args) != 3 {
    	fmt.Println("Two integer parameters expected")
    	os.Exit(1)
	}
	a, err := strconv.Atoi(os.Args[1])
	if err != nil {
    	fmt.Println("invalid first argument:", err)
    	os.Exit(1)
	}
	b, err := strconv.Atoi(os.Args[2])
	if err != nil {
    	fmt.Println("invalid second argument:", err)
    	os.Exit(1)
	}
	add := adder
	sub := subtractor
	mul := multiplier
	div := divider
	c, err := add(a, b)
	if err != nil {
    	fmt.Println("adding failed:", err)
	} else {
    	fmt.Println(c)
	}
	d, err := sub(a, b)
	if err != nil {
    	fmt.Println("subtracting failed:", err)
	} else {
    	fmt.Println(d)
	}
	e, err := mul(a, b)
	if err != nil {
    	fmt.Println("multiplying failed:", err)
	} else {
    	fmt.Println(e)
	}
	f, err := div(a, b)
	if err != nil {
    	fmt.Println("dividing failed:", err)
	} else {
    	fmt.Println(f)
	}
}

В частности, обратите внимание на строки:

add := adder
sub := subtractor
mul := multiplier
div := divider

Вместо того, чтобы вызывать функции напрямую, мы присваиваем их локальным переменным. Затем мы используем локальные переменные как функции в строках c, err := add(a, b), d, err := sub(a, b), e, err := mul(a, b) и f, ошибка := div(а, б).

В чем преимущество возможности присваивать функции переменным? Мы можем использовать это, чтобы сократить наш код. Давайте сохраним наши функции в срезе и зациклимся на них. Изменим код в main на следующий:

func main() {
	if len(os.Args) != 3 {
    	fmt.Println("Two integer parameters expected")
    	os.Exit(1)
	}
	a, err := strconv.Atoi(os.Args[1])
	if err != nil {
    	fmt.Println("invalid first argument:", err)
    	os.Exit(1)
	}
	b, err := strconv.Atoi(os.Args[2])
	if err != nil {
    	fmt.Println("invalid second argument:", err)
    	os.Exit(1)
	}
	funcs := []func(int, int) (int, error){
    	adder, subtractor, multiplier, divider,
	}
	ops := []string{
    	"adding", "subtracting", "multiplying", "dividing",
	}
	for i, f := range funcs {
    	v, err := f(a, b)
    	if err != nil {
        	fmt.Println(ops[i], "failed:", err)
    	} else {
        	fmt.Println(v)
    	}
	}
}

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

funcs := []func(int, int) (int, error){
    	adder, subtractor, multiplier, divider,
	}

мы помещаем функции в срез. Обратите внимание на тип среза. Это []func(int, int) (int, error). Тип функции — это комбинация типов ее параметров и типов возвращаемых значений. Слайсу могут быть назначены только функции с этой сигнатурой.

Мы также определяем срез строк, называемый ops, с именами операций. Мы используем это для сообщения об ошибках.

Теперь мы можем использовать цикл for для запуска функций. Логика одинакова для каждой функции в срезе:

  • Запуск функции

  • Проверка, вернула ли функция ненулевую ошибку:

  • Если это так, выводится сообщение об ошибке, используя значение в ops в той же позиции, что и текущая функция в funcs.

  • Если нет, печатается вывод

В большинстве случаев, когда вы хотите выполнить вычисление, вы хотите выполнить определенную операцию. Давайте изменим наш код, чтобы использовать для этого оператор switch. Заменим main следующим кодом:

//more

func main() {
	if len(os.Args) != 4 {
    	fmt.Println("Two integer parameters and an operator expected")
    	os.Exit(1)
	}
	a, err := strconv.Atoi(os.Args[1])
	if err != nil {
    	fmt.Println("invalid first argument:", err)
    	os.Exit(1)
	}
	op := os.Args[2]
	b, err := strconv.Atoi(os.Args[3])
	if err != nil {
    	fmt.Println("invalid second argument:", err)
    	os.Exit(1)
	}
	var action func(int, int) (int, error)
	var actionName string
	switch op {
	case "+":
    	action = adder
    	actionName = "adding"
	case "-":
    	action = subtractor
    	actionName = "subtracting"
	case "x":
    	action = multiplier
    	actionName = "multiplying"
	case "/":
    	action = divider
    	actionName = "dividing"
	default:
    	fmt.Println("Unknown operator:", op)
    	os.Exit(1)
	}
	v, err := action(a, b)
	if err != nil {
    	fmt.Println(actionName, "failed:", err)
	} else {
    	fmt.Println(v)
	}
}

Мы внесли несколько изменений в main, поэтому давайте рассмотрим их подробно. Теперь ищем три параметра, в порядковом номере номер оператора. Сначала мы проверяем, правильное ли количество значений находится в срезе os.Args:

if len(os.Args) != 4 {
    	fmt.Println("Two integer parameters and an operator expected")
    	os.Exit(1)
	}

Далее вытаскиваем числа и операторы:

a, err := strconv.Atoi(os.Args[1])
	if err != nil {
    	fmt.Println("invalid first argument:", err)
    	os.Exit(1)
	}
	op := os.Args[2]
	b, err := strconv.Atoi(os.Args[3])
	if err != nil {
    	fmt.Println("invalid second argument:", err)
    	os.Exit(1)
	}

Затем мы определяем переменную для хранения функции оператора и переменную для хранения имени операции (на случай, если нам нужно сообщить об ошибке):

var action func(int, int) (int, error)
	var actionName string

Мы используем оператор switch для оценки переменной op и просмотра того, какой оператор был предоставлен. Мы присваиваем правильную функцию и имя операции переменным action и actionName соответственно. Если предоставленный оператор не является одним из +, -, x или /, мы используем дефолтный кейс  для выхода с ошибкой.

(Мы используем x вместо * для умножения, потому что * имеет особое значение в оболочке Unix, и мы не хотим заключать наши параметры в кавычки.)

switch op {
	case "+":
    	action = adder
    	actionName = "adding"
	case "-":
    	action = subtractor
    	actionName = "subtracting"
	case "x":
    	action = multiplier
    	actionName = "multiplying"
	case "/":
    	action = divider
    	actionName = "dividing"
	default:
    	fmt.Println("Unknown operator:", op)
    	os.Exit(1)
	}

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

v, err := action(a, b)
	if err != nil {
    	fmt.Println(actionName, "failed:", err)
	} else {
    	fmt.Println(v)
	}

Мы можем сделать этот код еще более лаконичным, используя мапу вместо оператора switch. Во-первых, мы собираемся заменить //more определениями для двух переменных уровня пакета:

var opMap = map[string]func(int, int) (int, error){
    "+": adder,
    "-": subtractor,
    "x": multiplier,
    "/": divider,
}

var opNameMap = map[string]string{
    "+": "adding",
    "-": "subtracting",
    "x": "multiplying",
    "/": "dividing",
}

Теперь у нас есть две мапы на уровне пакета. opMap связывает допустимые операторы с функциями, реализующими их функциональность. opNameMap связывает допустимые операторы с именами операций. Это хорошее использование состояния на уровне пакета, поскольку эта информация неизменяема; мы никогда не собираемся менять значения в этих мапах.

Далее мы еще раз изменим код в main:

func main() {
    if len(os.Args) != 4 {
        fmt.Println("Two integer parameters and an operator expected")
        os.Exit(1)
    }
    a, err := strconv.Atoi(os.Args[1])
    if err != nil {
        fmt.Println("invalid first argument:", err)
        os.Exit(1)
    }
    op := os.Args[2]
    b, err := strconv.Atoi(os.Args[3])
    if err != nil {
        fmt.Println("invalid second argument:", err)
        os.Exit(1)
    }
    action, ok := opMap[op]
    if !ok {
        fmt.Println("Unknown operator:", op)
        os.Exit(1)
    }
    v, err := action(a, b)
    if err != nil {
        fmt.Println(opNameMap[op], "failed:", err)
    } else {
        fmt.Println(v)
    }
}

Мы заменили оператор switch следующими строками:

action, ok := opMap[op]
    if !ok {
        fmt.Println("Unknown operator:", op)
        os.Exit(1)
    }

Единственное другое изменение заключается в том, что у нас больше нет переменной actionName. В случае ошибки мы выводим значение opNameMap[op].

Мы рассмотрели написание функций, обработку ошибок и использование управляющих структур Go (if, for и switch). Все это присутствует в других языках, но теперь вы знаете, как Go реализует эти концепции. Логика, которую мы написали, разделена на разные функции, и они могут принимать входные данные, обрабатывать данные и возвращать выходные данные.


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

Комментарии (5)


  1. Number571
    06.07.2023 11:09
    +2

    Чем не зашёл метанит для освоения таких базовых вещей? Возможно это моё субъективное мнение, но основы языка Go можно буквально на каждом ресурсе найти по программированию.


  1. maledog
    06.07.2023 11:09
    +3

    Букв много, а смысла мало. "A Tour of Go" лаконичнее, но рассказывает гораздо больше про те же функции, например.


  1. xshura
    06.07.2023 11:09
    +1

    Чуток по стилю кода.

    func adder(a, b int) int

    "er" в конце принято использовать для именования интерфейсов.

    
    //var opMap = map[string]func(int, int) (int, error)
    
    var op = map[string]func(int, int) (int, error)

    Постфикс "Map" - сахар, и так понятно будет, что op это мапа (ну можно ops, т.к. массив).

    Не хватает:

    • описание анонимных функций;

    • и типа go func(){}


  1. NeoCode
    06.07.2023 11:09

    В Go самый правильный (с моей точки зрения разумеется) синтаксис функций

    • начинаются с ключевого слова (кажется после С++ уже все поняли что так лучше)

    • ничего лишнего (двоеточия, стрелочки и прочий мусор)

    • обычные функции и лямбды имеют единый синтаксис (опять же никакой стремной экзотики с палками и стрелками); таким образом нет необходимости выделять лямбды в отдельную концепцию, это просто функции


  1. zagart
    06.07.2023 11:09

    Я думал, что на -er и -or заканчиваются имена интерфейсов.