Как стать автором
Обновить

F# меня испортил, или почему я больше не хочу писать на C#

Время на прочтение13 мин
Количество просмотров66K

Раньше я очень любил C#


Это был мой основной язык программирования, и каждый раз, когда я сравнивал его с другими, я радовался тому, что в свое время случайно выбрал именно его. Python и Javascript сразу проигрывают динамической типизацией (если к джаваскрипту понятие типизации вообще имеет смысл применять), Java уступает дженериками, отстутствием ивентов, value-типов, вытекающей из этого карусели с разделением примитивов и объектов на два лагеря и зеркальными классами-обертками вроде Integer, отсутствием пропертей и так далее. Одним словом — C# клевый.


Отдельно отмечу, что я сейчас говорю о самом языке и удобстве написания кода на нем.
Тулинг, обилие библиотек и размер сообщества я сейчас в расчет не беру, потому что у каждого
из этих языков они развиты достаточно, чтобы промышленная разработка была комфортной в большинстве случаев.


А потом я из любопытства попробовал F#.


И что в нем такого?


Буду краток, в порядке значимости для меня:


  • Иммутабельные типы
  • Функциональная парадигма оказалась гораздо более строгой и стройной, чем то, что мы сегодня называем ООП.
  • Типы-суммы, они же Discriminated Unions или размеченные объединения.
  • Лаконичность синтаксиса
  • Computation Expressions
  • SRTP (Статически разрешаемые параметры-типы)
  • По умолчанию даже ссылочным типам нельзя присвоить null, и компилятор требует инициализацию при объявлении.
  • Выведение типов или type inference

С null все понятно, ничто так не засоряет код проекта, как бесконечные проверки возвращаемых значений вроде Task<IEnumerable<Employee>>. Так что сначала давайте обсудим иммутабельность и одновременно лаконичность.


Допустим, имеем следующий POCO класс:


public class Employee
{
    public Guid Id { get; set; }
    public string Name { get; set; }
    public string Email { get; set; }
    public string Phone { get; set; }
    public bool HasAccessToSomething { get; set; }
    public bool HasAccessToSomethinElse { get; set; }
}

Просто, емко, ничего лишнего. Казалось бы, куда лаконичней?


Соответствующий код на F# выглядит так:


type Employee =
{ Id: Guid
  Name: string
  Email: string
  Phone: string
  HasAccessToSomething: bool
  HasAccessToSomethingElse: bool }

Вот теперь действительно нет ничего лишнего. Полезная информация содержится в ключевом слове декларации типа данных, имени этого типа, именах полей и их типах данных. В примере из C# в каждой строчке есть ненужные public и { get; set; }. Помимо этого, в F# мы получили иммутабельность и защиту от null.


Ну, положим, иммутабельность мы можем и в C# организовать, а public с автодополнением написать недолго:


public class Employee
{
    public Guid Id { get; }
    public string Name { get; }
    public string Email { get; }
    public string Phone { get; }
    public bool HasAccessToSomething { get; }
    public bool HasAccessToSomethinElse { get; }

    public Employee(Guid id, string name, string email, string phone, bool hasAccessToSmth, bool hasAccessToSmthElse)
    {
        Id = id;
        Name = name;
        Email = email;
        Phone = phone;
        HasAccessToSomething = hasAccessToSmth;
        HasAccessToSomethinElse = hasAccessToSmthElse;
    }
}

Готово! Правда, количество кода увеличилось в 3 раза: все поля мы продублировали дважды.
Мало того, когда добавится новое поле, мы можем забыть добавить его в параметры конструктора и/или забыть присвоить значение внутри конструктора, и компилятор нам ничего не скажет.


В F# при добавлении поля вам нужно добавить новое поле. Все.


Инициализация же выглядит вот так:


let employee =
    { Id = Guid.NewGuid()
      Name = "Peter"
      Email = "peter@gmail.com"
      Phone = "8(800)555-35-35"
      HasAccessToSomething = true
      HasAccessToSomethinElse = false}

И если вы забудете одно поле, то код не скомпилируется. Поскольку тип неизменяемый, единственный способ внести изменение — создать новый экземпляр. Но что делать, если мы хотим изменить только одно поле? Все просто:


let employee2 = { employee with Name = "Valera" }

Как это сделать в C#? Ну, вы и без меня знаете.


Добавьте вложенные ссылочные поля, и теперь ваш { get; } ничего не гарантирует — вы можете изменить поля этого поля. Стоит ли упоминать коллекции?


Но так ли нам нужна эта иммутабельность?


