Статьи

Введение в разумно чистое функциональное программирование

Эта статья была рецензирована Panayiotis «pvgr» Велисаракос , Джезен Томас и Флориан Раппл . Спасибо всем рецензентам SitePoint за то, что сделали контент SitePoint как можно лучше!

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

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

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

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

Чистые функции

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

// pure function add(a, b) { return a + b; } 

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

 // impure var minimum = 21; var checkAge = function(age) { return age >= minimum; // if minimum is changed we're cactus }; 

Эта функция нечиста, поскольку она зависит от внешнего изменяемого состояния вне функции.

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

 // pure var checkAge = function(age) { var minimum = 21; return age >= minimum; }; 

Чистые функции не имеют побочных эффектов . Вот несколько важных моментов, о которых следует помнить:

  • Доступ к состоянию системы вне функции
  • Мутирующие объекты, переданные в качестве аргументов
  • Выполнение HTTP-вызова
  • Получение пользовательского ввода
  • Опрос DOM

Контролируемая мутация

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

 // impure, splice mutates the array var firstThree = function(arr) { return arr.splice(0,3); // arr may never be the same again }; // pure, slice returns a new array var firstThree = function(arr) { return arr.slice(0,3); }; 

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

 let items = ['a','b','c']; let newItems = pure(items); // I expect items to be ['a','b','c'] 

Преимущества чистых функций

Чистые функции имеют несколько преимуществ перед своими нечистыми аналогами:

  • Легче тестировать, так как их единственная обязанность — отобразить ввод -> вывод
  • Результаты кэшируются, так как один и тот же вход всегда дает один и тот же результат
  • Самодокументирование как явные зависимости функции
  • С ним легче работать, так как вам не нужно беспокоиться о побочных эффектах

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

Неоправданно чистое функциональное программирование

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

 import _ from 'ramda'; import $ from 'jquery'; var Impure = { getJSON: _.curry(function(callback, url) { $.getJSON(url, callback); }), setHtml: _.curry(function(sel, html) { $(sel).html(html); }) }; var img = function (url) { return $('<img />', { src: url }); }; var url = function (t) { return 'http://api.flickr.com/services/feeds/photos_public.gne?tags=' + t + '&format=json&jsoncallback=?'; }; var mediaUrl = _.compose(_.prop('m'), _.prop('media')); var mediaToImg = _.compose(img, mediaUrl); var images = _.compose(_.map(mediaToImg), _.prop('items')); var renderImages = _.compose(Impure.setHtml("body"), images); var app = _.compose(Impure.getJSON(renderImages), url); app("cats"); 

Потратьте минуту, чтобы переварить код выше.

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

  • Функция app принимает строку тегов
  • выбирает JSON из Flickr
  • вытаскивает URL-адреса из ответа
  • строит массив узлов <img>
  • вставляет их в документ
 var app = (tags)=> { let url = `http://api.flickr.com/services/feeds/photos_public.gne?tags=${tags}&format=json&jsoncallback=?` $.getJSON(url, (data)=> { let urls = data.items.map((item)=> item.media.m) let images = urls.map((url)=> $('<img />', { src: url }) ) $(document.body).html(images) }) } app("cats") 

Или этот альтернативный API, использующий абстракции, такие как fetch и Promise помогает нам еще больше прояснить смысл наших асинхронных действий.

 let flickr = (tags)=> { let url = `http://api.flickr.com/services/feeds/photos_public.gne?tags=${tags}&format=json&jsoncallback=?` return fetch(url) .then((resp)=> resp.json()) .then((data)=> { let urls = data.items.map((item)=> item.media.m ) let images = urls.map((url)=> $('<img />', { src: url }) ) return images }) } flickr("cats").then((images)=> { $(document.body).html(images) }) 

Примечание: fetch и Promise являются будущими стандартами, поэтому они требуют использования полифилов сегодня.

