Это был мой основной язык программирования, и каждый раз, когда я сравнивал его с другими, я радовался тому, что в свое время случайно выбрал именно его. Python и Javascript сразу проигрывают динамической типизацией (если к джаваскрипту понятие типизации вообще имеет смысл применять), Java уступает дженериками, отстутствием ивентов, value-типов, вытекающей из этого карусели с разделением примитивов и объектов на два лагеря и зеркальными классами-обертками вроде Integer, отсутствием пропертей и так далее. Одним словом — C# клевый. Отдельно отмечу, что я сейчас говорю о самом языке и удобстве написания кода на нем.
Тулинг, обилие библиотек и размер сообщества я сейчас в расчет не беру, потому что у каждого
из этих языков они развиты достаточно, чтобы промышленная разработка была комфортной в большинстве случаев. А потом я из любопытства попробовал F#. Буду краток, в порядке значимости для меня: С null все понятно, ничто так не засоряет код проекта, как бесконечные проверки возвращаемых значений вроде Task<IEnumerable<Employee>>. Так что сначала давайте обсудим иммутабельность и одновременно лаконичность. Допустим, имеем следующий POCO класс: Просто, емко, ничего лишнего. Казалось бы, куда лаконичней? Соответствующий код на F# выглядит так: Вот теперь действительно нет ничего лишнего. Полезная информация содержится в ключевом слове декларации типа данных, имени этого типа, именах полей и их типах данных. В примере из C# в каждой строчке есть ненужные public и { get; set; }. Помимо этого, в F# мы получили иммутабельность и защиту от null. Ну, положим, иммутабельность мы можем и в C# организовать, а public с автодополнением написать недолго: Готово! Правда, количество кода увеличилось в 3 раза: все поля мы продублировали дважды.
Мало того, когда добавится новое поле, мы можем забыть добавить его в параметры конструктора и/или забыть присвоить значение внутри конструктора, и компилятор нам ничего не скажет. В F# при добавлении поля вам нужно добавить новое поле. Все. Инициализация же выглядит вот так: И если вы забудете одно поле, то код не скомпилируется. Поскольку тип неизменяемый, единственный способ внести изменение — создать новый экземпляр. Но что делать, если мы хотим изменить только одно поле? Все просто: Как это сделать в C#? Ну, вы и без меня знаете. Добавьте вложенные ссылочные поля, и теперь ваш { get; } ничего не гарантирует — вы можете изменить поля этого поля. Стоит ли упоминать коллекции? Но так ли нам нужна эта иммутабельность? Я не случайно добавил два булевых поля про доступ куда-то. В реальных проектах за доступ отвечает какой-нибудь сервис, и часто он принимает на вход модель и мутирует ее, проставляя где надо true. И вот я в очередном месте программы получаю такую модель, в которой эти булевы свойства выставлены в false. Что это значит? Юзер не имеет доступ или просто модель не прогнали еще через аксес сервис? А может прогнали, но там забыли проинициализировать какие-то поля? Я не знаю, я должен проверить и прочитать кучу кода. Когда же структура неизменяема — я знаю, что там стоят актуальные значения, потому что компилятор обязывает меня полностью инициализировать объект декларации.
В противном случае при добавлении нового поля я должен: Актуализировать маппинги
Кроме того, можно не боятся, что мой объект мутирует внутри чужого кода или в другом потоке. Но в C# настолько трудно добиться настоящей иммутабельности, что писать такой код просто нерентабельно, иммутабельность такой ценой никак не сэкономит время разработки. Ну, хватит об иммутабельности. Что еще имеем? В F# мы так же бесплатно получили: Теперь мы можем использовать такие конструкции: И это действительно будет проверять равенство объектов. Equals который проверяет равенство по ссылке никому даром не нужен, у нас уже есть Object.ReferenceEquals, спасибо. Кто-то может сказать, что это никому не нужно, потому что мы не сравниваем объекты в реальных проектах, поэтому Equals & GetHashCode нам нужны так редко, что можно и ручками переопределить. Но я думаю, что причинно-следственная связь тут работает в братную сторону — мы не сравниваем объекты, потому что переопределять руками это все и поддерживать слишком дорого. Но когда это достается бесплатно, применение находится мгновенно: вы можете использовать прямиком ваши модели как ключи в словарях, складывать модели в HashSet<> & SortedSet<>, сравнивать объекты не по айдишнику (хотя эта опция, разумеется, доступна), а просто сравнивать. Думаю, большинство из нас впитали с молоком первого тимлида правило о том, что строить логику на эксепшнах плохо. Например, вместо try { i = Convert.ToInt32("4"); } catch()... правильней использовать int.TryParse. Но помимо этого примитивного и до тошноты затертого примера, мы постоянно нарушаем это правило. Юзер ввел невалидные данные? ValidationException. Вышли за границы массива? IndexOutOfRangeException! В умных книжках пишут, что исключения нужны для исключительных ситуаций, непредсказуемых, когда что-то пошло совсем не так и нет смысла пытаться продолжать работу. Хороший пример — OutOfMemoryException, StackOverflowException, AccessViolationException и т.д. Но вылезти за границы массива — это непредсказуемо? Серьезно? Индексатор на вход принимает Int32, множество допустимых значений которого составляет 2 в 32 степени. В большинстве случаев мы работаем с массивами, длина которых не превышает 10000. В редких случаях миллион. То есть значений Int32, которые вызовут исключение сильно больше, чем те, которые отработают корректно, то есть при случайно выбранном инте статистически более вероятно попасть в "исключительную" ситуацию!
То же самое с валидацией — юзер ввел кривые данные. Вот это сюрприз. Причина, по которой мы активно злоупотребляем исключениями, проста: нам не хватает мощности системы типов, чтобы адекватно описать сценарий "если все нормально, отдай результат, если нет, верни ошибку". Строгая типизация обязывает нас возвращать один и тот же тип во всех ветках исполнения метода (к счастью), но не хватало еще только в каждый тип добавлять string ErrorMessage & bool IsSuccess. Поэтому в реалиях C# исключения — пожалуй, меньшее из зол в данной ситуации. Опять-таки, можно написать класс Но тут нам опять придется написать кучу кода, если мы хотим, например, сделать невалидное состояние невозможным. В примитивной реализации можно присвоить и результат, и ошибку, и забыть проинициализировать IsOk, так что проблем от этого будет больше, чем пользы. В F# подобные вещи определяются проще: Никаких исключений, все лаконично, и главное, что код самодокументирован. Вам не нужно писать в xml doc, что метод кидает какое-то исключение, вам не нужно судорожно оборачивать вызов чужого метода в try/catch просто на всякий случай. В такой системе типов исключение — действительно непредсказуемая, неправильная ситуация. Когда вы кидаете исключения направо и налево, вам нужна нетривиальная обработка ошибок. Вот у вас появляется класс BusinessException или ApiException, теперь вам нужно наплодить исключений, отнаследованных от них, следить, чтобы везде использовались именно они, а если вы что-то перепутаете, то вместо, например, 404 или 403 клиент получит 500. Вас же ждет нудный разбор логов, чтение стек трейсов и так далее. F# компилятор кидает ворнинг, если мы в match перебрали не все возможные варианты. Что очень удобно, когда вы добавляете новый кейс в DU. В DU мы определяем воркфлоу, например: Тут мы сразу видим все возможные сценарии для данной операции, что гораздо наглядней общего списка исключений. А когда мы добавили новый кейс AgreeToTermsRequired в соответствии с новыми требованиями, компилятор кинул ворнинг там, где мы этот результат обрабатываем. Я ни разу не видел, чтобы в проектах использовали такое наглядное и описательное множество исключений (по понятным причинам). В итоге сценарии описаны в текстовых сообщениях этих исключений. В такой реализации появляются дубликаты, и, наоборот, разработчики ленятся добавлять новые сообщения, вместо этого делая существующие более общими. Индексация же по массиву теперь тоже очень лаконична, никаких if/else и проверки длины: Здесь используется тип стандартной библиотеки Option: Каждый раз, когда вы его используете, код сам вам говорит, что отсутствие значения здесь возможно согласно логике, а не из-за ошибки программиста. И компилятор кинет ворнинг, если вы забудете обработать все возможные варианты. Чистые функции и expression-based дизайн языка дают нам возможность писать очень стабильный код.
Чистая функция соответствует следующим критериям: Добавьте к этому тотальность (функция может корректно вычислить значение для любого возможного входящего параметра) и вы получите потокобезопасный код, который работает всегда правильно и который легко тестировать. Expression-based design говорит нам, что все является выражением, у всего есть результат выполнения. Например: Компилятор заставит нас учесть все возможные комбинации, мы не можем остановиться просто на if, забыв про else.
В C# это обычно выглядит так: Здесь легко можно потерять одну ветку в будущем, и a останется с дефолтным значением, то есть еще одно место, где может сыграть человеческий фактор. Конечно же, на одних чистых функциях далеко не уедешь — нам нужно I/O, как минимум. Но эти нечистые эффекты можно сильно ограничить до пользовательского ввода и работы с хранилищами данных. Бизнес-логика может быть реализована на чистых функциях, и в этом случае она будет стабильней швейцарских часов. Стандартный кейс: у вас есть сервис, который зависит от парочки других сервисов и репозитория. Те сервисы, в свою очередь, могут зависеть от других сервисов и от своих репозиториев. Все это скручивается могучим DI фреймворком в тугую колбасу функционала, отдается веб-апи контроллеру при реквесте. Каждая зависимость нашего сервиса, которых в среднем, допустим, от 2 до 5, как и сам наш сервис, обычно имеет 3-5 методов, разумеется, большая часть которых совершенно не нужна в каждом конкретном сценарии. Из всего этого раскидистого дерева методов нам нужно в каждом отдельном сценарии обычно 1-2 метода от каждой (?) зависимости, но мы связываем воедино весь блок функционала и создаем кучу объектов. И моки, конечно же. Куда ж без них — нам нужно же как-то протестировать всю эту красоту. И вот я хочу покрыть тестом метод, но для того, чтобы вызвать этот метод, мне нужен объект этого сервиса. Чтобы его создать, я должен пропихнуть в него моки. Загвоздка в том, чтобы понять, какие именно моки — какие-то в моем методе вообще не вызываются, они мне не нужны. Какие-то вызываются, но только пара методов из них. Поэтому в каждом тесте я делаю нудный сетап этих моков с возвращаемыми значениями и прочей требухой. Потом я хочу протестировать второй сценарий в том же методе. Меня ждет новый сетап. Иной раз в тестах на метод кода больше, чем в самом методе. И да, для каждого метода я должен лезть в его кишки и смотреть, какие же зависимости мне действительно нужны в этот раз. Проявляется это не только в тестах: когда я хочу использовать какой-то 1 метод сервиса, я должен удовлетворить все зависимости, чтобы создать сам сервис, даже если в моем методе половина из них не используется. Да, это на себя берет DI фреймворк, но все равно все эти зависимости необходимо зарегистрировать в нем. Нередко это может быть проблемой, например, если часть зависимостей лежит в другой сборке, и теперь нам нужно на нее добавить ссылку. В отдельных случаях это может сильно портить архитектуру, и тогда приходится извращаться с наследованием или выделять общий блок в отдельный сервис, тем самым увеличивая число компонентов в системе. Проблемы, безусловно, решаемые, но неприятные. В функциональной парадигме это работает немного по-другому. Самый крутой пацан здесь — чистая функция, а не объект. И преимущественно, как вы уже поняли, тут используют иммутабельные значения, а не мутабельные переменные. Кроме того, функции прекрасно композируются, поэтому, в большинстве случаев, нам не нужны объекты сервисов вообще. Репозиторий достает из базы то, что тебе нужно? Ну так достань и передай в сервис само значение, а не репозиторий! Простой сценарий выглядит примерно так: Для тех, кто не знаком с оператором |> и каррированием, это равносильно следующему коду: На C#: А поскольку функции прекрасно композируются, можно написать вообще вот так: Заметьте, тестировать Report.build теперь проще некуда. Вам моки не нужны вообще. Более того, есть фреймворк FsCheck, который генерирует сотни входных параметров и запускает с ними ваш метод, и показывает данные, на которых метод сломался. Пользы от таких тестов несравнимо больше, они действительно проверяют на прочность вашу систему, юнит-тесты ее скорее неуверенно щекочут.  Все, что вам нужно сделать для запуска таких тестов — 1 раз написать генератор для вашего типа. Чем это лучше написания моков? Генератор универсален, он подходит для всех будущих тестов, и вам не нужно знать имплементацию чего бы то ни было, для того, чтобы его написать. Кстати, зависимость от сборки с репозиториями или с их интерфейсами теперь не нужна. Все сборки оперируют общими типами и зависят только от нее, а не от друг друга. Если же вдруг вы решите сменить, например, EntityFramework на Dapper, сборку с бизнес логикой это не затронет вообще никак. Тут лучше показать, чем рассказать. Эта функция будет работать для любого типа, у которого определен оператор умножения с соответствующей сигнатурой. Разумеется, это работает и с обычными статическими методами, не только с операторами. И не только со статическими! Нам не нужно определять интерфейс, писать обёртки для чужих классов, имплементить интерфейс, единственное условие — чтобы у типа был метод с подходящей сигнатурой! Я не знаю способа сделать так в C#. Мы рассматривали пример с типом Result. Допустим, мы хотим выполнить каскад операций, каждая из которых нам возвращает этот самый Result. И если хоть одно звено в этой цепи вернет ошибку, мы хотим прекратить выполнение и вернуть ошибку сразу.
Вместо того, чтобы писать бесконечные Мы можем один раз написать И использовать это так: Теперь на каждой строчке с let! в случае Error e мы вернем ошибку. Если же все все будет хорошо, в конце вернем Ok r3.
И вы можете делать такие штуки для чего угодно, включая даже использование кастомных операций с кастомными названиями. Богатый простор для построения DSL. Кстати, есть такая штука и для асинхронного программирования, даже две — task & async. Первый для работы с привычными нам тасками, второй — для работы с Async. Эта штука из F#, от тасок главным образом отличается тем, что у нее cold start, она также имеет интеграцию с Tasks API. Вы можете строить сложные воркфлоу с каскадным и параллельным исполнением, а запускать их лишь когда они готовы. Выглядит это так: Поскольку рекорды (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# с этим справляется компилятор. Бесплатно. F#/C# developer