Я не случайно добавил два булевых поля про доступ куда-то. В реальных проектах за доступ отвечает какой-нибудь сервис, и часто он принимает на вход модель и мутирует ее, проставляя где надо true. И вот я в очередном месте программы получаю такую модель, в которой эти булевы свойства выставлены в false. Что это значит? Юзер не имеет доступ или просто модель не прогнали еще через аксес сервис? А может прогнали, но там забыли проинициализировать какие-то поля? Я не знаю, я должен проверить и прочитать кучу кода.


Когда же структура неизменяема — я знаю, что там стоят актуальные значения, потому что компилятор обязывает меня полностью инициализировать объект декларации.
В противном случае при добавлении нового поля я должен:


  • Проверить все места, где этот объект создается — возможно, там тоже нужно заполнить это поле
  • Проверить соответствующие сервисы, мутирующие этот объект
  • Написать/обновить юнит-тесты, затрагивающе это поле
  • Актуализировать маппинги
    Кроме того, можно не боятся, что мой объект мутирует внутри чужого кода или в другом потоке.


    Но в C# настолько трудно добиться настоящей иммутабельности, что писать такой код просто нерентабельно, иммутабельность такой ценой никак не сэкономит время разработки.



Ну, хватит об иммутабельности. Что еще имеем? В F# мы так же бесплатно получили:


  • Structural Equality
  • Structural Comparison

Теперь мы можем использовать такие конструкции:


if employee1 = employee2 then
//...

И это действительно будет проверять равенство объектов. Equals который проверяет равенство по ссылке никому даром не нужен, у нас уже есть Object.ReferenceEquals, спасибо.


Кто-то может сказать, что это никому не нужно, потому что мы не сравниваем объекты в реальных проектах, поэтому Equals & GetHashCode нам нужны так редко, что можно и ручками переопределить. Но я думаю, что причинно-следственная связь тут работает в братную сторону — мы не сравниваем объекты, потому что переопределять руками это все и поддерживать слишком дорого. Но когда это достается бесплатно, применение находится мгновенно: вы можете использовать прямиком ваши модели как ключи в словарях, складывать модели в HashSet<> & SortedSet<>, сравнивать объекты не по айдишнику (хотя эта опция, разумеется, доступна), а просто сравнивать.


Discriminated Unions


Думаю, большинство из нас впитали с молоком первого тимлида правило о том, что строить логику на эксепшнах плохо. Например, вместо try { i = Convert.ToInt32("4"); } catch()... правильней использовать int.TryParse.


Но помимо этого примитивного и до тошноты затертого примера, мы постоянно нарушаем это правило. Юзер ввел невалидные данные? ValidationException. Вышли за границы массива? IndexOutOfRangeException!


В умных книжках пишут, что исключения нужны для исключительных ситуаций, непредсказуемых, когда что-то пошло совсем не так и нет смысла пытаться продолжать работу. Хороший пример — OutOfMemoryException, StackOverflowException, AccessViolationException и т.д. Но вылезти за границы массива — это непредсказуемо? Серьезно? Индексатор на вход принимает Int32, множество допустимых значений которого составляет 2 в 32 степени. В большинстве случаев мы работаем с массивами, длина которых не превышает 10000. В редких случаях миллион. То есть значений Int32, которые вызовут исключение сильно больше, чем те, которые отработают корректно, то есть при случайно выбранном инте статистически более вероятно попасть в "исключительную" ситуацию!
То же самое с валидацией — юзер ввел кривые данные. Вот это сюрприз.


Причина, по которой мы активно злоупотребляем исключениями, проста: нам не хватает мощности системы типов, чтобы адекватно описать сценарий "если все нормально, отдай результат, если нет, верни ошибку". Строгая типизация обязывает нас возвращать один и тот же тип во всех ветках исполнения метода (к счастью), но не хватало еще только в каждый тип добавлять string ErrorMessage & bool IsSuccess. Поэтому в реалиях C# исключения — пожалуй, меньшее из зол в данной ситуации.


Опять-таки, можно написать класс


public class Result<TResult, TError>
{
    public bool IsOk { get; set; }
    public TResult Result { get; set; }
    public TError Error { get; set; }
}

Но тут нам опять придется написать кучу кода, если мы хотим, например, сделать невалидное состояние невозможным. В примитивной реализации можно присвоить и результат, и ошибку, и забыть проинициализировать IsOk, так что проблем от этого будет больше, чем пользы.


В F# подобные вещи определяются проще:


type Result<'TResult, 'TError> =
    | Ok of 'TResult
    | Error of 'TError

type ValidationResult<'TInput> =
    | Valid of 'TInput
    | Invalid of string list

