F# меня испортил, или почему я больше не хочу писать на C#
Раньше я очень любил C#
Это был мой основной язык программирования, и каждый раз, когда я сравнивал его с другими, я радовался тому, что в свое время случайно выбрал именно его. Python и Javascript сразу проигрывают динамической типизацией (если к джаваскрипту понятие типизации вообще имеет смысл применять), Java уступает дженериками, отстутствием ивентов, value-типов, вытекающей из этого карусели с разделением примитивов и объектов на два лагеря и зеркальными классами-обертками вроде Integer
, отсутствием пропертей и так далее. Одним словом — C# клевый.
Отдельно отмечу, что я сейчас говорю о самом языке и удобстве написания кода на нем.
Тулинг, обилие библиотек и размер сообщества я сейчас в расчет не беру, потому что у каждого
из этих языков они развиты достаточно, чтобы промышленная разработка была комфортной в большинстве случаев.
А потом я из любопытства попробовал F#.
И что в нем такого?
Буду краток, в порядке значимости для меня:
- Иммутабельные типы
- Функциональная парадигма оказалась гораздо более строгой и стройной, чем то, что мы сегодня называем ООП.
- Типы-суммы, они же
Discriminated Unions
или размеченные объединения. - Лаконичность синтаксиса
- Computation Expressions
- SRTP (Статически разрешаемые параметры-типы)
- По умолчанию даже ссылочным типам нельзя присвоить
null
, и компилятор требует инициализацию при объявлении. - Выведение типов или type inference
С null
все понятно, ничто так не засоряет код проекта, как бесконечные проверки возвращаемых значений вроде Task
. Так что сначала давайте обсудим иммутабельность и одновременно лаконичность.
Допустим, имеем следующий 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
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
{
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# с этим справляется компилятор. Бесплатно.