В комментах к статье "Синглтон - корень всех зол", который вообще-то про паттерн проектирования, я высказал мысль, что в функциональном программировании "все функции - синглтоны" (это уже в смысле 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 - порицание за кликбейтный заголовок. Хотя без него вряд бы появилась эта статья. Так что и от этого в конечном итоге польза.

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


  1. vadimr
    22.01.2025 09:43

    Не надо переусложнять.

    Функциональное программирование – это строго математичная вещь, там почти всё расписано до аксиом формальным символьным выводом. А паттерны проектирования - некие практические наблюдения, советы, обобщающие практику. Не пытайтесь объяснить первое через второе, это значительный шаг в сторону удаления от ясности ума.

    Ну и замыкания в ФП не "замораживают" значения, они только сохраняют лексический контекст. Захват значений в замыканиях – это сиплюсплюсовская объектно-ориентированная примочка.

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


    1. nin-jin
      22.01.2025 09:43

      Функция - синглтон в любых языках. Функции создаются в момент интерпретации кода программы (в том числе при eval). У функции есть явные и неявные параметры. К неявным относятся: лексический контекст и объектный контекст (ссылка на объект). Последний в некоторых языках всё же явный. Замыкание - экземпляр функции с хотя бы одним заданным неявным параметром. Это буквально рантайм структура из 2-3 ссылок. JIT компилятор может оптимизировать такие замыкания, на лету создавая новые функции, где неявные аргументы подставлены в код и оптимизированы.


      1. vadimr
        22.01.2025 09:43

        Я говорю только о том, что не надо предельно математически ясное в ФП понятие функции, которое имеет формальное аксиоматическое определение семантики (можно посмотреть, например, на 66 странице R7RS) объяснять мутным словом "синглтон".

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


        1. flancer Автор
          22.01.2025 09:43

          Я в своей статье рассматривал "синглтон" в контексте lifestyle - сколько экземпляров одной функции порождается в процессе выполнения программы. Вот и по мнению коллеги @nin-jin функции в программах создаются в одном экземпляре (если я правильно понял его коммент выше). Паттерн ОО-проектирования "синглтон" к моим рассуждениям отношения не имеет. Но очень легко запутаться, когда два разных понятия имеют одно и то же имя.


          1. unreal_undead2
            22.01.2025 09:43

            Функция как код - создаётся один раз, замыкание как код + захваченный контекст - создаётся каждый раз новое.


  1. unreal_undead2
    22.01.2025 09:43

    замыкания в ФП не "замораживают" значения, они только сохраняют лексический контекст

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


    1. vadimr
      22.01.2025 09:43

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


      1. unreal_undead2
        22.01.2025 09:43

        Ну банально

        * (defun mycar (p) (lambda () (car p)))
        MYCAR
        * (defvar p1 (cons 1 1))
        P1
        * (defvar p2 (cons 2 2))
        P2
        * (defvar c1 (mycar p1))
        C1
        * (defvar c2 (mycar p2))
        C2
        * (funcall c1)
        1
        * (funcall c2)
        2

        Лексически у нас одна лямбда в первой строке, но дальше создаются разные инстансы с разным поведением.

        каждая из которых в отдельности может являться синглтоном.

        Всё таки синглтон подразумевает, что при конструировании объекта мы каждый раз получаем одну и ту же сущность. А так то любой объект можно считать синглтоном самого себя )


        1. vadimr
          22.01.2025 09:43

          Я бы сказал, что это скорее ловушка неполного символьного обозначения, чем сущностная проблема. Если записывать значения лямбд в каком-нибудь гипотетическом языке, где семантика лиспа будет явно отражена в синтаксисе, то мы должны написать что-то вроде:

          c1 = (lambda () (car p)) {в контексте p=p1}

          c2 = (lambda () (car p)) {в контексте p=p2}

          и это две отчётливо разные лямбды.

          Просто так исторически повелось, что контекст имени в синтаксисе опускается. Но в семантике-то он присутствует.


          1. unreal_undead2
            22.01.2025 09:43

            Если один код в разных контекстах генерирует разные объекты - это уже в любом случае не синглтон.


            1. vadimr
              22.01.2025 09:43

              Если смотреть на функцию именно как на символическую запись её кода в отрыве от контекста, то да.


  1. nin-jin
    22.01.2025 09:43

    Из статьи складывается ощущение, что ФП - следующий этап развития после ООП, а ПП - вообще какой-то мезозой. Однако, стоит отметить, что ФП и объектная декомпозиция - вещи ортогональные. Объекты, точно так же могут быть неизменяемыми и содержать лишь чистые методы. И наоборот, грязные методы могут описывать идемпотентные инварианты между атрибутами объектов. Вот вам пример на ОРП на подумать:

    // чистый объект
    class TaxCalculator extends PureObject {
        amount() { return 0 }
        vat() { return this.amount() * 0.2 }
        sales() { return this.amount() * 0.07 }
        total() { return this.vat() + this.sales() }
    }
    
    class MyBasket extends ReactiveObject {
    
        // чистая функция, но грязный метод
        @mem cost( next ) { return next ?? 100 }
    
        // это вообще процедура в объекте
        @mem change( diff ) {
          this.cost( this.cost() + diff )
        }
    
        // а тут у нас ленивая фабрика с реактивным связыванием
        @mem taxes() {
            return new TaxCalculator({
                amount: ()=> this.cost()
            })
        }
    
        // вызывается каждый раз, когда налоги реально меняются
        @mem loging() {
          console.log( 'taxes: ', this.taxes().total() )
        }
    
    }
    


    1. flancer Автор
      22.01.2025 09:43

      Мне сложно "с листа" понять, о чём этот код - мне не хватает бэкграунда. Например, PureObject , ReactiveObject - я не знаю, что это за классы. @mem - это похоже на декоратор, я тоже не знаю, что он делает. Вы показываете "верхушку айсберга" и спрашиваете, что "под водой". Я не знаю. Реактивность не находится в фокусе моих интересов, чтобы я этим заинтересовался. Я даже "чистый объект" не осилил (так и не понял, чем он отличается от "грязного"?). Почему тут наследование от PureObject , а в примере - от $mol_object? В общем, это для меня слишком сложно. Но спасибо, что предложили подумать.


      1. nin-jin
        22.01.2025 09:43

        PureObject, ReactiveObject вставлены для простоты понимания кода, можно и без них. $mol_object даёт фабрику make, через которую удобно переопределяются методы при создании. @mem мемоизирует последнее возвращённое значение.


  1. kosuha666
    22.01.2025 09:43

    Пример в ООП части, я бы не согласился что это ООП, по-моему это процедура calculate обернутая в неймспейс TaxCalculator.

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

    Я бы такой код написал в ООП.

    class TaxOfPrice {
        constructor(price, tax) {
            this.price = price;
            this.tax = tax;
        }
    
        valueOf() {
            return (this.price * this.tax).toFixed(2);
        }
    
        toString() {
            return this.valueOf();
        }
    }
    
    class Tax {
        constructor(tax) {
            this.tax = tax;
        }
        
        valueOf() {
            return this.tax;
        }
    }
    
    const vat = new Tax(0.2);
    const sales = new Tax(0.07);
    
    console.log(`of price 100 vats = ${new TaxOfPrice(100, vat)}`);                   // of price 100 vats = 20.00
    console.log(`of price 100 sales = ${new TaxOfPrice(100, sales)}`);                // of price 100 sales = 7.00
    console.log(`of price 100 vats and sales = ${new TaxOfPrice(100, vat + sales)}`); // of price 100 vats and sales = 27.00


    И это не синглтоны =). Как по мне в ООП синглтоны не очень нужны именно для ООП. Они скорее нужны иногда для какой-то оптимизации на уровне компьютера. а на уровне ООП по-моему не нужны - чисто логически.


  1. JBFW
    22.01.2025 09:43

    Напоминает дискуссии средневековых схоластиков на тему "сколько ангелов уместится на острие иглы" )

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

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

    Но теперь вам нужно еще добавить еще расчет налогов сотрудника:
    - взять ФОТ, из него вычесть, сколько там, 18% соц. фонд, из него же 3% медстрах, из ОСТАВШЕГОСЯ 13% НДФЛ, а остаток выдать в руки сотруднику (цифры точно не помню, не суть важно)

    Ваш "новый программист" берет за основу старый код, практически копипасту, и пишет:

    function to_ss(amount){
      return calculateTax(0.18, amount); // 18% соц.
    }
    function to_ms(amount){
      return calculateTax(0.03, amount); // 3% мед.
    }
    function to_ndfl(amount){
      return calculateTax(0.13, amount); // 13% НДФЛ.
    }
    const ss = to_ss(1000);
    const ms = to_ms(1000);
    const ndfl = to_ndfl(1000 - ss - ms);
    const pay = 1000 - ss - ms - ndfl;

    Этого будет достаточно чтобы не попасть ни под санкции со стороны налоговой, ни под санкции со стороны какой-нибудь комиссии по труду.
    Всё просто и наглядно, и на вопрос сотрудника "Э, где моя тыща?" - ответ виден сразу.

    А теперь допустим что у вас был код вот такой:

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

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

    Это к тому, что налицо усложнение, у этого усложнения должна быть практическая выгода. В чем она тут? Быстрее обрабатывается? Потребляет меньше памяти? Сложнее заменить опытных программистов?


    1. flancer Автор
      22.01.2025 09:43

      Вот мне тут Игорь Иванович подсказывает:

      const calculateTax = (taxRate) => (amount) => amount * taxRate;
      
      const vat = calculateTax(0.2); // 20% НДС
      const toSS = calculateTax(0.18); // 18% соц.
      const toMS = calculateTax(0.03); // 3% мед.
      const toNDFL = calculateTax(0.13); // 13% НДФЛ.
      
      const ss = toSS(1000);
      const ms = toMS(1000);
      const ndfl = toNDFL(1000 - ss - ms);
      const pay = 1000 - ss - ms - ndfl;

      В общем, не сильно сложнее вашего варианта. Всё зависит от "заточки" мозгов разраба. Я, например, чистый JS'ник. "Висну" на первом же ts-декораторе - мозги не приучены читать такой код. После Java долго привыкал к PHP, после PHP долго привыкал к JS. Но отвыкаю быстро - полгода и нужно плющить мозг, чтобы понять о чём код. К стрелочным функциям в JS тоже не быстро приспособился, несколько месяцев ушло.


  1. XViivi
    22.01.2025 09:43

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

    Если этот пример считать, то всё же функции можно сделать не синглтоном, просто это антипаттерн, который или для странного api, или для какой-то jit-компиляции (не совсем это) и применять.