Русский
Русский
English
Статистика
Реклама

Декларативное программирование

Из песочницы Urban Bot или как писать чат-ботов для Telegram, Slack, Facebook на React.js

27.07.2020 22:10:16 | Автор: admin

image


В этой статье я хочу познакомить с новой библиотекой Urban Bot, которая адаптирует React для написания чат-ботов. Ниже я расскажу, зачем эта библиотека появилась на свет, какие дает преимущества и как написать вашего первого чат-бота.


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

В отличии от большинства чат-бот библиотек, которые чаще всего просто оборачивают http запросы в функции с готовыми аргументами и предоставляют подписки вида bot.on('message', callback), иногда позволяя передавать контекст между вызовами, Urban Bot предлагает совершенно иной подход к разработке чат-ботов через декларативное программирование и компонентный подход. Живой пример, написанный на Urban Bot, вы можете попробовать в Telegram, cсылка на чат-бот, и посмотреть код на GitHub.


Как мы заметили выше, чат-боты это полноценные UI приложения. А какой язык в 2020 и какая библиотека наиболее подходит для разработки UI приложений? Правильно, JavaScript и React. Такая интеграция позволяет легко и непринужденно строить чат-боты любой сложности без единого знания об API мессенджеров. Далее я расскажу, как создавать простые компоненты и на их основе строить сложных чат-ботов, работать с навигацией, создавать диалоги любой вложенности, писать одно приложение и запускать в любых мессенджерах, и многое другое.


Отправка сообщений


Так выглядит самый простой пример на Urban Bot. Для отправки текстового сообщения нам нужно создать функцию и вернуть из него готовый компонент Text с текстом внутри, который мы хотим отправить. Когда компонент отрендериться, все пользователи чат-бота получат сообщение "Hello, world!".


import React from 'react';import { Text } from '@urban-bot/core';function App() {    return (        <Text>           Hello, world!        </Text>    );}

Изображение можно отправить так:


import React from 'react';import { Image } from '@urban-bot/core';function App() {    const imageByURL =  'https://some-link.com/image.jpg';    return <Image file={imageByURL} />;}

Urban Bot имеет готовый набор компонентов, для каждого вида сообщений, для файлов File, для кнопок ButtonGroup и много других, подробнее можно взглянуть здесь. В каждый из них можно передать определенный набор пропсов, например, имитировать будто бот печатает сообщение 1 секунду <Text simulateTyping={1000}>.


Получение сообщений


Мы рассмотрели как посылать сообщения, давайте разберемся как подписываться на сообщения от пользователей. За подписки в Urban Bot отвечают React Hooks.


Чтобы подписаться на текстовые сообщения, мы можем использовать хук useText.


import React from 'react';import { Text, useText } from '@urban-bot/core';function App() {    useText(({ text }) => {        console.log(`Пользователь отправил сообщение ${text}`);    });    return (        <Text>            Hello, world!        </Text>    );}

Urban Bot предоставляет готовы набор хуков для разных типов сообщений. Например, useImage, если пользователь отправил изображение, useFile и т.д. Полный список здесь. В каждый хук также приходит мета информация, кто отправил сообщение и т.д.


Эхо бот


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


В этом компоненте мы впервые добавим работу с переменными через React хук useState. Этот хук возвращает переменную и функцию, чтобы ее изменять. React.useState нужен, чтобы изменение переменной приводило к ререндеру и, соответсвенно, отправке нового сообщения. Мы определим начальное значение переменной text как "Привет" и передадим в компонент Text. Также мы подпишемся на сообщения от пользователей с помощью хука useText, и будем изменять text через функцию setText. После вызова setText React перерендерит компонент Echo с новым значением, и пользователь получит новое сообщение с тем что он сам отправил боту.


import React from 'react';import { Text, useText } from '@urban-bot/core';function Echo() {    const [text, setText] = React.useState('Привет!');    useText(({ text }) => {        setText(text);    });    return (        <Text>            {text}        </Text>    );}

Кнопки


Давайте также напишем пример с кнопками, сделаем простейший счетчик. Для этого нам понадобятся компоненты ButtonGroup и Button. Каждой кнопке мы определим свой обработчик, который будет менять count на +1 или -1 и будем передавать результат в ButtonGroup в проп title. Мы установим проп isNewMessageEveryRender как false, чтобы при последующих ререндерах отправлялось не новое сообщение с новыми кнопками, а просто изменялось начальное сообщение.


import React from 'react';import { ButtonGroup, Button } from '@urban-bot/core';function Counter() {    const [count, setCount] = React.useState(0);    const increment = () => setCount(count + 1);    const decrement = () => setCount(count - 1);    return (        <ButtonGroup title={count} isNewMessageEveryRender={false}>            <Button onClick={increment}>+1</Button>            <Button onClick={decrement}>-1</Button>        </ButtonGroup>    );}


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


Теперь, когда пользователь напишет "/echo" отрендериться компонент Echo, когда "/counter" управление перейдет в Counter. Роуты также могут принимать path как regexp.


import React from 'react';import { Router, Route } from '@urban-bot/core';import { Echo } from './Echo';import { Counter } from './Counter';function App() {    return (        <Router>            <Route path="/echo">                <Echo />            </Route>            <Route path="/counter">                <Counter />            </Route>        </Router>    );}

Визуальный пример, чтобы увидеть как будет работать код.


image


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


Форматирование текста


Urban Bot позволяет стилизовать сообщения через привычные HTML теги. Писать жирным <b>, курсивом <i>, зачеркнутым <s>, переносить строки <br /> и так далее, полный список здесь.


Пример
const someCode = `function sum2() {    return 2 + 2;}if (sum2() !== 4) {    console.log('WTF');}`;<Text>    Usual text    <br />    <b>Bold text</b>    <br />    <i>Italic text</i>    <br />    <u>Underscore text</u>    <br />    <s>Strikethrough text</s>    <br />    <q>quote</q>    <br />    <b>        Bold and <s>Strikethrough text</s>    </b>    <br />    <code >Code 2 + 2</code >    <br />    <pre>{someCode}</pre>    <br />    <a href="http://personeltest.ru/aways/github.com/urban-bot/urban-bot">External link</a></Text>


Диалоги


Чат-боты могут вести диалоги с пользователем, что-то спрашивать, реагировать на сообщения в зависимости от ответа пользователя. В Urban Bot встроен компонент Dialog, который позволяет легко создавать диалоги любой сложности.


Пример плоского диалога. Бот задаст первый вопрос, после ответа задаст второй, потом третий, потом запустит callback onFinish с готовыми ответами. На каждом шаге можно обрабатывать ответ пользователя через callback onNext.


import React from 'react';import { Dialog, DialogStep, Text } from '@urban-bot/core';function FlatDialogExample() {    return (        <Dialog onFinish={(answers) => console.log(answers)}>            <DialogStep                content={<Text>Привет, как тебя зовут?</Text>}                 id="name"                onNext={(name) => console.log(name)}            >                <DialogStep                    content={<Text>Cколько тебе лет?</Text>}                    id="age"                >                    <DialogStep                         content={<Text>Из какого ты города?</Text>}                        id="city"                    />                </DialogStep>            </DialogStep>        </Dialog>    );}

Можно получать на следующем шаге прошлый ответ через паттерн render-props .


function FlatDialogExample() {    return (        <Dialog>            <DialogStep content={<Text>Привет, как тебя зовут?</Text>}>                {(name) => (                    <DialogStep                         content={<Text>{`${name}, cколько тебе лет?`}</Text>}                    />                )}            </DialogStep>        </Dialog>    );}

Можно добавить валидацию на каждый шаг.


