Функциональное программирование на F# (часть 1)
#dotnet #fsharp #csharp #functional_programmingНедавно прочитал книгу 1 “Unit Testing Principles, Practices, and Patterns” Владимира Хорикова. Книга классная, фундаментальная. В плане полезности для .NET backend разработчиков, по-моему, стоит на уровне CLR via C#, Dependency Injection in .NET, Patterns of Enterprise Application Architecture и т.д. Помимо вопросов тестирования, она также вкратце объясняет вопросы функциональной архитектуры и то, как такая архитектура положительно влияет на возможность тестирования кода. Ранее мне не приходилось сталкиваться с функциональными языками программирования, но после прочтения книги очень захотелось поработать с ними. Поскольку я C# разработчик, то проще всего было начать изучать функциональное программирование с другой разработкой Microsoft - языка F#. В этой статье мы попробуем реализовать один и тот же алгоритм в функциональном стиле на C# и F# и сравнить результат.
Объектно-ориентированное программирование
Предположим, нам нужно реализовать функционал, который будет вычислять n
первых чисел ряда Фибоначчи и выводить значения в консоль. Такую задачу на C# можно было бы решить вот так 2:
public class FibonacciWriter
{
public void Write(uint n)
{
uint prev = 0;
uint next = 1;
Console.WriteLine(prev);
uint i = 1;
while(i++ < n)
{
var next2 = prev + next;
prev = next;
next = next2;
Console.WriteLine(prev);
}
}
}
Какие проблемы могут возникнуть у такой реализации? Например, если потребуется изменить вывод консоль на вывод в файл, то мы не можем этого сделать. Это явное нарушение принципа Open/Closed. Чтобы избежать таких проблем, можно добавить абстрактный метод Output
:
public abstract class FibonacciWriter
{
public void Write(uint n)
{
uint prev = 0;
uint next = 1;
Output(prev);
uint i = 1;
while(i++ < n)
{
var next2 = prev + next;
prev = next;
next = next2;
Output(prev);
}
}
protected abstract void Output(uint i);
}
public class FibonacciConsoleWriter : FibonacciWriter
{
protected override void Output(uint i) =>
Console.Write($"{i} ");
}
В целом, такая реализация более менее нормальная и часто встречается на практике, но она нарушает принцип Single Responsibility. К тому же, для такого класса трудно написать unit-тест, т.к. метод Write
ничего не возвращает и для проверки результата придётся переопределять метод Output
. Очевидно, что для решения этой проблемы нам нужно разделить расчёт ряда Фибоначчи и вывод значений. Для этого к нам на помощь придёт функциональный подход.
Функциональное программирование
Функциональное программирование основано на использовании чистых функций. Согласно Википедии, чистая функция - это функция, которая:
- Возвращает один и тот же результат для одних и тех же аргументов.
- Не имеет побочных эффектов (не выполняет изменение объектов, изменение файлов и т.д.)
Вернёмся к классу FibonacciWriter
. Является ли метод Write
- чистой функцией? Конечно же нет. Во-первых, мы не можем сопоставить результат выполнения метода с входными аргументами (метод вообще ничего не возвращает). Во-вторых, метод имеет побочные эффекты в виде изменения переменных и вывода в консоль.
Давайте перепишем класс FibonacciWriter
в функциональном стиле:
public class Fibonacci
{
public IEnumerable<uint> Get(uint n) => Next(0, 1).Take(10);
private IEnumerable<uint> Next(uint previous, uint current)
{
yield return previous;
foreach (var item in Next(current, previous + current))
{
yield return item;
}
}
}
Основные отличия функциональной реализации:
- Метод
Get
возвращает коллекцию типаIEnumerable<uint>
. Это значительно упрощает тестирование. - Данные в классе иммутабельны. Если нет изменяющихся данных, то не требуется синхронизация потоков (для данного примера не актуально, но всё же).
- Нет side-эффектов. При написании тестов не понадобится создавать mock-объект и проверять вызов метода
Output
. - Появляется возможность независимо друг от друга развивать класс
Fibonacci
и класс, отвечающий за вывод данных.
Получается, что на C# тоже можно писать в функциональном стиле. Зачем тогда использовать F#? Я вижу несколько причин:
- Данные в F# иммутабельны по-умолчанию. Язык не позволяет просто так поменять значение переменной (строго говоря, это и не переменные вовсе). Такой подход заставляет разработчика писать не так, как в классических ООП языках.
- Синтаксис F# больше заточен под функциональное программирование (спасибо, кэп).
Перепишем класс Fibonacci
на F#:
module Fibonacci =
let next (previous, current) =
Some(previous, (current, previous + current))
let get n = Seq.unfold (next) (0, 1) |> Seq.take n
Как мы видим, код довольно сильно отличается от C#. Что тут изменилось:
- Написано меньше кода, нет фигурных скобок. Для разделения блоков кода используются отступы, как на Python.
- Для генерации значений ряда Фибоначчи используется
Seq.unfold
. По-сути, эта конструкция является эквивалентом ключевого словаyield
в C#. При помощиSeq.unfold
можно создавать коллекции с неизвестным конечным размером. - Оператор
|>
или pipe-оператор. Предназначен для создания цепочки вызовов. - Нет объявлений типов. F# очень хорошо умеет выводить типы значений и функций.
C# + F#
Прелесть всех языков .NET в том, что код написанный на F#, можно вызывать в программе на C#. Поэтому, можно спокойно написать вот такой код:
Console.Write("F# Fibonacci: ");
foreach (var item in FSharp.Fibonacci.get(10))
{
Console.Write($"{item} ");
}
Console.WriteLine();
На C#, кстати, модуль Fibonacci
будет в виде статического класса, а функции get
и next
станут статическими (что там происходит под капотом разберём в следующей статье):
public static class Fibonacci
{
public static FSharpOption<Tuple<int, Tuple<int, int>>> next(int previous, int current)
{
// next implementation
}
public static IEnumerable<int> get(int n)
{
// get implementation
}
}
Такой подход (использование C# в связке с F#), на мой взгляд, позволяет в приложениях эффективно разделить Business Logic Layer от Data Access Layer или Service Layer. На F# можно написать функциональное ядро для Business Logic, а всё остальное - на C#.
Резюме
Использование функционального подхода в программировании позволяет писать чистую архитектуру приложения, которая отвечает принципам SOLID. Для таких приложений проще писать тесты и их проще сопровождать. Поскольку для F# функциональное программирование является основной парадигмой, то писать в функциональный код на нём намного проще.