Статьи

Функциональный стиль — часть 9

Прагматизм

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

Функциональный стиль декларативен.

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

Это даже не новая идея. Возможно, вы уже много лет используете декларативный язык обработки данных, известный как язык структурированных запросов. Этот язык позволяет программисту выбирать поля из именованных репозиториев данных, где данные соответствуют определенным указанным предикатам. При желании вы можете упорядочивать по определенным полям данных или группировать по определенным полям данных, чтобы суммировать или подсчитывать данные, и вы можете включать или исключать группы данных, имеющие определенные свойства. Вам не нужно программно перемещаться по записям, переходить по ссылкам, искать нужные данные и собирать их самостоятельно, как программисты привыкли делать с базами данных, построенными на старой модели CODASYL. Сходство между SQL и операциями сбора, ставшими возможными благодаря функциям первого порядка, описанным в частях 3 и 4, очевидно. Действительно, разработчики языка C # пошли еще дальше и дали ему SQL-подобный синтаксис под названием Language Integrated Query (LINQ).

Функциональное и объектно-ориентированное программирование.

Функциональное программирование рассматривается как новая большая вещь, и некоторые люди ошибочно полагают, что FP является преемником объектно-ориентированного программирования и каким-то образом заменяет его. Я видел много постов в блогах и статей, провозглашающих, что «ОО мертв». Я сам так не думаю. Я считаю ОО и функциональное программирование ортогональными и взаимодополняющими, а не противоположными друг другу. Очевидно, что ОО-программирование не может объективно потерять ни одно из своих качеств в абсолютном выражении, поэтому единственный способ, которым он устареет, — это прийти к чему-то еще, что даст те же преимущества и лучше. Нигде в этой серии статей мы не видели ничего подобного.

У некоторых людей есть идея, что объектно-ориентированное и функциональное программирование являются полярными противоположностями или каким-то образом взаимоисключающими. Возможно, они начали думать об этом, потому что первоклассные функции предлагают альтернативные средства создания абстракций для объектно-ориентированного подхода интерфейсов и абстрактных классов. Это правда, что в Clojure я зависим от классов и интерфейсов гораздо меньше, чем в Java. Но, как мы видели в части 3, использование первоклассных функций для включения абстракции не ново. Тем не менее, идея о том, что функциональное программирование делает ОО-программирование устаревшим, просто неосведомлена. На мой взгляд, это выдает непонимание того, что такое ОО. Настоящая цель ОО — предоставить удобный и дисциплинированный способ достижения динамической диспетчеризации — другими словами, полиморфизма во время выполнения — без необходимости прибегать к указателям на функции. С этой точки зрения нет противоречия между ОО и ФП; оба стиля могут использоваться в одном и том же коде, и я думаю, что это хорошо.

В конце концов, ничего о ОО мандатах, которые вы должны изменить состояние. Конечно, такие языки, как Java и C #, делают изменчивое состояние настолько естественным, что обычно это путь наименьшего сопротивления, но это не одно и то же. Мне кажется вполне приемлемым иметь язык, который позволяет соединять данные и функции вместе, как это делают языки ОО, и поддерживает наследование и абстрактные типы, в то же время налагая дисциплину на состояние мутирования. Почему бы и нет? Никто никогда не говорил, что объекты должны быть изменяемыми.

Не уходи от сладкого места.

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

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

1
2
3
4
user=> (def v [1 2 3])
#'user/v
user=> (into v [4])
[1 2 3 4]

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

1
2
3
4
5
6
<T> List<T> into(List<T> list, T value) {
    var newList = new ArrayList<T>();
    newList.addAll(list);
    newList.add(value);
    return newList;
}

Другой вещью, которой не хватает в Java, являются инициализаторы буквальных коллекций. Я хотел бы иметь возможность создавать списки и карты на Java с той легкостью, с которой я могу это делать в Kotlin или Groovy, но, увы, на момент написания мы все еще не можем. Вы можете испытать желание использовать потоки в Java, чтобы обойти это, но будьте прагматичны. Это может показаться хорошей идеей:

1
List<String> s = Stream.of("fee", "fi", "fo", "fum").collect(toList());

но не забывайте, что вы всегда можете сделать это вместо этого:

1
List<String> s = Arrays.asList("fee", "fi", "fo", "fum");

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

1
2
3
4
5
6
Map<String, Integer> map = Stream.of(
        new AbstractMap.SimpleEntry<>("one", 1),
        new AbstractMap.SimpleEntry<>("two", 2))
        .collect(Collectors.toMap(
            AbstractMap.SimpleEntry::getKey,
            AbstractMap.SimpleEntry::getValue));

потому что это гораздо проще и гораздо более естественно сделать это:

1
2
3
Map<String, Integer> map = new HashMap<>();
map.put("one", 1);
map.put("two", 2);

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

Не используйте функции высшего порядка ради этого.

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

Это не абстракции, которые вы ищете.

Если мы согласны с тем, что можно программировать в функциональном стиле на объектно-ориентированном языке, из этого следует, что нет смысла отбрасывать преимущества, которые дает нам ОО-программирование. Самое главное, я имею в виду легкость, с которой мы можем создавать типы, которые говорят с точки зрения проблемной области. Функциональное программирование также способствует абстракции, но я заметил, что программисты, изучающие FP, склонны создавать типы, которые являются более алгоритмическими по своей природе, а не разговаривают на языке предметной области. Я бы не сказал, что вам следует избегать создания алгоритмических абстракций, но я настоятельно призываю вас не упускать возможность писать специфичный для предметной области код. Типы, которые говорят с точки зрения проблемы, которую должна решить программа — «что» — будут более полезными для программистов, которым нужно понимать ваш код после того, как вы уйдете, чем типы, которые говорят с точки зрения того, как решается проблема. решена. И помните, что более поздний программист может стать вашим будущим я!

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

Функциональный код более поточно-ориентированный.

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

На этой диаграмме проблематичен только один квадрант, но программы, как правило, собирают его так, как будто он создает какое-то гравитационное поле. Причины не загадочные. В начале параллельное программирование не существовало, потому что компьютерному оборудованию и операционным системам не хватало поддержки параллелизма. Следовательно, все компьютерные программы существовали на левой «неразделенной» половине диаграммы. Кроме того, данные были изменяемыми, потому что это было целесообразно делать из-за ограниченности ресурсов и компьютерной архитектуры. Таким образом, большая часть программирования происходила в верхнем левом углу «неразделенного и изменяемого» и оставалась там десятилетиями, пока в середине 00-х годов несколько ядер не стали стандартом для потребительского оборудования, после чего программирование перешло прямо к «общему и изменчивому». Угол Вот где боль должна быть найдена.

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

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

Пользуйтесь ясностью по умолчанию.

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

Преждевременная оптимизация — корень всего зла.

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

Кроме того, поставьте под сомнение свои предположения об эффективности кода: часто видно, что простые и понятные алгоритмы в любом случае превосходят непостижимые «оптимизированные». Дни программирования на голом железе на машинах с крошечной памятью давно прошли, и точно предсказать, как машина будет себя вести при выполнении вашей программы, нетривиально. Современные вычислительные архитектуры чрезвычайно сложны и многослойны: есть микрокод, прогнозирование ветвлений и умозрительное выполнение, кэши ЦП, многоядерные системы, гипервизоры, эмуляторы, контейнеры, операционные системы, потоки и процессы, языки высокого уровня и компиляторы, виртуальные среды выполнения, своевременная компиляция и интерпретируемые языки, кластеры и рои, сервис-ориентированные архитектуры, бессерверные вычисления и т. д. Методы программирования стали пользоваться популярностью по мере развития компьютерных систем; Подобно тому, как когда-то люди развертывали свои циклы для более быстрого выполнения, тогда кэш-память ЦП делала эту практику контрпродуктивной, но размер кешей увеличивался, и практика снова становилась эффективной.

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

Вывод.

Как написал Майкл Фезерс в 2010 году :

ОО делает код понятным за счет инкапсуляции движущихся частей. FP делает код понятным, сводя к минимуму движущиеся части.

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

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

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

Вся серия:

  1. Вступление
  2. Первые шаги
  3. Первоклассные функции I: лямбда-функции и карта
  4. Первоклассные функции II: фильтрация, уменьшение и многое другое
  5. Функции высшего порядка I: Композиция функций и монады
  6. Функции высшего порядка II: карри
  7. Ленивая оценка
  8. Постоянные структуры данных
  9. Прагматизм
Опубликовано на Java Code Geeks с разрешения Ричарда Уайлда, партнера нашей программы JCG . Смотреть оригинальную статью здесь: Функциональный стиль — часть 8

Мнения, высказанные участниками Java Code Geeks, являются их собственными.