122 69 18MB
Russian Pages 512 Year 2024
грокаем
функциональное программирование
2024
ББК 32.973.2-018 УДК 004.42 П37
Плахта Михал
П37 Грокаем функциональное программирование. — СПб.: Питер, 2024. — 512 с.: ил. — (Серия «Библиотека программиста»).
ISBN 978-5-4461-2373-5 Вам кажется, что функциональное программирование — это нечто сложное, доступное только гуру программирования? Эта книга развенчает миф об элитарности и позволит любому программисту с легкостью разобраться в хитросплетениях кода. От знакомых и простых идей ООП вы перейдете к ФП, рассматривая его на простых примерах, захватывающих упражнениях и большом количестве иллюстраций. Вы начнете с решения простых и маленьких задач, иллюстрирующих базовые понятия, такие как чистые функции и неизменяемые данные, научитесь писать код, лишенный типичных ошибок, обусловленных наличием сложного распределенного состояния, разберетесь с подходами к реализации ввода- вывода, параллельного выполнения и потоковой передачи данных. К концу книги вы будете создавать ясный функциональный код, который легко читается, тестируется и сопровождается.
16+ (В соответствии с Федеральным законом от 29 декабря 2010 г. № 436-ФЗ.)
ББК 32.973.2-018 УДК 004.42
Права на издание получены по соглашению с Manning Publications. Все права защищены. Никакая часть данной книги не может быть воспроизведена в какой бы то ни было форме без письменного разрешения владельцев авторских прав. Информация, содержащаяся в данной книге, получена из источников, рассматриваемых издательством как надежные. Тем не менее, имея в виду возможные человеческие или технические ошибки, издательство не может гарантировать абсолютную точность и полноту приводимых сведений и не несет ответственности за возможные ошибки, связанные с использованием книги. В книге возможны упоминания организаций, деятельность которых запрещена на территории Российской Федерации, таких как Meta Platforms Inc., Facebook, Instagram и др. Издательство не несет ответственности за доступность материалов, ссылки на которые вы можете найти в этой книге. На момент подготовки книги к изданию все ссылки на интернет-ресурсы были действующими.
ISBN 978-1617291838 англ. ISBN 978-5-4461-2373-5
© 2022 by Manning Publications Co. All rights reserved. © Перевод на русский язык ООО «Прогресс книга», 2023 © Издание на русском языке, оформление ООО «Прогресс книга», 2023 © Серия «Библиотека программиста», 2023
Оглавление
Предисловие.............................................................................................................................................. 20 Благодарности......................................................................................................................................... 21 Об этой книге............................................................................................................................................. 22 Кому адресована книга.................................................................................................................... 22 Структура издания............................................................................................................................. 22 О примерах программного кода................................................................................................. 23 Об авторе............................................................................................................................................... 23
От издательства...................................................................................................................................... 24
Часть I. Функциональный инструментарий 1
Изучение функционального программирования
26
Возможно, вы купили эту книгу потому, что.......................................................................... 27 Что нужно знать перед тем, как начать.................................................................................... 28 Как выглядят функции...................................................................................................................... 29 Встречайте: функция......................................................................................................................... 30 Когда код лжет..................................................................................................................................... 31 Императивный и декларативный стили.................................................................................. 32 Кофе-брейк: императивный и декларативный стили....................................................... 33 Объяснение для кофе-брейка: императивный и декларативный стили................................................................................................................... 34 Насколько полезно изучать функциональное программирование........................... 35
Оглавление
6
Прыжок в Scala..................................................................................................................................... 36 Практика функций в Scala............................................................................................................... 37 Подготовка инструментов.............................................................................................................. 38 Знакомство с REPL.............................................................................................................................. 39 Пишем свои первые функции!...................................................................................................... 40 Как использовать эту книгу........................................................................................................... 41 Резюме..................................................................................................................................................... 42
2
Чистые функции
43
Зачем нужны чистые функции......................................................................................................44 Императивное решение.................................................................................................................. 45 Ошибка в коде......................................................................................................................................46 Передача копий данных.................................................................................................................. 47 Ошибка в коде... снова.....................................................................................................................48 Повторные вычисления вместо сохранения......................................................................... 49 Сосредоточение внимания на логике путем передачи состояния............................. 50 Куда пропало состояние................................................................................................................. 51 Разница между чистыми и нечистыми функциями............................................................ 52 Кофе-брейк: преобразование в чистую функцию.............................................................. 53 Объяснение для кофе-брейка: преобразование в чистую функцию......................... 54 Мы доверяем чистым функциям................................................................................................. 56 Чистые функции в языках программирования.................................................................... 57 Трудно оставаться чистым............................................................................................................. 58 Чистые функции и чистый код..................................................................................................... 59 Кофе-брейк: чистая или нечистая.............................................................................................. 60 Объяснение для кофе-брейка: чистая или нечистая......................................................... 61 Использование Scala для написания чистых функций...................................................... 62 Практика чистых функций в Scala............................................................................................... 63 Тестирование чистых функций....................................................................................................64 Кофе-брейк: тестирование чистых функций......................................................................... 65 Объяснение для кофе-брейка: тестирование чистых функций................................... 66 Резюме..................................................................................................................................................... 67
3
Неизменяемые значения 68 Топливо для двигателя..................................................................................................................... 69 Еще один пример неизменяемости........................................................................................... 70 Можно ли доверять этой функции............................................................................................. 71 Изменяемость опасна...................................................................................................................... 72
Оглавление
7
Функции, которые лгут... снова.................................................................................................... 73 Борьба с изменяемостью за счет использования копий................................................. 74 Кофе-брейк: обжигаемся на изменяемости.......................................................................... 75 Объяснение для кофе-брейка: обжигаемся на изменяемости..................................... 76 Знакомьтесь: общее изменяемое состояние......................................................................... 80 Влияние состояния на возможность программирования.............................................. 82 Работа с движущимися частями..................................................................................................84 Работа с движущимися частями в ФП....................................................................................... 85 Неизменяемые значения в Scala................................................................................................. 86 Вырабатываем интуитивное понимание неизменности................................................. 87 Кофе-брейк: неизменяемый тип String.................................................................................... 88 Объяснение для кофе-брейка: неизменяемый тип String............................................... 89 Постойте... Разве это не плохо?.................................................................................................... 90 Чисто функциональный подход к общему изменяемому состоянию........................ 91 Практика работы с неизменяемыми списками.................................................................... 93 Резюме..................................................................................................................................................... 94
4
Функции как значения
95
Реализация требований в виде функций................................................................................ 96 Нечистые функции и изменяемые значения наносят ответный удар....................... 97 Использование Java Streams для сортировки списка....................................................... 98 Сигнатуры функций должны рассказывать всю правду................................................... 99 Изменение требований.................................................................................................................100 Мы можем передавать код в аргументах!.............................................................................102 Использование значений Function в Java.............................................................................103 Использование синтаксиса Function для устранения повторяющегося кода....................................................................................................................104 Передача пользовательских функций в виде аргументов............................................105 Кофе-брейк: функции как параметры.....................................................................................106 Объяснение для кофе-брейка: функции как параметры...............................................107 Проблемы с чтением функционального кода на Java.....................................................108 Передача функций в Scala............................................................................................................109 Глубокое погружение в sortBy.................................................................................................... 110 Сигнатуры с параметрами-функциями в Scala....................................................................111 Передача функций в виде аргументов в Scala.................................................................... 112 Практика передачи функций...................................................................................................... 113 Использование декларативного программирования.................................................... 114 Передача функций пользовательским функциям............................................................. 115 Маленькие функции и их обязанности.................................................................................. 116
8
Оглавление
Передача встроенных функций................................................................................................. 117 Кофе-брейк: передача функций в Scala................................................................................. 118 Объяснение для кофе-брейка: передача функций в Scala............................................ 119 Чего еще можно добиться, просто передавая функции................................................120 Применение функции к каждому элементу списка.......................................................... 121 Применение функции к каждому элементу списка с помощью map.......................122 Знакомство с map.............................................................................................................................123 Практика map..................................................................................................................................... 124 Изучите однажды, используйте постоянно.........................................................................125 Возврат части списка, соответствующей условию............................................................ 126 Возврат части списка с помощью filter................................................................................... 127 Знакомство с filter.............................................................................................................................128 Практика filter....................................................................................................................................129 Насколько далеко мы зашли в нашем путешествии........................................................130 Не повторяйся?.................................................................................................................................. 131 Легко ли использовать мой API.................................................................................................. 132 Добавления нового параметра недостаточно................................................................... 133 Функции могут возвращать функции.....................................................................................134 Использование функций, возвращающих функции......................................................... 135 Функции — это значения..............................................................................................................136 Кофе-брейк: возврат функций.................................................................................................... 137 Объяснение для кофе-брейка: возврат функций..............................................................138 Проектирование функциональных API.................................................................................. 139 Итеративный дизайн функциональных API..........................................................................140 Возврат функций из возвращаемых функций..................................................................... 141 Как вернуть функцию из возвращаемой функции............................................................ 142 Использование гибкого API, построенного с использованием возвращаемых функций................................................................................................................143 Использование нескольких списков параметров в функциях....................................144 У нас есть карринг!...........................................................................................................................145 Практика каррирования...............................................................................................................146 Программирование с передачей функций в виде значений....................................... 147 Свертка множества значений в одно......................................................................................148 Свертка множества значений в одно с помощью foldLeft............................................149 Знакомство с foldLeft......................................................................................................................150 Каждый должен знать и уметь пользоваться foldLeft..................................................... 151 Практика foldLeft.............................................................................................................................. 152 Моделирование неизменяемых данных............................................................................... 153 Использование типов-произведений с функциями высшего порядка...................154 Более лаконичный синтаксис встроенных функций........................................................ 155 Резюме...................................................................................................................................................156
Оглавление
9
Часть II. Функциональные программы 5
Последовательные программы
158
Написание конвейерных алгоритмов..................................................................................... 159 Составление больших программ из мелких деталей......................................................160 Императивный подход................................................................................................................... 161 flatten и flatMap.................................................................................................................................162 Практические примеры использования flatMap...............................................................163 flatMap и изменение размера списка.....................................................................................164 Кофе-брейк: работа со списками списков............................................................................165 Объяснение для кофе-брейка: работа со списками списков.......................................166 Объединение в цепочку вызовов flatMap и map..............................................................167 Вложенные вызовы flatMap.........................................................................................................168 Значения, зависящие от других значений............................................................................169 Практика использования вложенных вызовов flatMap................................................. 170 Улучшенный синтаксис вложенных вызовов flatMap...................................................... 171 for-выражения во спасение!........................................................................................................ 172 Кофе-брейк: flatMap и for-выражение.................................................................................... 173 Объяснение кофе-брейка: flatMap и for-выражение....................................................... 174 Знакомство с for-выражениями................................................................................................. 175 Это не тот for, который вы знаете!............................................................................................ 176 Внутреннее устройство for-выражения................................................................................. 177 Более сложные for-выражения.................................................................................................. 178 Проверка всех комбинаций с помощью for-выражения................................................ 179 Приемы фильтрации.......................................................................................................................180 Кофе-брейк: методы фильтрации............................................................................................. 181 Объяснение для кофе-брейка: методы фильтрации.......................................................182 В поисках большей абстракции.................................................................................................183 Сравнение map, foldLeft и flatMap............................................................................................184 Использование for-выражений с множествами Set.........................................................185 Использование for-выражений с данными нескольких типов....................................186 Практика for-выражений..............................................................................................................187 Определение for-выражения... снова......................................................................................188 Использование for-выражений с типами, не являющимися коллекциями...........189 Избегайте значений null: тип Option.......................................................................................190 Парсинг в виде конвейера........................................................................................................... 191 Кофе-брейк: парсинг с Option.................................................................................................... 192 Объяснение кофе-брейка: парсинг с Option.......................................................................193 Резюме...................................................................................................................................................194
Оглавление
10
6
Обработка ошибок
195
Изящная обработка множества различных ошибок.......................................................196 Возможно ли вообще справиться со всеми ними............................................................. 197 Сортировка списка телесериалов по продолжительности их выхода....................198 Реализация требования сортировки......................................................................................199 Обработка данных, поступающих из внешнего мира.....................................................200 Функциональный дизайн: конструирование из небольших блоков........................201 Парсинг строк в неизменяемые объекты.............................................................................202 Парсинг списка — это парсинг одного элемента..............................................................203 Парсинг String в TvShow................................................................................................................204 А как насчет возможных ошибок?............................................................................................205 Является ли возврат null хорошей идеей?............................................................................206 Как наиболее изящно обрабатывать потенциальные ошибки.....................................207 Реализация функции, возвращающей Option.....................................................................208 Option вынуждает обрабатывать возможные ошибки...................................................209 Конструирование из небольших блоков............................................................................... 210 Функциональный дизайн составляется из маленьких блоков................................... 211 Написание небольшой безопасной функции, возвращающей Option.................... 212 Функции, значения и выражения.............................................................................................. 215 Практика безопасных функций, возвращающих Option................................................ 216 Как распространяются ошибки................................................................................................. 217 Значения представляют ошибки............................................................................................... 218 Option, for-выражения и контролируемые исключения................................................ 219 Не лучше ли использовать контролируемые исключения?.........................................220 Условное восстановление............................................................................................................221 Условное восстановление в императивном стиле...........................................................222 Условное восстановление в функциональном стиле......................................................223 Контролируемые исключения не комбинируются друг с другом, в отличие от значений Option!...................................................................................................224 Как работает orElse..........................................................................................................................225 Практика функциональной обработки ошибок.................................................................226 Функции комбинируются даже при наличии ошибок....................................................227 Компилятор напоминает, что ошибки должны быть обработаны............................228 Ошибки компиляции нам на пользу!.......................................................................................229 Преобразование списка значений Option в простой список......................................230 Пусть компилятор будет нашим проводником..................................................................231 ...но не будем слишком доверять компилятору!................................................................232 Кофе-брейк: стратегии обработки ошибок..........................................................................233 Объяснение для кофе-брейка: стратегии обработки ошибок....................................234 Две разные стратегии обработки ошибок...........................................................................235
Оглавление
11
Стратегия обработки ошибок «все или ничего»................................................................236 Свертка списка значений Option в значение Option со списком...............................238 Теперь мы знаем, как обработать множество ошибок одновременно!.................239 Как узнать, в чем причина неудачи..........................................................................................240 Мы должны передать информацию об ошибке в возвращаемом значении........ 241 Передача сведений об ошибке с использованием Either..............................................242 Переход на использование Either.............................................................................................243 Возврат Either вместо Option......................................................................................................244 Практика безопасных функций, возвращающих Either..................................................248 Навыки работы с Option пригодились и для работы с Either.......................................249 Кофе-брейк: обработка ошибок с использованием Either............................................250 Объяснение для кофе-брейка: обработка ошибок с использованием Either................................................................................................................251 Работа с Option/Either.....................................................................................................................252 Резюме...................................................................................................................................................253
7
Требования как типы
254
Моделирование данных для минимизации ошибок программистов.....................255 Хорошо смоделированные данные не лгут.........................................................................256 Проектирование с использованием уже известного нам (простых типов).................................................................................................................................257 Использование данных, смоделированных как простые типы.........................................258 Кофе-брейк: недостатки простых типов...............................................................................259 Объяснение для кофе-брейка: недостатки простых типов..........................................260 Проблемы использования простых типов в моделях.....................................................261 Использование простых типов усложняет нашу работу!..............................................262 Новые типы защищают от передачи параметров не на своих местах....................263 Использование новых типов в моделях данных................................................................264 Практика использования новых типов..................................................................................265 Гарантии возможности только допустимых комбинаций данных.............................266 Моделирование возможности отсутствия данных..........................................................267 Изменения в модели вызывают изменения в логике......................................................268 Использование данных, смоделированных как значения Option.............................269 Функции высшего порядка решают!.......................................................................................270 Вероятно, для решения этой проблемы существует функция высшего порядка!.............................................................................................................................271 Кофе-брейк: forall/exists/contains..............................................................................................272 Объяснение для кофе-брейка: forall/exists/contains........................................................273 Объединение понятий внутри одного типа-произведения........................................ 274 Моделирование конечных диапазонов значений............................................................275
12
Оглавление
Использование типа-суммы........................................................................................................276 Улучшение модели с помощью типов-сумм........................................................................277 Использование комбинации «тип-сумма + тип-произведение»...............................278 Типы-произведения + типы-суммы = алгебраические типы данных (ADT)...............279 Использование моделей на основе ADT в реализациях поведения (функциях)....................................................................................................................280 Деструктуризация ADT с помощью сопоставления с образцом................................281 Дублирование кода и правило DRY.........................................................................................282 Практика сопоставления с образцом.....................................................................................283 Новые типы, ADT и сопоставление с образцом в дикой природе.............................284 Что можно сказать о наследовании.........................................................................................285 Кофе-брейк: проектирование функциональных данных..............................................286 Объяснение для кофе-брейка: дизайн функциональных данных.............................288 Моделирование поведения........................................................................................................289 Моделирование поведения как данных................................................................................290 Реализация функций с параметрами на основе ADT.......................................................291 Кофе-брейк: проектирование и удобство сопровождения.........................................292 Объяснение для кофе-брейка: проектирование и удобство сопровождения.........................................................................................................293 Резюме...................................................................................................................................................294
8
Ввод-вывод как значения
296
Общение с внешним миром........................................................................................................297 Интеграция с внешним API...........................................................................................................298 Свойства операции ввода-вывода с побочным эффектом...........................................299 Императивное решение для кода ввода-вывода с побочными эффектами...........................................................................................................................................300 Проблемы императивного подхода к вводу-выводу......................................................301 Позволит ли ФП добиться большего успеха........................................................................302 Ввод-вывод и использование его результата ....................................................................303 Императивный ввод-вывод.........................................................................................................304 Вычисления как значения IO.......................................................................................................305 Значения IO.........................................................................................................................................306 Значения IO в реальной жизни..................................................................................................307 Удаляем загрязнения......................................................................................................................308 Использование значений, полученных из двух операций ввода-вывода.............309 Объединение двух значений IO в одно.................................................................................. 310 Практика создания и объединения значений IO............................................................... 311 Разделение задач при работе только со значениями..................................................... 312 Тип IO — вирусный.......................................................................................................................... 313
Оглавление
13
Кофе-брейк: работа со значениями......................................................................................... 314 Объяснение для кофе-брейка: работа со значениями................................................... 315 На пути к функциональному вводу-выводу......................................................................... 316 Как быть со сбоями ввода-вывода........................................................................................... 317 Программа, описываемая значением IO, может завершиться неудачей!.............. 318 Помните orElse?................................................................................................................................. 319 Отложенные и немедленные вычисления...........................................................................320 Реализация стратегий восстановления с использованием IO.orElse.......................321 Реализация запасных вариантов с использованием orElse и pure............................322 Практика восстановления после сбоев в значениях IO.................................................323 Где должны обрабатываться потенциальные сбои..........................................................324 На пути к функциональному вводу-выводу с обработкой сбоев..............................325 Чистые функции не лгут даже в небезопасном мире!.....................................................326 Функциональная архитектура...................................................................................................327 Использование IO для сохранения данных..........................................................................328 Кофе-брейк: использование IO для сохранения данных...............................................331 Объяснение для кофе-брейка: использование IO для сохранения данных.................................................................................................................332 Интерпретация всего как значений.........................................................................................333 Интерпретация повторных попыток как значений..........................................................334 Интерпретация неизвестного количества вызовов API как значения....................336 Практика восприятия функциональных сигнатур............................................................338 Резюме...................................................................................................................................................340
9
Потоки данных как значения
342
Бесконечность не предел.............................................................................................................343 Работа с неизвестным количеством значений.................................................................. 344 Работа с внешними нечистыми вызовами API (снова)....................................................345 Функциональный подход к проектированию.....................................................................346 Неизменяемые ассоциативные массивы...............................................................................347 Практика неизменяемых ассоциативных массивов.........................................................348 Сколько вызовов IO следует сделать......................................................................................349 Проектирование снизу вверх.....................................................................................................350 Расширенные операции со списком.......................................................................................351 Знакомство с кортежами..............................................................................................................352 Упаковка и отбрасывание.............................................................................................................353 Сопоставление с образцом для кортежей...........................................................................354 Кофе-брейк: ассоциативные массивы и кортежи..............................................................355 Объяснение для кофе-брейка: ассоциативные массивы и кортежи........................356 Функциональные пазлы................................................................................................................357
14
Оглавление
Следование за типами в восходящем проектировании................................................358 Прототипирование и тупики......................................................................................................359 Рекурсивные функции....................................................................................................................360 Бесконечность и «ленивые» вычисления.............................................................................361 Структура рекурсивной функции.............................................................................................362 Обработка отсутствия значения в будущем (с использованием рекурсии).........363 Полезность бесконечных рекурсивных вызовов.............................................................364 Кофе-брейк: рекурсия и бесконечность...............................................................................365 Объяснение для кофе-брейка: рекурсия и бесконечность..........................................366 Создание различных программ IO с использованием рекурсии..............................367 Использование рекурсии для выполнения произвольного количества вызовов .......................................................................................................................368 Проблемы рекурсивной версии...............................................................................................369 Потоки данных...................................................................................................................................370 Потоки данных в императивных языках................................................................................371 Вычисление значений по требованию...................................................................................372 Потоковая обработка, производители и потребители..................................................373 Типы Stream и IO................................................................................................................................ 374 Функциональный тип Stream......................................................................................................375 Потоки в ФП — это значения......................................................................................................376 Потоки — это рекурсивные значения....................................................................................377 Примитивные операции и комбинаторы..............................................................................378 Потоки значений IO.........................................................................................................................379 Бесконечные потоки значений IO.............................................................................................380 Запуск программы ради побочных эффектов....................................................................381 Практика работы с потоками......................................................................................................382 Использование преимуществ потоков..................................................................................383 Бесконечный поток вызовов API...............................................................................................384 Обработка ошибок ввода-вывода в потоках.......................................................................385 Разделение ответственности......................................................................................................386 Скользящие окна..............................................................................................................................387 Ожидание между вызовами ввода-вывода.........................................................................390 Упаковка потоков.............................................................................................................................392 Преимущества потоковой обработки....................................................................................393 Резюме...................................................................................................................................................394
10 Параллельное программирование
396
Потоки выполнения повсюду.....................................................................................................397 Декларативный параллелизм.....................................................................................................398 Последовательные и параллельные вычисления.............................................................399
Оглавление
15
Кофе-брейк: последовательное мышление........................................................................ 400 Объяснение для кофе-брейка: последовательное мышление...................................401 Необходимость пакетной обработки.....................................................................................402 Пакетная реализация.....................................................................................................................403 Параллельный мир......................................................................................................................... 404 Параллельное состояние..............................................................................................................405 Императивный параллелизм......................................................................................................406 Атомарные ссылки...........................................................................................................................408 Знакомство с Ref...............................................................................................................................409 Обновление значений Ref............................................................................................................ 410 Использование значений Ref...................................................................................................... 411 Делаем все параллельно............................................................................................................... 412 parSequence в действии................................................................................................................ 413 Практика одновременно выполняющихся значений IO................................................ 416 Моделирование параллелизма................................................................................................. 417 Программирование с использованием ссылок Ref и волокон................................... 418 Значения IO, работающие бесконечно...................................................................................420 Кофе-брейк: параллельное мышление..................................................................................421 Объяснение для кофе-брейка: параллельное мышление............................................422 Необходимость асинхронности................................................................................................423 Подготовка к асинхронному доступу.....................................................................................424 Проектирование функциональных асинхронных программ......................................425 Управление виртуальными потоками вручную.................................................................426 Программирование функциональных асинхронных программ................................427 Резюме...................................................................................................................................................428
Часть III. Прикладное функциональное программирование
11 Разработка функциональных программ
430
Заставьте это работать, заставьте работать правильно, заставьте работать быстро...............................................................................................................................431 Моделирование с использованием неизменяемых значений...................................433 Моделирование предметной области и ФП........................................................................434 Моделирование доступа к данным..........................................................................................435 Мешок функций.................................................................................................................................436 Бизнес-логика как чистая функция..........................................................................................437 Отделение задачи доступа к данным .....................................................................................438
16
Оглавление
Интеграция с API с применением императивных библиотек и IO.............................439 Следуя проекту.................................................................................................................................442 Реализация действий ввода в виде значений IO...............................................................443 Отделение библиотеки ввода-вывода от других задач.................................................445 Каррирование и инверсия управления................................................................................ 446 Функции как значения...................................................................................................................447 Связываем все вместе................................................................................................................... 448 Мы заставили решение работать.............................................................................................449 Заставляем работать правильно...............................................................................................450 Утечки ресурсов................................................................................................................................451 Управление ресурсами..................................................................................................................452 Использование значения Resource..........................................................................................453 Мы заставили работать правильно.........................................................................................454 Кофе-брейк: заставьте работать быстро...............................................................................455 Объяснение для кофе-брейка: заставьте работать быстро.........................................456 Резюме...................................................................................................................................................457
12 Тестирование функциональных программ
458
У вас есть тесты?...............................................................................................................................459 Тесты — это просто функции......................................................................................................460 Выбор функций для тестирования...........................................................................................461 Тестирование на примерах.........................................................................................................462 Практика тестирования на примерах.....................................................................................463 Создание хороших примеров.................................................................................................... 464 Генерирование свойств.................................................................................................................465 Тестирование на основе свойств..............................................................................................466 Тестирование путем предоставления свойств..................................................................467 Делегирование работы путем передачи функций...........................................................468 Интерпретация сбоев тестов на основе свойств..............................................................469 Ошибка в тесте или в программе?............................................................................................470 Нестандартные генераторы........................................................................................................471 Тестирование более сложных случаев в удобочитаемой форме..............................473 Поиск и исправление ошибок в реализации....................................................................... 474 Кофе-брейк: тесты на основе свойств....................................................................................475 Объяснение для кофе-брейка: тесты на основе свойств..............................................476 Свойства и примеры.......................................................................................................................477 Охват требований............................................................................................................................478 Тестирование требований с побочными эффектами......................................................479 Определение правильного теста для работы....................................................................480 Тесты для проверки использования данных.......................................................................481
Оглавление
17
Практика имитации внешних сервисов с использованием IO....................................483 Тестирование и дизайн................................................................................................................. 484 Тесты для проверки интеграции с сервисами....................................................................485 Локальные серверы как значения Resource в интеграционных тестах..................486 Написание изолированных интеграционных тестов......................................................487 Интеграция с сервисом — единая ответственность.......................................................488 Кофе-брейк: написание интеграционных тестов..............................................................489 Объяснение для кофе-брейка: написание интеграционных тестов........................490 Интеграционные тесты выполняются дольше...................................................................491 Интеграционные тесты на основе свойств..........................................................................492 Выбор правильного подхода к тестированию...................................................................493 Разработка через тестирование...............................................................................................494 Написание теста для несуществующей функции..............................................................495 Красный, зеленый, рефакторинг...............................................................................................496 Делаем тесты зелеными................................................................................................................497 Добавление красных тестов........................................................................................................498 Последняя итерация TDD.............................................................................................................499 Резюме...................................................................................................................................................500 Последний танец..............................................................................................................................501
Приложение А. Памятка по Scala.........................................................................................502 Определение значения.................................................................................................................502 Определение функции...................................................................................................................502 Вызов функции...................................................................................................................................502 Создание неизменяемых коллекций.......................................................................................502 Передача функции по имени......................................................................................................502 Передача анонимной функции..................................................................................................502 Передача анонимной функции с двумя параметрами....................................................502 Определение функций с несколькими списками параметров (каррирование)..................................................................................................................................503 Объект Math........................................................................................................................................503 Определение case-класса (типа-произведения) и создание его значения...........503 Точечный синтаксис для доступа к значениям в case-классе......................................503 Синтаксис определения анонимных функций с символом подчеркивания........503 Отсутствующая реализация: ???................................................................................................503 Интерполяция строк.......................................................................................................................503 Передача многострочной функции.........................................................................................503 Автоматическое определение типов и пустые списки...................................................503 Автоматическое определение типа и форсирование типа..........................................504 Определение for-выражения......................................................................................................504 Объекты как модули и объект как мешки типов и функций........................................504
18
Оглавление
Определение непрозрачного типа (newtype).....................................................................504 Импорт всего из объекта с использованием синтаксиса подчеркивания............504 Создание и использование значения непрозрачного типа.........................................504 Определение перечислений (типов-сумм)..........................................................................505 Сопоставление с образцом.........................................................................................................505 Именование параметров в конструкторах и функциях классов................................505 Использование интерфейсов trait для определения пакетов функций.................505 Создание экземпляров интерфейсов trait (пакетов функций)....................................505 Значение Unit в Scala.......................................................................................................................505 Создание неизменяемого типа Map........................................................................................506 Передача функций, соответствующих образцу..................................................................506 Игнорирование значения с помощью символа подчеркивания...............................506 Протяженность интервалов времени и большие числа................................................506
Приложение Б. Жемчужины функционального программирования..........................................................................................................................507
Моей дорогой семье: Марте, Войтеку и Оле, за хорошее настроение и вдохновение. Моим родителям: Рене и Лешеку, за все возможности, которые вы мне дали.
Предисловие
Привет! Спасибо за то, что приобрели эту книгу. Последние десять лет я плотно общался с программистами, обсуждая подходы к программированию, поддержку программного кода и то, как концепции функционального программирования постепенно внедряются в основные языки. Многие из этих профессиональных разработчиков говорили, что изучать функциональные концепции, описанные в существующих источниках, по-прежнему очень сложно, поскольку они или слишком упрощены, или чрезмерно усложнены. С помощью своей книги я попытаюсь заполнить этот пробел. Ее цель — дать пошаговое практическое руководство программистам, которые хотят получить полное представление об основных концепциях функционального программирования. Люди лучше всего учатся на примерах, поэтому в книге их с избытком. Теория в ней всегда на втором месте. Прочитав книгу, вы сможете писать полноценные программы, используя парадигму функционального программирования и с комфортом погружаясь в его теоретические основы. Особенно полезной эта книга будет, если вы уже создавали нетривиальные приложения с помощью императивного объектно-ориентированного языка программирования, такого как Java или Ruby. Большим плюсом будет, если вы работали в команде, которой приходилось сталкиваться с ошибками и преодолевать проблемы с сопровождением, поскольку именно в этой области функциональное программирование предстает во всем своем блеске. Надеюсь, вам понравится читать главы и выполнять упражнения так же, как мне нравилось их писать. Еще раз спасибо за интерес к книге! Михал Плахта (Michał Płachta) Рад приветствовать вас здесь!
Благодарности
Прежде всего я хотел бы поблагодарить сообщество Scala за непрекращающийся поиск инструментов и методов, которые помогают создавать легко сопровождаемое программное обеспечение. Все идеи, представленные в книге, являются результатом бесчисленных часов анализа кода, дискуссий, многочисленных статей в блогах, горячих презентаций и анализа сбоев в промышленном окружении. Спасибо всем за ваш интерес. Я хочу поблагодарить мою семью, особенно мою жену Марту, за то, что поддерживала меня во время работы над этой книгой, за вдохновение и любовь. Большое спасибо моим замечательным детям, Войтеку и Оле, за то, что не давали мне слишком долго засиживаться за компьютером. Эта книга — результат труда многих людей. Я хотел бы поблагодарить сотрудников издательства Manning: рецензента Майкла Стивенса (Michael Stephens); редактора Берта Бейтса (Bert Bates); редактора-консультанта по аудитории Дженни Стаут (Jenny Stout); технического редактора Джоша Уайта (Josh White); литературного редактора Кристиан Берк (Christian Berk); производственного редактора Кери Хейлз (Keri Hales); технического корректора Убальдо Пескаторе (Ubaldo Pescatore); корректора Кэти Теннант (Katie Tennant) и всех других, кто помогал готовить эту книгу к печати. Спасибо всем рецензентам: Ахмаду Назиру Радже (Ahmad Nazir Raja), Эндрю Колье (Andrew Collier), Анджану Бакчу (Anjan Bacchu), Чарльзу Дэниэлсу (Charles Daniels), Крису Коттмайеру (Chris Kottmyer), Флавио Диезу (Flavio Diez), Джеффри Бонсеру (Geoffrey Bonser), Джанлуиджи Спаньуоло (Gianluigi Spagnuolo), Густаво Филипе Рамосу Гомесу (Gustavo Filipe Ramos Gomes), Джеймсу Ньика (James Nyika), Джеймсу Уотсону (James Watson), Джанин Джонсон (Janeen Johnson), Джеффу Лиму (Jeff Lim), Джоселин Леконт (Jocelyn Lecomte), Джону Гриффину (John Griffin), Джошу Коэну (Josh Cohen), Керри Койчу (Kerry Koitzsch), Марку Клифтону (Marc Clifton), Майку Теду (Mike Ted), Николаосу Вогиацису (Nikolaos Vogiatzis), Полу Брауну (Paul Brown), Райану Б. Харви (Ryan B. Harvey), Сандеру Росселю (Sander Rossel), Скотту Кингу (Scott King), Шрихари Шридхарану (Srihari Sridharan), Тейлору Долезалу (Taylor Dolezal), Тайлеру Коуэллису (Tyler Kowallis) и Уильяму Уилеру (William Wheeler); ваши предложения помогли улучшить эту книгу.
Об этой книге
Кому адресована книга Книга адресована читателям, имеющим хотя бы небольшой опыт разработки коммерческого программного обеспечения на любом из основных объектно-ориентированных языков программирования, таких как Java. Учебные примеры написаны на языке Scala, но, вообще говоря, эта книга — не о Scala. Никаких предварительных знаний этого языка или функционального программирования не требуется.
Структура издания Книга разделена на три части. В первой из них закладываются основы. Мы рассмотрим инструменты и методы, повсеместно используемые в функциональном программировании (ФП). В главе 1 мы поговорим о том, как лучше изучать ФП с помощью этой книги. В главе 2 обсудим разницу между чистыми и нечистыми функциями. В главе 3 вы познакомитесь с неизменяемыми значениями. Наконец, в главе 4 вы увидите, что чистые функции — это всего лишь неизменяемые значения, и познакомитесь со всеми сверхвозможностями, которые дает нам этот факт. Во второй части книги мы будем использовать только неизменяемые значения и чистые функции для решения реальных задач. В главе 5 представлена самая важная функция в ФП и показано, как она помогает создавать последовательные значения (и программы) в краткой и удобочитаемой форме. В главе 6 показано, как создавать последовательные программы, которые могут возвращать ошибки. В главе 7 вы познакомитесь с функцио нальным дизайном программного обеспечения. В главе 8 вы научитесь безопасным и функциональным способом обращаться с нечистым внешним миром, имеющим побочные эффекты. В главе 9 будут представлены потоки данных и системы потоковой передачи. Мы создадим потоки из сотен тысяч элементов, используя функциональный подход. В главе 10 мы наконец создадим несколько функциональных и безопасных параллельных программ.
Об этой книге
23
В третьей части мы реализуем настоящее функциональное приложение, использующее Wikidata в качестве источника данных. Мы используем его, чтобы выделить все, что рассмотрели в предыдущих частях. В главе 11 мы создадим модель неизменяемых данных и используем соответствующие типы, включая IO, для интеграции с Wikidata, применим кеширование и задействуем несколько потоков выполнения, чтобы ускорить работу приложения. Все эти задачи мы завернем в чистые функции и вдобавок посмотрим, как повторно использовать приемы объектно-ориентированного проектирования в функциональном мире. В главе 12 показано, как тестировать приложение, разработанное в главе 11, и то, насколько просто его поддерживать даже при значительном изменении требований. В конце мы рассмотрим набор упражнений, которые помогут вам освоить функциональное программирование.
О примерах программного кода Эта книга содержит множество примеров исходного кода как в листингах, так и внутри текста. В обоих случаях исходный код оформлен моноширинным шрифтом, чтобы можно было отделить его от обычного текста. Иногда код выделен жирным шрифтом — так подчеркнуты изменения по сравнению с предыдущими шагами в главе, например, когда в существующую строку кода добавляется новая функция. Во многих случаях исходный код был переформатирован; мы добавили разрывы строк и переделали отступы, чтобы уместить строки кода по ширине страницы. Многие листинги сопровождаются дополнительными примечаниями, в которых выделяются важные понятия. Выполняемые фрагменты кода можно найти в онлайн-версии этой книги, доступной по адресу https://livebook.manning.com/book/grokking-functional-programming. Код всех примеров также доступен для скачивания с сайта Manning: https://www.manning.com/books/grokkingfunctional-programming и из репозитория GitHub: https://github.com/miciek/grokkingfp-examples. Все книжные ресурсы, включая дополнительные материалы, доступны по адресу https:// michalplachta.com/book/.
Об авторе Михал Плахта — опытный инженер-программист и активный участник сообщества функционального программирования. Регулярно выступает на конференциях, проводит семинары, организует встречи и ведет блоги о создании поддерживаемого программного обеспечения.
От издательства
Ваши замечания, предложения, вопросы отправляйте по адресу [email protected] (издательство «Питер», компьютерная редакция). Мы будем рады узнать ваше мнение! На веб-сайте издательства www.piter.com вы найдете подробную информацию о наших книгах.
Часть I Функциональный инструментарий
В этой части закладываются основы. Здесь вы познакомитесь с инструментами и методами, широко распространенными в функциональном программировании. Все, что мы рассмотрим здесь, будет повторно использовано в других главах, и вы сможете применять эти знания далее в вашей карьере. В главе 1 мы обсудим основы и согласуем наши взгляды на подходы к изучению тем, представленных в этой книге. Мы настроим среду разработки, напишем немного кода и решим несколько первых упражнений. В главе 2 мы обсудим различия между чистыми и нечистыми функциями. При этом мы рассмотрим несколько императивных примеров, в которых показаны опасности, и фрагменты функционального кода, помогающего их ослабить. В главе 3 будет представлен партнер чистых функций: неизменяемые значения. Мы увидим, как одно не может жить без другого и что оба они определяют функциональное программирование. Наконец, в главе 4 вы увидите, что чистые функции — это просто значения, и познакомитесь со всеми сверхвозможностями, которые дает нам данный факт. Это позволит нам все объединить и собрать наш первый полноценный функциональный инструментарий.
Изучение функционального программирования
В этой главе вы узнаете: • кому адресована эта книга; • что такое функция; • насколько полезно функциональное программирование; • как установить необходимые инструменты; • как пользоваться этой книгой.
Я могу лишь показать конкретные примеры и позволить вам самим сделать вывод о том, что это такое. Ричард Хэмминг (Richard Hamming), Learning to Learn
1
Глава 1
I
Изучение функционального программирования
27
Возможно, вы купили эту книгу потому, что... Вам интересно больше узнать о функциональном программировании Вы слышали о функциональном программировании, читали о нем в «Википедии» и ищете новые книги по этой теме. Возможно, мудреные математические объяснения механики работы кода раздражали вас, но вы не утратили любопытства.
{
Я мечтал написать наименее пугающую книгу по функциональному программированию, которая отличалась бы простотой, практичностью и не заставляла раздражаться.
}
Уже пытались освоить функциональное программирование Вы уже пробовали изучать функциональное программирование, но многое все еще остается за гранью вашего понимания. Освоив одну ключевую концепцию, вы тут же сталкиваетесь со следующим препятствием. И оно требует освоения многих других понятий, которые позволили бы изучить его.
{
Изучение функционального программирования должно быть приятным. Эта книга побуждает двигаться маленькими шагами и предполагает, что ваши эндорфины будут поддерживать вас.
1
2
}
Вы все еще сомневаетесь Вы уже много лет пишете программы на объектно-ориентированном или императивном языке программирования. Вы испытали воздействие рекламной шумихи вокруг функционального программирования, прочитали несколько сообщений в блогах и даже попробовали программировать. Но все еще не видите, как эта парадигма может улучшить вашу жизнь как программиста. Данная книга в значительной степени сосредоточена на практическом применении функционального программирования. Это позволит добавить несколько функциональных концепций в ваш набор инструментов. Вы сможете применять их независимо от используемого языка программирования.
Что-то иное Какими бы ни были причины, в книге мы попытаемся рассмотреть их под другим углом. Основное внимание уделим обучению в ходе экспериментов и игр. Книга побуждает задавать вопросы и придумывать ответы с помощью программирования. Такой подход поможет вам выйти на новый профессиональный уровень. Надеюсь, вам понравится!
3
28 Часть I
I
Функциональный инструментарий
Что нужно знать перед тем, как начать Я предполагаю, что вы уже имеете опыт разработки программного обеспечения на любом из популярных языков, таких как Java, C++, C#, JavaScript или Python. Это довольно расплывчатая предпосылка, поэтому ниже приведены несколько простых контрольных пунктов, которые помогут вам убедиться, что мы находимся на одной волне.
Вам будет более комфортно, если • Вы знакомы с основными понятиями объектно-ориентированного программирования, такими как классы и объекты. • Сможете прочитать и понять такой код: class Book { private String title; private List authors;
}
public Book(String title) { this.title = title; this.authors = new ArrayList(); }
}
public void addAuthor(Author author) { this.authors.add(author); }
Объект Book имеет название и список объектов Author Конструктор: создает новый объект Book с названием, но без списка авторов
Добавляет объект Author в этот экземпляр Book
Книга будет максимально полезной, если • Вам приходилось сталкиваться с проблемами стабильности, тестируемости, регрессии или интеграции ваших программных модулей. • У вас возникали проблемы с отладкой кода, подобного этому: public void makeSoup(List ingredients) { if(ingredients.contains("water")) { add("water"); } else throw new NotEnoughIngredientsException(); heatUpUntilBoiling(); addVegetablesUsing(ingredients); waitMinutes(20); }
Вероятно, это будет не самый лучший суп, который вы когда-либо пробовали...
Вам не нужно • Быть экспертом в объектно-ориентированном программировании. • Быть мастером Java/C++/C#/Python. • Знать что-либо о любом функциональном языке программирования, таком как Kotlin, Scala, F#, Rust, Clojure или Haskell.
Глава 1
I
Изучение функционального программирования
29
Как выглядят функции Без дальнейших церемоний сразу перейдем к коду! У нас пока нет всех необходимых инструментов, но это нас не остановит, не так ли? Вот несколько разных функций. У них есть нечто общее: они получают какие-то значения на входе, что-то делают и, возможно, возвращают значения на выходе. Что ж, посмотрим: public static int add(int a, int b) { return a + b; }
Получает два целых числа и возвращает сумму
public static char getFirstCharacter(String s) { return s.charAt(0); } public static int divide(int a, int b) { return a / b; }
Получает строку и возвращает ее первый символ
Получает два целых числа, делит первое на второе и возвращает результат
public static void eatSoup(Soup soup) { // TODO: алгоритм «употребления супа» }
Получает объект Soup, что-то делает с ним и ничего не возвращает
Зачем все эти public static Возможно, вам интересно узнать, зачем в каждом определении используются модификаторы public static. Скажу так: это сделано нарочно. Все функции, используемые в данной книге, являются статическими (то есть для их выполнения не нужен экземпляр объекта). Их может вызывать кто угодно и откуда угодно, если у вызывающей стороны есть необходимые входные параметры. Эти функции работают только с данными, которые передает вызывающая сторона, и не более того. Конечно, это имеет важные последствия, которые мы рассмотрим далее в книге. А пока вспомним, что под словом «функция» мы подразумеваем общедоступную статическую функцию, которую можно вызвать из любого места.
Короткое упражнение Реализуйте две следующие функции: public static int increment(int x) { // TODO } public static String concatenate(String a, String b) { // TODO }
Ответы return x + 1; return a + b;
30 Часть I
I
Функциональный инструментарий
Встречайте: функция Как видите, функции бывают разными. По сути, каждая функция состоит из сигнатуры (заголовка) и тела, реализующего сигнатуру. public static int add(int a, int b) { return a + b; Тело }
Сигнатура
В книге мы сосредоточимся на функциях, возвращающих значения, поскольку, как вы увидите далее, эти функции лежат в основе функцио нального программирования. Мы не будем использовать функции, которые ничего не возвращают (например, void). Входное значение
f
Выходное значение
Функцию можно рассматривать как «черный ящик», который получает входное значение, выполняет с ним какие-то операции и возвращает выходное значение. Внутри «черного ящика» находится тело. Типы и имена входных и выходных значений — часть сигнатуры. То есть функцию add можно представить так: int a int b
add
int
Сигнатура и тело На диаграммах выше реализация функции (ее тело) спрятана внутри «черного ящика», а сигнатура видна всем. Это очень важное различие. Если одной сигнатуры достаточно, чтобы понять, что происходит внутри «черного ящика», то это большая победа для программистов, читающих код, поскольку им не нужно открывать «ящик» и анализировать, как он реализован, прежде чем использовать его.
ЭТО ВА Ж Н О! В ФП мы склонны больше сосредотачиваться на сигнатурах, чем на телах функций.
Короткое упражнение Нарисуйте функциональную диаграмму для следующей функции. Что внутри «черного ящика»? public static int increment(int x)
Ответ В «черный ящик» входит одна стрелка с подписью int x и выходит одна стрелка с подписью int. Реализация: return x + 1;
Глава 1
I
Изучение функционального программирования
31
Когда код лжет... Одни из самых сложных проблем, с которыми сталкивается программист, возникают, когда код делает то, чего делать не должен. Эти проблемы часто связаны с тем, что сигнатура говорит совсем о другом, не о том, что делает тело. Чтобы было понятнее, вернемся к четырем функциям, показанным выше: public static int add(int a, int b) { return a + b; } public static char getFirstCharacter(String s) { return s.charAt(0); } public static int divide(int a, int b) { return a / b; } public static void eatSoup(Soup soup) { // TODO: алгоритм «употребления супа» }
int a int b
String s
add
int
get First Character
char
int a int b
Soup soup
Удивительно, но три из этих четырех функций лгут.
divide
int
eat Soup
В О
Функции могут лгать?
К сожалению, да. Некоторые из предыдущих функций лгут совершенно серьезно. Обычно ложь состоит в том, что сигнатура рассказывает не обо всем, что делает тело.
Функция getFirstCharacter обещает, что, получив строку (значение типа String), вернет символ — значение типа char. Однако если мы решимся на подлог и передадим функции пустую строку, то она не вернет никакого символа, а вызовет исключение! Функция divide не вернет обещанного целого числа, если в аргументе b ей передать 0. Функция eatSoup обещает съесть предложенный нами суп, но если мы передадим его ей, то она ничего не сделает и вернет void (то есть ничего не вернет). Эта реализация характерна для большинства потомков. Функция add, напротив, вернет целое число (значение типа int, какие бы числа a и b мы ни передали ей), как и было обещано! Мы можем смело полагаться на такие функции! В книге мы сосредоточимся на функциях, которые не лгут. Сигнатуры которых рассказывают все об их телах. Вы узнаете, как можно создавать реальные программы, используя только такие функции.
Прочитав эту книгу, вы на учитесь с легкостью преобразовывать ваши функции в их надежные функциональные аналоги! ЭТО ВА Ж Н О! Функции, которые не лгут, — очень важная особенность ФП.
32 Часть I
I
Функциональный инструментарий
Императивный и декларативный стили Некоторые программисты делят языки программирования на две основные парадигмы: императивную и декларативную. Попробуем понять разницу между ними, выполнив простое упражнение. Представьте, что нам нужно создать функцию, вычисляющую счет в какой-то игре со словами. Игрок вводит слово, и функция возвращает счет. За каждый символ в слове дается один балл.
Императивный подсчет баллов public static int calculateScore(String word) int score = 0; for(char c : word.toCharArray()) { score++; } return score; }
{
Разработчик читает: «Перед началом вычислений сумма баллов инициализируется нулем, затем начинается перебор символов в слове, и с каждым новым символом сумма увеличивается на единицу. В конце вернуть получившуюся сумму»
Императивное программирование фокусируется на том, как должен вычисляться результат. Все дело в установлении конкретных шагов в определенном порядке. Мы достигаем желаемого результата, предоставляя подробный пошаговый алгоритм.
Декларативный подсчет баллов public static int wordScore(String word) return word.length(); }
{
Разработчик читает: «Сумма баллов за слово — это его длина»
Декларативный подход фокусируется на том, что нужно сделать, а не как. В данном случае мы говорим, что нам нужна длина этой строки, и возвращаем эту длину как сумму баллов для этого конкретного слова. Соответственно, мы можем просто использовать метод length из класса String в Java, чтобы получить количество символов, и нам неважно, как оно было вычислено. Мы также заменили имя функции calculateScore на wordScore. Разница может показаться незначительной, но использование существительного (word — «слово») заставляет наш мозг переключиться в декларативный режим и сосредотачиваться на том, что нужно сделать, а не на деталях того, как этого достичь. Декларативный код обычно лаконичнее и понятнее императивного. Несмотря на то что многие внутренние компоненты, такие как JVM или процессор, строго императивны, мы, будучи разработчиками приложений, можем активно использовать декларативный стиль и скрывать внутреннюю императивность, как сделали это, применив метод length. В книге вы научитесь писать программы, используя декларативный подход.
Кстати, SQL — тоже преиму щественно декларативный язык. На нем вы обычно указываете, какие данные вам нужны, и неважно, как они будут получены (по крайней мере во время разработки).
Глава 1
I
Изучение функционального программирования
33
Кофе-брейк: императивный и декларативный стили Добро пожаловать в самый первый раздел книги с упражнениями «Кофе-брейк»! Сейчас мы постараемся запомнить разницу между императивным и декларативным подходами. Что такое кофе-брейки в этой книге В книге вам встретятся упражнения нескольких видов. С первым вы уже сталкивались: короткие упражнения. Они отмечены большим знаком вопроса и разбросаны по всей книге. Эти упражнения легко выполнить без компьютера и листа бумаги с ручкой. Второй вид упражнений — кофе-брейки. Для их выполнения вам понадобится листок бумаги с ручкой или компьютер, какое-то время, а также желание поразмяться. В каждом таком упражнении мы постараемся затронуть определенную тему. Они играют важную роль в процессе обучения. Некоторые кофе-брейки могут оказаться для вас довольно сложными, однако не волнуйтесь, если не смогли решить их. На следующей странице вы всегда найдете ответ и объяснение. Но прежде, чем читать его, попытайтесь выполнить упражнение самостоятельно, потратив 5–10 минут. Это крайне важно для понимания темы, даже если вы не можете в ней разобраться.
Тяжело в учении, легко в бою!
В этом упражнении вам предлагается улучшить императивную функцию calculateScore и декларативную функцию wordScore. Реализуйте в них новое требование: сумма баллов за слово теперь должна быть равна количеству символов, отличных от 'a'. В качестве основы используйте следующий код: public static int calculateScore(String word) int score = 0; for(char c : word.toCharArray()) { score++; } return score; } public static int wordScore(String word) return word.length(); }
{
{
Не забудьте немного подумать над решением, прежде чем переходить к следующей странице. Лучше всего записать ответ на листе бумаги или в текстовом редакторе на компьютере
Измените функции выше так, чтобы выполнялись следующие условия: calculateScore("imperative") == 9 calculateScore("no") == 2
wordScore("declarative") == 9 wordScore("yes") == 3
34 Часть I
I
Функциональный инструментарий
Объяснение для кофе-брейка: императивный и декларативный стили Надеюсь, вам понравился ваш первый кофе-брейк. Теперь проверим ваши ответы. Начнем с императивного решения.
Императивное решение Императивный подход требует реализовать алгоритм непосредственно — «как». Что ж, нам нужно просмотреть все символы в слове, увеличить сумму баллов при встрече любого символа, отличного от 'a', и вернуть результат в конце. public static int calculateScore(String word) int score = 0; for(char c : word.toCharArray()) { if(c != ‘a’) score++; } return score; }
{
Вот и все! Мы просто добавили условный оператор if внутри цикла for.
Декларативное решение Декларативный подход фокусируется на том, что нужно сделать. В этом случае требование определяется декларативно: «сумма баллов за слово должна быть равна количеству символов, отличных от 'а'». Это требование можно реализовать почти напрямую: public static int wordScore(String word) return word.replace("a", "").length(); }
{
В качестве альтернативы можно добавить вспомогательную функцию. public static String stringWithoutChar(String s, char c) { return s.replace(Character.toString(c), ""); } public static int wordScore(String word) { return stringWithoutChar(word, 'a').length(); }
Возможно, вы придумали другое решение. Любое решение приемлемо, если оно фокусируется на строке без as (что), а не на for и if (как).
ЭТО ВА Ж Н О! В ФП мы чаще фокусируемся на том, что должно происходить, а не на том, как это должно происходить.
Глава 1
I
Изучение функционального программирования
35
Насколько полезно изучать функциональное программирование Функциональное программирование — это программирование с использованием функций: • сигнатуры которых не лгут; • имеющих максимально декларативные тела. В данной книге мы будем все глубже вникать в эти темы и в конце концов научимся создавать программы, даже не задумываясь о старых привычках. Уже одно это меняет правила игры. Однако на этом преимущества не заканчиваются. Есть и другие полезные навыки, которые вы приобретете, изучая функциональное программирование по этой книге.
Стиль написания кода
ФП Функциональные концепты
Стиль написания кода на любом языке До сих пор мы писали функции на Java, несмотря на то что этот язык считается языком объектно-ориентированного и императивного программирования. Оказывается, методы и особенности декларативного и функционального программирования постепенно проникают в Java и другие традиционно императивные языки. Вы уже можете использовать некоторые приемы на выбранном вами языке.
Функциональные концепции одинаковы в языках ФП Эта книга посвящена общим, универсальным особенностям и методам функционального программирования. Это означает, что, изучив некую концепцию на примере Scala, вы сможете применять ее во многих других языках ФП. Мы фокусируемся на аспектах, общих для многих языков ФП, а не на особенностях одного языка.
Функциональное и декларативное мышление Один из наиболее важных навыков, которые вы приобретете, — иной подход к решению задач программирования. Овладев всеми этими навыками, вы добавите еще один очень эффективный инструмент в свой арсенал инженера-программиста. Эта новая перспектива определенно поможет вам расти в вашей профессии, независимо от того, как ваша история развивалась до сих пор.
Функциональное мышление
36 Часть I
I
Функциональный инструментарий
Прыжок в Scala Большинство примеров и упражнений в этой книге написаны на Scala. Если вы не знакомы с этим языком, то не волнуйтесь, очень скоро вы освоите его основы.
В О
Почему Scala?
Этот выбор продиктован прагматизмом. Язык Scala обладает всеми особенностями функционального программирования, но его синтаксис по-прежнему похож на синтаксис многих императивных языков. Эта его особенность должна упростить процесс обучения. Помните, для нас важно тратить как можно меньше времени на синтаксис. Мы будем изучать Scala ровно настолько, насколько это необходимо, чтобы иметь возможность обсуждать крупные концепции функционального программирования. Мы выучим синтаксис ровно настолько, насколько это нужно для реализации функциональных решений больших, реальных проблем программирования. Наконец, мы будем относиться к Scala просто как к учебному инструменту. Прочитав книгу, вы сами решите, достаточно ли данного языка для ваших повседневных задач, или лучше использовать какой-то другой язык функционального программирования с более сумасшедшим синтаксисом, но с теми же концепциями.
Мы по-прежнему будем использовать Java для представления императивных примеров, а Scala будем применять только для представления полностью функциональных фрагментов кода.
Встречайте: функция... в Scala Выше в этой главе вы познакомились с нашей первой функцией, написанной на Java. Функция принимала два целочисленных параметра и возвращала их сумму. public static int add(int a, int b) { return a + b; }
Пришло время переписать эту функцию на Scala и познакомиться с новым синтаксисом. Определение функции с именем add
Параметр a имеет тип Int
Параметр b Функция add имеет возвращает тип Int значение типа Int
def add(a: Int, b: Int): Int = { a + b Тело состоит Тело функции add определено } из единственного выражения
внутри (необязательных) фигурных скобок
Scala позволяет опускать фигурные скобки (они необязательны), окружающие тело функции. Если программист опустил фигурные скобки, то компилятор будет ориентироваться на отступы, подобно языку Python. Вы можете использовать эту особенность, если хотите. Однако мы будем добавлять фигурные скобки в примеры в этой книге, чтобы, как уже говорилось выше, не тратить слишком много времени на изучение различий в синтаксисе
Глава 1
I
Изучение функционального программирования
37
Практика функций в Scala Теперь, познакомившись с синтаксисом определения функций в Scala, попробуем переписать некоторые из предыдущих Java-функций на Scala. Надеюсь, это облегчит переход. Какую роль играют разделы «Практика» в этой книге В книге предлагаются упражнения трех видов. С двумя из них вы уже сталкивались: короткие упражнения (небольшие упражнения, отмеченные большим знаком вопроса и легко решаемые в уме) и кофе-брейки (более сложные упражнения, вынуждающие подумать о концепциях с другой точки зрения и использовать для решения бумагу с ручкой или компьютер). Третий тип упражнений — практика. Они самые утомительные, поскольку в значительной степени основаны на повторении. В них обычно будет предлагаться от трех до пяти заданий, которые решаются совершенно одинаково. Это сделано нарочно — для тренировки мышечной памяти. Все, что вы узнаете в этих разделах, широко используется в книге, поэтому важно внедрить эти знания в практику как можно быстрее.
Мы ничего не говорили об инструментах, которые нужно установить на компьютеры, чтобы писать код на Scala, поэтому решите эти задания на листе бумаги
Ваша задача — переписать три следующие Java-функции на Scala: public static int increment(int x) { return x + 1; } public static char getFirstCharacter(String s) { return s.charAt(0); }
Примечания
public static int wordScore(String word) return word.length(); }
• Символьные значения
Ответы
{
def increment(x: Int): Int = { x + 1 } def getFirstCharacter(s: String): Char = { s.charAt(0) } def wordScore(word: String): Int = { word.length() }
• Тип String в Scala имеет точно такой же API, как и тип String в Java.
в Scala имеют тип Char.
• Целочисленный тип в Scala — Int.
• Точки с запятой в Scala можно опустить.
38 Часть I
I
Функциональный инструментарий
Подготовка инструментов Пришло время начать писать функциональный код Scala на компьютере. Для этого нужно установить некоторое программное обеспечение. Поскольку все компьютерные системы разные, выполняйте следующие действия осторожно и аккуратно. Скачайте проект с исходным кодом примеров из книги Первое и самое главное: каждый фрагмент кода, который вы увидите в этой книге, также доступен в сопутствующем проекте Java/Scala. Скачайте или клонируйте его, перейдя по ссылке https:// michalplachta.com/book. Проект включает файл README, в котором вы найдете информацию о том, как начать работу. Установите комплект разработчика Java Development Kit (JDK) Убедитесь, что на вашем компьютере установлен комплект JDK. Он позволит вам запускать код на Java и Scala (который, кстати, является языком JVM). Для этого выполните команду javac -version в терминале, и в ответ вы должны получить что-то вроде javac 17. Если это не так, то посетите сайт https://jdk.java.net/17/. Установите sbt (Scala build tool — инструмент сборки Scala) sbt — инструмент сборки, используемый в экосистеме Scala. Его
можно применять для создания, сборки и тестирования проектов. На странице https://www.scala-sbt.org/download.html можно найти инструкции по установке sbt на вашей платформе. Запустите его! В своей командной оболочке выполните команду sbt console, которая запустит цикл «чтение — вычисление — вывод» Scala (Read — Evaluate — Print Loop, REPL). Этот инструмент предпочтительно использовать для опробования примеров и выполнения упражнений из данной книги. Он позволит вам написать строку кода, нажать Enter и немедленно получить результат. Если запустить эту команду внутри папки с исходным кодом примеров из книги, то вы дополнительно получите доступ ко всем упражнениям. Особенно удобным это станет позже, когда примеры усложнятся. Но не будем забегать вперед и поэкспериментируем с самой оболочкой REPL. В следующем примере показано, как использовать этот инструмент. После запуска sbt console: Используемая версия Scala Welcome to Scala 3.1.3 Type in expressions for evaluation. Or try :help. scala> scala> 20 + 19 val res0: Int = 39
Если вы предпочитае те автоматизированный способ установки JDK/Scala либо хотите использовать Docker или веб-интерфейс, то обязательно посетите сайт книги, чтобы узнать об альтернативных способах выполнения предлагаемых в ней упражнений по программированию
JDK 17 была последней версией с долгосрочной поддержкой (Long Term Support, LTS) на момент написания книги. Другие LTS-версии тоже вполне подойдут
Примечание Я рекомендую использовать оболочку REPL (sbt console) для опробования примеров из этой книги, особенно вначале, поскольку она работает «из коробки» и вам не придется отвлекаться. Вы сможете загрузить все упражнения прямо в REPL. Однако после экспериментов с упражнениями вы сможете переключиться на IDE. Наиболее удобной для начинающих считается IntelliJ IDEA. После установки Java вы можете скачать эту IDE с сайта https:// www.jetbrains.com/ idea/.
Это приглашение к вводу. Здесь можно вводить команды и код. Введите какое-нибудь математическое выражение и нажмите Enter! Выражение было преобразовано в значение с именем res0, имеющее тип Int и равное 39
Глава 1
I
Изучение функционального программирования
39
Знакомство с REPL Вместе проведем небольшой сеанс работы с REPL и попутно рассмотрим несколько новых приемов программирования на Scala! Введите код и нажмите клавишу Enter, чтобы немедленно выполнить его
scala> print("Hello, World!") Hello, World!
REPL выводит результат в консоль scala> val n = 20 val n: Int = 20
val — это ключевое слово в Scala, определяющее постоянное значение. Обратите внимание, что val — часть языка, а не команда REPL REPL создала n как Int со значением 20. Это значение будет доступно на протяжении всего сеанса REPL
scala> n * 2 + 2 val res1: Int = 42
Мы можем ссылаться на любые значения, которые были определены ранее Если результату не назначается какое-то имя, то REPL автоматически сгенерирует его. В данном случае res1 — это имя, созданное оболочкой REPL. Оно имеет тип Int и значение 42
scala> res1 / 2 val res2: Int = 21
Мы можем ссылаться на любое значение, сгенерированное оболочкой REPL, как на любое другое значение Здесь REPL сгенерировала другое значение с именем res2 типа Int
scala> n val res3: Int = 20
Просто введите ранее определенное имя, чтобы получить его значение
scala> :load src/main/scala/ch01_IntroScala.scala def increment(x: Int): Int def getFirstCharacter(s: String): Char def wordScore(word: String): Int // определение объекта ch01_IntroScala scala> :quit
Вы можете загрузить любой файл с кодом на Scala из репозитория книги. Здесь загружается код для главы 1. REPL показывает, что было загружено: три функции, которые мы написали в предыдущем упражнении! Обязательно убедитесь в этом, заглянув в файл в текстовом редакторе
Полезные команды REPL :help — выводит список команд с описаниями. :reset — забыть все определения и начать сеанс
заново. Все команды самой оболочки REPL (не код) начинаются с двоеточия. Используйте :quit или :q для выхода из REPL
:quit — завершить сеанс (выйти из REPL).
Полезные сочетания клавиш Стрелки вверх/вниз — циклический просмотр предыдущих записей. Клавиша табуляции — показать параметры автодополнения, если они есть.
40 Часть I
I
Функциональный инструментарий
Пишем свои первые функции! Наступает важный момент! Сейчас вы напишете (и используете!) свои первые функции на Scala. Мы будем использовать только те из них, с которыми уже знакомы. Запустите Scala REPL (sbt console) и введите: scala> def increment(x: Int): Int = { | x + 1 | } def increment(x: Int): Int
Символ | появляется в выводе REPL всякий раз, когда вы нажимаете Enter при вводе многострочного выражения.
Как видите, оболочка REPL ответила выводом собственной строки. Этим она сообщила, что поняла код, введенный нами: имя increment, определяющее функцию, которая принимает параметр x типа Int и возвращает Int. Опробуем ее! scala> increment(6) val res0: Int = 7
Мы вызвали нашу функцию, передав ей аргумент 6. Функция вернула 7, как и ожидалось! Кроме того, REPL дала этому значению имя res0. Опробование фрагментов кода из этой книги Чтобы сделать листинги кода максимально удобочитаемыми, мы больше не будем добавлять приглашение REPL к вводу scala>. Мы также будем опускать подробные ответы REPL. Пример выше — это то, что вы должны делать в сеансе REPL. Однако в книге мы будем показывать только: def increment(x: Int): Int = { x + 1 } increment(6) → 7
Как видите, ответы REPL будут обозначаться стрелкой →. Она означает следующее: «после ввода предыдущего кода и нажатия Enter оболочка REPL должна ответить значением 7». Теперь попробуем написать и вызвать другую функцию, с которой уже встречались ранее: Я буду использовать этот риdef wordScore(word: String): Int = { word.length() } wordScore("Scala") → 5
Напомню, как должен выглядеть фрагмент кода слева в оболочке REPL
сунок, чтобы показать, что вы должны попытаться ввести код в сеансе REPL у себя.
scala> wordScore("Scala") val res1: Int = 5
Глава 1
I
Изучение функционального программирования
41
Как использовать эту книгу Прежде чем закончить главу, еще раз перечислю все способы, которыми эту книгу можно и нужно использовать. Не забывайте, что это техническое издание, поэтому не рассчитывайте прочитать книгу целиком за один присест. Вместо этого держите ее у себя на столе, положив рядом с клавиатурой и несколькими листами бумаги, на которых можно писать. Постарайтесь быть не пассивным читателем, а активным участником. Ниже приводятся несколько дополнительных советов, которые помогут вам. Выполняйте упражнения Возьмите за правило выполнять каждое упражнение. Не поддавайтесь искушению копировать код из книги в REPL. Короткие упражнения, кофе-брейки и практика...
Не подсматривайте ответы, особенно для упражнений «Кофе-брейк». Я понимаю, что вам будет приятно решить упражнение как можно быстрее, но это помешает успешному обучению в долгосрочной перспективе.
В книге вам встретятся упражнения трех видов: • короткие упражнения — небольшие упражнения, которые можно выполнить в уме, без применения каких-либо инструментов; • кофе-брейк — более сложные упражнения, цель которых — заставить вас задуматься о концепции и взглянуть на нее с другой точки зрения. Обычно для выполнения этих упражнений вам потребуется лист бумаги или компьютер; • практика — разделы, в значительной степени основанные на повторении. Используются для тренировки вашей мышечной памяти в отношении концепций и приемов, которые обязательно понадобятся в остальной части книги. Обустройте рабочее место обучения Держите под рукой бумагу и несколько карандашей или ручек разного цвета. Можно также использовать фломастеры или маркеры. Было бы желательно, чтобы вы работали в информативном пространстве, а не в скучном, стерильном месте. Не спешите Работайте в удобном для себя темпе. Он не обязательно должен быть постоянным. Для людей нормально то быстро бежать, то медленно идти, а иногда вообще стоять на месте. Отдых очень важен. Помните, что темы неравнозначны по сложности. Пишите код и все остальное В этой книге приводятся сотни фрагментов, которые можно просто скопировать в сеанс REPL. Каждая глава написана как «история REPL», но я предлагаю вам поэкспериментировать с кодом, попробовать писать свои версии и просто развлекаться!
С другой стороны, то, что дается легко, — не учит
Помните, что весь код, который встретится вам в этой книге, доступен в репозитории
42 Часть I
I
Функциональный инструментарий
Резюме В этой главе вы узнали о пяти очень важных навыках и концепциях, которые мы будем использовать в качестве основы в остальной части книги.
Кому адресована эта книга Я начал данную главу с попытки определить, почему вы решили прочитать эту книгу. Есть три основные причины, по которым вы, возможно, выбрали ее: вам просто интересно узнать о ФП; вам не хватило времени или удачи, чтобы как следует изучить ФП раньше; вы изучали эту тему раньше, но она вас не зацепила. Однако какими бы ни были причины, я уверен, что мой читатель — это программист, желающий научиться новым способам разработки приложений, экспериментируя и играя. От вас требуется лишь быть знакомыми с языком объектно-ориентированного программирования, таким как Java.
Что такое функция Затем вы познакомились с нашим главным героем — функцией — и мы немного поговорили о сигнатурах и телах. Мы также коснулись проблемы, когда сигнатура рассказывает не все о том, что делает тело, и отметили, как эта проблема усложняет программирование.
Насколько полезно функциональное программирование Мы обсудили различия между императивным и декларативным программированием, дали примерное определение функциональному программированию и выяснили, как оно может помочь вам в вашей карьере программиста.
Установка необходимых инструментов Мы установили sbt и использовали Scala REPL для реализации наших первых функций на Scala. Мы выяснили, как фрагменты кода из книги работают в REPL, и договорились, что далее в книге будем использовать → для обозначения ответов REPL во фрагментах кода.
Сеансы REPL будут обозначаться с помощью этого рисунка на протяжении всей книги. Не забывайте выполнять команду :reset перед началом новой главы.
Как пользоваться этой книгой Наконец, мы прошлись по структурным особенностям книги. Рассмотрели три типа упражнений (короткие упражнения, кофе-брейки и практика), обсудили подготовку рабочего места и порядок работы с фрагментами кода. Вы можете копировать и вставлять их в сеанс REPL, вводить вручную или загружать из файлов Scala, доступных в репозитории книги, ссылку на который вы найдете на сайте книги https://michalplachta.com/book . В репозитории есть файл README с инструкциями, которые помогут вам все настроить.
КОД : CH01_* Код примеров из этой главы доступен в файлах ch01_* в репозитории книги.
Чистые функции
В этой главе вы узнаете: • зачем нужны чистые функции; • как передавать копии данных; • как повторно вычислять значения вместо их хранения; • как передавать состояние; • как тестировать чистые функции.
Иногда наиболее элегантная реализация — это просто функция. Не метод. Не класс. Не фреймворк. Просто функция. Джон Кармак (John Carmack)
2
44 Часть I
I
Функциональный инструментарий
Зачем нужны чистые функции В предыдущей главе вы познакомились с функциями, которые не лгут. Их сигнатуры в точности рассказывают, что делает их тело. Мы пришли к заключению, что этим функциям можно доверять: чем меньше сюрпризов возникает при разработке кода, тем меньше ошибок будет в создаваемых нами приложениях. В этой главе вы познакомитесь с самой надежной из всех функций, которые не лгут: чистой функцией.
ЭТО ВА Ж Н О! Чистые функции — основа функционального программирования.
Скидки на покупки Начнем с примера, не использующего чистые функции. Мы посмотрим, какие проблемы свойственны этому решению, и попытаемся сначала решить их, используя интуитивное понимание. Наша задача — реализовать «покупательскую корзину», способную вычислять скидки на основе ее текущего содержимого. Требования: покупательская корзина 1. В корзину можно добавить любой товар (смоделирован как значение типа String). 2. При добавлении в корзину любой книги начисляется скидка 5 %. 3. Если в корзине нет книги, то скидка равна 0 %. 4. Товары в корзине доступны в любое время.
Мы можем запрограммировать решение, прямо отобразив вышеперечисленные требования в код. Вот диаграмма класса ShoppingCart, представляющего реализацию: ShoppingCart В корзину можно добавить любой товар
items: List bookAdded: boolean
addItem(item): void getDiscountPercentage(): int getItems(): List
Обратите внимание, что на диаграммах я иногда буду опускать типы и другие детали, чтобы сделать их как можно более понятными. Здесь я опустил тип String в спис ке параметров функции addItem.
При добавлении в корзину любой книги начисляется скидка 5 % Если в корзине нет книги, то скидка равна 0 %
Товары в корзине доступны в любое время
Прежде чем углубиться в реализацию, кратко рассмотрим диаграмму, приведенную выше. Класс ShoppingCart имеет два поля, items и bookAdded , которые определяют внутреннее состояние. Каждое требование реализуется как отдельный метод. Эти методы играют роль общедоступного интерфейса с остальным миром (клиентами класса).
Глава 2
I
Чистые функции
Императивное решение
ShoppingCart
Мы разработали решение нашей проблемы, добавив несколько полей состояния и общедоступных методов. Внимание! Дизайн класса ShoppingCart имеет очень серьезные проблемы! Мы обсудим их ниже. Если вы уже заметили их, то поздравляю! Если нет, то подумайте о возможных способах неправильного использования этого класса и кода, представленного ниже. Теперь пришло время написать код. public class ShoppingCart { private List items = new ArrayList(); private boolean bookAdded = false;
45
items: List bookAdded: boolean
addItem(item): void getDiscountPercentage(): int getItems(): List
Эта диаграмма представляет фрагмент кода в нижней части страницы. Серая область представляет состояние (то есть переменные, которые будут менять значения с течением времени) ShoppingCart
public void addItem(String item) { items.add(item); if(item.equals("Book")) { bookAdded = true; } } public int getDiscountPercentage() { if(bookAdded) { return 5; } else { return 0; } }
}
public List getItems() { return items; }
items bookAdded
false
addItem(item) getDiscountPercentage() getItems()
Не забывайте, что это лишь небольшой пример, цель которого — показать некоторые неочевидные проблемы, существующие в реальном коде и сложно выявляемые.
Выглядит разумно, правда? При добавлении книги в корзину мы присваиваем флагу bookAdded значение true. Этот флаг используется методом getDiscountPercentage для вычисления процента скидки. Оба поля, items и bookAdded, называются состоянием, поскольку их значения меняются со временем. Теперь посмотрим, как можно использовать этот класс. ShoppingCart cart = new ShoppingCart(); cart.addItem("Apple"); System.out.println(cart.getDiscountPercentage()); вывод в консоли: 0 cart.addItem("Book"); System.out.println(cart.getDiscountPercentage()); вывод в консоли: 5
cart.addItem("Apple");
ShoppingCart items
Apple
bookAdded
false
addItem(item) getDiscountPercentage() getItems() cart.addItem("Book");
ShoppingCart items
Apple, Book
bookAdded
true
addItem(item) getDiscountPercentage() getItems()
46 Часть I
I
Функциональный инструментарий
Ошибка в коде Весь код, который мы видели до сих пор, выглядит неплохо. Тем не менее реализация класса ShoppingCart содержит ошибку, во многом связанную с состоянием: полями items и bookAdded. Посмотрим на один возможный поток выполнения программы, чтобы увидеть проблему.
ShoppingCart cart = new ShoppingCart();
ShoppingCart
class ShoppingCart { private List items = new ArrayList(); private boolean bookAdded = false; public void addItem(String item) { items.add(item); if(item.equals("Book")) { bookAdded = true; } } public int getDiscountPercentage() { if(bookAdded) { return 5; } else { return 0; } } public List getItems() { return items; } }
items bookAdded
false cart.addItem("Apple");
addItem(item) getDiscountPercentage() getItems()
ShoppingCart items
Apple
bookAdded
false
cart.addItem("Lemon"); addItem(item) getDiscountPercentage() getItems()
ShoppingCart items
Apple, Lemon
bookAdded
false
cart.addItem("Book")
addItem(item) getDiscountPercentage() getItems()
cart.getItems().remove("Book");
ShoppingCart items
Apple, Lemon
bookAdded
true
addItem(item) getDiscountPercentage() getItems()
ShoppingCart items
Apple, Lemon, Book
bookAdded
true
addItem(item) getDiscountPercentage() getItems()
После удаления книги непосредственно из списка состояние оказывается повреждено: книги в корзине нет, но функция getDiscountPercentage() возвращает 5. Этот ошибочный результат возникает изза неправильной обработки состояния.
Да, мы не планировали такой способ применения getItems, но помните, что любая возможность рано или поздно будет кем-то использована. При программировании важно подумать обо всех возможных способах использования, чтобы как можно лучше защитить внутреннее состояние. Кстати, у getItems().add тоже есть проблемы! Опытные разработчики могут заметить, что она довольно очевидна, но будьте уверены, что такие проблемы встречаются довольно часто!
Глава 2
I
Чистые функции
47
Передача копий данных Проблему, с которой мы столкнулись в предыдущем примере, легко решить путем возврата копии списка из вызова getItems. public class ShoppingCart { private List items = new ArrayList(); private boolean bookAdded = false; public void addItem(String item) { items.add(item); if(item.equals("Book")) { bookAdded = true; } }
}
public int getDiscountPercentage() { if(bookAdded) { return 5; } else { return 0; } } public List getItems() { return items; }
Мы возвращаем не текущее состояние items, а создаем и возвращаем копию. В таком случае никто не сможет испортить items.
public List getItems() { return new ArrayList(items); }
Это изменение может показаться не таким уж важным, но передача копий данных — один из фундаментальных аспектов функционального программирования! Очень скоро мы подробно рассмотрим этот прием. Но сначала убедимся, что класс ShoppingCart работает верно, независимо от того, как он используется.
Почему используется копия вместо Collections. unmodifiableList, вы узнаете в главе 3
Удаление товара из корзины Допустим, клиенту нашего класса неожиданно понадобилась дополнительная возможность, не оговоренная вначале. Мы осознали это, получив горький опыт неправильной работы нашего кода. Вот требование № 5. 5. Любой товар, ранее добавленный в корзину, можно удалить.
Поскольку теперь вызывающей стороне возвращается копия items, для удовлетворения этого требования нужно добавить еще один общедоступный метод: public void removeItem(String item) { items.remove(item); if(item.equals("Book")) { bookAdded = false; } }
Это конец нашим проблемам? Теперь код работает правильно?
ЭТО ВА Ж Н О! В ФП мы передаем копии данных и не позволяем изменять их на месте.
48 Часть I
I
Функциональный инструментарий
Ошибка в коде... снова
class ShoppingCart { private List items = new ArrayList(); private boolean bookAdded = false; public void addItem(String item) { items.add(item); if(item.equals("Book")) { bookAdded = true; } }
Теперь мы возвращаем копию items и добавили метод removeItem, что значительно улучшило наше решение. Можем ли мы сказать, что с проблемой покончено? Оказывается, нет. Удивительно, но проблем с ShoppingCart и его внутренним состоянием даже больше, чем можно было ожидать. Посмотрим на другой возможный поток выполнения программы, чтобы увидеть новую проблему.
public int getDiscountPercentage() { if(bookAdded) { return 5; } else { return 0; } } public List getItems() { return new ArrayList(items); } public void removeItem(String item) { items.remove(item); if(item.equals("Book")) { bookAdded = false; } }
ShoppingCart cart = new ShoppingCart();
ShoppingCart items bookAdded
}
false
cart.addItem("Book") addItem(item) getDiscountPercentage() getItems() removeItem(item)
cart.addItem("Book")
ShoppingCart items
Book, Book
bookAdded
true cart.removeItem("Book");
addItem(item) getDiscountPercentage() getItems() removeItem(item)
Мы добавили две книги в корзину, а потом удалили одну из них. В результате снова получили поврежденное состояние: в корзине есть книга, но getDiscountPercentage() возвращает 0! Этот ошибочный результат обусловлен неправильной обработкой состояния.
ShoppingCart items
Book
bookAdded
true
addItem(item) getDiscountPercentage() getItems() removeItem(item)
ShoppingCart items
Book
bookAdded
false
addItem(item) getDiscountPercentage() getItems() removeItem(item)
Глава 2
I
Чистые функции
49
Повторные вычисления вместо сохранения Проблему, с которой мы столкнулись в предыдущем примере, можно решить, если отступить на шаг назад и переосмыслить нашу основную цель. Перед нами стояла задача реализовать функционал покупательской корзины, способной вычислять скидку. Мы попали в ловушку, пытаясь отслеживать все операции добавления и удаления и императивно определяя, была ли добавлена книга. Вместо этого можно просто вычислять скидку заново каждый раз, когда это необходимо, просматривая весь список. public class ShoppingCart { private List items = new ArrayList(); private boolean bookAdded = false; public void addItem(String item) { items.add(item); if(item.equals("Book")) { bookAdded = true; } } public int getDiscountPercentage() { if(bookAdded) { return 5; } else { return 0; } } public List getItems() { return new ArrayList(items); }
}
Мы удалили состояние bookAdded и перенесли логику вычисления скидки из addItem/removeItem в getDiscountPercentage. public int getDiscountPercentage() { if(items.contains("Book")) { return 5; } else { return 0; } } getDiscountPercentage
вычисляет скидку, когда она необходима, просматривая весь список.
public void removeItem(String item) { items.remove(item); if(item.equals("Book")) { bookAdded = false; } }
Какая перемена! Код стал намного безопаснее и менее проблематичным. Вся логика, связанная со скидками, теперь находится в getDis countPercentage. Мы убрали состояние bookAdded, доставившее нам столько проблем. Единственный недостаток новой версии — для очень больших списков покупок может потребоваться много времени, чтобы вычислить скидку. В крайних случаях мы можем пожертвовать производительностью ради удобочитаемости и простоты сопровождения.
Мы вернемся к этой теме в главе 3
50 Часть I
I
Функциональный инструментарий
Сосредоточение внимания на логике путем передачи состояния Посмотрим на окончательное решение и подумаем, как оно работает на самом деле. class ShoppingCart { private List items = new ArrayList(); public void addItem(String item) { items.add(item); } public int getDiscountPercentage() { if(items.contains("Book")) { return 5; } else { return 0; } } public List getItems() { return new ArrayList(items); }
}
public void removeItem(String item) { items.remove(item); }
items — это наше внутреннее состояние, с которым нам нужно быть осторожными addItem — это просто обертка вокруг метода add класса List
Это единственная функция, написанная нами от начала и до конца. Она реализует наши требования
getItems — это просто обертка, необходимая для защиты списка, возвращающая его копию
removeItem — это всего лишь обертка вокруг метода remove класса List
Проблема решения выше заключается в том, что нам потребовалось написать довольно много шаблонного кода, чтобы гарантировать недоступность состояния из-за пределов класса. При этом самой важной функцией с точки зрения бизнес-требований является getDiscountPercentage . По сути, это единственная необходимая функция! Мы можем избавиться от всех функций, обертывающих методы items, потребовав передачи списка items в качестве аргумента. class ShoppingCart { public static int getDiscountPercentage(List items) { if(items.contains("Book")) { return 5; } else { return 0; } } }
Это функциональное решение задачи!
Ключевое слово static указывает, что функции не нужен экземпляр, чтобы решить стоящую перед ней задачу.
Глава 2
I
Чистые функции
51
Куда пропало состояние Возможно, вас взволновали последние изменения в классе ShoppingCart. Разве можно просто удалить все состояние и оставить одну функцию? А остальные требования? Функция getDiscountPercentage в текущем виде не выполняет три следующих требования: • в корзину можно добавить любой товар (смоделирован как значение типа String); • товары в корзине доступны в любое время; • любой товар, ранее добавленный в корзину, можно удалить. Однако если приглядеться, то эти требования может удовлетворить любой класс списка! И в стандартной библиотеке есть из чего выбирать. Даже если бы в стандартной библиотеке не было ничего подходящего, мы могли бы написать свой класс, который ничего не знает о скидках.
items items.add("Apple");
List items = new ArrayList(); items.add("Apple"); System.out.println(ShoppingCart.getDiscountPercentage(items)); вывод в консоли: 0
items
Apple
items.add("Book");
items.add("Book"); System.out.println(ShoppingCart.getDiscountPercentage(items)); вывод в консоли: 5
Как видите, у нас нет никакого состояния, мы просто передаем список товаров в функцию вычисления скидки. При этом сохраняются все возможности, имевшиеся раньше, но теперь у нас меньше кода!
Разделение ответственности
Если в корзине нет книги, то скидка равна 0 %
Apple, Book
Мы уделим этой теме больше времени позже, особенно в главах 8 и 11
Мы разделили требования на два набора, которые выполняются разными частями кода! Благодаря этому мы получили независимые функции и классы, меньшие по размеру, которые проще читать и писать. В программной инженерии это называется разделением ответственности: каждая часть кода отвечает только за что-то одно и делает это хорошо. Еще раз пройдемся по всем требованиям и посмотрим, как они выполняются. При добавлении в корзину любой книги начисляется скидка 5 %
items
getDiscountPercentage int getDiscountPercentage(List items) { if(items.contains("Book")) { return 5; } else { return 0; } }
В корзину можно добавить любой товар Любой товар можно удалить из корзины
ЭТО ВА Ж Н О! В ФП мы разделяем ответственность по разным функциям.
ArrayList
или любой другой тип, реализующий интерфейс List add(string): void remove(string): void iterator(): Iterator
Товары в корзине доступны в любое время
Как видите, вся обработка состояния теперь выполняется в классе ArrayList.
52 Часть I
I
Функциональный инструментарий
Разница между чистыми и нечистыми функциями Мы прошли долгий путь от императивного до полностью функционального решения. Попутно исправили некоторые ошибки и обнаружили некоторые закономерности. Пришло время перечислить эти закономерности и наконец познакомиться с главным героем этой главы. Оказывается, последняя версия функции getDiscountPercentage обладает всеми чертами чистой функции. Императивное решение
Функциональное решение
class ShoppingCart { private List items = new ArrayList(); private boolean bookAdded = false;
class ShoppingCart { public static int getDiscountPercentage (List items) { if(items.contains("Book")) { return 5; } else { return 0; } } }
public void addItem(String item) { items.add(item); if(item.equals("Book")) { bookAdded = true; } Функция не возвращает значения }
Функция изменяет существующие значения public int getDiscountPercentage() { if(bookAdded) { return 5; } else { return 0; } Функция вычисляет результат, }
Функция всегда возвращает единственное значение
Функция вычисляет результат, опираясь только на аргументы Функция не изменяет существующие значения
опираясь не только на аргументы
public List getItems() { return items; } }
Функция вычисляет результат, опираясь не только на аргументы
Слева приводится императивное решение, с которого мы начали. Оно выглядело вполне нормальным, но потом мы глубже проанализировали его и обнаружили некоторые проблемы. Мы решили их, пользуясь интуицией, и случайно получили класс с одной статической функцией (справа). Затем мы проанализировали различия между этими функциями и заметили три черты функционального класса ShoppingCart, которые отсутствуют в императивном классе ShoppingCart. Это основные правила, которым мы должны следовать в функциональном программировании. На самом деле нам не нужна интуиция для решения наших проблем. Само следование этим правилам способствует выявлению проблем в коде и их устранению. Очень скоро мы подробнее поговорим об этом, но сначала я предлагаю вам самим попробовать применить эти три правила к своему решению.
ЭТО ВА Ж Н О! Мы используем три правила, помогающие создавать чистые функции, которые содержат меньше ошибок.
Глава 2
I
Чистые функции
Кофе-брейк: преобразование в чистую функцию Теперь настала ваша очередь преобразовать императивный код в чистую функцию, но на совершенно другом примере. Ваша задача — реорганизовать класс TipCalculator, который группа друзей может использовать для вычисления чаевых на основе количества участников застолья. Чаевые составляют 10 %, если общий счет оплачивают от одного до пяти человек. Если компания больше пяти человек, то размер чаевых составляет 20 %. Кроме того, необходимо предусмотреть обработку крайнего случая: когда количество обедающих равно нулю, то и размер чаевых, очевидно, равен 0 %. Вот сам код. Ваша задача — реорганизовать этот класс так, чтобы каждая функция удовлетворяла трем правилам чистых функций. class TipCalculator { private List names = new ArrayList(); private int tipPercentage = 0; public void addPerson(String name) { names.add(name); if(names.size() > 5) { tipPercentage = 20; } else if(names.size() > 0) { tipPercentage = 10; } } public List getNames() { return names; }
}
public int getTipPercentage() { return tipPercentage; }
Правила чистых функций 1. Функция всегда возвращает единственное значение. 2. Функция вычисляет результат, опираясь только на аргументы. 3. Функция не изменяет существующие значения.
Вспомните три приема, с которыми мы познакомились: повторные вычисления вместо сохранения, передача состояния в аргументах и передача копий данных.
53
54 Часть I
I
Функциональный инструментарий
Объяснение для кофе-брейка: преобразование в чистую функцию Похоже, что у класса TipCalculator есть проблемы, которые нам хорошо знакомы. Мы точно знаем, что должны делать. Для начала посмотрим, какие правила функции нарушают в TipCalcu lator. class TipCalculator { private List names = new ArrayList(); private int tipPercentage = 0; addPerson не возвращает никакого значения. Чистая функция всегда должна возвращать одно значение public void addPerson(String name) { names.add(name); if(names.size() > 5) { tipPercentage = 20; addPerson изменяет существующие значения: } else if(names.size() > 0) { names и tipPercentage. Чистая функция никогда tipPercentage = 10; не должна изменять существующие значения, } она может только создавать новые }
}
public List getNames() { return names; }
getNames вычисляет результат, используя внешнее состояние (переменная names). Для получения результата чистая функция должна использовать только аргументы
public int getTipPercentage() { return tipPercentage; }
getTipPercentage вычисляет результат, используя внешнее состояние (переменная tipPercentage). Для получения результата чистая функция должна использовать только аргументы
Повторные вычисления вместо сохранения Сначала исправим функцию getTipPercentage. Она вычисляет результат, используя значение поля tipPercentage, которое является внешней переменной и не передается в аргументах. Поле tipPercentage вычисляется и сохраняется функцией addPerson. Чтобы исправить функцию getTipPercentage, нужно применить два приема. Первый — повторное вычисление вместо сохранения. public int getTipPercentage() { if(names.size() > 5) { return 20; } else if(names.size() > 0) { return 10; } return 0; }
getTipPercentage все еще вычисляет результат, используя внешнее состояние, но мы всего в одном шаге, чтобы сделать ее чистой
Глава 2
I
Чистые функции
55
Передача состояния в качестве аргумента Теперь функция getTipPercentage вычисляет скидку заново, но по-прежнему использует внешнее значение names. Чтобы сделать getTipPercentage чистой функцией, достаточно просто передать ей состояние через аргументы. Но другие функции все еще нечистые. class TipCalculator { private List names = new ArrayList(); public void addPerson(String name) { names.add(name); } public List getNames() { return names; }
addPerson по-прежнему не возвращает никакого значения addPerson изменяет существующее значение: names
getNames вычисляет результат, используя внешнее состояние
public static int getTipPercentage(List names) { if(names.size() > 5) { return 20; Эта функция чистая, поэтому для вычисления } else if(names.size() > 0) { результата использует только аргументы. Теперь return 10; функция пересчитывает чаевые с текущим списком } else return 0; имен при каждом вызове. Эта версия намного } безопаснее предыдущей. Она не использует } изменяемое состояние, и мы можем рассуждать о getTipPercentage изолированно
Передача копий данных
Чтобы исправить addPerson, нужно убрать из нее все операции, изменяющие существующие значения, и добавить возврат измененных копий в качестве результата. class TipCalculator { public List addPerson(List names, String name) { List updated = new ArrayList(names); updated.add(name); return updated; }
addPerson теперь чистая функция, поскольку не изменяет существующие значения. Однако мы можем просто удалить ее, так как это всего лишь обертка для метода add класса ArrayList
public static int getTipPercentage(List names) { if(names.size() > 5) { return 20; Обратите внимание, что вам специально было } else if(names.size() > 0) { предложено реорганизовать три функции. В процессе return 10; мы немного изменили API и в итоге получили две } else return 0; (или одну?) функции. Вот как правила чистых функций } могут привести к лучшим и более безопасным API! }
Вот и все! Мы превратили императивные функции в чистые, просто следуя трем правилам. Теперь наш код проще понимать и, следовательно, удобнее сопровождать.
56 Часть I
I
Функциональный инструментарий
Мы доверяем чистым функциям Мы начали эту главу с реального примера, в котором крылись коварные ошибки. Нам удалось преобразовать его в чистую функцию, следуя трем простым правилам. Разумеется, точно так же мы могли исправить ошибки в императивном решении. Однако дело в том, что программисты, как правило, создают меньше ошибок, когда пытаются писать чистые функции вместо того, чтобы просто следовать требованиям и прямо реализовывать эти функции в форме классов и методов. Мы говорим, что о чистых функциях «легко рассуждать». Нам не нужно создавать большие модели переходов состояний в своем уме, чтобы понять их. Все, что делает функция, явно отражает ее сигнатура. Она принимает некоторые аргументы и возвращает результат. Остальное неважно.
ЭТО ВА Ж Н О! Чистые функции — основа функционального программирования.
Математические функции — чистые Прообразом чистых функций в программировании были математические функции, которые всегда чистые. Представим, что нам нужно рассчитать окончательную цену товара со скидкой. Она составляет 5 %. Мы достаем наш калькулятор и вводим цену, 20 долларов, затем нажимаем *, затем 95 (100 % – 5 %), а затем избавляемся от процентов, нажимая / 100. После нажатия = получаем окончательную цену: 19 долларов. То, что мы только что сделали, на математическом языке выражается так: f(x) = x * 95 / 100
Для любой заданной цены x функция f выше вернет цену со скидкой. То есть если в качестве x передать 20, 100 или 10, то вызов f даст нам правильные ответы: f(20) = 19 f(100) = 95 f(10) = 9.5
Функция f — чистая, поскольку обладает тремя отличительными чертами: • всегда возвращает единственное значение; • вычисляет результат, опираясь только на аргументы; • не изменяет существующие значения. Этим трем чертам и посвящена данная глава. Мы уже применяли их в наших задачах и отметили, что нашему коду можно больше доверять, если он организован в виде чистых функций. Теперь обсудим особенности и правила создания чистых функций. Далее мы поговорим о том, как различать подобные функции и какие примитивы языка программирования необходимы для их написания.
Если товар стоит 20 долларов, то с учетом скидки мы заплатим только 19 долларов! Неплохо!
Глава 2
I
Чистые функции
57
Чистые функции в языках программирования Вот чистая математическая функция, вычисляющая цену со скидкой: f(x) = x * 95 / 100
Ее легко перевести на язык Java: static double f(double x) { return x * 95.0 / 100.0; }
Математическая Java
Обе функции делают одно и то же: вычисляют 95 % от заданного значения.
Самое главное, что при переводе ничего не потерялось. Эта функция, написанная на Java, по-прежнему обладает тремя свойствами чистой функции.
Возвращает единственное значение Версия функции на Java, как и ее математический аналог, всегда возвращает ровно одно значение.
Вычисляет результат, опираясь только на аргументы Версия функции на Java, как и ее математический аналог, принимает один аргумент и вычисляет результат, опираясь только на этот аргумент. Никаких других данных функция не использует.
Не изменяет существующие значения Версия функции на Java и ее математический аналог ничего не изменяют в своем окружении. Они не изменяют существующие значения, не используют и не изменяют никакие поля состояния. Их можно вызвать многократно, и каждый раз они будут возвращать один и тот же результат для одного и того же набора аргументов. Без всяких сюрпризов!
Обратите внимание, что список тоже является единственным значением, даже притом что внутри может содержать несколько значений. Это нормально! Главное, чтобы функция всегда возвращала значение.
Чистая функция В общем случае мы говорим, что функция является чистой, если она: • возвращает единственное значение; • вычисляет результат, опираясь только на свои аргументы; • не изменяет существующие значения.
Короткое упражнение Быстро проверим вашу интуицию. Являются ли эти функции чистыми? static int f1(String s) { return s.length() * 3; }
static double f2(double x) { return x * Math.random(); }
Ответы f1: да, f2: нет.
58 Часть I
I
Функциональный инструментарий
Трудно оставаться чистым... Теперь мы знаем, как писать чистые функции на языках программирования. Но если они делают нашу жизнь программиста лучше, то почему не получили более широкое распространение? Если отвечать кратко, то потому, что используемые нами языки программирования не требуют этого. Чтобы ответить более развернуто, нужно рассказать небольшую предысторию. Вернемся к функции, вычисляющей цену с учетом 5%-ной скидки. static double f(double x) { return x * 95.0 / 100.0; }
Мы уже знаем, что она чистая. Однако Java не требует от нас этого: мы могли бы обойтись и другой, нечистой реализацией. Математика, с другой стороны, требует, чтобы все функции были чистыми. Математика
Программирование
Мы намного более ограничены при написании или изменении математических функций. Мы не можем добавить в функцию «что-то еще». Если кто-то решит воспользоваться нашей функцией f, то может быть уверен, что она вернет число, получив число. Математика не позволяет реализации делать что-либо неожиданное.
Большинство основных языков программирования позволяет изменять и добавлять все что угодно практически в любом месте в программе. Например, мы можем написать функцию f так: double f(double x) { spaceship.goToMars(); return x * 95.0 / 100.0; }
И нарушить наше обещание поль зователю f.
Сложность сохранения чистоты связана с тем, что языки программирования обычно обладают более широкими возможностями в выражении функций, чем математика. Однако чем шире возможности, тем больше ответственность. Мы отвечаем за создание программного обеспечения, которое может решать реальные задачи и в то же время легко сопровождается. К сожалению, широта возможностей может иметь неприятные последствия — и часто так и происходит.
Постараемся быть ближе к математике... Мы уже знаем, что можем выбирать между написанием чистых и нечистых функций, а также распространить некоторые наши математические знания на программирование. И для этого достаточно сосредоточиться всего на трех правилах чистых функций и стараться следовать им всегда.
Это уже нечистая функция. Хуже того, ее сигнатура лжет нам, поскольку функция может не вернуть значение double, если полет на Марс потерпит неудачу из-за исключения
Глава 2
I
Чистые функции
59
Чистые функции и чистый код Вы узнали, каким правилам должны следовать, чтобы сделать функции чистыми. Оказывается, все три правила имеют определенное влияние на порядок нашей работы. Они делают код чище. Однако на этом их преимущества не заканчиваются. Есть и другие!
Чистая функция
Единственная ответственность Возвращая только одно значение и не изменяя существующие значения, функция сможет делать только что-то одно и ничего больше. В информатике это называется единственной ответственностью.
Возвращает единственное значение. Использует только свои аргументы. Не изменяет существующие значения.
Отсутствие побочных эффектов Когда единственным наблюдаемым результатом функции является возвращаемый ею результат, мы говорим, что функция не имеет побочных эффектов.
В О
Что такое побочный эффект?
Любые действия, не связанные с вычислением результата на основе аргументов, являются побочным эффектом. То есть функция, выполняющая HTTP-вызов, имеет побочные эффекты. Если функция изменяет глобальную переменную или поле экземпляра, то также имеет побочные эффекты. Функция, что-то записывающая в базу данных, имеет побочные эффекты! Если функция выводит что-то в стандартный вывод, записывает что-то в файлы журналов, создает поток выполнения, генерирует исключение, рисует что-то на экране, то... вы правильно догадались! Побочный эффект, побочный эффект и еще раз побочный эффект.
Ссылочная прозрачность Если функцию вызвать многократно (в разное время дня и ночи, но с одними и теми же параметрами), то каждый раз она вернет один и тот же ответ. Независимо от того, в каком потоке выполнения вызывается функция, в каком состоянии находится приложение, работает ли база данных или нет, — f(20), f(20), f(20) вернет 19, 19, 19. Это свойство называется ссылочной прозрачностью (referential transparency). Если функция ссылочно-прозрачная, то ее вызов, например f(20), можно заменить его результатом, 19, и поведение программы от этого не изменится. Если для вычисления результата функция использует только свои аргументы и не изменяет существующие значения, то автоматически становится ссылочно-прозрачной.
Не волнуйтесь, это не значит, что все это нельзя делать в функциональных программах! Многие из этих операций мы будем выполнять далее в этой книге
60 Часть I
I
Функциональный инструментарий
Кофе-брейк: чистая или нечистая Прежде чем перейти к реорганизации некоторых нечистых функций, проверим, насколько вы понимаете три особенности чистых функций. Ваша задача — определить, какими из трех характеристик чистых функций обладает данная функция. Если ни одной, это означает, что она нечистая; если всеми тремя, это означает, что функция чистая. В любом другом случае функция является нечистой, но обладающей некоторыми чертами чистых функций. Например: static double f(double x) { return x * 95.0 / 100.0; }
Представлены все три характеристики (это чистая функция). Теперь ваша очередь. Тщательно проанализируйте каждую функцию, прежде чем дать ответ. Используйте контрольный список чистых функций в качестве руководства. static int increment(int x) { return x + 1; } static double randomPart(double x) { return x * Math.random(); } static int add(int a, int b) { return a + b; } class ShoppingCart { private List items = new ArrayList();
}
public int addItem(String item) { items.add(item); return items.size() + 5; }
static char getFirstCharacter(String s) { return s.charAt(0); }
Чистая функция Возвращает единственное значение. Использует только свои аргументы. Не изменяет существующие значения.
Глава 2
I
Чистые функции
61
Объяснение для кофе-брейка: чистая или нечистая Чтобы решить эту задачу, нужно ответить на три вопроса для каждой функции. Чистая функция 1. Всегда возвращает одно значение? 2. Вычисляет результат, опираясь только на свои аргументы? 3. Избегает изменения каких-либо существующих значений? static int increment(int x) { return x + 1; }
Да, да и да! Это чистая функция. static double randomPart(double x) { return x * Math.random(); }
Да, нет и да. Эта функция возвращает единственное значение и не изменяет никакие существующие значения, но для вычисления результата использует не только свой аргумент, но и результат вызова функции Math.random(), генерирующей случайное число (побочный эффект). static int add(int a, int b) { return a + b; }
Да, да и снова да! Это еще одна чистая функция! public int addItem(String item) { items.add(item); return items.size() + 5; }
Да, нет и нет. Это нечистая функция. Она возвращает единственное значение, но вычисляет его, основываясь не только на аргументах (использует состояние items, которое может содержать различные значения), и не избегает изменения существующих значений (добавляет элемент в список items). static char getFirstCharacter(String s) { return s.charAt(0); } Это довольно спорное утверждение для данного изолированного примера. Чаще всего исключения рассматриваются как другой поток выполнения программы. Мы вернемся к этой теме в главе 6
Нет, да и да. Еще одна нечистая функция. Она не всегда возвращает значение — возвращает первый символ заданной строки или генерирует исключение (получив пустую строку). Использует только свой аргумент и не изменяет существующие значения.
Возвращает единственное значение. Использует только свои аргументы. Не изменяет существующие значения.
Возвращает единственное значение. Использует только свои аргументы. Не изменяет существующие значения.
Возвращает единственное значение. Использует только свои аргументы. Не изменяет существующие значения.
Возвращает единственное значение. Использует только свои аргументы. Не изменяет существующие значения.
Возвращает единственное значение. Использует только свои аргументы. Не изменяет существующие значения.
62 Часть I
I
Функциональный инструментарий
Использование Scala для написания чистых функций Пришло время погрузиться в программирование немного глубже! Выше мы увидели, что на Java можно без особого труда писать простые чистые функции. Это относится и ко многим другим языкам, поддерживающим основные особенности функционального программирования и позволяющим писать чистые функции. Однако отдельные особенности, которые мы обсудим далее в книге, не получили широкого распространения (пока!), и для их представления мы будем использовать язык Scala. Поэтому, прежде чем углубляться, остановимся и немного попрактикуемся в написании чистых функций на Scala. Вот функциональная версия функции на Java: class ShoppingCart { public static int getDiscountPercentage(List items) { if(items.contains("Book")) { return 5; } else { return 0; } } }
На протяжении всей книги мы будем рассматривать программный код на разных языках, а не только на Java. Это нужно для того, чтобы доказать, что приемы, обсу ждаемые в этой книге, универсальны. Кроме того, поддержка многих из них постепенно внедряется в традиционно императивные языки
В Scala для создания объекта, единственного этого типа в программе, предназначено ключевое слово object. Мы используем его как контейнер для чистых функций. Вот как выглядит эквивалент приведенной выше Java-функции на Scala: object содержит функции object ShoppingCart { def getDiscountPercentage(items: List[String]): Int = { def отмечает начало опреif (items.contains("Book")) { деления функции 5 Здесь нет ключевого слова return, поскольку if } else { в Scala и других языках ФП является выражением. 0 } В качестве возвращаемого значения используется } последнее выражение в функции }
Использование этой функции немного отличается от использования функции на Java, поскольку списки List в Scala являются неизменяемыми объектами, то есть после создания их нельзя изменить. Удивительно, но эта особенность очень помогает в функциональном программировании. В следующей главе мы val justApple = List("Apple") ShoppingCart.getDiscountPercentage(justApple) → 0 val appleAndBook = List("Apple", "Book") ShoppingCart.getDiscountPercentage(appleAndBook) → 5
подробно обсудим неизменяемые значения, поэтому воспринимайте это их упоминание как анонс
Глава 2
I
Чистые функции
63
Практика чистых функций в Scala Ваша задача — переписать следующий Java-код на Scala. class TipCalculator { public static int getTipPercentage(List names) { if(names.size() > 5) { return 20; } else if(names.size() > 0) { return 10; } else return 0; } } List names = new ArrayList(); System.out.println(TipCalculator.getTipPercentage(names)); вывод в консоли: 0 names.add("Alice"); names.add("Bob"); names.add("Charlie"); System.out.println(TipCalculator.getTipPercentage(names)); вывод в консоли: 10 names.add("Daniel"); names.add("Emily"); names.add("Frank"); System.out.println(TipCalculator.getTipPercentage(names)); вывод в консоли: 20
Ответ object TipCalculator { def getTipPercentage(names: List[String]): if (names.size > 5) 20 else if (names.size > 0) 10 else 0 } }
Примечания
• Список List в Scala
создается с помощью вызова конструктора List(...) и передачи ему всех элементов в аргументах, разделенных запятыми.
• Есть возможность
создать пустой список, вызвав специальную функцию List.empty.
• Список List в Scala
нельзя изменить, поэтому каждый раз нужно создавать новый экземпляр.
• В Scala список строк определяется как List[String].
Обратите внимание, что в примерах на Java для вывода полученных значений мы используем println, а в примерах на Scala просто вызываем функцию и наблюдаем результат в виде ответа оболочки REPL. Если такой способ вам больше нравится, то можете попробовать использовать jshell для Java
Int = {
List.empty возвращает пустой список. Здесь мы передаем в аргументе пустой список
TipCalculator.getTipPercentage(List.empty) → 0 val smallGroup = List("Alice", "Bob", "Charlie") TipCalculator.getTipPercentage(smallGroup) → 10 val largeGroup = List("Alice", "Bob", "Charlie", "Daniel", "Emily", "Frank") TipCalculator.getTipPercentage(largeGroup) → 20
64 Часть I
I
Функциональный инструментарий
Тестирование чистых функций Одно из самых больших преимуществ чистых функций — их тестируемость. Простота тестирования — ключ к написанию удобочитаемого и простого в сопровождении промышленного кода. Функциональные программисты часто стремятся реализовать наиболее важные функции в виде чистых функций, что помогает тестировать их, используя очень простой подход модульного тестирования. Напоминание об использовании примеров кода из этой книги В начале листингов используется символ . Напомню: он служит лишь признаком того, что для опробования кода нужно запустить оболочку Scala REPL в терминале (sbt console), а затем последовательно вводить инструкции из листинга. Ответы Scala REPL в листингах предваряются символом стрелки →.
Теме тестирования посвящена отдельная глава в этой книге (12), но мы все же кратко перечислим некоторые подходы и методы тестирования, чтобы дополнительно подчерк нуть их важность, прежде чем перейти к ним
def getDiscountPercentage(items: List[String]): Int = { if (items.contains("Book")) { 5 Здесь мы не используем никакие библиотеки тестирования — только простые инструкции } else { (логические выражения). Так как код выполня0 ется в REPL, мы можем видеть результаты сразу } после выполнения инструкций } → getDiscountPercentage Если условие не выполняется, то getDiscountPercentage(List.empty) == 1 в REPL вы получите результат false → false getDiscountPercentage(List.empty) == 0 Такой способ проверки → true мы будем использовать getDiscountPercentage(List("Apple", "Book")) == 5 на протяжении всей книги → true
Обратите внимание, что код, проверяющий чистые функции, поразительно похож на код, используемый в реальности. Он просто вызывает функцию! Это очень помогает при написании качественных тестов. Одна строка описывает и входные данные, и ожидаемый результат. Сравните три теста чистых функций выше с тестом, который нужно написать для императивной корзины покупок, с которой мы начали (на Java): ShoppingCart cart = new ShoppingCart(); cart.addItem("Apple"); cart.addItem("Book"); assert(cart.getDiscountPercentage() == 5);
Как видите, для проверки императивного кода обычно требуется писать больше тестового кода из-за необходимости настроить состояние перед тестированием.
Этот тест включает несколько строк кода для настройки состояния, из-за чего читающим код потребуется чуть больше времени на то, чтобы понять, что он тестирует, в отличие от однострочного вызова чистой функции. Тестирование императивного кода становится еще более сложным, когда дело доходит до больших классов
Глава 2
I
Чистые функции
Кофе-брейк: тестирование чистых функций Используя чистые функции, мы делаем меньше ошибок. Однако на этом преимущества не заканчиваются. Чистые функции намного проще тестировать. Последний кофе-брейк в этой главе поможет вам начать писать модульные тесты для чистых функций. Ваша задача — написать несколько модульных тестов для каждой из следующих чистых функций. Попробуйте написать для каждой функции хотя бы по два теста, проверяющих разные требования. Чтобы получить тесты, наилучшие из возможных, не смотрите на реализации; просто взгляните на сигнатуры и их требования. def increment(x: Int): Int = { x + 1 } def add(a: Int, b: Int): Int = { a + b } def wordScore(word: String): Int = { word.replaceAll("a", "").length }
Интерпретируйте оценку слова (word score) как бизнес-требование для игры в слова.
Функция wordScore получает строку и возвращает оценку (количество баллов) данного слова в игре в слова. Оценка определяется как количество символов, отличных от 'а'. def getTipPercentage(names: List[String]): Int = { if (names.size > 5) 20 else if (names.size > 0) 10 else 0 }
Функция getTipPercentage получает список имен и вычисляет сумму чаевых, которые следует добавить к счету. Для небольших групп (до пяти человек) чаевые составляют 10 %. Для больших групп — 20 %. Если список имен пуст, то функция должна вернуть 0. def getFirstCharacter(s: String): Char = { if (s.length > 0) s.charAt(0) else ' ' }
Функция получает строку и возвращает ее первый символ. Получив пустую строку, она должна вернуть символ пробела (' ').
65
66 Часть I
I
Функциональный инструментарий
Объяснение для кофе-брейка: тестирование чистых функций Вот несколько примеров допустимых тестов. Это далеко не полный список! Ваши тесты, скорее всего, различаются, и это нормально. Самое главное — ощутить разницу между старыми добрыми тестами для кода с состоянием и функциональными тестами для чистых функций (плюс немного удобств от REPL).
Четырех тестов должно быть достаточно def increment(x: Int): Int = { для проверки различных крайних случаев, x + 1 таких как увеличение положительного } значения, отрицательного значения, increment(6) == 7 0 и значения, близкого к максимальному increment(0) == 1 значению целочисленного типа increment(-6) == -5 increment(Integer.MAX_VALUE - 1) == Integer.MAX_VALUE def add(a: Int, b: Int): Int = { a + b } add(2, 5) == 7 add(-2, 5) == 3
Вот несколько случаев сложения положительных и отрицательных значений. Кроме того, следует проверить сложение с максимальными и минимальными значениями, как это было сделано в тестах функции increment
def wordScore(word: String): Int = { word.replaceAll("a", "").length } wordScore("Scala") == 3 wordScore("function") == 8 wordScore("") == 0
Слово с буквой ‘a’, слово без буквы ‘a’ и пустое слово
def getTipPercentage(names: List[String]): Int = { if (names.size > 5) 20 else if (names.size > 0) 10 else 0 } getTipPercentage(List("Alice", "Bob")) == 10 getTipPercentage(List("Alice", "Bob", "Charlie", "Danny", "Emily", "Wojtek")) == 20 getTipPercentage(List.empty) == 0 def getFirstCharacter(s: String): Char = { if (s.length > 0) s.charAt(0) else ' ' } getFirstCharacter("Ola") == 'O' getFirstCharacter("") == ' ' getFirstCharacter(" Ha! ") == ' '
Надеюсь, вам понравилось писать однострочные, быстрые и стабильные тесты.
Необходимо написать тест для каждого требования: маленькая группа, большая группа и пустой список
Необходимо написать тест для каждого требования: обычная строка и пустая строка. Кроме того, желательно убедиться, что для строк, начинающихся с пробела, будет возвращаться тот же ответ, что и для пустых строк (пограничный случай)
Глава 2
I
Чистые функции
Резюме Вот и все! Подытожим все, что узнали о чистых функциях. Чистая функция • Возвращает единственное значение. • Вычисляет результат, опираясь только на свои аргументы. • Не изменяет существующие значения.
Зачем нужны чистые функции В начале этой главы мы написали простое императивное решение реальной задачи. Однако у нашего решения выявились некоторые проблемы, связанные с обработкой состояния. Мы пришли к выводу, что даже в простых императивных вычислениях состояние может вызывать некоторые неожиданные проблемы.
Передача копий данных Первая проблема обнаружилась, когда пользователи нашего класса начали использовать функцию remove() возвращаемого нами списка ArrayList. Мы решили эту проблему, передавая и возвращая копии данных.
Повторные вычисления вместо сохранения Но у нас оставалась еще одна проблема. Даже после добавления в интерфейс класса метода removeItem (который прежде отсутствовал) мы неправильно обрабатывали изменения состояния, что приводило к его повреждению. Мы узнали, что можем отказаться от части состояния и просто заново вычислять скидку на основе текущего содержимого корзины, когда это необходимо.
Передача состояния В итоге мы получили класс с пятью методами, четыре из которых оказались простыми обертками вокруг методов ArrayList. Мы решили избавиться от них за счет передачи списка непосредственно в функцию getDiscountPercentage. В итоге мы получили одну маленькую функцию, которая решает поставленную задачу.
Тестирование чистых функций Наконец, мы кратко обсудили еще одно большое преимущество чистых функций: простоту их тестирования. Тестирование — важный шаг, поскольку не только позволяет проверить правильность решения, но и играет роль его документации. Однако для этого тесты должны быть краткими и понятными. Мы вернемся к теме тестирования и подробно обсудим ее в главе 12.
67 КОД : CH02_* Код примеров из этой главы доступен в файлах ch02_* в репозитории книги.
Неизменяемые значения
В этой главе вы узнаете: • почему изменяемость опасна; • как бороться с изменяемостью, используя копии; • что такое общее изменяемое состояние; • как бороться с изменяемостью, используя неизменяемые значения; • как использовать неизменяемые API классов String и List.
Плохие программисты беспокоятся о коде. Хорошие программисты беспокоятся о структурах данных и их отношениях. Линус Торвальдс (Linus Torvalds)
3
Глава 3
I
Неизменяемые значения
Топливо для двигателя В предыдущей главе мы познакомились с чистыми функциями, которые будут нашими лучшими друзьями на протяжении всей книги. Мы перечислили и кратко обсудили некоторые недостатки изменяемого состояния — значений, которые могут изменяться. В этой главе мы сосредоточимся на проблемах изменяемых состояний и посмотрим, почему чистые функции в большинстве случаев не могут использовать эти состояния. Мы познакомимся с неизменяемыми значениями, которые широко применяются в функциональном программировании. Связь между чистыми функциями и неизменяемыми значениями настолько сильна, что функциональное программирование можно определить, используя всего две концепции. Функциональное программирование Это программирование с использованием чистых функций, манипулирующих неизменяемыми значениями. Если чистые функции образуют двигатель функциональных программ, то неизменяемые значения — это его топливо, масло и выхлоп.
Неизменяемое Чистая значение функция Неизменяемое значение
f
f
Неизменяемое значение
f
f
Неизменяемое значение
f f
В О
Можно ли писать полноценное приложение, используя только чистые функции и значения, которые никогда не изменяются?
Краткий ответ выглядит так: чистые функции создают копии данных и передают их дальше. В языке должны иметься определенные конструкции, позволяющие легко программировать операции с использованием копий. Узнать больше можно, прочитав более подробный ответ в этой и следующих главах.
69
70 Часть I
I
Функциональный инструментарий
Еще один пример неизменяемости Знакомясь с чистыми функциями, вы увидели, какие проблемы может доставить изменяемое состояние. Теперь пришло время вспомнить то, что мы выяснили, и рассмотреть другие потенциальные проблемы.
Путешествие по Европе Контекстом для нашего следующего примера послужит маршрут поездки. Допустим, мы планируем поездку по городам Европы: из Парижа в Краков. Составляем первый план:
Париж
Берлин
Краков
List planA = new ArrayList(); planA.add("Paris"); planA.add("Berlin"); planA.add("Kraków"); System.out.println("Plan A: " + planA); вывод в консоли: Plan A: [Paris, Berlin, Kraków]
Но затем один из наших друзей, большой поклонник Моцарта, как оказалось, настоял на посещении Вены перед приездом в Краков: Париж
Берлин
Краков Вена
Краков
List planB = replan(planA, "Vienna", "Kraków"); System.out.println("Plan B: " + planB); вывод в консоли: Plan B: [Paris, Berlin, Vienna, Kraków]
Наша задача — написать функцию replan, возвращающую обновленный план. Ей понадобятся три параметра: • план, который нужно изменить (например, [Paris, Berlin, Kraków]); • новый город для добавления в план (например, Vienna); • город, перед которым следует добавить новый (например, Kraków). Основываясь на этом описании и примере использования, можно сделать вывод, что функция replan должна иметь следующую сигнатуру: static List replan(List plan, String newCity, String beforeCity)
Возвращает новый план
Принимает заданный план plan и вставляет новый город newCity перед городом beforeCity и...
Глава 3
I
Неизменяемые значения
Можно ли доверять этой функции Рассмотрим одну из возможных реализаций функции replan и исследуем ее на исходном примере добавления Вены перед конечным пунктом путешествия — Краковом:
Время
List planA = new ArrayList(); planA.add("Paris"); planA.add("Berlin"); planA.add("Kraków"); List planB = replan(planA, "Vienna", "Kraków"); planA
Paris Berlin Kraków
plan
Paris Berlin Kraków
plan
Paris Berlin Vienna Kraków
planB
Paris Berlin Vienna Kraków
71 Код слева представлен также графически на диаграмме ниже. В серой области слева показаны переменные и то, как они меняются с течением времени (смотрите диаграмму сверху вниз)
replan Вызов
replan
static List replan(List plan, String newCity, String beforeCity) { int newCityIndex = plan.indexOf(beforeCity); plan.add(newCityIndex, newCity); return plan; }
plan "Vienna" "Kraków" 2
replan
вернула
Читайте эту диаграмму сверху вниз. Серая Имя область — это память, меняющаяся со временем. plan Имя, указывающее на поле с серой рамкой, представляет содержимое определенного адреса в памяти в конкретный момент времени
Как видите, сначала мы создаем первоначальный план — planA. Затем вызываем функцию replan, чтобы добавить в план посещение Вены перед Краковом. Затем внутри функции replan мы сначала выясняем индекс города (в плане Краков имеет индекс 2), перед которым требуется вставить в план новый город (Вену). Добавляем Вену в позицию с этим индексом, перемещая все последующие города на один индекс вправо и расширяя список. И наконец, возвращаем и сохраняем результат как planB. Мы получили желаемый результат, но пока рано праздновать. Окончательный план planB выглядит правильным, но если теперь вывести первоначальный план planА, то он будет отличаться от того, который был создан в самом начале. System.out.println("Plan A: " + planA); вывод в консоли: Plan A: [Paris, Berlin, Vienna, Kraków]
Что случилось? Взгляните на верхнюю часть страницы, где мы создали planA. В нем было всего три города. Как Вена прокралась в первоначальный план, если мы добавили ее только в planB? К сожалению, функция replan не работает так, как было обещано. Она не просто возвращает новый план с новым городом. Она изменяет не только возвращаемый список.
Как Вена (Vienna) прокралась в первоначальный план planА, если мы добавили ее только в planB?
72 Часть I
I
Функциональный инструментарий
Изменяемость опасна
Время
Вызывая функцию, принимающую список и возвращающую список, мы ожидаем получить новый список (новый экземпляр List). Но в действительности эта функция изменяет список, полученный в качестве параметра. planA plan
Paris Berlin Kraków
replan Вызов
replan
planA plan planA planB
Paris Berlin Vienna Kraków
static List replan(List plan, plan String newCity, "Vienna" String beforeCity) { "Kraków" int newCityIndex = plan.indexOf(beforeCity); 2 plan.add(newCityIndex, newCity); return plan; }
replan
вернула
Paris Berlin Vienna Kraków
plan
plan — это просто ссылка на тот же список в памяти, на который указывает planA, поэтому любые изменения в plan также отражаются на planA.
Теперь мы знаем, что произошло! К сожалению, replan солгала нам. Да, функция вернула желаемый результат, но при этом изменила список, переданный ей в качестве аргумента! Параметр plan внутри функции replan ссылается на тот же список в памяти, что и planA вне функции. Функция обещала вернуть новое значение (об этом свидетельствует тип возвращаемого значения List), но вместо этого просто изменила список, переданный ей в качестве аргумента! Изменения в plan отра зились на planA... Использование изменяемых значений опасно. Функция replan — это простая трехстрочная функция, заглянув в которую легко увидеть, что она изменяет входной аргумент. Но есть гораздо более сложные функции, которым нужно уделять намного больше внимания для того, чтобы исключить появление такой скрытой ошибки. Есть еще много чего, на чем вам нужно сосредоточиться, что не связано с решением текущей бизнес-задачи. Возвращаемый список — это копия или представление? Если это представление, то могу ли я изменить его?
Опытные разработчики могут заметить, что эта проблема довольно очевидна, но имейте в виду, что подобные проблемы трудно обнаружить в больших базах кода. ЭТО ВА Ж Н О! Исключение изменяемости лежит в основе функционального программирования.
Может ли replan изменить переданный ей список? Смогу ли я повторно использовать исходный список в качестве аргумента?
Глава 3
I
Неизменяемые значения
73
Функции, которые лгут... снова Чтобы решить проблему с функцией replan, вспомним тему главы 2: чистые функции. Информация, приводившаяся там, должна помочь вам понять эту конкретную проблему, с которой мы столкнулись, и, надеюсь, другие проблемы, связанные с изменяемыми значениями. Является ли replan чистой функцией?
Чистая функция
static List replan(List plan, String newCity, String beforeCity) { int newCityIndex = plan.indexOf(beforeCity); plan.add(newCityIndex, newCity); return plan; }
• Возвращает единственное значение. • Вычисляет результат, опираясь только на свои аргументы. • Не изменяет существующие значения.
Функция replan возвращает единственное значение и вычисляет его, опираясь только на свои аргументы. Но, как оказалось, она изменяет существующие значения (в данном случае изменяет список в первом аргументе: plan). То есть replan не является чистой функцией. Хуже того, нам потребовалось заглянуть в реализацию, чтобы понять это! Сначала мы просто посмотрели на ее сигнатуру и предположили, что это чистая функция. Затем использовали ее как таковую. Так и появляются ошибки. Такие функции хуже всего, поскольку рушат наши интуитивные представления. Чтобы лучше осознать это, посмотрим на другие функции, изменяющие существующие значения, которые можно найти в классе List, и попробуем угадать, какие из них что-то изменяют и могут вызвать подобные проблемы. Удаляет строку из этого списка (тип void возвращаемого значения указывает, что единственный возможный результат — изменение существующего списка) Добавляет все элементы из collection в конец этого списка
List
remove(string): void addAll(collection): boolean subList(from, to): List
Возвращает список, который является представлением данного списка (он не изменяется напрямую, но если изменить возвращаемое представление, то изменения затронут сам список)
Эти три метода класса List тоже находятся в стандартной библиотеке Java. Как видите, все они используют разные подходы к изменению существующего значения. Их сигнатуры противоречат нашим интуи тивным представлениям и способствуют появлению ошибок.
Интуиция очень важна в программировании. Чем интуитивнее используемый API, тем эффективнее вы справитесь со своей работой и тем меньше ошибок допустите. Вот почему мы стремимся использовать интуицию в своих интересах
74 Часть I
I
Функциональный инструментарий
Борьба с изменяемостью за счет использования копий Чтобы решить проблему, нужно убедиться, что наша функция replan не изменяет существующие значения, что она чистая. Теперь, зная, чего ожидают пользователи от нашей функции, мы перестанем изменять значения, передаваемые в аргументах, и перепишем replan. Нам не нужно менять сигнатуру — только внутреннюю реализацию: static List replan(List plan, String newCity, String beforeCity) { int newCityIndex = plan.indexOf(beforeCity); List replanned = new ArrayList(plan); replanned.add(newCityIndex, newCity); return replanned; }
Изменение значений внутри чистой функции Вы заметили, что эта версия создает копию входного аргумента в переменной replanned, а затем изменяет ее с помощью add? Но не нарушает ли это изменение третье правило чистых функций? Чтобы ответить на этот вопрос, вспомним это третье правило.
Помните о ссылочной прозрачности? Если вызвать функцию replan несколько раз с одними и теми же аргументами, то всегда ли она будет возвращать один и тот же результат? До ее реорганизации она каждый раз возвращала разные результаты. А после реорганизации именно так и будет работать! Наша функция replan теперь ссылочно-прозрачная!
Чистая функция • Возвращает единственное значение. • Вычисляет результат, опираясь только на свои аргументы. • Не изменяет существующие значения.
Чистые функции не изменяют существующие значения. Они не должны изменять никакие свои аргументы или значения в глобальной области видимости. Однако они могут изменять локальные значения. В нашем случае replan создает локальный список, изменяет его, а затем возвращает. Обратите внимание, что язык программирования Java поддерживает только изменяемые коллекции. Однако мы можем использовать силу чистых функций, изменяя только копии, созданные внутри функций. Теперь наша функция replan действует в полном соответствии с нашей интуицией и нас не ждут неприятные сюрпризы. System.out.println("Plan A: " + planA); вывод в консоли: Plan A: [Paris, Berlin, Kraków] List planB = replan(planA, "Vienna", "Kraków"); System.out.println("Plan B: " + planB); вывод в консоли: Plan B: [Paris, Berlin, Vienna, Kraków] System.out.println("Plan A: " + planA); вывод в консоли: Plan A: [Paris, Berlin, Kraków]
Очень скоро мы покажем, что в функциональных языках подобная реорганизация не нужна — даже внутри функций. И все же приятно сознавать, что многие приемы функцио нального программирования можно с успехом использовать в традиционно императивных языках, таких как Java, без каких-либо дополнительных функциональных библиотек.
Глава 3
I
Неизменяемые значения
75
Кофе-брейк: обжигаемся на изменяемости Теперь настала ваша очередь столкнуться с опасностями изменяемости. Ниже описывается еще один пример, таящий в себе проблему, в котором используется другой метод класса List, изменяющий свой аргумент.
Время на круге
Требования к totalTime
Самое важное, что измеряется в автоспорте, — это время прохождения круга. Автомобили или мотоциклы едут по трассе и пытаются показать минимально возможное время прохождения круга. Чем быстрее, тем лучше! Вот две функции:
• Функция должна возвращать общее время прохождения всех кругов, за исключением первого, который является прогревочным кругом и используется для подготовки автомобиля и шин к прохождению последующих кругов на максимальной скорости. • Вычисления выполняются, только если список содержит информацию как минимум о двух кругах.
static double totalTime(List lapTimes) { lapTimes.remove(0); double sum = 0; for (double x : lapTimes) { sum += x; } return sum; } static double avgTime(List lapTimes) { double time = totalTime(lapTimes); int laps = lapTimes.size(); return time / laps; }
А вот пример использования этих функций, иллюстрирующий проблему: ArrayList lapTimes = new ArrayList(); lapTimes.add(31.0); // прогревочный круг // (не участвует в вычислениях) lapTimes.add(20.9); Создается список, lapTimes.add(21.1); и в него заносятся реlapTimes.add(21.3); зультаты прохождения
Требования к avgTime • Функция должна вернуть среднее время прохождения круга, за исключением прогревочного. • Вычисления выполняются, только если список содержит информацию как минимум о двух кругах.
четырех кругов (значения секунд типа double)
System.out.printf("Total: %.1fs\n", totalTime(lapTimes)); System.out.printf("Avg: %.1fs", avgTime(lapTimes));
Вывод результатов функций с точностью до 0,1
Подумайте, что может пойти не так. Сможете ли вы перечислить как можно больше потенциальных проблем? Какая часть кода самая подозрительная? К сожалению, код выше выводит неверные значения: Total: 63.3s Avg: 21.2s
Ваша задача — выяснить, каким должен быть правильный результат, и исправить totalTime и/или avgTime.
76 Часть I
I
Функциональный инструментарий
Объяснение для кофе-брейка: обжигаемся на изменяемости Сначала выясним, каким должен быть правильный результат. Вы можете попробовать рассчитать среднее и общее время в уме. ArrayList lapTimes = new ArrayList(); lapTimes.add(31.0); // прогревочный круг lapTimes.add(20.9); lapTimes.add(21.1); lapTimes.add(21.3);
Total 0.0 20.9 42.0 63.3
Laps 0 1 2 3
Avg 20.9 21.0 21.1
System.out.printf("Total: %.1fs\n", totalTime(lapTimes)); System.out.printaf("Avg: %.1fs", avgTime(lapTimes));
Если бы функции были написаны в соответствии со спецификацией, то предыдущий код вывел бы: Total: 63.3s Avg: 21.1s
Но, запустив его, мы получим следующее: Total: 63.3s Avg: 21.2s
Почему код вывел число 21,2, а не 21,1, полученное вручную?! Что случилось?
Итак, totalTime вернула правильный результат (63.3). Но почему результат avgTime (21.2) отличается от вычисленного вручную (21.1)? Что это? Ошибка округления? Или просто ошибка в функции, которую мы пропустили?
Глава 3
I
Неизменяемые значения
77
Отладка totalTime Выясним, выполнив отладку обеих функций, начиная с totalTime.
totalTime lapTimes
31.0 20.9 21.1 21.3
static double totalTime(List lapTimes) { lapTimes.remove(0); double sum = 0; for (double x : lapTimes) { sum += x; } return sum; }
63.3
Функция totalTime получает список с четырьмя значениями времени прохождения круга, удаляет первое, затем складывает оставшиеся и возвращает результат вызывающей стороне. Все эти действия выглядят разумными, и действительно, мы получаем правильный результат, когда вызываем эту функцию. Пока все идет как надо.
Отладка avgTime avgTime lapTimes
31.0 20.9 21.1 21.3
static double avgTime(List lapTimes) { double time = totalTime(lapTimes); 63.3 int laps = lapTimes.size(); 3 return time / laps; 63.3 / 3 }
Когда avgTime вызывается отдельно, она возвращает правильный результат 21.1 . Так почему же мы получили 21.2, когда запустили следующий ниже код? Это все еще большая загадка, но не будем терять надежду. System.out.printf("Total: %.1fs\n", totalTime(lapTimes)); вывод в консоли: Total: 63.3s System.out.printf("Avg: %.1fs", avgTime(lapTimes)); вывод в консоли: Avg: 21.2s
21.1
78 Часть I
I
Функциональный инструментарий
Отладка totalTime и avgTime в комплексе
lapTimes
System.out.printf("Total: %.1fs\n", totalTime(lapTimes));
totalTime
31.0 20.9 21.1 21.3
31.0 20.9 21.1 21.3
static double totalTime(List lapTimes) { lapTimes.remove(0); double sum = 0; for (double x : lapTimes) { sum += x; } return sum; }
63.3
System.out.printf("Avg: %.1fs", avgTime(lapTimes));
avgTime 20.9 21.1 21.3
static double avgTime(List lapTimes) { double time = totalTime(lapTimes); 42.4 static double totalTime(List lapTimes) { lapTimes.remove(0); double sum = 0; for (double x : lapTimes) { sum += x; } return sum; }
20.9 21.1 21.3
21.1 21.3
21.2
totalTime
}
2 int laps = lapTimes.size(); return time / laps; 42.4 / 2
Как видите, ошибка не проявляется, когда функции используются по отдельности, и возникает, только когда они применяются в комплексе в более крупной программе. Изменяемость нас обманула!
42.4
Глава 3
I
Неизменяемые значения
79
Делаем функции чистыми, используя копии Мы уже научились справляться с такими проблемами. Наши функции totalTime и avgTime не являются чистыми, поскольку изменяют существующее значение — в данном случае lapTimes. Чтобы исправить проблему, нужно внутри обеих функций работать с копией lapTimes. static double totalTime(List lapTimes) { List withoutWarmUp = new ArrayList(lapTimes); withoutWarmUp.remove(0); // удалить прогревочный круг double sum = 0; for (double x : withoutWarmUp) { sum += x; } return sum; } static double avgTime(List lapTimes) { double time = totalTime(lapTimes); List withoutWarmUp = new ArrayList(lapTimes); withoutWarmUp.remove(0); // удалить прогревочный круг int laps = withoutWarmUp.size(); return time / laps; }
Теперь обе функции чистые: они возвращают единственное значение, вычисленное только на основе аргументов, и обе не изменяют никаких существующих значений. Теперь эти функции вызывают больше доверия, поскольку ведут себя более предсказуемо. Повторю еще раз: эта их особенность называется ссылочной прозрачностью. Наши функции будут возвращать одно и то же значение, если передать им одни и те же аргументы — несмотря ни на что.
Можно ли улучшить решение Некоторые из вас, вероятно, задаются вопросом о дублировании кода в полученном нами решении. Операция удаления прогревочного круга повторяется в обеих функциях. Это немного другая проблема, которая нарушает очень популярное правило: «Не повторяйся» (Don’t repeat yourself, DRY). Мы обратимся к этой проблеме и устраняющему ее правилу позже, поскольку для ее устранения в функциональном стиле нужен другой инструмент. Если вы попытались решить эту проблему, выполняя упражнения, и нашли рабочее решение, которое не изменяет существующие значения, то это очень хорошо! Если нет, то не волнуйтесь, так как скоро вы научитесь это делать. А прямо сейчас мы должны сосредоточиться только на том, чтобы избежать изменения значений.
Если многократные вызовы функции с одним и тем же набором аргументов всегда возвращают один и тот же результат, то мы говорим, что эта функция ссылочнопрозрачная
80 Часть I
I
Функциональный инструментарий
Знакомьтесь: общее изменяемое состояние Проблема, с которой мы столкнулись в предыдущих примерах, — лишь одна из многих, которые напрямую связаны с использованием общего изменяемого состояния.
Что такое общее изменяемое состояние Состояние — это экземпляр значения, хранящийся в одном месте и доступный из кода. Если это значение можно изменить, то, следовательно, мы имеем изменяемое состояние. Кроме того, если к этому изменяемому состоянию можно получить доступ из разных частей кода, это общее изменяемое состояние.
общее изменяемое состояние Это значение Это значение доступно можно изменить из разных частей на месте программы
Это значение хранится в одном месте и доступно коду
Рассмотрим наши проблемные примеры и определим, какие их части вызывали проблемы и могли быть классифицированы как общее изменяемое состояние.
Посмотрите, как мы переключаем внимание с функций, работающих с данными (то есть добавляющих или удаляющих данные), на сами данные (то есть plan и lapTimes)
replan
static List replan(List plan, String newCity, String beforeCity) { int newCityIndex = plan.indexOf(beforeCity); plan.add(newCityIndex, newCity); return plan; }
Здесь параметр функции plan является общим изменяемым состоянием! Поэтому replan — нечистая функция.
List plan
• Это состояние, поскольку к нему можно получить доступ. • Оно изменчиво. • Является общим (используется и изменяется функцией replan и самой программой).
Глава 3
I
Неизменяемые значения
81
totalTime static double totalTime(List lapTimes) { lapTimes.remove(0); double sum = 0; for (double x : lapTimes) { sum += x; } return sum; }
List lapTimes
• Это состояние, поскольку к нему можно получить доступ. • Оно изменчиво. • Является общим (используется функциями avgTime, totalTime и самой программой). Как видите, plan и lapTimes являются общими изменяемыми состояниями. Общее изменяемое состояние является строительным блоком в императивном программировании. Оно может принимать разные формы: глобальной переменной, поля экземпляра класса или любого хранилища, доступного для чтения и записи, такого как таблица в базе данных или файл. Его также можно передать в качестве аргумента. Самое главное, как мы только что видели, — это может вызвать серьезные проблемы.
3
2
1
2
3
1
2
1
3
1
2
3
Как вы, наверное, помните, императивное программирование заключается в следовании некоторым пошаговым процедурам. Они обычно работают с изменяемыми состояниями (например, алгоритмы сортировки изменяют существующие массивы)
82 Часть I
I
Функциональный инструментарий
Влияние состояния на возможность программирования Мозг программиста легко перегружается. Программируя какое-то решение, мы должны помнить о многих аспектах, и чем их больше, тем выше вероятность что-то упустить из виду или сделать что-то неправильно. Эта проблема связана не с изменяемым состоянием, а с программированием в целом. И все же изменяемое состояние является отправной точкой для остальной части обсуждения. Какие части программы используют это?
Что я должен вызвать после изменения?
Здесь может что-то измениться?
Какие значения это может иметь?
Во-первых, если приходится помнить о многих аспектах, чтобы запрограммировать решение задачи (обычное дело для нас), то задача существенно усложняется, если эти аспекты могут измениться в любое время, например, между вызовами функций или даже между двумя строками кода (при программировании с использованием потоков выполнения). Во-вторых, если эти постоянно меняющиеся аспекты являются к тому же общими, то возникает проблема владения и ответственности за них. Мы вынуждены постоянно спрашивать себя: «Могу ли я безопасно изменить это значение?», «Какие другие части программы используют это значение?» и «Если я изменю это значение, то какую сущность я должен уведомить об этом?».
Чем больше аспектов нужно помнить, тем выше когнитивная нагрузка. В предыдущем примере мы рассматривали функцию replan, которая принимала план как параметр и возвращала новый план. Несмотря на то что на вход мы передавали planA и сохраняли результат вызова функции в planB, мы все время работали с одним и тем же изменяемым объектом!
Глава 3
I
Неизменяемые значения
В-третьих, если множество сущностей может изменить заданное состояние, то у нас могут возникнуть проблемы с идентификацией всех возможных значений этого состояния. Очень заманчиво предположить, что состояние будет всегда иметь значения, сгенерированные только имеющимся кодом. Но это ошибочное предположение, если состояние является общим! Помните как мы предполагали, что после создания плана plan его нельзя изменить, поскольку функция replan возвращает новый план?
planA
Paris Berlin Kraków
Вызов replan planA plan
Paris Berlin Vienna Kraków
replan вернула planA
planB
Paris Berlin Vienna Kraków
Изменяемые общие состояния — это движущиеся части, на которые важно обращать внимание при программировании. Каждая часть может двигаться независимо и непредсказуемо. Эти особенности делают общие изменяемые состояния сложными. Все эти движущиеся части усложняют программу. Чем больше объем кода, тем сложнее становятся все вышеперечисленные проблемы! Вы наверняка сталкивались с очень распространенной ситуацией, когда изменение некоторого значения в одном месте программы вызывало громадные проблемы в другом, казалось бы, очень далеком месте. Вот наглядная иллюстрация сложности общего изменяемого состояния.
83
84 Часть I
I
Функциональный инструментарий
Работа с движущимися частями Наконец мы можем поговорить о методах работы с движущимися частями, или общим изменяемым состоянием. Далее мы рассмотрим три подхода: подход, который использовали для исправления функции replan, объектно-ориентированный подход и функциональный.
Наш подход replan static List replan(List plan, String newCity, String beforeCity) { int newCityIndex = plan.indexOf(beforeCity); List replanned = new ArrayList(plan); replanned.add(newCityIndex, newCity); return replanned; }
Это чистая функция. Она возвращает единственное значение, которое вычисляется только на основе аргументов. Она также не изменяет никакие существующие значения Мы гарантировали, что она не изменит существующего значения, создав совершенно новый список и скопировав элементы из входного списка
Объектно-ориентированный подход В объектно-ориентированном программировании (ООП) мы, вероятно, использовали бы инкапсуляцию для защиты изменяющихся данных. Инкапсуляция Инкапсуляция — это прием изоляции изменяемого состоя ния, обычно внутри объекта. Объект охраняет состояние, объявляя его приватным и следя за тем, чтобы все изменения выполнялись только через интерфейс объекта. В таком случае код, отвечающий за управление состоянием, хранится в одном месте. Все движущиеся части скрыты. Itinerary private List plan = new ArrayList(); public void replan(String newCity, String beforeCity) { int newCityIndex = plan.indexOf(beforeCity); plan.add(newCityIndex, newCity); } Разрешая изменять состояние, мы должны public void add(String city) { явно определить соотplan.add(city); ветствующие методы } public List getPlan() { return Collections.unmodifiableList(plan); }
Этой функции можно доверять. Она всегда вернет один и тот же результат, получив одни и те же аргументы В объектно-ориентированном программировании данные и методы, изменяющие эти данные, связаны друг с другом. Данные являются приватными и могут изменяться только методами Этот метод возвращает значение void, поскольку изменяет данные на месте. Мы теряем предыдущую версию плана. Кроме того, ему нельзя доверять, так как результат вызова с теми же аргументами может различаться (в зависимости от состояния) Нужно быть очень осторожными, чтобы не допустить утечки внутренних данных вовне. Мы должны вернуть копию или представление, чтобы гарантировать, что никто не сможет изменить состояние (то есть оно не станет общим). Чем больше класс, тем сложнее он становится и тем выше вероятность допустить ошибку
Глава 3
I
Неизменяемые значения
85
Работа с движущимися частями в ФП Пришло время представить вторую очень важную часть нашего функционального инструментария. Мы долго ходили вокруг да около этой темы, встречая по пути всяких изменчивых и нечистых злодеев. Мы знаем, что для борьбы с ними ООП использует инкапсуляцию. А что может предложить ФП?
Функциональный подход
(также используемый в ООП и приобретающий все большую популярность)
Здесь используется другая точка зрения: нужно свести к минимуму количество движущихся частей, а в идеале полностью избавиться от них. В функциональном коде не используется общее изменяемое состояние, в нем используется неизменяемое состояние или просто неизменяемые значения, действующие как состояния. Неизменяемые значения Это методика, гарантирующая, что однажды созданное значение уже нельзя будет изменить. Если программисту потребуется изменить хотя бы малую часть значения (например, добавить строку в список), то он должен создать новое значение, а старое оставить нетронутым.
ЭТО ВА Ж Н О! В ФП не используются изменяемые состояния — вместо них применяются неизменяемые состояния.
Использование неизменяемых значений вместо изменяемых устраняет целый ряд проблем, обсуждавшихся выше в этой главе. Данный прием также решает проблему, с которой мы столкнулись в функции replan. Так в чем же суть? Прежде всего, это совершенно новый мир; войдя в него, мы должны научиться применять неизменяемые значения в практических условиях. По этой причине следует использовать язык Scala, в который встроены неизменяемые коллекции, включая списки.
Краткий обзор функционального подхода в Scala Далее мы познакомимся с полностью функциональным подходом, включающим применение неизменяемых значений, и реализуем функцию replan на Scala. Для начала взгляните на окончательное решение ниже. Не волнуйтесь, если что-то вам покажется непонятным, — я объясню все детали по ходу дела. replan def replan(plan: List[String], newCity: String, beforeCity: String): List[String] = { val beforeCityIndex = plan.indexOf(beforeCity) val citiesBefore = plan.slice(0, beforeCityIndex) val citiesAfter = plan.slice(beforeCityIndex, plan.size) citiesBefore.appended(newCity).appendedAll(citiesAfter) }
Список List в Scala — неизменяемый. Все операции с ним возвращают новый список. Здесь мы используем комбинацию List.slice, List.appended и List.appendedAll, чтобы добавить новый город перед указанным. Эти методы оперируют аргументами по назначению и ничего не изменяют
86 Часть I
I
Функциональный инструментарий
Неизменяемые значения в Scala Разрабатывая функции, оперирующие неизменяемыми значениями, нужно использовать инструменты, приспособленные для этого. Как мы видели в последнем примере, экземпляры List и ArrayList в Java — изменяемые, поэтому нам пришлось применить некоторые трюки, чтобы получить чистые функции. Эти трюки вполне работоспособны, но их все еще можно улучшить. Пришло время вернуться к программированию на Scala, поскольку Scala имеет встроенную поддержку неизменяемости. Например, тип List в Scala по умолчанию является неизменяемым; экземпляр этого типа нельзя изменить на месте. Напоминание об использовании примеров кода из этой книги Прошло довольно много времени после того, как мы использовали Scala и REPL, поэтому я должен напомнить об обозначении, применяемом в книге. Всякий раз, когда вы видите фрагмент кода со значком > слева, это означает, что вы должны вводить код в сеансе Scala REPL (запускается командой sbt console в терминале). Нажимайте Enter после каждой строки, и оболочка REPL должна дать вам ответ, похожий на тот, который показан в книге после знака →.
Проверка List в Scala REPL Убедимся, что все вышесказанное — не пустые обещания и Scala действительно имеет встроенную поддержку неизменяемых значений. Запустите Scala REPL и введите: val appleBook = List("Apple", "Book") → List("Apple", "Book") val appleBookMango = appleBook.appended("Mango") → List("Mango", "Apple", "Book") appleBook.size → 2 appleBookMango.size → 3
То же самое можно сказать о любом другом языке функционального программирования. Однако независимо от выбранного языка вы можете ожидать полной поддержки неизменяемости Прежде чем продолжить, убедитесь, что используете правильную версию Scala. Если у вас возникли проблемы, то обратитесь к руководству в главе 1.
Убедитесь, что используете версию Scala, которую мы установили в главе 1. Если у вас возникли проблемы с вызовом функции appended, то, возможно, вы используете старую версию Scala
Эта функция добавляет элемент в конец списка List. Обратите внимание, что этот способ добавления элементов в список не является идиоматическим для Scala из-за низкой производительности (операция должна скопировать весь исходный список), но отлично подходит при работе с небольшими списками
Как видите, мы добавили Mango в конец списка List, который уже включал Apple и Book. Мы дали этому списку довольно скучное имя appleBookMango. Затем проверили размеры обоих списков, и оказалось, что после добавления нового элемента исходный список appleBook не изменился! Его размер по-прежнему равен 2 . Это означает, что в ответ на операцию добавления мы получили новый список! Правда в том, что в Scala список нельзя изменить. Каждая операция возвращает новый экземпляр List, несмотря ни на что.
Глава 3
I
Неизменяемые значения
87
Вырабатываем интуитивное понимание неизменности Пришло время перейти в неизменяемый мир. Но сделаем это аккуратно. Мы воспользуемся неизменяемым списком List в Scala. Но прежде, чем начать решать с его помощью проблему в функции replan, потренируемся в использовании неизменяемого API, который мы все уже хорошо знаем: String! Сравним List в Scala и String в Java. List[String]
String
appendedAll(list): List[String]
concat(string): String
val ab = List("a", "b") val cd = List("c", "d") val abcd = ab.appendedAll(cd)
String ab = "ab"; String cd = "cd"; String abcd = ab.concat(cd);
Мы добавили строку cd в конец строки ab и получили строку abcd. Строка ab не изменилась!
Мы добавили все элементы из cd в конец ab и получили обратно abcd. Список ab не изменился! ab
cd
a b
c d
abcd a b c d
ab
ab
cd
cd
abcd abcd
List[String]
String
slice(from, to): List[String]
substring(from, to): String
val abcd = List("a", "b", "c", "d") val bc = abcd.slice(1, 3)
Мы хотели извлечь элементы из середины четырехэлементного списка abcd и получили меньший двухэлементный список bc. Список abcd не изменился!
abcd
a b c d
bc
b c
String abcd = "abcd"; String bc = abcd.substring(1, 3);
Мы хотели извлечь символы из четырехбуквенной строки abcd и получили меньшую двухбуквенную строку bc. Строка abcd не изменилась! abcd abcd
bc
bc
Объект типа String — неизменяемый. Если вы понимаете, как работает String, то легко поймете неизменяемые коллекции. Если нет, то не волнуйтесь: мы немного поэкспериментируем со String, прежде чем двигаться дальше.
88 Часть I
I
Функциональный инструментарий
Кофе-брейк: неизменяемый тип String Поэкспериментируем с типом String из Java, но в... Scala! Этот язык использует тот же класс String, что и Java, поэтому относитесь ко всему, о чем рассказывается ниже, как к мягкому введению в неизменяемые API и практику их использования в Scala. Ваша задача — реализовать функцию abbreviate, которая принимает строку String c полным именем и возвращает сокращенную версию. String
abbreviate
String
Функция abbreviate должна принимать одну строку в аргументе и возвращать новую с результатом. Например, получив строку "Alonzo Church", функция должна вернуть "A. Church".
"Alonzo Church"
"A. Church"
"A Church"
String
abbreviate
String
String
abbreviate
String
String
abbreviate
String
Напомню, что функция abbreviate должна быть чистой и оперировать только неизменяемыми значениями. Она ничего не должна изменять! Используйте возможности создания новых значений на основе существующих. Вот несколько дополнительных советов.
Алонзо Черч (Alonzo Church) — математик, разработавший лямбда-исчисление, которое является основой ФП
"A. Church"
"A. Church"
"A. Church"
Чистая функция • Возвращает единственное значение. • Вычисляет результат, опираясь только на свои аргументы. • Не изменяет существу ющие значения.
• Тип String в Scala действует точно так же, как в Java, поэтому вы можете использовать имеющие ся у вас знания или заглянуть в документацию, описывающую тип String. • Тип String в Java — неизменяемый, поэтому, ограничив себя использованием методов String, вы можете быть уверены, что ваша реализация чистая и ничего не изменяет. • Для решения этого упражнения используйте Scala. Напомню, что функции определяются с помощью ключевого слова def: def function(stringParameter: String): String = { ... }
Глава 3
I
Неизменяемые значения
89
Объяснение для кофе-брейка: неизменяемый тип String Для начала вручную определим, чего мы хотим достичь. separator — это индекс первого вхождения пробела в name
Alonzo Church initial — это подстрока в начале name,
lastName — это подстрока в name: от индекса после separator до конца name
состоящая из одной буквы
Обратите внимание, что мы сосредоточились на определении отношений между входным значением и результатом. Использование «— это» в определениях важно и полезно, поскольку помогает представить каждый определяемый элемент как неизменяемое значение. initial — это подстрока в начале name,
состоящая из одной буквы
name
val initial = name.substring(0, 1)
Обратите внимание: мы сосредоточились на том, что нужно сделать, а не как это сделать. Это называется декларативным программированием. Alonzo Church
initial
A
separator
6
lastName
Church
separator — это индекс
первого вхождения пробела в name
val separator = name.indexOf(' ')
lastName — это подстрока в name: от индекса после separator val lastName = name.substring(separator + 1) до конца name
Следовательно, окончательная реализация может выглядеть так: def abbreviate(name: String): String = { val initial = name.substring(0, 1) val separator = name.indexOf(' ') val lastName = name.substring(separator + 1) initial + ". " + lastName }
Напомню, что в Scala можно не использовать ключевое слово return. В таком случае будет возвращен результат последнего выражения
Конечно, это лишь одна из возможных реализаций. Самое главное для нас — убедиться, что код не изменяет значения.
Обратите внимание, что мы игнорируем обработку ошибок. Об этом мы позаботимся в главе 6
90 Часть I
I
Функциональный инструментарий
Постойте... Разве это не плохо? В основе функционального программирования лежат чистые функции, которые оперируют неизменяемыми значениями. Это означает, что каждое выражение создает новый объект либо с нуля, либо путем копирования частей другого объекта. Никакие значения не могут быть изменены. У вас могут возникнуть несколько вопросов.
В О
Разве копирование не отражается негативно на производительности?
Да, копирование ухудшает производительность по сравнению с простым изменением на месте. Однако мы можем возразить, что в большинстве приложений это обстоятельство не имеет большого значения. Во многих случаях удобочитаемость и простота сопровождения кода перевешивает потенциальное падение производительности.
В О
То есть, если я использую функциональное программирование, мои приложения будут работать медленно?
Не обязательно. Анализируя производительность, прежде всего важно убедиться в правильном выборе кода для оптимизации. Сначала нужно найти узкое место и только потом пытаться его оптимизировать. Если вы уверены, что основным виновником является операция, не изменяющая значение, то у вас на выбор все еще остается несколько вариантов. Например, если проблема в том, что вы часто добавляете что-то в конец очень большого списка, то вместо добавления в конец можно попробовать использовать функцию добавления в начало, которая выполняется за постоянное время, не копирует и не изменяет старый список.
В О
Почему бы просто не использовать Collections.unmodifiable List в Java?
unmodifiableList принимает список в параметре и возвращает список, просто являющийся представлением исходного списка. Это представление действует как список, но его нельзя изменить. Он имеет метод add, и его можно даже вызвать... но это приведет к исключению во время выполнения, что нарушит наше доверие. Кроме того, даже если мы вернем «неизменяемый» список нашему пользователю, у нас сохраняется возможность изменить исходный список! Следовательно, пользователь не имеет никаких гарантий относительно возвращаемого значения, и все проблемы с общим изменяемым состоянием, обсуждавшиеся выше, все еще могут возникнуть.
ЭТО ВА Ж Н О! В ФП мы просто передаем неизменяемые значения!
С другой стороны, в некоторых сценариях можно использовать изменяемость для оптимизации. Вы по-прежнему можете использовать изменяемость и скрывать ее внутри чистой функции, как мы это делали выше. Самое главное — это должно делаться только в исключительных случаях и после тщательного анализа производительности программы
Мы обсудим рекурсивные значения в главе 9 Мы обсудим обработку исключений в главе 6
Глава 3
I
Неизменяемые значения
Чисто функциональный подход к общему изменяемому состоянию У нас есть почти все необходимые инструменты для решения проблемы функции replan (и многих других подобных функций) с применением чисто функционального подхода на Scala. Вот последняя версия, которую мы реализовали. replan static List replan(List plan, String newCity, String beforeCity) { int newCityIndex = plan.indexOf(beforeCity); List replanned = new ArrayList(plan); replanned.add(newCityIndex, newCity); return replanned; }
91 Это чистая функция. Она возвращает единственное значение, которое вычисляется только на основе аргументов, и не изменяет никакие существующие значения
Мы должны гарантировать, что она не изменит никакие существующие значения, создав совершенно новый список и скопировав элементы из входного списка Этой функции можно доверять. Она всегда вернет один и тот же результат, получив одни и те же аргументы
Теперь мы можем справиться с обозначенной проблемой, поскольку Scala, как и любой другой язык ФП, имеет встроенную поддержку неизменяемых коллекций. При использовании списка List в Scala нет необходимости беспокоиться о возможных изменениях. Его просто невозможно изменить!
Концентрация внимания на отношениях между значениями Прежде чем что-либо внедрять, ненадолго остановимся и посмотрим, как можно проанализировать такие проблемы и прийти к чисто функциональным решениям. Хитрость заключается в том, чтобы всегда начинать с перечисления всех известных взаимосвязей между входными значениями и результатом. Города, которые должны предшествовать newCity, — все города, стоящие перед beforeCityIndex в списке plan
plan
ЭТО ВА Ж Н О! Неизменяемость заставляет нас сосредоточиться на отношениях между значениями.
Города, которые должны следовать за newCity, — все города, стоящие после beforeCityIndex в списке plan
[Paris, Berlin, Kraków]
newCity
Vienna
beforeCity
Kraków
beforeCityIndex — это индекс beforeCity
в списке plan
92 Часть I
I
Функциональный инструментарий
Преобразование отношений в выражения Определив все отношения между входными значениями и желаемым результатом, можно попытаться представить их в виде выражений на Scala. beforeCityIndex — индекс beforeCity в списке plan
val beforeCityIndex = plan.indexOf(beforeCity) beforeCityIndex
2
Города, которые должны предшествовать newCity, — все города, стоящие перед beforeCityIndex в списке plan
val citiesBefore = plan.slice(0, beforeCityIndex)
Города, которые должны следовать за newCity, — все города, стоящие после beforeCityIndex в списке plan
val citiesAfter = plan.slice(beforeCityIndex, plan.size)
Новый план — это список citiesBefore, в конец которого добавлены newCity и citiesAfter
citiesBefore.appended(newCity).appendedAll(citiesAfter)
List[String]
citiesBefore
citiesAfter
Kraków
List[String]
slice(from, to): List[String]
appended(element): List[String]
Возвращает новый список, содержащий элементы из этого списка, начиная с индекса from и заканчивая элементом перед индексом to
Возвращает новый список, содержащий все элементы из этого списка и дополнительный элемент element в конце
val abcd = List("a", "b", "c", "d") val bc = abcd.slice(1, 3)
Paris Berlin
val ab = List("a", "b") val c = "c" val abc = ab.appended(c)
List[String] appendedAll(suffix: List[String]): List[String] Возвращает новый список, содержащий все элементы из этого списка и все элементы из suffix в конце val ab = List("a", "b") val cd = List("c", "d") val abcd = ab.appendedAll(cd)
Включение выражений в тело функции Самое сложное уже сделано! Теперь остается только скопировать эти выражения в код на Scala и написать сигнатуру функции: def replan(plan: List[String], newCity: String, beforeCity: String): List[String] = { val beforeCityIndex = plan.indexOf(beforeCity) val citiesBefore = plan.slice(0, beforeCityIndex) val citiesAfter = plan.slice(beforeCityIndex, plan.size) citiesBefore.appended(newCity).appendedAll(citiesAfter) }
И вуаля! Мы получили действующую реализацию, не имеющую никаких проблем с общим изменяемым состоянием. Мы использовали неизменяемый список List из Scala и можем быть уверены, что эта функция чистая и она всегда будет действовать одинаково!
Глава 3
I
Неизменяемые значения
93
Практика работы с неизменяемыми списками Пришло время написать несколько функций, использующих неизменяемые списки List в Scala: Напишите функцию firstTwo, которая получает список и возвращает новый, содержащий только два первых элемента из входного списка. Для нее должно выполняться следующее условие:
1
firstTwo(List("a", "b", "c")) == List("a", "b")
Напишите функцию lastTwo, получающую список и возвращающую новый, содержащий только два последних элемента из входного списка. Для нее должно выполняться следующее условие:
2
lastTwo(List("a", "b", "c")) == List("b", "c")
Напишите функцию moveFirstTwoToTheEnd, получающую список и возвращающую новый, в котором первые два элемента входного списка перемещены в конец. Для нее должно выполняться следующее условие:
3
movedFirstTwoToTheEnd(List("a", "b", "c")) == List("c", "a", "b")
Напишите функцию insertBeforeLast, получающую список и новый элемент и возвращающую новый список, в котором новый элемент находится перед последним элементом входного списка. Для нее должно выполняться следующее условие:
4
insertedBeforeLast(List("a", "b"), "c") == List("a", "c", "b")
Ответы def firstTwo(list: List[String]): List[String] = list.slice(0, 2) def lastTwo(list: List[String]): List[String] = list.slice(list.size - 2, list.size)
Обратите внимание, что в Scala можно опустить фигурные скобки и компилятор не будет считать это ошибкой.
def movedFirstTwoToTheEnd(list: List[String]): List[String] = { val firstTwo = list.slice(0, 2) val withoutFirstTwo = list.slice(2, list.size) Мы используем appendedAll, withoutFirstTwo.appendedAll(firstTwo) поскольку last — это список } List, содержащий один элемент def insertedBeforeLast(list: List[String], element: String): List[String] = { val last = list.slice(list.size - 1, list.size) val withoutLast = list.slice(0, list.size - 1) withoutLast.appended(element).appendedAll(last) }
94 Часть I
I
Функциональный инструментарий
Резюме Подытожим все, что узнали о неизменяемых значениях.
Изменяемость опасна Мы начали с простого императивного решения реальной задачи. Как оказалось, решение имеет некоторые проблемы, связанные с операциями со списками Java, которые изменяют список, переданный в качестве аргумента. Мы обнаружили, что необходимо уделять особое внимание работе с изменяемыми коллекциями. Простого взгляда на сигнатуру недостаточно — нужно очень внимательно изучить реализацию, чтобы не обжечься на изменяемости.
Борьба с изменяемостью с помощью копий Мы решили проблему, скопировав входной список и работая внутри функции только с копией. Так мы позаботились о том, чтобы ее поведение не вызвало удивления у пользователей функции.
Что такое общее изменяемое состояние Затем мы попытались понять, почему изменяемость вызывает такие большие проблемы. Мы определили общее изменяемое состояние в виде переменной, которая является общей для разных сущностей в коде и может ими изменяться.
Борьба с изменяемостью с помощью неизменяемых значений Наконец, вы познакомились с более эффективным приемом борьбы с общими изменяемыми состояниями: неизменяемыми значениями. Если функции принимают только неизменяемые значения и возвращают неизменяемые значения, то можно быть уверенными, что ничего не изменится неожиданно.
Использование неизменяемых строк String и списков List В конце мы выяснили, что класс String в Java уже использует этот подход и многие его методы возвращают новый экземпляр String. Вы узнали, что Scala имеет встроенную поддержку неизменяемых коллекций, и мы немного поэкспериментировали с функциями класса List: slice, append и appendAll. Оставшуюся часть книги мы посвятим рассмотрению всевозможных неизменяемых значений. Функциональное программирование Это программирование с использованием чистых функций, которые оперируют неизменяемыми значениями.
КОД : CH03_* Код примеров из этой главы доступен в файлах ch03_* в репозитории книги.
Функции как значения
В этой главе вы узнаете: • как передавать функции в аргументах другим функциям; • как использовать функцию sortBy; • как использовать функции map и filter; • как возвращать функции из функций; • что с функциями можно обращаться как со значениями; • как использовать функцию foldLeft; • как моделировать неизменяемые данные, используя типы-произведения.
Самая разрушительная фраза в языке: «Мы всегда так делали!» Грейс Хоппер (Grace Hopper)
4
96 Часть I
I
Функциональный инструментарий
Реализация требований в виде функций Мы потратили некоторое время на эксперименты с чистыми функциями и неизменяемыми значениями, которые составляют основу функционального программирования. В данной главе я постараюсь показать, насколько хорошо сочетаются эти две концепции. Вы узнаете, как полезно думать о бизнес-требованиях с точки зрения функций и что с чистыми функциями можно обращаться как со значениями.
Оценка слов Нам нужно реализовать функцию, которая оценивает слова в какой-нибудь словесной игре-головоломке. Требования к оценке слов 1. Оценка слова вычисляется как сумма баллов, по одному за каждую букву, отличную от буквы 'a'.
Внимание Первоначальные требования могут показаться тривиальными, но имейте в виду, что мы будем их менять и добавлять новые. Это позволит нам проверить, насколько хорошо наш код подготовлен к таким изменениям
2. Для заданного списка слов нужно вернуть отсортированный список, начинающийся со слова с наибольшей оценкой.
Как и было обещано, мы постараемся реализовать оба требования в виде функций. Первое довольно простое, но второе подразумевает дополнительный анализ, который позволит выполнить его правильно. Для score начала напишем некий псевдокод. Наша задача — реализовать функцию rankedWords, удовлетворяющую перечисленным требованиям. Так мы будем подходить ко всем требованиям в книге.
static int score(String word) { return word.replaceAll("a", "").length(); }
rankedWords Оценить каждое слово в списке, используя внешнюю функцию score.
words
"ada" "haskell" "scala" "java" "rust"
Создать новый список с теми же элементами, но отсортированными в порядке убывания оценок. Вернуть только что созданный список.
"haskell" "rust" "scala" "java" "ada"
Глава 4
I
Функции как значения
97
Нечистые функции и изменяемые значения наносят ответный удар Наша задача — написать функцию, получающую заданный список слов и возвращающую список тех же слов, но отсортированных в порядке убывания их оценок (от больших к меньшим). Мы попробуем применить несколько подходов для решения этой задачи, а затем выясним, какой из них лучше и почему. Начнем с самой первой реализации, которая приходит на ум. У нас уже есть функция, возвращающая оценку для данного слова:
rankedWords Оценить каждое слово в списке, используя внешнюю функцию score. Создать новый список с теми же элементами, но отсортированными в порядке убывания оценок. Вернуть только что созданный список.
static int score(String word) { return word.replaceAll("a", "").length(); }
Версия 1: с использованием функций Comparator и sort В Java, услышав о сортировке (sort), мы обычно начинаем думать о компараторе (comparator). Последуем за нашей интуицией и реализуем возможное решение задачи: static Comparator scoreComparator = new Comparator() { public int compare(String w1, String w2) { return Integer.compare(score(w2), score(w1)); } };
Это поле означает, что значение, определенное в тексте, будет использоваться и упоминаться далее в этой главе
scoreComparator сравнивает две строки и возвращает ту, для которой score вычисляет большую оценку.
Теперь, имея список слов, мы можем получить искомый результат, используя метод сортировки sort, который принимает созданный нами компаратор Comparator: Обратите внимание, что static List rankedWords(List words) { words.sort(scoreComparator); return words; }
функция score подсчитывает буквы, отличные от ‘а’. Вот почему слово haskell получило 6 баллов
Теперь проверим только что созданную функцию rankedWords: List words = Arrays.asList("ada", "haskell", "scala", "java", "rust"); List ranking = rankedWords(words); System.out.println(ranking); вывод в консоли: [haskell, rust, scala, java, ada]
У нас получилось! Слово haskell находится на вершине рейтинга с оценкой 6. Однако есть одна проблема. Наша функция изменяет существующий список слов! Обратите внимание, что метод sort возвращает значение void, из чего следует, что он изменяет переданный ему аргумент. Это решение нарушает правила чистых функций и имеет те же проблемы, которые мы обсуждали в предыдущих главах. Мы можем улучшить решение и сделать это на Java!
Простите меня, если на вас нахлынули неприятные воспоминания о нечистых функциях, когда вы читали код, изменяющий существующее значение
98 Часть I
I
Функциональный инструментарий
Использование Java Streams для сортировки списка К счастью, Java позволяет использовать созданный нами Comparator так, чтобы не нарушать правила чистых функций, предлагая Java Streams!
Версия 2: ранжирование слов с использованием Streams
Чистая функция • Возвращает единственное значение. • Вычисляет результат, опираясь только на свои аргументы. • Не изменяет существующие значения.
У нас есть функция, возвращающая оценку заданного слова, и Com parator, использующий эту функцию внутри. В них ничего менять не надо. Мы можем смело использовать Stream API и передавать scoreComparator. Возвращает новый поток данных Stream, создающий элементы из списка words.
1
static List rankedWords( Возвращает новый поток данных Stream, List words 2 создающий элементы из предыдущего ) { потока Stream, но выполняет сортировку, return words.stream() 1 используя указанный компаратор Comparator. .sorted(scoreComparator) 2 .collect(Collectors.toList()); 3 } Возвращает новый список List, копируя 3 все элементы из предыдущего потока Stream.
Вот как работает Stream API. Мы объединяем в цепочку вызовы методов. Вызов метода stream() коллекции возвращает объект Stream. Большинство методов класса Stream возвращает новый объект Stream! И самое главное: в этом фрагменте ничего не изменяется. Каждый вызов метода возвращает новый неизменяемый объект, и collect возвращает совершенно новый список List! Теперь, когда у нас есть намного лучшее решение, проверим, работает ли оно так, как задумано. List words = Arrays.asList("ada", "haskell", "scala", "java", "rust"); List ranking = rankedWords(words); System.out.println(ranking); вывод в консоли: [haskell, rust, scala, java, ada] System.out.println(words); вывод в консоли: [ada, haskell, scala, java, rust]
ЭТО ВА Ж Н О! Некоторые основные языки предлагают API, поддерживающие неизменяемость (например, Java Streams).
ranking — это совершенно новый список, содержащий ранжированный список слов Cписок words не изменился. Большая победа!
Похоже, что все правильно. Мы получили функцию, которая возвращает единственное значение и не изменяет существующие значения. Однако она все еще использует внешнее значение для вычисления результата — объект scoreComparator, который определен вне области видимости функции. Это означает, что сигнатура rankedWords рассказывает не всю правду о происходящем внутри. Stream API помог улучшить решение, но нам нужно большее.
Помните обсу ждение функций, которые лгут? Они лгут, когда их сигнатуры рассказывают не всю правду об их телах.
Глава 4
I
Функции как значения
99
Сигнатуры функций должны рассказывать всю правду При взгляде на сигнатуру функции rankedWords возникает невольный вопрос: что подсказывает этой функции о том, в каком порядке сор тировать слова? Чтобы найти ответ на этот вопрос, нужно заглянуть в реализацию. Однако в идеале хотелось бы иметь подсказку в списке параметров, и это желание отражает второе правило чистых функций. Вычисляет результат, опираясь только на свои аргументы.
Версия 3: передача алгоритмов в аргументах В следующей итерации мы должны найти такое решение, которое позволит рассуждать о происходящем внутри функции после простого взгляда на ее сигнатуру. Для этого следует раскрыть изначально скрытую зависимость от scoreComparator и потребовать передачу объекта Comparator во втором параметре. До
? static List rankedWords(List words) { return words.stream() Функция вычисляет .sorted(scoreComparator) результат, опираясь только .collect(Collectors.toList()); на свои аргументы? }
После static List rankedWords(Comparator comparator, List words) { return words.stream() Функция вычисляет .sorted(comparator) результат, опираясь только .collect(Collectors.toList()); на свои аргументы. }
Теперь функция абсолютно чистая, и о ее поведении можно рассуждать, просто взглянув на ее сигнатуру. Обратите внимание, что появление дополнительного параметра требует изменить способ вызова функции. List words = Arrays.asList("ada", "haskell", "scala", "java", "rust"); List ranking = rankedWords(scoreComparator, words); System.out.println(ranking); вывод в консоли: [haskell, rust, scala, java, ada]
scoreComparator сравнивает две строки и возвращает ту, для которой score вычисляет большую оценку.
Поначалу правила чистых функций могут казаться строгими или ненужными. Однако применение этих правил делает код более читабельным, тестируемым и удобным для сопровождения. И чем больше мы думаем об этих правилах, когда программируем, тем более естественным становится весь процесс.
100 Часть I
I
Функциональный инструментарий
Изменение требований До сих пор мы пытались удовлетворять начальные требования и придерживаться правил чистых функций, оптимизировать функции так, чтобы сделать их более читабельными и удобными в сопровождении. Это важно, поскольку в реальном мире код чаще читают, чем пишут. Это также важно для изменения какого-то кода по мере изменений бизнес-требований. Чем проще понять текущий код, тем проще его изменить.
Версия 4: изменение алгоритма ранжирования Посмотрим, как поведет себя функция rankedWords в новых обстоя тельствах. Вот первоначальные и дополнительные требования. Первоначальные требования 1. Оценка слова вычисляется как сумма баллов, по одному за каждую букву, отличную от буквы 'a'. 2. Для заданного списка слов нужно вернуть отсортированный список, начинающийся со слова с наибольшей оценкой.
Дополнительные требования 1. Если в слове есть буква 'c', то к его окончательной оценке следует прибавить пять баллов. 2. Код должен поддерживать старый способ вычисления оценки (без начисления дополнительных баллов за букву 'c').
Глава 4
I
Функции как значения
101
Алгоритм вычисления оценки нуждается в некоторых дополнениях. К счастью, следуя функциональному подходу, мы получили множество мелких функций, часть из которых вообще не придется менять! score static int score(String word) { return word.replaceAll("a", "").length(); }
scoreWithBonus static int scoreWithBonus(String word) { int base = score(word); if (word.contains("c")) return base + 5; else return base; }
static Comparator scoreComparator = new Comparator() { public int compare(String w1, String w2) { return Integer.compare(score(w2), score(w1)); } };
static Comparator scoreWithBonusComparator = new Comparator() { public int compare(String w1, String w2) { return Integer.compare(scoreWithBonus(w2), scoreWithBonus(w1)); } };
rankedWords(scoreComparator, words); [haskell, rust, scala, java, ada]
rankedWords(scoreWithBonusComparator, words); [scala, haskell, rust, java, ada]
Мы смогли реализовать новое требование, добавив новую версию Comparator и повторно использовав точно такую же функцию RankedWords. Это хорошее решение. Мы смогли получить его благодаря новому параметру функции, добавленному ранее. Это решение прекрасно работает, но кажется, что оно дублирует слишком много кода. В частности, код, использующий scoreComparator и scoreWithBonusComparator, в этой и в предыдущей версии ничем не отличается. Попробуем улучшить его!
Мы постепенно входим в функциональный мир, так что с этого момента перестанем применять System.out.println для вывода результатов в консоль и используем только выражения и их значения
102 Часть I
I
Функциональный инструментарий
Мы можем передавать код в аргументах! Streams помогают сделать наш код более функциональным, предоставляя возможность выполнения операций с неизменяемыми коллекциями. Но это не единственная функциональная особенность, имеющая здесь место! В первом параметре функция rankedWords принимает Comparator, но если приглядеться, то можно заметить, что его единственная обязанность — передавать поведение, похожее на поведение функции! Присмотритесь повнимательнее к происходящему в нашей функции rankedWords. Передавая экземпляр Comparator, мы передаем конкретное поведение, а не экземпляр каких-то данных. Мы передаем код, отвечающий за упорядочение двух слов! Функции тоже могут это делать.
Java Streams действительно великолепны. Мы будем использовать идеи, лежащие в основе Java Streams, чтобы рассмотреть некоторые фундаментальные концепции ФП
List rankedWords(Comparator comparator, List words) { return words .stream() .sorted(comparator) .collect(Collectors.toList()); }
Как видите, функция rankedWords принимает Comparator, который на самом деле является лишь фрагментом кода, отвечающего за сортировку. Самое замечательное, что эту идею можно выразить на языке Java и передать функцию вместо объекта Comparator. Comparator scoreComparator = (w1, w2) -> Integer.compare(score(w2), score(w1));
Мы можем использовать этот прием, чтобы сократить код. Взгляните: Все эти три фрагмента кода эквивалентны. Они делают одно и то же. Comparator scoreComparator = (w1, w2) -> Integer.compare(score(w2), score(w1)); Comparator scoreComparator = new Comparator() { public int compare(String w1, String w2) { return Integer.compare(score(w2), score(w1)); } }; rankedWords(scoreComparator, words); [haskell, rust, scala, java, ada]
rankedWords(scoreComparator, words); [haskell, rust, scala, java, ada]
rankedWords( (w1, w2) -> Integer.compare(score(w2), score(w1)); words ); [haskell, rust, scala, java, ada]
Благодаря синтаксису функций Java можно передать (и поменять, если требования изменятся!) алгоритм сортировки всего в одной строке кода.
Глава 4
I
Функции как значения
Использование значений Function в Java
103
String word
score
int
Познакомимся поближе с типом Function в Java и попробуем использовать новые знания для улучшения нашего решения. Тип Function в Java представляет функцию, которая принимает один параметр и возвращает один результат. Например, нашу функцию scoreFunction, которая принимает строку и возвращает целое число, можно реализовать как экземпляр Function.
scoreFunction — это ссылка на объект в памяти, содержащий функцию, принимающую String и возвращающую Integer
Function scoreFunction = w -> w.replaceAll("a", "").length();
Теперь эту функцию scoreFunction можно передавать как самое обычное значение. Можно сказать, что наша функция хранится как неизменяемое значение! Ее можно интерпретировать как любую другую ссылку (такую как words). Для вызова такой функции нужно использовать метод apply. scoreFunction
String w
words
Integer
"ada" "haskell" "scala" "java" "rust"
scoreFunction.apply("java"); → 2
Можно создать и другую ссылку на то же значение и использовать ее. Function f = scoreFunction; f.apply("java"); → 2
Использование функций (статических методов) static int score(String word) { return word.replaceAll("a", "").length(); }
score("java"); 2
Функции вызываются непосредственно, путем передачи им аргументов. Здесь мы вызываем score("java")
и получаем 2.
ЭТО ВА Ж Н О! Функции, хранящиеся как значения, — это основа ФП.
Обратите внимание на синтаксис ->, поддерживаемый в Java. Он определяет функцию без имени. Слева от стрелки находятся аргументы, а справа — тело функции
Использование значения Function Function scoreFunction = w -> w.replaceAll("a", "").length(); Чтобы создать значение Function, нужно использовать синтаксис стрелки. Это определение эквивалентно определению слева. scoreFunction.apply("java"); 2 Чтобы вызвать функцию, хранящуюся как значение Function, нужно вызвать метод apply.
static boolean isHighScoringWord(String word) { return score(word) > 5; }
Function isHighScoringWordFunction = w -> scoreFunction.apply(w) > 5;
isHighScoringWord("java"); false
isHighScoringWordFunction.apply("java"); false
Значения Function можно использовать повторно, аналогично тому, как повторно используются функции.
104 Часть I
I
Функциональный инструментарий
Использование синтаксиса Function для устранения повторяющегося кода Попробуем использовать все, что мы только что узнали, чтобы избавиться от повторяющегося кода в нашем решении. scoreWithBonus
score static int score(String word) { return word.replaceAll("a", "").length(); }
static int scoreWithBonus(String word) { int base = score(word); if (word.contains("c")) return base + 5; else return base; }
static Comparator scoreComparator = new Comparator() { public int compare(String w1, String w2) { return Integer.compare(score(w2), score(w1)); } };
static Comparator scoreWithBonusComparator = new Comparator() { public int compare(String w1, String w2) { return Integer.compare(scoreWithBonus(w2), scoreWithBonus(w1)); } };
rankedWords(scoreComparator, words); [haskell, rust, scala, java, ada]
rankedWords(scoreWithBonusComparator, words); [scala, haskell, rust, java, ada]
scoreComparator и scoreWithBonusComparator очень похожи и различа-
ются только вызовом разных функций оценки. Посмотрим, как будут выглядеть функции, показанные выше, если определить их с использованием синтаксиса стрелки в Java (->). Эти функции эквивалентны, но сохраняются как значения: Comparator scoreComparator = (w1, w2) -> Integer.compare( score(w2), score(w1) ); rankedWords(scoreComparator, words); [haskell, rust, scala, java, ada]
Comparator scoreWithBonusComparator = (w1, w2) -> Integer.compare( scoreWithBonus(w2), scoreWithBonus(w1) ); rankedWords(scoreWithBonusComparator, words); [scala, haskell, rust, java, ada]
Передача функций-значений в функцию (то есть в статический метод в Java), ожидающую получить Comparator, выглядит красиво и позволяет повторно использовать большие фрагменты кода даже при изменении бизнес-требований. Благодаря этой возможности мы смогли повторно использовать функцию RankedWords, которая принимает как параметр words, так и параметр Comparator. И все же остаются некоторые проблемы, которые нужно решить, чтобы сделать код еще лучше и удобнее. • Функция, которую мы передаем в rankedWords в качестве Comparator, выглядит слишком сложной для той простой задачи, которую она решает. Желательно иметь возможность передать требуемый алгоритм сортировки более кратким и чистым способом. • В этом решении все еще остается некий повторяющийся код, который используется в scoreComparator и scoreWithBonusComparator. Они различаются только используемой функцией оценки. Желательно заставить этот факт работать в нашу пользу.
Если быть совсем точными, то Comparator эквивалентен BiFunction, а не Function. BiFunction — это тип, определяющий функцию с двумя параметрами, а Function — тип, определяющий функцию с одним параметром. Подробнее об этом мы поговорим ниже в данной главе
Глава 4
I
Функции как значения
105
Передача пользовательских функций в виде аргументов Было бы желательно, чтобы наша функция rankedWords была меньше и проще, чем компаратор Comparator, который выглядит слишком сложным для решаемой им задачи. Более того, теперь, когда у нас два компаратора, бóльшая часть их кода повторяется. На самом деле нам нужно просто указать, какой алгоритм подсчета баллов использовать. Может быть, есть возможность просто передать алгоритм оценки в виде аргумента? К счастью, мы уже познакомились с методом, который идеально подходит для этого: тип Function. Мы можем передать алгоритм оценки в виде параметра!
Версия 5: передача функции оценки Нам не нужно передавать Comparator в параметре. Все, что нам нужно, — это передать функцию rankedWords, реализующую способ оценки заданного слова. Это единственное, что изменилось в коде после изменения бизнес-требований, и, следовательно, это должна быть единственная настраиваемая часть кода. Настройка производится через параметры. Попробуем сделать так, чтобы функция rankedWords принимала параметр с другой функцией и использовала ее для ранжирования слов. Наша цель показана справа.
Function scoreFunction = w -> score(w); rankedWords(scoreFunction, words); → [haskell, rust, scala, java, ada] Function scoreWithBonusFunction =
w -> scoreWithBonus(w); Чтобы получить API такого типа, нужно words); принять параметр типа Function и ис- rankedWords(scoreWithBonusFunction, → [scala, haskell, rust, java, ada] пользовать его для создания экземпляра Comparator, необходимого методу sort класса Stream. Обратите внимание: мы легко можем понять, что делает эта функция, просто взглянув на ЭТО ВА Ж Н О! ее сигнатуру! Это буквально ранжирование words с использованием Функции, алгоритма оценки wordScore! принимающие другие функstatic List rankedWords(Function wordScore, ции в парамеList words) { трах, широко Comparator wordComparator = распростра(w1, w2) -> Integer.compare( Мы принимаем параметр типа Function и тем нены в ФП. wordScore.apply(w2), самым позволяем настраивать алгоритм оценки wordScore.apply(w1) слов. Внутри создается Comparator, для чего ); используется функция оценки, переданная в виде значения wordScore. return words .stream() .sorted(wordComparator) Мы возвращаем копию входного списка, используя .collect(Collectors.toList()); интерфейс неизменяемых коллекций Java Stream API. }
Теперь использование нашей функции выглядит очень лаконично и читабельно. rankedWords(w -> score(w), words); rankedWords(w -> scoreWithBonus(w), words);
Обратите внимание, что код, вызывающий функцию rankedWords, отвечает за предоставление алгоритма оценки.
106 Часть I
I
Функциональный инструментарий
Кофе-брейк: функции как параметры Теперь ваша очередь использовать возможность передачи функций в виде параметров другим функциям. Для этого добавим еще одно требование к существующей функции rankedWords. Но сначала повторим, что у нас уже есть. Требования к оценке слов 1. Оценка слова вычисляется как сумма баллов, по одному за каждую букву, отличную от буквы 'a'. 2. Для заданного списка слов нужно вернуть отсортированный список, начинающийся со слова с наибольшей оценкой.
Требования к оценке слов с начис лением дополнительных баллов 1. Если в слове есть буква 'c' , то к его окончательной оценке следует прибавить пять баллов. 2. Код должен поддерживать старый способ вычисления оценки (без начисления дополнительных баллов за букву 'c').
static List rankedWords(Function wordScore, List words) { Comparator wordComparator = (w1, w2) -> Integer.compare( wordScore.apply(w2), wordScore.apply(w1) );
}
return words .stream() .sorted(wordComparator) .collect(Collectors.toList());
Обратите внимание, что это дополнительное требование. Все предыдущие требования по-прежнему необходимо учитывать
Упражнение: реализуйте новое требование Ваша задача — реализовать новую версию функции rankedWords и привести примеры использования в трех случаях: • ранжирование слов только с простой оценкой (без дополнительных и штрафных баллов); • ранжирование слов с начислением дополнительных баллов; • ранжирование слов с начислением дополнительных баллов и штрафом.
Новые требования: возможность штрафа 1. Из оценки слова следует вычесть семь штрафных баллов, если слово содержит букву 's'. 2. Код должен поддерживать старый способ вычисления оценки (без начисления дополнительных баллов за букву 'c').
Подумайте, как лучше реализовать тело rankedWords и определить ее API (сигнатуру). Что должна сообщать сигнатура? Это самодокументирование? Насколько легко будет понять происходящее внутри вашей новой версии?
Глава 4
I
Функции как значения
107
Объяснение для кофе-брейка: функции как параметры Сначала сосредоточимся на новом требовании. Похоже, что функция rankedWords вообще не нуждается в каких-либо изменениях! Причина в том, что алгоритм оценки мы уже «передаем отдельно» в виде параметра типа Function. Итак, нам нужно написать новую функцию, учитывающую все слагаемые: базовую оценку, дополнительные баллы и штраф:
Новые требования: возможность штрафа 1. Из оценки слова следует вычесть семь штрафных баллов, если слово содержит букву 's'. 2. Код должен поддерживать старый способ вычисления оценки (без начисления дополнительных баллов за букву 'c').
Мы используем условный оператор Java, который является выражением, возвращающим результат (в нашем случае — целое число). Оператор if в Java — это инструкция, и мы не можем использовать ее здесь. Подробнее об инструкциях и выраТеперь мы можем просто передать эту функцию в rankedWords: жениях мы поговорим в главе 5 rankedWords(w -> scoreWithBonusAndPenalty(w), words); → [java, ada, scala, haskell, rust] static int scoreWithBonusAndPenalty(String word) { int base = score(word); int bonus = word.contains("c") ? 5 : 0; int penalty = word.contains("s") ? 7 : 0; return base + bonus - penalty; }
Мы можем сделать еще лучше! Эта часть главы посвящена передаче функций, но вся книга посвящена функциям и моделированию с их помощью. Вот почему я не могу не показать вам еще одно решение, использующее множество небольших независимых функций. Каждое требование в этом решении может быть реализовано в виде отдельной функции, и все эти функции можно использовать при вызове rankedWords благодаря гибкости синтаксиса стрелки. static int bonus(String word) { return word.contains("c") ? 5 : 0; } static int penalty(String word) { return word.contains("s") ? 7 : 0; }
Решение состоит в том, чтобы передать другую функцию нашей неизменившейся функции rankedWords
И снова мы просто передаем другую функцию в нашу неизменившуюся функцию rankedWords, однако на этот раз используем синтаксис стрелки, чтобы встроить алгоритм. Это очень чистое и читабельное решение, поскольку каждое требование имеет свое место в коде
rankedWords(w -> score(w) + bonus(w) - penalty(w), words); → [java, ada, scala, haskell, rust]
Это упражнение имеет множество правильных решений, и мы не можем рассмотреть их все. Если ваш код выглядит иначе, но возвращает правильные результаты и вы ничего не меняли в функции rankedWords, то это хорошо!
108 Часть I
I
Функциональный инструментарий
Проблемы с чтением функционального кода на Java Наше окончательное решение очень функционально по своей природе. Оно обеспечивает неизменяемость и поддерживает правила чистых функций. Мы реализовали это на Java, доказав, что это очень универсальный язык, способный выражать программы в парадигмах, отличных от объектно-ориентированной. Это прекрасно, поскольку мы можем изучать концепции ФП без перехода к новому языку. К сожалению, есть и некоторые практические проблемы использования приемов функционального программирования в Java: мы вынуждены писать слишком много кода и применять изменяемые списки.
Слишком много кода Противники использования полноценного функционального стиля для написания программ на Java говорят, что это решение несколько раздуто и содержит много шума. Что ж, посмотрим: static List rankedWords(Function wordScore, List words) { Comparator wordComparator = (w1, w2) -> Integer.compare( wordScore.apply(w2), wordScore.apply(w1) Важные части выделены ); жирным шрифтом. Остальное выглядит как «связующий» return words код. .stream() .sorted(wordComparator) .collect(Collectors.toList()); }
Как видите, это утверждение имеет некоторые основания. С точки зрения объема кода наибольшего внимания заслуживают те, кто будет читать код, а не его автор. Код читают гораздо чаще, чем пишут. Поэтому мы всегда должны оптимизировать код в целях удобства чтения. Это означает, что если есть возможность писать чистый, самодокументирующийся код, используя меньшее количество символов, то ею следует воспользоваться.
Мы по-прежнему используем изменяемые списки Наше решение выражено в виде функции (чистой функции), но она по-прежнему получает и возвращает изменяемые списки. Мы тщательно следим за тем, чтобы не изменить входной список внутри функции, используя Java Stream API, но, взглянув только на сигнатуру, читатели нашего кода не узнают об этом. Они увидят списки List и, не заглянув в реализацию, не смогут с уверенностью предположить, что они не изменяются.
Глава 4
I
Функции как значения
109
Передача функций в Scala Пришло время вернуться к языку Scala. Запустим оболочку REPL и приступим! На следующих нескольких страницах мы сначала напишем функцию rankedWords на Scala, а затем реализуем еще больше требований, используя специальный функциональный синтаксис. Чтобы вспомнить, что мы узнали о Scala, сначала определим и используем несколько небольших функций. Затем попытаемся передать одну из них как аргумент стандартной библиотечной функции sortBy, определенной в классе List. А пока обратите внимание, насколько выразительным получается код, и не волнуйтесь, если не знаете, как работает sortBy, — мы скоро поговорим об этом. Последнее напоминание об использовании примеров кода из этой книги До сих пор мы в основном использовали Java, и поскольку сейчас переходим к более глубокому изучению ФП на Scala, это идеальный момент для последнего напоминания о том, что в начале листинга служит признаком того, что для опробования кода нужно запустить оболочку Scala REPL в терминале (sbt console), а затем последовательно вводить инструкции из листинга. Строки, содержащие упрощенные ответы REPL, предваряются символом →. Ответы REPL содержат результаты выполнения введенных перед этим инструкций (выражений). def inc(x: Int): Int = x + 1 → inc inc(2) → 3
Введите код и нажмите клавишу Enter, чтобы выполнить его немедленно. Здесь определяется однострочная функция!
REPL подтверждает, что мы определили функцию с именем inc Вызов функции inc с передачей ей 2 в параметре x. REPL выводит результат
def score(word: String): Int = word.replaceAll("a", "").length → score
score("java") → 2
Это определение двухстрочной функции
REPL подтверждает, что мы определили функцию с именем score. Эта функция будет доступна на протяжении всего сеанса REPL Вызов функции score с передачей ей строки "java" в параметре word. REPL выводит результат: 2
val words = List("rust", "java") → words: List[String]
words.sortBy(score) → List("java", "rust")
В REPL можно определять многострочные функции. Если определение не завершено и вы нажмете Enter, то REPL будет ждать ввода дополнительного кода
Мы можем создать новое значение, содержащее неизменяемый список из двух слов. Ему присваивается имя words, что подтверждается ответом REPL
Теперь мы вызываем функцию sortBy, которая определена в классе List в Scala. Функция sortBy принимает в качестве параметра другую функцию и использует ее для сортировки содержимого списка, а в результате возвращает новый отсор тированный список, который выводит REPL. Список words при этом не изменяется
110 Часть I
I
Функциональный инструментарий
Глубокое погружение в sortBy Рассмотрим подробнее функцию sortBy и попытаемся интуитивно понять, что происходит внутри нее, взглянув на предыдущий сеанс REPL с другой точки зрения: def score(word: String): Int = word.replaceAll("a", "").length
Мы определили функцию score, которая принимает String и возвращает Int. Здесь нет ничего нового.
val words = List("rust", "java")
Мы определили список слов, хранящий несколько значений String. И снова это не первый раз, когда мы делаем что-то подобное.
Мы определили новый список, вызвав метод sortBy объекта words. Этот метод принимает функцию, которая, в свою очередь, принимает String и возвращает Int, val sortedWords = words.sortBy(score) вызывает данную функцию для каждого элемента в words, создает новый список и заполняет его элементами из words, но в порядке сортировки, используя соответствующие значения Int. List("rust", "java").sortBy(score)
Обратите внимание, что нам не нужно определять words для выполнения sortBy. Метод можно применить непосредственно к List("rust", "java"), который является тем же неизменяемым списком, но используется только здесь. Выражение слева возвращает точно такой же результат.
Как видите, мы можем отсортировать неизменяемый список List("rust", "java"), вызвав метод sortBy, который принимает один параметр — функцию. Функция в параметре, в свою очередь, принимает String и возвращает Int. Метод sortBy использует эту функцию для сортировки элементов в списке: List(
1
2
List(
rust
score(
rust
) = 4
java
java
score(
java
) = 2
rust
).sortBy(score)
В О
sortBy вызывает score для каждого элемента
3
)
sortBy возвращает новый список
В Scala порядок целых чисел по умолчанию уже определен. В данном случае sortBy неявно знает, что целые числа нужно сортировать в порядке возрастания. (Это достигается с помощью особенности языка, называемой «неявно определенные умолчания» (implicits).)
Подождите, функция, которая принимает функцию, которая принимает String?
Да, я понимаю, что звучит неудобоваримо. И это одна из самых больших сложностей в функциональном программировании. И одно из самых больших удобств! Мы будем много практиковаться, пока это не станет для вас привычным.
Глава 4
I
Функции как значения
111
Сигнатуры с параметрами-функциями в Scala Воспользуемся нашими новыми знаниями о функции sortBy, определенной в классе List, который представляет неизменяемые списки в Scala, и попробуем переписать Java-решение на Scala. Вот код на Java, который у нас получился: static List rankedWords(Function wordScore, List words) { Comparator wordComparator = Мы пришли к выводу, что это хорошее (w1, w2) -> Integer.compare( решение, поскольку оно не изменяет входwordScore.apply(w2), ной список и использует объект Function wordScore.apply(w1) для параметризации алгоритма ранжи); рования. Однако решение получилось слишком объемным для такой маленькой return words задачи, а кроме того, в нем используются .stream() изменяемые списки Java. Поэтому наше .sorted(wordComparator) решение может вызвать некоторые пробле.collect(Collectors.toList()); мы у коллег и товарищей по команде }
Теперь напишем функцию rankedWords на Scala. Сначала определим ее сигнатуру, а затем перейдем к реализации.
Сигнатура Как вы, наверное, помните, при программировании с использованием чистых функций важно убедиться, что их сигнатуры говорят всю правду о происходящем внутри функций. Сигнатура чистой функции не лжет, и поэтому читатель нашего кода сможет понять суть происходящего внутри, просто взглянув на сигнатуру и не заглядывая в реализацию. Как же выглядит сигнатура функции rankedWords в Scala и чем она отличается от сигнатуры в Java? wordScore — это функция, принимающая String и возвращающая Int def rankedWords(wordScore: String => Int, words: List[String]): List[String] words — это неизменяемый список строк
Функция возвращает новый список строк
Для обозначения параметров-функций в Scala используется символ двойной стрелки (=>), например String => Int, как показано в примере выше. Чтобы добиться аналогичного результата в Java, мы должны написать Function.
ЭТО ВА Ж Н О! Функции, которые не лгут, имеют решаю щее значение для поддержки кода.
112 Часть I
I
Функциональный инструментарий
Передача функций в виде аргументов в Scala Итак, у нас есть сигнатура функции rankedWords. Теперь перейдем к ее реализации. К счастью, мы уже знаем, как сортировать списки в Scala, — с помощью функции sortBy!
Требования к оценке слов 1. Оценка слова вычисляется как сумма баллов, по одному за каждую букву, отличную от буквы 'a'.
Первая попытка: использование sortBy Вспомним, как выглядит наша функция score:
2. Для заданного списка слов нужно вернуть отсортированный список, начинающийся со слова с наибольшей оценкой.
def score(word: String): Int = word.replaceAll("a", "").length
Однако если использовать ее в sortBy в текущем виде, то мы получим неверный результат. List(
1
2
3
List(
rust
score(
rust
) = 4
java
java
score(
java
) = 2
rust
).sortBy(score)
sortBy вызывает score для каждого элемента
)
sortBy возвращает новый список
В Scala порядок сортировки целых чисел по умолчанию уже определен. Они сортируются в порядке возрастания. То есть sortBy знает, что элемент с наименьшей оценкой 2 должен следовать первым.
Оценка слова rust выше оценки слова java (4 и 2 соответственно), но rust вставляется в список с результатом после java. Это объясняется тем, что sortBy по умолчанию выполняет сортировку в порядке возра стания. Чтобы изменить порядок на обратный, можно воспользоваться старым трюком — сделать оценки отрицательными. def negativeScore(word: String): Int = -score(word) List(
2
1
rust
negativeScore(
rust )
= 4
java
negativeScore(
java )
= 2
).sortBy(negativeScore)
sortBy вызывает negativeScore для каждого элемента
Можем использовать этот прием в нашей реализации: def rankedWords(wordScore: String => Int, words: List[String]): List[String] = { def negativeScore(word: String): Int = -wordScore(word) words.sortBy(negativeScore) }
List(
3
rust java
)
sortBy возвращает новый список В Scala функции можно определять в любом месте в коде. Здесь функция negativeScore определяется внутри функции rankedWords. Эта внутренняя функция будет доступна только внутри rankedWords
Код решает нашу задачу, но выглядит хакерским. Это не тот чистый код, который хотелось бы иметь в проектах. Мы улучшим решение, но, прежде чем попробовать новый подход, подкрепим новые знания практикой.
Глава 4
I
Функции как значения
113
Практика передачи функций Пришло время попробовать передавать некоторые функции в Scala. Не забудьте запустить Scala REPL. Отсортируйте список строк по их длине в порядке возрастания. входные данные: List("scala", "rust", "ada") ожидаемый результат: List("ada", "rust", "scala")
Отсортируйте приведенный ниже список строк по количеству букв 's' внутри этих строк в порядке возрастания. входные данные: List("rust", "ada") ожидаемый результат: List("ada", "rust")
Отсортируйте приведенный ниже список целых чисел в порядке убывания. входные данные: List(5, 1, 2, 4, 3) ожидаемый результат: List(5, 4, 3, 2, 1)
По аналогии со вторым упражнением отсортируйте приведенный ниже список строк по количеству букв 's' внутри этих строк, но в порядке убывания. входные данные: List("ada", "rust")
1 2 3 4
результат: List("rust", "ada")
Ответы def len(s: String): Int = s.length → len List("scala", "rust", "ada").sortBy(len) → List("ada", "rust", "scala") def numberOfS(s: String): Int = s.length - s.replaceAll("s", "").length → numberOfS List("rust", "ada").sortBy(numberOfS) → List("ada", "rust")
Обратите внимание, что в ФП все является выражением, и REPL помогает нам, постоянно напоминая об этом. Каждая введенная строка кода выполняется, и мы получаем результат. Здесь результат подтверждает, что функция len была определена. Эти «определяющие» ответы будут показываться только в ключевых листингах и опускаться в остальных
def negative(i: Int): Int = -i → negative List(5, 1, 2, 4, 3).sortBy(negative) → List(5, 4, 3, 2, 1) def negativeNumberOfS(s: String): Int = -numberOfS(s) → negativeNumberOfS List("ada", "rust").sortBy(negativeNumberOfS) → List("rust", "ada")
1 2 3 4
114 Часть I
I
Функциональный инструментарий
Использование декларативного программирования Нам удалось реализовать rankedWords в нескольких строках кода на Scala, но мы можем сделать лучше. Рассмотрим код, который мы уже написали, и убедимся, что понимаем, почему он все еще недостаточно хорош.
Ранжирование слов хакерским способом def rankedWords(wordScore: String => Int, words: List[String]): List[String] = { def negativeScore(word: String): Int = -wordScore(word) words.sortBy(negativeScore) }
Здесь всего две строчки кода, но начинающему разработчику, который впервые видит такой код, придется очень хорошо подумать. Ему нужно предусмотреть изменение знака оценки, что является просто деталью реализации сортировки в обратном порядке. Это рецепт изменения порядка сортировки. И всякий раз, когда мы размышляем о том, как достигнуть желаемого результата, а не о том, что должно быть достигнуто, мы делаем код менее читабельным.
Декларативное ранжирование слов
Под «хакерским» мы подразумеваем «рабочее решение, разработанное с использованием какого-то трюка». Трюк в данном случае заключается в изменении знака оценок. Разработчик читает: «Чтобы вычислить оценку для слова, сначала нужно гарантировать получение отрицательной оценки, а затем использовать ее для сортировки слов»
Декларативный подход фокусируется на том, что нужно сделать, а не на том, как это сделать. Мы можем реализовать rankedWords, просто указав, что нужно сделать. В этом случае мы говорим, что нам нужны строки, отсортированные по wordScore в естественном порядке (по возрастанию) и в обратном. def rankedWords(wordScore: String => Int, words: List[String]): List[String] = { words.sortBy(wordScore).reverse } List(
1
2
List(
Разработчик читает: «Сначала список слов сор тируется по возрастанию оценок, а затем результат переворачивается»
4
3
List(
rust
score( rust ) = 4
java
rust
rust
java
score( java ) = 2
rust
java
java
).sortBy(score)
sortBy вызывает score для каждого элемента
).reverse sortBy возвращает новый список, имы применяем кнему метод reverse
Декларативный код обычно более лаконичен и понятен, чем императивный. Такой подход делает функцию rankedWords намного более краткой и читабельной.
5
)
reverse возвращает новый список
Глава 4
I
Функции как значения
115
Передача функций пользовательским функциям Наша окончательная версия rankedWords, написанная на Scala, — это чистая функция, использующая неизменяемые списки и имеющая сигнатуру, которая говорит всю правду о ее теле. Все идет нормально! Итак, у нас есть такой код: def rankedWords(wordScore: String => Int, words: List[String]): List[String] = { words.sortBy(wordScore).reverse }
words
Но как мы будем использовать эту функцию? Точно так же, как в примерах, в которых мы передавали свои функции в вызов функции sortBy из стандартной библиотеки (то есть указав имя функции в качестве параметра).
"ada" "haskell" "scala" "java" "rust"
def score(word: String): Int = word.replaceAll("a", "").length rankedWords(score, words) → List("haskell", "rust", "scala", "java", "ada")
Сравним это решение с решением на Java, которое мы придумали ранее, и попробуем подвести итоги и сравнить, как выполняются требования в обеих версиях. Обратите внимание, что вскоре мы добавим другие возможности. Java
Scala
List rankedWords( Function wordScore, List words ) { Comparator wordComparator = (w1, w2) -> Integer.compare( wordScore.apply(w2), wordScore.apply(w1) );
def rankedWords( wordScore: String => Int, words: List[String] ): List[String] = { words.sortBy(wordScore).reverse }
}
return words.stream() .sorted(wordComparator) .collect(Collectors.toList());
Требование 1
Оценка слова вычисляется как сумма баллов, по одному за каждую букву, отличную от буквы 'a'.
static int score(String word) { return word.replaceAll("a", "").length(); }
def score(word: String): Int = word.replaceAll("a", "").length
rankedWords(w -> score(w), words); → [haskell, rust, scala, java, ada]
rankedWords(score, words) → List("haskell", "rust", "scala", "java", "ada")
Требование 2
Дополнительно, если в слове есть буква 'c', к окончательной оценке следует прибавить пять баллов.
static int scoreWithBonus(String word) { int base = score(word); if (word.contains("c")) return base + 5; else return base; }
def scoreWithBonus(word: String): Int = { val base = score(word) if (word.contains("c")) base + 5 else base }
rankedWords(w -> scoreWithBonus(w), words); → [scala, haskell, rust, java, ada]
rankedWords(scoreWithBonus, words) → List("scala", "haskell", "rust", "java", "ada")
116 Часть I
I
Функциональный инструментарий
Маленькие функции и их обязанности Прежде чем перейти к реализации дополнительных возможностей в нашей задаче ранжирования слов, остановимся ненадолго и поговорим о функциональном дизайне программного обеспечения. В этой книге мы учимся использовать чистые функции в качестве основных инструментов реализации новых требований. Каждое требование в этой книге реализовано как функция — и эта традиция продолжится, даже когда позже мы перейдем к более сложным требованиям!
Оптимизация для удобочитаемости Основная причина того, почему программисты сосредотачиваются на дизайне программного обеспечения, заключается в их стремлении упростить сопровождение этого ПО. Чтобы фрагмент кода было легко поддерживать, он должен быть простым и понятным другим программистам из нашей команды, чтобы они могли быстро и уверенно вносить в него изменения. Вот почему мы фокусируемся на читабельности кода. В функциональном программировании применяются те же общие правила хорошего дизайна, что и в других парадигмах. Разница лишь в том, что они применяются на уровне функций. Поэтому мы фокусируемся на том, чтобы каждая функция реализовала одно небольшое требование бизнеса.
Что не так с функцией scoreWithBonus Вот наша функция scoreWithBonus. Что за проблема в ней скрывается? Внутри она использует score и реализует некоторую дополнительную логику (прибавляет дополнительные баллы). Это означает, что она решает не одну простую задачу. score def score(word: String): Int = word.replaceAll("a", "").length
scoreWithBonus
Вычисляет оценку.
def scoreWithBonus(word: String): Int = { val base = score(word) if (word.contains("c")) base + 5 else base }
Вычисляет дополнительные баллы. Вычисляет окончательную оценку.
Мы можем сделать этот код еще более читабельным и удобным для сопровождения, убрав вычисление дополнительных баллов в (как вы уже наверняка догадались!) отдельную функцию с именем bonus.
score def score(word: String): Int = word.replaceAll("a", "").length
bonus def bonus(word: String): Int = if (word.contains("c")) 5 else 0
Мы бы предпочли использовать только эти две функции, поскольку каждая из них описывает одно бизнес-требование.
? Но тогда нам нужно что-то еще, возможно функция, отвечающая за вычисление итоговой оценки (сложение основных и дополнительных баллов).
Глава 4
I
Функции как значения
117
Передача встроенных функций score def score(word: String): Int = word.replaceAll("a", "").length
def bonus(word: String): Int = if (word.contains("c")) 5 else 0
Как же нам объединить результаты функций score и bonus? Нам нужен прием, одновременно эффективный, удобочитаемый и не требующий особых церемоний. Мы уже использовали его в Java: анонимные функции. Обычно это довольно короткие, часто однострочные, функции, которые не нуждаются в именах, поскольку выполняемые ими действия совершенно очевидны. Мы можем определить и передать функцию другой функции в одной строке кода внутри списка параметров. Посмотрите варианты кода ниже, чтобы понять, как передавать анонимные функции в обоих языках. Требование 1
Java
Как противоположный пример: создание новой именованной функции в нескольких строках кода, например scoreWithBonus, требует много церемоний
Оценка слова вычисляется как сумма баллов, по одному за каждую букву, отличную от буквы 'a'.
rankedWords(w -> score(w), words); → [haskell, rust, scala, java, ada]
Scala
def score(word: String): Int = word.replaceAll("a", "").length
rankedWords(score, words) → List("haskell", "rust", "scala", "java", "ada")
Дополнительно, если в слове есть буква 'c', к окончательной оценке следует прибавить пять баллов. Использовать отдельную функцию.
static int scoreWithBonus(String word) { int base = score(word); if (word.contains("c")) return base + 5; else return base; }
def scoreWithBonus(word: String): Int = { val base = score(word) if (word.contains("c")) base + 5 else base }
rankedWords(w -> scoreWithBonus(w), words); → [scala, haskell, rust, java, ada]
rankedWords(scoreWithBonus, words) → List("scala", "haskell", "rust", "java", "ada")
Напоминание о том, что мы делали прежде
static int score(String word) { return word.replaceAll("a", "").length(); }
Требование 2
?
bonus
Мы определяем новую функцию bonus, вычисляющую Дополнительно, если в слове есть буква 'c', дополнительные баллы, к окончательной оценке следует прибавить пять баллов. Требование 2 согласно бизнес-требованиям. Затем используем ее Использовать встроенную анонимную функцию. вместе с функцией score static int bonus(String word) { def bonus(word: String): Int = { внутри встроенной return word.contains("c") ? 5 : 0; if (word.contains("c")) 5 else 0 анонимной функции. }
}
rankedWords(w -> score(w) + bonus(w), words); rankedWords(w => score(w) + bonus(w), words) → List("scala", "haskell", "rust", "java", "ada") → [scala, haskell, rust, java, ada]
Обратите внимание, что в качестве анонимных функций должны передаваться только очень маленькие, простые и однострочные функции. Прекрасным примером может служить сложение score и bonus. Его нельзя прочитать неправильно. А при передаче такой функции в виде аргумента код становится более лаконичным и читабельным.
118 Часть I
I
Функциональный инструментарий
Кофе-брейк: передача функций в Scala Теперь ваша очередь использовать возможность передачи функций в виде аргументов другим функциям. Выше мы выполнили это упражнение в Java. Теперь пришло время реализовать новое требование на Scala с помощью встроенной в язык поддержки неизменяемых значений. Вам нужно будет реализовать еще одно требование в существующей функции rankedWords. Но сначала повторим то, что уже сделали до сих пор. def rankedWords(wordScore: String => Int, words: List[String]): List[String] = { words.sortBy(wordScore).reverse } def score(word: String): Int = word.replaceAll("a", "").length def bonus(word: String): Int = if (word.contains("c")) 5 else 0 Для реализации этого требования понадобилась всего одна дополнительная функция
Упражнение: реализуйте новое требование По аналогии с решением, которое вы написали на Java, реализуйте функцию вычисления штрафа. Прежде чем начать, попробуйте сначала показать, как можно было бы использовать rankedWords в сценарии с начислением дополнительных и штрафных баллов.
Ваши задачи
Требования к оценке слов 1. Оценка слова вычисляется как сумма баллов, по одному за каждую букву, отличную от буквы 'a'. 2. Для заданного списка слов нужно вернуть отсортированный список, начинающийся со слова с наибольшей оценкой.
Это требование было реализовано с помощью двух чистых функций
Требования к оценке слов с начис лением дополнительных баллов 1. Если в слове есть буква 'c', то к его окончательной оценке следует прибавить пять баллов. 2. Код должен поддерживать старый способ вычисления оценки (без начисления дополнительных баллов за букву 'c').
Новые требования: возможность штрафа 1. Из оценки слова следует вычесть семь штрафных баллов, если слово содержит букву 's'. 2. Код должен поддерживать старый способ вычисления оценки (без начисления дополнительных баллов за букву 'c').
• Покажите, как ранжировать слова, используя функцию score (без начисления дополнительных и штрафных баллов). • Покажите, как ранжировать слова, используя функции score и bonus (без штрафа). • Реализуйте новую функцию, соответствующую новому требованию. • Покажите, как ранжировать слова с дополнительными и штрафными баллами.
Глава 4
I
Функции как значения
119
Объяснение для кофе-брейка: передача функций в Scala Рассмотрим все четыре задания одно за другим.
Требования к оценке слов
Использование RankedWords с функцией score Это разминка, чтобы вы могли вспомнить, как создавать и передавать именованные функции в Scala. def score(word: String): Int = word.replaceAll("a", "").length rankedWords(score, words) → List("haskell", "rust", "scala", "java", "ada")
Использование rankedWords c функциями score и bonus
rankedWords(w => score(w) + bonus(w), words) → List("scala", "haskell", "rust", "java", "ada")
Реализация нового требования Теперь пришло время заняться новой реализацией. В этой книге вы изучаете функциональное программирование и дизайн функционального ПО. Теперь должно быть совершенно очевидно, что в ответ на вопрос: «Что нужно сделать, чтобы реа лизовать новое требование?» — вы должны ответить: «Создать новую функцию!» Следовательно: def penalty(word: String): Int = if (word.contains("s")) 7 else 0
Использование rankedWords с функциями score, bonus и penalty Благодаря всем этим разминкам мы теперь знаем, как использовать новую функцию для реализации нового требования. Вот легко читаемая анонимная функция:
2. Для заданного списка слов нужно вернуть отсортированный список, начинающийся со слова с наибольшей оценкой.
Если есть именованная функция, которая принимает String и возвращает Int, то мы можем передать ее, просто указав ее имя
Во второй задаче нам нужно сложить результаты двух функций; так что мы не можем передать их по имени. Поэтому нам нужно определить новую анонимную функцию, которая выполняет сложение, и передать ее, используя синтаксис функции (=>). def bonus(word: String): Int = if (word.contains("c")) 5 else 0
1. Оценка слова вычисляется как сумма баллов, по одному за каждую букву, отличную от буквы 'a'.
Требования к оценке слов с начис лением дополнительных баллов 1. Если в слове есть буква 'c', то к его окончательной оценке следует прибавить пять баллов. 2. Код должен поддерживать старый способ вычисления оценки (без начисления дополнительных баллов за букву 'c'). Здесь нужно определить анонимную функцию, которая принимает String (с именем w) и возвращает Int. Ее тело встраивается в список параметров
Новые требования: возможность штрафа 1. Из оценки слова следует вычесть семь штрафных баллов, если слово содержит букву 's'. 2. Код должен поддерживать старый способ вычисления оценки (без начисления дополнительных баллов за букву 'c'). Это еще одна очень простая функция, которая напрямую реализует требование. Вам скучно? Что ж, поддерживаемый код должен быть скучным. Это то, к чему мы все должны стремиться
rankedWords(w => score(w) + bonus(w) - penalty(w), words) → List("java", "scala", "ada", "haskell", "rust"))
Мы определяем анонимную функцию, которая принимает String (с именем w) и возвращает Int
120 Часть I
I
Функциональный инструментарий
Чего еще можно добиться, просто передавая функции
В О
Хорошо, я понял! Мы можем выполнять сортировку, передавая функцию в виде аргумента. Это единственное, что позволяет этот новый метод передачи функций? В этой главе мы отсортировали много списков, так много, что кто-то может подумать, будто сортировка — это единственное, что можно делать, передавая функции. Однако на самом деле это не так! Мы будем использовать функции в качестве параметров для выполнения многих требований в этой главе и далее! Кроме того, в обозримом будущем мы не будем ничего сортировать — обещаю.
Прием передачи функций в виде аргументов широко распространен в функциональном программировании. Мы рассмотрим другие его применения, реализовав еще несколько функций в нашей системе ранжирования слов. Посмотрим, как мог бы звучать гипотетический разговор между человеком, запрашивающим новые возможности, и человеком, реализующим их на примере функции rankedWords, с которой мы работали. Нам нужно ранжировать слова по величине оценки. Без проблем! Я просто передам функцию ввызов sortBy! А теперь нам нужна возможность начисления дополнительных иштрафных баллов. Я просто передам вsortBy другую функцию! Теперь нам нужно получить исходные оценки для статистических вычислений. Тоесть вы даете мне список слов ихотите получить список их оценок? Дайте подумать...
Как видите, до сих пор это было довольно просто. Мы только передавали функции в функцию sortBy, однако на сей раз это не сработает. Можем ли мы реализовать эту новую функцию таким же лаконичным и удобочитаемым способом? Да, мы можем! Мы реализуем эту и многие другие функции позже!
Глава 4
I
Функции как значения
121
Применение функции к каждому элементу списка Теперь у нас появилось новое требование для реализации. Новые требования: получение оценок 1. Требуется получить оценку для каждого слова в списке. 2. Функция, отвечающая за ранжирование, должна работать как прежде (мы не можем изменить ни одну из имеющихся функций).
Для справки: императивное решение на Java Впредь мы не будем решать задачи на Java, но иногда я все же буду приводить для справки императивные решения на этом языке, чтобы дать вам возможность увидеть их различия и сделать выводы. static List wordScores( Function wordScore, List words ) { List result = new ArrayList(); for(String word : words) { result.add(wordScore.apply(word)); } return result; }
Как видите, код выше имеет те же проблемы:
Создается новый список, который будет заполняться полученными значениями Мы применяем заданную функцию к каждому элементу, используя цикл for. Результат функции добавляется в итоговый список
• использует изменяемые коллекции (ArrayList); • не очень лаконичен. Очевидно, что для такой простой задачи функция содержит слишком много кода.
Для справки: решение на Java с использованием Streams Конечно, мы можем написать более удачное решение, используя Java Streams: static List wordScores( Function wordScore, List words ) { return words.stream() .map(wordScore).collect(Collectors.toList()); }
Внимание, спойлер! Список проблем почти наверняка будет таким же: изменяемые коллекции, нечистые функции и/или слишком много кода
Функция stream() позволяет работать с копией входного списка. Мы используем метод map класса Stream, чтобы применить функцию к каждому элементу. Если вы не знаете, как работает map, то продолжайте читать
Обычно я рекомендую использовать инструменты Streams в подобных решениях, так как они не изменяют существующие значения. Однако я не буду приводить справочные примеры на Java с использованием Streams и стану показывать только обобщенные версии кода на Java без Streams, поскольку так это делается в других императивных языках и их проще сравнивать.
122 Часть I
I
Функциональный инструментарий
Применение функции к каждому элементу списка с помощью map Новое требование можно быстро реализовать с помощью функции map, повсеместно используемой в функциональном программировании. Она очень похожа на уже знакомую нам функцию sortBy, принимая только один параметр, и этот параметр — функция! Посмотрим на реализацию нового требования на языке Scala с использованием встроенного неизменяемого типа List, имеющего функцию map. Обратите внимание, как без лишних хлопот мы превращаем список строк в список целых чисел
def wordScores(wordScore: String => Int, words: List[String]): List[Int] = { words.map(wordScore) }
Как видите, это решение очень похоже на версию для Java Streams. Фактически метод map извлекает из списка каждый элемент, применяет к нему функцию и сохраняет результат в новом возвращаемом списке. То есть он тоже ничего не изменяет. Тип List в Scala поддерживает неизменяемость.
Сигнатура Углубимся в код и внимательно посмотрим, как он работает. Как всегда, начнем с сигнатуры функции, чтобы получить представление о том, что внутри. wordScore — это функция, принимающая String и возвращающая Int
def wordScores(wordScore: String => Int, words: List[String]): List[Int] Функция возвращает новый words — это список целых чисел неизменяемый список строк
Тело Тело функции состоит из единственного вызова метода map, который выполняет всю тяжелую работу. Если предположить, что список words содержит "rust" и "java", а функция, переданная в параметре wordScore, возвращает количество букв, отличных от 'a', то вот что происходит на самом деле. List(
1
2
List( 3
rust
wordScore( rust ) = 4
4
java
wordScore( java ) = 2
2
).map(wordScore)
)
map используется вместо цикла for, необходимого в императивной версии.
Обратите внимание, что здесь мы используем функцию wordScore, даже не зная, что она делает! Она передается как параметр и может делать что угодно, при условии, что принимает String и возвращает Int.
Глава 4
I
Функции как значения
123
Знакомство с map Есть несколько утверждений в отношении функции map, которые всегда верны. Обсудим их. Функция map принимает только один параметр, и этот параметр — функция. Если список содержит строки, то эта функция должна принимать одну строку и возвращать значение того же или другого типа. Тип значений, возвращаемых этой функцией, точно соответствует типу элементов в возвращаемом списке. Например: Обратите внимание, как мы преdef wordScores(wordScore: String => Int, words: List[String]): List[Int] = { words.map(wordScore) }
вращаем список строк в список целых чисел, передавая функцию, которая принимает строку и возвращает целое число
map вызывается для объекта words, имеющего тип List[String]. Это означает, что функция, которая передается в вызов map, должна принимать параметр типа String. К счастью, wordScore как раз такая функция! Тип значения, возвращаемого функцией wordScore, точ-
но соответствует типу элементов списка, возвращаемого функцией map. wordScore возвращает значения Int; соответственно map вернет List[Int].
В этой книге мы будем использовать множество диаграмм для всех обобщенных функций, которые нам встретятся. Вместо конкретных типов, таких как String и Int, будем применять в этих диаграммах более обобщенные обозначения: списки будут содержать значения типа A (то есть List[A]), функции будут принимать элементы типа A и возвращать элементы типа B, а map будет возвращать List[B]. List[A].map(f: A => B): List[B]
Применяет переданную функцию f к каждому элементу входного списка с элементами типа A и создает новый список с новыми элементами типа B. Размеры входного и выходного списков одинаковы. Порядок элементов сохраняется. List(
).map(
=>
map сохраняет порядок элементов. Это значит, если элемент предшествовал другому элементу во входном списке, то его преобразованная версия тоже будет предшествовать в итоговом списке раньше. В нашем случае A — это String, а B — Int
score получает слово и возвращает его оценку.
)
List(
Использование карты на практике Еще раз взглянем на наше решение и попробуем использовать его с реальной функцией оценки, чтобы убедиться, что мы движемся в правильном направлении. val words = List("ada", "haskell", "scala", "java", "rust") wordScores(w => score(w) + bonus(w) - penalty(w), words) → List(1, -1, 1, 2, -3)
Видите? Передача функций — очень эффективный прием. Теперь ваша очередь сделать это.
bonus получает слово и возвращает количество дополнительных баллов. penalty получает слово и возвращает количество штрафных баллов.
124 Часть I
I
Функциональный инструментарий
Практика map Пришло время попрактиковаться в передаче некоторых функций методу map. И снова используйте для экспериментов Scala REPL, чтобы навыки применения map стали для вас привычными. Возвращает длины строк в списке. входные данные: List("scala", "rust", "ada") результат: List(5, 4, 3)
Возвращает количество букв 's' в строках в списке. входные данные: List("rust", "ada") результат: List(1, 0)
Изменяет знак всех целых чисел в списке и возвращает результат в новом списке. входные данные: List(5, 1, 2, 4, 0) результат: List(-5, -1, -2, -4, 0)
Удваивает все целые числа в списке и возвращает результат в новом списке. входные данные: List(5, 1, 2, 4, 0) результат: List(10, 2, 4, 8, 0)
1 2 3 4
Ответы def len(s: String): Int = s.length → len List("scala", "rust", "ada").map(len) → List(5, 4, 3)
1
def numberOfS(s: String): Int = s.length - s.replaceAll("s", "").length → numberOfS List("rust", "ada").map(numberOfS) → List(1, 0)
2
def negative(i: Int): Int = -i → negative List(5, 1, 2, 4, 0).map(negative) → List(-5, -1, -2, -4, 0)
3
def double(i: Int): Int = 2 * i → double List(5, 1, 2, 4, 0).map(double) → List(10, 2, 4, 8, 0)
4
Глава 4
I
Функции как значения
125
Изучите однажды, используйте постоянно Если посмотреть на две функции, созданные в этой главе, то можно увидеть, что они имеют много общего. def rankedWords(wordScore: String => Int, words: List[String]): List[String] = { words.sortBy(wordScore).reverse } def wordScores(wordScore: String => Int, words: List[String]): List[Int] = { words.map(wordScore) }
Это не совпадение. Шаблон определения функции, принимающей другую функцию в параметре, можно с успехом использовать во многих других контекстах, а не только для выполнения операций с коллекциями. На данный момент мы используем коллекции лишь в качестве первого шага, чтобы сформировать интуитивное представление об этом шаблоне. Затем, когда интуитивное понимание сформируется, вы увидите возможность настраивать свои функции с использованием параметров-функций практически везде, не только в контексте коллекций.
ЭТО ВА Ж Н О! Функции, принимающие другие функции в параметрах, широко распространены в ФП.
Чего еще можно добиться, передавая функции Теперь вернемся к гипотетическому разговору между человеком, запрашивающим новые возможности, и человеком, реализующим их, беззаботно передавая функции в параметрах. Нам нужно ранжировать слова по величине оценки. А теперь нам нужна возможность начисления дополнительных иштрафных баллов.
Без проблем! Я просто передам функцию ввызов sortBy!
Я просто передам вsortBy другую функцию!
Теперь нам нужно получить исходные оценки для статистических вычислений. Мы хотим получить врезультате только слова снаибольшей оценкой.
Я просто передам функцию оценки ввызов map!
Я уверен, что должен передать функцию... куда-то...
Мы передавали функции в вызовы функций sortBy и map, но теперь у нас появилось еще одно требование! Можно ли реализовать эту новую функцию таким же лаконичным и удобочитаемым способом, передав функцию другой функции?
126 Часть I
I
Функциональный инструментарий
Возврат части списка, соответствующей условию Теперь у нас есть новое требование для реализации. Новые требования: получение слов с высокой оценкой 1. Требуется получить список слов с высокой оценкой. 2. Функции, реализованные до сих пор, должны работать как прежде (мы не можем изменить ни одну из имеющихся функций).
Короткое упражнение: сигнатура В этой книге мы разбираем принципы дизайна функционального программного обеспечения. Именно поэтому всегда начинаем разработку новых функций с их сигнатур. Настала ваша очередь попытаться самостоя тельно определить сигнатуру, опираясь на приведенные выше требования. Отложите книгу на какое-то время и попробуйте ответить на вопрос: как будет выглядеть сигнатура функции highScoringWords на Scala?
Ответ вы найдете в обсуждении.
Для справки: императивное решение на Java Прежде чем приступить к реализации функционального решения, посмотрим, как будет выглядеть императивное решение на Java и какие проблемы у него обнаружатся. static List highScoringWords( Function wordScore, List words ) { List result = new ArrayList(); for (String word : words) { if (wordScore.apply(word) > 1) result.add(word); } return result; }
Создается новый список, который будет заполняться полученными значениями
В цикле for к каждому элементу применяется функция. Если условие выполняется, то результат функции добавляется в итоговый список
И снова код выше имеет те же проблемы: • использует изменяемые коллекции (ArrayList); • не очень лаконичен. Интуитивно понятно, что решение получилось слишком объемным для такой маленькой задачи.
Сигнатура на Scala Scala (и другие языки ФП) позволяет использовать неизменяемый список List. def highScoringWords(wordScore: String => Int, words: List[String]): List[String]
Список проблем содержит уже знакомые нам пункты.
Глава 4
I
Функции как значения
127
Возврат части списка с помощью filter Вы, наверное, уже догадались, что новое требование легко реализовать с помощью функции класса List. И вы правы! Эта функция называется filter. Она очень похожа на функции sortBy и map, с которыми мы уже встречались, поскольку тоже принимает лишь один параметр, и этот параметр — функция! Посмотрим на реализацию нового требования на Scala и неизменяемого списка List с функцией filter: def highScoringWords(wordScore: String => Int, words: List[String]): List[String] = { words.filter(word => wordScore(word) > 1) }
Мы задаем условие для проверки каждого элемента. Если условие выполняется, то элемент добавляется в итоговый список
Функция filter извлекает каждый элемент из списка и применяет к нему заданное условие, возвращая только элементы, удовле творяющие этому условию. При этом она ничего не изменяет. Класс List в Scala поддерживает неизменяемость, поэтому возвращаемый список — это совершенно новый список элементов, удовлетворяющих условию, которое мы передали как функцию в функцию filter.
Сигнатура
wordScore — это функция, принимающая String и возвращающая Int
def highScoringWords(wordScore: String => Int, words: List[String]): List[String] words — это неизменяемый Функция возвращает
новый список строк
список строк
Тело Тело функции просто вызывает filter и передает функцию, возвращающую логическое значение. Если предположить, что список words содержит "rust" и "java", а функция, переданная в параметре wordScore, является нашей функцией оценки слов с начислением дополнительных и штрафных баллов, то вот что происходит на самом деле. List(
1
2
rust
wordScore( rust ) = -3
java
wordScore( java ) =
).filter(word => wordScore(word) > 1 )
filter вызывает wordScore(word) > 1 для каждого элемента (word)
2
-3 > 1 2 > 1
List(
3 )
filter возвращает новый список, содержащий только один элемент
filter используется вместо цикла for, который был нужен в императивной версии. И снова обратите внимание, что мы используем функцию wordScore, даже не зная, что она делает! Она передается как параметр и может делать что угодно, при условии, что принимает String и возвращает Int.
128 Часть I
I
Функциональный инструментарий
Знакомство с filter Функция filter принимает только один параметр, и этот параметр — функция. Если список содержит строки, то эта функция должна принимать одну строку. Однако, в отличие от map, функция, которая передается в filter, всегда должна возвращать логическое значение, чтобы последняя могла решить — включать этот элемент в итоговый список или нет. Тип элементов списка, возвращаемого функцией filter, совпадает с типом элементов во входном списке. Например: def highScoringWords(wordScore: String => Int, words: List[String]): List[String] = { words.filter(word => wordScore(word) > 1) }
filter применяется к списку words, имеющему тип List[String]. Это означает, что функция, которая передается в filter, должна принимать значение типа String. wordScore принимает строки, но возвращает
filter принимает один параметр — функцию. Она должна принимать строку и возвращать логическое значение
целые числа, поэтому нам нужно создать новую функцию, принимающую строку String и возвращающую логическое значение Boolean. word => wordScore(word) > 1
Мы используем синтаксис с двойной стрелкой, чтобы создать встроенную анонимную функцию, которая возвращает true, если оценка слова в параметре выше 1, и false в противном случае. Функцию, передаваемую в вызов filter, можно рассматривать как функцию принятия решения. Она решает, какие элементы нужно включить в результат. List[A].filter(f: A => Boolean): List[A]
Применяет переданную функцию f к каждому элементу входного списка с элементами типа A и создает новый список с теми же элементами типа A , для которых filter вернула true. Размеры входного и выходного списков могут различаться. Порядок элементов сохраняется. List( List(
).filter(
=> yes or no? )
)
Использование filter на практике Теперь, познакомившись с filter поближе, попробуем использовать нашу новую функцию. val words = List("ada", "haskell", "scala", "java", "rust") highScoringWords(w => score(w) + bonus(w) - penalty(w), words) → List("java")
Только java имеет оценку выше 1 (2, если быть точными). Мы снова использовали все функции вычисления оценок, и возможность передавать функции спасла положение.
filter сохраняет порядок элементов. Это значит, что если элемент предшествовал другому элементу во входном спис ке, то он будет предшествовать ему и в итоговом списке при условии, что оба включены. score def score(word: String): Int = word.replaceAll("a", "").length
bonus def bonus(word: String): Int = if (word.contains("c")) 5 else 0
penalty def penalty(word: String): Int = if (word.contains("s")) 7 else 0
Глава 4
I
Функции как значения
129
Практика filter Пришло время попрактиковаться в передаче некоторых функций методу filter. И снова используйте для экспериментов Scala REPL, чтобы до конца понять, как работает filter.
1
Возвращает слова короче пяти символов. входные данные: List("scala", "rust", "ada") результат: List("rust", "ada")
Возвращает слова, в которых больше двух букв 's'. входные данные: List("rust", "ada") результат: List()
Возвращает новый список только с нечетными числами. входные данные: List(5, 1, 2, 4, 0) результат: List(5, 1)
2 3 4
Возвращает новый список с числами больше 4. входные данные: List(5, 1, 2, 4, 0) результат: List(5)
Ответы def len(s: String): Int = s.length → len List("scala", "rust", "ada").filter(word => len(word) < 5) → List("rust", "ada") def numberOfS(s: String): Int = s.length - s.replaceAll("s", "").length → numberOfS List("rust", "ada").filter(word => numberOfS(word) > 2) → List() def odd(i: Int): Boolean = i % 2 == 1 → odd List(5, 1, 2, 4, 0).filter(odd) → List(5, 1) def largerThan4(i: Int): Boolean = i > 4 → largerThan4 List(5, 1, 2, 4, 0).filter(largerThan4) → List(5)
Обратите внимание, что мы можем использовать именованную функцию, если она возвращает логическое значение. Если такой функции нет, то мы должны создать встроенную анонимную функцию
1 2 3 4
130 Часть I
I
Функциональный инструментарий
Насколько далеко мы зашли в нашем путешествии... Прежде чем перейти к следующей теме, остановимся ненадолго и порассуждаем о том, что вы узнали и что еще должны понять. Итак, в этой части книги вы познакомились с основными инструментами: чистыми функциями (глава 2), неизменяемыми значениями (глава 3) — и узнали, что чистые функции можно рассматривать как неизменяемые значения (текущая глава). Эти три аспекта — все, что нам нужно, чтобы заняться функциональным программированием. Однако функции и значения могут взаимодействовать друг с другом множеством способов, поэтому мы должны рассматривать разные проблемы с разных точек зрения. Знакомство с возможностью использования функций как значений Процесс обучения в этой главе состоит из трех шагов. Мы выполнили первый шаг, когда познакомились с очень специфической возможностью. Теперь мы переходим к теме, имеющей более архитектурный характер, но все еще тесно связанной с тем фактом, что функции — это просто значения.
Шаг 1 Передача функций в виде аргументов Функции — это значения, которые можно передавать в виде аргументов другим функциям. List(
).map(
List(
)
)
List( List(
=>
).filter(
=> yes or no? )
)
Шаг 2 Возврат функций из функций Функции — это значения, и их можно возвращать из других функций. В этой части основное внимание уделяется функциональному дизайну — как создавать гибкие API, использующие возможность возвращать функции в виде возвращаемых значений. Здесь мы рассмотрим и используем функциональную реализацию шаблона компоновщика (builder), объединение вызовов методов в цепочку и создание текучего (fluent) интерфейса, с которыми вы, возможно, знакомы по опыту ООП. Например: new HighScoringWords.Builder() .scoringFunction(w -> score(w)) .highScoreBoundary(5) .words(Arrays.asList("java", "scala") .build();
Вы познакомитесь со всеми этими инструментами на примере реализации реального требования, возвращая функции из функций.
А теперь перейдем ко второму шагу. Нас ждет еще одно требование, настолько жесткое, что нам придется придумать совершенно новый способ решения задач.
Мы здесь! Далее мы сделаем очень большой шаг! В этой главе вам предстоит узнать многое, поэтому посмотрите на то, что ждет вас впереди. Шаг 3 Функции как значения Функции — это значения, которые можно передавать и возвращать. В заключение мы посмотрим, как все эти возможности сочетаются друг с другом. Вы познакомитесь с функцией из стандартной библиотеки, которая использует оба приема: передачу функций в виде аргументов и возврат в виде результата. Затем мы посмотрим, насколько хорошо умеем моделировать данные, используя неизменяемые значения, и как вместе с этими значениями можно передавать функции, определенные на них, для создания более сложных алгоритмов.
Глава 4
I
Функции как значения
131
Не повторяйся? Вернемся к нашему примеру и посмотрим, что произойдет, если добавить еще больше требований. На данный момент мы можем возвращать слова с высокой оценкой, а код выглядит очень лаконично и читабельно. Следующее требование поможет по-настоящему проверить наш новый подход.
Это то, что мы хотели бы иметь постоянно, даже в свете новых и меняющихся требований.
В настоящий момент порог определения высоких оценок равен 1, но унас будет реализовано несколько игровых режимов сразными порогами. Пока унас три режима спорогами высоких оценок 1, 0 и5. Я посмотрю, что можно сделать...
Предположим, что нам нужно реализовать три случая получения слов с высокой оценкой из заданного списка: • слово с оценкой выше 1 (текущая реализация); • слово с оценкой выше 0; • слово с оценкой выше 5. Чтобы реализовать три случая выше, нам нужно повторить много кода: def highScoringWords(wordScore: String => Int, words: List[String]): List[String] = { words.filter(word => wordScore(word) > 1) } def highScoringWords0(wordScore: String => Int, words: List[String]): List[String] = { words.filter(word => wordScore(word) > 0) } def highScoringWords5(wordScore: String => Int, words: List[String]): List[String] = { words.filter(word => wordScore(word) > 5) } val words = List("ada", "haskell", "scala", "java", "rust") highScoringWords(w => score(w) + bonus(w) - penalty(w), words) → List("java") highScoringWords0(w => score(w) + bonus(w) - penalty(w), words) → List("ada","scala","java") highScoringWords5(w => score(w) + bonus(w) - penalty(w), words) → List()
Получилось слишком много повторяющегося кода! Посмотрим, какие инструменты можно использовать, чтобы избежать повторов!
Текущая версия функции использует порог высокой оценки, жестко определенный как 1. Вот почему нам нужно создать две почти точные копии этой функции с разными именами и пороговыми значениями 0 и 5 соответственно. Кроме того, нам нужно передать одну и ту же функцию оценки каждой из этих почти точных копий. Слишком много повторяющегося кода!
132 Часть I
I
Функциональный инструментарий
Легко ли использовать мой API Возможно, вы думаете, что проблему с порогами высоких оценок можно решить, добавив в функцию highScoringWords третий параметр. Что ж, это, безусловно, правильный подход, и он устранит некоторый повторяющийся код. Однако на следующих нескольких страницах вы увидите, что этого недостаточно. В данном решении все равно останутся неприятные повторения, и, что еще хуже, они будут иметь место в клиентском коде, использующем нашу функцию с помощью реализованного нами API. Обратите внимание, что в функциональном коде API — это обычно сигнатура функции.
Теперь вы знаете, почему мы уделяем так много внимания сигнатурам!
Эта книга знакомит с функциональным программированием. Основная цель, однако, состоит в том, чтобы показать, как оно решает реальные задачи. Поскольку код читают и анализируют чаще, чем пишут, мы должны сосредоточиться на особенностях использования наших функций. Особенности их реализации тоже важны, но вторичны. Наше решение будет использоваться другими программистами, и мы всегда должны помнить об этом. Не задумываясь над тем, как будет использоваться наш API... highScoringWords
Я добавил вфункцию новый параметр. Это решает вашу проблему?
def highScoringWords(wordScore: String => Int, words: List[String], higherThan: Int): List[String] = { words.filter(word => wordScore(word) > higherThan) }
Спасибо, но теперь ею сложно пользоваться, поскольку везде приходится копировать функцию подсчета баллов:
highScoringWords(w => score(w) + bonus(w) - penalty(w), words, 1) highScoringWords(w => score(w) + bonus(w) - penalty(w), words, 0) highScoringWords(w => score(w) + bonus(w) - penalty(w), words, 5)
После размышлений об удобстве использования API...
ЗАБЕГАЯ ВПЕРЕД!
Придя к заключению, что добавления нового параметра недостаточно, рассмотрим новый прием функционального программирования, который поможет создать более удобный API. Обратите внимание, что следующий код — это то, что нам предстоит написать. Сосредоточьтесь на клиентском коде. И не волнуйтесь, если пока чего-то не понимаете, — все, что нам нужно, мы исследуем далее в текущей главе. highScoringWords def highScoringWords(wordScore: String => Int, words: List[String] ): Int => List[String] = { higherThan => words.filter(word => wordScore(word) > higherThan) }
Я использовал новый прием, скоторым познакомился вкниге «Грокаем функциональное программирование». Сталоли проще пользоваться этой функцией?
Это поразительно! Теперь мой код лаконичен илегко читается! val wordsWithScoreHigherThan: Int => List[String] = highScoringWords(w => score(w) + bonus(w) - penalty(w), words) wordsWithScoreHigherThan(1) Функция подсчета баллов неизменилась wordsWithScoreHigherThan(0) иопределяется только один раз. Теперь wordsWithScoreHigherThan(5)
вкоде нет никаких неприятных повторений.
Глава 4
I
Функции как значения
133
Добавления нового параметра недостаточно Увидев проблему с разными порогами высоких оценок, вы наверняка подумали: «Ее легко исправить: достаточно добавить новый параметр и все!» К сожалению, этого недостаточно. Посмотрим почему. Как можно видеть в примере выше, новый параметр кое в чем помог, избавив от необходимости определять новую функцию для каждого отдельного значения порога. Однако некоторый код все равно повторяется, и от этого повторения нельзя избавиться добавлением нового параметра. Нужно нечто большее — прием, позволяющий передать функции только часть параметров функции, а другие оставить на потом. Подведем итог: def highScoringWords(wordScore: String => Int, words: List[String]): List[String] = { words.filter(word => wordScore(word) > 1) Нам нужно параметризовать функцию } и преобразовать это значение в параметр Int.
def highScoringWords(wordScore: String => Int, words: List[String], higherThan: Int): List[String] = { words.filter(word => wordScore(word) > higherThan) } Теперь мы можем использовать новую функцию
highScoringWords во всех трех (и более!) случаях
без создания новой функции для каждой ситуации.
Теперь наша функция highScoringWords принимает функцию оценки, список строк и целое число. Она по-прежнему возвращает список слов с высокой оценкой. Параметры с функцией оценки и целым числом используются для создания анонимной функции, которая передается функции filter.
highScoringWords(w => score(w) + bonus(w) - penalty(w), words, 1) → List("java") highScoringWords(w => score(w) + bonus(w) - penalty(w), words, 0) → List("ada", "scala", "java") highScoringWords(w => score(w) + bonus(w) - penalty(w), words, 5) → List()
ПРОБЛЕМА!
Мы избавились от части повторяющегося кода, но его все еще довольно много! Мы по-прежнему передаем одну и ту же функцию подсчета баллов в вызов highScoringWords.
def highScoringWords(wordScore: String => Int, words: List[String], higherThan: Int): List[String]
Было бы желательно, чтобы wordScore и words передавались в одном месте...
...а аргумент highThan — в другом, чтобы исключить все повторения.
134 Часть I
I
Функциональный инструментарий
Функции могут возвращать функции Чтобы решить проблему повторяющегося кода, нужно нечто большее, чем простой новый параметр. Нужен прием, который отложит применение этого нового параметра. Желаемого можно добиться, создав функцию, которая возвращает функцию. Изменим функцию highScoringWords, как показано ниже.
Сигнатура Начнем с сигнатуры. Ниже определяется наша самая первая функция, возвращающая другую функцию! Функция принимает два параметра!
Не забывайте, что сигнатуры функций многое рассказывают о том, что делают эти функции и как их использовать.
Функция возвращает функцию, которая принимает Int и возвращает List[String]
def highScoringWords(wordScore: String => Int, words: List[String]): Int => List[String]
Тело Как мы можем вернуть функцию из другой функции? Для этого можно использовать тот же синтаксис, который мы использовали для передачи функций в sortBy, map и filter. def highScoringWords(wordScore: String => Int, words: List[String]): Int => List[String] = { higherThan => words.filter(word => wordScore(word) > higherThan) }
• • •
Возвращаемое значение — это функция, которая принимает параметр highThan и возвращает List[String]. Мы используем filter, чтобы создать новый отфильтрованный список строк. Мы используем highThan внутри функции, которая передается в вызов filter. higherThan =>
Это определение встроенной анонимной функции, которая принимает один параметр с именем higherThan. higherThan => words.filter(
)
Это определение встроенной анонимной функции, которая принимает один параметр с именем higherThan и возвращает список List[String], созданный в процессе фильтрации списка words. (word =>
)
Это определение встроенной анонимной функции, которая принимает один параметр с именем word. (word => wordScore(word) > higherThan)
Это определение встроенной анонимной функции, которая принимает один параметр с именем word и возвращает логическое значение.
Глава 4
I
Функции как значения
135
Использование функций, возвращающих функции Возврат функции из highScoringWords позволяет нам передать два параметра в одном месте кода, а третий параметр — позже. Посмотрим, как это сделать. Наша функция highScoringWords принимает функцию оценки и список строк и возвращает функцию, которая принимает Int и возвращает List[String]. def highScoringWords(wordScore: String => Int, words: List[String]): Int => List[String] = { higherThan => words.filter(word => wordScore(word) > higherThan) } highScoringWords(w => score(w) + bonus(w) - penalty(w), words) → Int => List[String]
Вызывая эту функцию, мы получаем другую функцию! val wordsWithScoreHigherThan: Int => List[String] = highScoringWords(w => score(w) + bonus(w) - penalty(w), words)
Теперь мы можем сохранить результат как функцию, которая принимает только один аргумент Int и возвращает список строк
Мы смогли передать только два первых аргумента: функцию wordScore и список слов words. Мы можем сохранить результат вызова highScoringWords (анонимную функцию) под новым именем.
Теперь мы можем использовать эту новую функцию WithScoreHigherThan для реализации всех трех (и более!) случаев, не создавая отдельную функцию для каждого из них и не повторяя определение функции подсчета баллов. Это возможно, поскольку мы уже применили два аргумента ранее. Функция wordsWithScoreHigherThan уже знает, какой список слов и какую функцию подсчета очков использовать, ей нужен лишь аргумент highThan, чтобы вернуть конечный результат. Именно этого мы и хотели! wordsWithScoreHigherThan(1) → List("java") wordsWithScoreHigherThan(0) → List("ada", "scala", "java") wordsWithScoreHigherThan(5) → List()
ПРОБЛЕМА РЕШЕНА! Никаких повторов! Теперь мы знаем, какой фрагмент кода отвечает за определение функции подсчета баллов и порогового значения высоких оценок. Здесь не остается места для ошибки!
Сейчас не нужно писать много кода, а тот, что остался, получился очень лаконичным. Это может даже показаться обманом! Чтобы избавиться от этого чувства, разберем самую критическую часть и попытаемся понять, что происходит в действительности. Это определение нового неизменяемого значения
Эта функция принимает Int и возвращает List[String]
val wordsWithScoreHigherThan: Int => List[String] = highScoringWords(w => score(w) + bonus(w) - penalty(w), words)
Это значение создается путем вызова функции highScoringWord, которая возвращает функцию, принимающую Int и возвращающую List[String]!
Несмотря на то что функцию
wordsWithScoreHigherThan мы
получили в результате вызова функции highScoringWords, мы можем обращаться с ней точно так же, как с любой другой функцией. Она принимает Int и возвращает List[String], если передать ей целое число: wordsWithScoreHigherThan(1) → List("java")
136 Часть I
I
Функциональный инструментарий
Функции — это значения
В О
То есть все это означает, что мы можем вернуть функцию из функции, а затем сохранить ее как val под любым именем так же, как можно вернуть и сохранить Int, String или List[String]? Да! Именно это и означает! В функциональном программировании функции подобны любым другим значениям. Их можно передавать в аргументах, возвращать в возвращаемых значениях и ссылаться на них под разными именами (используя синтаксис val).
Функции, переданные в аргументах, являются значениями Список параметров highScoringWords имеет два параметра. И оба интерпретируются одинаково; оба являются обычными неизменяемыми значениями. def highScoringWords(wordScore: String => Int, words: List[String] ): Int => List[String] = { higherThan => words.filter(word => wordScore(word) > higherThan) }
ЭТО ВА Ж Н О! В функцио нальном программировании функции подобны любым другим значениям.
Значение, возвращаемое из highScoringWords, — это функция, которую можно использовать везде, где требуется функция, принимающая Int и возвращающая List[String]
Функции, возвращаемые из других функций, являются значениями Но подождите! Это еще не все! Мы также смогли вернуть функцию из другой функции, сохранить ее с выбранным нами именем и затем вызвать!
Мы просто передаем значения Мы передавали функции в sortBy, map и filter. Однако в Scala и в других функциональных языках нет реальной разницы между функциями и другими значениями. А пока понимание этой особенности не стало для вас естественным, считайте функции объектами с поведением, как, например, объект Comparator в Java, который содержит единственную функцию внутри: Comparator scoreComparator = new Comparator() { public int compare(String w1, String w2) { return Integer.compare(score(w2), score(w1)); } };
Такой способ интерпретации подходит и для ФП. Функции — это просто значения; вы можете передавать их в виде аргументов, создавать их и возвращать из других функций. Вы можете просто передавать их как обычные строки.
Это одна из самых больших сложностей для тех, кто начинает осваивать ФП. Я допускаю, что вам будет сложно осознать это даже после прочтения данной главы. Вот почему я предлагаю вам альтернативную ментальную модель и обязательно буду периодически возвращаться к данной теме.
Глава 4
I
Функции как значения
137
Кофе-брейк: возврат функций Во время этого кофе-брейка вы должны будете переписать некоторые из функций, написанных ранее. Это поможет вам лучше усвоить тот факт, что функции можно использовать как обычные значения. Вы также будете возвращать функции из функций и использовать их в уже знакомых стандартных функциях, передавая возвращаемые функции в виде аргументов. Обязательно обдумайте каждое требование (и реализуйте на своем компьютере), прежде чем переходить к ответам. Ваша задача — реализовать каждое из четырех следующих требований, чтобы их дизайн был достаточно хорош для внесения дополнительных изменений, если такие потребуются в будущем. Поэтому каждое упражнение содержит первоначальное требование и его измененную версию. Обязательно в каждом решении используйте функцию, возвращающую другую функцию. Верните новый список со всеми числами больше 4. входные данные: List(5, 1, 2, 4, 0) результат: List(5)
1
Измененное требование: верните новый список со всеми числами больше 1.
Подсказка: аргументы 4 и 1 должны передаваться где-то в другом месте.
входные данные: List(5, 1, 2, 4, 0) результат: List(5, 2, 4)
Верните новый список, содержащий только числа, кратные 5. входные данные: List(5, 1, 2, 4, 15) результат: List(5, 15)
2
Измененное требование: верните новый список, содержащий только числа, кратные 2. входные данные: List(5, 1, 2, 4, 15) результат: List(2, 4)
Верните слова, которые короче четырех символов. входные данные: List("scala", "ada") результат: List("ada")
3
Измененное требование: верните слова, которые короче семи символов. входные данные: List("scala", "ada") результат: List("scala", "ada")
Верните слова, содержащие больше двух букв 's'. входные данные: List("rust", "ada") результат: List()
4
Измененное требование: верните слова, содержащие больше нуля букв 's'. входные данные: List("rust", "ada") результат: List("rust")
Подсказка: аргументы 5 и 2 должны передаваться где-то в другом месте. Может быть, в новой функции? Подсказка: аргументы 4 и 7 должны передаваться в функцию, которая возвращает другую функцию. Подсказка: можно повторно использовать функцию numberOfS, показанную на предыдущей странице, но одного этого недостаточно!
138 Часть I
I
Функциональный инструментарий
Объяснение для кофе-брейка: возврат функций Надеюсь, вам не было скучно! Как всегда, есть несколько способов решить эти упражнения. Если вы использовали функции, возвращающие другие функции, которые принимают Int в параметрах, то вы молодцы! Если у вас ничего не получилось, то не переживайте; озарение обязательно придет! Чем больше времени вы потратили на решение этих упражнений, тем ближе вы к откровению. А теперь решим их вместе.
Мы снова будем использовать Scala REPL. Обязательно опробуйте решения, предложенные ниже, особенно если у вас не получилось решить упражнения самостоятельно.
Верните новый список со всеми числами больше 4 (или 1). def largerThan(n: Int): Int => Boolean = i => i > n → largerThan List(5, 1, 2, 4, 0).filter(largerThan(4)) → List(5) List(5, 1, 2, 4, 0).filter(largerThan(1)) → List(5, 2, 4)
1
Мы определили функцию largeThan, принимающую Int и возвращающую функцию, которую можно использовать в вызове filter.
Верните новый список, содержащий только числа, кратные 5 (или 2).
2
def divisibleBy(n: Int): Int => Boolean = i => i % n == 0 → divisibleBy Мы определили функцию divisibleBy, принимающую Int и возвращаList(5, 1, 2, 4, 15).filter(divisibleBy(5)) ющую функцию, которую можно → List(5, 15) использовать в вызове filter. Затем мы использовали эту функцию для List(5, 1, 2, 4, 15).filter(divisibleBy(2)) фильтрации различных чисел. → List(2, 4)
Верните слова, которые короче четырех символов (или семи).
3
def shorterThan(n: Int): String => Boolean = s => s.length < n → shorterThan Мы определили функцию List("scala", "ada").filter(shorterThan(4)) shorterThan, принимающую Int → List("ada") и возвращающую функцию, которую можно использовать в вызове filter. List("scala", "ada").filter(shorterThan(7)) Затем мы использовали эту функцию → List("scala", "ada") для фильтрации различных строк.
Верните слова, содержащие больше двух букв 's' (или больше нуля). def numberOfS(s: String): Int = s.length - s.replaceAll("s", "").length → numberOfS def containsS(moreThan: Int): String => Boolean = s => numberOfS(s) > moreThan → containsS List("rust", "ada").filter(containsS(2)) → List() List("rust", "ada").filter(containsS(0)) → List("rust")
4
Мы определили функцию containsS, принимающую Int и возвращающую функцию, которую можно использовать в вызове filter. Внутри этой новой функции мы повторно использовали функцию numberOfS, которую написали ранее. Затем мы использовали containsS для фильтрации различных строк.
Глава 4
I
Функции как значения
Проектирование функциональных API Мы ввели очень важную концепцию: функции — это просто значения, которые можно возвращать из других функций. В нашем примере возникла проблема с функцией с тремя параметрами, и мы преобразовали ее в функцию с двумя параметрами, которая возвращает функцию с одним параметром. Логика внутри функции не изменилась (изменились только сигнатуры), но это значительно упростило наше решение! Проведем сравнение классическим способом «до и после». До
Вот функция, соответствующая вашему требованию об определении слов с высокой оценкой.
Привет! Я highScoringWords. Передайте мне функцию подсчета баллов, список слов и пороговое значение, а я верну вам список слов с высокими оценками!
highScoringWords def highScoringWords(wordScore: String => Int, words: List[String], higherThan: Int): List[String] = { words.filter(word => wordScore(word) > higherThan) }
Привет! Рада видеть вас. Я хотела бы получать списки слов с разными пороговыми значениями высоких оценок: 1, 0 и 5. highScoringWords(w => score(w) + bonus(w) - penalty(w), words, 1) highScoringWords(w => score(w) + bonus(w) - penalty(w), words, 0) highScoringWords(w => score(w) + bonus(w) - penalty(w), words, 5)
Что ж, похоже, мне придется много раз написать один и тот же код и смириться с высокой вероятностью ошибиться.
После
Вот функция, соответствующая вашему требованию об определении слов с высокой оценкой. Я использовал новый прием. Нужно вызвать эту функцию с двумя параметрами, чтобы получить функцию, которой можно передавать различные пороговые значения. highScoringWords
Привет! Я highScoringWords. Передайте мне функцию подсчета баллов и список слов, а я верну вам еще одну функцию! Ей нужно будет передать порог высоких оценок, чтобы получить список слов с высокими оценками!
def highScoringWords(wordScore: String => Int, words: List[String] ): Int => List[String] = { higherThan => words.filter(word => wordScore(word) > higherThan ) }
Привет! Рада видеть вас. Я хотела бы получать списки слов с разными пороговыми значениями высоких оценок: 1, 0 и 5. val wordsWithScoreHigherThan: Int => List[String] = highScoringWords(w => score(w) + bonus(w) - penalty(w), words) wordsWithScoreHigherThan(1) wordsWithScoreHigherThan(0) wordsWithScoreHigherThan(5)
Отлично! Теперь у меня получился лаконичный и легко читаемый код! Спасибо!
Обратите внимание, что всю информацию, необходимую клиенту, можно получить из сигнатур! Используя этот прием, можно гарантировать, что клиенты смогут реализовать все свои варианты и написать читаемый, тестируемый и удобный для сопровождения код. Это основа хорошего дизайна программного обеспечения.
139
140 Часть I
I
Функциональный инструментарий
Итеративный дизайн функциональных API Функциональное программирование предлагает очень гибкие инструменты для разработки API. И в этой части книги вы как раз знакомитесь с ними. Однако одни только инструменты не сделают из вас хороших программистов. Вы должны знать, как использовать их в различных контекстах. Этому вы будете учиться далее в книге, а в данной главе я показал вам один из самых универсальных способов написания программного обеспечения и практику его применения в ФП: итеративное проектирование, основанное на отзывах клиентов. Дизайн меняется на основе текущих предположений. Мы формируем сигнатуры наших функций, исходя из требований и отзывов клиента о нашем коде. Изменения в данном случае только приветствуются. Мы, как программисты, не должны удивляться, когда требования изменяются или становятся более конкретными. Именно это и происходит в следующем разделе данной главы. Посмотрим, как мы можем использовать имеющиеся у нас инструменты, особенно возврат функций из функций, чтобы справиться с изменяющимися требованиями.
И их не особенно много, что уже хорошо! У нас есть чистые функции, неизменяемые значения и функции, которые можно использовать как значения, с которыми мы познакомились в этой главе. Нам больше ничего и не нужно!
В последнее время ячасто использую функцию highScoringWords истолкнулась спроблемой: чтобы получить высоко оцененные слова из разных списков ис разными пороговыми значениями, мне приходится писать много повторяющегося кода. Вы можете убедиться вэтом сами, попробовав получить высоко оцененные слова из разных списков ис разными пороговыми значениями.
Как видите, наш API не так хорош, как мы думали. Воспроизведем проблему и попробуем использовать два разных списка слов. val words = List("ada", "haskell", "scala", "java", "rust") val words2 = List("football", "f1", "hockey", "basketball") val wordsWithScoreHigherThan: Int => List[String] = highScoringWords(w => score(w) + bonus(w) - penalty(w), words) val words2WithScoreHigherThan: Int => List[String] = highScoringWords(w => score(w) + bonus(w) - penalty(w), words2) wordsWithScoreHigherThan(1) → List("java")) wordsWithScoreHigherThan(0) → List("ada", "scala", "java") wordsWithScoreHigherThan(5) → List()
Здесь кроется самая большая проблема. Необходимо определить очень похожую функцию для каждого списка слов!
Первая функция используется, чтобы получить высоко оцененные слова из первого списка. Мы получаем три результата, поскольку у нас есть три разных пороговых значения; следовательно, мы не можем сократить код меньше чем до трех вызовов функции
words2WithScoreHigherThan(1) → List("football", "f1", "hockey") words2WithScoreHigherThan(0) → List("football", "f1", "hockey", "basketball") words2WithScoreHigherThan(5) → List("football", "hockey")
В дополнение к списку слов, который мы использовали до сих пор, здесь определяется новый список (words2), чтобы смоделировать игру в слова ближе к реальности
Хуже того, мы должны использовать вторую функцию, чтобы получить высоко оцененные слова из второго списка. А так как нам нужно использовать три пороговых значения, наш код становится еще более раздутым из-за трех дополнительных вызовов функций!
Глава 4
I
Функции как значения
Возврат функций из возвращаемых функций Если внимательно посмотреть на проблему, с которой мы столкнулись, то можно заметить некоторое сходство с проблемой, которую мы решили ранее, возвращая функцию. Вспомним, что мы сделали. До
141
Если при чтении таких заголовков у вас начинает кружиться голова, то не волнуйтесь: на следующих нескольких страницах будет представлен более удачный способ определения этих вещей.
Функция с тремя параметрами
Мы хотели передать wordScore def highScoringWords(wordScore: String => Int, и words в одном месте... words: List[String], higherThan: Int): List[String] ...а аргумент higherThan — в другом, чтобы исключить дублирование кода, когда необходимо передать несколько После Функция с двумя параметрами разных значений в higherThan. Поэтому мы преобразовали функцию с тремя параметрами в функцию с двумя параметрами, которая возвращает функцию с одним параметром. def highScoringWords(wordScore: String => Int, words: List[String]): Int => List[String]
При таком подходе мы могли бы передавать Int (аргумент highrThan) независимо от wordScore и words. Однако нам по-прежнему нужно передавать wordScore и words вместе, и это причина текущей проблемы. Было бы желательно сохранить тот же алгоритм оценки и иметь возможность использовать разные списки слов words и значения higherThan. К счастью, данная проблема выглядит точно так же, как предыдущая, поэтому мы можем использовать точно такой же инструмент для ее решения. До
У этого приема есть название, и он широко используется в функциональном программировании. Совсем скоро мы раскроем его название!
Функция с двумя параметрами
def highScoringWords(wordScore: String => Int words: List[String] ): Int => List[String]
После Функция с одним параметром
Теперь нам нужно передать wordScore в одном месте... ...а аргумент words — в другом, чтобы исключить дублирование кода, обусловленное необходимостью передачи разных списков слов.
Итак, нам нужно преобразовать функцию с двумя параметрами, которая возвращает функцию с одним параметром, в функцию с одним параметром, возвращающую функцию с одним параметром, которая, в свою очередь, возвращает другую функцию с одним параметром.
В предложении слева нет ошибки. Нам действительно нужно это сделать.
def highScoringWords(wordScore: String => Int): Int => List[String] => List[String]
Эта функция принимает один параметр — функцию, принимающую String и возвращающую Int
Эта функция возвращает функцию, которая принимает Int, и возвращает функцию, принимающую List[String] и возвращающую новый List[String]
142 Часть I
I
Функциональный инструментарий
Как вернуть функцию из возвращаемой функции Теперь сигнатура нашей новой функции с одним параметром известна, и мы ожидаем, что она решит нашу проблему: необходимость передавать один и тот же аргумент снова и снова. Именно поэтому мы хотим добиться возможности применять каждый параметр независимо.
Повторим: если вам не нравятся такие заголовки, то не волнуйтесь: скоро будет представлен более удачный способ определения функций, возвращающих другие функции
Эта функция принимает один Эта функция возвращает функцию, которая принимает параметр — функцию, принимающую Int и возвращает функцию, принимающую String и возвращающую Int List[String] и возвращающую новый List[String] def highScoringWords(wordScore: String => Int): Int => List[String] => List[String] = { higherThan => words => words.filter(word => wordScore(word) > higherThan) } • • • •
Возвращаемое значение — это функция, которая принимает параметр higherThan и возвращает функцию. Возвращаемая функция принимает параметр words и возвращает List[String]. Мы используем filter для создания нового отфильтрованного списка строк, как и раньше. Мы используем higherThan и words внутри функции, которую мы передаем в функцию filter.
higherThan => Это определение встроенной анонимной функции, которая принимает один параметр с именем higherThan. higherThan => words => Это определение встроенной анонимной функции, которая принимает один параметр с именем higherThan и возвращает другую анонимную функцию, принимающую один параметр с именем words. higherThan => words => words.filter(
)
Это определение встроенной анонимной функции, которая принимает один параметр с именем higherThan и возвращает другую анонимную функцию, принимающую один параметр с именем words и возвращающую List[String], созданный в процессе фильтрации списка words. (word =>
)
Это определение встроенной анонимной функции, которая принимает один параметр с именем word. (word => wordScore(word) > higherThan) Это определение встроенной анонимной функции, которая принимает один параметр с именем word и возвращает логическое значение Boolean.
Как видите, мы использовали точно такой же синтаксис двойной стрелки для определения функции, которая возвращает функцию, возвращающую функцию, которая в свою очередь возвращает функцию. Теперь проверим нашу новую реализацию и посмотрим, как она действует.
Глава 4
I
Функции как значения
143
Использование гибкого API, построенного с использованием возвращаемых функций Действительно ли преобразование функции с тремя параметрами в функцию, принимающую только один параметр и возвращающую функцию, помогает решить нашу проблему? Посмотрим, как можно использовать новую версию highScoringWords. Привет! Я новая версия highScoringWords. Для каждого заданного параметра явозвращаю новую функцию. Передайте мне функцию подсчета баллов, ия верну вам другую функцию! Ей нужно передать порог высоких оценок, ивответ она вернет еще одну функцию. Этой функции нужно передать список слов, иона вернет слова свысокой оценкой.
Вот функция, соответствующая вашему требованию об определении слов свысокой оценкой. Я снова использовал новый прием. Теперь сначала нужно передать алгоритм подсчета баллов, азатем, независимо, порог для определения понятия «высокая оценка».
highScoringWords def highScoringWords( wordScore: String => Int ): Int => List[String] => List[String] = { higherThan => words => words.filter(word => wordScore(word) > higherThan) }
Привет! Приятно познакомиться сновой тобой! Я хотелабы получить списки слов сразными пороговыми значениями высокой оценки по моему выбору. Итак, сначала ядолжна передать функцию подсчета баллов, затем порог высокой оценки итолько потом списки слов. Попробую! Передавая первый аргумент, функцию подсчета баллов, яполучаю функцию, которой все также нужно передать два аргумента, чтобы получить результат. Я могу передать их позже. Хорошо, что эта функция будет «хранить» алгоритм подсчета баллов имне непридется повторять его позже. val wordsWithScoreHigherThan: Int => List[String] => List[String] = highScoringWords(w => score(w) + bonus(w) - penalty(w))
Теперь мне нужно передать порог высокой оценки, чтобы получить следующую функцию. Ее можно сохранить как val, если известно, что будет использоваться только один порог. Однако мы уже знаем, что будет использоваться несколько разных порогов инесколько разных списков слов, поэтому можем применить оба оставшихся аргумента сразу, недавая имени «промежуточной функции». val words = List("ada", "haskell", "scala", "java", "rust") val words2 = List("football", "f1", "hockey", "basketball") wordsWithScoreHigherThan(1)(words) → List("java") wordsWithScoreHigherThan(0)(words2) → List("football", "f1", "hockey", "basketball") wordsWithScoreHigherThan(5)(words2) → List("football", "hockey")
Здесь мы передаем сразу два аргумента двум оставшимся функциям. Внизу есть еще два вызова функций: передавая порог высокой оценки (higherThan), мы получаем в ответ функцию. Мы не сохраняем ее как val, а сразу вызываем со списком слов по нашему выбору. Это только один из способов использования этой функции, но он идеально подходит для данного конкретного случая
Обратите внимание, что, решив добиться абсолютной гибкости, мы все равно могли бы использовать ту же функцию highScoringWords подобно тому, как использовали функцию с тремя параметрами, передавая каждый аргумент в отдельном списке параметров: highScoringWords(w => score(w) + bonus(w) - penalty(w))(1)(words) → List("java")
Пользователь функции сможет сам выбрать, какие параметры зафиксировать (путем сохранения функции в именованном значении val), а какие — нет (путем передачи аргументов сразу после получения функции в той же строке).
144 Часть I
I
Функциональный инструментарий
Использование нескольких списков параметров в функциях Теперь мы знаем, что возврат функций из других функций можно с успехом использовать для решения проблем. Клиенты наших функций получают возможность писать меньше кода, сохраняя большую гибкость. Они могут писать небольшие независимые и узкоспециализированные функции. Они не будут вынуждены дублировать код. Тем не менее есть небольшая проблема, связанная с синтаксисом.
ЭТО ВА Ж Н О! Возвращае мые функции — основа разработки гибких API.
Проблема: непоследовательность в именовании параметров Проблема заключается в непоследовательности синтаксиса определения даже одной функции внутри другой. Он работает нормально, но может немного запутать читателя. В качестве примера рассмотрим функцию highScoringWords: def highScoringWords( wordScore: String => Int ): Int => List[String] => List[String] = { higherThan => words => words.filter(word => wordScore(word) > higherThan) }
В действительности в этом фрагменте кода имеется три списка параметров:
Под списком параметров я подразумеваю ту часть сигнатуры, где определяются имена параметров и их типы.
• wordScore: String => Int; • higherThan: Int; • words: List[String]. Однако они записываются совершенно по-другому. Первый записывается как обычно, а остальные — с использованием синтаксиса двойной стрелки, с помощью которого мы определяли встроенные анонимные функции. К счастью, есть возможность применять более последовательный синтаксис!
Решение: несколько списков параметров В Scala и других функциональных языках можно использовать синтаксис, позволяющий указывать несколько списков параметров. В нашем случае нам нужно три.
Обратите внимание, что первый список параметров включает параметр, явля ющийся функцией, а второй и третий — это параметры простых типов.
def highScoringWords(wordScore: String => Int): Int => List[String] => List[String] = { higherThan => words => Оба определения highScoringWords равнозначны: words.filter(word => wordScore(word) > higherThan) они определяют функцию, принимающую другую }
функцию в параметре и возвращающую функцию, которая принимает второй параметр и возвращает функцию, просто записаны они с использованием разного синтаксиса.
def highScoringWords(wordScore: String => Int)(higherThan: Int)(words: List[String]): List[String] = { words.filter(word => wordScore(word) > higherThan) }
Глава 4
I
Функции как значения
У нас есть карринг! Для решения двух последних проблем с highScoringWords мы смогли использовать один и тот же прием: преобразовали функцию с несколькими параметрами в функцию с одним параметром, которая возвращает другую функцию с одним параметром. Этот прием называется каррированием (currying).
145 Название «каррирование» выбрано в честь логика Хаскелла Карри (Haskell Curry). Это тот же человек, в честь которого был назван язык программирования.
Каррирование Преобразование функций с несколькими параметрами в серию функций с одним параметром, возвращаемых друг из друга, называется каррированием (currying). Каррирование позволяет создавать очень гибкие API. Клиентский код получает возможность передавать каждый параметр по-разному, наиболее подходящим способом! Это помогает избежать дублирования и сделать код более читабельным и удобным для сопровождения, поскольку мы можем не перегружать читателя различными параметрами одновременно. def f(a: A, b: B, c: C): D Функция с несколькими параметрами без каррирования, принимающая три параметра и возвращающая значение типа D def f(a: A): B => C => D использование синтаксиса с возвратом функций
Каррированная функция с одним параметром, принимающая один параметр и возвращающая другую функцию с одним параметром, которая, в свою очередь, возвращает функцию с одним параметром, возвращающую значение типа D
def f(a: A)(b: B)(c: C): D Каррированная функция с одним параметром, принимающая один параметр и возвращающая использование синтаксиса другую функцию с одним параметром, которая, со списками нескольких в свою очередь, возвращает функцию с одним параметров параметром, возвращающую значение типа D
Поэтому highScoringWords стала каррированной, когда мы преобразовали ее в функцию с одним параметром, возвращающую другую функцию. Это позволило нам обеспечить возможность передачи каждого из трех аргументов независимо друг от друга! Но нам все еще нужно было определить, какой параметр будет первым, вторым и т. д. Этот порядок очень важен и должен основываться на требованиях.
Они идентичны сточки зрения использования. Единственное различие состоит втом, что всинтаксисе свозвратом функций мы указываем только типы значений, ав функциях со списками нескольких параметров— имена всех значений В языке Haskell, созданном Хаскеллом Карри, все функции каррированы. То есть все функции в Haskell принимают только один аргумент.
Алгоритм подсчета баллов
Порог высокой оценки
Список слов
Алгоритм подсчета баллов задается в начале при выборе режима игры.
Порог высокой оценки выбирается позже в процессе работы программы, когда выбирается настройка сложности (поэтому данный параметр следует после алгоритма подсчета баллов).
Список слов динамично меняется в каждом раунде игры, когда игрок вводит новые списки слов.
def highScoringWords(wordScore: String => Int)(higherThan: Int)(words: List[String]): List[String] = { words.filter(word => wordScore(word) > higherThan) }
146 Часть I
I
Функциональный инструментарий
Практика каррирования Вернемся к функциям, которые мы написали в предыдущем упражнении. Преобразуйте их в каррированные версии. Это упражнение должно помочь вам развить мышечную память. И снова используйте для экспериментов Scala REPL, чтобы по-настоящему понять, что такое каррирование.
Ваши задачи Верните новый список со всеми числами больше 4 (передайте 4 в виде аргумента). входные данные: List(5, 1, 2, 4, 0)
результат: List(5)
Верните новый список, содержащий числа, кратные 5 (передайте 5 в виде аргумента). входные данные: List(5, 1, 2, 4, 15)
результат: List(5, 15)
Верните слова короче четырех символов (передайте 4 в виде аргумента). входные данные: List("scala", "ada")
результат: List("ada")
Верните слова, в которых есть более двух букв 's' (передайте 2 в виде аргумента). входные данные: List("rust", "ada")
1
результат: List()
Ответы def largerThan(n: Int)(i: Int): Boolean = i > n → largerThan List(5, 1, 2, 4, 0).filter(largerThan(4)) → List(5) def divisibleBy(n: Int)(i: Int): Boolean = i % n == 0 → divisibleBy List(5, 1, 2, 4, 15).filter(divisibleBy(5)) → List(5, 15) def shorterThan(n: Int)(s: String): Boolean = s.length < n → shorterThan
2 3 4 1 2 3
List("scala", "ada").filter(shorterThan(4)) → List("ada") def numberOfS(s: String): Int = s.length - s.replaceAll("s", "").length → numberOfS def containsS(moreThan: Int)(s: String): Boolean = numberOfS(s) > moreThan → containsS List("rust", "ada").filter(containsS(2)) → List()
4
Глава 4
I
Функции как значения
Программирование с передачей функций в виде значений Теперь вернемся к гипотетическому разговору между человеком, запрашивающим новые возможности, и человеком, беззаботно реализующим их, передавая функции в виде параметров (которые являются простыми значениями, такими же, как Int и List, верно?). Нам нужно ранжировать слова по их оценкам.
Легко! Я просто передам функцию вsortBy!
Хорошо, теперь нам нужны вознаграждения иштрафы. Я просто передам вsortBy другую функцию! Теперь нам нужно получить исходные оценки для статистических целей. Верните нам только слова свысокой оценкой. Хорошо, но теперь нам нужна совокупная оценка всех заданных слов...
Я просто передам функцию оценки вmap! Я передам функцию оценки вfilter! Я уверен, что должен передать функцию... куда-то...
У нас появилось еще одно требование! На этот раз мы должны вернуть совокупную оценку всех заданных слов и, как всегда, сделать это в чисто функциональной манере: передавая неизменяемые значения (включая функции) в чистые функции. Это поможет нам с самого начала предотвратить появление множества проблем! Функции высшего порядка Прежде чем перейти к замене другого цикла for однострочной функцией, позвольте мне подчеркнуть один очень важный момент: прием передачи функций в параметрах другим функциям и возврата функций из функций широко распространен в функциональном коде. Он распространен настолько, что получил собственное название: функция, принимающая или возвращающая другую функцию, называется функцией высшего порядка. Функции sortBy, map и filter — лишь отдельные примеры подобных функций, имеющихся в стандартной библиотеке, и далее мы встретим многие другие функции высшего порядка и даже напишем свои! Второй важный момент: мы будем применять map, filter и другие функции высшего порядка не только к коллекциям, но и к другим типам. Мы разберем их однажды и будем использовать постоянно.
147
148 Часть I
I
Функциональный инструментарий
Свертка множества значений в одно Рассмотрим наше новое требование более подробно. Новое требование: вернуть совокупный балл • Вернуть совокупную оценку слов во входном списке. • Все, что реализовано до сих пор, должно работать без изменений (мы не можем изменить ни одну существующую функцию).
Короткое упражнение: сигнатура В функциональном программировании сигнатура несет очень важную информацию. Вот почему мы всегда начинаем разработку новых функций с определения их сигнатур. Это очень полезный подход, помогающий направить процесс мышления. Вот почему я предлагаю выполнить следующее небольшое упражнение (снова!). Прежде чем двинуться дальше, я прошу вас подумать над новым требованием и представить, как будет выглядеть сигнатура функции, реализующей его.
Ответ вы найдете в обсуждении.
Для справки: императивное решение на Java Прежде чем реализовать функциональное решение и раскрыть тайну сигнатуры функции, посмотрим, как выглядит императивное решение на Java и какие проблемы оно имеет. static int cumulativeScore( Function wordScore, List words ) { int result = 0; for (String word : words) { result += wordScore.apply(word); } return result; }
Создается новое изменяемое целое и инициализируется 0 С помощью цикла for к каждому элементу применяется функция. Результат функции прибавляется к изменяемому целому, созданному вначале
В этом коде наблюдаются уже знакомые нам проблемы: • он использует изменяемые коллекции (List); • он недостаточно лаконичен. Интуитивно понятно, что решение получилось слишком объемным для такой маленькой задачи.
Сигнатура на Scala Вот как может выглядеть сигнатура функции на языке Scala: def cumulativeScore(wordScore: String => Int, words: List[String]): Int
Не забывайте, что, несмотря на сходство с версией на Java, здесь используется неизменяемый список!
Глава 4
I
Функции как значения
149
Свертка множества значений в одно с помощью foldLeft Вы не раз видели, как быстро мы можем реализовать все требования. Мы использовали sortBy, map и filter — функции класса List, которые принимают другие функции в параметрах, используют их и возвращают новые неизменяемые значения. Это новое требование тоже можно реализовать, используя тот же способ. Нужная нам функция называется foldLeft. Вот возможная реализация нового требования на Scala с использованием неизменяемого списка List и функции foldLeft: def cumulativeScore(wordScore: String => Int, words: List[String]): Int = { words.foldLeft(0)((total, word) => total + wordScore(word)) }
Мы передаем функцию, прибавляющую текущий элемент к текущей сумме, которая изначально равна 0
Да, foldLeft немного отличается от рассмотренных нами ранее стандартных функций, но общее правило остается прежним. Она извлекает каждый элемент из списка, применяет к нему переданную функцию и текущую сумму, а затем переходит к следующему элементу. Она тоже ничего не меняет.
Сигнатура wordScore — это функция, принимающая String и возвращающая Int def cumulativeScore(wordScore: String => Int, words: List[String]): Int words — неизменяемый
список строк
Функция возвращает Int
Тело Тело функции состоит из единственного вызова foldLeft, в который передается Int и функция, принимающая два параметра и возвращающая Int. List(
1
rust java
foldLeft вызывает заданную функцию для каждого элемента 0 + wordScore( rust ) = -3
первоначально сумма равна 0
2
Предполагается, что передаваемая функция wordScore включает начисление дополнительных и штрафных баллов.
foldLeft возвращает единственное значение: результат вызова для предыдущего элемента (java) 3
сумма становится равной –3 -3 + wordScore(
).foldLeft(0)((total, word) => total + wordScore(word)) Анонимная функция, принимающая два параметра: сумму total типа Int и слово word из списка; и вычисляющая оценку данного слова и прибавляющая ее к сумме total
java )
= -1
Эта анонимная функция вызывается для каждого элемента списка (в данном случае дважды). total передается из вызова в вызов (причем первоначально total = 0).
150 Часть I
I
Функциональный инструментарий
Знакомство с foldLeft foldLeft накапливает значение, просматривая все элементы списка
и вызывая переданную ей функцию. Накапливаемое значение называется аккумулятором. Его начальное значение передается в foldLeft в первом параметре. В нашем случае это 0, то есть наш аккумулятор имеет тип Int и начальное значение 0: words.foldLeft(0)
Учитывая, что words — это список строк, предыдущий вызов вернет функцию, которая принимает функцию с двумя параметрами, Int и String, возвращающую Int: words.foldLeft(0)((total, word) => total + wordScore(word))
Внутри foldLeft вызывает переданную функцию для каждого элемента из списка words (передавая его как word) и накапливает сумму в total. Для первого элемента функция вызывается с начальным значением аккумулятора (в нашем случае — 0). Результат вызова функции для последнего элемента становится результатом foldLeft. List(
Начало
total + wordScore(word) 1
ada
0 + wordScore(
ada
haskell
1 + wordScore(
haskell
scala
0 + wordScore(
scala
java
1 + wordScore(
java
)
rust
3 + wordScore(
rust
–3 )
)
= 1 –1 ) = 0
)
1
2
= 1 = 3 = 0
foldLeft перебирает элементы и применяет к каждому заданную функцию. Значение аккумулятора передается в следующий вызов функции (применяемый к следующему элементу).
).foldLeft(0)((total, word) => total + wordScore(word)) = 0 List[A].foldLeft(z: B)(f: (B, A) => B): B
Накапливает значение типа B, применяя переданную функцию f к каждому элементу исходного списка (содержащему значения типа A) и текущее значение аккумулятора, который первоначально получает значение z. Каждый параметр находится в своем списке параметров, а значит, можно использовать каррирование. Возвращается только одно значение. List( → 1
Мы используем синтаксис с двойной стрелкой, чтобы создать встроенную анонимную функцию. Однако на этот раз мы создаем функцию с двумя параметрами, а не с одним. Как видите, сам синтаксис не изменился. Мы просто передаем имена аргументов в круглых скобках
).foldLeft(0)(
=> incrementIfGrey)
Глава 4
I
Функции как значения
151
Каждый должен знать и уметь пользоваться foldLeft Функция foldLeft может вызвать проблемы, поэтому рассмотрим некоторые ее интересные особенности, прежде чем переходить к упражнениям с ней. Следующее описание должно дать вам совершенно необходимое представление о том, что происходит «за кулисами».
Почему в имени присутствует слово left Дело в том, что вычисления выполняются в направлении слева направо. Функция foldLeft начинает обход с крайнего левого элемента и объ единяет его с начальным значением, после чего переходит к следующему. words.foldLeft(0)((total, word) => total + wordScore(word)) words.foldLeft(0)((total, word) => total + wordScore(word)) List(
ada
haskell
scala
java
rust
total + wordScore("ada") 0 + 1 = 1 total + wordScore("haskell") 1 + (-1) = 0 total + wordScore("scala") 0 + 1 = 1 Слово left (слева) в имени foldLeft подсказывает, что накопление аккумулятора начинается с оценки первого (левого) элемента списка.
total + wordScore("java") 1 + 2 = 3 total + wordScore("rust") 3 + (-3) = 0
Ее можно использовать только для подсчета суммы целых чисел? Нет, не только для подсчета сумм! Функцию foldLeft можно использовать везде, где нужно получить одно значение на основе произвольного количества значений. Суммирование — всего лишь один из классических примеров, но вы можете (и будете) вычислять другие значения, такие как минимум, максимум, ближайшее к некоторому заданному значению и т. д. Более того, как мы увидим далее, foldLeft может применяться к типам, не являющимся коллекциями. Вдобавок мы увидим, что начальным значением может быть коллекция!
Напоминает функцию reduce из Java Streams! Выше в этой главе упоминалось, что все функциональные возможности можно реализовать с помощью Java Streams. Этот интерфейс лучше императивных аналогов, но и в нем остаются некоторые проблемы. Если вы знакомы с Java Streams, то, вероятно, это знакомство вам очень помогло. С другой стороны, если вы не знакомы с Java Streams, то вам должно быть намного легче сейчас, после прочтения этой главы.
То, что мы увидим далее в книге, действительно ошеломляет: если в качестве начального значения передать пустой список List, то foldLeft вернет другой список List! В этом сила функционального подхода. Вы можете повторно использовать знания в разных контекстах!
152 Часть I
I
Функциональный инструментарий
Практика foldLeft Пришло время попробовать написать свои функции и передать их в foldLeft. И снова используйте для экспериментов Scala REPL, чтобы лучше закрепить обретенные знания.
Ваши задачи
1
Верните сумму всех целых чисел в заданном списке. входные данные: List(5, 1, 2, 4, 100) результат: 112
2
Верните общую длину всех слов в заданном списке. входные данные: List("scala", "rust", "ada") результат: 12
Верните количество букв 's', найденных во всех словах в заданном списке. входные данные: List("scala", "haskell", "rust", "ada") результат: 3
Верните максимальное из всех целых чисел в заданном списке. входные данные: List(5, 1, 2, 4, 15) результат: 15
Ответы
3 4 1
List(5, 1, 2, 4, 100).foldLeft(0)((sum, i) => sum + i) → 112 def len(s: String): Int = s.length → len
2
List("scala", "rust", "ada") .foldLeft(0)((total, s) => total + len(s)) → 12
3
def numberOfS(s: String): Int = s.length - s.replaceAll("s", "").length → numberOfS List("scala", "haskell", "rust", "ada") .foldLeft(0)((total, str) => total + numberOfS(str)) → 3 List(5, 1, 2, 4, 15) .foldLeft(Int.MinValue)((max, i) => if (i > max) i else max) → 15 Начальное значение должно быть нейтральным в контексте операции. Здесь нельзя использовать 0, поскольку список может содержать только отрицательные значения, и тогда 0 будет неправильным ответом
4
Здесь также можно использовать Math.max(max, i)
Глава 4
I
Функции как значения
153
Моделирование неизменяемых данных До сих пор мы применяли map, filter и foldLeft к спискам строк и целых чисел. Обычно этого достаточно для ознакомления, но в реальной жизни приходится использовать более сложные модели данных. Поскольку эта книга посвящена практичности и удобству сопровождения кода в промышленном окружении, мы должны рассмотреть еще одно обстоятельство, чтобы с уверенностью сказать, что наш функциональный инструментарий полностью укомплектован.
Соединение двух частей информации вместе Если сущность представлена типом String, то наши функции высшего порядка могут применяться к ней как есть. Но иногда сущность состоит из двух или более элементов информации. Например, если бы мы решили представить язык программирования его названием и годом появления, то нас ожидали бы проблемы. Функциональные языки предоставляют специальную языковую конструкцию для определения составных неизменяемых значений, которые могут содержать несколько значений разных типов. В ФП такой тип называется типом-произведением и в Scala определяется как case class. В нашем примере мы представим язык программирования его названием и годом появления. Это означает, что мы объединим два фрагмента информации: String и Int. case class ProgrammingLanguage(name: String, year: Int)
Вот и все! Мы только что определили новый тип. Но как им пользоваться? Попробуем вместе. Обязательно повторите у себя следующий сеанс REPL.
Не забывайте, что map, filter и foldLeft — это функции, принимающие другие функции в параметрах. Наряду с функциями, возвращающими функции, они называются функциями высшего порядка. В Kotlin такие типы определяются как data class. Мы обсудим причины, почему такие типы называют типами-произведениями, в главе 7, а пока просто будем считать, что так называют типы, представленные комбинацией других типов в фиксированном порядке.
case class ProgrammingLanguage(name: String, year: Int) → defined case class ProgrammingLanguage Для начала создадим два значения типа ProgrammingLanguage, указав для каждого оба значения val javalang = ProgrammingLanguage("Java", 1995) (String и Int), из которых они → javalang: ProgrammingLanguage состоят. Обратите внимание, что val scalalang = ProgrammingLanguage("Scala", 2004) нам не нужно использовать клю→ scalalang: ProgrammingLanguage чевое слово new, как в Java. Мы можем использовать точечный синтаксис для доступа к внутренним «полям». Эта инструкция возвращает строку Java javalang.name → Java Поскольку name возвращает строку, мы можем использовать javalang.year это поле как обычную строку (например, получить ее длину) → 1995 scalalang.name.length Наконец, поскольку year возвращает Int, мы → 5 можем использовать это поле как обычное (scalalang.year + javalang.year) / 2 целое число (например, для вычисления → 1999 среднего года появления для обоих языков, если в этом будет хоть какой-то смысл).
154 Часть I
I
Функциональный инструментарий
Использование типов-произведений с функциями высшего порядка Оказывается, типы-произведения идеально подходят для моделирования данных. Вдобавок они хорошо работают с функциями высшего порядка.
Типы-произведения — неизменяемые Первое большое преимущество: типы-произведения — неизменяемые. Значение типа-произведения после создания останется неизменным до конца света (или до завершения программы). Вот почему можно обращаться к любым полям case class, но нельзя присвоить им другие значения. val javalang = ProgrammingLanguage("Java", 1995) → javalang: ProgrammingLanguage javalang.year = 2021 → error: reassignment to val javalang.year → 1995
Получение списка имен с помощью map Типы-произведения и их неизменяемость используются в функциях высшего порядка для создания более надежного кода и больших чистых функций. Если у вас есть список значений ProgrammingLanguage и вы хотите вернуть только список названий: val javalang val scalalang
= ProgrammingLanguage("Java", 1995) = ProgrammingLanguage("Scala", 2004)
val languages = List(javalang, scalalang) → List(ProgrammingLanguage("Java", 1995), ProgrammingLanguage("Scala", 2004)) languages.map(lang => lang.name) → List("Java", "Scala")
map получает функцию, которая принимает ProgrammingLanguage и возвращает String
Выбор наиболее молодых языков с помощью filter Аналогично можно использовать filter. Просто передадим функцию, принимающую ProgrammingLanguage и возвращающую логическое значение, — наше решение о необходимости включения данного экземпляра ProgrammingLanguage в итоговый список. Обратите внимание, что filter возвращает список List(ProgrammingLanguages): languages.filter(lang => lang.year > 2000) → List(ProgrammingLanguage("Scala", 2004)) filter получает функцию, которая принимает ProgrammingLanguage и возвращает логическое значение Boolean. Мы написали эту функцию, используя синтаксис с двойной стрелкой, и передаем ее, встроив определение в вызов
Глава 4
I
Функции как значения
155
Более лаконичный синтаксис встроенных функций Как мы только что видели, типы-произведения и функции высшего порядка очень хорошо сочетаются друг с другом. Они лежат в основе всех функциональных приложений, которые используются в промышленных окружениях и которые мы напишем в этой книге. Они настолько распространены, что в Scala (а также в некоторые другие языки) был добавлен специальный синтаксис для определения встраиваемых функций, принимающих значение case-класса, которые передаются в map, filter и другие функции.
Использование map и filter с синтаксисом подчеркивания Вернемся к предыдущему примеру, где мы использовали map и filter. Обратите внимание, как много повторяющегося кода приходится писать при определении и передаче функций в map и filter: Нам нужно получить наval javalang val scalalang
= ProgrammingLanguage("Java", 1995) = ProgrammingLanguage("Scala", 2004)
val languages = List(javalang, scalalang) → List(ProgrammingLanguage("Java", 1995), ProgrammingLanguage("Scala", 2004))
звание, но бо́льшая часть кода просто присваивает имя lang экземпляру ProgrammingLanguage, а затем использует его для доступа к полю name
Здесь то же самое. Нам нужно получить год, но бо́льшая часть кода просто присваивает имя lang экземпляру ProgrammingLanguage, а затем использует его для доступа к полю year
languages.map(lang => lang.name) → List("Java", "Scala") languages.filter(lang => lang.year > 2000) → List(ProgrammingLanguage("Scala", 2004))
Чтобы избавиться от повторяющегося кода, можно использовать синтаксис подчеркивания:
lang => lang.name
превращается в _.name
lang => lang.year > 2000 превращается в _.year > 2000
На самом деле совсем не обязательно давать имя входному параметру и можно просто показать, что нас интересует только одно из его полей. Здесь подчеркивание — это просто значение типа ProgrammingLanguage, но оно не имеет явного имени.
Обратите внимание, что использование синтаксиса подчеркивания — это всего лишь еще один способ определения функции! Мы все так же передаем функции в map и filter, но в более лаконичной форме. languages.map(_.name) → List("Java", "Scala") languages.filter(_.year > 2000) → List(ProgrammingLanguage("Scala", 2004))
Не забывайте, что значок в фрагментах кода означает необходимость запустить сеанс Scala REPL и опробовать предложенный далее код в этом сеансе. Вы опробовали примеры с языками программирования?
156 Часть I
I
Функциональный инструментарий
Резюме
КОД : CH04_*
Вот и все! Теперь вы знаете, что функции особенно важны в функцио нальном программировании (сюрприз!). Подытожим все, что вы узнали в этой главе.
Передача функций в параметрах Сначала вы познакомились с сортировкой и узнали, как она реализуется в Java. Функция sort принимает объект Comparator, содержащий алгоритм, который сравнивает два элемента. Оказалось, что этот метод широко распространен в Scala и других функциональных языках, где функции можно передавать напрямую, используя синтаксис с двойной стрелкой.
Функция sortBy Затем мы отсортировали список List в Scala с помощью функции sortBy, которой передали функцию сравнения в виде аргумента. При этом мы не изменили никаких значений.
Функции map и filter
Код примеров из этой главы доступен в файлах ch04_*в репозитории книги. Функции, принимающие другие функции в параметрах, называются функциями высшего порядка. sortBy — функция высшего порядка.
Мы еще не раз будем возвращаться ко многим из этих функций на протяжении всей книги, поэтому убедитесь, что грокнули их!
Далее в этой главе мы использовали точно такой же метод для преобразования списков с помощью функций map и filter (и конечно же, передавали им свою функцию в виде параметра!). Их использование очень похоже на использование их версий из Java Streams, но в Scala и других языках ФП списки неизменяемы по умолчанию, что делает код более надежным и удобным в сопровождении.
Возврат функций из функций Затем мы выяснили, что некоторые функции могут возвращать функции. Мы можем использовать эту возможность для настройки функций на основе входящих параметров. Существует также специальный синтаксис определения нескольких списков параметров, делающий код более читабельным за счет включения всех параметров и их типов в сигнатуру — это называется каррированием.
Функция foldLeft Разобрав все, что перечислено выше, мы познакомились с функцией foldLeft и использовали ее. Эта функция способна свернуть список значений до одного значения. В качестве примера можно привести суммирование, но этим действием круг возможных алгоритмов не ограничивается.
Моделирование неизменяемых данных с использованием типа-произведения В конце главы были представлены типы-произведения как способ объединения нескольких фрагментов информации. Мы выяснили, что такие типы с успехом могут обрабатываться функциями высшего порядка, поскольку они неизменяемы.
map и filter — функции высшего порядка.
Функции, принимающие другие функции в параметрах и/ или возвращающие функции, называются функциями высшего порядка. foldLeft — функция высшего порядка. Она каррирована и имеет два списка параметров. Для реализации типов-произведений в Scala используется объявление case class.
Часть II Функциональные программы
Теперь у нас есть основа для создания настоящих функциональных программ. В следующих главах мы будем использовать только неизменяемые значения и чистые функции, включая функции высшего порядка. В главе 5 вы познакомитесь с самой важной функцией высшего порядка в ФП: flatMap. Она помогает создавать последовательные значения (и программы) в краткой и удобочитаемой форме. В главе 6 мы разберем, как создавать последовательные программы, способные возвращать ошибки, и как защитить себя от различных критических ситуаций. В главе 7 вы близко познакомитесь с миром функционального дизайна. Мы будем моделировать данные и параметры функций так, чтобы исключить множество недопустимых (с точки зрения бизнеса) экземпляров и аргументов. В главе 8 вы научитесь обращаться с нечистым внешним миром и побочными эффектами безопасным и функциональным способом. Мы выполним множество операций ввода-вывода (смоделировав вызовы внешней базы данных или сервиса), в том числе и неудачные. Знание особенностей обработки ошибок, моделирования данных и поведения, а также использования ввода-вывода поможет нам представить потоки данных и потоковые системы в главе 9. Мы создадим потоки данных, состоящие из сотен тысяч элементов, используя функциональный подход. В главе 10 мы наконец создадим несколько функциональных и безопасных параллельных программ. Убедимся, что все методы, представленные в предыдущих главах, по-прежнему применимы даже при наличии нескольких потоков выполнения.
Последовательные программы
В этой главе вы узнаете: • как работать со списками списков с помощью flatten; • как писать последовательные программы, используя flatMap вместо циклов for; • как писать последовательные программы в удобочитаемом виде, используя for-выражения; • как использовать условия внутри for-выражений; • как выявить другие типы, поддерживающие flatMap.
Внутри каждой большой программы есть маленькая программа, пытающаяся выбраться наружу. Тони Хоар (Tony Hoare), Efficient Production of Large Programs
5
Глава 5
I
Последовательные программы
159
Написание конвейерных алгоритмов Одним из самых популярных шаблонов проектирования в современных языках программирования является конвейерная обработка (pipelining). Мы можем записать множество вычислений в виде конвейера — последовательности операций, которые все вместе представляют большую и более сложную операцию. Это другой взгляд на создание последовательных программ. Рассмотрим пример. У нас есть список из трех интересных книг: case class Book(title: String, authors: List[String]) val books = List( Book("FP in Scala", List("Chiusano", "Bjarnason")), Book("The Hobbit", List("Tolkien")), Book("Modern Java in Action", List("Urma", "Fusco", "Mycroft")) )
Наша задача — подсчитать, сколько из них имеют в названии слово Scala. Будучи знакомыми с map и filter, мы можем написать такое решение: books .map(_.title) .filter(_.contains("Scala")) .size → 1
Напомню, что этот код эквивалентен следующему: books .map(book => book.title) .filter(title => title.contains("Scala")) .size
.map
Названия книг List[String]
Названия книг List[String]
.filter
Названия книг со словом Scala List[String]
Названия книг со словом Scala List[String]
.size
Книги List[Book]
Книги
.map
.filter
В этой главе мы поговорим о последовательных алгоритмах и программах. Последовательная программа — это фрагмент кода, представля ющий логику, которая принимает значение, пошагово (последовательно) преобразует его и возвращает окончательное значение
Количество книг со словом Scala в названии Int .size
Количество книг со словом Scala в названии Int
Мы только что создали конвейер! Он состоит из трех этапов, каждый из которых имеет вход и выход. Их можно соединить, только если выход одного имеет тот же тип, что и вход следующего. Например, этап map выводит List[String]. Следовательно, его выход можно подключить к любому этапу, принимающему List[String] на входе. В примере выше мы соединили три этапа и сформировали конвейер, который реализует алгоритм, решающий исходную задачу. Это пошаговый алгоритм, или последовательная программа, созданный с использованием map и filter.
Это очень естественный метод программирования, который иногда называют цепочкой вызовов. Если для вас этот прием выглядит естественным, то у вас не должно возникнуть проблем с пониманием содержимого данной главы. Если нет, то я помогу вам обрести это понимание!
160 Часть II
I
Функциональные программы
Составление больших программ из мелких деталей Конвейеры можно конструировать из небольших фрагментов кода, пригодных для компоновки и повторного использования. Соответственно, мы можем разделить большую задачу на мелкие части, решить каждую из них по отдельности, а затем собрать конвейер для решения исходной задачи.
Рекомендация экранизаций книг Рассмотрим нашу первую цель. И снова у нас есть список книг: case class Book(title: String, authors: List[String]) val books = List( Book("FP in Scala", List("Chiusano", "Bjarnason")), Book("The Hobbit", List("Tolkien")) )
Этот подход называется «разделяй и властвуй» и был Святым Граалем для целых поколений программистов. Функциональные программисты стараются использовать его везде, где только возможно
У нас также есть функция, возвращающая список экранизаций книг (Movies) по имени автора (на данный момент поддерживается лишь Толкин): case class Movie(title: String) def bookAdaptations(author: String): List[Movie] = if (author == "Tolkien") List(Movie("An Unexpected Journey"), Movie("The Desolation of Smaug")) else List.empty
Наша задача — вернуть последовательность рекомендуемых фильмов на основе книг. Для данных выше мы должны вернуть список с двумя элементами в ленте: def recommendationFeed(books: List[Book]) = ??? recommendationFeed(books) → List("You may like An Unexpected Journey, because you liked Tolkien's The Hobbit", "You may like The Desolation of Smaug, because you liked Tolkien's The Hobbit")
На псевдокоде желаемое можно выразить так. Шаг 1 Для каждой книги
извлечь имя автора.
вызвать функцию bookAdaptations, Шаг 2 Для каждого автора чтобы получить список фильмов. сконструировать строковую Шаг 3 Для каждого фильма ленту рекомендаций.
В первой части главы мы реализуем этот алгоритм в виде чисто функционального конвейера — последовательной программы.
bookAdaptations Функция, принимающая имя автора и возвращающая список фильмов (экранизаций их книг). Для автора "Tolkien" возвращает два фильма и ничего для любых других авторов.
??? в Scala означает «отсутствие реализации». Эта комбинация используется, чтобы оставить реализацию некоторых частей на потом. Она является частью синтаксиса языка, компилируется без ошибок, но вызывает исключение во время выполнения
Глава 5
I
Последовательные программы
161
Императивный подход Шаг 1 Для каждой книги
извлечь имя автора.
вызвать функцию bookAdaptations, Шаг 2 Для каждого автора чтобы получить список фильмов. сконструировать строковую Шаг 3 Для каждого фильма ленту рекомендаций.
Ленту рекомендаций можно представить в виде трех вложенных циклов for. static List recommendationFeed(List books) { List result = new ArrayList(); 1 for (Book book : books) for (String author : book.authors) for (Movie movie : bookAdaptations(author)) { 2 result.add(String.format( 3 "You may like %s, because you liked %s's %s", movie.title, author, book.title)); } return result; 1 }
bookAdaptations Функция, принимающая имя автора и возвращающая список фильмов (экранизаций их книг). Для автора "Tolkien" возвращает два фильма и ничего для любых других авторов.
Если у вас есть опыт программирования на Java или любом другом современном императивном языке, то вы должны довольно уверенно читать этот код. Сначала он создает изменяемый список, а затем перебирает три коллекции во вложенных циклах for. В теле цикла for в изменяемый список добавляются новые элементы String. Последняя строка кода возвращает этот список в качестве результата.
Императивное решение задачи Решение выше имеет три проблемы. 1 Использование и возврат изменяемого списка
2 Каждый цикл for добавляет еще один уровень отступов
Операции чтения и обновления списка разбросаны по всей функции — в первой и последней строках, а также внутри тела for. О таком коде трудно рассуждать. Чем больше функция, тем острее становится данная проблема. Мы уже обсуждали это в главе 3.
Чем больше уровней вложенности в коде, тем сложнее его читать. Вложенные циклы с локальными переменными имеют отступ вправо, поэтому добавление дополнительных условий обычно означает, что программисту нужно все дальше смещать взгляд.
3 Использование инструкций вместо выражений в теле for
Вложенный цикл for ничего не возвращает, поэтому мы должны использовать инструкции с побочными эффектами в его теле, чтобы решить исходную задачу. Вот почему пришлось использовать изменяемый список. Кроме того, использование инструкций затрудняет тестирование кода.
Мы вернемся к проблеме выражений и инструкций ниже в этой главе
162 Часть II
I
Функциональные программы
flatten и flatMap Шаг 1 Для каждой книги
извлечь имя автора.
вызвать функцию bookAdaptations, Шаг 2 Для каждого автора чтобы получить список фильмов. сконструировать строковую Шаг 3 Для каждого фильма ленту рекомендаций.
Попробуем решить задачу, используя парадигму функционального программирования, шаг за шагом. Начнем с извлечения авторов (шаг 1 на диаграмме выше). Вот наш список из двух книг:
Обратите внимание, что у книги может быть несколько авторов
case class Book(title: String, authors: List[String]) val books = List( Book("FP in Scala", List("Chiusano", "Bjarnason")), Book("The Hobbit", List("Tolkien")) )
Теперь получим список всех авторов этих книг:
books
books.map(_.authors) // или: books.map(book => book.authors) → List(List("Chiusano", "Bjarnason"), List("Tolkien"))
Это не совсем то, что нам нужно. Вместо списка всех авторов мы получили список списков авторов. Если внимательно посмотреть на функцию, переданную в вызов функции map, то станет ясно, что произошло: book.authors — это List[String], поэтому мы отобразили каждый элемент списка в новый список, получив список списков. Тайна раскрыта!
Cписок из двух книг:
• FP in Scala
Кьюзано и Бьярнасона;
• The Hobbit Толкина.
К счастью, мы можем избежать создания обертывающего списка, используя функцию flatten, которая преобразует список списков в простой, или плоский (flatten), список. List(
List( List(
Chiusano
List(
Tolkien
).flatten
Bjarnason
Chiusano Bjarnason
flatten перебирает элементы списка списков и извлекает сначала все элементы из первого списка, затем из второго и т. д.
Tolkien
books.map(_.authors).flatten // или: books.flatMap(book => book.authors) → List("Chiusano", "Bjarnason", "Tolkien")
Сценарий применения map с использованием функции, возвращающей экземпляр отображаемого типа, и последующим вызовом flatten настолько распространен, что все функциональные языки предоставляют функцию, которая выполняет оба шага одновременно. В Scala эта функция называется flatMap. Вооружившись этим знанием, мы можем теперь получить список всех авторов с еще меньшим количеством кода:
Видите разницу между map и flatMap?
books.flatMap(_.authors) // или: books.map(book => book.authors).flatten → List("Chiusano", "Bjarnason", "Tolkien")
Глава 5
I
Последовательные программы
163
Практические примеры использования flatMap Шаг 1 Для каждой книги
извлечь имя автора.
вызвать функцию bookAdaptations, Шаг 2 Для каждого автора чтобы получить список фильмов. сконструировать строковую Шаг 3 Для каждого фильма ленту рекомендаций.
Мы реализовали первый этап нашего конвейера, и теперь у нас есть список авторов. На втором шаге этот список нужно преобразовать в список фильмов — экранизаций книг этих авторов. К счастью, мы можем использовать функцию bookAdaptations. val authors = List("Chiusano", "Bjarnason", "Tolkien") authors.map(bookAdaptations) → List(List.empty, List.empty, List(Movie("An Unexpected Journey"), Movie("The Desolation of Smaug")))
Мы снова получили список списков, но теперь знаем, что делать: authors.map(bookAdaptations).flatten → List(Movie("An Unexpected Journey"), Movie("The Desolation of Smaug"))
И если мы объединим оба шага в конвейер (получающий список книг, а не авторов), то получим:
FP in Scala
❷
Bjarnason
List.empty List.empty
Bjarnason
)
Заданная функция применяется к обеим книгам, в результате чего получается два списка, которые последовательно объединяются в один список большего размера.
Tolkien ) List( Unexpected Journey Desolation of Smaug )
List(
Unexpected Journey
Список из двух книг: и Бьярнасона;
List( Tolkien )
Chiusano
books
Первый вызов flatMap
List( Chiusano List(
Для автора "Tolkien" возвращает два фильма и ничего для любых других авторов.
• The Hobbit Толкина.
Hobbit )
❶
Функция, принимающая имя автора и возвращающая список фильмов (экранизаций их книг)
• FP in Scala Кьюзано
books .flatMap(_.authors) ❶ .flatMap(bookAdaptations) ❷ → List(Movie("An Unexpected Journey"), Movie("The Desolation of Smaug")) List(
bookAdaptations
Desolation of Smaug )
Второй вызов flatMap Заданная функция применяется к каждому элементу, создавая три списка: два пустых и один с двумя фильмами. Они объеди няются, и получается конечный результат.
164 Часть II
I
Функциональные программы
flatMap и изменение размера списка Посмотрим, что на самом деле произошло в последнем примере.
List(
Hobbit )
FP in Scala
Мы начали с двух книг, затем: • первый вызов flatMap вернул список с тремя авторами; • второй вызов flatMap вернул список с двумя фильмами. Очевидно, что flatMap может изменить не только тип итогового списка (например, с Book на String), но и его размер, что недоступно для map.
List( Tolkien ) List( Chiusano List(
Chiusano
Bjarnason
List.empty List.empty
Bjarnason
)
Tolkien ) List( Unexpected Journey Desolation of Smaug )
List(
Unexpected Journey
Desolation of Smaug )
Остается один вопрос: где программист принимает решение о размере итогового списка? Чтобы ответить на него, сосредоточимся на втором вызове flatMap из предыдущего примера. Разделим его на отдельные вызовы map и flatten: val authors = List("Chiusano", "Bjarnason", "Tolkien") val movieLists = authors.map(bookAdaptations) → List(List.empty, List.empty, List(Movie("An Unexpected Journey"), Movie("The Desolation of Smaug"))))
Как видите, применение map к списку из трех элементов приводит к созданию нового списка ровно с тремя элементами. Однако элементами этого нового списка являются списки, поэтому мы должны преобразовать список списков в плоский список, чтобы получить осмысленный результат. Напомню, что flatten извлекает элементы каждого списка и добавляет их в новый итоговый список. А поскольку два из трех полученных списков пусты, итоговый список содержит только элементы из третьего списка. Вот почему третий вызов flatMap в предыдущем примере дал в результате список с двумя элементами. movieLists.flatten → List(Movie("An Unexpected Journey"), Movie("The Desolation of Smaug"))
Короткое упражнение: сколько элементов получится? Скажите, сколько элементов будут содержать итоговые списки: List(1, 2, List(1, 2, List(1, 2, if(i %
3).flatMap(i => List(i, i + 10)) 3).flatMap(i => List(i * 2)) 3).flatMap(i => 2 == 0) List(i) else List.empty)
Ответы 6, 3, 1
Глава 5
I
Последовательные программы
165
Кофе-брейк: работа со списками списков В этом упражнении мы будем использовать следующее определение Book: case class Book(title: String, authors: List[String])
Вот функция, которая принимает имя друга и возвращает список рекомендованных им книг: def recommendedBooks(friend: String): List[Book] = { val scala = List( Book("FP in Scala", List("Chiusano", "Bjarnason")), Book("Get Programming with Scala", List("Sfregola"))) val fiction = List( Book("Harry Potter", List("Rowling")), Book("The Lord of the Rings", List("Tolkien")))
}
if(friend == "Alice") scala else if(friend == "Bob") fiction else List.empty
RecommendedBooks Принимает имя друга и возвращает список книг, которые он рекомендует:
• Алиса рекомендует FP
in Scala и Get Programming in Scala;
• Боб рекомендует Harry Potter и The Lord of the Rings;
• другие друзья ничего не рекомендуют.
Предполагая, что у нас есть список друзей, получите список всех книг, которые они рекомендуют (замените ??? фактическим кодом). val friends = List("Alice", "Bob", "Charlie") val recommendations = ??? → List(Book(FP in Scala, List(Chiusano, Bjarnason)), Book(Get Programming with Scala, List(Sfregola)), Book(Harry Potter, List(Rowling)), Book(The Lord of the Rings, List(Tolkien)))
Имея список всех книг, рекомендованных друзьями (созданный выше), получите список авторов этих книг.
1
2
val authors = ??? → List(Chiusano, Bjarnason, Sfregola, Rowling, Tolkien)
Попробуйте решить второе упражнение, использовав только одно выражение, получающее список друзей friends и объединяющее вызовы функций в цепочку.
Краткое напоминание о сигнатурах функций в List[A] • def map(f: A => B): List[B] • def flatten: List[B] // A должен быть списком List • def flatMap(f: A => List[B]): List[B]
3
166 Часть II
I
Функциональные программы
Объяснение для кофе-брейка: работа со списками списков Сначала рассмотрим имеющиеся функции и данные:
1
• friends: List[String]; • recommendedBooks: String => List[Book]. У нас есть List[String] и функция, принимающая String и возвращающая List[Book]. Первая мысль, которая приходит в голову, — использовать map. val friends = List("Alice", "Bob", "Charlie") val friendsBooks = friends.map(recommendedBooks) → List( List( Book("FP in Scala", List("Chiusano", "Bjarnason")), Book("Get Programming with Scala",List("Sfregola")) ), List( Book("Harry Potter", List("Rowling")), Book("The Lord of the Rings", List("Tolkien")) ), List() )
О этот ужасный список списков! К счастью, мы знаем, как с этим бороться:
RecommendedBooks Принимает имя друга и возвращает список книг, которые он рекомендует:
• Алиса рекоменду-
ет FP in Scala и Get Programming in Scala;
• Боб рекомендует
Harry Potter и The Lord of the Rings;
• другие друзья
ничего не рекомендуют.
val recommendations = friendsBooks.flatten → List(Book(FP in Scala, List(Chiusano, Bjarnason)), Book(Get Programming with Scala, List(Sfregola)), Book(Harry Potter, List(Rowling)), Book(The Lord of the Rings, List(Tolkien)))
Мы решили упражнение, использовав map, а затем flatten. Эта комбинация напоминает нам о существовании более удобной функции flatMap, дающей тот же результат: friends.flatMap(recommendedBooks)
Во втором упражнении у нас уже есть список рекомендованных книг, поэтому нам остается только применить к нему flatMap с функцией _.authors:
2
val authors = recommendations.flatMap(_.authors) → List(Chiusano, Bjarnason, Sfregola, Rowling, Tolkien)
Мы можем записать все это в виде одного выражения, объединив вызовы flatMap: friends .flatMap(recommendedBooks) .flatMap(_.authors)
3
Глава 5
I
Последовательные программы
167
Объединение в цепочку вызовов flatMap и map Шаг 1 Для каждой книги
извлечь имя автора.
вызвать функцию bookAdaptations, Шаг 2 Для каждого автора чтобы получить список фильмов. сконструировать строковую Шаг 3 Для каждого фильма ленту рекомендаций.
Вернемся к текущему примеру. Мы можем получить фильмы. Теперь нам нужно реализовать последний этап конвейера: создание строковой ленты рекомендаций. В настоящее время наш конвейер выглядит так: val books = List( Book("FP in Scala", List("Chiusano", "Bjarnason")), Book("The Hobbit", List("Tolkien"))) val movies = books .flatMap(_.authors) .flatMap(bookAdaptations) → List(Movie("An Unexpected Journey"), Movie("The Desolation of Smaug"))
bookAdaptations Функция, принимающая имя автора и возвращающая список фильмов (экранизаций их книг). Для автора “Tolkien” возвращает два фильма и ничего для любых других авторов.
Для каждого фильма нам нужно создать строку в виде:
«Вам может понравиться $movieTitle, поскольку вам понравилась $bookTitle автора». Решение кажется очень простым. Нужно просто отобразить каждый фильм и создать строку, верно? movies.map(movie => s"You may like ${movie.title}, " + s"because you liked $author's ${book.title}")
В чем проблема? У нас нет доступа ни к автору, ни к книге в теле функции, которая передается в вызов map! У нас есть только фильм movie, содержащий одно лишь название title. Говоря более формально, наши вызовы flatMap объединяются в цепочку, а это значит, функции внутри них имеют доступ только к одному элементу. books .flatMap(book
=>
Доступен экземпляр book
)
.flatMap(author =>
Доступен экземпляр author
)
.map (movie
Доступен экземпляр movie
)
=>
Сначала выполняется обход элементов списка книг и создается список авторов. Только потом выполняется следующий вызов flatMap. Как следствие, для преобразования можно использовать лишь простые функции с одним параметром. Если бы нам понадобилось добавить третий вызов flatMap и передать ему книгу book, то мы бы не смогли этого сделать, поскольку он находится в другой области видимости в цепочке вызовов.
В Scala к любому строковому литералу можно добавить префикс s, чтобы позволить компилятору интерполировать значения внутри кавычек. При этом учитываться будут только значения, заключенные в ${} или снабженные префиксом $. Например, во время выполнения выражение ${movie.title} будет заменено названием данного фильма. Выражения $author и ${book.title} тоже будут заменены соответствующими значениями. Это похоже на использование String.format в Java
168 Часть II
I
Функциональные программы
Вложенные вызовы flatMap Шаг 1 Для каждой книги
извлечь имя автора.
вызвать функцию bookAdaptations, Шаг 2 Для каждого автора чтобы получить список фильмов. сконструировать строковую Шаг 3 Для каждого фильма ленту рекомендаций.
На самом деле для реализации третьего этапа конвейера нам нужен доступ ко всем промежуточным значениям (book, author и movie) в одном месте. Следовательно, нам нужно удержать их все в одной области видимости. Для этого можно использовать вложенные вызовы flatMap. Каждый этап конвейера должен быть определен внутри существующей области видимости, чтобы каждое промежуточное значение было доступно на всех этапах.
Вот как можно передать многострочную функцию в вызов flatMap, map, filter или любую другую функцию высшего порядка
books.flatMap(book => book.authors.flatMap(author => bookAdaptations(author).map(movie => Доступны все значения: - book - author - movie
)
)
Мы можем создать единое значение на основе всех промежуточных значений, поскольку теперь они находятся в текущей области видимости
)
Мы можем так поступить? Вы можете задаться вопросом, насколько безопасно такое преобразование цепочки вызовов flatMaps в последовательность вложенных вызовов и получится ли тот же самый ответ. Да, это безопасно. Да, ответ будет точно таким же. Обратите внимание, что flatMap всегда возвращает список List, который, в свою очередь, может быть плоским. Неважно, используем ли мы версию с цепочкой или вложенными вызовами, — пока мы используем flatMap, мы остаемся в контексте списка List. List(1, 2, 3) .flatMap(a => List(a * 2)) .flatMap(b => List(b, b + 10)) → List(2, 12, 4, 14, 6, 16) List(1, 2, 3) .flatMap(a => List(a * 2).flatMap(b => List(b, b + 10)) ) → List(2, 12, 4, 14, 6, 16)
Функция flatMap очень необычная. Мы не сможем преобразовать цепочку вызовов map в последовательность вложенных вызовов, поскольку map не гарантирует возврат списка, независимо от передаваемой ей функции. Функция flatMap дает такую гарантию.
ЭТО ВА Ж Н О! Функция flatMap — самая важная в ФП.
Глава 5
I
Последовательные программы
169
Значения, зависящие от других значений Шаг 1 Для каждой книги
извлечь имя автора.
вызвать функцию bookAdaptations, Шаг 2 Для каждого автора чтобы получить список фильмов. сконструировать строковую Шаг 3 Для каждого фильма ленту рекомендаций.
Теперь посмотрим на вложенные вызовы flatMap в действии и наконец решим исходную задачу! Мы создадим ленту рекомендаций: все фильмы, обнаруженные нашим конвейером, представленные в удобочитаемом виде. books .flatMap(book
=> Доступен экземпляр book
)
.flatMap(author => Доступен экземпляр author ) .map (movie
=> Доступен экземпляр movie
)
books.flatMap(book => book.authors.flatMap(author => bookAdaptations(author).map(movie => Доступны все значения: - book; - author; - movie ) ) )
В фрагменте слева можно вернуть только строку, созданную из значений, созданных на предыдущем вызове flatMap (то есть в нашей области видимости присутствует лишь значение movie). Чтобы решить задачу, дополнительно нужен доступ к book и author — наше значение String должно зависеть от трех значений в области видимости. Нам нужно преобразовать цепочку вызовов flatMap во вложенную последовательность вызовов. def recommendationFeed(books: List[Book]) = { books.flatMap(book => book.authors.flatMap(author => bookAdaptations(author).map(movie => s"You may like ${movie.title}, " + s"because you liked $author's ${book.title}" ) ) ) } recommendationFeed(books) → List("You may like An Unexpected Journey, because you liked Tolkien's The Hobbit", "You may like The Desolation of Smaug, because you liked Tolkien's The Hobbit"))
bookAdaptations Функция, принимающая имя автора и возвращающая список фильмов (экранизаций их книг). Для автора "Tolkien" возвращает два фильма и ничего для любых других авторов.
books Список из двух книг:
• FP in Scala Кьюзано и Бьярнасона;
На этом мы завершаем решение исходной задачи. Функ• The Hobbit Толкина. ция recommendationFeed — это функциональная версия конвейера. Это решение лучше императивного, но все еще имеет некоторые проблемы. Кто-то может возразить, что вложение вызовов очень похоже на вложение императивных циклов for, и я с этим полностью согласен! К счастью, эта проблема тоже имеет решение.
170 Часть II
I
Функциональные программы
Практика использования вложенных вызовов flatMap Прежде чем двинуться дальше, закрепим навык работы с вложенными вызовами flatMap. Представьте, что вы определили тип-произведение Point: case class Point(x: Int, y: Int)
Заполните пустые поля, чтобы получить список, показанный ниже: List( ??? ).flatMap(x => List( ??? ).map(y => Point(x, y) ) ) → List(Point(1,-2), Point(1,7))
Вам нужно заполнить два списка числами, чтобы в результате получился желаемый список. Размышляя над решением, помните о следующих аспектах: • необходимо использовать вложенные вызовы flatMap, чтобы при создании экземпляра Point иметь доступ как к x, так и к y; • последней вызывается функция map, поскольку функция, которую мы ей передаем, возвращает значение Point, а не List.
Ответ Единственный возможный способ создать требуемый список: List(1).flatMap(x => List(-2, 7).map(y => Point(x, y) ) ) → List(Point(1,-2), Point(1,7))
Создать такой список с помощью цепочки вызовов flatMap и map не получится, поскольку конструктору Point нужно, чтобы оба значения, x и y, находились в его области видимости.
Напомню еще раз, что ??? в Scala означает «отсутствие реа лизации». Этот код компилируется без ошибок, но вызывает исключение во время выполнения. Данная возможность используется, чтобы оставить некоторые части кода для более поздней реализации, что часто случается при проектировании сверху вниз. Если такая конструкция встречается в упражнении, то вам нужно заменить ее действительным кодом, чтобы получить требуемый результат
Дополнительные упражнения
Еще поэкспериментируем с примером выше. Исходный фрагмент кода генерирует две точки. Сколько точек будет сгенерировано кодом выше, если: • • • • •
заменить List(-2, 7) на List(-2, 7, 10); заменить List(1) на List(1, 2); выполнить сразу обе замены, перечисленные выше; заменить List(1) на List.empty[Int]; заменить List(-2, 7) на List.empty[Int]?
Ответы 3, 4, 6, 0, 0
Глава 5
I
Последовательные программы
171
Улучшенный синтаксис вложенных вызовов flatMap Вызовы flatMap стали менее читаемыми, после того как мы начали вкладывать их друг в друга. А вкладывать мы их начали, поскольку хотели получить доступ к значениям из всех списков, участвующих в вычислениях, что часто бывает необходимо во многих последовательных алгоритмах, реализованных в форме конвейера. К сожалению, вложенность делает код менее читаемым, особенно когда уровней вложенности много. books.flatMap(book => book.authors.flatMap(author => bookAdaptations(author).map(movie => s"You may like ${movie.title}, " + s"because you liked $author's ${book.title}" ) ) )
Проблема с плохой удобочитаемостью вызовов flatMap характерна не только для Scala. Та же проблема с функциями map, flatMap и другими может возникнуть и в других языках программирования.
А можно ли убить двух зайцев сразу? Оказывается, да! Scala предлагает специальный синтаксис, элегантно решающий проблему вложенных вызовов flatMap, — for-выражения (for comprehension). Посмотрим, как они работают, прямо сейчас! Загляните в раздел ниже и не волнуйтесь, если не поймете, что там происходит. Сосредоточьтесь на удобочитаемости, а детали мы разберем очень скоро. Знакомство с for-генераторами Мы еще не знакомы с for-выражениями, но прежде, чем познакомиться с ними, посмотрим, как они могут помочь решить нашу проблему. for-выражение выглядит так: for { x doSomething(x, y)))
В других языках тоже имеется нечто подобное! Стоит отметить, что for-выражения не являются уникальным механизмом Scala. В Haskell, например, есть нотация do, преследующая ту же цель.
Обратите внимание, что это не цикл for! Это совершенно другой механизм, имеющий похожее имя и использу ющий то же ключевое слово. Мы не применяем циклы for в функциональном программировании. У нас есть для этого map!
172 Часть II
I
Функциональные программы
for-выражения во спасение! Шаг 1 Для каждой книги
извлечь имя автора.
вызвать функцию bookAdaptations, Шаг 2 Для каждого автора чтобы получить список фильмов. сконструировать строковую Шаг 3 Для каждого фильма ленту рекомендаций.
Теперь, зная механику преобразования вложенных вызовов flatMap в более удобный и понятный синтаксис, попробуем переписать решение исходной задачи построчно, чтобы сделать его более читабельным. for { books.flatMap(book => ... book ... author ... movie ...)
1
for { x wasArtistActive(artist, period) case SearchByActiveLength(howLong, until) => activeLength(artist, until) >= howLong } В заключительном условии используется функция activeLength. ) ) Она возвращает общее количество лет активной деятельности
данного исполнителя, даже если у него были перерывы в карьере.
294 Часть II
I
Функциональные программы
Резюме В этой главе вы познакомились с новыми типами и ADT (которые представляют собой типы-суммы, реализованные в Scala как перечисления enum , и/или типы-произведения, реализованные как case-классы), а также с сопоставлением с образцом, которое помогает обрабатывать ADT в чистых функциях (поведении). Мы рассмотрели проблему повторяющегося кода и сравнили ADT с наследованием в ООП. Мы также рассмотрели несколько новых и очень универсальных функций высшего порядка. Они поддерживаются типами Option, List и многими другими. Но самое главное — вы научились использовать все эти приемы для проектирования.
ADT расшифровывается как Algebraic Data Type (алгебраический тип данных).
КОД : CH07_* Код примеров из этой главы доступен в файлах ch07_* в репозитории книги.
Моделируйте неизменяемые данные, чтобы минимизировать риск ошибки Мы выяснили, что чем меньше простых типов используем, тем меньше проблем у нас может возникнуть позже. Всегда полезно обернуть тип новым типом или использовать более специализированный ADT. Они помогают уменьшить риск возможных ошибок.
Моделируйте требования как неизменяемые данные Мы обнаружили, что можем использовать ADT в качестве параметров функций и с их помощью сделать сигнатуры и реа лизации более читабельными и безопасными.
Кроме того, мы использовали тип Option как часть модели, то есть в совершенно ином контексте, чем видели ранее. Напомню, что до сих пор мы использовали Option как тип возвращаемого значения, который намекает, что функция может оказаться не в состоянии вычислить и вернуть какое-то конкретное значение
Ищите проблемы в требованиях с помощью компилятора Мы укрепили нашу дружбу с компилятором и подняли ее на новый уровень. Это очень помогло нам, когда мы начали использовать новые типы и ADT вместо простых типов.
Глава 7
I
Требования как типы
295
Убедитесь, что наша логика всегда выполняется на действительных данных Мы исследовали проблему, аналогичную той, которая встретилась нам в предыдущей главе. Но при этом нам не понадобилась полноценная обработка ошибок во время выполнения, как было показано, когда мы использовали Either. А не понадобилась она потому, что мы смоделировали требования настолько подробно (с использованием типов), что стало невозможно даже создать многие недопустимые значения. Эта так называемая обработка ошибок во время компиляции намного превосходит версию времени выполнения, хотя и не всегда возможна. Все эти методы широко распространены в ФП. Они просты и очень эффективны, так же как чистые функции, неизменяемые значения и функции высшего порядка. Вот почему мы потратили так много времени на рассмотрение различных подходов к проектированию программного обеспечения и преимуществ функционального предметного проектирования. Теперь мы воспользуемся плодами этого тяжелого труда, ведь, оказывается, ADT останутся с нами надолго. (Впрочем, они были с нами с самого начала в виде List, Option и Either (да, это тоже ADT), но не будем забегать вперед!) Следующая наша остановка — ввод-вывод!
Как будет показано в главе 12, для аналогичных целей можно использовать ADT и Either.
Мы еще вернемся к этой теме в главе 12
Ввод-вывод как значения
В этой главе вы узнаете: • как использовать значения для реализации программ с побочными эффектами; • как использовать данные из небезопасных источников; • как безопасно сохранять данные вне программы; • как сообщить, что код имеет побочные эффекты; • как отделить чистый код от нечистого.
...Мы должны сохранять его четким, ясным и простым, если не хотим быть раздавленными сложностями нашего собственного творения... Эдсгер Дейкстра (Edsger Dijkstra), The Next Forty Years
8
Глава 8
I
Ввод-вывод как значения
297
Общение с внешним миром В этой главе мы наконец обратим внимание на слона в комнате.
В
Теперь я вижу, как чистые функции и надежные сигнатуры могут помочь писать более качественное и удобное в сопровождении программное обеспечение. Но будем честными. Нам всегда будет нужно получать что-то извне — обратившись к внешнему API или базе данных. Кроме того, почти каждое приложение в мире имеет состояние, которое необходимо где-то хранить. Таким образом, не является ли концепция чистых функций немного ограничивающей нас?
О
Мы можем общаться с внешним миром и при этом пользоваться чистыми функциями! Суть здесь та же самая, как при обработке ошибок (см. главу 6) и моделировании требований в виде типов (см. главу 7). Мы представляем все как значения описательных типов!
Помните, что, разрабатывая функцию, которая может дать сбой, мы не генерируем исключений. Вместо этого мы сообщаем, что вызов может завершиться неудачей, возвращая значение определенного типа (например, Either). Аналогично, когда нам нужна функция, принимающая только определенную комбинацию аргументов, мы указываем это в сигнатуре, выбирая определенный тип, моделирующий данное предположение. В данной главе мы посмотрим, как применить точно такой же подход для создания и обработки побочных эффектов! Помните, что побочный эффект — это все, что делает функцию нечистой. Код, который выполняет ввод-вывод, имеет побочный эффект и делает функции нечистыми. Рассмотрим пример такой функции. Следующий код должен сохранять информацию о заданной встрече: void createMeeting(List attendees, MeetingTime time)
Как мы обсуждали в главе 2, такая функция не является чистой. Что еще более важно, в ее сигнатуре указано, что она ничего не возвращает (void в Java). Этот метод довольно популярен в императивных языках. Если функция ничего не возвращает, то, возможно, делает что-то важное. Чтобы понять, что именно (сохраняет что-то в базе данных или просто обновляет внутренний список ArrayList), программисту необходимо погрузиться в реализацию. Чистые функции не лгут, что гарантирует удобочитаемость кода, поскольку читателям не нужно просматривать каждую строку реализации, чтобы понять в общих чертах, что она делает. Я покажу, как это сделать в духе функционального программирования, заключив вызов createMeeting в чистую функцию!
Ввод-вывод и побочные эффекты Мы будем использовать термины «ввод-вывод» и «побочные эффекты» как синонимы. Обычно неважно, что делает функцию нечистой. Важно, что мы должны учесть это и исходить из наихудшего сценария, которым обычно является какой-то ввод-вывод. Чистая функция Возвращает единственное значение. Использует только свои аргументы. Не изменяет существующие значения.
298 Часть II
I
Функциональные программы
Интеграция с внешним API Вернемся немного назад и перечислим требования к приложению, рассматриваемому в этой главе. Наша задача — создать простой планировщик встреч. Требования: планировщик встреч 1. Для заданных двух участников и продолжительности встречи функция должна найти свободное окно в расписании. 2. Функция должна сохранять информацию о встрече в заданном окне в календарях всех участников. 3. Функция должна использовать нечистые функции, взаимодействующие с внешним миром: calendarEntriesApiCall и createMeetingApiCall. Мы не можем изменить эти функции. (Предположим, что они предоставляются внешней клиентской библиотекой.)
Функции calendarEntriesApiCall и createMeetingApiCall выполняют некоторые операции ввода-вывода. В данном случае эти побочные операции взаимодействуют с каким-то API календаря. Предположим, что эти функции предоставляются внешней клиентской библиотекой. Это означает, что мы не можем их изменить, но обязаны использовать их для правильной реализации функциональности, поскольку API календаря хранит состояние календарей всех людей.
Напомню, что под операциями ввода-вывода мы подразумеваем действия, которые должны извлекать или сохранять данные вне нашей программы. Эти действия часто называют побочными эффектами
Вызовы API календаря Чтобы упростить задачу, мы будем использовать не настоящий API (пока!), а только его имитацию с похожим поведением, которое мы могли бы ожидать от реального API. В качестве модели данных мы используем тип-произведение MeetingTime. Он поддерживается вызовами имитации API, которые иногда могут завершаться не удачей и возвращать разные результаты при каждом вызове. Таким способом имитируется доступ к неизвестным данным, находящимся за пределами нашей программы.
Вскоре мы рассмотрим более проблемные версии функций «клиентской библио теки». Кроме того, в главах 11 и 12 мы будем использовать настоящий API!
case class MeetingTime(startHour: Int, endHour: Int)
Смоделированные функции клиентской библиотеки императивно реализованы на Java. Мы их неконтролируем ине можем их изменить. Здесь мы предоставляем нечистые «обертки», которые будем использовать внашем коде def createMeetingApiCall( names: List[String], meetingTime: MeetingTime ): Unit = {
}
static void createMeetingApiCall( вер 25 % List names, оя MeetingTime meetingTime) { тн ос т Random rand = new Random(); и if(rand.nextFloat() < 0.25) throw new RuntimeException(" "); System.out.printf("SIDE-EFFECT"); }
def calendarEntriesApiCall(name: String): List[MeetingTime] = { static List calendarEntriesApiCall(String name) { Random rand = new Random(); вер 25 % if (rand.nextFloat() < 0.25) оя throw new RuntimeException("Connection error"); тн if (name.equals("Alice")) ос т и return List.of(new MeetingTime(8, 10), new MeetingTime(11, 12)); else if (name.equals("Bob")) return List.of(new MeetingTime(9, 10)); else return List.of(new MeetingTime(rand.nextInt(5) + 8, rand.nextInt(4) + 13)); }
} Для всех, кроме участников с именами Alice и Bob, успешный вызов API возвращает случайную встречу в период с 8:00 до 12:00 и с 13:00 до 16:00. Такие нечистые побочные эффекты мы пометили значком молнии.
Глава 8
I
Ввод-вывод как значения
299
Свойства операции ввода-вывода с побочным эффектом Наша задача — создать функцию планирования встреч. Причем это должна быть чистая функция! То есть она должна иметь сигнатуру, которая не лжет. Нам также нужно использовать две предоставленные нечистые функции, являющиеся внешними по отношению к нашему коду, но выполняющие некоторые важные операции ввода-вывода, от которых зависит наше приложение: calendarEntriesApiCall и createMeetingApiCall. Мы обернули их как нечистые функции на Scala. def calendarEntriesApiCall(name: String): List[MeetingTime] def createMeetingApiCall(names: List[String], meetingTime: MeetingTime): Unit
Напомню еще раз, что мы должны их использовать, но не можем изменить. Более того, мы можем даже не знать, как они реализованы внутри (что часто бывает, когда клиентские библиотеки предоставляются в виде двоичных файлов). Кроме того, нас не должно волновать, что у них внутри. Важно, что им нельзя доверять. Это внешний мир для нас. Работая с ними, мы должны быть готовы к любым каверзам с их стороны! Эти функции могут (и будут) вести себя недетерминированно.
Как операции ввода-вывода могут вести себя неправильно
Тип Unit в Scala эквивалентен void в Java и других языках. Если функция возвращает Unit, то это означает, что внутри она выполняет какие-то нечистые операции Мы показали смоделированную реализацию, которая иногда дает сбой и для полноты картины возвращает случайные результаты. Мы больше не будем возвращаться к ней и сосредоточимся на функциональных приемах работы с такими функциями
В этой главе мы будем использовать пример API, представляющий операцию ввода-вывода. Это вполне прагматичный подход, поскольку все что угодно можно представить как вызов API (даже вызов базы данных). Интеграция с внешними API — типичная задача по программированию, которую приходится решать в том числе и в чистом функциональном коде, поэтому очень важно, чтобы создаВызов API может возвращать разные ваемая нами логика могла изящно обрарезультаты для одного и того же аргумента. батывать все крайние случаи. Вызов API может завершиться ошибкой
В этой книге мы рассмотрим все три сценасоединения (или другой ошибкой). рия, но в данной главе основное внимание Вызов API может выполняться слишком долго. уделим двум первым из них, поскольку они представлены двумя функциями «клиScala может использовать ентской библиотеки» *ApiCall, с которыми мы должимперативный код ны интегрировать свой код. Эти две нечистые функотметить, что мы можем испольции, предоставляемые как часть задачи, возвращают Стоит зовать функции Java в Scala, поскольку случайные результаты и генерируют случайные оба они являются языками JVM. Это исключения. Они написаны на Java, согласно услови- довольно распространенный случай, так ям задачи, поэтому мы должны помнить и понимать, как многие клиентские библиотеки начто они не являются частью нашего кода. Соответ- писаны на Java и являются императивными. Вы сможете использовать любые ственно, мы не можем изменить их, чтобы сделать из них в своих функциональных прилоболее функциональными. Но мы должны обраба- жениях, включая большие приложения, о которых пойдет речь в главе 11. тывать все их каверзы в наших чистых функциях!
300 Часть II
I
Функциональные программы
Императивное решение для кода ввода-вывода с побочными эффектами Прежде чем начать знакомиться с чисто функциональными концепциями ввода-вывода, подготовим почву и посмотрим, как будет выглядеть планировщик встреч, который мы должны реализовать в этой главе, если написать его на императивном языке Java. Мы будем использовать предоставленные библиотечные функции напрямую. Обратите внимание, что это решение имеет множество недостатков, которые мы рассмотрели в предыдущих главах. Мы перечислим их все (в качестве подведения итогов) при рефакторинге этой императивной, нечистой функции schedule в чистую версию, написанную на Scala.
Я ценю, что современный язык Java становится все более функциональным, но специально для сравнения решил использовать более классический, императивный подход в программах на Java. Но вообще вы сможете применять многие функциональные приемы из этой книги в своих программах на Java (и Kotlin!)
static MeetingTime schedule(String person1, String person2, int lengthHours) { Сначала нужно получить все текущие записи из календаря для обоих участников, вызвав внешний API (то есть функцию calendarEntriesApiCall, которая имитирует потенциально некорректно работающий API календаря). List person1Entries = calendarEntriesApiCall(person1); List person2Entries = calendarEntriesApiCall(person2);
1
List scheduledMeetings = new ArrayList(); scheduledMeetings.addAll(person1Entries); scheduledMeetings.addAll(person2Entries); 2 Создается список всех уже запланированных встреч. List slots = new ArrayList(); for (int startHour = 8; startHour < 16 - lengthHours + 1; startHour++) { slots.add(new MeetingTime(startHour, startHour + lengthHours)); } Генерируются все возможные окна в рабочие часы (8–16) заданной 3 продолжительности (lengthHours). List possibleMeetings = new ArrayList(); for (var slot : slots) { Теперь можно создать список всех временных 4 var meetingPossible = true; окон, не пересекающихся с уже запланироfor (var meeting : scheduledMeetings) { ванными встречами, просматривая каждое if (slot.endHour > meeting.startHour && meeting.endHour > slot.startHour) { возможное окно и проверяя, не пересекается ли оно с какой-либо существующей встречей. meetingPossible = false; Если не пересекается, то добавляется break; в итоговый список possibleMeetings. } } Наконец, если список possibleMeetings непустой, if (meetingPossible) { то берем первый элемент и вызываем внешний API, possibleMeetings.add(slot); чтобы сохранить и вернуть эту встречу. Если ни одного } 5 свободного окна не найдено, то мы ничего не можем } сохранить, поэтому возвращаем null. if (!possibleMeetings.isEmpty()) { createMeetingApiCall(List.of(person1, person2), possibleMeetings.get(0)); return possibleMeetings.get(0); MeetingTime— это класс сдвумя полями (startHour } else return null; иendHour) ифункциями equals, hashCode иtoString. } В Java его можно определить как тип записи (см. код врепозитории книги).
Глава 8
I
Ввод-вывод как значения
301
Проблемы императивного подхода к вводу-выводу Императивное решение имеет множество проблем, хотя работает правильно (как будто): schedule("Alice", "Bob", 1) 1-часовая встреча → MeetingTime[startHour=10, endHour=11] schedule("Alice", "Bob", 2) 2-часовая встреча → MeetingTime[startHour=12, endHour=14] schedule("Alice", "Bob", 5) 5-часовая встреча не может быть → null schedule("Alice", "Charlie", 2) запланирована → MeetingTime[startHour=14, endHour=16]
Однако, как мы уже не раз обсуждали в этой книге, код читают гораздо чаще, чем пишут, поэтому его всегда следует оптимизировать для удобочитаемости. Это единственный способ гарантировать, что код будет легко сопровождать (то есть в будущем программисты смогут обновлять или изменять его максимально уверенно). Вот проблемы, о которых мы можем думать, основываясь на том, что мы знаем до сих пор. Императивное решение
calendarEntriesApiCall вызывает API календаря. Смоделированная нами версия:
• возвращает две встречи для Alice: 8–10, 11–12;
• возвращает одну встречу для Bob: 9–10; • возвращает одну встречу для всех
остальных произвольно начинающуюся между 8 и 12 и заканчивающуюся между 12 и 16.
Но может и ничего не вернуть (с вероятностью 25 %), поэтому вам нужно быть удачливыми (или настойчивыми), чтобы получить эти результаты.
Обратите внимание, что вызов API для Charlie может каждый раз возвращать разные значения (это нечистое поведение), поэтому вы можете получать разные результаты при вызове schedule для Charlie (даже null).
Хорошо работает всценариях, развивающихся по счастливому пути.
static MeetingTime schedule(String person1, String person2, int lengthHours)
Проблема 1 Эта функция решает как минимум две задачи. Вызывает внешние функции и отыскивает свободное окно, и все это делается внутри одной функции.
Проблема 2 Если какая-либо из трех внешних функций завершится неудачей, то так же завершится и вся операция. Если в вызове API возникает какая-либо проблема (сетевая ошибка, ошибка сервера и т. д.), то вся функция завершается неудачей с исключением, что может запутать пользователя. На данный момент наша имитация клиентской библиотеки дает сбой только иногда, но это не может быть утешением, поскольку сильно влияет на императивную реализацию функции schedule, которую мы только что написали. Мы не должны предполагать, что вызовы API всегда будут завершаться успешно.
Проблема 3 Сигнатура лжет. В сигнатуре указано, что функция возвращает значение MeetingTime, однако если вызов API завершится неудачей, то функция сгенерирует исключение и не вернет значение MeetingTime. Кроме того, она вернет null, если не найдет подходящего временного окна в календаре. (Мы уже умеем моделировать значения, которые могут не существовать, и используем это умение в функциональной версии функции schedule. Помните, какие варианты есть в нашем распоряжении?)
302 Часть II
I
Функциональные программы
Позволит ли ФП добиться большего успеха
В
Разве это не погоня за недостижимым? Большинство реальных приложений должны выполнять ввод-вывод, обрабатывать ошибки подключения и многие другие. Я не думаю, что мы сможем получить что-то большее, чем дает код на Java, который вы показали.
ЭТО ВА Ж Н О! В ФП мы стремимся убрать загрязнения из большинства наших функций.
О
Да, мы сможем добиться большего. Мы не должны мириться с кодом, осуществляющим ввод-вывод, который разбросан по всему приложению. Это сделало бы нашу жизнь несчастной. Мы решим все три перечисленные проблемы, удалив загрязнения из большинства наших функций, следуя функциональному подходу.
К концу главы вы сможете понять и написать функциональное решение, которое выглядит так: def schedule(attendees: List[String], lengthHours: Int): IO[Option[MeetingTime]] = { for { existingMeetings IO.unit Д } ВПЕРЕ БЕГАЯ А З } yield possibleMeeting }
Нам еще предстоит узнать, как работает тип IO, но обратите внимание, насколько богата сигнатура и что она говорит о функции. Кроме того, заметьте, что эта реализация более универсальна, поскольку она позволяет планировать встречи более чем для двух человек.
Наше путешествие уже началось. В этой главе мы решим все три проблемы, представив функциональные альтернативы и переписав функцию schedule на Scala с использованием новых концепций. Кроме того, как и было обещано, у вас будет возможность повторить пройденное в предыдущих главах и посмотреть, как все эти функциональные элементы встают на свои места. Проблема 1 Эта функция решает как минимум две задачи.
Проблема 2
В этой главе мы увидим, как решить каждую из этих проблем спомощью FP, проблема за проблемой.
Если какая-либо из трех внешних функций завершится неудачей, то так же завершится и вся операция.
Проблема 3 Сигнатура лжет.
Посмотрите, как много знакомых приемов используется во фрагменте выше!
Глава 8
I
Ввод-вывод как значения
Ввод-вывод и использование его результата Аббревиатура IO расшифровывается как input/output — «ввод/ вывод». Соответственно, у нас есть две операции ввода-вывода. Операции ввода-вывода Операции ввода Сюда относятся все операции, получающие что-то из-за пределов нашей программы (например, читающие значение из базы данных, выполняющие вызов API или получающие какие-либо данные от пользователя). К операциям ввода также относятся операции чтения из общей памяти, содержимое которой может изменяться.
303 Цель проведения этого различия — помочь вам взглянуть на ввод-вывод с другой точки зрения. Некоторые операции могут одновременно быть и вводом и выводом, как будет показано далее в этой главе.
Операции вывода Сюда относятся все операции, сохраняющие что-то для последующего использования (например, в базу данных или API или отображающие что-то в графическом интерфейсе). К операциям вывода также относится запись в общую память, содержимое которой может изменяться, и многие другие случаи.
Небезопасный код Основное свойство обоих типов операций ввода-вывода — они выполняют небезопасный код, который может вести себя по-разному в зависимости от многих факторов. Вот почему чтение и запись в общую память считаются небезопасными и относятся к категории операций ввода-вывода с побочными эффектами. Помните, что чистые функции всегда должны вести себя одинаково, независимо от того, где и когда вызываются. Обе операции действия уже представлены функциями из нашей клиентской библиотеки: calendarEntriesApiCall — это операция ввода, которая читает значение из потенциально небезопасного местоположения, а createMeetingApiCall — это операция вывода, которая записывает значение в потенциально небезопасное местоположение. Естественно, сначала мы сосредоточимся на использовании операций ввода-вывода для извлечения данных (то есть ввода). Углубимся в начало функции schedule: List person1Entries = calendarEntriesApiCall(person1); List person2Entries = calendarEntriesApiCall(person2);
Подобный код можно увидеть во многих программах. Это плохо, поскольку он делает две вещи: • выполняет некоторую операцию ввода-вывода (вызывает API, скорее всего, по сети); • предоставляет результат действия ввода-вывода (List). В действительности для нас важен результат, а не сама операция. Нам нужен список значений MeetingTime, чтобы из него выбрать окно для встречи. Но тот факт, что это значение извлекается из внешнего мира, имеет важные последствия: процедура извлечения может потерпеть сбой (ошибка соединения), потребовать слишком много времени для выполнения или вернуть значение в формате, отличном от ожидаемого, что приведет к ошибке интерпретации. Как результат, мы выполняем операцию ввода, только чтобы получить значение, но в процессе можем попасть в неприятные ситуации. И нам нужно как-то их обработать!
Обратите внимание, что мы все еще анализируем императивную версию на Java, написанную несколькими страницами выше
Другими словами, операция ввода фокусируется на значении, которое она производит, а операция вывода — на самом побочном эффекте.
304 Часть II
I
Функциональные программы
Императивный ввод-вывод
Проблема 1
Несмотря на то, что нас интересует только значение, произведенное операцией ввода-вывода, мы вынуждены думать обо всех последствиях этой операции. В Java код обязательно должен выглядеть как-то так: List person1Entries = null; try { person1Entries = calendarEntriesApiCall(person1); } catch(Exception e) { // повторная попытка: person1Entries = calendarEntriesApiCall(person1); }
Эта функция решает как минимум две задачи.
Плюс аналогичный код для person2Entries! Скорее всего, он был бы инкапсулирован в отдельную функцию или объект, но это не устраняет проблему, которую мы пытаемся решить.
Конечно, это лишь одно из возможных решений сложной проблемы обнаружения и устранения сбоев. В таких сценариях мы часто стараемся предусмотреть механизм восстановления в случае сбоя. Здесь мы использовали стратегию повторных попыток с одной попыткой. Но есть множество других вариантов; вот некоторые из них: сделать большое количество попыток, использовать кешированное значение, использовать значение по умолчанию или повторить попытку через определенное время. Механизмы восстановления — обширная тема, заслуживающая отдельной книги. Здесь важно следующее: должны ли мы на самом деле заботиться о стратегии восстановления внутри функции schedule? Не должны! Чем больше повторных попыток и конструкций trycatch, тем менее очевидна вся бизнес-логика. Тот факт, что в функции schedule мы выполняем ввод-вывод, должен быть лишь небольшой деталью, поскольку самым главным является поиск свободного окна для новой встречи и его сохранение в API. Вы видели какое-либо упоминание о стратегии повторных попыток в бизнес-требованиях?
Переплетающиеся задачи Это сочетание различных обязанностей и уровней абстракции называется переплетающимися задачами (entangled concerns). Чем больше задач переплетается в одной функции, тем сложнее будет изменять, обновлять и поддерживать ее. Обратите внимание: мы не говорим, что повторение потенциально неудачной операции не имеет смысла. Имеет! Мы говорим, что задача выполнения повторных попыток не должна переплетаться с кодом, реализующим бизнес-логику (то есть она не должна отвлекать внимание читателя кода от бизнес-задачи). Разумеется, если функция schedule будет содержать большое количество try-catch или других конструкций обработки сбоев, то ее будет трудно читать и, следовательно, она будет не очень удобной в сопровождении. Отделим получение и использование значения в бизнес-логике от операции ввода-вывода, извлекающей это значение.
Говоря словами Эдсгера Дейкстры, приведенными в начале этой главы, мы будем «раздавлены сложностями нашего собственного творения» Обратите внимание, что в функции schedule мы переплели процесс использования List с небезопасной задачей ввода-вывода, получающей его.
I
Глава 8
Ввод-вывод как значения
305
Вычисления как значения IO К счастью, функциональное программирование может подстраховать нас. Подобно типам Either и Option, которые можно использовать для обработки ошибок или потенциально отсутствующих значений соответственно, в нашем распоряжении имеется тип IO, позволяющий отделить операции ввода-вывода, получающие значение, от операций использования полученного значения. Сначала посмотрим, как действует тип IO, а затем углубимся в детали его работы. Это должно быть довольно легко, поскольку IO очень похож на Either, Option и List — он поддерживает множество уже знакомых вам функций! Прежде всего IO[A] — это значение, такое же как Option[A] или Either[A, B]. В ФП мы работаем с функциями и неизменяемыми значениями. Как работает IO IO[A] имеет несколько кон-
кретных подтипов, но мы для простоты представим здесь только два из них: Pure[A] и Delay[A].
IO[A] Pure[A]
Delay[A]
a: A
thunk: () => A
Чтобы создать значение типа IO, нужно решить, будет ли это уже известное значение или для его получения необходимо запустить небезопасный код с побочными эффектами. Если значение уже известно, то можно использовать конструктор IO.pure, который просто обернет это значение и вернет IO[A] (которое внутри является значением Pure[A]): val existingInt: IO[Int] = IO.pure(6)
Если нужно вызвать небезопасную функцию getIntUn safely() — нечистую функцию, способную сгенерировать исключение, то следует использовать конструктор IO.delay, который обернет потенциально небезопасный вызов, не выполняя его (используя механизм, аналогичный замыканиям): val intFromUnsafePlace: IO[Int] = IO.delay(getIntUnsafely())
В этих примерах оба конструктора, pure и delay, возвращают значение IO[Int], представляющее потенциально небезопасное вычисление (например, операцию ввода-вывода с побочным эффектом), которое в случае успеха даст значение Int. Обратите внимание на слово представляющее в предыдущем предложении. Значение IO[Int] — это представление вычисления, которое дает значение Int. Нам нужно каким-то образом выполнить или интерпретировать это значение IO[Int], чтобы получить итоговое значение Int.
ЭТО ВА Ж Н О! В ФП мы просто передаем неизменяемые значения!
Функциональный тип IO в Scala является частью библиотеки cats-effect. Чтобы использовать тип IO, нужно сначала импортировать cats.effect.IO. Он уже доступен вам, если вы используете sbt console из репозитория книги.
Конструктор IO.pure являет собой пример немедленных, или «жадных» (eager), вычислений, IO.delay — пример отложенных, или «ленивых» (lazy), вычислений. Подроб нее об этих видах вычислений мы поговорим ниже в данной главе. Самым важным в этом случае является то, что мы используем отложенные вычисления, чтобы не вызывать небезопасный код и делегировать эту ответственность другой сущности и в другом месте.
306 Часть II
I
Функциональные программы
Значения IO Очень важно помнить, что IO[Int] или IO[MeetingTime] — это просто неизменяемое значение, подобное любому другому неизменяемому значению. Что такое IO IO[A] — это значение, представляющее операцию ввода-вывода
с возможным побочным эффектом (или другую небезопасную операцию), которая в случае успеха создает значение типа A .
В
Но как именно IO может нам помочь? И как быть с требованием использовать стороннюю нечистую функцию calendarEntriesApiCall, которая потенциально небезопасна? Мы же не можем изменить ее сигнатуру, поскольку это нам недоступно!
О
Мы не будем менять функцию calendarEntriesApiCall. Обычно, когда вам предоставляют какую-то стороннюю библиотеку для интеграции с системой во внешнем мире, вы не можете что-то там изменить. Поэтому оставим calendarEntriesApiCall неприкосновенной! Но мы обернем эту стороннюю, нечистую функцию другой, которая возвращает значение IO!
В нашем примере у нас есть небезопасная функция, находящаяся в клиентской библиотеке и имеющая следующую сигнатуру, которую нельзя изменить: def calendarEntriesApiCall(name: String): List[MeetingTime]
Поскольку это операция ввода-вывода, используемая для получения значения, а мы не хотим нести ответственность за ее выполнение, то можно поместить ее внутрь значения IO[List[MeetingTime]] с помощью конструктора IO.delay: import ch08_SchedulingMeetings.calendarEntriesApiCall def calendarEntries(name: String): IO[List[MeetingTime]] = { IO.delay(calendarEntriesApiCall(name)) }
Вот и все! Мы использовали calendarEntriesApiCall согласно требованию. Наша новая функция возвращает IO[List[MeetingTime]] — чистое значение, представляющее операцию ввода-вывода, которая после успешного выполнения вернет List[MeetingTime]. Одна из замечательных особенностей функции calendarEntries — она никогда не потерпит неудачу, независимо от того, сколько исключений сгенерирует calendarEntriesApiCall! Но как она работает и что это нам дает? Далее мы немного поэкспериментируем с типом IO, чтобы лучше понять его.
Сравните обе сигнатуры. Между ними есть принципиальная разница, которую мы рассмотрим позже
Если вы решите опробовать примеры в своей оболочке REPL, то импортируйте нечистые функции из модуля ch08_Schedu lingMeetings, как показано здесь. Значения не генерируют исключений, и чистые функции тоже не делают этого!
Глава 8
I
Ввод-вывод как значения
Значения IO в реальной жизни IO.delay принимает блок кода и не выполняет его, но воз-
вращает значение, представляющее этот блок и значение, которое он создаст при выполнении. Прежде чем использовать (и выполнить) IO в нашем планировщике встреч, рассмотрим небольшой пример. Предположим, у нас есть нечистая функция на Java, которая выводит строку, а затем возвращает случайное число от 1 до 6. def castTheDieImpure(): Int = {
}
static int castTheDieImpure() { System.out.println("The die is cast"); Random rand = new Random(); return rand.nextInt(6) + 1; }
307 Напомню, что мы можем использовать код на Java из Scala, поскольку и Java, и Scala являются языками JVM. Мы используем это обстоятельство в оставшейся части книги. На Java мы будем представлять императивные решения, обычно в виде нечистых функций, а на Scala — их функциональные аналоги без каких-либо изменений. Надеюсь, этот подход соответствует вашему будущему опыту.
Мы обернули эту нечистую функцию, никак не изменяя, чтобы ее можно было использовать в нашем проекте.
После выполнения этой функции мы неожиданно получаем два результата. Не забудьте импортировать эту функцию
в сеансе REPL sbt console. import ch08_CastingDie.castTheDieImpure castTheDieImpure() → вывод в консоли: The die is cast Что здесь случилось? Мы вызвали функцию → 3 и получили больше, чем заявлено в сигнатуре?
На самом деле мы получили только один результат — значение Int, но сама функция вывела дополнительную строку, что можно рассматривать как побочный эффект, поскольку функция делает больше, чем заявлено в сигнатуре (которая обещает просто вернуть Int!). Кроме того, при повторном вызове есть вероятность получить другой результат! Это нечистая функция, поэтому мы, будучи функциональными программистами, должны относиться к ней как к потенциальной угрозе удобству сопровождения кода — это небезопасный код и его нужно заключить в IO! Заключим вызов нечистой функции внутрь IO, использовав IO.delay. def castTheDie(): IO[Int] = IO.delay(castTheDieImpure()) → def castTheDie(): IO[Int] castTheDie() → IO[Int]
И что? Ничего не произошло! Конструктор IO.delay принял блок кода (вызов castTheDieImpure()) и не выполнил его. Он вернул значение, представляющее переданный блок кода, не выполнив его. Сколько бы раз мы ни вызвали castTheDie(), она всегда будет возвращать одно и то же значение IO[Int], точно так же как List(1, 2, 3) всегда будет возвращать одно и то же значение List[Int]. Никаких побочных эффектов, ничего не выводится в консоль, и никаких случайных чисел не возвращается. castTheDie() возвращает значение, которое позже можно интерпретировать (выполнить) и в конечном итоге получить значение Int путем вызова функции castTheDieImpure(), сохраненной внутри объекта IO. Это позволяет сосредоточиться на использовании сгенерированного значения Int, не запуская код, который его генерирует. Далее я покажу, как запускать и использовать IO.
Функциональные программисты относятся ко всем нечистым функциям с такой же предосторожностью, как и к операциям ввода-вывода. ЭТО ВА Ж Н О! В ФП мы относимся к нечистым функциям как к небезопасному коду. Проблема 1 Эта функция решает как минимум две задачи. Другими словами, делегируем ответственность за выполнение небезопасного кода
308 Часть II
I
Функциональные программы
Удаляем загрязнения Мы отделили использование значения, созданного операцией ввода-вывода, от выполнения этой операции. Сначала посмотрим, как запустить значение IO.
Запуск значений IO IO[A] можно запустить, вызвав специальную функцию unsafeRunSync(). val dieCast: IO[Int] = castTheDie() → dieCast: IO[Int] import cats.effect.unsafe.implicits.global dieCast.unsafeRunSync() → вывод в консоли: The die is cast → 3
Видите? Мы можем запустить операцию и получить то же самое поведение, что и при вызове castTheDieImpure() (вывод строки в консоли и случайное значение Int). Однако в ФП unsafeRunSync вызывается обычно только один раз и в другом месте — в конце нашей программы, с небольшим количеством нечистого кода! За его выполнение отвечает другая сущность. Основное преимущество использования IO заключается в том, что это просто значение, принадлежащее чистому миру. Наша цель — сосредоточить как можно больше кода в чистом мире, чтобы использовать все сверхвозможности, о которых мы узнали из этой книги. Все нечистые функции (генераторы случайных чисел, вывод строк, вызовы API и выборка из базы данных) принадлежат нечистому миру. Чистый мир
ЭТО ВА Ж Н О! В ФП удаляем загрязнения из большинства наших функций.
Нечистый мир castTheDieImpure
castTheDie def castTheDie(): IO[Int] = { IO.delay(castTheDieImpure()) }
Не забудьте вставить этот оператор импорта перед запуском значения IO. Он открывает доступ к пулам потоков выполнения, которые будут использоваться для выполнения отложенных вычислений и побочных эффектов. Потоки выполнения (threads) и параллелизм мы обсудим в главе 10. (Кстати, сеанс sbt console выполняет импорт этого модуля автоматически)
def castTheDieImpure(): Int = { }
Мы переложили ответственность за выполнение небезопасного кода на функцию, вызывающую castTheDie. Вызывающая функция находится в нечистом мире. Мы можем смело писать остальную бизнес-логику на чистой стороне мира, используя только значения IO.
Главный процесс приложения val dieCast: IO[Int] = castTheDie() dieCast.unsafeRunSync()
Теперь посмотрим, как можно работать исключительно со значениями IO, представляющими операции ввода-вывода, не запуская их! Значения IO, так же как List, Option и Either, могут преобразовываться с использованием чистых функций. Но самое интересное, что эти функции уже знакомы вам! Давайте ощутим настоящую силу чистой стороны мира!
Глава 8
I
Ввод-вывод как значения
309
Использование значений, полученных из двух операций ввода-вывода Хватит теории! Пришло время соединить все точки и задействовать возможности функционального программирования в наших интересах. Сначала мы завершим пример с выводом строки The die is cast1, а затем предложим вам самостоятельно реализовать похожее решение в планировщике встреч! Представьте, что вам нужно дважды бросить кубик и вернуть сумму двух бросков. Эта задача тривиально решается с использованием нечистой версии: castTheDieImpure() + castTheDieImpure() → вывод в консоли: The die is cast → вывод в консоли: The die is cast → 9
Элементарно просто, верно? В консоли появились два сообщения и окончательный результат, в данном случае число 9. Теперь вспомним, что операции ввода-вывода обычно связаны с более высоким риском, что что-то пойдет не по плану. Можно сказать, что есть вероятность падения кубика со стола.
def castTheDieImpure(): Int = { static int castTheDieImpure() { Random rand = new Random(); if (rand.nextBoolean()) throw new RuntimeException("Die fell off"); return rand.nextInt(6) + 1; } }
import ch08_CastingDie.WithFailures.castTheDieImpure
Достаточно ли красиво, на ваш взгляд, выглядит выражение castThe DieImpure() + castTheDieImpure()? Если бы вы не знали, как реализована функция castTheDieImpure, и просто доверились ее сигнатуре, обещающей всегда возвращать целое число, то у вас были бы большие проблемы. Реальные проекты обычно содержат огромные объемы кода, и у вас просто не будет достаточно времени, чтобы тщательно изучить каждую строку реализации, верно? Вот здесь нам и поможет тип IO. Хотелось бы не думать о возможных исключениях в этом конкретном месте программы, а просто показать логику, получающую откуда-то два числа и суммирующую их. Было бы замечательно выразить в коде только эту логику и получить обратно значение, представляющее ее без всяких условий? def castTheDie(): IO[Int] = IO.delay(castTheDieImpure()) → def castTheDie(): IO[Int] castTheDie() + castTheDie() → ошибка компиляции! Компиляция завершается неудачей, поскольку IO[Int] не поддерживает операцию сложения. Вы не можете просто сложить два значения, представляющие потенциально небезопасные вычисления!
1
The die is cast — «Кубик брошен». — Примеч. пер.
Проблема 1 Эта функция решает как минимум две задачи. Когда функция возвращает Int, ее клиент может предположить, что она вернет Int при вызове.
Когда функция возвращает IO[Int], это озна чает, что она возвращает значение, которое можно выполнить позже, но оно может содержать небезопасный код и завершиться неудачей. Функция вернет Int только в случае успеха.
310 Часть II
I
Функциональные программы
Объединение двух значений IO в одно
В
Мы не можем просто сложить два значения IO, но как тогда объединить их? Почему это так сложно?
castTheDie() + castTheDie → ошибка компиляции!
О
Значения IO[Int] нельзя сложить, поскольку они представляют нечто большее, чем простые значения Int. Они представляют потенциально небезопасные действия, которые должны выполниться позже, так как могут привести к сбою. Разве это не полезно? Как видите, это непростое значение, и мы воспользуемся данным фактом в своих интересах.
Объединим эти значения IO. К счастью, мы уже решали очень похожую задачу: получали два числа, и если они существовали, то мы складывали их. val aOption: Option[Int] = Some(2) val bOption: Option[Int] = Some(4) aOption + bOption → ошибка компиляции!
Компиляция этого кода завершается неудачей, поскольку Option[Int] не поддерживает операцию сложения. Вы не можете просто сложить два потенциально отсутствующих значения!
Вспомните, как мы объединяли два и более значения Option. Мы использовали нашего старого доброго друга (функцию flatMap) внутри for-выражения. val result: Option[Int] = for { Это for-выражение создает значение типа a