let validateAndExecute input =
    match validate input with // проверяем результат функции валидации
    | Valid input -> Ok (execute input) // если валидно - возвращаем "Ок" с результатом
    | Invalid of messages -> Error messages // если нет, возвращаем ошибку со списком сообщений

Никаких исключений, все лаконично, и главное, что код самодокументирован. Вам не нужно писать в xml doc, что метод кидает какое-то исключение, вам не нужно судорожно оборачивать вызов чужого метода в try/catch просто на всякий случай. В такой системе типов исключение — действительно непредсказуемая, неправильная ситуация.


Когда вы кидаете исключения направо и налево, вам нужна нетривиальная обработка ошибок. Вот у вас появляется класс BusinessException или ApiException, теперь вам нужно наплодить исключений, отнаследованных от них, следить, чтобы везде использовались именно они, а если вы что-то перепутаете, то вместо, например, 404 или 403 клиент получит 500. Вас же ждет нудный разбор логов, чтение стек трейсов и так далее.


F# компилятор кидает ворнинг, если мы в match перебрали не все возможные варианты. Что очень удобно, когда вы добавляете новый кейс в DU. В DU мы определяем воркфлоу, например:


type UserCreationResult =
    | UserCreated of id:Guid
    | InvalidChars of errorMessage:string
    | AgreeToTermsRequired
    | EmailRequired
    | AlreadyExists

Тут мы сразу видим все возможные сценарии для данной операции, что гораздо наглядней общего списка исключений. А когда мы добавили новый кейс AgreeToTermsRequired в соответствии с новыми требованиями, компилятор кинул ворнинг там, где мы этот результат обрабатываем.


Я ни разу не видел, чтобы в проектах использовали такое наглядное и описательное множество исключений (по понятным причинам). В итоге сценарии описаны в текстовых сообщениях этих исключений. В такой реализации появляются дубликаты, и, наоборот, разработчики ленятся добавлять новые сообщения, вместо этого делая существующие более общими.


Индексация же по массиву теперь тоже очень лаконична, никаких if/else и проверки длины:


let doSmth myArray index =
    match Array.tryItem index myArray with
    | Some elem -> Console.WriteLine(elem)
    | None -> ()

Здесь используется тип стандартной библиотеки Option:


type Option<'T> =
    | Some of 'T
    | None

Каждый раз, когда вы его используете, код сам вам говорит, что отсутствие значения здесь возможно согласно логике, а не из-за ошибки программиста. И компилятор кинет ворнинг, если вы забудете обработать все возможные варианты.


Строгость парадигмы


Чистые функции и expression-based дизайн языка дают нам возможность писать очень стабильный код.
Чистая функция соответствует следующим критериям:


  • Единственный результат ее работы — вычисление значения. Она не изменяет ничего во внешнем мире.
  • Функция всегда возвращает одно и то же значение для одного и того же аргумента.

Добавьте к этому тотальность (функция может корректно вычислить значение для любого возможного входящего параметра) и вы получите потокобезопасный код, который работает всегда правильно и который легко тестировать.


Expression-based design говорит нам, что все является выражением, у всего есть результат выполнения. Например:


let a = if someCondition then 1 else 2

Компилятор заставит нас учесть все возможные комбинации, мы не можем остановиться просто на if, забыв про else.
В C# это обычно выглядит так:


int a = 0;
if(someCondition)
{
    a = 1;
}
else
{
    a = 2;
}

Здесь легко можно потерять одну ветку в будущем, и a останется с дефолтным значением, то есть еще одно место, где может сыграть человеческий фактор.


Конечно же, на одних чистых функциях далеко не уедешь — нам нужно I/O, как минимум. Но эти нечистые эффекты можно сильно ограничить до пользовательского ввода и работы с хранилищами данных. Бизнес-логика может быть реализована на чистых функциях, и в этом случае она будет стабильней швейцарских часов.


Уход от привычного ООП


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


Каждая зависимость нашего сервиса, которых в среднем, допустим, от 2 до 5, как и сам наш сервис, обычно имеет 3-5 методов, разумеется, большая часть которых совершенно не нужна в каждом конкретном сценарии. Из всего этого раскидистого дерева методов нам нужно в каждом отдельном сценарии обычно 1-2 метода от каждой (?) зависимости, но мы связываем воедино весь блок функционала и создаем кучу объектов. И моки, конечно же. Куда ж без них — нам нужно же как-то протестировать всю эту красоту. И вот я хочу покрыть тестом метод, но для того, чтобы вызвать этот метод, мне нужен объект этого сервиса. Чтобы его создать, я должен пропихнуть в него моки. Загвоздка в том, чтобы понять, какие именно моки — какие-то в моем методе вообще не вызываются, они мне не нужны. Какие-то вызываются, но только пара методов из них. Поэтому в каждом тесте я делаю нудный сетап этих моков с возвращаемыми значениями и прочей требухой. Потом я хочу протестировать второй сценарий в том же методе. Меня ждет новый сетап. Иной раз в тестах на метод кода больше, чем в самом методе. И да, для каждого метода я должен лезть в его кишки и смотреть, какие же зависимости мне действительно нужны в этот раз.


