Здравствуйте, уважаемые читатели! Наши искания в области языка C# серьезно перекликаются с этой статьей, автор которой — специалист по функциональному программированию на C#. Статья — отрывок из готовящейся книги, поэтому в конце поста предлагаем за эту книгу проголосовать.
Многие программисты неявно подразумевают, что «функциональное программирование (ФП) должно реализовываться только на функциональном языке». C# — объектно-ориентированный язык, поэтому не стоит и пытаться писать на нем функциональный код.
Разумеется, это поверхностная трактовка. Если вы обладаете чуть более глубокими знаниями C# и представляете себе его эволюцию, то, вероятно, в курсе, что язык C# мультипарадигмальный (точно как и F#) и что, пусть он изначально и был в основном императивным и объектно-ориентированным, в каждой последующей версии добавлялись и продолжают добавляться многочисленные функциональные возможности.
Итак, напрашивается вопрос: насколько хорош нынешний язык C# для функционального программирования? Перед тем, как ответить на этот вопрос, я поясню, что понимаю под «функциональным программированием». Это парадигма, в которой:
- Делается акцент на работе с функциями
- Принято избегать изменения состояния
Чтобы язык способствовал программированию в таком стиле, он должен:
- Поддерживать функции как элементы 1-го класса; то есть, должна быть возможность трактовать функцию как любое другое значение, например, использовать функции как аргументы или возвращаемые значения других функций, либо хранить функции в коллекциях
- Пресекать всякие частичные «местные» замены (или вообще сделать их невозможными): переменные, объекты и структуры данных по умолчанию должны быть неизменяемыми, причем должно быть легко создавать модифицированные версии объекта
- Автоматически управлять памятью: ведь мы создаем такие модифицированные копии, а не обновляем данные на месте, и в результате у нас множатся объекты. Это непрактично в языке, где отсутствует автоматическое управление памятью
С учетом всего этого, ставим вопрос ребром:
Насколько язык C# — функциональный?
Ну… давайте посмотрим.
1) Функции в C# — действительно значения первого класса. Рассмотрим, например,
следующий код:
Func<int, int> triple = x => x * 3;
var range = Enumerable.Range(1, 3);
var triples = range.Select(triple);
triples // => [3, 6, 9]
Здесь видно, что функции – действительно значения первого класса, и можно присвоить функцию переменной triple, после чего задать ее в качестве аргумента Select.
На самом деле, поддержка функций как значений первого класса существовала в C# с первых версий языка, это делалось при помощи типа Delegate. Впоследствии были введены лямбда-выражения, и поддержка этой возможности на уровне синтаксиса только улучшилась.
В языке обнаруживаются некоторые причуды и ограничения, когда речь заходит о выводе типов (особенно, если мы хотим передавать многоаргументные функции другим функциям в качестве аргументов); об этом мы поговорим в главе 8. Но, вообще, поддержка функций как значений первого класса здесь достаточно хороша.
2) В идеале, язык также должен пресекать местные замены. Здесь – самый крупный недостаток C#; все по умолчанию изменяемо, и программисту требуется немало потрудиться, чтобы обеспечить неизменяемость. (Сравните с F#, где переменные по умолчанию неизменяемы, и, чтобы переменную можно было менять, ее требуется специально пометить как mutable.)
Что насчет типов? Во фреймворке есть несколько неизменяемых типов, например, string и DateTime, но определяемые пользователем изменяемые типы в языке поддерживаются плохо (хотя, как будет показано ниже, ситуация немного исправилась в C#, и в последующих версиях также должна улучшаться). Наконец, коллекции во фреймворке являются изменяемыми, но уже имеется солидная библиотека неизменяемых коллекций.
3) С другой стороны, в C# выполняется более важное требование: автоматическое управление памятью. Таким образом, хотя язык и не стимулирует стиль программирования, не допускающий местных замен, программировать в таком стиле на C# удобно благодаря сборке мусора.
Итак, в C# очень хорошо поддерживаются некоторые (но не все) приемы функционального программирования. Язык эволюционирует, и поддержка функциональных приемов в нем улучшается.
Далее рассмотрим несколько черт языка C# из прошлого, настоящего и обозримого будущего – речь пойдет о возможностях, особенно важных в контексте функционального программирования.
Функциональная сущность LINQ
Когда вышел язык C# 3 одновременно с фреймворком .NET 3.5, там оказалась масса возможностей, по сути заимствованных из функциональных языков. Часть из них вошла в библиотеку (System.Linq
), а некоторые другие возможности обеспечивали или оптимизировали те или иные черты LINQ – например, методы расширения и деревья выражений.
В LINQ предлагаются реализации многих распространенных операций над списками (или, в более общем виде, над «последовательностями», именно так с технической точки зрения нужно называть IEnumerable
); наиболее распространенные из подобных операций – отображение, сортировка и фильтрация. Вот пример, в котором представлены все три:
Enumerable.Range(1, 100).
Where(i => i % 20 == 0).
OrderBy(i => -i).
Select(i => $”{i}%”)
// => [“100%”, “80%”, “60%”, “40%”, “20%”]
Обратите внимание, как Where, OrderBy и Select принимают другие функции в качестве аргументов и не изменяют полученный IEnumerable, а возвращают новый IEnumerable, иллюстрируя оба принципа ФП, упомянутые мною выше.
LINQ позволяет запрашивать не только объекты, находящиеся в памяти (LINQ to Objects), но и различные иные источники данных, например, SQL-таблицы и данные в формате XML. Программисты, работающие с C#, признали LINQ в качестве стандартного инструментария для работы со списками и реляционными данными (а на такую информацию приходится существенная часть любой базы кода). С одной стороны это означает, что вы уже немного представляете, каков из себя API функциональной библиотеки.
С другой стороны, при работе с иными типами специалисты по C# обычно придерживаются императивного стиля, выражая задуманное поведение программы в виде последовательных инструкций управления потоком. Поэтому большинство баз кода на C#, которые мне доводилось видеть – это чересполосица функционального (работа с IEnumerable
и IQueryable
) и императивного стиля (все остальное).
Таким образом, хотя C#-программисты и в курсе, каковы достоинства работы с функциональной библиотекой, например, с LINQ, они недостаточно плотно знакомы с принципами устройства LINQ, что мешает им самостоятельно использовать такие приемы при проектировании.
Это – одна из проблем, решить которые призвана моя книга.
Функциональные возможности в C#6 и C#7
Пусть C#6 и C#7 и не столь революционны, как C#3, эти версии привносят в язык множество мелких изменений, которые в совокупности значительно повышают удобство работы и идиоматичность синтаксиса при написании кода.
ПРИМЕЧАНИЕ: Большинство нововведений в C#6 и C#7 оптимизируют синтаксис, а не дополняют функционал. Поэтому, если вы работаете со старой версией C#, то все равно сможете пользоваться всеми приемами, описанными в этой книге (разве что ручной работы будет чуть больше). Однако, новые возможности значительно повышают удобочитаемость кода, и программировать в функциональном стиле становится приятнее.
Рассмотрим, как эти возможности реализованы в нижеприведенном листинге, а затем обсудим, почему они важны в ФП.
Листинг 1. Возможности C#6 и C#7, важные в контексте функционального программирования
using static System.Math; <1>
public class Circle
{
public Circle(double radius)
=> Radius = radius; <2>
public double Radius { get; } <2>
public double Circumference <3>
=> PI * 2 * Radius; <3>
public double Area
{
get
{
double Square(double d) => Pow(d, 2); <4>
return PI * Square(Radius);
}
}
public (double Circumference, double Area) Stats <5>
=> (Circumference, Area);
}
using static
обеспечивает неквалифицированный доступ к статическим членамSystem.Math
, например,PI
иPow
ниже- Авто-свойство
getter-only
можно установить только в конструкторе - Свойство в теле выражения
- Локальная функция – это метод, объявленный внутри другого метода
- Синтаксис кортежей C#7 допускает имена членов
Импорт статических членов при помощи «using static»
Инструкция using static
в C#6 позволяет “импортировать” статические члены класса (в данном случае речь идет о классе System.Math
). Таким образом, в нашем случае можно вызывать члены PI
и Pow
из Math
без дополнительной квалификации.
using static System.Math;
//...
public double Circumference
=> PI * 2 * Radius;
Почему это важно? В ФП приоритет отдается таким функциям, поведение которых зависит лишь от их входных аргументов, поскольку можно отдельно протестировать каждую такую функцию и рассуждать о ней вне контекста (сравните с методами экземпляров, реализация каждого из них зависит от членов экземпляра). Эти функции в C# реализуются как статические методы, поэтому функциональная библиотека в C# будет состоять в основном из статических методов.
Инструкция using static
облегчает потребление таких библиотек и, хотя злоупотребление ею может приводить к загрязнению пространства имен, умеренное использование дает чистый, удобочитаемый код.
Более простые неизменяемые типы с getter-only авто-свойствами
При объявлении getter-only
авто-свойства, например, Radius
, компилятор неявно объявляет readonly резервное поле. В результате значение этим свойствам может быть присвоено лишь в конструкторе или внутристрочно.
public Circle(double radius)
=> Radius = radius;
public double Radius { get; }
Getter-only
автосвойства в C#6 облегчают определение неизменяемых типов. Это видно на примере класса Circle: в нем есть всего одно поле (резервное поле Radius
), предназначенное только для чтения; итак, создав Circle
, мы уже не можем его изменить.
Более лаконичные функции с членами в теле выражения
Свойство Circumference
объявляется вместе с «телом выражения», которое начинается с =>
, а не с обычным «телом инструкции», заключаемым в { }
. Обратите внимание, насколько лаконичнее этот код по сравнению со свойством Area!
public double Circumference
=> PI * 2 * Radius;
В ФП принято писать множество простых функций, среди которых полно однострочных. Затем такие функции компонуются в более сложные рабочие управляющие потоки. Методы, объявляемые в теле выражения, в таком случае сводят к минимуму синтаксические помехи. Это особенно наглядно, если попробовать написать функцию, которая возвращала бы функцию – в книге это делается очень часто.
Синтаксис с объявлением в теле выражения появился в C#6 для методов и свойств, а в C#7 стал более универсальным и применяется также с конструкторами, деструкторами, геттерами и сеттерами.
Локальные функции
Если приходится писать множество простых функций, это означает, что зачастую функция вызывается всего из одного места. В C#7 это можно запрограммировать явно, объявляя методы в области видимости метода; например, метод Square объявляется в области видимости геттера Area
.
get
{
double Square(double d) => Pow(d, 2);
return PI * Square(Radius);
}
Оптимизированный синтаксис кортежей
Пожалуй, в этом заключается важнейшее свойство C#7. Поэтому можно с легкостью создавать и потреблять кортежи и, что самое важное, присваивать их элементам значимые имена. Например, свойство Stats
возвращает кортеж типа (double, double)
, но дополнительно задает значимые имена для элементов кортежа, и по ним можно обращаться к этим элементам.
public (double Circumference, double Area) Stats
=> (Circumference, Area);
Причина важности кортежей в ФП, опять же, объясняется все той же тенденцией: разбивать задачи на как можно более компактные функции. У на может получиться тип данных, применяемый для захвата информации, возвращаемой всего одной функцией и принимаемой другой функцией в качестве ввода. Было бы нерационально определять для таких структур выделенные типы, не соответствующие никаким абстракциям из предметной области – как раз в таких случаях и пригодятся кортежи.
В будущем язык C# станет функциональнее?
Когда в начале 2016 года я писал черновик этой главы, я с интересом отметил, что все возможности, вызывавшие у команды разработчиков «сильный интерес», традиционно ассоциируются с функциональными языками. Среди этих возможностей:
- Регистрируемые типы (неизменяемые типы без трафаретного кода)
- Алгебраические типы данных (мощное дополнение к системе типов)
- Сопоставление с шаблоном (напоминает оператор `switch` переключающий “форму” данных, например, их тип, а не только значение)
- Оптимизированный синтаксис кортежей
Однако, пришлось довольствоваться лишь последним пунктом. В ограниченном объеме реализовано и сопоставление с шаблоном, но пока это лишь бледная тень сопоставления с шаблоном, доступного в функциональных языках, и на практике такой версии обычно недостаточно.
С другой стороны, такие возможности планируются в следующих версиях, и уже идет проработка соответствующих предложений. Таким образом, в будущем мы, вероятно, увидим в C# регистрируемые типы и сопоставление по шаблону.
Итак, C# и далее будет развиваться как мультипарадигмальный язык со все более выраженным функциональным компонентом.
Автор: Издательский дом «Питер»