Behavior of Passing Nullable<T> into a Generic Method that accepts T?

76 Views Asked by At

I am wondering what the behavior of passing a Nullable<int> type to a generic method that accepts a T? (another Nullable<int> type). Here is the code in question:

int? myNullableParameter = 8;
MyGenericMethod(myNullableParameter);

void MyGenericMethod<T>(T? parameter)
{
    //Do Something with parameter
    Console.WriteLine($"{parameter}");
}

As this compiles fine here is the confusion. I am setting the type of myNullableParameter to Nullable<int>. Then I pass this type into the parameterized generic method as Nullable<T>. However, the type of the parameter is also a T? which gets resolved from the compiler to a Nullable<T>. So is it a Nullable<Nullable<int>>? Or does the compiler simply ignore this second Nullable<T> and keep the parameter inside the method as a Nullable<int>?

Thanks!

1

There are 1 best solutions below

4
Dai On

(My answer assumes you're using C# 9.0 or later)

I am wondering what the behavior of passing a Nullable<int> type to a generic method that accepts a T? (another Nullable<int> type).

Methinks you have a misconception; from here it looks like you think/understand/believe that when an unconstrained generic type T is annotated as T? then it means C#+CLR will handle value-typed T type-arguments as Nullable<T> and reference-typed T type-arguments as [AllowNull]/[MaybeNull], including somehow marshalling non-null value-types between T? and T.

However the truth is... it doesn't.

Since C# 9.0, the generic-type syntax T? (when T is unconstrained) just means that:

  • "If T is a reference-type, then at-this-point, this T is possibly-null, i.e. [AllowNull]/[MaybeNull]"
  • "If T is a Nullable<U>, then at-this-point, T could be null"
    • i.e. nullableStruct.HasValue == false
    • Similarly, if a type T (not a T?) appears in the same scope, then that denotes a non-null Nullable<T> (i.e. value.HasValue == true).
  • "If T is a value-type (and not Nullable<U>), then this T is a non-nullable T value-type".
    • This one stands out like a sore thumb and is an ugly new wart on the C# language. le sigh.

A demonstration:

Consider this method:

void AcceptsAllegedlyNullableT<T>( T? nullableTee, T notNullableTee ) // <-- T is unconstrained here
{
    // etc
}

Case 1: Non-nullable-reference-type T := String

If we specify T = String then:

  • The nullableTee parameter retains its ? annotation, so it becomes String?.
  • The notNullableTee parameter represents (non-nullable) String.

So we have void AcceptsAllegedlyNullableT<String>( String? nullableTee, String notNullableTee ) such that nullableTee accepts a maybe-null String reference and notNullableTee does not.

...so far, so good.

Case 2: Nullable-reference-type T := String?

If we specify T = String?, then the nullableTee parameter won't be String??, but instead the annotations collapse down to String?

...while T notNullableTee turns into String? notNullableTee.

So we have void AcceptsAllegedlyNullableT<String?>( String? nullableTee, String? notNullableTee ) such that nullableTee accepts a maybe-null String reference - and notNullableTee is actually quite nullable.

...feeling awkward yet?

Case 3: (Non-nullable) value-type T := Int32

If we specify T = Int32, then the ? annotation on nullableTee's type is a lie because its static type remains Int32 and not Nullable<Int32>.

  • So we have void AcceptsAllegedlyNullableT<Int32>( Int32 nullableTee, Int32 notNullableTee ) such that nullableTee cannot accept a Nullable<Int32>, despite the ? annotation, but at least notNullableTee is accurately-named once again...
  • I've overheard people saying that in-light of this, the ? annotation should be interpreted as "maybe-default(T)" and not "maybe-null" - but those people live a in bubble.

Case 4: Nullable-value-type T := Nullable<Int32> (aka Int32?)

If we specify T = Nullable<Int32> (aka Int32? or int?), then the ? annotation on nullableTee's type is basically ignored by everything now - and all T values are nullable too; so now we have void AcceptsAllegedlyNullableT<Nullable<Int32>>( Nullable<Int32> nullableTee, Nullable<Int32> notNullableTee )

...so our notNullableTee is a liar-liar-type-system-on-fire.


Here's what happens when you run such a program with those type-parameter arguments:

void Main()
{
    AcceptsAllegedlyNullableT<String >( null, "a"  );
    AcceptsAllegedlyNullableT<String?>( null, null );
    
    AcceptsAllegedlyNullableT<Int32  >(  123,  456 );
    AcceptsAllegedlyNullableT<Int32? >( null, null );
}

static void AcceptsAllegedlyNullableT<T>( T? nullableTee, T notNullableTee ) // <-- T is unconstrained here
{
    Console.WriteLine( "------------------------------------------" );
    
    Type staticType   = typeof(T);
    Type runtimeType1 = nullableTee   ?.GetType();
    Type runtimeType2 = notNullableTee?.GetType();
    
    Console.WriteLine( "Runtime value of nullableTee   : " + ( nullableTee   ?.ToString() ?? "null" ) );
    Console.WriteLine( "Runtime value of notNullableTee: " + ( notNullableTee?.ToString() ?? "null" ) );
    
    Console.WriteLine( "Static-type of nullableTee   : " + typeof(T) .FullName );
    Console.WriteLine( "Static-type of notNullableTee: " + typeof(T?).FullName );
    
    Console.WriteLine( "Runtime-type of nullableTee   : " + ( nullableTee   ?.GetType().FullName ?? "null" ) );
    Console.WriteLine( "Runtime-type of notNullableTee: " + ( notNullableTee?.GetType().FullName ?? "null" ) );
    
    Console.WriteLine( "------------------------------------------" );
}

Console output:

Runtime value of nullableTee   : null
Runtime value of notNullableTee: a
Static-type of nullableTee   : System.String
Static-type of notNullableTee: System.String
Runtime-type of nullableTee   : null
Runtime-type of notNullableTee: System.String
------------------------------------------
------------------------------------------
Runtime value of nullableTee   : null
Runtime value of notNullableTee: null
Static-type of nullableTee   : System.String
Static-type of notNullableTee: System.String
Runtime-type of nullableTee   : null
Runtime-type of notNullableTee: null
------------------------------------------
------------------------------------------
Runtime value of nullableTee   : 123
Runtime value of notNullableTee: 456
Static-type of nullableTee   : System.Int32
Static-type of notNullableTee: System.Int32
Runtime-type of nullableTee   : System.Int32
Runtime-type of notNullableTee: System.Int32
------------------------------------------
------------------------------------------
Runtime value of nullableTee   : null
Runtime value of notNullableTee: null
Static-type of nullableTee   : System.Nullable`1[[System.Int32, System.Private.CoreLib, Version=7.0.0.0, Culture=neutral, PublicKeyToken=7cec85d7bea7798e]]
Static-type of notNullableTee: System.Nullable`1[[System.Int32, System.Private.CoreLib, Version=7.0.0.0, Culture=neutral, PublicKeyToken=7cec85d7bea7798e]]
Runtime-type of nullableTee   : null
Runtime-type of notNullableTee: null
------------------------------------------