Хабрахабр

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

Раньше я очень любил 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 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 HasAccessToSomething: bool HasAccessToSomethingElse: bool }

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

Ну, положим, иммутабельность мы можем и в 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. Думаю, большинство из нас впитали с молоком первого тимлида правило о том, что строить логику на эксепшнах плохо. TryParse. ToInt32("4"); } catch()... правильней использовать int.

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

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

Что очень удобно, когда вы добавляете новый кейс в DU. F# компилятор кидает ворнинг, если мы в match перебрали не все возможные варианты. В 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 фреймворком в тугую колбасу функционала, отдается веб-апи контроллеру при реквесте.

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

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

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

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

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 }

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

Первый для работы с привычными нам тасками, второй — для работы с Async. Кстати, есть такая штука и для асинхронного программирования, даже две — task & 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

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

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

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

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

Пруф

Итог

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

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

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть