Заметки на полях

Closures, Curring, Partial function application and friends

Продолжаем продолжать

Итак, у нас получился целый набор функций: reduce, map, filter, flatMap и многие другие позволяющие работать со списками.

Едим дальше…

Closures

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

С явным способом всё относительно просто: значение передаётся в качестве аргумента:

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 минут - мы заново рассчитываем вероятность осадков.

Для простоты пусть:

Напишем функцию, которая принимает вышеперечисленные параметры и решает нужно ли актуализировать информацию по актуализации вероятности осадков:

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;
}  

Мы справились с задачей, но кажется что наша функция не очень удобная. Что можно сделать:

Вместо всего этого мы можем сделать следующее:


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))

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

Афта-пати

Chaning (цепочка вызовов)

Многим должна быть знакома следующая форма кода:

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.

Генераторы ака Lazy Sequence

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

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

Для начала напишем функцию 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 с объектно-ориентированной системой, основанной на замыканиях. Он многому научился, и с нетерпением ждал случая сообщить учителю о своих успехах.

Во время следующей прогулки с Кх Аном, Антон, пытаясь произвести хорошее впечатление, сказал: “Учитель, я прилежно изучил этот вопрос, и понимаю теперь, что объекты - воистину замыкания для бедных.” Кх Ан в ответ ударил Антона палкой и воскликнул: “Когда же ты чему-то научишься? Замыкания - это объекты для бедных!” В эту секунду Антон обрел просветление.

◀◀ ▶▶

Нашли ошибку?