|
|
|||||||||||||||||||||||||||||
|
Введение в функциональное программирование для .NET-разработчиковИсточник: Журнал MSDN Крис Маринос
Наверняка в последнее время вы хоть что-то слышали о F# - новейшем пополнении в семействе языков для Microsoft Visual Studio. Для изучения F# есть много веских причин:четкий синтаксис, мощные средства работы с множеством потоков и способность к взаимодействию с другими языками, совместимыми с Microsoft .NET Framework. Однако F# включает несколько новых важных концепций, в которые нужно вникнуть, прежде чем пользоваться ими. Краткий экскурс - неплохой способ приступить к освоению другого объектно-ориентированного языка или даже динамического языка вроде Ruby или Python. Такое возможно потому, что вам уже известна большая часть словарного запаса подобных языков и вы просто изучаете новый синтаксис. Но F# стоит здесь особняком. F# - это язык функционального программирования, и его словарь отличается куда больше, чем вы могли бы ожидать. Более того, функциональные языки традиционно применялись в академических кругах, поэтому определения совершенно новых для вас терминов могут оказаться сложными в понимании. К счастью, F# проектировали вовсе не как академический язык. Его синтаксис позволяет с помощью методик функционального программирования решать задачи новыми и более эффективными способами, в то же время поддерживая объектно-ориентированные и императивные стили, к которым вы привыкли как .NET-разработчик. В отличие от других .NET-языков структура F# на основе множества парадигм означает, что вы свободны в выборе наиболее подходящего для конкретной задачи стиля программирования. Функциональное программирование в F# заключается в написании четкого и эффективного кода для решения проблем реального программного обеспечения. Оно завязано на такие методики, как применение функций более высокого порядка (higher order functions) и композиция функций (function composition) для создания эффективных и простых в понимании поведений. И оно же состоит в том, чтобы ваш код было легче воспринимать, тестировать и распараллеливать, удаляя скрытые сложности. Но, чтобы воспользоваться преимуществами всех этих фантастических средств F#, нужно разобраться в основах. В этой статье я поясню базовые концепции, используя словарь, к которому вы уже привыкли в .NET. Я также покажу некоторые приемы функционального программирования, которые вы сможете применять в существующем коде, и некоторые способы, при которых вы уже программировали функционально. К концу статьи вы поймете функциональное программирование в достаточной мере, чтобы начать работу с F# в Visual Studio 2010. Основы функционального программированияБольшинству .NET-разработчиков легче понять функциональное программирование, отталкиваясь от обратного - от того, чем оно не является.Императивное программирование считается стилем, прямо противоположным функциональному. Это также стиль, с которым вы, вероятно, знакомы лучше всего, поскольку большинство мейнстримовых языков программирования являются императивными. Функциональное и императивное программирование отличаются на фундаментальном уровне, и это можно увидеть даже в таком простейшем коде: int number = 0; number++; Очевидно, что здесь значение переменной увеличивается на единицу. Ничего особенного, но взгляните на другой способ решения той же задачи: const int number = 0; const int result = number + 1; Значение number по-прежнему увеличивается на единицу, но оно не модифицируется по месту. Результат сохраняется как другая константа, потому что компилятор не разрешает изменять значение константы. Константы неизменяемы, потому что их значения нельзя модифицировать после определения. И напротив, переменная number в первом примере была изменяемой, так как вы могли модифицировать ее значение. Эти два подхода иллюстрируют одно из фундаментальных различий между императивным и функциональным программированием. В императивном программировании делается упор на использование изменяемых переменных, тогда как в функциональном - на применение неизменяемых значений. Большинство .NET-разработчиков сказало бы, что number и result в предыдущих примерах являются переменными, но как "функциональный" программист вы должны аккуратнее подбирать слова. В конце концов, одна лишь идея постоянной переменной может просто запутать в лучшем случае. Вместо этого в функциональном программировании говорят, что number и result являются значениями. Термин "переменная" резервируется за объектами, которые можно изменять. Заметьте, что эти термины - не эксклюзив функционального программирования, но они гораздо важнее при программировании в функциональном стиле. Разница может показаться незначительной, но это фундамент множества концепций, которые и делают функциональное программирование таким эффективным. Изменяемые переменные являются корневой причиной многих неприятнейших ошибок. Как вы увидите, они ведут к неявным зависимостям между различными частями вашего кода, что создает массу проблем, особенно в сочетании с параллельным выполнением. В противоположность этому неизменяемые переменные убирают значительную часть проблем. Они делают возможным применение методик функционального программирования, при котором, например, функции используются как значения, и композиционного программирования, о котором я также расскажу подробнее чуть позже. Если на данном этапе функциональное программирование вызывает у вас скепсис, не волнуйтесь. Это естественно. Большинство "императивных" программистов обучали тому, что с неизменяемыми значениями нельзя сделать ничего полезного. Но рассмотрим такой пример: string stringValue = "world!"; string result = stringValue.Insert(0, "hello "); Функция Insert формирует строку "hello world!", но вы знаете, что Insert не модифицирует исходную строку. А все потому, что в .NET строки неизменяемы. Проектировщики .NET Framework использовали здесь функциональный подход, так как он упрощает написание более качественного кода, работающего со строками. Поскольку строки - один из самых широко применяемых типов данных в .NET Framework (наряду с другими базовыми типами вроде целых, DateTimes и прочими), все шансы за то, что на самом деле вы шире используете функциональное программирование, чем вам это кажется. Приступая к работе с F#F# поставляется с Visual Studio 2010, и самую последнюю его версию можно найти по ссылке msdn.microsoft.com/vstudio. Если вы используете Visual Studio 2008, то скачайте надстройку для F# из F# Developer Center по ссылке msdn.microsoft.com/fsharp, где вы также найдете инструкции по установке Mono. F# добавляет в Visual Studio новое окно - F# Interactive, которое позволяет интерактивно выполнять код на F#. Вы можете считать его более мощной версией окна Immediate, доступной даже в том случае, если вы не переключались в режим отладки. Если вы знаете Ruby или Python, то заметите, что F# Interactive выполняет цикл "чтение-оценка-вывод" (Read-Evaluate-Print Loop, REPL), который является полезным средством для освоения F# и возможностью быстро поэкспериментировать с кодом. Я буду использовать F# Interactive, чтобы показать, что происходит при компиляции и выполнении кода. Если вы выделите какой-то код в Visual Studio и нажмете Alt+Enter, то отправите его в F# Interactive. Возьмем простой пример на F#: let number = 0 let result = number + 1 Запустив этот код в F# Interactive, вы получите следующее: val number : int = 0 val result : int = 1 Вероятно, вы уже догадались по ключевому слову val, что number и result являются неизменными значениями. Вы можете увидеть это, использовав оператор присваивания (<-) в F#: > number <- 15;; number <- 15;; ^^^^^^^^^^^^ stdin(3,1): error FS0027: This value is not mutable > Поскольку вы теперь знаете, что функциональное программирование основано на неизменяемости, эта ошибка должна быть вам понятной. Ключевое слово let используется для создания неизменяемых привязок между именами и значениями. В терминологии C# то же самое можно было сказать так:по умолчанию в F# все является константами. Вы можете сделать переменную изменяемой, но только явным образом. По умолчанию поведение прямо противоположно тому, к чему вы привыкли в императивных языках: let mutable myVariable = 0 myVariable <- 15 Логическое распознавание типов и чувствительность к пробелам и отступамF# позволяет объявлять переменные и значения без указания их типов, поэтому можно подумать, будто F# является динамическим языком, но это не так. Важно понимать, что F# такой же статический язык, как C# или C++. Однако F# имеет мощную систему логического распознавания типов (type inference), которая позволяет во многих местах не указывать типы объектов. Это упрощает синтаксис и делает его лаконичнее, в то же время сохраняя безопасность типов, присущую статическим языкам. Хотя такие системы логического распознавания типов в действительности отсутствуют в императивных языках, само по себе распознавание типов не связано напрямую с функциональным программированием. Однако распознавание типов - критическая важная концепция, которую нужно понять, если вы хотите освоить F#. К счастью, если вы знаете C#, то наверняка уже знакомы с базовой концепцией логического распознавания типов из-за ключевого слова var: // Here, the type is explictily given Dictionary<string, string> dictionary = new Dictionary<string, string>(); // but here, the type is inferred var dictionary = new Dictionary<string, string>(); Обе строки кода на C# создают новые переменные, которые статически типизируются как Dictionary<string, string>, но во втором случае ключевое слово var сообщает компилятору логически определять тип переменной за вас. F# выводит эту концепцию на новый уровень. Например, вот функция add в F#: let add x y = x + y let four = add 2 2 В приведенном выше коде нет ни одного указания типа, но F# Interactive раскрывает статическую типизацию: val add : int -> int -> int val four : int = 4 Смысл стрелок я подробнее объясню потом, а пока вы можете интерпретировать это так:функция add принимает два int-аргумента, а four является значением типа int. Компилятор F# смог логически распознать все типы, исходя из определений add и four. Компилятор использует при этом правила, которые выходят за рамки данной статьи, но, если вас это интересует, вы можете узнать больше в F# Developer Center. Логическое распознавание типов - один из способов, с помощью которых F# удаляет лишний "шум" из вашего кода, но обратите внимание еще и на отсутствие фигурных скобок и ключевых слов, обозначающих тело функции add или ее возвращаемое значение. А все дело в том, что F# по умолчанию является языком, чувствительным к пробелам и отступам. В F# вы указываете тело функции простыми отступами, а возвращаемое значение - тем, что оно находится в последней строке функции. По аналогии с распознаванием типов чувствительность к пробелам и отступам не имеет прямого отношения к функциональному программированию, но вы должны быть знакомы с этой концепцией, чтобы пользоваться F#. Побочные эффектыТеперь вы знаете, что функциональное программирование отличается от императивного тем, что опирается на неизменяемые значения, а не на модифицируемые переменные, но сам по себе этот факт не слишком полезен. Следующее, в чем нам предстоит разобраться, - побочные эффекты (side effects). В императивном программировании вывод функции зависит от ее входного аргумента и текущего состояния программы. В функциональном - функция зависит только от своих входных аргументов. Иначе говоря, когда вы вызываете функцию более одного раза с одним и тем же входным значением, вы всегда получаете одно и то же выходное значение. Причина, по которой этого нет в императивном программировании, связана с побочными эффектами, как показано на рис. 1. Рис. 1 Побочные эффекты изменяемые переменных public MemoryStream GetStream() { var stream = new MemoryStream(); var writer = new StreamWriter(stream); writer.WriteLine("line one"); writer.WriteLine("line two"); writer.WriteLine("line three"); writer.Flush(); stream.Position = 0; return stream; } [TestMethod] public void CausingASideEffect() { using (var reader = new StreamReader(GetStream())) { var line1 = reader.ReadLine(); var line2 = reader.ReadLine(); Assert.AreNotEqual(line1, line2); } } При первом вызове ReadLine поток данных считывается, пока не встретится символ перевода на новую строку. Потом ReadLine возвращает весь текст вплоть до новой строки. Между этими операциями изменяемая переменная, представляющая позицию в потоке данных, обновляется. Это и есть побочный эффект. При втором вызове ReadLine значение этой переменной (Position) уже изменилось, поэтому ReadLine возвращает другое значение. А теперь рассмотрим одно из самых важных следствий использования побочных эффектов. Во-первых, взгляните на простой класс PiggBank и некоторые методы для работы с ним ( рис. 2). Рис. 2 Изменяемые PiggyBank public class PiggyBank{ public PiggyBank(int coins){ Coins = coins; } public int Coins { get; set; } } private void DepositCoins(PiggyBank piggyBank){ piggyBank.Coins += 10; } private void BuyCandy(PiggyBank piggyBank){ if (piggyBank.Coins < 7) throw new ArgumentException( "Not enough money for candy!", "piggyBank"); piggyBank.Coins -= 7; } Если у вас есть свинья-копилка с пятью монетками внутри, вы можете вызвать DepositCoins до BuyCandy, но обратное приведет к генерации исключения: // this works fine var piggyBank = new PiggyBank(5); DepositCoins(piggyBank); BuyCandy(piggyBank); // but this raises an ArgumentException var piggyBank = new PiggyBank(5); BuyCandy(piggyBank); DepositCoins(piggyBank); Функции BuyCandy и DepositCoins обновляют состояние свиньи-копилки через побочный эффект. Соответственно поведение каждой функции зависит от состояния этой копилки. Поскольку число монеток изменяемое, порядок вызова этих функций имеет значение. Другими словами, между двумя этими методами существует неявная зависимость. Давайте сделаем число монеток только для чтения, чтобы имитировать неизменяемую структуру данных. На рис. 3 показано, что BuyCandy и DepositCoins теперь возвращают новые объекты PiggyBank вместо обновления существующего PiggyBank. Рис.3 Неизменяемые PiggyBank public class PiggyBank{ public PiggyBank(int coins){ Coins = coins; } public int Coins { get; private set; } } private PiggyBank DepositCoins(PiggyBank piggyBank){ return new PiggyBank(piggyBank.Coins + 10); } private PiggyBank BuyCandy(PiggyBank piggyBank){ if (piggyBank.Coins < 7) throw new ArgumentException( "Not enough money for candy!", "piggyBank"); return new PiggyBank(piggyBank.Coins - 7); } Как и раньше, если вы попытаетесь вызвать BuyCandy до DepositCoins, вы получите исключение: // still raises an ArgumentException var piggyBank = new PiggyBank(5); BuyCandy(piggyBank); DepositCoins(piggyBank); Но даже если вы поменяете порядок вызовов на обратный, вы получите тот же результат: // now this raises an ArgumentException, too! var piggyBank = new PiggyBank(5); DepositCoins(piggyBank); BuyCandy(piggyBank); Здесь BuyCandy и DepositCoins зависят только от своих входных аргументов, так как число монеток неизменно. Вы можете выполнять эти функции в любом порядке, и результат будет одним и тем же. Неявная зависимость между функциями исчезла. Однако, если вы хотите успешного выполнения BuyCandy, то должны сделать результат BuyCandy зависимым от выхода DepositCoins. Вводить такую зависимость нужно явным образом: var piggyBank = new PiggyBank(5); BuyCandy(DepositCoins(piggyBank)); Это тонкое различие с далеко идущими последствиями. Общее изменяемое состояние и неявные зависимости являются источником некоторых из самых трудноуловимых "багов" в императивном коде и причиной, по которой многопоточное программирование столь сложно в императивных языках. Когда приходится заботиться о порядке выполнения функций, вы должны опираться на громоздкие механизмы блокировки, иначе все пойдет наперекосяк. Чисто функциональные программы свободны от побочных эффектов и неявных зависимостей синхронизации, так что порядок выполнения функций не имеет никакого значения. То есть вам не нужно волноваться о механизмах блокировки и прочих, крайне подверженных ошибкам методиках многопоточного программирования. Простота работы с множеством потоков является основной причиной того, что в последнее время функциональное программирование привлекает повышенное внимание, но у него есть и много других преимуществ. Функции, свободные от побочных эффектов, легче тестировать, поскольку они полагаются только на свои входные аргументы. Они проще в сопровождении, потому что не полагаются неявно на логику из других функций. Функции, свободные от побочных эффектов, также имеют тенденцию к большей компактности и простоте в комбинировании. О последнем я расскажу подробнее чуть позже. В F# вы оцениваете функции по их значениям результатов, а не по побочным эффектам. В императивных языках функции обычно вызываются для выполнения чего-либо, а в функциональных - для получения некоего результата. Вы можете увидеть это в F#, взглянув на выражение if: let isEven x = if x % 2 = 0 then "yes" else "no" Вы уже знаете, что в F# последняя строка функции это ее возвращаемое значение, но в этом примере в последней строке функции содержится выражение if. Это не трюк компилятора. В F# даже выражения if спроектированы на возврат значений: let isEven2 x = let result = if x % 2 = 0 then "yes" else "no" result Значение result имеет тип string и присваивается прямо выражению if. Здесь есть аналогия с тем, как работает условный оператор в C#: string result = x % 2 == 0 ? "yes" : "no"; Условный оператор возвращает значение, не создавая побочного эффекта. Это более функциональный подход. В противоположность этому выражение if в C# более императивное, так как не возвращает результат. Все, что оно делает, вызывает побочные эффекты. Композиция функцийТеперь, когда вы увидели некоторые из преимуществ функций, свободных от побочных эффектов, вы готовы задействовать их полный потенциал в F#. Для начала рассмотрим код на C#, который возводит в квадрат числа от 0 до 10: IList<int> values = 0.Through(10).ToList(); IList<int> squaredValues = new List<int>(); for (int i = 0; i < values.Count; i++) { squaredValues.Add(Square(values[i])); } Не считая вспомогательных методов Through и Square, этот код вполне стандартен для C#. Хорошие программисты на C#, вероятно, поморщились бы от моего использования цикла for вместо foreach - и были бы правы. Современные языки вроде C# предоставляют циклы foreach в качестве абстракции для упрощения перебора перечислений, избавляя от необходимости явных индексаторов. В этом они преуспели, но взгляните на код на рис. 4. Рис. 4 Использование циклов foreach IList<int> values = 0.Through(10).ToList(); // square a list IList<int> squaredValues = new List<int>(); foreach (int value in values) { squaredValues.Add(Square(value)); } // filter out the even values in a list IList<int> evens = new List<int>(); foreach(int value in values) { if (IsEven(value)) { evens.Add(value); } } // take the square of the even values IList<int> results = new List<int>(); foreach (int value in values) { if (IsEven(value)) { results.Add(Square(value)); } } В этом примере циклы foreach очень похожи, но тело каждого цикла выполняет немного разные операции. Императивные программисты давно привыкли к такому дублированию кода, потому что этот код считается идиоматическим. Функциональные программисты используют иной подход. Вместо создания абстракций наподобие циклов foreach, помогающих перебирать списки, они применяют функции, свободные от побочных эффектов: let numbers = {0..10} let squaredValues = Seq.map Square numbers Этот код на F# тоже возводит в квадрат последовательность чисел, но делает это с помощью функции более высокого порядка. Это просто функции, которые принимают в качестве входного аргумента другую функцию. В данном случае функция Seq.map принимает функцию Square в качестве аргумента. Она применяет эту функцию к каждому числу в последовательности и возвращает последовательность чисел, возведенных в квадрат. Именно из функций более высокого порядка многие говорят, что в функциональном программировании функции используются как данные. Это означает лишь то, что функции можно передавать как параметры или присваивать значениям либо переменным точно так же, как типы int или string. В терминологии C# функции более высокого порядка очень сильно напоминают концепцию делегатов и лямбда-выражений. Применение функций более высокого порядка - одна из методик, которые делают функциональное программирование таким мощным. С их помощью вы можете выделить дублируемый в циклах foreach код и инкапсулировать его в автономные и свободные от побочных эффектов функции. Каждая из этих функций выполняет одну небольшую операцию, которую обрабатывал бы код внутри соответствующего цикла foreach. Поскольку они свободны от побочных эффектов, вы можете комбинировать их для создания более читаемого и простого в сопровождении кода, который делает то же самое, что и циклы foreach: let squareOfEvens = numbers /> Seq.filter IsEven /> Seq.map Square Единственная непонятная часть этого кода - оператор />. Этот оператор используется для большей читаемости кода, разрешая вам переупорядочивать аргументы в функции, чтобы последний аргумент первым попался вам на глаза. Его определение очень простое: let (/>) x f = f x Без оператора /> код squareOfEvens выглядел бы так: let squareOfEvens2 = Seq.map Square (Seq.filter IsEven numbers) Если вы применяете LINQ, то подобное использование функций более высокого порядка должно показаться вам очень знакомым. А все дело в том, что корни LINQ уходят глубоко в функциональное программирование. По сути, вы можете легко адаптировать задачу возведения в квадрат четных чисел под C# с применением методов LINQ: var squareOfEvens = numbers .Where(IsEven) .Select(Square); Это транслируется в следующий синтаксис LINQ-запроса: var squareOfEvens = from number in numbers where IsEven(number) select Square(number); Использование LINQ в коде на C# или Visual Basic позволяет эксплуатировать некоторые возможности функционального программирования в повседневной работе. И это отличный способ освоения методик такого программирования. Когда вы начнете регулярно использовать функции более высокого порядка, вы в конце концов придете к такой ситуации, в которой захотите создать небольшую, очень специфическую функцию для передачи в функцию более высокого порядка. Для решения этой задачи "функциональные" программисты используют лямбда-функции. Это просто функции, которые вы определяете, не присваивая имен. Обычно они очень маленькие и имеют сугубо специфическое применение. Например, вот еще один способ, которым вы могли бы возводить в квадрат четные числа, используя лямбду: let withLambdas = numbers /> Seq.filter (fun x -> x % 2 = 0) /> Seq.map (fun x -> x * x) Единственное различие между этим и предыдущим кодом заключается в том, что Square и IsEven определены как лямбды. В F# вы объявляете лямбда-функцию ключевым словом fun. Лямбды следует использовать только для объявления функций одного специфического применения, потому что их сложно задействовать вне области видимости, в которой они были определены. По этой причине Square и IsEven - плохой выбор на роль лямбда-функций, так как они полезны во многих ситуациях. Карринг и частичное применениеТеперь вы знаете почти все основы, необходимые для того, чтобы приступить к работе с F#, но есть еще одна концепция, которую вы должны освоить. В предыдущих примерах оператор /> и стрелки в сигнатурах типов из F# Interactive тесно связаны с концепцией так называемого карринга. Карринг (currying) - это разбиение функции со множеством аргументов на серию функций, каждая из которых принимает один аргумент, и в конечном счете они дают тот же результат, что и исходная функция. Карринг, по-видимому, является в этой статье самой трудной концепцией для .NET-разработчика, особенно из-за того, что его часто путают с частичным применением (partial application). Работу обеих концепций иллюстрирует следующий пример: let multiply x y = x * y let double = multiply 2 let ten = double 5 Прямо сейчас вы должны увидеть поведение, отличающееся от того, которое вы наблюдали бы в большинстве императивных языков. Второе выражение создает новую функцию double передачей одного аргумента в функцию, которая принимает два. Результат - функция, которая принимает один аргумент типа int и возвращает то же самое, как если бы вы вызвали multiply с x, равным 2, и y, равным этому аргументу. По своему поведению этот код эквивалентен: let double2 z = multiply 2 z Зачастую в таком случае ошибочно полагают, что multiply разбивается до double. Но это верно лишь отчасти. Функция multiply подвергается каррингу, однако это происходит, когда она определена, так как в F# функции подвергаются каррингу по умолчанию. Когда создается функция double, точнее сказать, что функция multiply применяется частично. Давайте пройдем все этапы в деталях. Еще раз повторю, что при карринге функция с множеством аргументов разбивается на серию функций с одним аргументом, которые в конечном счете дают тот же результат, что и исходная. Функция multiply имеет следующую сигнатуру типа согласно F# Interactive: val multiply : int -> int -> int К этому моменту мы расшифровали, что multiply является функцией с двумя аргументами типа int и возвращаемым значением того же типа. Теперь я поясню, что происходит на самом деле. Функция multiply в действительности состоит из двух функций. Первая принимает один аргумент типа int и возвращает другую функцию, в конечном счете связывающую x с конкретным значением. Эта функция также принимает один аргумент типа int, которое можно считать значением, связываемым с y. После вызова этой второй функции x и y связаны со своими значениями, поэтому результатом является произведение x на y, как определено в теле функции double. Чтобы создать double, первая функция в цепочке функций multiply оценивается как частично применяемая multiply. Полученной функции присваивается имя double. Когда оценивается double, она использует свой аргумент вместе с частично примененным значением для создания результата. Использование F# и функционального программированияТеперь, когда у вас есть достаточный словарь для того, чтобы начать работу с F# и окунуться в функциональное программирование, перед вами открывается уйма вариантов, что делать дальше. Окно F# Interactive позволяет исследовать код на F# и быстро создавать сценарии (scripts) F#. Оно также полезно для рутинной проверки поведения библиотечных функций .NET без обращения к справочным файлам или поиску в Интернете. F# превосходен в выражении сложных алгоритмов, поэтому вы можете инкапсулировать соответствующие части кода своего приложения в F#-библиотеки, чтобы потом вызывать их из других .NET-языков. Это особенно полезно в инженерных или параллельных приложениях. Наконец, вы можете использовать методики функционального программирования в своей повседневной разработке для .NET даже без написания кода на F#. Просто используйте LINQ вместо циклов for или foreach. Попробуйте применять делегаты для создания функций более высокого порядка. Ограничьте себя в использовании изменяемости и побочных эффектов в своих императивных языках. Как только вы начнете писать код в функциональном стиле, вы очень скоро захотите создавать больше кода на F#
|
|