function FlatDialogExample() {    return (        <Dialog onFinish={(answers) => console.log(answers)}>            <DialogStep                content={<Text>Привет, как тебя зовут?</Text>}                id="name"                validation={{                     isValid: (answer) => answer !== 'Самуэль',                     errorText: 'Самуэль заблокирован.'                 }}            >                // ...            </DialogStep>        </Dialog>    );}

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


import React from 'react';import { Dialog, DialogStep, Text, ButtonGroup, Button } from '@urban-bot/core';function TreeDialogExample() {    return (        <Dialog>            <DialogStep                content={                    <ButtonGroup title="Привет, что вы хотите купить?">                        <Button id="hat">Футболка</Button>                        <Button id="glasses">Очки</Button>                    </ButtonGroup>                }            >                <DialogStep                    match="hat"                    content={                        <ButtonGroup title="Футболка какого размера?">                            <Button id="m">S</Button>                            <Button id="s">M</Button>                            <Button id="l">L</Button>                        </ButtonGroup>                    }                />                <DialogStep                    match="glasses"                    content={                        <ButtonGroup title="Очки какого цвета?">                            <Button id="black">Черный</Button>                            <Button id="white">Белый</Button>                        </ButtonGroup>                    }                />            </DialogStep>        </Dialog>    );}

Состояние


Что вы можете использовать для управления состоянием? Все то же что и в любом React приложении. Можете использовать React useState и передавать состояние ниже по дереву компонентов через пропсы или React Context. Можно использовать библиотеки для управления состоянием: Redux (пример), MobX (пример), Apollo и любые другие, которые обычно используют вместе с React, вы можете даже переиспользовать готовые части из готовых React Web или React Native приложений, так как Urban Bot использует тот же чистый React, который работает в миллионах приложений.


Сессия


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


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


function Counter() {    const [count, setCount] = React.useState(0);    const increment = () => setCount(count + 1);    const decrement = () => setCount(count - 1);    return (        <ButtonGroup title={count} isNewMessageEveryRender={false}>            <Button onClick={increment}>+1</Button>            <Button onClick={decrement}>-1</Button>        </ButtonGroup>    );}

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


function Counter() {    const [count, setCount] = useGlobalCount();   // ...}

Также, в любом месте приложения мы можем получить данные о конкретном чате, какой пользователь написал, с каким никнеймом и т.д. Если чат-бот персональный, то chat и from будут совпадать.


import React from 'react';import { Text, useText, useBotContext } from '@urban-bot/core';function UserId() {    const { chat } = useBotContext();    useText(({ from }) => console.log(`Пришло сообщение от ${from.username}`));   return <Text>Чат id {chat.id}</Text>;}

Типизация


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


Запуск в мессенджерах


Большой плюс Urban Bot, что он не привязан ни к одному мессенджеру. Есть основной пакет @urban-bot/core, который позволяет создавать абстрактных чат-ботов, а уже их подключать к определенным мессенджерам. В данный момент есть поддержка Telegram, Slack, Facebook. В дальнейшем, мы планируем добавлять любые мессенджеры, где есть чат-боты и открытое API. Если вам интересно, и вы хотите писать Urban Bot приложения для других мессенджеров, скажем Viber, Discord или у вас есть свой мессенджер то пишите к нам в группу https://t.me/urbanbotjs, одной просьбы будет достаточно, чтобы появилось большая мотивация реализовать ваш функционал.


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


Скажем, у нас есть готовое приложение App и мы хотим его запустить его в Telegram. Для этого нам понадобится класс UrbanBotTelegram из пакет @urban-bot/telegram. Функция render из @urban-bot/core подобная ReactDOM.render и компонент Root. Мы создаем экземпляр UrbanBotTelegram и передаем туда бот токен из Telegram, также можно передать isPolling, чтобы не настраивать вебхук, и бот работал локально. Готовый экземпляр мы передаем в компонент Root, и оборачиваем наше готовое приложение и, соответсвенно, передаем все в функцию render, которая запустит все процессы.


Более подробная инструкция с видео как разрабатывать локально и деплоить Telegram бота можно прочитать в инструкции.


import React from 'react';import { render, Root } from '@urban-bot/core';import { UrbanBotTelegram } from '@urban-bot/telegram';import { App } from './App';const urbanBotTelegram = new UrbanBotTelegram({    token: 'telegramToken',    isPolling: true,});render(    <Root bot={urbanBotTelegram}>        <App />    </Root>);

Если нам нужно запустить два и более мессенджера, просто используем пакеты других мессенджеров, передаем туда нужные данные и делаем по аналогии с Telegram.


// ...import { UrbanBotSlack } from '@urban-bot/slack';// ...render(    <Root bot={urbanBotTelegram}>        <App />    </Root>);const urbanBotSlack = new UrbanBotSlack({    signingSecret: 'slackSigningSecret',    token: 'slackToken',});render(    <Root bot={urbanBotSlack}>        <App />    </Root>);

Прямой вызов API


С помощью Urban Bot вы можете создавать чат-ботов просто описывая их через компоненты. А что если вам будет нужно вручную вызвать API? Каждый экземпляр UrbanBot* содержит в себе API клиент для активного мессенджера. Рассмотрим пример для Telegram.


Мы можем получить bot с помощью хука useBotContext. bot содержит client и type c типом мессенджера. client будет представлять собой экземпляр библиотеки node-telegram-bot-api . В любом месте приложения можно получить client и вызвать любой метод на ваше усмотрение, скажем блокировать пользователя, если он написал нецензурное сообщение.


import React from 'react';import { useText, useBotContext } from '@urban-bot/core';function SomeComponent() {    const { bot } = useBotContext();    useText(({ text, chat, from }) => {        if (text.includes('бл***')) {            bot.client.kickChatMember(chat.id, from.id);        }    });    // ...}

В каждом мессенджере уникальный API. Если вы разрабатываете несколько мессенджеров одновременно, можно отделять функционал сравнивая bot.type.


import { useBotContext } from '@urban-bot/core';import { UrbanBotTelegram } from '@urban-bot/telegram';import { UrbanBotSlack } from '@urban-bot/slack';function SomeComponent() {    const { bot } = useBotContext();    if (bot.type === UrbanBotTelegram.type) {        // telegram api        bot.client.kickChatMember(/* ... */);    }    if (bot.type === UrbanBotSlack.type) {        // slack api        bot.client.conversations.kick(/* ... */);    }    // ...}

Как попробовать?


У Urban Bot есть стартер, который позволит вам начать разрабатывать чат-ботов за минуту, сделан по аналогии с create-rect-app. Все что вам нужно, чтобы попробовать Urban Bot это выполнить команду в терминале для


TypeScript


npx create-urban-bot my-app

JavaScript


npx create-urban-bot my-app --template js

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


Итого


Несмотря на то что Urban Bot запустился только недавно, на мой взгляд это библиотека с огромным потенциалом. Только представьте, если у вас есть базовые знания React, вы можете написать чат-бот любой сложности на все возможные платформы, создавать и использовать библиотеки с готовым набором компонентов на манер ui-kit, переиспользовать код между вашими другими UI приложеними на React, будь то web или mobile.


Если вы уже разрабатывали чат-ботов, попробуйте Urban Bot, вы почувствуете как библиотека делает за вас кучу работы. Если никогда не разрабатывали, но имеете представление о React, то напишете вашего первого чат-бота за 5 минут. Если вам понравилась идея и хотите, чтобы проект развивался дальше, вступайте в нашу группу в Telegram, ставьте звезду на гитхабе, будем рады любому фидбеку.


Полезные ссылки


Сайт
Github
Группа в Telegram
Наглядный чат-бот в Telegram, с открытым кодом.
Как создать Todo List чат-бот в Telegram с помощью React.js

Подробнее..

Шпаргалка по функциональному программированию

19.03.2021 10:13:30 | Автор: admin

Привет, меня зовут Григорий Бизюкин, я преподаватель Школы разработки интерфейсов и фронтенд-разработчик в Яндексе. Давайте поговорим о функциональном программировании в мире JavaScript. Мы все про ФП что-то слышали, нам всем оно интересно, но у меня, когда я искал полезные материалы для подготовки к лекциям, сложилось такое впечатление: есть куча статей, каждая из которых либо говорит об ФП общими словами, либо раскрывает отдельный маленький кусочек темы, чего, конечно, недостаточно.



Добавим функционального света


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


Оглавление

Функциональное программирование


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


Важно понимать, что когда на глаза попадается очередная статья Почему разработчик обязательно должен знать ФП, то автор, вероятно, говорит именно о нескольких подходах из мира ФП, которые можно применить у себя на проекте, чем о том, что вам пора пересесть за Haskell.


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


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


За и против


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


В целом считается, что ФП делает код понятнее, потому что является более декларативным. Остальные рассуждения оставим за скобками, так как на Хабре уже достаточно статей, где рассмотрены разные аргументы как за ФП, так и против. При желании можно обратиться к ним, чтобы решить для себя, когда вы хотите использовать ФП, а когда нет. Здесь мы сосредоточимся на объяснении терминов и подходов.


Императивный vs декларативный


Императивный подход говорит о том, как решать задачу, декларативный что хотим получить в результате.


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


В разработке та же история. Когда мы пишем декларативно, код выглядит гораздо проще:


const array = [4, 8, 15, null, 23, undefined]// императивный подходconst imperative = []for (let i = 0, len = array.length; i < len; ++i) {    if (array[i]) {        imperative.push(array[i])    }}// декларативный подходconst declarative = array.filter(Boolean)

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


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


/* css */.button {    color: azure;}

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


Такая же история и с SQL:


-- SQLSELECT titleFROM filmsWHERE rating > 9GROUP BY director

Запрос говорит о результате, а не о том, как именно его выполнить.


Функции и процедуры


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


const f = (x) => x * Math.sin(1 / x)

Процедура, в свою очередь, вызывается ради побочных эффектов:


const print = (...args) => {    const style = 'color: orange;'    console.log('%c' + args.join('\n'), style)}

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


В JS не существует процедур, потому что то, что мы считаем процедурой, на самом деле является функцией без return. Если опустить return, функция всё равно неявно возвращает undefined и остаётся функцией.


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


Параметры и аргументы


Параметры это переменные, созданные в объявлении функции. Аргументы конкретные значения, переданные при вызове.


// x  параметр (почти любое число)const f = (x) => x * Math.sin(1 / x)// 0.17  аргумент (конкретное число)f(0.17)

Сигнатура


Количество, тип и порядок параметров. Объявление функции в JS не содержит информации о типе параметров из-за динамической типизации. Если не используется TypeScript, эту информацию можно указать через JSDoc.


/** * @param {*} value * @param {Function|Array<string>|null} [replacer] * @param {number|string|null} [space] * @returns {string} */function toJSON (value, replacer, space) {    return JSON.stringify(value, replacer, space)}

Арность


Арность количество параметров, которые принимает функция. В JavaScript арность функции можно определить при помощи свойства length.


const awesome = (good, better, theBest) => {}awesome.length // 3

У свойства length есть особенности, которые следует учитывать:


// аргументы по умолчаниюconst defaultParams = (answer = 42) => {}defaultParams.length // 0// остаточные параметрыconst restParams = (...args) => {}restParams.length // 0// деструктуризацияconst destructuring = ({target}) => {}destructuring.length // 1

Рекурсия


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


function factorial (n) {    if (n <= 1) {        return 1    }    return n * factorial(n - 1)}

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


function factorial (n, total = 1) {    if (n <= 1) {        return total    }    return factorial(n - 1, n * total)}

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


Функция первого класса


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


// присваиватьconst assign = () => {}// передаватьconst passFn = (fn) => fn()// возвращатьconst returnFn = () => () => {}

Функция высшего порядка


Функции, которые принимают или возвращают другие функции. С ними мы работаем каждый день.


// map, filter, reduce и т.д.[0, NaN, Infinity].filter(Boolean)// обещанияnew Promise((res) => setTimeout(res, 300))// обработчики событийdocument.addEventListener('keydown', ({code, key}) => {    console.log(code, key)})

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


Предикат


Это функция, которая возвращает логическое значение. Самый распространённый пример использование предиката внутри функций filter, some, every.


const array = [4, 8, 15, 16, 23, 42]// isEven  это предикатconst isEven = (x) => x % 2 === 0const even = array.filter(isEven)

Замыкание


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


const createCounter = tag => count => ({    inc () { ++count },    dec () { --count },    val () {        console.log(`${tag}: ${count}`)    }})const pomoCounter = createCounter('pomo')const work = pomoCounter(0)work.inc()work.val() // pomo: 1const rest = pomoCounter(4)rest.dec()rest.val() // pomo: 3

В примере внутри замыкания хранятся две переменные: tag и count. Каждый раз, когда мы создаём новую переменную внутри другой функции и возвращаем её наружу, функция находит переменную, объявленную во внешней функции, через замыкание. Если тема замыканий кажется чем-то загадочным, почитайте о них подробнее в блоге HTML Academy.


Мемоизация


Полезный приём функция кеширует результаты своего вызова:


const memo = (fn, cache = new Map) => param => {    if (!cache.has(param)) {        cache.set(param, fn(param))    }    return cache.get(param)}const f = memo((x) => x * Math.sin(1 / x))f(0.314) // вычислитьf(0.314) // взять из кеша

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


Конвейер и композиция


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


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


Конвейер


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


# вывести идентификаторы процессов с подстрокой kernelps aux | grep 'kernel' | awk '{ print $2 }'

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


const double = (n) => n * 2const increment = (n) => n + 1// без конвейерного оператораdouble(increment(double(double(5)))) // 42// с конвейерным оператором5 |> double |> double |> increment |> double // 42

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


pipe(double, double, increment, double)(5) // 42

Аргумент, переданный в конвейер, последовательно проходит слева направо:


// 5 -> 10 -> 20 -> 21 -> 42

Хм, а что если запустить конвейер в другую сторону?


Композиция


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


// композиция функций в чистом видеdouble(increment(double(double(5)))) // 42

Если записать то же самое через вспомогательную функцию compose, получится:


compose(double, increment, double, double)(5)

Внешне всё осталось почти так же, но место вызова функции increment изменилось, потому что теперь цепочка вычислений стала работать справа налево:


// 42 <- 21 <- 20 <- 10 <- 5

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


// оригинальная цепочка вызововone(two(three(x)))// более естественно с точки зрения чтенияpipe(three, two, one)(x)// более естественно с точки зрения записиcompose(one, two, three)(x)

Таким образом, конвейер и композиция это два направления одного потока данных.


Преимущества


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


Создание новых абстракций


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


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


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


// готовые кубикиconst words = str => str    .toLowerCase().match(/[а-яё]+/g)const unique = iter => [...new Set(iter)]const text = `Съешь ещё этих мягкихфранцузских булок, да выпей же чаю`const foundWords = words(text)const uniqueWords = unique(wordsFound)

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


function getUniqueWords (text) {    return unique(words(text))}const uniqueWords = getUniqueWords(text)

Сила композиции в том, что с её помощью можно создавать новые абстракции гораздо проще и удобнее:


// создаём новую деталь через композициюconst getUniqueWords = compose(unique, words)const uniqueWords = getUniqueWords(text)

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


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


const sort = iter => [...iter].sort()// новая деталь, которая пригодится для новых построекconst getSortedUniqueWords = compose(sort, getUniqueWords)const sortedUniqueWords = getSortedUniqueWords(text)

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


// вложенная композицияcompose(sort, compose(unique, words))// линейная композицияcompose(sort, unique, words)

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


Бесточечный стиль


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


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


// стиль с параметрамиfunction getUniqueWords (text) {    return unique(words(text))}// стиль без параметров (бесточечный стиль)const getUniqueWords = compose(unique, words)

При работе со стилем без параметров функция не упоминает данные, которые мы обрабатываем.


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


Ограничения


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


const translate => (lang, text) => magicSyncApi(lang, text)const getTranslatedWords = compose(translate, unique, words)getTranslatedWords(text) // упс... что-то сломалось

Здесь на помощь приходит частичное примирение и каррирование, о которых мы поговорим позже.


Пишем сами


Реализовать конвейер можно было бы так:


const pipe = (...fns) => (x) => fns.reduce((v, f) => f(v), x)

Чтобы реализовать композицию, достаточно заменить reduce на reduceRight:


const compose = (...fns) => (x) => fns.reduceRight((v, f) => f(v), x)

Как на практике?


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


На проекте с Redux композиция наверняка будет использоваться для middleware, потому что createStore принимает только один усилитель (enhancer), а их, как правило, требуется хотя бы несколько.


// композиция в reduxconst store = createStore(    reducer,    compose(        applyMiddleware(...middleware),        DevTools.instrument(),    ))

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


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


const notifications = [    { text: 'Warning!', lang: 'en', closed: true },    { text: 'Внимание!', lang: 'ru', closed: false },    { text: 'Attention!', lang: 'en', closed: false }]// goodnotifications.filter((notification) => {    // ...проверить все условия})// betternotifications    .filter(isOpen)    .filter(isLang)// the bestcompose(    isLang,    isOpen)(notifications)

Частичное применение и каррирование


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


Для демонстрации работы частичного применения и каррирования будем использовать такую незамысловатую функцию:


const sum = (x, y, z) =>    console.log(x + y + z)

Частичное применение


Преобразует функцию в одну функцию с меньшим числом параметров.


const partialSum = partial(sum, 8)partialSum(13, 21) // 42

Каррирование


Преобразует функцию в набор функций с единственным параметром.


const curriedSum = curry(sum)curriedSum(8)(13)(21) // 42

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


curriedSum(8, 13)(21) // 42curriedSum(8, 13, 21) // 42

В чём разница?


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


const partialSum = partial(sum, 42)partialSum() // NaN, потому что 42 + undefined + undefined

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


const curriedSum = curry(sum)curriedSum(8) // новая функция  sum(8)curriedSum(8)(13) // ещё одна новая функция  sum(8, 13)curriedSum(8)(13)(21) // 42, потому что набралось нужное число аргументов

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


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


Решение задачи с композицией


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


const translate => (lang, text) => magicSyncApi(lang, text)// через частичное применениеconst english = partial(translate, 'en')// через каррированиеconst english = curry(translate)('en')// создать новую деталь с возможностью переводаconst getTranslatedWords = compose(english, unique, words)getTranslatedWords(text) // теперь всё работает

Порядок данных


Частичное применение и каррирование чувствительны к порядку данных. Существует два подхода к порядку объявления параметров.


// сперва итерация, затем данные (iterate-first data-last)const translate => (lang, text) => /* */// сперва данные, затем итерация (data-first, iterate-last)const translate => (text, lang) => /* */

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


function flip (fn) {    return (...args) => fn(...args.reverse())}const curryRight = compose(curry, flip)const partialRight = compose(partial, flip)

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


Специализация


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


const fetchApi = (baseUrl, path) =>    fetch(`${baseUrl}${path}`)        .then(res => res.json())

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


// каррированиеconst fetchCurry = curry(fetchApi)const fetchUnsplash = fetchCurry('https://api.unsplash.com')const fetchRandomPhoto = fetchUnsplash(fetchApi, '/photos/random')// частичное применениеconst fetchUnsplash = partial(fetchApi, 'https://api.unsplash.com')const fetchRandomPhoto = partial(fetchUnsplash, '/photos/random')

Пишем сами


Свою версию частичного применения можно написать примерно так:


function partial (fn, ...apply) {    return (...args) => fn(...apply, ...args)}

Каррирование выглядит немного сложнее:


function curry (fn) {    return (...args) => args.length >= fn.length ?        fn(...args) : curry(fn.bind(null, ...args))}

Как на практике?


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


А ещё в JavaScript у функций есть метод .bind, который реализует частичное применение из коробки, поэтому, если порядок параметров позволяет, то вуаля:


const fetchApi = (baseUrl, endpoint) =>    fetch(`${baseUrl}${endpoint}`)        .then(res => res.json())const fetchUnsplash = fetchApi.bind(null, 'https://api.unsplash.com')const fetchRandomPhoto = fetchUnsplash.bind(null, '/photos/random')

Неизменяемые данные


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


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


// mutable glassconst takeGlass = (volume) => ({    look () { console.log(volume) },    drink (amount) {        volume = Math.max(volume - amount, 0)        return this    }})const mutable = takeGlass(100)mutable.drink(20).drink(30).look() // 50mutable.look() // 50

С неизменяемыми структурами данных совершенно другая история. Перед тем, как сделать глоток, создаётся точная копия стакана, и мы пьём уже из копии. Таким образом, после первого глотка у нас будет два стакана: один полный, из другого мы немного отпили. Исходный стакан останется без изменений. Это и есть неизменяемые данные.


// immutable glassconst takeGlass = (volume) => ({    look () { console.log(volume) },    drink (amount) {        return takeGlass(Math.max(volume - amount, 0))    }})const immutable = takeGlass(100)immutable.drink(20).drink(30).look() // 50immutable.look() // 100

Преимущества неизменяемых структур данных:
предсказуемое изменение состояния,
быстрое сравнение по ссылке,
кешируемость,
легко распараллеливать,
легко тестировать.


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


Нечаянное мутирование данных


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


function sortArray (array) {    return array.sort()}const fruits = ['orange', 'pineapple', 'apple']const sorted = sortArray(fruits)// упс... исходный массив тоже изменилсяconsole.log(fruits) // ['apple', 'orange', 'pineapple']console.log(sorted) // ['apple', 'orange', 'pineapple']

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


const object = {}// const означает константную ссылкуobject = {} // TypeError: Assignment to constant variable// но сам объект можно беспрепятственно изменятьobject.value = 42 // мутация объекта

Все ссылочные типы: объекты, массивы и другие всегда передаются по ссылке. Во время присваивания или передачи параметра происходит копирование ссылки, но не самих данных.


const array = []// копия ссылкиconst ref = arrayref.push('apple')// ещё одна копия ссылкиconst append = (ref) => {   ref.push('orange')}append(array)// массив дважды мутирован через ссылкуconsole.log(array) // [ 'apple', 'orange' ]

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


const object = { val: 42, ref: {} }const frozen = Object.freeze(object)// игнорирование ошибки без 'use strict'// или же TypeError: Cannot assign to read only property...frozen.val = 23// мутирование вложенных данных по ссылкеfrozen.ref.boom = 'woops'console.log(frozen) // { val: 42, ref: { boom: 'woops' }

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


const object = { val: 42, ref: {} }const proxy = new Proxy(object, {    set () { return true },    deleteProperty () { return true }})// изменение или удаление свойства не сработаетproxy.val = 19delete proxy.val// точно так же, как и добавление новогоproxy.newProp = 23// но вложенные объекты всё ещё мутабельныproxy.object.boom = 'woops'console.log(proxy) // { value: 42, ref: { boom: 'woops' } }

В общем, в JavaScript нельзя просто так взять и защитить данные от непреднамеренного изменения.


Затраты на копирование


С копированием данных тоже не всё так просто. В большинстве случаев работает копирование массивов и объектов встроенными средствами JavaScript:


const array = [4, 8, 15, 16, 23]const object = { val: 42 }// создать новый объект или массив[].concat(array)Object.assign({}, oject)// но через деструктуризацию удобнее[...array]{...object}

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


const object = { val: 42, ref: {} }const copy = { ...object }copy.val = 23copy.ref.boom = 'woops'console.log(object) // { val: 42, ref: { boom: 'woops' }

Такая же история с функциональными методами массивов map и filter создают поверхностную копию исходного массива.


const array = [null, 42, {}]const copy = array.filter(Boolean)copy[0] = 23copy[1].boom = 'woops'console.log(array) // [ null, 42, { boom: 'woops' } ]console.log(copy) // [ 23, { boom: 'woops' }

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


The problems of shared mutable state and how to avoid them
What is the most efficient way to deep clone an object in JavaScript?


Неизменяемые структуры данных (persistent data structures)


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


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


import produce from 'immer';const object = { ref: { data: {} } };const immutable = produce(object, (draft) => {  draft.ref.boom = 'woops';});console.log(object) // { ref: { data: {} }console.log(immutable) // { ref: { data: {}, boom: 'woops' }console.log(object.ref.data === immutable.ref.data) // true

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


Как на практике?


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


const addTodo = (state = initState, action) => {    switch (action.type) {        case ADD_TODO: {            return {                ...state,                todos: [...state.todos, action.todo]            }        }        default: {            return state;        }    }}

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


import produce from 'immer'const addTodo = (state = initState, action) =>    produce(state, draft => {        switch (action.type) {            case ADD_TODO: {                draft.todos.push(action.todo)                break            }        }    })

Чистые функции (pure functions)


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


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


Побочные эффекты (side effects)


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


function impure () {    // логирование    console.log('side effects')    // запись в файл    fs.writeFileSync('log.txt', `${new Date}\n`, 'utf8')    // запрос на сервер и т. д.    fetch('/analytics/pixel')}

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


Работа с глобальными переменными тоже побочный эффект.


function impure () {    // глобальная переменная    app.state.hasError = true}

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


function impure () {    // модификация DOM    document.getElementById('menu').hidden = true    // установка обработчика    window.addEventListener('scroll', () => {})    // запись в локальное хранилище    localStorage.setItem('status', 'ok')}

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


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


function impure (o) {    return Object.defineProperty(o, 'mark', {        value: true,        enumerable: true,    })}const object = {}const marked = impure(object)// defineProperty мутировала исходный объектconsole.log(object) // { mark: true }

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


Зависимость от параметров


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


function impure () {    // глобальная переменная    if (NODE_ENV === 'development') { /* */ }    // чтение данных из DOM    const { value } = document.querySelector('.email')    // обращение к локальному хранилищу    const id = localStorage.getItem('sessionId')    // чтение из файла и т. д.    const text = fs.readFileSync('file.txt', 'utf8')}

Внешние зависимости можно заменить на зависимость от параметров.


Непредсказуемый результат


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


function impure (min, max) {    return Math.floor(Math.random() * (max - min + 1) + min)}impure(1, 10) // 4impure(1, 10) // 2

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


function pure (min, max, random = Math.random()) {    return Math.floor(random * (max - min + 1) + min)}pure(1, 10, 0.42) // 5pure(1, 10, 0.42) // 5

Теперь функция всегда возвращает один и тот же результат для одних и тех же параметров.


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


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


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


const refTransparency = () =>    Math.pow(2, 5) + Math.sqrt(100)// вызов функцииrefTransparency()// можно раскрытьMath.pow(2, 5) + Math.sqrt(100)// и без особых трудностей понять результат32 + 10 // 42

Так почему бы всё не написать на чистых функциях?


Абсолютная и относительная чистота


Если взять и написать программу только из чистых функций, то получится:


(() => {})() // абсолютная чистота

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


// побочные эффекты выносятся за пределыconst text = fs.readFileSync('file.txt', 'utf8')// функция получает нужные данные только через параметрыfunction pure (text) {    // ... чистота}

Кроме того, чистота относительна. Функция ниже чистая или нет?


// pure или impure?function circleArea (radius) {    return Math.PI * (radius ** 2)}

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


Заключение


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


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


Жаргон функционального программирования
Functional-Light JavaScript
Mostly adequate guide to Functional Programming


Кроме того, загляните в репозиторий Awesome FP JS, вдруг найдёте что-то интересное для себя. Если же захочется целиком погрузиться в функциональную парадигму, но при этом продолжать разрабатывать фронтенд, можно посмотреть в сторону Ramda или Elm.


Спасибо за внимание!

Подробнее..

Роль логического программирования, и стоит ли планировать его изучение на 2021-й

22.12.2020 00:22:49 | Автор: admin

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

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

"Мда" - думаете Вы, и этим все сказано. Сложно! И тут наш отважный герой должен бы был перейти по второй ссылке, но я позволю себе сделать небольшую вставку, описав главное действующее лицо: Вы, по моей задумке, новичок в программировании, а даже если и нет, то точно не знакомы с логическим его обличием. Если же читатель уже несколько (или даже много) искушен знаниями в этой области, то рекомендую прочитать статью Что такое логическое программирование и зачем оно нам нужно, раз уж в вас горит интерес и любопытство к теме, а изучение материала ниже оставьте менее опытным коллегам.

Итак, пришло время второй ссылки. Что это будет? Статья на Хабре? Может быть статья на ином ресурсе? Прочитав пару первых абзацев на разных сайтах, вы, скорее всего, мало что поймете, так как, во-первых, материал обычно ориентирован на знающего читателя, во-вторых, хорошей и понятной информации по теме не так много в русскоязычном интернете, в-третьих, там почему-то постоянно речь идёт о некоем "прологе" (речь о языке программирования Prolog, разумеется), но сам язык, кажется, использует мало кто (почётное 35 место в рейтинге TIOBE). Однако наш герой не теряет мотивации и, спустя некоторое время, натыкается на эту самую статью, желая, все-таки понять:

  • Что такое логическое программирование

  • Какова история его создания и фундаментальные основы (серьезно, какому новичку это может быть интересно?)

  • Зачем и где его применяют

  • Стоит ли лично вам его изучать

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

Что такое логическое программирование

В школе на уроках информатики многие, если не все, слышали про Pascal (а кто-то даже писал на нем). Многие также могли слышать про Python, C/C++/C#, Java. Обычно программирование начинают изучать именно с языков из этого набора, поэтому все привыкли, что программа выглядит как-то так:

НачатьКоманда1Команда2Если УСЛОВИЕ  Команда3Иначе  Команда4Закончить

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

Давайте устроимся поудобнее рядом со своим компьютером и порассуждаем о жизни и смерти вместе с Аристотелем:

Всякий человек смертен.

Сократ - человек.

Следовательно, Сократ смертен.

Звучит логично. Но есть ли способ научить компьютер делать выводы как Аристотель? Конечно! И вот тут мы вспомним о Prolog-e, который так часто мелькает при поиске информации о логическом программировании. Как несложно догадаться, Prolog (Пролог) является самым популярным чисто логическим языком программирования. Давайте рассуждения об этом языке оставим на следующие разделы статьи, а пока что продемонстрируем "фишки" логических языков, используя Пролог.

Напишем небольшую программу, где перечислим, кто является людьми (ограничимся тремя) и добавим правило "всякий человек смертен":

% Всё, что после знака процента в строке - комментарииhuman('Plato'). % Платон - человекhuman('Socrates'). % Сократ - тоже человекhuman('Aristotle'). % Конечно, человеком был и Аристотель% ...и др. философыmortal(X) :- human(X). % Читаем так: "X смертен, если X - человек"

Что ж, давайте спросим у компьютера, смертен ли Сократ:

?- mortal('Socrates').true.

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

Так, теперь стоит успокоиться и разобраться, что же произошло. Вначале мы записали т. н. факты, то есть знания нашей программы о мире. В нашем случае ей известно лишь то, что Платон, Сократ и Аристотель - люди. Но что за странная запись "human('Socrates')." и почему это выглядит как функция? На самом деле "human" и "mortal" - предикаты от одной переменной. Да, тут уже пошли термины, но постараюсь объяснять их просто и понятно для тех, кто привык к императивному нормальному программированию.

Логическое программирование основано на логике предикатов. Предикатом называется (и здесь я позволю себе вольность) функция от нуля или более переменных, возвращающая значение логического типа (истина (true) или ложь (false)). Факт - это предикат с определенным набором параметров и заведомо известным значением.

% слова с большой буквы Prolog считает переменными, поэтому их следует заключать в кавычкиlike('Petya', 'Milk'). % программа знает, что Петя любит молокоgood('Kesha'). % Кеша хорошийnumber_of_sides('Triangle', 3). % у треугольника три вершиныlike('Misha', X). % не является фактом, так как значение переменной X не определено

Помимо фактов в логической программе присутствуют правила вывода. В данном случае это "mortal(X) :- human(X).". Набор правил вывода - это знания нашей программы о том, как выводить (искать/подбирать) решение. Правила записываются следующим образом:

a(X,Y,Z) :- b(X), c(Y,Z), d().

Предикат a от трех аргументов вернет истину, если удастся доказать истинность предикатов b, c и d. Читаются правила справа налево следующим образом: "Если b от X истинно И c от X, Y истинно И d истинно, то a от X, Y, Z истинно".

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

% Опишем набор фактов о том, кто что обычно ест на завтрак в семье Петиeat(father, cheese).eat(father, apple).eat(father, melon).eat(mother, meat).eat(sister, meat).eat('Petya', cheese).eat(brother, orange).

Теперь начнём делать запросы к программе (всё те же предикаты):

?- eat(father, apple). % ест ли отец яблокиtrue.?- eat(father, meat).  % ест ли отец мясоfalse.?- eat(sister, X). % что ест сестраX = meat.?- eat(X, cheese). % кто ест сырX = father ;X = 'Petya'.?- eat(X, Y). % кто что естX = father,Y = cheese ;X = father,Y = apple ;X = father,Y = melon ;X = mother,Y = meat ;X = sister,Y = meat ;X = 'Petya',Y = cheese ;X = brother,Y = orange.

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

Какие задачи и как можно решать с помощью логического программирования

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

d(X,X,1) :- !. % производная X по X = 1d(T,X,0) :- atomic(T). % производная константы = 0d(U+V,X,DU+DV) :- d(U,X,DU), d(V,X,DV). % производная суммы = сумме производныхd(U-V,X,DU-DV) :- d(U,X,DU), d(V,X,DV). d(-T,X,-R) :- d(T,X,R).d(C*U,X,C*W) :- atomic(C), C\=X, !, d(U,X,W). % производная константы, умноженной на выражение = константе на производную от выраженияd(U*V,X,Vd*U+Ud*V) :- d(U,X,Ud), d(V,X,Vd). % производная произведенияd(U/V,X,(Ud*V-Vd*U)/(V*V)) :- d(U,X,Ud), d(V,X,Vd). 

Запустим:

?- d((x-1)/(x+1),x,R).   R =  ((1-0)*(x+1)-(1+0)*(x-1))/((x+1)*(x+1)).

Пусть производная получилась довольно громоздкой, но мы и не ставили цель её упростить. Главное, из примера видно, что правила вывода производной на Prolog-е описываются очень близким образом к их математическому представлению. Чтобы сделать подобное на привычных языках программирования, пришлось бы вводить понятие дерева выражений, описывать каждое правило в виде функции и т. д. Тут же мы обошлись 8-ю строками. Но здесь важно остановиться и задуматься: компьютер не начал работать как-то иначе, он все ещё обрабатывает последовательности команд. Стало быть, те самые деревья, которые где-то все-таки должны быть зашиты, чтобы программа работала, действительно присутствуют, но в неявном виде. Деревья эти именуют "деревьями вывода", именно они позволяют подбирать нужные значения переменных, перебирая все возможные варианты их значений (существует механизм отсечения, который является надстройкой над логической основой языка, но не будем об этом).

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

speciality(X,tech_translator) :- studied_languages(X), studied_technical(X). % X - технический переводчик, если изучал языки и технические предметыspeciality(X,programmer) :- studied(X,mathematics), studied(X, compscience). % X - программист, если изучал математику и компьютерные наукиspeciality(X,lit_translator) :- studied_languages(X), studied(X,literature). % X - литературный переводчик, если изучал языкиstudied_technical(X) :- studied(X,mathematics). % X изучал технические предметы, если изучал математикуstudied_technical(X) :- studied(X,compscience). % ...или компьютерные наукиstudied_languages(X) :- studied(X,english). % X изучал языки, если изучал английскийstudied_languages(X) :- studied(X,german). % ...или немецкийstudied(petya,mathematics). % Петя изучал математикуstudied(petya,compscience). % ...компьютерные наукиstudied(petya,english). % ...и английскиstudied(vasya,german). % Вася изучал немецкийstudied(vasya,literature). %...и литературу

Спросим, кто из ребят, известных компьютеру - технический переводчик:

?- speciality(X,tech_translator).X = petya ;X = petya ;false.

Агато есть Петя, Петя и ложь Что-то не так, подумает программист и попробует разобраться. На самом деле, перебирая все варианты значений X, Пролог пройдёт по такому дереву:

Дерево будет обходиться в глубину, то есть сначала рассматривается всё левое поддерево для каждой вершины, затем правое. Таким образом, Пролог дважды докажет, что Петя - технический переводчик, но больше решений не найдёт и вернёт false. Стало быть, половина дерева нам, в общем-то, была не нужна. В данном случае, перебор не выглядит особенно страшным, всего-то обработали лишнюю запись в базе. Чтобы показать "опасность" перебора, рассмотрим другой пример:

Представим, что перед нами в ячейках расположены три чёрных и три белых шара (как на картинке выше), которые требуется поменять местами. За один ход шар может или передвинуться в соседнюю пустую клетку, или в пустую клетку за соседним шаром ("перепрыгнуть" его). Решать будем поиском в ширину в пространстве состояний (состоянием будем считать расположение шаров в ячейках). Суть этого метода заключается в том, что мы ищем все пути длины 1, затем все их продления, затем продления продлений и т. д., пока не найдем целевую вершину (состояние). Почему поиск в ширину? Он первым делом выведет самый оптимальный путь, то есть самый короткий. Как может выглядеть код решения:

% Обозначения: w - белый шар, b - чёрный, e - пустая ячейкаis_ball(w). % w - шарis_ball(b). % b - шарnear([X,e|T],[e,X|T]) :- is_ball(X). % если фишка рядом с пустой ячейкой, то можно переместитьсяnear([e,X|T],[X,e|T]) :- is_ball(X).jump([X,Y,e|T],[e,Y,X|T]) :- is_ball(X), is_ball(Y). % если за соседним шаром есть пустая ячейка, то можно переместитьсяjump([e,Y,X|T],[X,Y,e|T]) :- is_ball(X), is_ball(Y).% предикат перемещения. Мы или рассматриваем первые элементы списка, или убираем первый элемент и повторяем операциюmove(L1,L2) :- near(L1,L2). move(L1,L2) :- jump(L1,L2).move([X|T1],[X|T2]) :- move(T1,T2).% предикат продления текущего пути. Если из состояния X можно перейти в состояние Y и% Y не содержится в текущем пути, то Y - удачное продлениеprolong([X|T],[Y,X|T]) :- move(X,Y), not(member(Y,[X|T])).% Первый аргумент - очередь путей, второй - целевое состояние, третий - результат, то есть найденный путьbdth([[X|T]|_],X,R) :- reverse([X|T], R). % Поиск в ширину нашел решение, если первый элемент пути совпадает с целью (путь наращивается с начала, так что перевернем результат)bdth([P|QI],Y,R) :- bagof(Z,prolong(P,Z),T), append(QI,T,QO), !, bdth(QO,Y,R). % Ищем все возможные продления первого пути и кладём в очередь, рекурсивно запускаем поискbdth([_|T],Y,R) :- bdth(T,Y,R). % Если продлений на предыдущем шаге не нашлось, то есть bagof вернул false, убираем первый путь из очередиbsearch(X,Y,R) :- bdth([[X]],Y,R). % Удобная обёртка над предикатом bdth% Предикат, который решает нашу задачу и выводит результат и длину найденного пути на экранsolve :- bsearch([w,w,w,e,b,b,b],[b,b,b,e,w,w,w],P), write(P), nl, length(P, Len), write(Len), nl.

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

Со стороны улучшения алгоритма можно предложить использовать поиск в глубину. Но как же, он ведь не даст оптимального результата? Сделаем просто: ограничим глубину поиска. Так мы точно не забьём стек и, возможно, получим ответ. Поступим так: проверим, есть ли пути длины 1, затем длины 2, затем длины 4 и т. д. Получим так называемый поиск с итерационным заглублением:

% Первый аргумент - текущий путь, второй - целевое состояние, третий - результат, то есть найденный путьdpth_id([X|T],X,R,0) :- reverse([X|T], R). % Успешное окончание поискаdpth_id(P,Y,R,N) :- N > 0, prolong(P,P1), N1 is N - 1, dpth_id(P1,Y,R,N1). % Если счётчик >0, то уменьшаем его и продолжаем поиск рекурсивноgenerator(1). % Изначально предикат вернет 1generator(N) :- generator(M), N is M + 1. % Рекурсивно получаем 2, 3, 4 и т. д.isearch(X,Y,R) :- generator(D), dpth_id([X],Y,R,D). % Удобная обертка, которая будет вызывать поиск от каждого натурального значения глубины.

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

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

near([w,e|T],[e,w|T]).near([e,b|T],[b,e|T]).jump([w,X,e|T],[e,X,w|T]) :- is_ball(X).jump([e,X,b|T],[b,X,e|T]) :- is_ball(X).

Хм, код стал даже проще. Запустив мы убедимся, что поиск (оба варианта), во-первых, работает, во-вторых, работает быстро, в-третьих, работает быстро и выводит результат. Это успех. Мало того, что мы решили задачку, только что был создан самый настоящий искусственный интеллект. Программа получает входные данные и желаемый результат, а затем сама ищет, как его достигнуть. Да, это однозначно успех.

Зачем и где применяют логическое программирование

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

  • Анализ естественного языка: Пример с производной - классический пример разбора выражений. Но если мы заменим арифметические операторы, переменные и числа на слова, добавим правила, скажем, английского языка, то сможем получить программу, разбирающую текст на структурные элементы. Занимательно, что одновременно мы получим и программу, способную генерировать текст. Но если логическое программирование можно удобно и эффективно использовать для анализа и разбора текста, то в задачах генерации качественного текста скорее придется обращаться к нейросетям. Тут важно отметить, что рассуждая об анализе и генерации предложений нельзя не упомянуть сложность решения подобных задач. Человек при составлении и восприятии текста ориентируется не только на набор слов и их значений, но и на свою картину мира. К примеру, если в базе лежит факт "Миша починил Маше компьютер", то на вопрос "Разбирается ли Миша в компьютерах?" программа не сможет ответить, не смотря даже на то, что решение вроде как "на поверхности". Именно из-за низкой скорости и особенностей использования на чисто логических языках не занимаются тем, что мы ждем увидеть, загуглив "нейросети" (поиск котиков на картинке, например, не для Пролога). Но вот задачи синтаксического разбора, текстовой аналитики и т. п. на логических языках решать очень даже комфортно.

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

  • Мета-программирование: С помощью того же Пролога можно описать свой собственный язык, соответственно, со своими правилами. Это достаточно важный момент для многих проектов, где сталкиваются специалисты разных сфер. Например, стоит задача анализа неких химических данных. В команде работают химик и программист. Получается, программист должен постоянно советоваться с химиком о том, что и как ему делать, ведь химическим формулам то в IT не учат. А разговаривать - это, в общем-то, не особенно приятно, особенно если кто-то умнее тебя. И тут программисту было бы очень удобно написать простой язык, на котором химик сможет сам записать свои формулы, не трогая разработчика. Вот тут и пригодится логическое программирование.

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

Стоит ли планировать его изучение на 2021-й

Тут оставлю своё субъективное мнение, разделённое на две части:

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

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

И здесь остаётся лишь пожелать продуктивного 2021-го года!

Подробнее..

Навеянное Prolog-ом коммерческое решение пробыло больше 10 лет в эксплуатации

03.11.2020 14:09:04 | Автор: admin
Для большинства программистов которые хотя бы слышали про Prolog это только странный артефакт из времён когда компьютеры были размером с динозавров. Некоторые сдали и забыли в институте. И лишь узкие как листочек A4 специалисты сталкивались с чем-то подобным в современном мире. Так уж получилось, что далёком 2003-ем году я использовал некоторые решения подчерпнутые из Prolog-а, в коммерческих играх на Flash и больше десятилетия они радовали французов. Причём применил я это полудекларативное решение не потому что прочитал книжку Братко и впечатлился, а потому что это было реально нужно нашему проекту. Я до сих пор регулярно порываюсь воспроизвести то решение на современном уровне, потому что оно очень много где было бы полезным в современном игрострое, но, к сожалению, каждый раз находятся дела поважнее В общем об этом всём и расскажу.


Скриншот той самой флэшовой игры до сих пор приветствует вас на сайте toox.com/jeux/jeux-de-cartes/coinche

Постановка задачи и её актуальность


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

В принципе, так как игра идёт в закрытую, люди готовы простить роботам мелкие просчёты и альтернативную одарённость. В основном потому что карт робота не видят. Под игрока с симака, под вистуза с туза и тому подобные простые правила, которые я вытряхнул из нашего французского заказчика позволили сделать минимально приемлемого кидателя карт. Он был нафигчен за неделю прямо на if-ах. С другой стороны игра идёт двое на двое, очки считаются для пары, и вполне естественно игрок не хочет, чтобы его тупой напарник заходил со второго короля, то есть имея на руках короля и ещё какую-то мелкую карту делал ход в эту масть, вместо того чтобы дать противнику сыграть туза, пропустив его мелкой картой и забрать следующий ход в эту масть своим королём. (На самом деле в этих играх вторая по старшинству карта 10, но тут и дальше я буду говорить в понятных русским терминах). Но если туз по какой-то причине вышел из игры, а у вас Дама и ещё что-то мелкое, то это же почти как второй король. Особенно если предварительно прорядить козырей. А ещё вы, например, играете не в Белот, где используется 32 карты, а в Таро, в которой игра идёт колодой в 78 карт, (той самой, по которой некоторые гадают). И там в некоторых случаях забрать взятку может даже не третья дама, а четвёртый валет. В общем всё это порождает такое количество граничных случаев, что тупой болванчик на if-ах становится уже как-то совсем неприемлемо сложным. И вот на этом месте я сказал: Ба! Да я же начитался Братко и впечатлился! Дальше я на несколько дней сбежал из офиса, сел с ноутбуком в кафешке и спустя несколько дней породил нечто.

Основные идеи


На чём, декларативно говоря основан Пролог? На фактах, например:

мама('Галя', 'Ира').мама('Ира', 'Алиса').

и на термах, или правилах, например если А мама Б, то А девочка:

девочка(А) :- мама(А, Б).

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

предок(A, B) :- мама(A, B).предок(А, В) :- предок(А, С), предок(С, В).

А потом ты такой Спрашиваешь:

?- предок (X, 'Алиса')

А страшно логичный Пролог тебе и отвечает:

X = 'Ира'X = 'Галя'

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

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

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

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

Общие хотелки


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

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

Получившийся в итоге синтаксис бибилотеки Logrus


Щаз будет очень много синтаксиса.

1) В рантайме дерево решения хранится в виде некоторых классов, но первое, что я к нему приделал, как только оно заработало Import и Export в JSON. Оказалось, что это удобно ещё и потому, что если у вас не сильно поменялась страктура данных обновление правил можно накатить из файлом без перекомпиляции. Запись в виде JSON оказалось на столько удобной, что на одном из следующих проектов программисты когда торопились иногда вместо того чтобы писать нормальную команду делали просто state.AplayJSON("..."); и в нём нужное действие вставляли в виде JSON строки. На производительности это, конечно, сказывалось не очень хорошо, но если не регулярно и только в ответ на нажатие пользователя, то не страшно Всё дальше я буду иллюстрировать сразу JSON-ами. JSON-ы воспроизвожу примерно и по памяти, потому что шибко давно дело было. Строго говоря, JSON не гарантирует порядок следования нод в объекте, но большинство библиотек все-же его соблюдают, и здесь и далее порядок нод активно используется.

2) Основной структурной единицей движка стало Правило. Правило состоит из условия и действия. Обычно правила шли массивами и применялись одно за другим каждое один раз.:

[{"condition":{}, "action":{}}, {"condition":{}, "action":{}}]

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

{"condition":{    "player":{        "$X":{"gold" : "<20", "name":"$NAME"}    }},    "action":{}}

Такая конструкция будет означать, что для того чтобы было вызвано действие в дереве должна иметься на верхнем уровне нода player в которой одна или несколько дочерних нод, в каждой из которых есть поля gold со значением меньше 20 и name. Если такое условие будет выполнено, то будет вызвано действие, причём в качестве входных данных ему будут переданы в переменной X ключ ноды, а в переменной NAME имя игрока. Если подходящих нод и соотвественно возможных конкретизаций окажется несколько, то действие вызвано будет несколько раз с каждой из найденных конкретизаций на входе.

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

5) C $ начинаются имена переменных. Они могут встречаться как в виде ключа, такого как $X и тогда будет выбрано несколько конкретизаций, в виде значения листа, такого как $NAME, могут вставляться в арифметические выражения: например так: {gold: "< $X * 10"}И тогда использоваться для проверки условий, проверку пройдёт только те игроки, у которых золота больше чем их порядковый номер умноженный на 10, и наконец Они могут непосредственно вычисляться в каком-нибудь выражении, например так:{gold: "$X = 3 + $this"} где $this это значение текущей точки в которой вызвано вычисление. Прохождение этого узла конкретезирует значение переменной $X как 3+количество золота у игрока. Из возможностей, которые приходили в голову не реализована была только одна переменная не может впервые встречаться в правой части арифметического выражения, это будет ошибка, к моменту использования в качестве аргумента она уже должна быть конкретизирована одним из нескольких других способов.

6) Переменная в выражении может встречаться сколько угодно раз, при этом первое упоминание её конкретизирует, а следующие будут проверкой на равенство. Например такая конструкция возьмёт первого игрока, проверит его на наличие денег, потом поищет другого игрока для которого первый бы был целью. Если не найдет, откатится к точке конкретизиции X выберет следующего игрока, проверит на деньги и так далее пока не переберёт все допустимые варианты X и Y. Поменяв строки местами программист изменит порядок проверок, но не конечный результат:

{ "player":{    "$X":{"gold":">20"},    "$Y":{"target":"$X"}}}

7) Действие также представляет из себя шаблон дерева, могущий содержать переменные и арифметические выражения, и дерево состояния игры будет изменено так чтобы ему соответствовать. Например этот шаблон, назначит игроку X противника в виде игрока Y, но если бы по какой-то причине игрока Y не существовало он был бы создан. А игрок superfluous вообще будет удалён. На момент создания игры со скриншота признаком удаления было значение null, но потом я заменил его на зарезервированное слово, на случай если кому-то потребуется по ключу вставить пустое значение. В целом принцип, думаю, понятен, и смысл совершаемых с игрой действий в основном тоже.

{    "condition":{    "player":{        "$X":{"gold":">20"},        "$Y":{"target":"$X"}}},    "action":{        "$X":{"target":"$Y"},        "superfluous":"@remove"}}

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

{    "condition":{        "player":{            "$X":{}, "$Y":{"target":"$X"}}},    "action":[        {"condition":{}, "action":{}},        {"condition":{}, "action":{}}]}

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

{    "condition":{        "player":{            "$X":{}, "$Y":{"target":"$X"}}},    "action":{        "$X":{"@rules":[            {"condition":{}, "action":{}},            {"condition":{}, "action":{}}]}}

10) Повторение правила могло быть задано в качестве ещё одной ноды, этим достигалась, если надо, рекурсия ограниченной глубины. Но на практике такое решение обычно не было необходимым. Его можно использовать так же чтобы повторять пачку правил сколько нужно, поместив его в action:

{    "condition":{},    "action":{},    "repeat":5}

11) Дерево правил можно было грузить из нескольких файлов JSON, их древовидная структура просто накладывалась одна на другую. Это было удобно чтобы разбить правила на отдельные осмысленные блоки. Наверное полезен был бы и какой-нибудь Include, сейчас уже не помню как у нас оно было устроено.

12) Логирование! Любое правило могло иметь ноду "@log":true, что приводило к тому, что это правило начинало очень подробно срать в лог описанием процесса решения. Какие конкретизации пробуем, какие ветки рассуждений пресекаются и почему. Логирование было иерархическое, то есть вложенное правило могло быть "@log":false и всё что происходит в нём и ниже в лог не попадёт. В идеале я бы хотел чтобы эту ноду можно было оставляь вообще у любом месте дерева условий, чтобы смотреть только на происходящее в одном уровне шаблона, но такое расширение я так, кажется, и не доделал. Возможно отладка и без него проходила нормально, поэтому его и откладывали на когда-нибудь.

13) Типизация. Игрушка была на столько старой, что некоторые из нынешних программистов тогда ещё не родились. Написана она была на языке ActionScript2, в котором была динамическая типизация и наследование через прототипы доступное прямо в рантайме. Из современных языков которые на слуху, так устроен только Python. Однако к данной идее прикрутить типизацию не представляет особой сложности. Я бы сделал это используя ключевой символ ':' например так: "$X:int" однако тут может возникнуть хитрость если первое вхождение переменной не имело никого указанного типа. А кроме того может возникнуть путаница с тернарным оператором.

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

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

{    "player":{        "$X":{"gold":"<20", "gold@cnt":"$COUNT"}    }}

15) Потребовались арифметические операции, которые можно было выполнять вообще без использования какой-то ноды. По традиции пролога они обозначались '_' и их можно быть много:

{    "_":"$SUM=$A+$B",    "_@check":"@SUM <20"}

16) Раз есть проход проверки вверх по дереву, потребовался и спуск вниз, делающийся через ключевое слово "@parent". Читаемости это, конечно, не прибавляло, но обойтись без этого никак не получалось. Тут, конечно, прямо напрашивается какой-то аналог функции path который бы переадресовывал на указанное место в дереве, но я не помню, успел я его реализовать его в итоге или нет:

