I was watching Don Syme talk about the F# compiler and was impressed that it implicitly promotes a mutable value type to the heap (FSharpRef<>) when it escapes the stack context:
let x(p) = let mutable x = 3 let f () = x <- x + 1 f
But that weird corner case in which the compiler performed so gracefully got me thinking about all the reference type wrapping F# does by default.
First, I’d like to understand why Option<'T> is a reference type. I see Option<'T> as a textbook example of when to use a struct vs a class, but since ValueOption<'T> exists I feel I must be missing something. From the .NET design guidelines, structs should be used when:
- it’s immutable
- it’s short-lived
- logically represents a single value
- small (under 16 bytes)
- boxed infrequently
F# is immutable by default and this wrapper is typically short-lived: a method returns an option that is immediately decomposed with a match expression. The corner case of all corner cases would be for someone to declare an option as mutable and let it escape the context, but as shown above, the compiler handles that perfectly.
ValueOption<'T> doesn’t automatically meet the 16-byte criteria, but it only increases the obj size by 4 bytes. Option<'T> has a single 8-byte backing field for a pointer (
internal T item;) and uses
item = null for pattern matching. Since 'V value types have a default value, it adds an extra 32-bits to the size of 'V for an enum backing field:
ValueNone | ValueSome.
The same logic applies to single case DUs. This tick’s the remaining box on the when to use struct checklist:
- commonly embedded in other objects
type OrderID = OrderID of int get’s embedded in type Order, but defaults to boxing primitive types. This behavior put me off from using them, but I do see the value (just spent a few hours debugging a method that was passed a userID instead of their groupID). I think I’ll use the struct version in the future.
I’ll save regular DU’s for another question. There’s a bit more to the struct version of that wrapper and I’m not sure why it creates a backing field for each case (
internal Case1 case1; internal Case2 case2;) instead of one generic field (
internal T item;).