7 Простых методов функционального программирования в Go
Вокруг функционального программирования (ФП) много шумихи, и многие крутые ребята занимаются этим, но это не серебряная пуля. Как и другие парадигмы / стили программирования, функциональное программирование также имеет свои плюсы и минусы, и можно предпочесть одну парадигму другой. Если вы разработчик Go и хотите заняться функциональным программированием, не волнуйтесь, вам не нужно изучать языки, ориентированные на функциональное программирование, такие как Haskell или Clojure (или даже Scala или JavaScript, хотя они не являются чисто функциональными языками программирования), поскольку вы владеете Go то этот пост для вас.
Я не собираюсь подробно разбирать все концепции функционального программирования, вместо этого я сосредоточусь на том, что вы можете сделать в Go, которые соответствуют концепциям функционального программирования. Я также не собираюсь обсуждать плюсы и минусы функционального программирования в целом.
Что такое функциональное программирование?
Функциональное программирование — это парадигма программирования - стиль построения структуры и элементов компьютерных программ, который рассматривает вычисления как оценку математических функций и избегает изменения состояния и изменяемых данных.
Следовательно, в функциональном программировании есть два очень важных правила
- Нет мутаций данных: это означает, что объект данных не должен изменяться после его создания.
- Нет неявного состояния: следует избегать скрытого / неявного состояния. В функциональном программировании состояние не устраняется, вместо этого оно становится видимым и явным
Помимо этого, ниже приведены концепции функционального программирования, которые могут быть применены в Go, мы коснемся их ниже.
Использование функционального программирования не означает "все или ничего", вы всегда можете использовать концепции функционального программирования в дополнение к объектно-ориентированным или императивным концепциям в Go. Преимущества функционального программирования можно использовать везде, где это возможно, независимо от используемой парадигмы или языка. И это именно то, что мы собираемся увидеть.
Функциональное программирование в Go
Golang - язык поддерживающий множество парадигм, поэтому давайте посмотрим, как мы можем применить некоторые из описанных выше концепций функционального программирования в Go.
Функции первого класса и более высокого порядка
Функции первого класса (функционируйте как гражданин первого класса) означают, что вы можете назначать функции переменным, передавать функцию в качестве аргумента другой функции или возвращать функцию из другой. Go поддерживает это и, следовательно, упрощает написание таких концепций, как замыкания, каррирование и функции более высокого порядка.
Функция может рассматриваться как функция более высокого порядка, только если она принимает одну или несколько функций в качестве параметров или если в результате она возвращает другую функцию.
В Go это довольно легко сделать
func main() { var list = []string{"Orange", "Apple", "Banana", "Grape"} // мы передаем массив и функцию в качестве аргументов для метода mapForEach. var out = mapForEach(list, func(it string) int { return len(it) }) fmt.Println(out) // [6, 5, 6, 5] } // Функция высшего порядка принимает массив и функцию как аргументы func mapForEach(arr []string, fn func(it string) int) []int { var newArray = []int{} for _, it := range arr { // мы выполняем переданный метод newArray = append(newArray, fn(it)) } return newArray }
Замыкание и каррирование так же возможны в Go
// это функция высшего порядка возвращающая другую функцию func add(x int) func(y int) int { // A function is returned here as closure // variable x is obtained from the outer scope of this method and memorized in the closure return func(y int) int { return x + y } } func main() { // we are currying the add method to create more variations var add10 = add(10) var add20 = add(20) var add30 = add(30) fmt.Println(add10(5)) // 15 fmt.Println(add20(5)) // 25 fmt.Println(add30(5)) // 35 }
В стандартных библиотеках Go также имеется множество встроенных функций более высокого порядка. В Go также есть несколько библиотек функциональных стилей, подобных этой, и эта предлагает функциональные методы, подобные map-reduce.
Чистые функции
Как мы уже видели, чистая функция должна возвращать значения только на основе переданных аргументов и не должна влиять или зависеть от глобального состояния. Это можно легко сделать в Go.
Это довольно просто, возьмите ниже, это чистая функция. Он всегда будет возвращать один и тот же результат для заданного ввода, и его поведение очень предсказуемо. Мы можем безопасно кэшировать метод, если это необходимо.
func sum(a, b int) int { return a + b }
Если мы добавим дополнительную строку в эту функцию, поведение станет непредсказуемым, поскольку теперь оно имеет побочный эффект, влияющий на внешнее состояние.
var holder = map[string]int{} func sum(a, b int) int { c := a + b holder[fmt.Sprintf("%d+%d", a, b)] = c return c }
Поэтому постарайтесь, чтобы ваши функции были чистыми и простыми.
Рекурсия
Функциональное программирование предпочитает рекурсию циклам. Давайте посмотрим пример вычисления факториала числа.
В традиционном итеративном подходе:
func factorial(num int) int { result := 1 for ; num > 0; num-- { result *= num } return result } func main() { fmt.Println(factorial(20)) // 2432902008176640000 }
То же самое можно сделать, используя рекурсию, как показано ниже, которая предпочтительна в функциональном программировании.
func factorial(num int) int { if num == 0 { return 1 } return num * factorial(num-1) } func main() { fmt.Println(factorial(20)) // 2432902008176640000 }
Недостатком рекурсивного подхода является то, что в большинстве случаев он будет медленнее по сравнению с итеративным подходом (преимущество, к которому мы стремимся, - простота и удобочитаемость кода) и может привести к ошибкам переполнения стека, поскольку каждый вызов функции необходимо сохранять в виде фрейма в стеке. Чтобы избежать этого, предпочтительна хвостовая рекурсия, особенно когда рекурсия выполняется слишком много раз. В хвостовой рекурсии рекурсивный вызов является последним, что выполняется функцией, и, следовательно, фрейм стека функций не должен сохраняться компилятором. Большинство компиляторов могут оптимизировать код хвостовой рекурсии так же, как оптимизируется итеративный код, что позволяет избежать снижения производительности. Компилятор Go, к сожалению, не выполняет эту оптимизацию.
Теперь, используя хвостовую рекурсию, можно записать ту же функцию, что и ниже, но Go не оптимизирует это, хотя есть обходные пути, тем не менее, он показал лучшие результаты в тестах.
func factorialTailRec(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(factorialTailRec(20)) // 2432902008176640000 }
Я провел несколько тестов со всеми 3 подходами, и вот результат, как вы можете видеть, цикл по-прежнему является наиболее эффективным, за которым следует хвостовая рекурсия.
goos: linux goarch: amd64 BenchmarkFactorialLoop-12 100000000 11.7 ns/op 0 B/op 0 allocs/op BenchmarkFactorialRec-12 30000000 52.9 ns/op 0 B/op 0 allocs/op BenchmarkFactorialTailRec-12 50000000 44.2 ns/op 0 B/op 0 allocs/op PASS ok _/home/deepu/workspace/deepu105.github.io/temp 5.072s Success: Benchmarks passed.
Рассмотрите возможность использования рекурсии при написании кода Go для удобства чтения и неизменяемости, но если производительность критична или если количество итераций будет огромным, используйте стандартные циклы.
Ленивое вычисление
Отложенное вычисление или нестрогое вычисление - это процесс задержки вычисления выражения до тех пор, пока оно не понадобится. В общем, Go выполняет строгую / нетерпеливую оценку, но для операндов типа &&
и ||
выполняет ленивую оценку. Мы можем использовать функции более высокого порядка, замыкания, схемы и каналы для эмуляции отложенных вычислений.
Возьмем этот пример, где Go с нетерпением оценивает все.
func main() { fmt.Println(addOrMultiply(true, add(4), multiply(4))) // 8 fmt.Println(addOrMultiply(false, add(4), multiply(4))) // 16 } func add(x int) int { fmt.Println("executing add") // this is printed since the functions are evaluated first return x + x } func multiply(x int) int { fmt.Println("executing multiply") // this is printed since the functions are evaluated first return x * x } func addOrMultiply(add bool, onAdd, onMultiply int) int { if add { return onAdd } return onMultiply }
Это приведет к следующему результату, и мы видим, что обе функции выполняются всегда
executing add executing multiply 8 executing add executing multiply 16
Мы можем использовать функции более высокого порядка, чтобы переписать это в лениво оцениваемую версию
func add(x int) int { fmt.Println("executing add") return x + x } func multiply(x int) int { fmt.Println("executing multiply") return x * x } func main() { fmt.Println(addOrMultiply(true, add, multiply, 4)) fmt.Println(addOrMultiply(false, add, multiply, 4)) } // This is now a higher-order-function hence evaluation of the functions are delayed in if-else func addOrMultiply(add bool, onAdd, onMultiply func(t int) int, t int) int { if add { return onAdd(t) } return onMultiply(t) }
Это выводит следующее, и мы видим, что были выполнены только требуемые функции
executing add 8 executing multiply 16
Есть и другие способы сделать это, используя Sync & Futures, подобные этому, и используя каналы и схемы, подобные этому. Выполнение отложенных оценок в Go в большинстве случаев может не стоить сложности кода, но если рассматриваемые функции сложны с точки зрения обработки, то их абсолютно стоит того, чтобы их лениво оценивать.
Система типов
Go имеет сильную систему типов, а также довольно приличный вывод типов. Единственное, чего не хватает по сравнению с другими функциональными языками программирования - это чего-то вроде классов case и сопоставления с образцом.
Ссылочная прозрачность
Функциональные программы не имеют операторов присваивания, то есть значение переменной в функциональной программе никогда не изменяется после определения. Это исключает любые побочные эффекты, поскольку любая переменная может быть заменена ее фактическим значением в любой момент выполнения. Таким образом, функциональные программы являются ссылочно прозрачными.
К сожалению, существует не так много способов строго ограничить мутацию данных в Go, однако, используя чистые функции и явно избегая мутаций данных и переназначения с использованием других концепций, которые мы видели ранее, этого можно достичь. Go по умолчанию передает переменные по значению, за исключением срезов и карт. Поэтому, насколько это возможно, избегайте передачи их по ссылке (с использованием указателей).
Например, приведенное ниже изменит внешнее состояние, поскольку мы передаем параметр по ссылке, и, следовательно, не обеспечиваем ссылочную прозрачность.
func main() { type Person struct { firstName string lastName string fullName string age int } var getFullName = func(in *Person) string { in.fullName = in.firstName + in.lastName // data mutation return in.fullName } john := Person{ "john", "doe", "", 30, } fmt.Println(getFullName(&john)) // johndoe fmt.Println(john) // {john doe johndoe 30} }
Если мы передаем параметры по значению, мы можем обеспечить ссылочную прозрачность, даже если происходит случайная мутация передаваемых данных внутри функции.
func main() { type Person struct { firstName string lastName string fullName string age int } var getFullName = func(in Person) string { in.fullName = in.firstName + in.lastName return in.fullName } john := Person{ "john", "doe", "", 30, } fmt.Println(getFullName(john)) fmt.Println(john) }
Мы не можем полагаться на это, когда передаваемые параметры являются картами или срезами.
Структуры данных
При использовании методов функционального программирования рекомендуется использовать функциональные типы данных, такие как Стеки (Stacks), Карты (Maps) и Очереди (Queues). Следовательно, карты лучше, чем массивы или хэш-сеты в функциональном программировании в качестве хранилищ данных.
Заключение
Это всего лишь введение для тех, кто пытается применить некоторые методы функционального программирования в Go. В Go можно сделать гораздо больше, а с добавлением дженериков в следующей мажорной версии это должно стать еще проще. Как я уже говорил ранее, функциональное программирование - это не серебряная пуля, но оно предлагает множество полезных методов для более понятного, поддерживаемого и тестируемого кода. Он может прекрасно сосуществовать с императивным и объектно-ориентированным стилями программирования. На самом деле, мы все должны использовать все самое лучшее.