Недавно прочитал книгу 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. Очевидно, что для решения этой проблемы нам нужно разделить расчёт ряда Фибоначчи и вывод значений. Для этого к нам на помощь придёт функциональный подход.

Функциональное программирование

Функциональное программирование основано на использовании чистых функций. Согласно Википедии, чистая функция - это функция, которая:

  1. Возвращает один и тот же результат для одних и тех же аргументов.
  2. Не имеет побочных эффектов (не выполняет изменение объектов, изменение файлов и т.д.)

Вернёмся к классу 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# функциональное программирование является основной парадигмой, то писать в функциональный код на нём намного проще.

Ссылки