Puzzler with structs, generics and nullability
Recently I found a non-obvious behavior of combining structs with nullability.
All code for this post is available at my GitHub
The puzzler
Let’s say we want to write a generic transform function for nullable:
public static TResult? Transform<TSource, TResult>(
TSource? source,
Func<TSource, TResult> transformation)
=> source is null
? default
: transformation(source);
It gets a nullable argument, a transformation (function that gets an argument and returns a result) and applies transformation if argument is not null. Otherwise, it returns null.
It works great until you try to apply this to some value type, say DateTime:
DateTime? source = null;
DateTime? result = NullableStructsTransformer.Transform(source, x => x.AddDays(1));
You are getting the following:
Severity Code Description Project File Line Suppression State Error CS1061 ‘DateTime?’ does not contain a definition for ‘AddDays’ and no accessible extension method ‘AddDays’ accepting a first argument of type ‘DateTime?’ could be found
Ok you are are adding an overload with struct constrain and it compiles:
public static TResult? Transform<TSource, TResult>(
TSource? source,
Func<TSource, TResult> transformation)
where TSource : struct // <-- This one
=> source is null
? default
: transformation(source.Value);
But… it does not work. For non-null values it return transformed values, but for nulls it returns default values.
For example, for dates it returns <0001-01-01 00:00:00.000>
.
The following test fails:
[Fact]
public void Transform_IfNullStruct_ReturnsNull()
{
DateTime? source = null;
DateTime? result = NullableStructsTransformer.Transform(source, x => x.AddDays(1));
result.Should().BeNull();
}
Did not expect result to have a value, but found <0001-01-01 00:00:00.000>.
The fix
The fixed version is below
public static TResult? TransformFixed<TSource, TResult>(
TSource? source,
Func<TSource, TResult> transformation)
where TSource : struct
where TResult : struct // <-- Add constraint on result
=> source is null
? default(TResult?) // <-- Specify return type
: transformation(source.Value);
The explanation
Lets insert our code into sharplab to compare:
Not working version:
[return: System.Runtime.CompilerServices.Nullable(2)]
public static TResult Transform<TSource, [System.Runtime.CompilerServices.Nullable(2)] TResult>(Nullable<TSource> source, [System.Runtime.CompilerServices.Nullable(new byte[] { 1, 0, 1 })] Func<TSource, TResult> transformation) where TSource : struct
{
return (!source.HasValue) ? default(TResult) : transformation(source.Value);
}
Working version:
public static Nullable<TResult> TransformFixed<TSource, TResult>(Nullable<TSource> source, [System.Runtime.CompilerServices.Nullable(new byte[] { 1, 0, 0 })] Func<TSource, TResult> transformation) where TSource : struct where TResult : struct
{
return (!source.HasValue) ? null : new Nullable<TResult>(transformation(source.Value));
}
.NET have two nullable types implementations:
- reference nullable type
- value nullable types
The first kind is not a true .net runtime type, but just an attribute + compiler support.
The second kind is a struct wrapper around value.
When compiler does not know whether result will be value type or not it generates a generic method which returns a result, but marked with nullable attribute. Which works with reference types (since they are always nullable from runtime standpoint), but produces a strange hybrid in case of value types: struct marked with a nullable attribute instead of Nullable<T> for struct.
So actually, instead of default(TResult?) in case of struct it produces default(TResult) which is empty value.
Further steps
We have two versions: 1st one to transform structs to reference types; 2nd one to transform structs to structs.
If somebody accidentally uses 1st version instead of second, we can have compiled code with a problem which will be found in runtime.
Fortunately, we can prevent it easily: just add
where TResult: class
into type constraints of the first version.
All code for this post is available at my GitHub