Posted on 2021-09-03

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

Ладно, попробую ещё раз.

Чтобы понять функциональное программирование нужно принять два концепта: нелюбовь к переменным и состояниям — зачем куда-то записывать переменную, если мы знаем что с ней будем делать потом?

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

И все остальные парадигмы функционального программирования (например отложенное вычисление, функторы, монады, паттернматчинг и так далее) — строится вокруг этого.

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

Например, давайте возьмём go как один из самых тупых языков. Самый простой пример функционального программирования будет выглядеть вот так:


func main() {
 strings.Trim(strings.Trim(a, ","), "!")
}

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

Часто такие штуки экономят время на чтение программы.

Например, вот так выглядит программа в императивном подходе.

func main() {
	object1 := string(b)
	object2 := strings.Trim(a, "!")
	
	fmt.Sprintf("%v, %v", object1, object2)
}

А вот так в функциональном

func main() {
	fmt.Sprintf("%v, %v", string(a), strings.Trim(b, "!"))
}

Разница в том, что вместо того чтобы вычислять в голове object1 и object2, мы засовываем в функцию результаты вычисления функций.

Если переписать это всё текстом, то имперетивное программирование звучит как:

Обьект1 это результат применения функции string к обьекту b Обьект2 это результат применения функции strings.Trim к обьекту a

А результат это строка содержащая в себе Обьект1 и Обьект2 перечисленные через запятую

Функциональное описание звучит как:

Результат это строка, содержащая в себе перечисленные через запятую результаты применения функнции string() к обьекту b и функции strings.Trim к обьекту а

Смысл в том, что императивное описание у нас в голове всё равно так или иначе превратится в функциональное, когда мы раскрываем и подставляем определения. Поэтому так много споров о именовании переменных.

Например, чтобы упростить чтение в императивном подходе мы можем назвать переменные stringB и trimmedA

	stringB := string(b)
	trimmedA := strings.Trim(a, "!")


	fmt.Printf("%v, %v", stringB, trimmedA)

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

В императивном примере stringB и trimmedA могут быть изменены до того как дойдут до fmt.Sprintf

	stringB := string(b)
	trimmedA := strings.Trim(a, "!")

	go func(){
		trimmedA = "other"
	}()

	fmt.Printf("%v, %v", stringB, trimmedA)

Всё равно почему это может произойти, но в императивном программировании мы, как читатели и интерпретаторы кода, всегда не уверены в том, что переменная названная как-то, делает именно то, что мы от неё ожидаем.

Это порождает множество трудноуловимых для нашего мозга ошибок.

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

Тоже самое относится и к функциям — их поведение не должно меняться, если на входе одни и те же данные. Это тоже де-факто стандарт в императивном мире. Иначе ваши юнит тесты пойдут по одному месту. Да и читать такой код будет невероятно сложно.

Теперь переходим к отложенному вычислению.

Допустим у нас есть массив с числами отсортированный по возрастанию. Нам надо умножить числа в нём на 3 и вывести результаты, которые не больше 21

func main() {
	list := []int{1,2,3,4,5,6,7,8,9,10}
	var multipliedList []int
	for _, k := range list {
		multipliedList = append(multipliedList, k*3)
	}
	for _, k := range multipliedList {
		if k <= 21 {
			fmt.Printf("%v, ", k)
		}
	}
}

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

Давайте переформулируем — проходи по списку и умножай элементы на три и если результат не больше 21, то выведи его

func main() {
	list := []int{1,2,3,4,5,6,7,8,9,10}

	// we can use for
	for k := 0; k < len(list) && list[k] * 3 <= 21; k++ {
		fmt.Printf("%v, ", list[k] * 3)
	}

	// we can use if
	for _, k := range list {
		if m := k * 3; m <= 21 {
			fmt.Printf("%v, ", m)
		} else {
			break
		}
	}
}

И то, и другое это уже оптимизированные подпрограммы. И вычисления не происходят, если мы достигли точки остановки. Но в первом случае нам надо дважды делать операцию умножения (что делать если она дорогая?) А в втором у нас возможно изменение состояния k, до тех пор, пока оно дойдёт до fmt.Printf

Но что если мы получаем значения не из списка, а из канала? Например, если мы асинхронно отправляем запросы куда-то наружу или результаты вычислений сложные

func main() {
	listCh := make(chan int)
	go func(){
		list := []int{1,2,3,4,5,6,7,8,9,10}
		for _, k := range list {
			listCh <- k
		}
	}()

	for k := range listCh {
		if m := k * 3; m <= 21 {
			fmt.Printf("%v, ", m)
		} else {
			break
		}
	}

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

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

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

Для этого мы можем вынести умножение и проверку в функцию:

	for k := range func(ch <-chan int) <-chan int {
		resCh := make(chan int)
		go func() {
			for n := range ch {
				if m := 3 * n; m <= 21 {
					resCh <- m
				} else {
					close(resCh)
				}
			}
		}()
		return resCh
	}(listCh) {
		fmt.Printf("%v, ", k)
	}

Если захотим, то мы сможем как-то наименовать func(ch <-chan int) <-chan int и даже протестировать отдельно от вывода.

Альтернативно мы можем создать мапинг функции по значениям

	for k := range listCh{
		if FmapMultyCheck(k, func(i interface{}) (int, error){
			return fmt.Printf("%v, ", i)
		}) {
			break
		}
	}
}

func FmapMultyCheck(i int, f func(interface{}) (int, error)) bool {
	if k := i * 3; k <= 21 {
		f(k)
		return false
	}
	return true
}

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

Но неплохо знать и понимать подобные паттерны чтобы адекватно работать с разными асинхронными задачами и каналами.

И напоследок, давайте разберем нотацию obj.Func().Func2().Func3() с которой есть несколько проблем.

Главная из которых — нет визуального различия между тем используют ли такую нотацию для того чтобы последовательно изменить сам обьект или для того чтобы создавать копии обьекта.

Именно из-за этого я часто выступаю в кодревью против функций типа

func (u *Obj) Func() *Obj {
  u.something = "a"
  return u
}

Вторая проблема — то что почти всегда обьект не заворачивается в Maybe интерфейс к которому мы потом применяем функции. То есть мы всегда ожидаем что Func() нам вернёт что-то, у чего будет возможность применить функцию Func2(), в то время как ссылка может возвращать nil

Правильная реализация такого включет в себя некий ObjMaybe интерфейс у которого есть минимум две имплементации: Obj и ObjNothing При чём Nothing просто передаёт себя дальше при вызове любой последующей функции Я ещё для удобства записываю в него ошибку, чтобы иметь возможность дальше прочитать.

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

Ладно, уже не комментарий получился а какой-то гигантский пост. Давайте если что я потом новый напишу.