Продолжаем продолжать
Итак, у нас получился целый набор функций: reduce
, map
, filter
, flatMap
и многие другие позволяющие работать со списками.
Едим дальше…
Есть два способа, как некоторое значение может попасть внутрь тела функции, чтобы быть использованным: явный и неявный.
С явным способом всё относительно просто: значение передаётся в качестве аргумента:
function sum (a, b) {
return a + b
}
let first = 2
let second = 3
sum(first, second) // 5
first
и second
здесь - примитивные типы и при передаче в качестве аргументов в функцию sum
их значения копируются. Таким образом именам a
и b
присваиваются копии first
и second
. И если кто-то попытается сделать что-то вроде
function sum (a, b) {
let old_a = a
a = 42
return old_a + b
}
let first = 2
let second = 3
sum(first, second) // 5
first // 2
то, к счастью, first
останется с прежним значением.
Однако есть нюанс - в случае передачи в качестве аргумента чего-то по ссылке произойдёт следующее:
function sum (a, b) {
let old_a = a.value
a.value = 42
return old_a + b.value
}
let first = {"value" : 2}
let second = {"value" : 3}
sum(first, second) // 5
first //42
sum
получает в качестве аргументов копии ссылок, а содержание по этим ссылкам одно и тоже.
function sum (a, b) { // +---------------------+
let old_a = a.value // | a (copy of 'first') |-------+
a.value = 42 // +---------------------+ |
// | b (copy of 'second')| |
return old_a + b.value // +---------------------+ V
} // | +-----------+
// +--------+---------|--->| value : 2 |
let first = {"value" : 2} // | first | | +-----------+
// +--------+ +--->| value : 3 |
let second = {"value" : 3} // | second | +----->+-----------+
// +--------+-------+
Поэтому следует быть осторожным со ссылочными типами данных даже в случае явной передаче их в функцию.
То что происходит в a.value = 42
называется в терминах ФП называется мутацией, т.е. переприсваивание значения в уже существующей переменной.
При программировании в функциональном стиле всячески избегают мутаций вплоть до полного отказа от них или невозможности их совершать, в виду специфики языка или при помощи механизмов языковой платформы, которая как способствует написанию логики без мутаций с одной стороны, так и препятствуя написанию кода с мутациями с другой.
Но вернёмся к способам использования данных внутри функций. Был рассмотрен явный способ - когда переменные передаются в функцию в качестве аргументов.
Что касается неявных, то тут на сцену и выходят замыкания. В простейшем виде замыкание выглядит следующим образом:
let a = 42;
function get_a() {
return a
}
get_a() // 42
Хмм. Мы не передавали a
в качестве аргумента в функцию, однако функция каким-то образом нашла её и после вызова вернула её значение.
Современные языки программирования обладают механизмом, позволяющим функциям (или методам) находить переменные, которые не были переданы в качестве аргументов, но находятся в области видимости функций. Это и называется замыканием.
Зачем это может быть нужно?
let list = [1,2,3]
let factor = 5
map(list, (num) => num * factor) // [5,10,3]
function Counter(init_value) {
return () => return ++init_value
}
let increase_and_get = Counter(10)
increase_and_get() // 11
increase_and_get() // 12
increase_and_get() // 13
Представим ситуацию: в аэропорте взвешивают багаж и допускают только тот, который меньше определённых размеров: height_limit = 30.0, width_limit = 50.0
и веса: weight_limit = 20.0
.
Напишем функцию, которая будет проверять одну единицу багажа на соответствие параметрам:
function is_availiable_baggage(bag, height_limit, width_limit, weight_limit) {
return bag.height < height_limit
&& bug.width < width_limit
&& bag.weight < weight_limit
}
Вроде бы всё верно?
А теперь вспомним интерфейс функции filter
:
filter(list, f)
, где f
- некоторая функция, которая принимает в качестве аргумента элемент списка и возвращает true
/false
. Можно описать f
как x => boolean
.
Наша функция is_availiable_baggage
не подходит по интерфейсу - f
ничего не знает о height_limit, width_limit, weight_limit
и не может их использовать в таком виде.
Что ж давайте модифицируем нашу функцию:
let height_limit = 30.0
let width_limit = 50.0
let weight_limit = 30.0
function is_availiable_baggage(bag) {
return bag.height < height_limit
&& bug.width < width_limit
&& bag.weight < weight_limit
}
filter(bags, is_availiable_baggage)
Мы выделили эту логику в отдельную функцию и можем переиспользовать. Более того: в js мы могли бы например создать отдельный класс BaggageValidator
, единственный знающий о лимитах багажа, которые он получал бы из конфигурационного файла или из бд. И это было бы хорошее решение.
Но предположим, что в вашем прекрасном и любимом языке программирования помимо отсутствия for
, массивов (к элементам которых можно обращаться по индексу, т.к. они располагаются в памяти подряд один за другим) - отсутствуют ещё и классы (так исторически сложилось ¯\_(ツ)_/¯
- вы же не перестанете любить свой прекрасный язык программирования после этого, несмотря на отсутствие всего вышеперечисленного?). Но у вас всё ещё есть структуры или ассоциативные массивы, связные списки и функции - а это уже кое-что.
Так как же, не имея классов, сделать условие для фильтра и причём здесь каррирование?
Чуть ранее рассказывалось о замыканиях, поэтому попробуем решить эту проблему с помощью них:
function is_availiable_baggage (height_limit, width_limit, weight_limit){
return (bag) => bag.height < height_limit
&& bug.width < width_limit
&& bag.weight < weight_limit
}
let height_limit = 30.0
let width_limit = 50.0
let weight_limit = 30.0
filter(bags, is_availiable_baggage(height_limit, width_limit, weight_limit))
Теперь is_availiable_baggage
не проверяет непосредственно соответствие багажа определённым параметрам, а возвращает функцию, замкнув в ней переданные в качестве аргументов значения предельных характеристик.
Эта техника называется Частичное применение - приведение функции к другой функции с меньшей арностью (т.е. к функции с меньшим количеством аргументов).
Что такое каррирование? Каррирование - преобразование функции от многих аргументов в набор вложенных функций, каждая из которых является функцией от одного аргумента.
Для нашего примера: если нет возможности заранее определить все параметры (height_limit, width_limit, weight_limit
) можно каррировать функцию is_availiable_baggage
:
function is_availiable_baggage(height_limit) {
return (width_limit) =>
(weight_limit) =>
(bag) => bag.height < height_limit
&& bug.width < width_limit
&& bag.weight < weight_limit
}
// или в виде лямбды
const is_availiable_baggage = (height_limit) =>
(width_limit) =>
(weight_limit) =>
(bag) => bag.height < height_limit
&& bug.width < width_limit
&& bag.weight < weight_limit
Тогда использовать её можно будет следующим образом:
let height_limit = 30.0
let width_limit = 50.0
let weight_limit = 30.0
filter(bags, is_availiable_baggage(height_limit)(width_limit)(weight_limit))
// более детально
let with_hight_limit = is_availiable_baggage(height_limit)
let with_hight_and_width_limits = with_hight_limit(width_limit)
let with_hight_and_width_and_weight_limits = with_hight_and_width_limits(weight_limit)
filter(bags, with_hight_and_width_and_weight_limits)
Количество уровней вложенности при каррирования должно быть равно уровню вызовов для получения конечного результата.
Одно из главных неудобств при подобного рода использовании техники каррировании - нужно соблюдать порядок и количество вызовов функций, особенно если используются “разношёрстные” аргументы (числа, строки, списки). В такой ситуации легко сбиться и в результате будет не то, что ожидается. Это дополнительно осложняется в языках с динамической типизацией, где нет компилятора, а статический анализатор кода складывает свои полномочия в попытках понять: какого типа будет та или иная переменная.
Сможете написать функцию
carry
, которая каррирует переданную функцию с произвольным количеством аргументов?
lazy evaluation
)Ленивые вычисления - ещё одна полезна техника, которая позволяет избежать вычислений, если в них нет необходимости.
Представим, что у нас есть сервис, который анализирует погоду и выдаёт вероятность осадков. Не будем вдаваться в детали - как именно вычисляется эта вероятность - скажем только, что это достаточно продолжительный и сложный процесс. Этот сервис доступен пользователям и они могут слать запросы, чтобы узнать вероятность осадков в текущий момент. Так как процесс вычисления этой вероятности долгий - считаем, что текущая вероятность с предыдущего запроса актуальна в течение 15 минут. А по истечении 15 минут - мы заново рассчитываем вероятность осадков.
Для простоты пусть:
current_chance_of_rain
calc_chance_of_rain
, которая принимает в качестве параметров координаты, влажность, температуру воздуха, давление, а так же направление и скорость ветраis_expired
- флаг, сигнализирующий о том, что 15 мин прошли и нужно заново рассчитать значение вероятности осадков.Напишем функцию, которая принимает вышеперечисленные параметры и решает нужно ли актуализировать информацию по актуализации вероятности осадков:
function chance_of_rain(current_chance_of_rain, is_expired, temperature, wetness, ... and_other_weather_params) {
return is_expired ? calc_chance_of_rain(temperature, wetness, ... and_other_weather_params) : current_chance_of_rain;
}
Мы справились с задачей, но кажется что наша функция не очень удобная. Что можно сделать:
calc_chance_of_rain
в качестве аргумента, но она принимает параметры, которые внутри chance_of_rain
неоткуда взять.chance_of_rain
, но тогда мы не сможем использовать chance_of_rain
в отрыве от контекста с необходимыми нам параметрами.chance_of_rain
.calc_chance_of_rain
или аргументов для этой функции передать само значение, но тогда сама идея избежать долгих вычислений теряет всякий смыслВместо всего этого мы можем сделать следующее:
function chance_of_rain (current_chance_of_rain, is_expired, calcIfExpired) {
return is_expired ? calcIfExpired() : current_chance_of_rain;
}
// ... где-то дальше в коде
let new_chance_of_rain = chance_of_rain(current_chance_of_rain, is_expired,
() => calcIfExpired(temperature, wetness, ... and_other_wether_params))
Что здесь происходит: вместо того, чтобы передать новое значение или аргументы для получения значения - мы передаём функцию по получению значения, которая будет выполнена только в момент вызова. Таким образом мы избежим ненужных вычислений. Т.е. можно сказать что мы отложили вычисление.
Многим должна быть знакома следующая форма кода:
let array = [1,2,3,4,5]
array
.map(x => x + 1)
.map(x => x * 10) // [20, 30, 40, 50, 60]
...
или такая
let p = await Promise.resolve(5)
.then(x => x + 1)
.then(x => x * 10)
// p == 60
Возможность составлять цепочки вызовов может быть очень удобной, когда работа с данными идёт не напрямую, а через класс-“обёртку” (Promise
, List
, Array
, Optional
).
Как это достигается? В простейшем исполнении примерно так:
class Chainable {
constructor(value) {
this._value = value
}
getValue() {
return this._value
}
andThen(f) {
return new Chainable(f(this._value))
}
// или `andThen` может выглядеть так:
// andThen(f) {
// this._value = f(this._value)
// return this
// }
}
new Chainable(5)
.andThen(x => x + 1)
.andThen(x => x * 10)
.getValue() // 60
Давайте попробуем теперь создать класс Array
с методом andThen
(например - название метода может быть любым) для возможности делать цепочку вычислений.
class MyArray {
constructor(arr) {
this._arr = arr
}
getValue() {
return this._arr
}
andThen(f) {
this._arr = f(this._arr)
return this
}
}
new MyArray([1,2,3])
.andThen(arr => map(arr, (x) => x + 1))
.andThen(list => map(arr, (x) => x * 10))
.getValue() // [20,30,40]
👍🏻
Всё хорошо, но не очень удобно вызывать ещё map
внутри функции, учитывая, что будет необходимость применять некоторую функцию к каждому элементу, а не ко всему списку целиком. Давайте сделаем у класса метод map
принимающий функцию, которая будет приниматься к каждому элементу и результатом её будет объект класса MyArray
для возможности продолжить цепочку вычислений
class MyArr {
constructor(arr) {
this._arr = arr
}
getValue() {
return this._arr
}
map(f) {
// `map` здесь - это отдельная функция, которую мы написали ранее
return new MyArr(map(this._arr, f))
}
}
new MyArr([1,2,3])
.map(x => x + 1)
.map(x => x * 10)
.getValue() // [20,30,40]
Как говорится вуа-ля. 👍🏻
Основная идея механизма цепочки вызовов: “зачейненый” метод должен возвращать что-то на чём можно продолжить цепочку вызовов - не важно как этот метод называется, неважно какой метод будет вызван дальше - в нашем примере мы использовали только andThen
или только map
, но если бы у нашего класса MyArray
был метод filter
с возможностью делать цепочки вызовов, то мы вполне могли бы чередовать filter
и map
.
- Можете проделать то же самое для методов
filter
,flatMap
,reduce
- @@ Можете ли сделать цепочку вызовов только с помощью функций ?
До сих пор мы имели дело со списками и данными, у которых есть начало и конец, т.е. их размерность была нам известна. А можно ли сделать неограниченную или очень большую последовательность? А если можно - то как уместить её в ограниченную память?
На самом деле у нас уже есть все инструменты, которые могут помочь нам реализации.
Для начала напишем функцию range
, которая будет возвращать чиселку из заданного диапазона:
function range(from, to) {
let init = from == undefined ? 0 : from
if (to == undefined) {return () => init++}
return () => init < to ? init++ : null
}
let from = 1
let to = 10
let gen = range(from, to);
// сделаем цикл побольше на 2
for (let index = from ; index < to + 2; index++) {
console.log(gen());
}
Вывод:
1
2
3
4
5
6
7
8
9
null
null
Как вы можете заметить можно генерировать последовательность бесконечно, вызвав range()
без аргументов.
Так же мы можем не просто указать диапазон, а указать логику, по которой будет генерироваться следующее число:
function gen(next, seed) {
let init = seed == undefined ? 0 : seed
let value = init
return () => {
let current_value = value
value = next(value)
return current_value;
}
}
let from = 1
let to = 10
let next = gen(i => i + 2, from);
for (let index = from ; index < to + 2; index++) {
console.log(next());
}
1
3
5
7
9
11
13
15
17
19
21
Напишите функции
take
,skip
, которые смогут работать с “ленивыми” последовательностями. @ А так же функциюapply
, которая будет применять одну или несколько функций к генерируемой последовательности.
А теперь давайте изобретём… что-то-вроде-классов, потому что… почему бы и да? Для этого нам понадобятся функции, замыкания и… пожалуй всё.
Для простоты сделаем простой “класс” Date
, который будет
// date.js
function Date(year, month, day) {
return (f)=> f(year, month, day)
}
// Если очень хочется,
// можно поместить нижеописанные методы внутрь функции Date,
// чтобы было совсем похоже на класс.
function getYear() {
return (y,m,d) => y
}
function getMonth() {
return (y,m,d) => m
}
function getDay() {
return (y,m,d) => d
}
// Мутировать текущие значения года, месяца или дня мы не можем ФИЗИЧЕСКИ.
// Поэтому - "следите за руками" (c)
function setYear(year) {
return (y,m,d) => Date(year, m, d)
}
function setMonth(month) {
return (y,m,d) => Date(y, month, d)
}
function setDay(day) {
return (y,m,d) => Date(y, m, day)
}
Поняли фокус? Мы вернули новое замыкание с новым стейтом, но для вызывающей стороны ничего не изменилось: такой же интерфейс взаимодействия и неважно - новая это сущность вернулась или старая.
Давайте проверим:
let date = Date(2022, 01, 01)
date(getYear()) // 2022
date(setYear(2021))(getYear()) // 2021
// НО !!!
date(getYear()) // 2022
А теперь внимание:
// То что мы реализовали в файле date.js
// используя функции и замыкания
date(getYear())
// это настоящий js.
// И не только js, а почти любой язык с классами
date.getYear()
Как видите разница синтаксиса минимальная (и сути, пожалуй, тоже).
Не будем утверждать, что эволюция программированию шла именно по такому пути, и в какой-то момент инженеры языков программирования решили не оперировать простыми структурами как то: ассоциативный массив, список, строка, число или какой-либо другой языковой примитив - передавая их внутрь функции для исполнения над ними набора операций, а сделать объекты, которые хранят в себе состояние обусловленное набором полей, а так же набором методов, управляющих с этим состоянием, создавая подобие “поведения” у этих объектов, сокрытое от вызывающей стороны.
Оба этих подхода: разделённые примитивы и функции для работы с ними с одной стороны, и объекты с состоянием и поведенческими методами с другой - имеют свои плюсы и минусы (как впрочем и всё в рамках и за пределами программирования).
И разницу между этими подходами, кажется, можно выразить следующим образом:
// модуль List содержащий функции для работы со списками
List.map(list_of_numbers, (num)=> num + 1)
// объект класса List
list_of_numbers.map((num)=> num + 1)
Как-то однажды знаменитый учитель Кх Ан вышел на прогулку с учеником Антоном. Надеясь разговорить учителя, Антон спросил: “Учитель, слыхал я, что объекты - очень хорошая штука - правда ли это?” Кх Ан посмотрел на ученика с жалостью в глазах и ответил: “Глупый ученик! Объекты - всего лишь замыкания для бедных.”
Пристыженный Антон простился с учителем и вернулся в свою комнату, горя желанием как можно скорее изучить замыкания. Он внимательно прочитал все статьи из серии “Lambda: The Ultimate”, и родственные им статьи, и написал небольшой интерпретатор Scheme с объектно-ориентированной системой, основанной на замыканиях. Он многому научился, и с нетерпением ждал случая сообщить учителю о своих успехах.
Во время следующей прогулки с Кх Аном, Антон, пытаясь произвести хорошее впечатление, сказал: “Учитель, я прилежно изучил этот вопрос, и понимаю теперь, что объекты - воистину замыкания для бедных.” Кх Ан в ответ ударил Антона палкой и воскликнул: “Когда же ты чему-то научишься? Замыкания - это объекты для бедных!” В эту секунду Антон обрел просветление.