Проявляется это не только в тестах: когда я хочу использовать какой-то 1 метод сервиса, я должен удовлетворить все зависимости, чтобы создать сам сервис, даже если в моем методе половина из них не используется. Да, это на себя берет DI фреймворк, но все равно все эти зависимости необходимо зарегистрировать в нем. Нередко это может быть проблемой, например, если часть зависимостей лежит в другой сборке, и теперь нам нужно на нее добавить ссылку. В отдельных случаях это может сильно портить архитектуру, и тогда приходится извращаться с наследованием или выделять общий блок в отдельный сервис, тем самым увеличивая число компонентов в системе. Проблемы, безусловно, решаемые, но неприятные.


В функциональной парадигме это работает немного по-другому. Самый крутой пацан здесь — чистая функция, а не объект. И преимущественно, как вы уже поняли, тут используют иммутабельные значения, а не мутабельные переменные. Кроме того, функции прекрасно композируются, поэтому, в большинстве случаев, нам не нужны объекты сервисов вообще. Репозиторий достает из базы то, что тебе нужно? Ну так достань и передай в сервис само значение, а не репозиторий!


Простой сценарий выглядит примерно так:


let getReport queryData =
    use connection = getConnection()
    queryData
    |> DataRepository.get connection // зависимость от коннекшна мы внедряем в функцию, а не в конструктор
    // и вот нам уже не нужно следить за lifestyle'ом зависимостей в огромном дереве
    |> Report.build

Для тех, кто не знаком с оператором |> и каррированием, это равносильно следующему коду:


let gerReport queryData =
    use connection = getConnection()
    Report.build(DataRepository.get connection queryData)

На C#:


public ReportModel GetReport(QueryData queryData)
{
    using(var connection = GetConnection())
    {
        // Report здесь -- статический класс. В него компилируются F# модули
        return Report.Build(DataRepository.Get(connection, queryData));
    }
}

А поскольку функции прекрасно композируются, можно написать вообще вот так:


let getReport qyertData =
    use connection = getConnection()
    queryData
    |> (DataRepository.get connection >> Report.build)

Заметьте, тестировать Report.build теперь проще некуда. Вам моки не нужны вообще. Более того, есть фреймворк FsCheck, который генерирует сотни входных параметров и запускает с ними ваш метод, и показывает данные, на которых метод сломался. Пользы от таких тестов несравнимо больше, они действительно проверяют на прочность вашу систему, юнит-тесты ее скорее неуверенно щекочут.


Все, что вам нужно сделать для запуска таких тестов — 1 раз написать генератор для вашего типа. Чем это лучше написания моков? Генератор универсален, он подходит для всех будущих тестов, и вам не нужно знать имплементацию чего бы то ни было, для того, чтобы его написать.


Кстати, зависимость от сборки с репозиториями или с их интерфейсами теперь не нужна. Все сборки оперируют общими типами и зависят только от нее, а не от друг друга. Если же вдруг вы решите сменить, например, EntityFramework на Dapper, сборку с бизнес логикой это не затронет вообще никак.


Statically Resolved Type Parameters (SRTP)


Тут лучше показать, чем рассказать.


let inline square
     (x: ^a when ^a: (static member (*): ^a -> ^a -> ^a)) = x * x

Эта функция будет работать для любого типа, у которого определен оператор умножения с соответствующей сигнатурой. Разумеется, это работает и с обычными статическими методами, не только с операторами. И не только со статическими!


let inline GetBodyAsync x = (^a: (member GetBodyAsync: unit -> ^b) x)

open System.Threading.Tasks
type A() =
    member this.GetBodyAsync() = Task.FromResult 1

type B() =
    member this.GetBodyAsync() = async { return 2 }

A() |> GetBodyAsync |> fun x -> x.Result // 1
B() |> GetBodyAsync |> Async.RunSynchronously // 2

Нам не нужно определять интерфейс, писать обёртки для чужих классов, имплементить интерфейс, единственное условие — чтобы у типа был метод с подходящей сигнатурой! Я не знаю способа сделать так в C#.