Запрос Ajax и операции DOM никогда не будут чистыми, но мы могли бы сделать из всех функций чистую функцию, отображая ответ JSON в массив изображений — давайте пока извиним зависимость от jQuery.

 let responseToImages = (resp)=> { let urls = resp.items.map((item)=> item.media.m ) let images = urls.map((url)=> $('<img />', { src: url })) return images } 

Наша функция сейчас делает две вещи:

  • отображение ответных data -> urls
  • urls отображения -> images

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

 let urls = (data)=> { return data.items.map((item)=> item.media.m) } let images = (urls)=> { return urls.map((url)=> $('<img />', { src: url })) } let responseToImages = _.compose(images, urls) 

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

Вот что делает compose , передавая urls в нашу функцию images .

 let responseToImages = (data)=> { return images(urls(data)) } 

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

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

Код легче читать и понимать?

Основные функции

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

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

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

Массивы

функции

Меньше — больше

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

 let items = ['a', 'b', 'c']; let upperCaseItems = ()=> { let arr = []; for (let i = 0, ii = items.length; i < ii; i++) { let item = items[i]; arr.push(item.toUpperCase()); } items = arr; } 

Уменьшить зависимость функций от общего состояния

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

 // pure let upperCaseItems = (items)=> { let arr = []; for (let i = 0, ii = items.length; i < ii; i++) { let item = items[0]; arr.push(item.toUpperCase()); } return arr; } 

Используйте более читаемые языковые абстракции, такие как forEach для итерации

 let upperCaseItems = (items)=> { let arr = []; items.forEach((item) => { arr.push(item.toUpperCase()); }); return arr; } 

Используйте абстракции более высокого уровня, такие как map чтобы уменьшить объем кода

 let upperCaseItems = (items)=> { return items.map((item)=> item.toUpperCase()) } 

Приведите функции к их простейшим формам

 let upperCase = (item)=> item.toUpperCase() let upperCaseItems = (items)=> items.map(upperCase) 

Удалить код, пока он не перестанет работать

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

 let items = ['a', 'b', 'c'] let upperCaseItems = items.map((item)=> item.toUpperCase()) 

тестирование

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

Запустите терминал, и ваш текстовый редактор будет готов и готов. Мы будем использовать Mocha в качестве нашего тестового прогона и Babel для компиляции нашего кода ES6.

 mkdir test-harness cd test-harness npm init -yes npm install mocha babel-register babel-preset-es2015 --save-dev echo '{ "presets": ["es2015"] }' > .babelrc mkdir test touch test/example.js 

У Mocha есть множество удобных функций, таких как description, и it разбивать наши тесты и ловушки, такие как before и after для задач настройки и демонтажа. assert — это пакет базового узла, который может выполнять простые тесты на равенство, assert и assert.deepEqual — наиболее полезные функции, о которых следует знать.

Давайте напишем наш первый тест в test/example.js

 import assert from 'assert'; describe('Math', ()=> { describe('.floor', ()=> { it('rounds down to the nearest whole number', ()=> { let value = Math.floor(4.24) assert(value === 4) }) }) }) 

Откройте package.json и измените сценарий "test" следующим образом

 mocha --compilers js:babel-register --recursive 

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

 Math .floor ✓ rounds down to the nearest whole number 1 passing (32ms) 

Boom.

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

 mocha --compilers js:babel-register --recursive -w 

Тестирование нашего модуля Flickr

Давайте добавим наш модуль в lib/flickr.js

 import $ from 'jquery'; import { compose } from 'underscore'; let urls = (data)=> { return data.items.map((item)=> item.media.m) } let images = (urls)=> { return urls.map((url)=> $('<img />', { src: url })[0] ) } let responseToImages = compose(images, urls) let flickr = (tags)=> { let url = `http://api.flickr.com/services/feeds/photos_public.gne?tags=${tags}&format=json&jsoncallback=?` return fetch(url) .then((response)=> response.json()) .then(responseToImages) } export default { _responseToImages: responseToImages, flickr: flickr, } 

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

