Путаясь в замыканиях

6d456a42c08f31cbec72f36dca80828f.png

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

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

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

ФП → ООП → Процедуры

Для демонстрации результатов я реализовал очень простую задачу:

Создать функционал для расчёта НДС (20%) и налога с продаж (7%).

сначала в виде ФП-кода, затем в виде ООП, а затем в процедурном стиле. В ретроспективе видно, каким образом шло развитие подходов к созданию ПО.

ФП

В функциональном стиле эта задача может быть решена таким образом

const calculateTax = (taxRate) => (amount) => amount * taxRate;
const vat = calculateTax(0.2);       // 20% НДС
const salesTax = calculateTax(0.07); // 7% налог с продаж

console.log(vat(100));      // Вывод: 20
console.log(salesTax(100)); // Вывод: 7.000000000000001

Лично я вижу тут три функции: calculateTax, vat, salesTax . Первая функция является фабрикой для двух других. В результате своей работы она создаёт замыкание, помещает данные в это замыкание, затем создаёт функцию и помещает её туда же. Для двух запусков фабрики имеем на выходе две функции — vat или salesTax .

Можно было бы сказать, что внутри vat и salesTax у нас есть два экземпляра одной и той же функции (amount) => amount * taxRate, но нет — при ближайшем рассмотрении видно, что это две разные функции: (amount) => amount * 0.2 и (amount) => amount * 0.07

ООП

В объектно-ориентированном стиле получается несколько более многословно, но зато явно видно место, где хранятся данные (значение ставки налога — this.taxRate):

class TaxCalculator {
    constructor(taxRate) {
        this.taxRate = taxRate;
    }

    calculate(amount) {
        return amount * this.taxRate;
    }
}

const vat = new TaxCalculator(0.2);    // 20% НДС
const sales = new TaxCalculator(0.07); // 7% налог с продаж

console.log(vat.calculate(100));  
console.log(salesTax.calculate(100)); 

Совершенно ясно, что метод calculate (аналог функции (amount) => amount * taxRate) существует в единственном экземпляре у прототипа класса TaxCalculator. Где-то там,»под капотом», движок вызывает один и тот же код для разного окружения, передавая в него необходимые параметры.

Но это же JavaScript, мы можем переписать этот же код таким образом:

class TaxCalculator {
    constructor(taxRate) {
        this.taxRate = taxRate;
        return (amount) => amount * this.taxRate;
    }
}

const vat = new TaxCalculator(0.2); 
const sales = new TaxCalculator(0.07); 

console.log(vat(100));       
console.log(salesTax(100));  

или даже таким:

class TaxCalculator {
    constructor(taxRate) {
        return (amount) => amount * taxRate;
    }
}

А теперь сравните с:

 (taxRate) => (amount) => amount * taxRate;

Можно сказать, что замыкание — это анонимный класс со своими приватными свойствами, который используется для создания экземпляров объектов.

Процедуры

Этот же функционал можно реализовать и в процедурном стиле: разделение кода на части, линейное выполнение. Если не злоупотреблять глобальными данными, то получается по сути очень похоже на ФП (чистые функции, неизменяемые данные, отсутствие сторонних эффектов):

function calculateTax(taxRate, amount) {
    return taxRate * amount;
}

function vat(amount) {
    return calculateTax(0.2, amount); // 20% НДС
}

function salesTax(amount) {
    return calculateTax(0.07, amount); // 7% налог с продаж
}

console.log(vat(100));        // Вывод: 20
console.log(salesTax(100));   // Вывод: 7.000000000000001

Несмотря на свою похожесть на ФП, тем не менее в данном коде не создаются замыкания. Это старая добрая линейная передача параметров в подпрограммы по ходу выполнения.

Немного истории

Если копать в »глубину веков», то можно вспомнить, как писалось подобное до того, как ООП стало мейнстримом. Вот пример на языке Pascal:

program TaxCalculation;

{$APPTYPE CONSOLE}

uses
  SysUtils;

// Процедура для вычисления налога
procedure CalculateTax(taxRate: Real; amount: Real; var result: Real);
begin
  result := taxRate * amount; // Модифицируем выходной параметр
end;

// Главная программа
var
  amount: Real;
  pvnRate, salesTaxRate: Real;
  pvnResult, salesTaxResult: Real;
begin
  amount := 100.0;         // Сумма
  pvnRate := 0.2;          // Ставка НДС (20%)
  salesTaxRate := 0.07;    // Ставка налога с продаж (7%)

  CalculateTax(pvnRate, amount, pvnResult);
  CalculateTax(salesTaxRate, amount, salesTaxResult);

  WriteLn('PVN (20% от ', amount:0:2, '): ', pvnResult:0:2);
  WriteLn('Sales Tax (7% от ', amount:0:2, '): ', salesTaxResult:0:2);
end.

Обратите внимание на объявление процедуры:

procedure CalculateTax(taxRate: Real; amount: Real; var result: Real);

вот на эту часть:

var result: Real

Сейчас так уже не делают, а раньше можно было указать, передаётся ли параметр в процедуру по ссылке или по значению. Это сейчас в JS нет процедур в принципе — всё является функциями, даже если ничего не возвращается. А раньше в процедурах разделяли аргументы на входные и выходные. В параметры, передаваемые по ссылке (выходные), можно было поместить результаты расчётов и вызывающий код мог ими воспользоваться после завершения работы процедуры.

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

Уверен, что на уровне машинных кодов примерно так это и работает.

Эволюция разработки

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

Процедурный подход применял функции в лоб:

function fn(a, b, c, ..., x, y, z) {
    return a * b + c - ... - x * y + z;
}

ООП предложил часть данных »заморозить» в виде атрибутов объекта:

class Clazz {
    constructor(a, b, c) {
        this.a = a;
        this.b = b;
        this.c = c;
    }

    run(x, y, z) {
        return this.a * this.b + this.c - ... - x * y + z;
    }
}

Можно считать, что все параметры функции поделили на две группы:

  • конфигурационные параметры: то, что отличает один объект класса от другого (например, ставка налога)

  • оперативные параметры: данные для обработки (например, сумма для расчёта налога).

Ну и в настоящий момент наша процедура со своими «конфигурационными параметрами» сворачивается до такого состояния:

const fn = (a, b, c) => (x, y, z) => a * b + c - ... - x * y + z;

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

const fn = (a) => (b) => (c) => ... => (z) => a * b + c ... + z;

Так синглтоны ли функции?

И да, и нет.

Можно сказать, что в ФП »все функции — синглтоны», но это будет неправдой.

Вот, например, функция для расчёта количества вещества при его разведении водой:

const calculateDilution = ratio => substanceAmount => substanceAmount * ratio;
const dilutionWith20Percent = calculateDilution(0.2);

»Под капотом» у dilutionWith20Percent будет функция, аналогичная функции vat:

(x) => x * 0.2

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

Тем не менее, утверждать, что в ФП »функции не синглтоны, потому что существуют замыкания» тоже не верно. Вот аналогичный пример без замыканий:

const vat = (amount) => amount * 0.2;
const dilution20 = (val) => val * 0.2;

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

Заключение

В этой версии Вселенной радикализм не в почёте. Всё как обычно: есть правила, из них есть исключения, из исключений есть свои исключения. Очень редко утверждения со словами »все»,»всегда»,»никто»,»никогда» и т.п. подобными являются истинными.

Благодарю коллег за ценные замечания и отдельно @Tishka17.

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

Ну, а Maximiliano Contieri — порицание за кликбейтный заголовок. Хотя без него вряд бы появилась эта статья. Так что и от этого в конечном итоге польза.

© Habrahabr.ru