Всем привет, напоминаем о том, что в этом месяце в OTUS стартует новый набор по курсу «Разработчик Golang». Несмотря на некоторый хейт предыдущей статьи по Golang, наш внештатный автор решил рискнуть продолжить серию статей, посвященных этому языку. Мы попробуем пройти по этому тонкому льду еще раз, оперевшись на то, на что в Golang вроде как можно опереться — на функциональную парадигму.



Напоминаем, что данная статья является неким материалом для «внеклассного чтения» и не имеет отношения к программе курса, с которой можно ознакомиться тут.

Понятно, что у профессиональных программистов на других языках Golang вызывает
раздражение — вроде бы взрослый компилируемый язык, однако понятие классов и наследования отсутствует в принципе (хотя, ООП в языке реализуется, пускай и достаточно непривычным способом, через систему структов и интерфейсов). Однако сегодня мы посмотрим на основные реализации привычных конструкций в функциональной парадигме и постараемся объяснить и их, и сам синтаксис языка.



Сейчас достаточно много хайпа вокруг функциональной парадигмы (FP). Однако она тоже не является панацеей от всех проблем, и тоже имеет свои плюсы и минусы.

Кратко о том, что же такое функциональная парадигма


Функциональная парадигма пришла в программирование из математики. Она формирует следующие требования к программе:

  • Отсутствие изменений в уже имеющихся данных.
  • Нет скрытого состояния.

Что это нам дает?

Наши функции работают без сторонних эффектов. Иными словами, функция должна только возвращать значение и не должна влиять на какие-то внешние данные.

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

Итак, какие возможности имеет Golang для реализации функциональной парадигмы:

Функции первого класса


Функции первого класса есть во многих языках программирования. Читатель этой статьи скорее всего уже знает их концепцию из столь распространенного JavaScript, но я повторю ещё раз. Функции первого класса (high order function) это функции, которые могут возвращать в качестве знания другую функцию, принимать в качестве аргумента функцию, и передавать значение функции другой переменной.
Давайте с самого начала условимся: чтобы сэкономить место, я выкинул из кода, который здесь представлен, две первые строчки: 'package main' и импорт 'import «fmt»'. Но для запуска кода на вашей машине не забудьте их добавить).