{    "condition":{        "player":{            "$X":{}, "$Y":{"target":"$X"}}},    "action":{        "$X":{"rules":[            {                "condition":{                    "@parent":{"@parent":{"":""}}            }        ]},    }}

17) У действия появилась возможность непосредственно подёргать какой-нибудь метод класса. Это такой пинок под дых читаемости, и я бы предпочёл какой-нибудь аналог #include, но уж как есть, из песни слов не выкинешь. Интересно, смогу ли я без этого обойтись на практике если реанимирую библиотеку сейчас на C#?

18) У правила появилась дополнительная настройка повторять действие не для всех найденных конкретизаций, а только для первой. Не помню сейчас как называлась, но зачем-то этот костыль был полезен для каких-то правил.

Результаты использования


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

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

Конечно по скорости выполнения это всё заметно уступало мешанине if-ов, особенно в реализации на AS2 с его прототипами и динамическими объектами, но не на столько, чтобы это нельзя было выкатить в прод.

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

Прошло много лет, я поменял работу десяток раз и каждый раз обновляя резюме я заходил на toox.com и с удивлением обнаруживал своё поделие всё ещё в строю. Даже заходил сыграть партию другую. И один раз в Belot мне случайно попался набор карт дающий максимально возможное количество очков. Вероятность выпадения такого набора один на три миллиона.

Когда-нибудь я соберу волю в горсть и сделаю современный ремейк Logrus-а под C# и Unity3d, например для гексагональной стратегички, о которой мечтаю. Но это будет не сегодня, сегодня я пойду спать. Долг по распространению идей, которые стоят того чтобы их распространять успешно выполнен.

В заключение пара анекдотов


Расположены мы были в новосибирском Академгородке. Арендовали офис в институте. А заказчик француз, с местными нравами совершенно не знакомый. И вот на третий или четвёртый месяц совместной работы приезжает он к нам в гости, знакомиться. Заселился на выходных в местную гостиницу Золотая Долина и в понедельник, говорит менеджеру, давай в десять утра встречай меня на такси, поедем с программистами знакомиться. А Вовчик возьми да и приедь в 10. В общем подъезжают они к институту, стучаться в дверь, а с той стороны приходит бабушка вахтёрша и совершенно ничего не понимая на них смотрит из-за закрытой на замок двери. В такую рань ни научных сотрудников, ни арендующих офисы программистов в здании отродясь не бывало. Они её буквально разбудили.

А вот тоже другой случай. Звонит как то наш Себастьян Перейра к менеджеру и говорит, что они тут чудом смогли прорваться в телевизор и скоро нас с нашим сайтом покажут по телевизору. Через 8 часов примерно. Так что вы там сделайте чтобы он работал понадёжнее На часах 2 января Не важно какое время И вот Вовчик берёт такси, начинает по общагам и квартирам собирать программистов совершенно в состоянии размазни, и свозить их в офис. Я в тот день впервые вы жизни увидел нашего сисадмина. До этого момента он всё делал исключительно удалённо. И вот мы каждый подкрутили кто что мог. Я, в частности выломал всю эту систему поставив на её место назад пачку if-ов и вот сидим мы, смотрим на график посещаемости и вдруг видим как он начинает переть вверх. Где-то на отметке x15 сервак грохнулся. Но админ сказал, что всё хорошо, упал аккуратно, сейчас сам поднимется. За тот день сервак падал ещё трижды.
Подробнее..

Жизнь на PostgreSQL

12.10.2020 20:04:57 | Автор: admin
Недавно на Хабре была опубликована статья Морской бой в PostgreSQL. Должен признаться: я обожаю решать на SQL задачи, для SQL не предназначенные. Особенно одним SQL-оператором. И полностью согласен с авторами:

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

И еще. Будем честны: всегда использовать SQL по назначению тоска зеленая. Вспомните, какие примеры приводятся во всех учебниках, начиная с той самой статьи Кодда? Поставщики да детали, сотрудники да отделы Агде же удовольствие, где же фан? Для меня один из источников вдохновения сравнение процедурных решений с декларативными.

Я, позвольте, не буду объяснять, что такое Жизнь Джона Конвея. Скажу только, что оказывается используя клеточный автомат Жизни, можно построить универсальную машину Тьюринга. Мне кажется, это грандиозный факт.

Так вот, можно ли реализовать игру Жизнь одним оператором SQL?

Окей, приступим.

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

Кстати о матрицах
Такое же представление удобно использовать для представления матриц, особенно разреженных:

CREATE TABLE matrix (  rw  integer,  cl  integer,  val float);

Простой пример, эффективно взрывающий процедурно настроенный мозг, умножение матриц. Напомню, что произведением матрицы A(LM) на матрицу B(MN) является матрица С(LN), элементы которой ci,j = k = 1...M ai,kbk,j.

Процедурный алгоритм использует тройной вложенный цикл по i, j, k. А SQL-запросу достаточно простого соединения:

SELECT a.rw, b.cl, sum(a.val * b.val)FROM a    JOIN b ON a.cl = b.rwGROUP BY a.rw, b.cl;

Запрос стоит внимательно изучить и понять. С непривычки это совсем даже непросто. Здесь нет циклов: запрос оперирует множествами элементов и их соединением. Здесь нет размерности матрицы. Здесь не нужно хранить в таблице нулевые элементы.

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

Итак, поле:

CREATE TABLE cells(    x integer,    y integer);INSERT INTO cells VALUES    (0,2), (1,2), (2,2), (2,1), (1,0); -- glider

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

WITH shift(x,y) AS (    VALUES (0,1), (0,-1), (1,0), (-1,0), (1,1), (1,-1), (-1,1), (-1,-1)),neighbors(x,y,cnt) AS (    SELECT t.x, t.y, count(*)    FROM (        SELECT c.x + s.x, c.y + s.y        FROM cells c            CROSS JOIN shift s    ) t(x,y)    GROUP BY t.x, t.y )SELECT * FROM neighbors;

Сдвиги (shift) тоже можно сконструировать запросом, но, пожалуй, проще от этого не станет.

Имея соседей, остается решить, какие клетки должны погибнуть, а какие родиться:

WITH shift(x,y) AS (    ...),neighbors(x,y,cnt) AS (    ...),generation(x,y,status) AS (    SELECT coalesce(n.x,c.x),           coalesce(n.y,c.y),           CASE                WHEN c.x IS NULL THEN 'NEW'                WHEN n.cnt IN (2,3) THEN 'STAY'                ELSE 'DIE'           END    FROM neighbors n        FULL JOIN cells c ON c.x = n.x AND c.y = n.y    WHERE (c.x IS NULL AND n.cnt = 3)          OR          (c.x IS NOT NULL))SELECT * FROM generation;

Полное соединение здесь необходимо, чтобы, с одной стороны, в пустой клетке могла зародиться новая жизнь, а с другой чтобы погубить живые клетки на отшибе. Унас три условия попадания в выборку: либо клетка пуста и у нее ровно три соседа (тогда она должна ожить и получает статус NEW), либо жива и имеет двух или трех соседей (тогда она выживает и получает статус STAY), либо жива, но имеет меньше двух или более трех соседей (тогда она обречена на гибель и получает статус DIE).

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

WITH shift(x,y) AS (    ...),neighbors(x,y,cnt) AS (    ...),generation(x,y,status) AS (    ...),del AS (     DELETE FROM cells    WHERE (x,y) IN (        SELECT x, y FROM generation WHERE status = 'DIE'  )),ins AS (    INSERT INTO cells        SELECT x, y FROM generation WHERE status = 'NEW')SELECT *FROM generationWHERE status IN ('STAY','NEW');

Собственно, вся логика игры написана!

К этому алгоритму уже нетрудно прикрутить отображение результата в виде привычной глазу двумерной матрицы. И, как вишенкой на торте, можно закончить запрос командой psql \watch, чтобы поколения сменяли друг друга на экране каждую секунду.

Вот весь запрос целиком с минимально удобоваримым выводом. Copy, paste, and enjoy!

WITH shift(x,y) AS (    VALUES (0,1), (0,-1), (1,0), (-1,0), (1,1), (1,-1), (-1,1), (-1,-1)),neighbors(x,y,cnt) AS (    SELECT t.x, t.y, count(*)    FROM (        SELECT c.x + s.x, c.y + s.y        FROM cells c            CROSS JOIN shift s    ) t(x,y)    GROUP BY t.x, t.y ),generation(x,y,status) AS (    SELECT coalesce(n.x,c.x),           coalesce(n.y,c.y),           CASE                WHEN c.x IS NULL THEN 'NEW'                WHEN n.cnt IN (2,3) THEN 'STAY'                ELSE 'DIE'           END    FROM neighbors n        FULL JOIN cells c ON c.x = n.x AND c.y = n.y    WHERE (c.x IS NULL AND n.cnt = 3)          OR          (c.x IS NOT NULL)),del AS (     DELETE FROM cells    WHERE (x,y) IN (        SELECT x, y FROM generation WHERE status = 'DIE'  )),ins AS (    INSERT INTO cells        SELECT x, y FROM generation WHERE status = 'NEW'),dimensions(x1,x2,y1,y2) AS (    SELECT min(x), max(x), min(y), max(y)    FROM generation    WHERE status IN ('STAY','NEW'))SELECT string_agg(CASE WHEN g.x IS NULL THEN ' ' ELSE '*' END, '' ORDER BY cols.x)FROM dimensions d    CROSS JOIN generate_series(d.x1,d.x2) cols(x)    CROSS JOIN generate_series(d.y1,d.y2) lines(y)    LEFT JOIN generation g ON g.x = cols.x AND g.y = lines.y AND g.status IN ('STAY','NEW')GROUP BY lines.yORDER BY lines.y\watch 1
Подробнее..

Эволюция декларативных UI-фреймворков от динозавров к Jetpack Compose

08.04.2021 16:21:10 | Автор: admin


Проект Jetpack Compose привлёк много внимания в Android-мире, ещё когда был в альфа-версии. А недавно добрался до беты так что теперь всем Android-разработчикам пора понимать, что он собой представляет.


Поэтому мы сделали для Хабра текстовую версию доклада Матвея Малькова с нашей конференции Mobius. Матвей работает в Google UK и лично причастен к Compose, так что из доклада можно узнать кое-что о внутренностях проекта. Но доклад не ограничивается этим: внимание уделено не только Jetpack Compose, а всему декларативному подходу в целом.


Кстати, недавно появился ещё и проект Compose for Desktop от JetBrains. И скоро на Mobius о нём тоже будет рассказ из первых рук: 14 апреля об этом расскажет руководитель проекта Николай Иготти.


Далее повествование будет от лица спикера.



Я хотел бы рассказать не только про Jetpack Compose, но и поговорить в целом про декларативные UI-фреймворки: окунуться в их прошлое, настоящее и будущее.


Прошлое: о декларативном и императивном


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


Декларативное программирование это парадигма, которая позволяет нам задавать программы, не описывая control flow. Иными словами, мы описываем не как, а что мы хотим видеть на экране. Это самое большое отличие от императивного программирования.



Вот пока пример не про UI. Часть How это императивная история. Мы берем var и складываем значения у него в цикле. И у нас есть What декларативное программирование, где мы говорим, что хотим сделать: заредьюсить список с помощью какой-то операции.


Возьмём пример попроще:


val a = 4
val b = -2
val result = a + b


Эти три строчки являются интерактивными или декларативными? Ответ зависит от того, с какой стороны посмотреть. Раз мы говорим о прошлом, вспомним, как развивались языки программирования.


Поколения языков программирования



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



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



Ребята придумали языки высокого уровня: Java, Kotlin, C, C++. Они тоже очень декларативные по сравнению с предыдущими поколениями, потому что ты не объясняешь машине, что положить, а просто говоришь: a = 4, b = 2 и result = a + b. Но все это прошлое.



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


Очевидно, что любое новое поколение по сравнению с предыдущим более простое в использовании для более сложных задач. Наши проблемы развиваются, и нам нужно решать их проще. Когда возникают новые проблемы, мы придумываем новые абстракции, новые поколения, которые решают их более эффективно. В числе примеров четвертого поколения 1C, R, SQL, RegEx. Ещё в четвёртом поколении есть domain specific languages, то есть небольшие фреймворки, которые решают конкретные проблемы: React, RxJava, JetpackCompose.


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


Например, в C++ есть intrinsics или inline ASM. В React мы можем получать reference прямо на DOM-элементы или контролировать жизненный цикл. Отличный пример в RxJava PublishSubject. В Jetpack Compose есть imperative drawing, imperative touch-handling, onCommit. Это даёт больший контроль, а также возможность строить поверх императивного свои декларативные абстракции.


UI-фреймворки: вчера и сегодня


Говорить о фреймворках мы будем в разрезе Android и веба, потому что проблемы в UI, с которыми сталкиваются Android-разработчики, есть и в вебе, и в других UI-областях.


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


Если я заменю HTML на main_layout.xml, который есть у нас у всех в Android-приложении, ничего не изменится. Это тот же самый язык разметки, сконвертированный с HTML для Android. Это тот же подход к UI: вызываешь функцию inflate, и получаешь дерево UI.


Проблема в том, что мы не живем в 1995 году и не пишем статические сайты, статические приложения, где на каждую ссылку у нас загружается новая страница. Мы пишем сложные приложения, которые содержат много state и анимации. Это называется динамический UI, к которому мы применяем изменения на лету.


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



Когда мы нажимаем на кнопку Read All, у нас 0 непрочитанных. Чтобы разобраться, рассмотрим дерево нашего UI (будь то хоть Android с вещами вроде TextView, хоть хоть HTML-элементы вроде div). И перед нами такой человек, его будут звать Капитан Динамичность.



У нас есть 100 непрочитанных сообщений, и пользователь нажимает Read All. Капитан Динамичность берет UI, понимает, что нужно поменять, находит это, убирает лишние элементы, соединяет нового родителя с child. Молодец!



Потом пришло новое сообщение. Нужно снова создать подветку UI (надеюсь, не по частям, а одну часть), можно это делать через View.GONE или View.INVISIBLE. И мы снова получаем непрочитанные сообщения. Проблема в том, что если добавляется кнопка undo или какие-то ещё новые требования, становится сложно следить за всеми состояниями. И переходов между ними уже совсем не два.


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


val unreadRoot = document.getElementsByClassName("unreadRoot"); val count = document.createElement('div'); count.textContent = 'You have $count unread messages'; unreadRoot.appendChild(count);

В JavaScript без фреймворков люди пишут примерно так: они берут элементы, императивно создают их, ставят им текст, аппендят childs получается Капитан Динамичность.
То же самое происходит у нас на андроиде:


val unreadIcon = findViewById(R.id.unread) if (unreadCount == 0) { unreadIcon.setVisibility(GONE) } else if (unreadCount > 0) {           unreadIcon.setVisibility(VISIBLE)           unreadIcon.setCount(unreadCount)           var hasImportant = unreadMessages.exists(it.isImportant)           if (hasImportant) {           unreadCount.setTextColor(Red)           } else {           unreadCount.setTextColor(Blue)           } }

Я не проверил unreadIcon на null, хотя стоило. Где-то я обновил TextColor в зависимости от чего-то. Не забыть бы поменять обратно при каких-то изменениях.


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


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


UI-фреймворки: настоящее и будущее. Главные принципы


Новый UI-фреймворк:


  • Убирает переходы между состояниями (для разработчика)
  • UI это функция от состояния
  • Объединяет markup и динамическое обновление

Скорее всего, придется модифицировать наш HTML или XML или объединить это с динамическим обновлением, чтобы позволить создавать UI, как функцию от какого-то state.



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


Чтобы это сделать, нам придется соединить Markup и Dynamic, которые у нас есть на третьем уровне и построить фреймворк четвертого уровня, который позволяет задавать динамический Markup. И есть понимание, что это уже сделано. Например, React объединил HTML и JavaScript в React Components. И вместе с ним идет JSX, который позволяет создавать внутри JavaScript как будто бы HTML.


В случае с Jetpack Compose аналогично: Composable-функции делают то же самое, соединяя разметку и динамику.



Разберёмся в том, что такое Compose, как он работает, чтобы понять контекст.


В его случае любой UI-элемент это функция с аннотацией Composable. Только Composable-функции могут вызвать Composable-функции.


@Composable fun UnreadSection(count: Int) {            Text(         text = "You have $count unread messages",          color = if (count > 10) Color.Red else Color.Green          )       if (count > 0) {            ReadAllButton()        }        }

Функция принимает состояние в данном случае функция UnreadSection принимает количество непрочитанных сообщений. И дальше на основании состояния я могу делать все, что угодно. Могу прямо здесь сказать, что у меня есть текст с определенным цветом. Могу вставить конструкции Kotlin: if или for. Могу добавить кнопку или убрать её. Для этого не нужно больше вручную настраивать её visibility, обновлять текст, искать его откуда-то из XML и так далее.


Вернёмся к примеру с функцией от false и true. Если мы поставим UnreadSection(count = 100), у нас получится одно дерево, а если UnreadSection(count = 0), то получится другое.



И в этом случае мы можем сказать, где чему равен count. Jetpack Compose так и работает.


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


Заменить экран с одного на другой дорогая история.


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


Разберемся, как воплотить идею.



У нас есть UI это функция от состояния. С этим мы разобрались, это можно сделать в нашем фреймворке. Итак, у нас есть реальный UI это то, что видит пользователь. И у нас есть виртуальная репрезентация, которую мы можем построить в фреймворке. У нас функция принимает true, пропадает количество unread count или, наоборот, появляется при false. Мы строим новую виртуальную репрезентацию. После этого мы можем посчитать diff этих виртуальных репрезентаций, на его основе построим список императивных операций. Например, это могут быть псевдокод и операции, которые построили над реальным UI. Все будет делаться внутри фреймворка.



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


Наш псевдокод называется Reconciliation, он делает за нас то, что мы делали. Если Reconciliation кажется вам сложным словом, то запомните это как Авто Капитан Динамичность.


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


Reconciliation


Базовая задача звучит так: построить минимальный набор команд для трансформации дерева А в дерево Б.


Проблема: общий алгоритм построения набора операций, необходимых для трансформации одного дерева в другое, имеет скорость O(n3).


Кубическая сложность во времени значит, что если у нас есть дерево с тысячей элементов (ну, среднестатический такой UI), то нам надо будет сделать один миллиард операций, чтобы обновить их. Так не пойдёт.


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


Упрощение 1 (очень общее упрощение, которое вместе со вторым упрощением в каком-то виде применяются в React, Compose): если мы понимаем, что обновляемый элемент/поддерево те же самые, то можем обновить дерево без пересчитывания полностью.


Упрощение 2: если входные данные этого компонента не меняются, то не меняется и поддерево, которое ему соответствует.


Это позволяет в среднем за линейное время преобразовывать дерево А в дерево Б.


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


Давайте посмотрим, как мы это делаем внутри Jetpack Compose.


Jetpack Compose внутри


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


Мы не храним виртуальную репрезентацию как дерево, а используем Gap buffer. Это классическая, но малоизвестная структура данных, используется в текстовых редакторах вроде vim и Emacs. У неё есть, грубо говоря, список, и в этом списке есть gap. Gap один, он может быть большой или маленький, его можно увеличивать. И мы можем совершать определённые операции в этом Gap buffer. Первая логическая операция insert. Это операция добавления, и она константная.



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



Мы не можем вклиниться в середину быстро и добавлять туда элементы. Это tradeoff, который мы делаем в Compose с расчетом на то, что структура конкретно нашего UI меняется не так часто, реже, чем происходят апдейты. Например, есть текстовый View, и в нем чаще меняется строчка текста, чем он превращается в иконку.


Итак, рассмотрим на реальном примере, что же происходит в Gap buffer. Мы будем делать что-то примерно такое: у нас есть Mobius, мы кликаем обновляется счётчик.



Как это сделать по Jetpack Compose? Мы можем сделать Composable-функцию:


@Composable fun Counter(title: String) {     var count by state { 0f }     Button(        text = "$title: $count" ,        onClick = { count++ }        ) }

Мы можем задать state (позже разберёмся, как это работает главное, что есть обёртка, которую я могу обновлять по клику). И у нас есть Button, в котором $title (в данном случае Mobius) и count (сначала 0, увеличивается при клике).


Сейчас произойдет бум-момент, потому что мы посмотрим, что же делает аннотация @Composable. Она превращает код примерно в такой:


fun Counter($composer:Composer,$key: Int,title: String) {     $composer.start($key)     var count by state($composer,key=123) { 0f }     Button($composer, key=456         text = "$title: $count" ,         onClick = { count++ }      )      $composer.end }

Что здесь происходит? У нас здесь есть composer, вы можете на него ссылаться, как доступ к Gap buffer. И именно поэтому только в composable-функции могут быть composable-функции: им всем нужен Gap buffer, им нужен scope, где они сейчас находятся, нужна текущая позиция Gap buffer.


Еще мы вставляем ключи. Это уникальный ключ, важно, чтобы они были разные, и на разные button будут разные ключи.


Также мы в начале вставляем в начале функции composer.start и в конце composer.end.


Посмотрим, что произойдёт, если возьму эту функцию и применю к UI.



Gap buffer может быть больше, а это просто отрезок, где мы сейчас находимся. Мы вызываем функцию Counter и идём по ней:
Мы говорим: у нас есть start, кладём туда такую штуку, как Group($key).
Потом идём на следующую строчку, и там есть key = 123, это еще одна Group(123).
После этого, как я уже говорил, state генерирует обёртку с классом state со значением 0.
Потом у нас есть Button, где есть key = 456, кладем туда Group = 456.
У Button есть параметры, мы кладём их в slot table.
Естественно, у Button внутри тоже есть имплементация, это же не просто компонент. Внутри него проходят операции, у него есть текст и клики.



Так будет выглядеть наш Gap buffer, когда мы покажем этот Counter. И если мы посмотрим, как я и говорил, то даже линейные виртуальные репрезентации всё равно показывают это дерево, которое у нас и является UI. У нас есть Counter это вся штука, которую мы построили, у нас есть State и остаток Button.



В каком-то плане мы строим call stack-функцию в этом случае. Причём запоминая её параметры и уходя внутрь, то есть это depth first traversal-итерация по дереву.


Возвращаемся к тому, что мы делаем не статический UI, у нас бывают обновления.


Давайте представим, что мы обновили в этом Counter Mobius на Matvei или на что-то ещё. Новый заголовок title приходит в новый Counter изолированно. Мы знаем, что title начинается с Group(key), идем туда. Смотрим, что key=123 не поменялся, значит, state не поменялся, то есть возвращаем вверх тот же самый state, который был у нас до этого. key=456 тоже не поменялся, смотрим на text он поменялся. Лямбда не поменялась, но так как у меня text, а он идет после ключа button, то есть является параметром Button, нам нужно зайти внутрь Button и тоже поменять его.



Всё связано таким образом, и мы можем понять, что у нас поменялось. Ключи дают возможность определить, та же самая это кнопка или нет. Если она та же самая, можно пойти внутрь и обновить её. И мы делаем императивный flush, перерисовывая нужные части UI, которые обновились.


Это простой пример, посмотрим на что-то сложнее. Как уже говорил, с Compose мы можем делать if else, for и прочие вещи. Мы не рассмотрели примеры, когда я куда-то двигаю Gap buffer.


@Composable fun Feeds(items: List<FeedItem>) {    if (items.isEmpty) {          Text("No feeds")      } else {         FeedList(items)     } }

У нас есть Feeds список каких-то вещей, чатов и чего угодно. Эта Composable-функция принимает List<FeedItem>, и он может быть пустой. Нужно показать какую-то нотификацию: no feeds, создать чат, какой-то UI.


Compose сгенерирует нам ключи с помощью Composable-аннотации, которые мы положим в buffer.



Элементов нет, идем в if (isEmpty == true) и попадаем в 123, ставим Group(123) и текст. Дальше не идем. Потом идем в базу данных, в интернет, и у нас появляются Item: новый чат или что-то еще. Снова вызываем функцию Feeds и попадаем в else statement, потому что у нас есть чат. И кажется, что 456 не равно 123.


Двигаем Gap за линейное время: это довольно длинная операция, но мы понимаем, что UI поменялся, нужно пересоздать дерево. Старое дерево нам уже не нужно, можем его удалить. Берем Group(456) и ставим новые Feeds. Таким образом мы можем создавать conditional-логики в нашем UI. Перестраиваем полностью под дерево, потому что if стал else, все поменялось.



Ключи это не 123 и 456, мы не берём их с неба. Они зависят от местоположения в файле вашего Composable. Если у нас будет еще один text, то у него будет другой key внутри if, и мы будем генерировать эти цифры каждый раз для новой встречи в коде компонента. Это называется positional memoization. Это core-принцип в Jetpack Compose, потому что нам нужно понимать что, как и когда перезвать.


В positional memoization главная штука называется remember. Это функция, которая может принимать набор параметров, например:


@Composable fun FilterList(items: List, query: String) {   val filteredItems = remember(items, query) {    items.filter(it.name.contains(query))   }   FeedList(items) }

В примере эта функция принимает items и query и лямбду, которую она будет вызывать. Мы делаем фильтрацию списка, у нас есть items [A, AB, BC], есть query, который мы вводим в поиске, A. Мы берем и кладем их в slot table. Она зеленая, значит, это тоже Composable-функция. И мы говорим, что в items у нас лежат [A, AB].



Если мы призовем этот FilterList, например, когда поменялись параметры, то если items, query те же самые, мы можем пропустить вычисление и вернуть сразу [A, AB].


Даже если у нас будет два разных FilterList с одинаковыми items, у них все равно будет свой remember, потому что они будут объявлены в разных местах в коде, у них будут разные ключи и разные remember.


Интересно, что у remember может не быть параметров. И мы можем сделать такую штуку:


@Composable fun Item(name: String) {      val result = remember {      calculation()      }      Text($name: $result) }

Пример, в котором calculation() является math.random(). Если я сделаю список этих item, у каждого item будет свой calculation, свой результат, и он сохранится на весь жизненный цикл этого item, даже если мы будем менять у него имя. Мы берём remember, у него нет параметров, то есть мы всегда возвращаем тот же самый calculation. Но мы можем пересчитать его только в первый раз, потому что тогда его не было в Gap buffer, а теперь он есть, и мы просто возвращаем его.


Все эти Composable-функции это примерно то же самое. То есть мы берём параметры:


  • Поменялись идем внутрь функции;
  • Не поменялись возвращаем то же самое.

Стейт


Помните, я рассказывал вам о том, что у нас есть Counter, и мы можем там создать state?


@Composable fun Counter(title: String) {     var count by state { 0f }     Button(         text = "$title: $count" ,         onClick = { count++ }     ) }

У state тогда был ключ 123, и после того, как мы возвращали, тот же самый state при апдейте counter. Все потому, что state внутри выглядит как просто remember. Это remember от функции MutableState. И функция mutableStateOf сделана примерно как MutableListOf в Kotlin.


@Composable inline fun state(init: () -> T): MutableState =      remember { mutableStateOf(init()) } fun mutableStateOf(value: T): MutableState {  }

Идея в том, что именно в функции state мы ремемберим MutableStateOf, мы можем понять, что он тот же самый. И если мы будем менять title у кнопки, state останется тем же самым. То есть я могу поменять title с Mobius на Matvei с counter = 14: title поменяется, а counter останется 14.


Интерфейс MutableState очень простой, наследуется от state.


interface MutableState<T> : State<T> {      override var value: T } interface State<T> {     val value: T }

Все, что делает MutableStateOf, создает объект MutableState. Он принимает Initial, чтобы мы могли правильно создать его. Это не просто обертка, которая содержит класс с <T>, хотя для нас он так и выглядит.


Мы создаём такой инстанс MutableState, который позволяет отслеживать ваше прочтение и потом обновлять только прочитавших. Это не нужно в идеальном мире, потому что в Compose мы умеем сами пробежать по Gap buffer и понять, что происходит. Но для важных, интересных кейсов эта вещь позволяет нам обновлять прочитавших или, по-другому говоря, совершать scoped observations, когда мы можем понять, что и в каком scope произошло.


Разберем на примере.



Вот наш Counter с нашим сгенерированным slot table. Мы не будем создавать здесь этот state с помощью функции state, а перенесем его в лист параметров. Gap buffer поменяется.



Мы не будем говорить о том, хороший этот подход или плохой. Приходит Counter, и мы можем его менять. Если вы сгенерировали и передали MutableState, мы можем понять, что где-то в text есть state.value, мы прочитали value из MutableState. Мы прочитали это внутри scope Counter. Запомним это, обновляем только Counter и ничего кроме.



Давайте посмотрим, почему это здорово. Рассмотрим такой пример:



У нас есть три кнопки, у них есть свои counter и кнопка Increase all, которая будет инкризить все counter. Один из вариантов сделать MutableState параметром counter, чтобы мы могли контролировать все. Мы можем создать App:


fun App(counterData: Map<String, MutableState<Int>>) {   Column {       counterData.forEach { (title, counterState) ->       Counter(title, counterState)       }       Button(        text = "Increase All!",        onClick = {        counterData.forEach{ (_, countState) ->             countState.value++         }        }       )     } }

После этого я говорю, что у нас есть Column в Compose, и завожу там counterData.forEach. И отдаю ему title (Mobius, Matvei или Memoization) и counterState нашему counter. После этого добавляем кнопку Button, которая будет по клику обновлять все counters, которые у нас есть. И у нас есть counters, которые работают отдельно, и Increase all, который обновляет все.


Мы помним, что у нас MutableState, и мы умеем это все обновлять скоупами. Мы построили примерно такое дерево:



Можете представить себе, что этот пример очень маленький, и помимо counter внизу, у нас может быть их много UI в середине. Increase all может быть далеко в штуке, у которой тоже есть доступ к MutableState.


В этих counters мы делаем count.value, который прочитали в каком-то скоупе этого counter. А button просто делает Increase all forEach по it.value. Запись в MutableState ни на что не обрекает тебя, тебе не нужно перерисовывать все, если ты обновляешь.


Кликаем на Increase all. В этом случае, когда мы понимаем, что value изменились, мы не бежим по всему дереву, от parent, от владельцев этих MutableState. Нам нужно понять, что все было это было прочитано внутри скоупов counter, и обновить только их. Если мы обновили только один counter, обновится только один. Если все три, то обновляются все три с помощью forEach. Мы избавимся от всех итераций по всем деревьям и просто обновим эти поддеревья, начиная с самого близкого, который прочитал.



Это возможно, потому что у нас есть compiler-плагин, который позволяет получать эти крутые штуки.


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


State<T> &MutableState<T> все от нас прячут, мы говорим state.value и получаем текущее значение. Наша Composable-функция будет сама реализована, нам ничего не нужно для этого делать.


Также очень удобно, что можно создать новое значение одной строчкой, быстро создать composition-bound state внутри Composable-функции.


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


Если вы, например, рисуете какой-нибудь чекбокс, и читаете value только в стадии рисовки, мы перерисуем только ваш canvas. Это позволяет делать супероптимизированные вещи, которые, возможно, вам нужны.


Важно понимать, что Shared MutableState<>, который мы сделали, не бесплатный. Если вы принимаете MutableState как параметр компонента, то компонент сможет менять state родителя. Станет очень сложно следить за общим состоянием системы, и мы рискуем напороться на то, за что боролись. Мы хотели, чтобы все стало проще с помощью фреймворков, но нас заставляют понимать, куда ты отдал свой state.


Пример:


@Composablefun Counter(    countState: MutableState<Int>) {    Button(      text = "count: ${countState.value}",      onClick = { countState.value += 1 }      ) }

И мы его вот так используем:


@Composable fun App() {     val counterState: MutableState = state { 0f }     Counter(counterState) }

И когда я отдаю этот state в Counter, я прощаюсь с этим state и не знаю, что с ним произойдет. Я не контролирую, что произойдет внутри Counter. Его могут положить куда-то, кому-то отдать. Это усложняет систему, но дает классные штуки.


Решением этого может быть state hosting, когда мы поднимаем state выше, а владельцем становится какой-то parent, например, App. Button в этом случае будет принимать State только для чтения, а не MutableState, как раньше.


Второе решение Controlled components это компоненты, у которых нет state, но есть snapshot этого state.


@Composable fun Counter(     currentCount: Int,     onCountChange: (Int) -> Unit ) {     Button(        text = "count: $currentCount",        onClick = { onCountChange(currentCount + 1) }        ) }

Мы можем зарефакторить наш Counter так, чтобы он принимал currentCount и лямбду, которую он звал бы на onCountChange. Если мой текущий Count = 7, то я попросил бы кого-нибудь обновиться на 8. Это то, что мы делаем в чекбоксах, текстфилдах и прочем. Это то, почему очень сложно иногда писать приложение на андроиде, когда есть edit text, и нам хочется что-то поменять, потому что MutableState везде шарится. Тут же counter принимает только snapshot и предлагает кому-то поменять значение.


@Composable fun App() {   val counterState: MutableState = state { 0f }   Counter(       currentCount = counterState.value,       onCountChange = { counterState.value += 1 }      ) }

У меня есть MutableState, мы говорим в App: вот тебе текущее значение. App обладает state и никому не отдает его. По взгляду на App сразу можно понять, что происходит. Именно поэтому вы можете превратить val counterState с помощью делегатов в var. И в этом случае у вас вообще нет доступа к MutableState. Если вам не нужна обертка, то это, возможно, лучший способ создавать state.


@Composable fun App() {    var counterStateValue: Int by state { 0f }    Counter(        currentCount = counterStateValue,        onCountChange = { counterStateValue += 1 }        ) }

Важно понимать, что в этом случае мы теряем возможность перелейаутить, перерисовать или переобновить какой-то scope. Но гибкость = ответственность.


MutableState<T> это классная штука, но если вы используете его в публичных API, оно ведет к разделению владения state. Альтернатива, о которой я уже говорил, это controlled components.


Вторая альтернатива принимать State<T> только для чтения. То есть, если вам все еще нужно, чтобы ваш Counter очень умно скоупом поглощал MutableState<T>, вы можете отдать ему этот State<T> и читать его там, где вам нужно. Все будет так же, мы все перекомпозируем, перелейаутим, перерисуем. Просто State<T> не дает записать это туда, потому что там val.


Вы можете сделать так и все также через control input вызывать лямбду onCounterChange, чтобы другие люди могли обновлять этот State<T>.


Другая альтернатива иммутабельные классы и структуры данных, с ними таких проблем нет. И вообще MutableState использовать необязательно. Вы можете использовать иммутабельные классы, передавать их как параметры в ваши Composable-функции.


Вместо выводов


Декларативные UI-фреймворки абстрагируют обновления UI, дают нам UI как функцию от состояния и быстро обновляют его внутри себя.


На основе этих выводов можно понять, как работает любой такой фреймворк: React, Jetpack Compose и остальные.


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


Ссылки


Главная
Туториал
Багтрекер
Kotlin slack, #compose channel


Если доклад Матвея с Mobius вам интересен, вероятно, вам будет интересно и на новом Mobius, который пройдёт 13-16 апреля.

Там будет и доклад о проекте Compose for Desktop, и много другого для Android-разработчиков: про Gradle, корутины в Kotlin и так далее. Полную программу можно посмотреть на сайте конференции.
Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru