Null-checking considerations in F# - it's harder than you think
May 18, 2015The near-complete obviation of nulls is perhaps the most frequently- (and hilariously-) cited benefit of working in F#, as compared to C#. Nulls certainly still exist in F#, but as a practical matter it really is quite rare that they need to be considered explicitly within an all-F# codebase.
It turns out this cuts both ways. On those infrequent occasions where one does need to check for nulls, F# actually makes it surprisingly difficult to do so safely and efficiently.
In this post I’ve tried to aggregate some best practices and pitfalls, in the form of DOs and DON’Ts, for F# null-checking.
Recall that there are a handful of ways that nulls can be introduced into F# code, even for “non-nullable” F# types:
- Affecting all reference types
- C# or other CLI language consumers passing null
- .NET framework APIs returning null (e.g. LINQ
FirstOrDefault<T>()
) - F#
Unchecked.defaultof
- F#
Array.zeroCreate
- Affecting only “nullable types”: non-F# types or F# types marked with
[<AllowNullLiteral>]
- null literals
DON’Ts
Don’t use ‘x = null’ or ‘x <> null’
It’s unfortunate that the most obvious approach is not the best. This form is perfectly functional for nullable types, but the generated IL is unoptimized and ends up being quite slow. Standard F# structural/generic comparison is used here, which is a well-known drag on performance.
F#:
let nullCheck01 x = (x = null)
Codegen (C# equivalent):
public static bool nullCheck01<a>(a x) where a : class
{
return LanguagePrimitives.HashCompare.GenericEqualityIntrinsic<a>(x, default(a));
}
// --- F# core library ---
public static bool GenericEqualityIntrinsic<T>(T x, T y)
{
return LanguagePrimitives.HashCompare.GenericEqualityObj(false, LanguagePrimitives.HashCompare.fsEqualityComparerNoHashingPER, );
}
internal static bool GenericEqualityObj(bool er, IEqualityComparer iec, object xobj, object yobj)
{
if(xobj == null) return yobj == null;
if(yobj != null) { /* ... core logic, fairly big, prevents JIT inlining ... */ }
return false;
}
By design, this form is not permitted for non-nullable types, as the compiler will not allow null
to represent an instance of such a type. One workaround is to convert to System.Object
first, i.e. (box x) = null
. This is perfectly functional, even for non-nullable types, but it is still slow.
Don’t use x = Unchecked.defaultof<_>
When the need arises to null-check a non-nullable type, one might be tempted to compare directly against Unchecked.defaultof<_>
, rather that converting to System.Object
and comparing against a null literal.
Unfortunately, this approach falls on its face when used with functional-style record or discriminated union types. Due to its baked-in notion of immutability and non-nullability for such types, the compiler assumes that it is always safe to codegen their equality comparisons as simply x.Equals(y)
. That’s fine until x is null, in which case the null-check itself blows up with a NullReferenceException
!
F#:
type Cat = { Name : string; Lives : int }
let nullCheck02 (x : Cat) = (x = Unchecked.defaultof<_>)
Codegen (C# equivalent):
public static bool nullCheck02(Cat x)
{
Cat obj = null;
// nullref
return x.Equals(obj, LanguagePrimitives.GenericEqualityComparer);
}
Don’t expect to catch NullReferenceException
Let’s say you decide that you’ll mostly ignore the possibility of nulls, and but harden your code by catching NullReferenceException.
F#:
type Cat = { Name : string; Lives : int }
let ohNoes x =
try
if x.Lives > 1 then Some({ x with Lives = x.Lives - 1})
else None
with
| :? NullReferenceException -> None
ohNoes (Unchecked.defaultof<_>)
Most F# developers will be surprised to find that this still fails with an unhandled NullReferenceException
.
How is that possible? NullReferenceException
is clearly handled!
Due to Cat
’s immutability/non-nullability, the compiler believes the contents of the try
block cannot fail. Thus it performs an “optimization” by removing the try/catch altogether. Indeed, if you inspect the generated IL, you will find that the try/catch block simply isn’t generated:
Codegen (C# equivalent):
public static FSharpOption<Cat> ohNoes(Cat x)
{
if (x.Lives > 1)
{
return FSharpOption<Cat>.Some(new Cat(x.Name, x.Lives - 1));
}
return FSharpOption<Cat>.None;
}
FWIW this is an altogether bogus optimization anyway, and has been removed in F# 4.0.
DOs
Do pattern match against null
Pattern matching against null results in much better generated IL than comparison with =
or <>
. Nullable types can be matched directly, non-nullable types must be converted to System.Object
first by using box
.
F#:
type Cat = { Name : string; Lives : int }
let nullCheck03 x = match x with null -> true | _ -> false
let nullCheck04 (x : Cat) = match box x with null -> true | _ -> false
Codegen:
// nullCheck04 is identical except generic "a" becomes concrete "Cat"
.method public static bool nullCheck03<class a> (!!a x) cil managed
{
.maxstack 8
IL_0000: ldarg.0
IL_0001: box !!a
IL_0006: brfalse.s IL_000a
IL_0008: ldc.i4.0
IL_0009: ret
IL_000a: ldc.i4.1
IL_000b: ret
}
The JIT produces exactly the same native code for both of these functions:
; if x is null, go to 00D50503
00D504FB test ecx,ecx
00D504FD je 00D50503
; set return value to 0 (false)
00D504FF xor eax,eax
; return
; set return value to 1 (true)
00D50503 mov eax,1
; return
Do use Object.ReferenceEquals(x, null)
Another way to get efficient null-checking is to call the BCL method Object.ReferenceEquals
.
F#:
let nullCheck05 (x : 't when 't : null) = System.Object.ReferenceEquals(x, null)
Codegen:
.method public static bool nullCheck05<class t> (!!t x) cil managed
{
.maxstack 8
IL_0000: nop
IL_0001: ldarg.0
IL_0002: box !!t
IL_0007: ldnull
IL_0008: call bool [mscorlib]System.Object::ReferenceEquals(object, object)
IL_000d: ret
}
// --- BCL ---
.method public hidebysig static bool ReferenceEquals (object objA, object objB) cil managed
{
.maxstack 8
IL_0000: ldarg.0
IL_0001: ldarg.1
IL_0002: ceq
IL_0004: ret
}
At the IL level this appears to suffer from the overhead of an additional method call, but ReferenceEquals
is small enough that it will be inlined by the JIT. The resulting native code winds up being at least as good as our earlier efforts, perhaps even better:
; if x is null, set the lowest byte of return value to 1
; otherwise set it to 0
012404A3 test ecx,ecx
012404A5 sete al
; fill out the rest of return value with 0s
012404A8 movzx eax,al
; return
By some measurements, Object.ReferenceEquals
(or pattern-matching) is 7-8x faster than comparison with =
or <>
.
Do use isNull
In F# 4.0, a new helper function isNull
has been added, which has the same pattern matching implementation as nullCheck03
above. It only applies to nullable types, so you will need to box
non-nullable types first. box
and isNull
are both inline functions, so either way the final JITed code is the same as shown above.
Do use Option.ofObj
In F# 4.0, a new helper function Option.ofObj
has been added, which will convert a nullable object into an option instance, based on whether it’s null or not. This adds a bit of perf overhead, but is very handy for when you’d prefer to lean on the type system rather than worry about nulls.