Функциональная парадигма на Go: основные техники
Всем привет, напоминаем о том, что в этом месяце в OTUS стартует новый набор по курсу «Разработчик Golang». Несмотря на некоторый хейт предыдущей статьи по Golang, наш внештатный автор решил рискнуть продолжить серию статей, посвященных этому языку. Мы попробуем пройти по этому тонкому льду еще раз, оперевшись на то, на что в Golang вроде как можно опереться — на функциональную парадигму.
Напоминаем, что данная статья является неким материалом для «внеклассного чтения» и не имеет отношения к программе курса, с которой можно ознакомиться тут.
Понятно, что у профессиональных программистов на других языках Golang вызывает
раздражение — вроде бы взрослый компилируемый язык, однако понятие классов и наследования отсутствует в принципе (хотя, ООП в языке реализуется, пускай и достаточно непривычным способом, через систему структов и интерфейсов). Однако сегодня мы посмотрим на основные реализации привычных конструкций в функциональной парадигме и постараемся объяснить и их, и сам синтаксис языка.
Сейчас достаточно много хайпа вокруг функциональной парадигмы (FP). Однако она тоже не является панацеей от всех проблем, и тоже имеет свои плюсы и минусы.
Кратко о том, что же такое функциональная парадигма
Функциональная парадигма пришла в программирование из математики. Она формирует следующие требования к программе:
Наши функции работают без сторонних эффектов. Иными словами, функция должна только возвращать значение и не должна влиять на какие-то внешние данные.
Использование чистых функций. Они повышают ретестовую надежность функций вне зависимости от приходящих данных — иными словами, программы становятся более надежными для тестирования и результаты их работы становятся более предсказуемыми.
Итак, какие возможности имеет Golang для реализации функциональной парадигмы:
Функции первого класса
Функции первого класса есть во многих языках программирования. Читатель этой статьи скорее всего уже знает их концепцию из столь распространенного JavaScript, но я повторю ещё раз. Функции первого класса (high order function) это функции, которые могут возвращать в качестве знания другую функцию, принимать в качестве аргумента функцию, и передавать значение функции другой переменной.
Давайте с самого начала условимся: чтобы сэкономить место, я выкинул из кода, который здесь представлен, две первые строчки: ‘package main’ и импорт ‘import «fmt»’. Но для запуска кода на вашей машине не забудьте их добавить).
func main() < var list = []int// здесь мы получаем массив чисел var out = forEach(list, func(it int) int < // принимает на вход наш массив //forEach нам пришлось "Эмулировать" самостоятельно return (it * it) // возвращает степень каждого отдельного элемента >) fmt.Println(out) // [225, 256, 2025, 1156] fmt.Println(list) // наш изначальный массив остался нетронутым > func forEach(arr []int, fn func(it int) int) []int < // принимает на вход массив с числами, функцию с числом, и вид возвращаемого типа var newArray = []int<>// создаем новый массив для "неизменяемости" наших данных for _, it := range arr < newArray = append(newArray, fn(it)) // нам все равно приходится воспользоваться for >return newArray >
На самом деле вовсе не обязательно самостоятельно придумывать с нуля свой map или foreach . Есть множество библиотек, которые это реализуют, остается только их подключить. К примеру, вот эта.
Замыкания и карринг функций
Замыкания есть во множестве современных языков программирования. Замыкания — это функция, которая ссылается на свободные переменные области видимости своей родительской функции. Карринг функции — это изменение функции от вида func(a,b,c) до вида func(a)(b)(c) .
Приведем пример замыканий и карринга в Go:
//пример замыкания func multiply(x int) func(y int) int < //именованная и анонимная функция return func(y int) int < // классическое возвращение функции, если вы это видели например в JS return x * y >> func main() < //приведем пару примеров каррирования функции var mult10 = multiply(10) var mult15 = multiply(15) fmt.Println(mult10(5)) //50 fmt.Println(mult15(15))//225 >
Чистые функции
Как мы уже говорили до этого, чистые функции это те, которые возвращают значения, которые связанны только с аргументами, приходящими на вход, и не влияющие на глобальное состояние.
Приведем пример неудачной, «грязной» функции:
var arrToSave = map[string]int<> //map - это неупорядочненные ключ - значения в Golang func dirtySum(a, b int) int < c := a + b arrToSave[fmt.Sprintf("%d", a, b)] = c //кто не в курсе, аргумент "%d" - это нужно для вывода десятичного числа return c >
Здесь у нас функция должна принимать работать максимально предсказуемо:
func simpleSum(x, y int) int < return x + y >func main() < fmt.Printf("%v", dirtySum(13, 12)) //сейчас две эти переменные сработают одинаково // но у "грязной" результат выполнения куда менее предсказуем fmt.Printf("%v", simpleSum(13, 12)) >
«Заходит как-то рекурсия в бар, и больше в бар никто не заходит»
Из сборника несмешных анекдотов.
Рекурсия
В функциональной парадигме принято отдавать предпочтение рекурсии — для чистоты и прозрачности, вместо использовании простого перебора через for .
Приведем пример вычисления факториала с помощью императивной и декларативной парадигмы:
func funcFactorial(num int) int < if num == 0 < return 1 >return num * funcFactorial(num-1) > func imperativeFactorial(num int) int < var result int = 1 for ; num >0; num-- < //тут у нас привычный for result *= num >return result > func main() < fmt.Println(funcFactorial(20)) // как вы догадываетесь результаты тут будут одинаковые fmt.Println(imperativeFactorial(20)) //однако дело в скорости вычисления функций >
Сейчас функция рекурсии работает достаточно неэффективно. Попробуем её немного переписать, что бы оптимизировать скорость её вычисления:
func factTailRec(num int) int < return factorial(1, num) // отдельная функция на "хвост" функции >func factorial(accumulator, val int) int < if val == 1 < return accumulator >return factorial(accumulator*val, val-1) > func main() < fmt.Println(factTailRec(20)) // 2432902008176640000 >
Наша скорость вычисления факториала незначительно возросла. Бенчмарки приводить не буду).
В Go к сожалению не реализована оптимизация рекурсии из «коробки», поэтому хвост рекурсии приходится оптимизировать самостоятельно. Хотя, вне сомнения, наверняка может найтись полезная библиотека на данную тему. Например, есть вот такая «Loadash для Golang» классная на эту тему.
Ленивые вычисления
В теории программирования, ленивые вычисления (так же известные как «отложенные вычисления») это процесс откладывания вычисления до того момента, пока это не потребуется. В Golang пока нет поддержки ленивого вычисления прямо «из коробки» поэтому мы можем только засимулировать это:
func mult(x, y int) int < fmt.Println("выполняем умножение") return x * x. >func divide(x, y int) int < fmt.Println("выполняем деление") return x / y //тут не вижу смысла что-то комментировать >func main() < fmt.Println(multOrDivide(true, mult, divide, 17, 3)) //здесь вызываем наши "эмулирующую" ленивые вычисления функцию, которая через 1 аргумент решает, // пора ли уже вызывать вычисления функции или нет fmt.Println(multOrDivide(false, mult, divide, 17, 3)) >// наш if - else откладывает выполнение наших "одиночных" функций func multOrDivide(add bool, onMult, onDivide func(t, z int) int, t, z int) int < if add < return onMult(t, z) >return onDivide(t, z) >
Чаще всего «эмулированные» ленивые выражения не стоят того, потому что чрезмерно усложняют код, но если ваши функции достаточно тяжелы в управлении, тогда стоит воспользоваться и этим методом. Но вы можете обратиться к другим решения, например, к этим .
На этом все. У нас получилось только введение в функциональную парадигму на Golang. К сожалению, часть возможностей пришлось засимулировать. Часть, вполне развитые функциональные приемы, такие как монады, сюда не вошли, потому что статей о них в Go на хабре полно. В самом языке ещё многое может улучшиться, например со следующий большой версией (GO 2) в языке ожидается появление генериков. Что ж, будем ждать и надеяться).