func main() {
	var list = []int{15, 16, 45, 34}
	// здесь мы получаем массив чисел
	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) в языке ожидается появление генериков. Что ж, будем ждать и надеяться).

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


  1. ErgoZru
    18.11.2019 16:54
    +1

    Несмотря на некоторый хейт Golang на Хабре, наш внештатный автор решил рискнуть продолжить серию статей

    Может все-таки хейт не Golang, а вашего автора/компании/качества статей? Лично я хейта на Golang не заметил.


    1. MaxRokatansky Автор
      18.11.2019 17:04

      Возможно наш читатель действительно ждет чего-то более хардкорного от нашего блога. Мы не исключаем этого и в принципе готовим на будущее более «инженерный» материал. ЧТо касается хейта, вот, например, очень яркий пример habr.com/ru/company/netologyru/blog/471782


  1. DmitriyTitov
    18.11.2019 17:37
    +2

    Функции первого класса (high order function)

    Какая-то каша из англоязычных терминов first class citizen и high(er) order functions. Это не одно и то же.

    ООП в языке реализуется, пускай и достаточно непривычным способом, через систему структов и интерфейсов

    Было бы интересно, если бы автор (преподаватель языка?) пояснил вот это предложение некоторыми примерами или просто текстом. Что имелось в виду? (Без подвоха — действительно интересно)


    1. mayorovp
      19.11.2019 09:06

      Какая-то каша из англоязычных терминов first class citizen и high(er) order functions. Это не одно и то же.

      Ну, это, конечно же, не одно и то же — но вот одно без другого в нормальных языках существовать не может. Если функция — first class citizen, то её автоматически можно принять как параметр или вернуть как результат, т.е. сделать high order function. В обратную сторону это "автоматически" не работает (можно представить язык с HOF, но с особыми функциями), но напрашивается.


    1. isakura3131
      19.11.2019 12:28

      лучше за меня ответит вот эта статья, только она конечно давненько вышло, но ничего особенно с того момента не поменялось. habr.com/ru/post/225907


  1. 0xd34df00d
    18.11.2019 18:14
    +1

    вроде бы взрослый компилируемый язык, однако понятие классов и наследования отсутствует в принципе

    Это нестрашно, и дело совсем не в этом. В конце концов, в том же хаскеле тоже нет (ООП-подобных) классов и наследования. А чтобы понять, в чём дело, давайте посмотрим на ваших же примерах.


    На самом деле вовсе не обязательно самостоятельно придумывать с нуля свой map или foreach.

    А можете всё-таки придумать такой map, который бы работал со списками с произвольными типами? И не через interface {}.


    Есть множество библиотек, которые это реализуют, остается только их подключить. К примеру, вот эта.

    Там же везде interface{}. Какие гарантии типобезопасности даёт компилятор?


    Приведем пример замыканий и карринга в Go

    Ух ты, клёво, смогли каррировать функцию, принимающую два инта. А можно написать функцию curry :: ((a, b) -> c) -> a - >b -> c (пардон за негошный синтаксис, просто словами это говорить уж больно неуклюже)? Только чтобы без interface{}.


    1. DmitriyTitov
      18.11.2019 18:23

      Писать в функциональном стиле на Go — либо развлечение для ума, либо глупость. Всем желающим можно ознакомиться с видео Франсеска Кампоя на эту (и другие похожие) тему.
      Дальше у вас стандартные вопросы-претензии к языку: «а можно ли в Go так и эдак».
      Отвечаю как разработчик Go: так нельзя и эдак не выйдет. Такой язык, не всем нравится.


      1. 0xd34df00d
        18.11.2019 18:44
        +1

        Такой язык, не всем нравится.

        Я в первом же абзаце своего комментария и написал, что дальнейшее — ответ на вопрос о том, почему Go нравится не всем, и что дело не в отсутствии классов.


        Кстати, интересно, стандартность претензий их скорее легитимизирует или же обесценивает, но то такое.


        1. DmitriyTitov
          18.11.2019 19:31

          Кстати, интересно, стандартность претензий их скорее легитимизирует или же обесценивает, но то такое.

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

          А в вашем комментарии нет ответа на вопрос — есть только ещё вопросы к языку. Таких можно придумать к какому угодно языку.
          В Go мало языковых возможностей — «Неудобно, приходится переизобретать велосипеды!»
          В JavaScript реализованы все парадигмы и куча возможностей — «Помойка, как их вообще запомнить!?»
          Ну и так далее.
          Я пишу на Go — в моём случае задачи решаются на этом языке легко и эффективно. Обобщённого программирования иногда не хватает, а так — всё нормально.


          1. 0xd34df00d
            18.11.2019 19:42

            Претензии сообщества принимаются к сведению и обобщённое программирование обещают сделать.

            Блин, прям жалко стало всех тех апологетов и евангелистов, которые 10 лет рассказывали, что дженерики не нужны.


            А в вашем комментарии нет ответа на вопрос — есть только ещё вопросы к языку.

            Ответы на них известны: «нельзя», разве нет? Так что это риторические вопросы, если хотите.


            Таких можно придумать к какому угодно языку.

            Вы абсолютно правы! И лично у меня претензии и вопросы есть ко всем языкам, которые я ковырял достаточно плотно, от плюсов до хаскеля с идрисом. Только это могут быть претензии очень разного уровня, и в других языках может быть что-то, что на эти вопросы-претензии отвечает и оправдывает использование языка даже со всеми его недостатками. А вот Go или JS — не оправдывает. Ну, лично для меня. YMMV, конечно же.


            1. acmnu
              19.11.2019 16:28

              А вот Go или JS — не оправдывает.

              У Go два оправдания: простая многопоточность из коробки на CSP и компилируемость в бинарник. Да, это можно сделать например на C++, но желающих не так много.


              1. 0xd34df00d
                19.11.2019 16:56
                +2

                У Go два оправдания: простая многопоточность из коробки

                Ага. Простая.


                компилируемость в бинарник

                В единый бинарник? Если честно, ИМХО это какое-то сомнительное преимущество. Ну, то есть, да, хорошо, но чем плохо иметь более чем один бинарник? Всё равно деплоить будете либо заворачиванием в пакеты типа deb/rpm, либо в докер.


                1. acmnu
                  19.11.2019 18:03

                  В единый бинарник?

                  Можно и в статический и в динамически линкуемый. Тут все как в C/C++.


                  Для микросервисов размеры важны. Статически скомпилированный бинарник занимает очень мало места. Как следствие время старта того же контейнера на непрогретой ноде очень малое. Аналогичный по функционалу контейнер на Python или Java будет занимать на порядки больше места. Да, понятно, что после первого запуска слои окажутся на ноде и все будет хорошо, но сам первый запуск может быть сложен по io. Особенно это заметно на ad-hoc запусках в разных PaaS средах, типа Heroku, Yarn и подобных.


    1. loki82
      18.11.2019 18:33

      А можете всё-таки придумать такой map, который бы работал со списками с произвольными типами? И не через interface {}.
      А зачем? Где это используется? Например в java?


      1. 0xd34df00d
        18.11.2019 18:46
        +1

        А зачем?

        Зачем с произвольными типами? Или зачем не через interface {}?


        Если первое, то чтобы не заниматься копипастой под разные типы.
        Если второе, то чтобы компилятор мог поймать больше ошибок. Иначе это недалеко ушло от C с его голым void* везде, и говорить, что у Go строгая типизация, как-то странно.


        Где это используется? Например в java?

        Про джаву не скажу, я на ней не пишу и её не знаю, а на тех языках, где пишу (в основном C++ и Haskell) это используется регулярно.


        1. mayorovp
          19.11.2019 09:01

          и говорить, что у Go строгая типизация, как-то странно

          Уточнение: и говорить, что у Go статическая типизация, как-то странно. Строгой (сильной) типизации никакой interface {} не мешает.


    1. Jedi_PHP
      19.11.2019 09:26
      -1

      > придумать [для Go] такой map, который бы работал со списками с произвольными типами

      Это в теории можно реализовать, через unsafePointer. Но ни о каких гарантиях типобезопасности речи и не будет, естественно. Как и о гарантииях отсуствия сегфолтов =)


  1. loki82
    18.11.2019 19:04

    В C++ есть map с произвольными типами? Можно пример где это может понадобиться? Я не могу придумать ничего кроме как получить неизвестные данные, и их структурировать.
    UPD. Почему то не в ту ветку попало. Вопрос к 0xd34df00d.


    1. 0xd34df00d
      18.11.2019 19:32

      В C++ есть map с произвольными типами?

      Да, называется std::transform. Или в ranges есть. Или у меня самописный костыль в одном из моих проектов (который писался до ranges).


      Я не могу придумать ничего кроме как получить неизвестные данные, и их структурировать.

      Почему? Типы данных вполне известны, просто они в каждой точке вызова map могуть быть различными. Где-то вы умножаете каждый int в массива на два при помощи map, а где-то — парсите каждую строку в массиве в жсон-объект.


      1. loki82
        18.11.2019 19:42

        Вот про json думал. Но там же структура известна. Зачем там interface{}? Про не известную структуру не говорим. Мы точно ожидаем определённую структуру. Тогда и интерфейс не нужен. Про int наверное что то
        Узкоспециализированное? Не сталкивался. Std::transform это не конструкция языка, а подключаемая библиотека. Так?


        1. 0xd34df00d
          18.11.2019 19:47

          Вот про json думал. Но там же структура известна.

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


          И тогда у вас одна функция map один раз инкапсулирует в себе логику пробегания по массиву (или по слайсу, или по чему вашей душе угодно), и всё.


          Std::transform это не конструкция языка, а подключаемая библиотека. Так?

          Именно. А в Go её написать не получится.


          1. anton19286
            19.11.2019 09:20

            Можно подключить внешний кодогенератор. Чем макросы в плюсах и являются, в принципе.


            1. mayorovp
              19.11.2019 09:45

              Для ограниченного набора типов такое решение сойдёт. Для произвольных типов внешнего кодогенератора недостаточно, надо что-то с языком делать.


        1. DmitriyTitov
          18.11.2019 19:53

          Вот про json думал. Но там же структура известна. Зачем там interface{}?

          В Go вся работа с JSON идёт с помощью интерфейсов.

          	rv := reflect.ValueOf(v)
          	if rv.Kind() != reflect.Ptr || rv.IsNil() {
          		return &InvalidUnmarshalError{reflect.TypeOf(v)}
          	}


          Это — кусок функции десериализации (Unmarshal) пакета json. Вызывается пакет reflect — а там всё на пустых интерфейсах.


  1. esata
    19.11.2019 14:48

    Почти каждая статья подобного рода скатывается в срачи на тему «почему в го нет дженериков»