In my last post in this series, I covered five features that C# has that F# doesn't have, relating to some fundamental constructs: mutability and statements. I showed how those fives features preclude certain fundamental features in F# that (in my opinion) outweight the perceived benefits of the original five features. In this post, I'm going to continue with another five features, except these are more of a pick-and-mix set of features rather than being specifically related to a core part of the differences between C# and F#.
I hope they make your think about your code differently - let's get started.
6. Cyclical references
F# does not allow you to reference a value or type unless you've already declared it. This not only applies to symbols in a file, but to the order of files in the project!
let foo = sprintf "Hello, %s" name // does not compile
let name = "isaac"
This seems like a massive limitation, but as it turns out, this feature has several very useful properties:
- It supports the type inference system by reducing the number of types that could potentially be in scope at any one time.
- It makes so-called "spaghetti code" very, very hard to achieve, since you can't reference anything "beneath" where your code is declared.
- It dramatically simplifies the way you think about dependencies and code: if you ever want to know where in a project a type lives in terms of dependencies, just look at where the file physically lives in the project. Things at the "top" of the project are effectively shared throughout, but won't have access to anything else, whilst the final file will be able to see everything but can't be used by anything - typically the entry point or perhaps the composition root to your application.
F# does have support for
rec
modules and namespaces which allow you to temporarily disable this feature. However, it is normally only be for when you genuinely have recursive types.
7. Easy nulls
F# makes it difficult to use null. In fact, for F# types such as records, unions and tuples, it's simply not allowed, as it isn't a part of the type system.
type User = { Name : string; Email : string }
let user:User = null // Error: The type 'User' does not have 'null' as a proper value
Instead, F# introduces the Option
type, which is a much more explicit way to deal with absence of value:
type User = { Name : string; Email : string }
let user:Option<User> = None
In this case, Option<'T>
is a real type that exists at runtime. There are no custom operators to "convert" between optional and non-optional types, or !
or !!
to "temporarily" turn off the type checker when you can't be bothered to "do the right thing". Nor does the compiler do any flow analysis to implicitly convert between nullable and non-nullable types. It's very simple and consistent - and it works remarkably well.
Note: Types coming from other .NET languages such as C# (and this includes the BCL) don't currently benefit from this; in F#5 or 6, it's likely that F# will get interoperability with C#8 nullability markers, although the behaviour will be much more in line with how F# generally works.
C# developers are now starting to appreciate the benefits that non-nullability grants you, including increased confidence, fewer bugs, and a tighter domain model. As mentioned last time out, C#'s nullability model does have some limitations and the designers have been quite honest that it's a "best endeavours" approach. It's also quite complicated, using flow analysis and rules that I've seen people scratching their heads with - it has to work with all the other features in C#, such as constructors, properties, overloads, not to mention interoperability with C# code that doesn't have this flag switched on etc. - there's a lot there!
Also witness how there are now specific attributes as well as custom operators that you can decorate methods to help guide the compiler. It isn't a simple system to operate - you'll need to invest time to truly understand and appreciate it.
8. Overloading
For let
-bound functions in F# you can't do overloading, although you can work around this through the use of discriminated unions - or fall back to static classes with members. However, there's a good reason for not supporting overloading in F#: it adds a huge amount of complexity to the type checker - ask anyone on the C# team what the most complex part of the language is and you'll nearly always hear "overload resolution" (although this might now be replaced with "nullable types"...). And this means that overloading doesn't play nicely with either type inference or partially applied functions.
In everyday usage overloading is a nice feature to have, and there are times when F# developers will fall back to static classes in order to achieve them, but it's normally the exception to the rule - you can (and we do) happily write applications without using it at all.
9. Open type hierarchies
With classes and inheritance, you can create type hierarchies that can be extended by anyone, even after compilation. For example, you may create a base class Foo, and someone else may inherit from it, overriding methods etc. as they go; the runtime will polymorphically dispatch to the derived methods as required.
In F#, if you stick to the "FP" side of the language, the closest equivalent to inheritance in order to model the "is-a" relationship is discriminated unions. The most important "limitation" of DUs is that they force you to declare all cases of the union up-front, at compile time. You cannot "add" new cases elsewhere in code, nor can you add new implementations in external libraries.
At first glance, this seems like a massive restriction. But, unless you are building a genuinely open plugin model, DUs will nearly always be a better fit for what you're modelling: one of the main benefits you get from a "closed" model is that the compiler understands all possible cases that you may be dealing with. Take the following hierarchy of contact methods for a customer, and a function that knows how to dispatch a message to them:
type ContactMethod =
| Email of address:string
| Telephone of number:string
/// Sends a message using the supplied contact method
let send contactMethod message =
match contactMethod with
| Email address -> sprintf "Emailing %s with message %s" address message
| Telephone number -> sprintf "Calling %s to tell them %s. Ring, ring!" number message
If I add another case to the set of contact methods - let's say Postal
- the compiler will automatically warn me that I haven't handled this case in every code block that matches on a contact method. And more importantly, the compiler will stop warning me once it knows that I've handled all possible cases. This is especially powerful because you can recursively match on combinations of DUs - imagine having a list of countries and having different ways of composing emails depending on the country; the compiler can help ensure that you always deal with all combinations of Postal and Country etc.
This sort of compiler support is not easily achievable with inheritance, since anyone can add a new implementation at any time and therefore the compiler can't provide exhaustive checks for you.
10. Protected keyword
To end with, here's a very simple one: F# does not support the protected keyword. It lets you consume protected values created in e.g. C#, and even override it - but you can't declare them yourself. This is again simply part of simplifying F# to focus on what it wants to lead you down the path to: functional-first with a smattering of object-based programming.
Summary
This feature... | ...stops you having this |
---|---|
Cyclical references | Simplified code flow; type inference |
Easy null | More confidence in your code; fewer classes of bugs |
Overloading | Type inference; easier to add other features to the language |
Open type hierarchies | Compiler support for exhaustive checking |
Protected | Limited risk of complex inheritance |
That's the end of some C# features that F# doesn't have! In the next post in this series, I'll loop back to the start and discuss the original blog post and discussion that started this off, and compare and contrast some of the solutions we can achieve in C# and F#.