У нас есть пара новых зависимостей: jquery , underscore и polyfills для fetch и Promise . Чтобы проверить их, мы можем использовать jsdom чтобы заполнить window и document DOM объектов, и мы можем использовать пакет sinon для создания sinon API выборки.

 npm install jquery underscore whatwg-fetch es6-promise jsdom sinon --save-dev touch test/_setup.js 

Откройте test/_setup.js и мы настроим jsdom с нашими глобальными переменными, от которых зависит наш модуль.

 global.document = require('jsdom').jsdom('<html></html>'); global.window = document.defaultView; global.$ = require('jquery')(window); global.fetch = require('whatwg-fetch').fetch; 

Наши тесты могут находиться в test/flickr.js где мы сделаем утверждения о выводе наших функций при заданных входных данных. Мы «заглушаем» или переопределяем метод глобальной выборки для перехвата и фальсификации HTTP-запроса, чтобы мы могли запускать наши тесты, не обращаясь напрямую к API-интерфейсу Flickr.

 import assert from 'assert'; import Flickr from "../lib/flickr"; import sinon from "sinon"; import { Promise } from 'es6-promise'; import { Response } from 'whatwg-fetch'; let sampleResponse = { items: [{ media: { m: 'lolcat.jpg' } },{ media: { m: 'dancing_pug.gif' } }] } // In a real project we'd shift this test helper into a module let jsonResponse = (obj)=> { let json = JSON.stringify(obj); var response = new Response(json, { status: 200, headers: { 'Content-type': 'application/json' } }); return Promise.resolve(response); } describe('Flickr', ()=> { describe('._responseToImages', ()=> { it("maps response JSON to a NodeList of <img>", ()=> { let images = Flickr._responseToImages(sampleResponse); assert(images.length === 2); assert(images[0].nodeName === 'IMG'); assert(images[0].src === 'lolcat.jpg'); }) }) describe('.flickr', ()=> { // Intercept calls to fetch(url) and return a Promise before(()=> { sinon.stub(global, 'fetch', (url)=> { return jsonResponse(sampleResponse) }) }) // Put that thing back where it came from or so help me! after(()=> { global.fetch.restore(); }) it("returns a Promise that resolves with a NodeList of <img>", (done)=> { Flickr.flickr('cats').then((images)=> { assert(images.length === 2); assert(images[1].nodeName === 'IMG'); assert(images[1].src === 'dancing_pug.gif'); done(); }) }) }) }) 

Запустите наши тесты еще раз с npm test и вы увидите три гарантирующих зеленых галочки.

 Math .floor ✓ rounds down to the nearest whole number Flickr ._responseToImages ✓ maps response JSON to a NodeList of <img> .flickr ✓ returns a Promise that resolves with a NodeList of <img> 3 passing (67ms) 

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

Чистые функции, используйте их.

  • Наиболее подходящее руководство профессора Фрисби по функциональному программированию — @drboolean — Эта превосходная бесплатная книга Брайана Лонсдорфа о функциональном программировании — лучшее руководство по ФП, которое я встречал. Многие идеи и примеры в этой статье взяты из этой книги.
  • Eloquent Javascript — Функциональное программирование @marijnjh — Книга Марины Хавербеке остается одним из моих самых любимых вступлений в программирование и также имеет большую главу по функциональному программированию.
  • Подчеркивание — копание в служебную библиотеку, такую ​​как Underscore, Lodash или Ramda, является важным шагом в становлении как разработчика. Понимание того, как использовать эти функции, значительно сократит объем кода, который вам нужно написать, и сделает ваши программы более декларативными.

На этом пока все! Спасибо за чтение, и я надеюсь, что вы нашли это хорошее введение в функциональное программирование, рефакторинг и тестирование в JavaScript. В настоящее время это интересная парадигма, которая вызывает волнение, во многом благодаря растущей популярности таких библиотек, как React , Redux , Elm , Cycle и ReactiveX, которые поощряют или применяют эти шаблоны.

Прыгай, вода теплая.