I'm loath to fall into the C# vs F# discussion, but I had an interesting discussion recently with several experienced .NET developers who have been exploring some of the new features in C#8 (and now version 9!) many of which fall into the domain of functional programming in some way - although just like when LINQ first came out, this is not always obvious at first glance (see here for the excellent original post by Dan Clarke that started the discussion).
Part of the discussion centered around something I am often asked about, which is if I'm somehow concerned that C#'s growing feature set will "overtake" F# and render it useless. I'm really not worred about that happening, and I promised to document the reasons why. So this is the first post in a short series that hopefully answer that question; as you might expect, the answer is not a simple one-liner and has got me thinking about many of the reasons why I think that this is.
This post starts the series by looking at the basic premise of whether by adding features that are similar to those in F#, C# can effectively replace F# - and gives six reasons why this really isn't likely to achieve a satisfactory result.
1. Every new feature must work with every existing feature
Every feature that is added to a language has to co-exist with the rest of them harmoniously; it's not simply a case of throwing random new features and seeing which ones stick, but a case of adding them in such a way that they "plug in" to the existing patchwork of features already laid out. Think of it as a recipe for a meal rather than a menu at a restaurant. You can't pick and choose which features you want in the language on any given day.
And just like all the ingredients in a recipe have to blend together properly (and some ingredients simply don't go well together), sometimes language features directly work against other features - making it sometimes difficult, if not impossible, to bring in a new feature. This is especially true here as one of the core principles of .NET languages is to as rarely as possible introduce breaking changes.
This might mean that features may sometimes be added, but only in a limited sense - leaving you with a feeling that a feature isn't that powerful, when in reality, it is limited by necessity. Here are three simple examples of this. You might not even be aware that these features are (currently) somehow limited in comparison to other languages!
Type inference is a powerful and useful feature, but in C# it is limited in comparison to other languages. This is not because C# is "bad", but simply because other features such as method overloading and implicit conversions actively hamper what you can do with type inference.
C#8 has done some excellent work to introduce nullability checking - I can only imagine how difficult it has been to add this in. But there are limitations to what can be achieved:
- For starters, it's an optional feature (understandable - otherwise, this would be a breaking change).
- It's been added using features such as branch and flow analysis as opposed to a genuine participant in the type system. One of the effects of this that it is (as I understand it) a "best endeavours" approach, rather than something that can be provably correct through the type system.
- It's a complicated feature, not only for the C# to implement but, importantly, for developers to reason about. This is, again, in part due to the fact that nullability has been added in version 8 of C#, and not at version 1. There's flow analysis, attributes to help guide the compiler, new operators such as
!!- all of these contribute to what I believe could make it challenging for developers to understand why a decision has been by the compiler as to why something is nullable or not.
The F# approach to nullability is quite different, using a simple type to indicate optionality - there's no "special" language support or custom operators. One of the benefits of this means it's much less effort to learn how to use it, since it uses standard F# types such as unions.
Switch expressions are a nice addition to C#. However, using classes, they are somewhat limited - for example, you cannot exhaustively switch across all combinations (giving you the confidence that you've dealt with all conditions). This might well be possible in the future with the addition of another feature known as case classes or union types.
The C#9 roadmap has now been published; discriminated unions have now been pushed back to C#10.
Consider type inference above - in C#, it's a feature of relatively limited value. In F#, it supports many refactoring patterns and figures in a much more fundamental way that we write code. If you've never used a language like F# though, this might never occur to you and you'll be left thinking that type inference is simply "var for the left hand side of assignment".
2. Less is sometimes more
We generally think of features in a language as simply additive, but it's more complex than that; we often don't think about the benefits of not having a given feature! Some features we often think are essential for software development but in reality, they're not; the benefits of not having a feature in a language is sometimes difficult to spot.
It's beyond the scope of this post to provide all the details, but it's something I'll be covering in the next post in this series. For now just understand that although F# provides very good interop to C#, there are a number of features which are either more challenging to use from, or are simply not available in, F# - and removing these features has not only simplified the way we write code, but has also enabled other features to be strengthed.
3. Consider the teams you'll be working on
If you truly embrace functional programming in C#, consider the rest of your team and how you'll be writing code alongside them. I can speak from personal experience in this regard - you run the risk of writing code that the rest of your team see as completely atypical, and may find difficult to understand. In effect, you could be developing code that has a completely different style to the rest of the codebase, despite the fact that it's written in the same language - and you may be the only person on the team that can maintain it.
This is often one of the reasons I hear people that want to benefit from FP saying when they use C# in their teams - unfortunately, simply using C# syntax but writing code in a fully FP style will simply mean that the majority of C# developers will not appreciate the code style you adopt - and will potentially be left with the feeling that FP is simply a bad idea.
4. There is no such thing as a free lunch
C# is a great language; however, it would be foolhardy to say that it can do everything perfectly. Writing code in a functional style in C# is a bit like driving a Ferrari through the jungle - it's simply not what it was designed to do. Conversely, on the Autobahn where there are no speed limits, it shines. The further you go into the world of FP in C#, the more and more keywords you need (adding complexity throughout) to achieve the same things that F# achieves for free. This is not about saying that F# is intrisincally better than C#, but where the underlying strengths of both languages sit - and importantly, which style better suits how you like to model solutions.
Don't fool yourself into thinking that by adopting higher order functions and using switch expressions that you're now "doing" FP - you're only scratching the surface, and the cost that C# imposes upon you the more you "push against" the natural tendencies of the language, the more you will end up with a negative impression of FP. I say the same thing to F# developers when they try and write imperative code using mutable objects, inheritance and so on - it's not what the language was designed to do. Listen to the language.
The simple truth is that there is no short cut to getting the full benefits of functional programming, which on .NET is served through F#. There is no magic keyword that can negate the real benefits F# provides by offering features such as expressions and immutability as first-class features that are pervasive throughout the language and libraries.
5. Language defaults are important
When I used to advocate using F#, I could easily point to features in the language that C# didn't have and illustrate some of the ease-of-use. For example, tuples, records, pattern matching, discriminated unions etc.. Today, things are a little different - C# contains (or will soon contain) variants of all of those features. I think that it's not especially worth talking about the set of features of either language, but instead focusing on defaults and fundamentals instead.
There are some fundamentals at play in a language that often simply can't easily be changed. This point is really about acknowledging that, and understanding and appreciating that these defaults have far-reaching consequences. If you want to gain the benefits of functional programming, you must pay the "cost" - and that means playing by the rules of FP, such as:
- Expressions, not statements
- Immutability, not mutability
- Separation of state and behaviour, not objects with hidden state
- Pure functions, not side effects
- Declarative, not imperative
- Composition, not inheritance
Now think about C# and how things have changed over the years. I would suggest that when C# first came out, virtually all of the characteristics on the right column were prevalent - think about how the early versions of file system access used the Template pattern, for example. Since then many features have been added that have brought in some elements from the left side. Sometimes, the two sides can play together nicely; other times, it can be difficult to mix and match.
Even though you may be writing code that favours - such as immutability or declarative code - the roots of C# are firmly geared in the other camp. There are costs to this that realistically, are very difficult to overcome: for example, you couldn't tomorrow suddenly say that C# would adopt immutability by default.
6. More features <> better
I think that today C# has more features, keywords and operators in everyday use than F#. However, don't fall into the trap of measuring the productivity of a language by the number of features that it contains i.e. that the more features it has, the "better" it is - I firmly believe that this is totally the wrong metric to use.
Think about the character of C# and what features you find genuinely useful when developing. How many features in C# that you use do you really rely on? I'm not talking about obscure features that you touch once a year but core features of C# that perhaps in the past you found super-useful but these days you've found alternative ways to achieve the same thing. This is a difficult question, particuarly if you've only worked in one programming language or been exposed to a single way of developing software - we often take for granted features in our day-to-day language which, actually, aren't always the case.
C# is in an interesting position now - it's adding new features that are in some ways cannibalising other features in the language. Which ones should you use going forward? Is there a right or wrong? Indeed, looking at the recent announcement on C#9, one can see this happening right now. As a new developer to C#, how would you approach the language?
In my next post I'll give some concrete examples where F# intentionally restricts what you can do in comparison to C#, and why this is. In the meantime, have a watch of a recent talk I gave at NDC here which discusses many of the points raised above. Until my next post, have
(fun _ -> ())!