F# Spoiled Us Or Why We Don’t Want To Write In C#
The C# was my main programming language, and every time when I compared it with others, I was glad that I had chosen it in my time.
Python and Javascript immediately lose to dynamic typing (if the concept of typing makes sense to apply to a javascript), Java is inferior to generics, the lack of events, value-types resulting from this carousel with the division of primitives and objects into two camps and mirror wrapper classes like Integer lack of prop, and so on. In a word – C# is cool.
Separately, I note that I am now talking about the language itself and the convenience of writing code on it.
Tuling, the abundance of libraries and the size of the community, I now do not take into account, because of everyone
Of these languages, they are developed enough to make industrial development comfortable in most cases.
And then, out of curiosity, I tried F#.
And what’s in it?
I will be brief, in order of importance to me:
- Immutable types
- The functional paradigm turned out to be much stricter and more harmonious than what we today call the PLO.
- Types of sums, they are also Discriminated Unions or marked associations
- Laconic syntax
- Computation expressions
- SRTP (Statically Allowed Type Parameters)
- By default, even reference types cannot be assigned null, and the compiler requires initialization upon declaration.
- Type inference or type inference
With null, everything is clear; nothing clogs the project code more than endless checks of return values like Task<IEnumerable<Employee>>. So first, let’s discuss immunity and conciseness at the same time.
Suppose we have the following POCO class:
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; } }
Simply, capaciously, nothing superfluous. It would seem much laconic?
The corresponding F# code looks like this:
type Employee = { Id: Guid Name: string Email: string Phone: string HasAccessToSomething: bool HasAccessToSomethingElse: bool }
Now there really is nothing superfluous. Useful information is contained in the keyword of the data type declaration, name of this type, field names, and their data types. In the example from C#, in each line there are unnecessary public and {get; set; }. In addition, in F# we got immunity and protection against null.
Well, let’s suppose that we can also organize immobility in C#, and not to write public with automatic completion:
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; } }
Done! However, the amount of code has increased 3 times: we duplicated all fields twice.
Moreover, when a new field is added, we can forget to add it to the constructor parameters and/or forget to assign a value inside the constructor, and the compiler will not tell us anything.
In F#, when adding a field, you need to add a new field. Everything.
Initialization looks like this:
let employee = { Id = Guid.NewGuid() Name = "Peter" Email = "peter@gmail.com" Phone = "1(647)555-35-35" HasAccessToSomething = true HasAccessToSomethinElse = false}
And if you forget one field, the code will not compile. Since the type is immutable, the only way to make a change is to create a new instance. But what if we want to change only one field? It’s simple:
<span class="hljs-keyword">let</span> <span class="hljs-attr">employee2</span> = { employee <span class="hljs-keyword">with</span> <span class="hljs-attr">Name</span> = <span class="hljs-string">"Max"</span> }
How to do it in C#? Well, you know without me.
Add nested reference fields, and now your {get; } does not guarantee anything – you can change the fields of this field. Should I mention the collection?
But do we really need this immunity?
I did not accidentally add two Boolean fields about accessing somewhere. In real projects, some service is responsible for access, and often it takes a model to the input and mutates it, putting it in the right place. And here I am in the next place of the program I get a model in which these boolean properties are set to false. What does it mean? The user does not have access or just the model is not even driven through the service access? Or maybe they drove out, but they forgot to initialize some fields? I don’t know, I have to check and read a bunch of code.
When the structure is immutable – I know that there are actual values there, because the compiler obliges me to completely initialize the declaration object.
Otherwise, when adding a new field, I must:
- Check all the places where this object is created – perhaps there too you need to fill this field
- Check for relevant services that mutate this object.
- Write/update unit tests affecting this field
- Update Mappings
- In addition, you can not be afraid that my object mutates inside someone else’s code or in another thread.
But in C# it is so difficult to achieve real immunity that writing such code is simply unprofitable, immobility at such a price does not save development time.
Well, enough about immunity. What else do we have? In F#, we also received for free:
- Structural equality
- Structural comparison
Now we can use the following constructions:
And it really will check for equality of objects. Equals which checks equality by reference is not needed by anyone, we already have Objected.ReferenceEquals, thanks.
if employee1 = employee2 then //...
Someone may say that nobody needs it because we don’t compare objects in real projects, so we need Equals & GetHashCode so rarely that we can override with pens. But I think that the causal link here works in a fraternal direction – we don’t compare objects, because it’s too expensive to redefine everything and support it. But when it comes for free, the application is instantaneous: you can use your models directly as keys in dictionaries, add models to HashSet <> & SortedSet <>, compare objects not according to your choice (although this option is, of course, available), but simply compare.
Discriminated Unions
I think most of us imbibed with the milk of the first timlid the rule that it’s bad to build logic on execons. For example, instead of try {i = Convert.ToInt32 (“4”); } catch () … better use int.TryParse.
But in addition to this primitive and nauseated example, we constantly violate this rule. Did user enter invalid data? ValidationException. Out of bounds array? IndexOutOfRangeException!
In smartbooks, they write that exception are needed for exceptional situations, unpredictable when something went wrong and there is no point in trying to continue working. A good example is OutOfMemoryException, StackOverflowException, AccessViolationException, etc. But getting out of the array is unpredictable? Seriously? The input indexer accepts Int32, the set of valid values of which is 2 to 32 degrees. In most cases, we work with arrays whose length does not exceed 10,000. In rare cases, a million. That is, the Int32 values that will cause an exception are much larger than those that will work correctly, that is, if a randomly selected into is statistically more likely to get into an “exceptional” situation!
The same with validation – the user entered the curve data. What a surprise.
- The reason why we actively abuse exceptions is simple: we lack the power of the type system to adequately describe the scenario “if everything is fine, give the result, if not, return an error”. Strong typing obliges us to return the same type in all branches of the method execution (fortunately), but it was still not enough to add a string ErrorMessage & bool IsSuccess to each type. Therefore, in the realities of C# exceptions – perhaps the lesser of the evils in this situation.
Again, you can write a class
public class Result<TResult, TError> { public bool IsOk { get; set; } public TResult Result { get; set; } public TError Error { get; set; } }
But here we again have to write a bunch of code, if we want, for example, to make an invalid state impossible. In a primitive implementation, you can assign both the result and the error, and forget to initialize IsOk, so there will be more problems from this than good.
In F#, things like this are made easier:
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 // check the result of the validation function | Valid input -> Ok (execute input) // if valid - return "OK" with the result | Invalid of messages -> Error messages // if not, return an error with the list of messages
No exceptions, everything is concise, and most importantly, the code is self-documented. You do not need to write to the XML doc that the method throws some kind of exception, you do not need to convulsively wrap the call to someone else’s method in try/catch just in case. In such a type system, an exception is a truly unpredictable, wrong situation.
- When you throw exceptions right and left, you need non-trivial error handling. Here you have a class BusinessException or ApiException, now you need to spawn exceptions inherited from them, make sure that they are used everywhere, and if you confuse something, instead of, for example, 404 or 403, the client will receive 500. tedious log analysis, reading the stack of traces and so on.
The F# compiler throws a Warning if we have not looked at all possible options in the match. Which is very convenient when you add a new case to DU. In DU, we define workflow, for example:
type UserCreationResult = | UserCreated of id:Guid | InvalidChars of errorMessage:string | AgreeToTermsRequired | EmailRequired | AlreadyExists
Here we immediately see all the possible scenarios for this operation, which is much clearer than the general list of exceptions. And when we added the new AgreementToTermsRequired case in accordance with the new requirements, the compiler threw the warning where we process this result.
I have never seen projects use such a visual and descriptive set of exceptions (for obvious reasons). As a result, the scripts are described in the text messages of these exceptions. In such an implementation, duplicates appear, and, conversely, developers are too lazy to add new messages, instead of making existing ones more general.
The array indexing is now also very concise, no if/else and length checks:
let doSmth myArray index = match Array.tryItem index myArray with | Some elem -> Console.WriteLine(elem) | None -> ()
The standard library type is Option:
type Option<'T> = | Some of 'T | None
Each time you use it, the code itself tells you that the absence of a value here is possible according to logic, and not because of a programmer’s error. And the compiler will throw a worm if you forget to handle all possible options.
The severity of the paradigm
Pure functions and expression-based language design enable us to write very stable code.
The net function meets the following criteria:
- The only result of her work is the calculation of the value. It does not change anything in the outside world.
- A function always returns the same value for the same argument.
Add to this the totality (the function can correctly calculate the value for any possible input parameter) and you get a thread-safe code that always works correctly and is easy to test.
Expression-based design tells us that everything is an expression, everything has an output.
For example:
let a = if someCondition then 1 else 2
The compiler will force us to take into account all possible combinations, we cannot just stop at if, forgetting about else.
In C#, this usually looks like this:
int a = 0; if(someCondition) { a = 1; } else { a = 2; }
Here you can easily lose one branch in the future, and a will remain with the default value, that is, another place where the human factor can play.
Of course, on some pure functions, you will not go far – we need I / O, at least. But these unclean effects can be severely limited to user input and work with data warehouses. Business logic can be implemented on pure functions, in which case it will be more stable than Swiss watches.
Avoiding the usual OOP
Standard case: you have a service that depends on a couple of other services and the repository. Those services, in turn, may depend on other services and on their repositories. All this is twisted by a powerful DI framework into a tight sausage of functionality, given to the web api controller when requesting.
Each dependence of our service, which on average, say, from 2 to 5, like our service itself, usually has 3-5 methods, of course, most of which are completely unnecessary in each specific scenario. From all this spreading tree of methods, we need in each separate scenario usually 1-2 methods from each (?) Dependency, but we connect together the entire block of functionality and create a bunch of objects. Where without them – we need to somehow test all this beauty. And here I want to cover the test method but in order to call this method,
- I need an object to this service. The catch is to understand which mocks are not called up in my method at all, I don’t need them. Some are called, but only a couple of methods from them. Therefore, in each test I make a tedious setup of these mocks with return values and other tripe. Then I want to test the second script in the same method. I’m waiting for a new setup. Sometimes in the tests for the method of code more than in the method itself. And yes, for each method I have to crawl into his guts and look at what dependencies I really need this time.
This manifests itself not only in tests: when I want to use some kind of 1 service method, I have to satisfy all dependencies in order to create the service itself, even if half of them are not used in my method. Yes, the DI framework takes over, but all the same, all these dependencies need to be registered in it. This can often be a problem, for example, if some of the dependencies are in a different assembly, and now we need to add a link to it. In some cases, this can greatly spoil the architecture, and then you have to pervert with inheritance or to allocate a common block into a separate service, thereby increasing the number of components in the system. Problems, of course, solved, but unpleasant.
In the functional paradigm, this works a little differently. The coolest kid here is a pure function, not an object. And predominantly, as you already understood, immutable values are used here, and not mutable variables. In addition, functions are perfectly composited; therefore, in most cases, we do not need service objects at all. The repository gets what you need from the database? Well, get it and pass the value itself to the service, not the repository!
A simple script looks like this:
let getReport queryData = use connection = getConnection () queryData |> DataRepository.get connection // we inject the dependency on the connection into the function, not into the constructor // and now we no longer need to follow the lifestyle of dependencies in a huge tree |> Report.build
For those who are not familiar with the operator |> and carrying, this is equivalent to the following code:
let gerReport queryData = use connection = getConnection() Report.build(DataRepository.get connection queryData)
In C#:
public ReportModel GetReport (QueryData queryData) { using (var connection = GetConnection ()) { // Report here is a static class. F # modules are compiled into it. return Report.Build (DataRepository.Get (connection, queryData)); } }
And since functions are perfectly composited, you can write something like this:
let getReport qyertData = use connection = getConnection() queryData |> (DataRepository.get connection >> Report.build)
Notice, testing Report.build is now easier than ever. You do not need mockups at all. Moreover, there is the FsCheck framework, which generates hundreds of input parameters and runs your method with them, and shows the data on which the method broke. The benefits of such tests are incomparably greater, they do test your system for durability, and unit tests tickle it rather uncertainly.
All you need to do to run such tests is to write a generator for your type once. What is better than writing mockups? The generator is universal, it is suitable for all future tests, and you do not need to know the implementation of anything, in order to write it.
By the way, dependence on the assembly with repositories or with their interfaces is no longer needed. All assemblies operate on common types and depend only on it, and not on each other. If suddenly you decide to change, for example, EntityFramework to Dapper, the assembly with the business logic will not affect it at all.
Statically Resolved Type Parameters (SRTP)
It is better to show than to tell.
let inline square (x: ^a when ^a: (static member (*): ^a -> ^a -> ^a)) = x * x
This function will work for any type for which the multiplication operator with the appropriate signature is defined. Of course, this also works with conventional static methods, not only with operators. And not only with static!
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
We do not need to define the interface, write wrappers for foreign classes, implement the interface, the only condition is that the type has a method with a suitable signature! I don’t know the way to do this in C#.
Computation expressions
We considered an example with type Result. Suppose we want to perform a cascade of operations, each of which returns this very Result. And if at least one link in this chain returns an error, we want to stop the execution and return the error immediately.
Instead of writing endlessly.
let res arg = match doJob arg with | Error e -> Error e | Ok r -> match doJob2 r with | Error e -> Error e | Ok r -> ...
We can write once:
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()
And use it like this:
let res arg = result { let! r = doJob arg let! r2 = doJob2 r let! r3 = doJob3 r2 return r3 }
Now on every line with let! in the case of Error e, we will return an error. If everything is all right, at the end we will return Ok r3.
And you can do such things for anything, including even using custom operations with custom names. Rich room to build DSL.
By the way, there is such a thing for asynchronous programming, even two – task & async. The first is for working with familiar tastes, the second is for working with Async. This thing from F# is different from the task because it has a cold start, it also has integration with the Tasks API. You can build complex workflows with cascading and parallel execution, and run them only when they are ready. It looks like this:
let myTask = task { let! result = doSmthAsync () // essence like 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
The file structure in the project
Since the records (DTO, models, etc.) are announced concisely and do not contain any logic, the number of files in the project significantly decreases. Domain types can be described in 1 file, types specific to a narrow block or layer can be defined in another file too.
By the way, in F# the order of lines of code and files is important – by default, in the current line you can only use what you have already described above. This is by design, and this is very cool because it protects you from cyclical dependencies. This also helps with revisions – the order of the files in the project gives design errors: if a high-level component is defined at the very top, it means someone has messed with dependencies. And this can be seen at a glance, and now imagine how much time it will take for you to find such things in C#.
Conclusion
Having received all these powerful tools and getting used to them, you begin to solve problems faster and more elegantly. Most of the code, once written and once tested, always works. Writing again in C# is for me to abandon them and lose in productivity. It was as if I was returning to the last century — so I ran in comfortable sneakers, and now in sandals. Better than nothing, but worse than something. Yes, various features are slowly added to it – both pattern matching and records will be delivered, and even nullable reference types.
But all this, firstly, much later than in F#, secondly, is poorer. Pattern matching without Discriminated unions & Record destruction – well, better than nothing. Nullable reference types – not bad, but Option is better.
I would say that the main problem of F# is that it is hard to “sell” it to the secretary.
But if all of you decide to study F# – it will be easy to be involved.
And it will be nice to write tests, and there will really be a lot of benefit from them. Property-based tests (those that I described in the FsCheck example) showed me several times design errors that QA would search for a very long time. Unit tests basically showed me that I forgot to update something in the test configuration. And yes, from time to time, they showed that I had missed something somewhere in the code.