Computation Expressions


Мы рассматривали пример с типом Result. Допустим, мы хотим выполнить каскад операций, каждая из которых нам возвращает этот самый Result. И если хоть одно звено в этой цепи вернет ошибку, мы хотим прекратить выполнение и вернуть ошибку сразу.
Вместо того, чтобы писать бесконечные


let res arg =
    match doJob arg with
    | Error e -> Error e
    | Ok r ->
        match doJob2 r with
        | Error e -> Error e
        | Ok r -> ...

Мы можем один раз написать


type ResultBuilder() =
    member __.Bind(x, f) =
        match x with
        | Error e -> Error e
        | Ok x -> f x
    member __.Return x = Ok x
    member __.ReturnFrom x = x

let result = ResultBuilder()

И использовать это так:


let res arg =
    result {
        let! r = doJob arg
        let! r2 = doJob2 r
        let! r3 = doJob3 r2
        return r3
    }

Теперь на каждой строчке с let! в случае Error e мы вернем ошибку. Если же все все будет хорошо, в конце вернем Ok r3.
И вы можете делать такие штуки для чего угодно, включая даже использование кастомных операций с кастомными названиями. Богатый простор для построения DSL.


Кстати, есть такая штука и для асинхронного программирования, даже две — task & async. Первый для работы с привычными нам тасками, второй — для работы с Async. Эта штука из F#, от тасок главным образом отличается тем, что у нее cold start, она также имеет интеграцию с Tasks API. Вы можете строить сложные воркфлоу с каскадным и параллельным исполнением, а запускать их лишь когда они готовы. Выглядит это так:


let myTask =
    task {
        let! result = doSmthAsync() // суть как у await Task
        let! result2 = doSmthElseAsync(result)
        return result2
    }

let myAsync =
    async {
        let! result = doAsync()
        let! result2 = do2Async(result)
        do! do3Async(result2)
        return result2
    }

let result2 = myAsync |> Async.RunSynchronously

let result2Task = myAsync |> Async.StartAsTask

let result2FromTask = myTask |> Async.AwaitTask

Структура файлов в проекте


Поскольку рекорды (DTO, модели и тд) объявляются лаконично и не содержат никакой логики, в проекте существенно уменьшается количество файлов. Доменные типы могут быть описаны в 1 файле, типы, специфичные для какого-то узкого блока или слоя могут быть определены в другом файле тоже вместе.


Кстати, в F# важен порядок строк кода и файлов — по умолчанию в текущей строчке вы можете использовать только то, что уже описали выше. Это by design, и это очень круто, потому что предохраняет вас от циклических зависимостей. Это так же помогает при ревью — порядок файлов в проекте выдает ошибки проектирования: если в самом верху определен высокоуровневый компонент, значит кто-то накосячил с зависимостями. И это видно с первого взгляда, а теперь представьте, сколько времени вам потребуется для того, чтобы в C# при ревью такое обнаружить.


Для сравнения, вся логика и доменные типы игры Змейка у меня описана в 7 файлах, все кроме одного меньше 130 строк кода.


Пруф


Итог


Получив все эти мощные инструменты и привыкнув к ним, начинаешь решать задачи быстрее и изящней. Большая часть кода, 1 раз написанная и 1 раз протестированная работает всегда. Писать же снова на C# для меня значит отказаться от них и потерять в продуктивности. Я словно возвращаюсь в прошлый век — вот я бегал в удобных кроссовках, а теперь в лаптях. Лучше, чем ничего, но хуже, чем что-то. Да, в него потихоньку добавляют разные фичи — и pattern matching, и рекорды завезут, и даже nullable reference types.
Но все это, во-первых, сильно позже, чем в F#, во-вторых, беднее. Pattern matching без Discriminated unions & Record destruction — ну, лучше, чем ничего. Nullable reference types — неплохо, но Option лучше.
Я бы сказал, что главная проблема F# — это то, что тяжело его "продать" сишарпистам.
Но если вы все же решитесь изучить F# — втянуться будет легко.


И тесты будет писать приятно, и от них действительно будет много пользы. Property-based тесты (те, что я описывал в примере с FsCheck) мне несколько раз показали ошибки проектирования, которые силами QA искались бы очень долго. Юнит-тесты же в основном показывали мне, что я забыл что-то обновить в конфигурации тестов. И да, время от времени, показывали, что я что-то где-то упустил в коде. В F# с этим справляется компилятор. Бесплатно.

Теги:
Хабы:
Всего голосов 82: ↑79 и ↓3+76
Комментарии327

Публикации

Истории

Работа

Ближайшие события

15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань