September 20, 2019

Мутабельность, и то, как держать ее в секрете.

Воспротивься стремлению к чистоте

Есть довольно распространенная идея в кругах любителей ФП, которая звучит примерно так: "Избегайте мутабельного состояния". Это хорошая идея, и есть множество статей, объясняющих, почему она хороша. Я в большей степени с ними согласен, но иногда люди за деревьями леса не видят, когда думают о цели, преследуемой этой идеей.

Так зачем мы хотим избежать мутабельности?

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

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

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

ФП и ясность

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

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

Главное отличие между двумя этими свойствами

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

Очень часто люди слишком много фокусируются на ясности и читаемости функционального программирования, потому что это самое явное его свойство. Когда мы пишем ФП код, особенно когда только привыкаем к этой парадигме, работая над небольшими участками, именно ясность ФП проявляется во всей красе.

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

Небольшой пример,

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

from itertools import reduce

def primes(mx):
    return reduce(lambda l, x: l if any(map(lambda d: x % d == 0, l)) else l + [x], range(2, mx))

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

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

Одна из причин тому - то, что reduce привносит императивный подход управления потоком, но в декларативной манере. В чисто функциональных языках программирования, таких, как Haskell, без этого не обойтись, а в не чисто функциональных языках (таких, как питон, в нашем случае) это является очень полезным инструментом. Но reduce чаще гораздо сложнее читается, чем императивная версия того же кода.

А теперь к императивному

Давайте теперь напишем этот код по-другому:

def primes(mx):
    acc = []
    for x in range(2, mx):
        found = False
        for d in acc:
            if x % d == 0:
                found = True
        if not found:
            acc.append(x)
    return acc

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

Итог

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

Давайте всегда будем стараться к разумному, а не слепо следовать написанной где-то идее!