Защитите свой Android-код на будущее, часть 1: основы функционального и реактивного программирования

Опубликовано: 2022-08-31

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

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

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

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

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

Функциональное программирование (FP) — это шаблон, в котором вы строите свою программу как композицию функций, преобразуя данные из $A$ в $B$, в $C$ и т. д., пока не будет достигнут желаемый результат. В объектно-ориентированном программировании (ООП) вы говорите компьютеру, что делать, инструкция за инструкцией. Функциональное программирование отличается: вы отказываетесь от потока управления и вместо этого определяете «рецепт функций», чтобы получить свой результат.

Зеленый прямоугольник слева с текстом «Ввод: x» имеет стрелку, указывающую на светло-серый прямоугольник с надписью «Функция: f». Внутри светло-серого прямоугольника есть три цилиндра со стрелками, указывающими вправо: первый — светло-голубой с надписью «A(x)», второй — темно-синий с надписью «B(x)», а третий — темно-серый с надписью «C». (Икс)." Справа от светло-серого прямоугольника находится зеленый прямоугольник с текстом «Выход: f(x)». Внизу светло-серого прямоугольника есть стрелка, указывающая вниз на текст «Побочные эффекты».
Шаблон функционального программирования

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

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

Мы более подробно рассмотрим три ключевых аспекта FP:

  • ФП является декларативным.
  • FP использует композицию функций.
  • Функции FP чистые.

Хорошей отправной точкой для дальнейшего погружения в мир FP является Haskell, чисто функциональный язык со строгой типизацией. Я рекомендую Learn You a Haskell во благо! интерактивный учебник как полезный ресурс.

Ингредиент FP № 1: декларативное программирование

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

Императивное решение

Во-первых, давайте рассмотрим императивное решение этой проблемы в Kotlin:

 fun namesImperative(input: List<String>): List<String> { val result = mutableListOf<String>() val vowels = listOf('A', 'E', 'I', 'O', 'U','a', 'e', 'i', 'o', 'u') for (name in input) { // loop 1 var vowelsCount = 0 for (char in name) { // loop 2 if (isVowel(char, vowels)) { vowelsCount++ if (vowelsCount == 3) { val uppercaseName = StringBuilder() for (finalChar in name) { // loop 3 var transformedChar = finalChar // ignore that the first letter might be uppercase if (isVowel(finalChar, vowels)) { transformedChar = finalChar.uppercaseChar() } uppercaseName.append(transformedChar) } result.add(uppercaseName.toString()) break } } } } return result } fun isVowel(char: Char, vowels: List<Char>): Boolean { return vowels.contains(char) } fun main() { println(namesImperative(listOf("Iliyan", "Annabel", "Nicole", "John", "Anthony", "Ben", "Ken"))) // [IlIyAn, AnnAbEl, NIcOlE] }

Теперь мы проанализируем наше императивное решение с учетом нескольких ключевых факторов разработки:

  • Самое эффективное: это решение имеет оптимальное использование памяти и хорошо работает в анализе Big O (на основе минимального количества сравнений). В этом алгоритме имеет смысл анализировать количество сравнений между символами, потому что это основная операция в нашем алгоритме. Пусть $n$ будет количеством имен, а $k$ будет средней длиной имен.

    • Количество сравнений в худшем случае: $n(10k)(10k) = 100nk^2$
    • Объяснение: $n$ (цикл 1) * $10k$ (для каждого символа мы сравниваем с 10 возможными гласными) * $10k$ (мы снова выполняем isVowel() , чтобы решить, нужно ли заглавную букву записывать — опять же, в в худшем случае это сравнивается с 10 гласными).
    • Результат: Поскольку средняя длина имени не будет превышать 100 символов, можно сказать, что наш алгоритм выполняется за время $O(n)$ .
  • Сложный с плохой читабельностью: по сравнению с декларативным решением, которое мы рассмотрим далее, это решение намного длиннее и труднее для понимания.
  • Подвержен ошибкам: код изменяет result , vowelsCount и transformedChar ; эти мутации состояния могут привести к незаметным ошибкам, таким как забывание сбросить vowelsCount обратно в 0. Поток выполнения также может усложниться, и легко забыть добавить оператор break в третьем цикле.
  • Плохая ремонтопригодность: поскольку наш код сложен и подвержен ошибкам, рефакторинг или изменение поведения этого кода могут быть затруднены. Например, если бы задача была модифицирована для выбора имен с тремя гласными и пятью согласными, нам пришлось бы вводить новые переменные и менять циклы, оставляя много возможностей для ошибок.

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

Декларативное решение

Теперь, когда мы понимаем, чем декларативное программирование не является , давайте представим наше декларативное решение в Kotlin:

 fun namesDeclarative(input: List<String>): List<String> = input.filter { name -> name.count(::isVowel) >= 3 }.map { name -> name.map { char -> if (isVowel(char)) char.uppercaseChar() else char }.joinToString("") } fun isVowel(char: Char): Boolean = listOf('A', 'E', 'I', 'O', 'U', 'a', 'e', 'i', 'o', 'u').contains(char) fun main() { println(namesDeclarative(listOf("Iliyan", "Annabel", "Nicole", "John", "Anthony", "Ben", "Ken"))) // [IlIyAn, AnnAbEl, NIcOlE] }

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

  • Эффективность: и императивная, и декларативная реализации выполняются за линейное время, но императивная реализация немного более эффективна, потому что здесь я использовал name.count() , которая будет продолжать считать гласные до конца имени (даже после нахождения трех гласных). ). Мы можем легко решить эту проблему, написав простую hasThreeVowels(String): Boolean . В этом решении используется тот же алгоритм, что и в императивном решении, поэтому здесь применяется тот же анализ сложности: наш алгоритм выполняется за время $O(n)$ .
  • Краткость с хорошей читабельностью: императивное решение составляет 44 строки с большим отступом по сравнению с длиной нашего декларативного решения в 16 строк с небольшим отступом. Строки и табуляции — это еще не все, но с первого взгляда на два файла становится очевидным, что наше декларативное решение намного удобнее для чтения.
  • Менее подвержен ошибкам: в этом примере все неизменно. Мы преобразуем List<String> всех имен в List<String> имен с тремя или более гласными, а затем преобразуем каждое слово String в слово String с гласными в верхнем регистре. В целом отсутствие мутаций, вложенных циклов или разрывов, а также отказ от потока управления делают код проще и меньше места для ошибок.
  • Хорошая ремонтопригодность: вы можете легко рефакторить декларативный код благодаря его удобочитаемости и надежности. В нашем предыдущем примере (допустим, задача была изменена для выбора имен с тремя гласными и пятью согласными) простым решением было бы добавить следующие операторы в условие filter : val vowels = name.count(::isVowel); vowels >= 3 && name.length - vowels >= 5 val vowels = name.count(::isVowel); vowels >= 3 && name.length - vowels >= 5 .

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

Бонусное декларативное решение

Давайте взглянем на декларативную реализацию той же задачи на чисто функциональном языке, таком как Haskell, чтобы продемонстрировать, как она читается. Если вы не знакомы с Haskell, обратите внимание, что расширение . оператор в Haskell читается как «после». Например, solution = map uppercaseVowels . filter hasThreeVowels solution = map uppercaseVowels . filter hasThreeVowels переводится как «преобразование гласных в верхний регистр после фильтрации имен, содержащих три гласных».

 import Data.Char(toUpper) namesSolution :: [String] -> [String] namesSolution = map uppercaseVowels . filter hasThreeVowels hasThreeVowels :: String -> Bool hasThreeVowels s = count isVowel s >= 3 uppercaseVowels :: String -> String uppercaseVowels = map uppercaseVowel where uppercaseVowel :: Char -> Char uppercaseVowel c | isVowel c = toUpper c | otherwise = c isVowel :: Char -> Bool isVowel c = c `elem` vowels vowels :: [Char] vowels = ['A', 'E', 'I', 'O', 'U', 'a', 'e', 'i', 'o', 'u'] count :: (a -> Bool) -> [a] -> Int count _ [] = 0 count pred (x:xs) | pred x = 1 + count pred xs | otherwise = count pred xs main :: IO () main = print $ namesSolution ["Iliyan", "Annabel", "Nicole", "John", "Anthony", "Ben", "Ken"] -- ["IlIyAn","AnnAbEl","NIcOlE"]

Это решение работает аналогично нашему декларативному решению Kotlin с некоторыми дополнительными преимуществами: оно удобочитаемо, просто, если вы понимаете синтаксис Haskell, чисто функционально и лениво.

Ключевые выводы

Декларативное программирование полезно как для FP, так и для реактивного программирования (которое мы рассмотрим в следующем разделе).

  • Он описывает, «чего» вы хотите достичь, а не «как» этого добиться, с точным порядком выполнения утверждений.
  • Он абстрагирует поток управления программой и вместо этого фокусируется на проблеме с точки зрения преобразований (например, $A \стрелка вправо B \стрелка вправо C \стрелка вправо D$).
  • Он поощряет менее сложный, более лаконичный и более читаемый код, который легче рефакторить и изменять. Если ваш код Android не читается как предложение, вы, вероятно, делаете что-то не так.

Если ваш код Android не читается как предложение, вы, вероятно, делаете что-то не так.

Твитнуть

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

Ингредиент FP № 2: функциональная композиция

Композиция функций — это математическая концепция, лежащая в основе функционального программирования. Если функция $f$ принимает $A$ на вход и выдает $B$ на выходе ($f: A \rightarrow B$), а функция $g$ принимает $B$ и выдает $C$ ($g: B \rightarrow C$), то вы можете создать третью функцию, $h$, которая принимает $A$ и производит $C$ ($h: A \rightarrow C$). Мы можем определить эту третью функцию как композицию $g$ с $f$, также обозначаемую как $g \circ f$ или $g(f())$:

Синий прямоугольник с надписью «A» имеет стрелку «f», указывающую на синий прямоугольник с надписью «B», у которого есть стрелка «g», указывающую на синий прямоугольник с надписью «C». В ячейке «А» также есть параллельная стрелка «go f», указывающая прямо на ячейку «С».
Функции f, g и h, композиция g с f.

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

  1. isVowel :: Char -> Bool : Учитывая Char , вернуть, является ли это гласным или нет ( Bool ).
  2. countVowels :: String -> Int : Учитывая String , вернуть количество гласных в ней ( Int ).
  3. hasThreeVowels :: String -> Bool : Учитывая String , вернуть, содержит ли она по крайней мере три гласных ( Bool ).
  4. uppercaseVowels :: String -> String : Учитывая String , вернуть новую String с гласными в верхнем регистре.

Наше декларативное решение, достигнутое с помощью композиции функций, — это map uppercaseVowels . filter hasThreeVowels map uppercaseVowels . filter hasThreeVowels .

На верхней диаграмме есть три синих прямоугольника «[String]», соединенных стрелками, указывающими вправо. Первая стрелка помечена как «filter has3Vowels», а вторая помечена как «map uppercaseVowels». Ниже на второй диаграмме есть два синих прямоугольника слева, «Char» вверху и «String» внизу, указывающие на синий прямоугольник справа, «Bool». Стрелка от «Char» до «Bool» помечена как «isVowel», а стрелка от «String» до «Bool» помечена как «has3Vowels». Поле «Строка» также имеет стрелку, указывающую на себя, с надписью «Гласные в верхнем регистре».
Пример композиции функций с использованием нашей проблемы с именами.

Этот пример немного сложнее, чем простая формула $A \rightarrow B \ rightarrow C$, но он демонстрирует принцип композиции функций.

Ключевые выводы

Композиция функций — это простая, но мощная концепция.

  • Он обеспечивает стратегию решения сложных проблем, в которой проблемы разбиваются на более мелкие и простые шаги и объединяются в одно решение.
  • Он предоставляет стандартные блоки, позволяющие легко добавлять, удалять или изменять части окончательного решения, не беспокоясь о том, что что-то сломается.
  • Вы можете составить $g(f())$, если вывод $f$ соответствует типу ввода $g$.

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

Ингредиент FP №3: Чистота

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

Давайте рассмотрим пример псевдокода с использованием математических функций. Предположим, у нас есть функция makeEven , которая удваивает введенное целое число, чтобы сделать его четным, и что наш код выполняет строку makeEven(x) + x , используя ввод x = 2 . В математике это вычисление всегда переводилось бы в вычисление $2x + x = 3x = 3(2) = 6$ и является чистой функцией. Однако в программировании это не всегда так: если функция makeEven(x) x , удвоив его до того, как код вернул наш результат, тогда наша строка вычислила бы $2x + (2x) = 4x = 4(2) = 8$. и, что еще хуже, результат будет меняться с каждым makeEven .

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

  • Частичные функции: это функции, которые не определены для всех входных значений, таких как деление. С точки зрения программирования, это функции, которые выдают исключение: fun divide(a: Int, b: Int): Float исключение ArithmeticException для ввода b = 0 , вызванное делением на ноль.
  • Общие функции: эти функции определены для всех входных значений, но могут давать разные выходные данные или побочные эффекты при вызове с теми же входными данными. Мир Android полон тотальных функций: Log.d , LocalDateTime.now и Locale.getDefault — это лишь несколько примеров.

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

Совет: Чтобы сделать полную функцию чистой, вы можете абстрагировать ее побочные эффекты, передав их в качестве параметра функции более высокого порядка. Таким образом, вы можете легко протестировать общие функции, передав иммитированную функцию более высокого порядка. В этом примере используется аннотация @SideEffect из библиотеки Ivy FRP, которую мы рассмотрим позже в этом руководстве:

 suspend fun deadlinePassed( deadline: LocalDate, @SideEffect currentDate: suspend () -> LocalDate ): Boolean = deadline.isAfter(currentDate())

Ключевые выводы

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

  • Будьте осторожны с частичными функциями — они могут привести к сбою вашего приложения.
  • Составление тотальных функций не является детерминированным; это может привести к непредсказуемому поведению.
  • По возможности пишите чистые функции. Вы выиграете от повышенной стабильности кода.

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

Реактивное программирование 101

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

Два основных синих прямоугольника, «Наблюдаемый» и «Состояние», имеют два основных пути между собой. Первый — через «Наблюдает (прислушивается к изменениям)». Второй — через «Уведомления (о последнем состоянии)» в синее поле «UI (API в серверной части)», которое переходит через «Преобразует пользовательский ввод в» в синее поле «Событие», которое переходит через «Триггеры» в синий поле «Композиция функций» и, наконец, через «Производит (новое состояние)». Затем «Состояние» также соединяется с «Композицией функций» через «Действует как вход для».
Общий цикл реактивного программирования.

Базовыми элементами цикла реактивного программирования являются события, декларативный конвейер, состояния и наблюдаемые:

  • События — это сигналы из внешнего мира, обычно в форме пользовательского ввода или системных событий, которые запускают обновления. Целью события является преобразование сигнала во вход конвейера.
  • Декларативный конвейер — это композиция функций, которая принимает (Event, State) в качестве входных данных и преобразует эти входные данные в новое State (выходное): (Event, State) -> f -> g -> … -> n -> State . Конвейеры должны работать асинхронно, чтобы обрабатывать несколько событий, не блокируя другие конвейеры и не ожидая их завершения.
  • Состояния — это представление модели данных программного приложения в данный момент времени. Логика предметной области использует состояние для вычисления желаемого следующего состояния и выполнения соответствующих обновлений.
  • Наблюдаемые объекты прослушивают изменения состояния и информируют подписчиков об этих изменениях. В Android наблюдаемые обычно реализуются с помощью Flow , LiveData или RxJava , и они уведомляют пользовательский интерфейс об обновлениях состояния, чтобы он мог реагировать соответствующим образом.

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

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

Функциональное и реактивное программирование — две мощные парадигмы. Эти концепции выходят за рамки недолговечных библиотек и API-интерфейсов и улучшат ваши навыки программирования на долгие годы.

Более того, возможности FP и реактивного программирования увеличиваются при их сочетании. Теперь, когда у нас есть четкие определения функционального и реактивного программирования, мы можем собрать их воедино. Во второй части этого руководства мы определяем парадигму функционального реактивного программирования (FRP) и применяем ее на практике с помощью примера реализации приложения и соответствующих библиотек Android.

Блог Toptal Engineering выражает благодарность Таруну Гоялу за рассмотрение примеров кода, представленных в этой статье.