Семантика, а не синтаксис; Расширение возможностей разработчиков с помощью функционального программирования
Эта статья - просто сборник моих мыслей о моих любимых языках и о том, почему они мне нравятся. По большей части я думаю, что разработчики программного обеспечения действуют как художники; на нашу привязанность или нежелание использовать различные технологии в значительной степени влияют новизна, эмоциональная связь и личные ассоциации. Нам нравится то, что нам нравится, не обязательно то, что "правильно", если вообще есть что-то правильное.
Однако в последнее время я видел, как несколько языков вызывают радость у меня и других разработчиков, и я потратил некоторое время на размышления о том, почему это так; что заставляет эти (казалось бы, несопоставимые и несвязанные) языки, похоже, вызывать одинаковое рвение и интерес у их пользователей.
Это кажущееся несоответствие очень важно. Rust, Elixir, f # и Go нельзя спутать друг с другом, но эмоциональная реакция их сторонников кажется знакомой. И между различными причудами определения функций, платформ, определений объектов и т. д, кажется, есть более фантастическая этика дизайна, которая привлекает людей.
Поэтому я хотел бы выделить некоторые из тех, которые я заметил, и, возможно, немного объяснить, почему я считаю их важными для нас.
Примечание. Я буду использовать здесь примеры из крошечной реализации Snake Game, которую я написал на F #, потому что этот язык иллюстрирует практически все, о чем я буду говорить сегодня. Кроме того, мне это нравится.
Неизменяемость по умолчанию
Если бы мне пришлось выдвинуть единственную наиболее мощную из этих языковых семантик, чтобы повлиять и улучшить тип программ, которые мы пишем, это должно было бы быть решение сделать переменные не изменяемыми по умолчанию после инициализации. Концепция явной мутации кажется настолько фундаментально вписанной в то, что означает программирование, что идея работы без нее кажется немыслимой. Что значит программировать с его помощью?
Для компьютера - не так уж много. Под капотом языки, которые используют неизменяемость, - это те же переменные и пробелы в памяти, к которым мы привыкли. Но для разработчика очень важно иметь возможность гарантировать, что данные могут быть только такими, какими вы их определили в первый раз. И если вы хотите чего-то нового, вы можете использовать первую вещь в качестве шаблона для новой вещи, но это не одно и то же.
Давайте посмотрим на пример.
[<StructAttribute>]
type Game = { Food: Food; Snake: Snake; Size: int; Status: Status }
let advance game =
match game with
| AlreadyOver -> game
...
| EatsFood ->
let newSnake = updateSnake game.Snake true
let newFood = createFood game.Size (newSnake.Head :: newSnake.Tail)
{ game with Snake = newSnake; Food = newFood }
Здесь у нас есть основной тип данных для нашей реализации Snake, тип Game
, запись / объект с полями Food типа Food
, Snake типа Snake
, Size типа int
и Status типа Status
. Мы узнаем, что это за типы, немного позже в этой статье, но сейчас я хочу сосредоточиться на фрагменте функции advance()
, показанной ниже.
advance()
- это функция, которая принимает игру и возвращает игру. Я обрезал большую часть реализации, но сохранил ту часть, где продвижение определило, что змея съела кусок еды.
Посмотрим на порядок действий:
let newSnake = updateSnake game.Snake true
используется для создания новой змейки на основе состояния старой.let newFood = createFood game.Size (newSnake.Head:: newSnake.Tail)
создает новый кусок еды, передав размер сетки и нашу новую змейку.- Наконец мы возвращаемся к
{ game with Snake = newSnake; Food = newFood }
. Теперь это выглядит очень похоже на обновление с учетом состояния. Он меняет игровые поля на эти новые значения. Но он делает новую запись, используя значения из старой игры, но с этими новыми изменениями.
Старая игра осталась неизменной. Игра вернула совершенно другое значение. Но семантика языка делает процесс создания новых значений менее затратным, эффективным и разумным. Таким образом, нам не нужно беспокоиться о действиях, которые впоследствии могут случайно изменить предыдущие значения.
Очень важно подумать об этой последней части. Дело не в том, что мы не можем программировать подобным образом на других языках. Просто их семантика делает это менее стоящим. Труднее отследить, мутируешь ты или нет. Идиоматические методы и функции в этих языках мутируют. Слишком частое создание новых значений может привести к снижению производительности. Все это семантические барьеры для использования неизменяемых значений и лишают разработчика возможности использовать этот стиль программирования, что приводит к более ненадежному коду.
В таких языках, как F # и Rust, ключевое слово mutable
является преднамеренным индикатором языка, значение которого вы собираетесь изменить. В таких языках, как Elm, вы вообще не можете производить мутацию. Но в любом случае это заставляет программиста гораздо больше задуматься о том, как они меняют состояния в своем коде. А в сфере разработки программного обеспечения важна продуманность *.
Нет значений по умолчанию
Я не буду тратить на это слишком много времени, так как многие, многие люди рассказывали об опасностях нулевых значений.
Достаточно сказать, что трудно доверять типам, набору текста и самим функциям на языках, которые не гарантируют или не могут гарантировать, что функция вернет тип, который вы ожидаете, и, возможно, что более важно, не принуждает вас писать операции, которые возвращают типы, которым вы говорите, что они делают.
Это нормально, если функция возвращает Some value
или Nothing
. Это семантически правильная логическая операция. Иногда что-то не получается. Что не хорошо, так это то, что язык вставляет нулевое значение, потому что вы забыли вернуть значение на всех путях операции в вашей функции. Трудно писать и использовать код, которому вы не можете доверять. Трудно читать и следовать документам, если каждая функция не может возвращать значение, которое она должна.
let changeDirection game proposedChange =
match proposedChange with
| Perpendicular game.Snake.Direction ->
{ game with Snake = { game.Snake with Direction = proposedChange } }
| _ -> game
Функция changeDirection
отвечает за изменение способа движения змеи. У него есть некоторая логика защиты, чтобы убедиться, что направление змеи может изменяться только перпендикулярно. Змея, движущаяся вверх, может двигаться влево или вправо, но не может, например, вернуться обратно в себя.
| _ -> game
- это случай по умолчанию для нашего оператора match (switch), когда мы возвращаем игру, которая была введена без изменений. И F # будет жаловаться, если:
- Мы забываем значение по умолчанию (или не учитываем все возможные формы ввода)
- Мы возвращаем из этой функции что угодно, кроме игры, в любой момент. Он не будет компилироваться, если мы не скажем ему, что эта функция может возвращать игру или что-то еще. Но тогда, везде, где мы вызываем эту функцию, нам придется иметь дело с тем фактом, что она может вернуть игру, а может и нет. Все наши входные данные должны соответствовать нашим выходным данным.
И это означает, что если я скажу, что функция возвращает значение int
, сам язык гарантирует, что я не лгу, и я бы предпочел не быть лжецом.
Почти каждый известный мне язык, созданный за последнее десятилетие, не имеет значения по умолчанию для своих функций и объектов. То, что задумывалось как удобство, оказалось недостатком, и оказалось, что разработчики предпочитают жить без него.
Краткость
У меня много мыслей о краткости. Так много мыслей. Я не могу написать их все, потому что есть что-то неправильное в том, чтобы не быть кратким в том, говоря о сущности краткого.
Итак, вкратце)
Популярность языков программирования и парадигм падает и падает. Но ничто по-настоящему не исчезает. В 90-е и более поздние годы мы видели C#, C++ и Java, возможно, в период их расцвета в качестве современного языка разработки программного обеспечения. Много раз утверждалось, что появление динамических языков, таких как python, ruby и javascript, стало прямым ответом на то, что разработчики почувствовали трения из-за накладных расходов корпоративных языков.
Некоторые думают, что это сопротивление жесткости статической печати. Разработчики хотели большей свободы и тратили меньше времени на «споры о типах», отдавая предпочтение выполнению действий, а не определению структур.
Я думаю, что это часть этого, а не все. В частности, я не верю, что проблема в типах была обязательной, это скорее побочный ущерб от невероятно подробного синтаксиса языка.
Фигурные скобки, модификаторы доступности, точки с запятой, точка с запятой, точка с запятой и определения типов всегда везде создавали устрашающий синтаксис для новых разработчиков и, казалось, добавляли нагрузки для начинающего разработчика; чем лучше вы понимали язык, тем больше кода вам приходилось писать, чтобы выразить себя.
let private opposite = // Direction -> Direction
function
| Up -> Down
| Down -> Up
| Left -> Right
| Right -> Left
Вот частная функция в F #, которая возвращает противоположное направление. F # не делает явных операторов возврата в своих функциях; все является выражением, поэтому тело функции является допустимым возвратом. Отступы определяют границы тела функции. Новые строки определяют следующий регистр в переключателе. Стрелки (->) отделяют случаи от результатов. Мизансцена.
Такие языки, как python, F # и Rust, в отличие от более старых итераций корпоративных языков, делают все возможное, чтобы исключить излишний синтаксис, подробные символы и чрезмерно детальное описание каждой конструкции. Они используют пробелы как синтаксис; идея, которая, возможно, не имеет большого смысла для компиляции, но имеет огромное значение для удобства чтения человеком. Код, который люди могут читать и анализировать, лексически лаконичен.
По большому счету, языки становятся короче и выразительнее, больше полагаясь на интуитивно понятные пробелы для определения области видимости.
А что касается вопроса о подробных определениях типов:
Вывод типа
В качестве прямого продолжения модели краткости, описанной выше, недавно мы увидели появление вывода типа (способность компилятора языка или среды выполнения определять и применять типы на основе использования).
Весь код F #, который я вам показал, полностью / строго типизирован. Каждый параметр функции и возврат функции были выведены и реализованы средством проверки типов.
Некоторые инструменты, такие как расширение VSCode Ionide, используют это преимущество и отображают для вас типы.
Все комментарии к типу, которые вы видите здесь, накладываются на код. На самом деле они не записываются в файл.
Мне трудно вернуться к динамическим языкам, когда я знаю, что могу получить все преимущества сильных типов и гарантии времени компиляции без необходимости явно записывать всю информацию о типе.
Безопасность сочетается с краткостью. По общему признанию, вывод типа не идеален, и вы теряете контекст, если читаете код вне оптимизированного редактора, но в этот момент все еще не хуже, чем если бы код был динамическим, и вы все еще знаете, что вся логика безопасна по типу и проверяется.
Я никогда лично не чувствовал, что замедление, о котором упоминали энтузиасты динамического программирования, связано с использованием статических типов - я думаю, что пишу рабочий код быстрее с помощью строгой типизации - но если вас беспокоит скорость и выразительность, вывод типов кажется отличным способом смягчить его.
Абстрактные типы данных
Мы достигли финального паттерна, который я хочу здесь обсудить, и я чувствую, что оставил лучшее напоследок; по крайней мере, для меня это мой личный фаворит из всего, что мы обсуждали здесь, и тот, который оказал наибольшее влияние на мое развитие как разработчика.
Алгебраические типы данных, также известные как Пользовательские типы, такие как Объединение и Типы продуктов, являются относительно простой концепцией с глубокими приложениями.
В конечном счете, программирование дает машине инструкции для выполнения значимой работы. А современное программирование предполагает создание абстракций, которые создают поддерживаемый код, выполняющий желаемое нами поведение. Значения, функции, классы, модули и все эти другие пространства имен позволяют нам определять конструкции и идеи, которые отображают реальную область наших усилий в программное пространство структур данных и логики.
Алгебраические структуры данных (ADT) обеспечивают простой синтаксис для выражения формы проблемы с минимальными накладными расходами.
Посмотрим как.
type Game = { Food: Food; Snake: Snake; Size: int; Status: Status }
and Snake = { Head: Head; Tail: Tail; Direction: Direction }
and Head = Position
and Tail = Position list
and Food = Position
and Position = int * int
and Status =
| Active
| Won
| Lost
and Direction =
| Up
| Down
| Left
| Right
Это типы, представляющие сферу моей игры «Змейка». Так сказать, концепции, которые имеют значение для идеи змеи. Что такое змейка?
Какой минимальный объем информации необходим для игры в змейку?
Здесь происходит несколько интересных вещей:
- Структура данных для игры состоит из более мелких структур.
- Мы можем легко создавать псевдонимы типов (давать им более семантически значимые имена, соответствующие контексту нашего приложения). Как
Food
и -Head
змеи это просто позиции, но мы можем использовать их псевдонимы в нашем коде для большей ясности. - Статус и Направление являются типами Союза. Они похожи на перечисления, но не являются целыми числами или строками. Это полностью определенные значения, которые мы можем использовать в нашем коде, например, создавать наши собственные примитивы, уникальные для этого приложения.
Возможно, вам это не покажется особенно захватывающим, если вы скажете, что это просто причудливые перечисления и записи, но ADT полностью не обременены формой:
type Message =
| Restart
| Dir of Direction
| Tick
Здесь мы создаем тип Message
, который имеет два более простых значения Restart
и Tick
не полагающиеся ни на какие другие данные, и одно параметризованное значение Dir
, для которого требуется Direction
.
let update game msg =
match msg with
| Restart -> Game.init 10
| Dir direction -> Game.changeDirection game direction
| Tick -> Game.advance game
Когда мы потребляем этот тип данных, мы можем принимать решения и получать доступ к связанным данным с каждым значением, но не смешивать их. Мы не можем использовать направление ни в каком случае, кроме случая Dir direction
, потому что направления существуют только для этого значения.
Это позволяет нам точно моделировать домены без потерь. Выразить что-то вроде этого типа сообщения на таком языке, как Java, - нетривиальная операция, и для этого требуется значительно больше кода. Как следствие, люди редко это делают, предпочитая использовать больше мутаций и значений, допускающих значение NULL, для обработки состояний, в которых данные отсутствуют или не должны существовать.
И это вызывает больше ошибок.
Мы не должны писать код, который втискивает нашу реальную область в примитивные типы наших языков программирования; наши языки программирования должны предоставлять инструменты для точного и безотходного представления нашей области. Чем лучше представление, тем проще будет работать с данными.
Теперь ADT - это для меня первоклассная функция на любом языке, который я хочу использовать. Чем большее сопротивление язык вызывает у меня описанию того, что на самом деле представляет собой вещь, тем меньше я обнаруживаю, что мне хочется ее использовать.
Последние мысли
Если вы находитесь здесь, спасибо, что нашли время, чтобы прочитать мое небольшое любовное письмо к шаблонам, которые мне нравятся, и почему я думаю, что другим разработчикам они также нравятся.
Мы прошли весь этот путь без упоминания функционального программирования, и это сделано намеренно. В то время как почти все эти шаблоны имели свое происхождение и заметные итерации в пространстве функционального программирования, я недавно обнаружил, что ухожу от попыток разделить мир на функциональный и нефункциональный; я лучше расскажу о шаблонах, которые мне нравятся, и инструментах, которые их реализуют.
Сам F # недавно сделал то же самое со своим обновленным слоганом:
F # дает возможность каждому писать краткий, надежный и производительный код.
Цель не в том, чтобы быть функциональным или объектно-ориентированным. Это не должен быть самый популярный или самый быстрый язык.
Это помогает людям писать хороший код.
Чтобы помочь разработчикам выразить свои желания.
Это во избежание багов и ошибок.
Языки, которые хорошо воплощают эти идеи, кажутся хорошо принятыми. И дело не только в новых языках. Все старые инструменты и фреймворки думают о расширении возможностей разработчиков.
Точки с запятой здесь никогда не было. То, о чем мы только что говорили, не является рекомендациями или предложениями в руководствах по стилю. Они не похоронены в фолиантах типа «Все, что вам нужно знать о X». Они встроены в сами языковые экосистемы.
Дайте мне знать в комментариях, что вы думаете, и какие языковые шаблоны приносят вам удовольствие.