Функциональное программирование на Python. Часть 5. Эффекты операции присваивания

Автор: | 02.06.2020

Одним из определяющих моментов при выборе модели вычисления для языка программирования является наличие или отсутствие операции присваивания. К чему приводит введение “присваивания” в язык, и чем принципиально отличаются функциональные и не функциональные языки, этим и другим вопросам посвящена данная статья.

Вычисления без использования присваивания

Начнем с математики. Посмотрите на выражение, приведенное ниже:

\( c=\sqrt{a^2+b^2}\) 

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

\( f(x,y)=\sqrt{x^2+y^2}\) 

Давайте разберемся с тем, будет вычислено значение такой функции при заданных аргументах x и y? Например при x=3, y=4:

\(f(3,4)=?\) 

Первое, что мы сделаем, это подставим вместо x и y в функции f(x,y) значения 3 и 4:

\(f(3,4)=\sqrt{3^2+4^2}\) 

После этого вычислим квадраты чисел:

\(f(3,4)=\sqrt{9+16}\) 

Выполним операцию сложения:

\(f(3,4)=\sqrt{25}\) 

Извлечем квадратный корень из 25:

\(f(3,4)=5\) 

Изменим внешний вид нашей функции f(x,y). Для этого подготовим отдельные функции, которые выполняют нужные нам арифметические операции:

\(sqrt(x)=\sqrt{x}\) 

\(sq(x)=x^2\) 

\(sum(x,y)=x+y\) 

Перепишем определение f(x,y) через представленные выше функции:

\(f(x,y)=sqrt(sum(sq(x), sq(y)))))\) 

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

Такое представление функции в математике, мало чем отличается от того, как это было бы сделано в программировании, например на Haskell код будет выглядеть так:

sq = \x -> x^2
add = \x y -> x+ y

f = \x y -> sqrt (add (sq x) (sq y))

Функцию sqrt определять не нужно, она уже есть в Haskell.

Процесс вычисления/выполнения (это касается функциональных языков) функции будет выглядеть так:

f 3 4

-> sqrt(sum(sq(3), sq(4)))))
-> sqrt(sum(9, 16))
-> sqrt(25)
-> 5

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

Язык Python, как уже говорилось ранее, позволяет писать в функциональном стиле, код для  f(x,y) будет выглядеть так:

sqrt = lambda x: x**0.5
sq = lambda x: x**2
add = lambda x, y: x + y

f = lambda x, y: sqrt(add(sq(x), sq(y)))

Выполним нашу программу:

>>> f(3,4)
5.0

Как вы могли заметить, мы не использовали присваивание значений переменным в наших реализациях (на Haskell это вообще не возможно). Строки вида sqrt = lambda x: x**0.5 хоть и похожи на то, что мы как будто присваиваем переменной sqrt какое-то значение, на самом деле это один из возможных вариантов объявления функции, который позволяет писать более лаконичный код.

Вычисления с использованием присваивания

Напишем реализацию вычисления функции f(x,y) на Python с использованием операции присваивания в виде последовательности операций, назовем ее f1:

def f1(x, y):
    t1 = x**2
    t2 = y**2
    t3 = t1+t2
    return t3**0.5

Проверим, что эта функция работает:

>>> f1(3, 4)
5.0

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

Про декларативный и императивный подходы на примерах

Когда мы пишем код программы для вычисления значения функции  f(x,y) так:

f = lambda x, y: sqrt(add(sq(x), sq(y)))

Мы описывает, ЧТО МЫ ХОТИМ ПОЛУЧИТЬ.

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

Выполнение программы – это вычисление функции.

Такой стиль называют декларативным

В случае такой реализации:

def f1(x, y):
    t1 = x**2
    t2 = y**2
    t3 = t1+t2
    return t3**0.5

Мы описываем процесс того, КАК МЫ ХОТИМ ЭТО СДЕЛАТЬ.

Опишем функцию f1 словами: возьмем первый аргумент, возведем его в квадрат, сохраним полученное значение во временной переменной, после этого второй аргумент тоже возведем в квадрат и сохраним значение уже в другой переменной, вычислим сумму из значений этих переменных, сохраним ее, и возведем в степень 0.5.

Выполнение программы – это последовательное выполнение инструкций.

Это императивный стиль.

Следует заметить, что современные промышленные языки программирования (C++, Java, C#, Python) являются мультипарадигменными, то есть позволяют разрабатывать ПО в разных стилях.

Референциальная прозрачность

Пока мы не меняем объекты (не вводим операцию присваивания), можно говорить об их равенстве с точки зрения поведения.

Рассмотрим функцию adder:

def adder(a):
    def helper(x):
        return x + a
    return helper

Она принимает в качестве аргумента какое-то число a и возвращает функцию, которая прибавляет этот аргумент к переданному ей значению. Создадим с помощью adder две функции, которые прибавляют тройку к переданному числу:

a1 = adder(3)
a2 = adder(3)

Эти функции будут эквивалентны с точки зрения поведения. Понятно, что они не равны как объекты в языке Python (т.к. в этом случае сравниваются id объектов):

>>> a1 == a2
False

Но если мы сделаем так:

>>> a1(12)
15

>>> a2(4)
7

а потом вот так:

>>> a1(7)
10

>>> a2(7)
10

Результат работы функций a1 и a2 на аргументе 7 никогда не изменится и всегда будет равен 10 (сколько бы раз и на каких аргументах до этого они не вызывались).

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

Это позволяет рассматривать объект как совокупность его частей. Взгляните на пример:

add3 = adder(3)
add5 = adder(5)
add9 = adder(9)

proc1 = lambda x: add3(x) + add5(x) + add9(x)
>>> proc1(7)
38

Функция proc1 – это сумма результатов значений, возвращаемых add3, add5 и add9 и для того, чтобы определить, чему равен вызов proc1 на аргументе 7 никаких дополнительных знаний не нужно. Значение, возвращаемое proc1, всегда определяется только результатами add3, add5 и add9.

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

def adder_mod(a):
    tmp = 0
    def helper(x):
        nonlocal tmp
        tmp = tmp + x + a
        return tmp

    return helper

Создадим на базе нее две функции:

a1_mod = adder_mod(3)
a2_mod = adder_mod(3)

Вызовем их на аргументах 12 и 4, также как мы это сделали для a1, a2 (см. выше)

>>> a1_mod(12)
15

>>> a2_mod(4)
7

Пока результаты совпадают с тем, что были получены для a1 и a2. Но теперь вызовем их с аргументом 7:

>>> a1_mod(7)
25

>>> a2_mod(7)
17

Получили отличные друг от друга значения. Более того, каждый раз, вызывая функцию a1_mod на аргументе 7, мы будем получать разные ответы:

>>> a1_mod(7)
35

>>> a1_mod(7)
45

>>> a1_mod(7)
55

Функции a1_mod и a2_mod не являются эквивалентными с вычислительной точки зрения. В этом случает не выполняется ссылочная прозрачность. У построенных нами объектах появилась индивидуальность!

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

Когда пропадает ссылочная прозрачность, становится трудно определять эквивалентность объектов. Объект становится “больше”, чем набор компонент, из которых он состоит. Если вы знакомы с DDD, то entity – это как раз объекты с состоянием, у которых есть индивидуальность, они не определяются набором своих свойств или значениями аргументов (если мы говорим про функции). А вот value object, напротив, состоянием не обладают, и они полностью определяются значениями свойств (если это объект класса) или аргументами (если это функции, хотя в DDD value object’ы это объекты класса, функция, в рамках этого подхода, скорее относится к категории service).

Несколько слов о символах в языках программирования

Под символом в данном случае понимается имя аргумента функции, имя переменной и т.п.

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

\( f(x,y)=sqrt(sum(sq(x), sq(y))))) \)

аргументы x и y – это имена для конкретных значений, которые вместо них будут подставлены при ее выполнении, например:

\( f(3,4)=sqrt(sum(sq(3), sq(4))))) \)

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

Проблемы с параллелизмом

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

Когда у нас одно ядро и на нем выполняется только одна программа без распараллеливания вычислений, то проблем нет, оба подхода, с присваиванием и без него, будут работать. Как только мы хотим что-то выполнять параллельно, то присваивание делает такое программирование более “опасным”. 

Представьте, что у нас есть библиотека для работы с банковским счетом, которая имеет три функции add, sub, get_account (пример носит иллюстративный характер):

account = 0

# Размещение денег на счете
def add(value):
    global account
    account += value

# Снятие денег со счета
def sub(value):
    global account
    account -= value

# Количество денег на счету
def get_account():
    global account
    return account

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

def session1():
    add(100)    # перевел деньги с другого счета    
    sleep(1)    # открыл страницу в интернет магазине...
    sub(30)     # совершил покупку
    print("First:", get_account())

Второй клиент смотрит: достаточно ли денег на счету, если да, то снимает 90 рублей:

def session2():    
    acc = get_account()

    if acc > 90:    # смотрит достаточно ли денег, если да, то ...
        sub(90)     # снимает нужную сумму

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

# Пока первый человек ищет товар в интернет магазине...
Thread(target=session1).start()
sleep(0.1)

# Второй смотрит достаточно ли денег на счете и
# снимает нужную сумму
Thread(target=session2).start()

# Немного подождем и посмотрим состояние счета
sleep(0.5)
print(get_account())

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

Проиллюстрируем эту ситуацию на диаграмме:

Моделирование с помощью потоков

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

С банковской системой также, внесение и снятие денег – это части, более сложного процесса и для того, чтобы его моделировать, мы вводим изменяемый объект – счет, и работаем с ним. 

А что если не рассматривать движение одной части системы относительно другой? Если рассматривать эволюцию сложной системы целиком? Попытаемся выдавить время из системы!

Для примера с банковским счетом, это означает, что теперь работа с “Сложным процессом” внесения и снятия денег будет происходить как с единой системой:

Обратите внимание, теперь у нас появилась возможность использовать подстановочную модель! Сколько бы раз мы не вызывали функцию определения состояния счета с аргументом: [+100, -40, +10, +10], всегда будем получать результат 80. У такого объекта нет состояния, и операция присваивания для моделирования не нужна.

С банковским счетом есть одна проблема, она заключается в том, что для клиентов, которые им пользуются, время никуда не делось, и для того, чтобы вызывать функцию определения баланса без использования состояния, нам придется отдельно завести реестр транзакций, в который будут сохраняться все операции. Мы избавились от времени для модели процесса определения текущего баланса, но оно перешло в систему “банк + клиент”, а отсюда его убрать уже нельзя, нам в любом случае придется сохранять операции клиента. 

Такой подход называется моделирование с помощью потоков. В рамках целевой системы (банковский счет) мы избавляемся от состояния и присваивания. Аргументом функции при таком типе моделирования будет не скалярное значение, а вектор. Для реализации банковской системы такой подход плохо применим, но для многих задач моделирования систем, физики, химии, автоматизации и т.п. – это отличный вариант более безопасного построения системы. Потоковая модель выполнения позволяет реализовывать системы с состоянием без присваивания.

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

P.S.

Вводные уроки по “Линейной алгебре на Python” вы можете найти соответствующей странице нашего сайта. Все уроки по этой теме собраны в книге “Линейная алгебра на Python”.
Книга: Линейная алгебра на Python
Если вам интересна тема анализа данных, то мы рекомендуем ознакомиться с библиотекой Pandas.  Для начала вы можете познакомиться с вводными уроками. Все уроки по библиотеке Pandas собраны в книге Pandas. Работа с данными”.
Книга: Pandas. Работа с данными

Поделиться
Share on VK
VK
Tweet about this on Twitter
Twitter
Share on Facebook
Facebook

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *