Код
#статьи

Сдвиг парадигмы: JavaScript и переход от императивного программирования к функциональному

Объясняем, как писать код на JavaScript, чтобы избавиться от рантаймовых болячек.

 vlada_maestro / shutterstock

Александр Кузьмин

эксперт

об авторе

Работаю тимлидом в компании Devexperts, занимаюсь коммерческой frontend-разработкой более 11 лет. За это время довелось поработать с разными технологиями: от традиционных до более экзотических, таких как разработка приложений для SmartTV. Считаю, что программирование никогда не было и не будет простым занятием. Оно требует больших усилий для роста, и только через преодоление этого барьера можно достичь профессиональных высот.


Ссылки


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

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

Это всё накладывается на заложенную в JS «безотказность»: неявное приведение типов, два nullable-типа для разных случаев и отсутствие деления чисел на целые и дробные. Но такой код таит в себе большое количество подводных камней и неочевидных моментов, значительно усложняющих как процесс разработки, так и отладку.

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

Давно пора было признать, что JavaScript — монополист в мире веба, и научиться играть по его правилам, а не пытаться интерпретировать их через призму куда более консервативных языков. Сами посудите: ни одна технология, соперничавшая с ним за место под солнцем, не смогла конкурировать: ни старенький и немощный VBScript, ни более современный Dart, который компания Google ещё шесть лет назад проталкивала под флагом «убийцы JavaScript». Помимо них, был целый зоопарк синтаксического сахара, который умер с выходом новой версии стандарта. Всё оказалось тщетно, и мы вынуждены жить с тем, что имеем.

Более того, JavaScript активно полез на сервер, в десктоп и мобильную разработку, где плотно занял свои ниши, причём вполне законно и обоснованно. Дошло до того, что можно вполне успешно писать на JavaScript в приложении, написанном на JavaScript (спасибо GitHub за Electron и Atom, пророка его). Приди Dart на пять лет раньше, и этого, возможно, не случилось бы, но это время давно потеряно, и вряд ли когда-нибудь настанет благоприятный момент для перехода. К тому же, слезать с иглы NPM уже слишком больно и дорого.

Так что же делать? Есть различные варианты. Конечно, всегда можно переползти на Dart, продираясь через дикие заросли и осваивая целину. Не самая удачная затея, требующая вложения большого количества ресурсов, но в России есть целая одна крупная компания, где он используется в качестве производственного стандарта. Если же NPM совсем не отпускает (что вероятнее), можно прикрутить на привычный синтаксис типизацию в виде TypeScript или Flow.

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

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

Чистый JavaScript: инструкция для начинающего функционального сапёра

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

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

let a = 0;

function inc() {
    console.log(a);
    a += 1;
}

inc(); // 0
inc(); // 1
inc(); // 2
inc(); // 3
inc(); // 4
inc(); // 5
inc(); // 6
inc(); // 7
inc(); // 8
inc(); // 9

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

document
    .getElementById("increment")
    .addEventListener("click", inc);

Теперь вызов функции происходит при нажатии на кнопку c id="increment». Но что произойдёт, если между нажатиями на кнопку кто-то поменяет значение переменной a?

Да, выведется другой результат. Более того, язык позволяет нам заменить лежащее там число на всё, что угодно. И если в случае со строкой мы получим строку, на конце которой будет столько единиц, сколько раз мы нажмём на кнопку, то в случае с объектом или undefined мы получим значение NaN — волшебную вещь, которая получается при попытке применить арифметический оператор к значению, его не поддерживающему. Ещё одна логическая затычка.

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

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

(function () {
    let a = 0;

    function inc() {
        console.log(a);
        return (a += 1);
    }
  
    document
        .getElementById("increment")
        .addEventListener("click", inc);
}());

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

let a = 0;

function inc(a) {
    console.log(a);
    return a + 1;
}

a = inc(a);

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

Решается это достаточно просто:

const handleClick = () => {
    a = inc(a);
};

document
    .getElementById("increment")
    .addEventListener("click", handleClick);

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

function inc(a) {
    if (typeof a === 'number') {
        return a + 1;
    }
}

(function () {
    let a = 0;

    const handleClick = () => {
        console.log(a);
        a = inc(a);
    };

    document
        .getElementById("increment")
        .addEventListener("click", handleClick);
}());

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

Да, показанный пример в какой-то мере является вырожденным, ведь мы могли безболезненно затащить реализацию функции inc в handleClick. Поэтому прежде, чем мы перейдём к более сложным примерам, расскажу, почему я так не сделал.

Функциональные джедаи против императивных штурмовиков

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

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

В нашем случае handleClick — именно такая функция. Мы передали её в качестве обработчика события, и она хранится снаружи. Поэтому мы можем вызвать её и безболезненно изменять переменную a, даже если последняя скрыта от прямого доступа — ровно до тех пор, пока не отцепим обработчик от события.

Это работает, в первую очередь, благодаря тому, как формируется область видимости функции. В JavaScript существует понятие объект переменных, или Variable Object, в котором поимённо хранятся все доступные переменные и функции. В глобальной области видимости в браузере это объект window.

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

Этот факт скромно намекает на то, что язык был заточен под функции с самого начала, а значит, мы можем вообще весь код завернуть в них. Почему бы и нет? Ведь мы живём так уже очень давно — и по сей день: на каком-то этапе развития фронтенда держать код каждого файла целиком внутри самовызывающейся функции стало производственной нормой. С появлением и распространением модульных систем, таких как RequireJS и CommonJS, это вышло на новый уровень: код заворачивался в функции без участия разработчика.

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

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

Но ведь и в классических языках тоже можно всё обложить функциями, а современные версии некоторых из них уже даже предоставляют средства для создания замыканий. В чём же разница между ними и JavaScript?

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

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

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

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

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

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

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

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

Чтобы глубже понять то, о чём я только что сказал, рассмотрим ещё пару примеров.

Проекции, или функциональное преобразование массивов

Когда речь заходит про более сложные типы данных, в отличие от рассмотренных выше примитивов, количество проблем увеличивается. Начиная с того, что в JavaScript они передаются по ссылке, и заканчивая тем, что до выхода современного стандарта автоматизация работы с ними превращалась в ад. Особенно до 2011 года, пока в стандарте ECMAScript 5.1 не ввели расширения для стандартных структур данных.

Так, например, работа с массивами осуществлялась следующим образом:

var a = [1, 2, 3, 4, 5];
var b = [];

for (var i = 0; i < a.length; i++) {
    b[i] = inc(a[i]);
}

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

function map(a, fn) {
    var b = [];

    if (a instanceof Array) {
        for (var i = 0; i < a.length; i++) {
            b[i] = fn(a[i]);
        }
    }

    return b;
}

(function () {
    var a = [1, 2, 3, 4, 5];
    var b = map(a, inc); // [2, 3, 4, 5, 6]
    var c = map(b, inc); // [3, 4, 5, 6, 7]
}());

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

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

Точно такой же результат даст нам вызов на массиве метода map, появившегося в JavaScript 1.6:

var b = a.map(inc); // [2, 3, 4, 5, 6]

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

function isEven(x) {
    return x % 2 === 0;
}

function sum(a, b) {
    return a + b;
}

(function () {
    var a = [1, 2, 3, 4, 5];
    var b = a
        .map(inc)            // [2, 3, 4, 5, 6]
        .filter(isEven)      // [2, 4, 6]
        .reduce(sum, 0);     // 12
}());

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

Задача: считать количество кликов по кнопке, выводить сообщение, когда оно достигает 10, и начинать счёт заново.

Руководствуясь предыдущими рецептами, мы можем написать что-то такое:

(function () {
    let a = 0;

    const handleClick = () => {
        a = inc(a);
        if (a === 10) {
            console.log('10 clicks!');
            a = 0;
        }
    };

    document
        .getElementById('increment')
        .addEventListener('click', handleClick);
}());

И оно будет прекрасно работать. Правда, мы всё ещё держим для этого переменную. А если нам нужно реагировать на одни и те же события разным образом? Конечно, всегда можно добавить ещё одну функцию, поставить её в качестве обработчика и спокойно жить. Но мы можем не повторять код и поступить более элегантно. Нам нужно лишь воспользоваться шаблоном «Издатель — подписчик», также более известным под названием Observable-Observer.

Функциональный наблюдатель

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

class Observable {
    _subscribe = () => {};

    constructor(subscribe) { // function
        if (subscribe) {
            this._subscribe = subscribe;
        }
    }

    subscribe(subscriber) {
        this._subscribe(subscriber);
    }
}

Объект subscriber у нас пока обладает лишь функцией next, которая будет вызываться после каждого события, и полем value, в котором мы будем держать счётчик. Его мы передадим при подписке.

const nObserver = {
    value: 0,
    next() {
        this.value = inc(this.value);

        if (this.value === 10) {
            console.log('10 clicks!');
            this.value = 0;
        }
    },
};

И, наконец, нам нужно создать Observable и подписаться на него:

const nObservable = new Observable(subscriber => {
    document
        .getElementById('increment')
        .addEventListener('click', e => {
            subscriber.next(e);
        });
});

nObservable.subscribe(nObserver);

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

function normalizedRandom(a, b) {
    return Math.floor(Math.random() * (b - a)) + a;
}

function channel() {
    return normalizedRandom(0, 255);
}

const nObserver2 = {
    next(e) {
        e.target.style.background = `rgb(${channel()}, ${channel()}, ${channel()})`;
    },
};

setTimeout(() => {
    nObservable.subscribe(nObserver2);
}, 2000);

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

function normalizedRandom(a, b) {
    return Math.floor(Math.random() * (b - a)) + a;
}

function channel() {
    return normalizedRandom(0, 255);
}

const nObservable = new Observable(subscriber => {
    document
        .getElementById('increment')
        .addEventListener('click', e => {
            subscriber.next(e);
        });
});

nObservable.subscribe({
    value: 0,
    next() {
        this.value = inc(this.value);

        if (this.value === 10) {
            console.log('10 clicks!');
            this.value = 0;
        }
    },
});

setTimeout(() => {
    nObservable.subscribe({
        next(e) {
            e.target.style.background = `rgb(${channel()}, ${channel()}, ${channel()})`;
        },
    });
}, 2000);

Получилось более чем чисто, если не считать работы с DOM-элементами, но я выше упоминал, что изменения состояния интерфейса — вполне приемлемый побочный эффект. Более того, объект подписчика, в котором мы держим и значение, и обработчик, невозможно ни изменить, ни прочитать: он намертво скрыт внутри экземпляра класса Observable как часть замыкания, и явно нигде его значение не записывается.

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

Помимо неё, существуют библиотеки и для других языков: от C++ до Swift и Kotlin. Полный список можно посмотреть на GitHub проекта ReactiveX. Их обилие прекрасно показывает, что практически любой язык позволяет перейти от императивного подхода к функциональному при правильном его использовании.

Императивное заключение функциональной статьи

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

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

Как уже было сказано, отдельно стоит рассмотреть как типизацию данных в JavaScript, так и работу паттерна Observable-Observer. Более глубокое погружение в функциональное программирование может дать нам ещё больше преимуществ. Так, например, заимствование подхода языка Haskell поможет нам решить проблему с null и undefined, а также позволит обрабатывать ошибки без использования блока try {} catch.

Но обо всём об этом — как-нибудь в другой раз.

Проверьте свой английский. Бесплатно ➞
Нескучные задания: small talk, поиск выдуманных слов — и не только. Подробный фидбэк от преподавателя + персональный план по повышению уровня.
Пройти тест
Понравилась статья?
Да

Пользуясь нашим сайтом, вы соглашаетесь с тем, что мы используем cookies 🍪

Ссылка скопирована