c# Indicate nullability of generic member

Viewed 390

In this contrived C# 8 example:

#nullable enable
class Fred<T>
{
    T Value;  // If T is a nullable type, Value can be null.
    public Fred()                 { }
    public void SetValue(T value) { Value = value; }
    public T GetValue()           { return Value; }
    public string Describe()      { return Value.ToString() ?? "oops"; }
}
class George
{
    George()
    {
        Fred<George> fredGeorge = new Fred<George>();
        George g = fredGeorge.GetValue();
        Fred<float> fredFloat = new Fred<float>();
        float f = fredFloat.GetValue();
    }
}

I have three design goals:

  1. The compiler should warn me if I write any methods for Fred that blindly assume that 'Value' will never be null
  2. The compiler should warn me if I write any methods outside of Fred (say, in George) that blindly assume that 'GetValue' will never return null.
  3. No compiler warnings (if I have written code that doesn't blindly assume values won't be null)

So this first version isn't bad, I get a warning in Fred that Describe() might be dereferencing a null reference (satisfies goal #1) but I also get a warning that Value is uninitialized in Fred's constructor (violates goal #3) and George compiles without any warnings (violates goal #2). If I make this change:

public Fred() { Value = default; }

George still compiles without warnings (violates goal #2) and I get a different warning in Fred's constructor about a "Possible null reference assignment" (violates goal #3).

I can get rid of the possible null reference assignment by using the null-forgiving operator:

public Fred() { Value = default!; }

And now Fred only has the correct warning (possible dereference in Describe()), but George also compiles without warning (violates goal #2).

If I try to indicate that 'Value' can be null:

T? Value;

I get a compiler error that "A nullable type parameter must be known to be a value type or non-nullable reference type" so that's no good.

If I go back to

T Value;

and add the "MaybeNull" attribute:

[return: MaybeNull]
public T GetValue() { return Value; }

I get two warnings - one in Fred.Describe() warning of a possible null dereference (correct) and one in George warning that fredGeorge.GetValue() might be null (correct). There is no warning about fredFloat.GetValue() being null (correct).

So after adding code to expect null references, what I end up with is this:

class Fred<T>
{
    T Value;

    public Fred()
    {
        Value = default!;
    }

    public void SetValue(T value)
    {
        Value = value;
    }

    [return: MaybeNull]
    public T GetValue()
    {
        return Value;
    }

    public string Describe()
    {
        return (Value == null) ? "null" : (Value.ToString() ?? "ToString is null");
    }
}

class George
{
    George()
    {
        Fred<George> fredGeorge = new Fred<George>();
        George? g = fredGeorge.GetValue();
        Fred<float> fredFloat = new Fred<float>();
        float f = fredFloat.GetValue();
    }
}

Is this the correct pattern for this functionality?

1 Answers

In System.Diagnostics.CodeAnalysis there is an attribute AllowNullAttribute that specifies that null is allowed as an input even if the corresponding type disallows it. I would use this attribute in your final sample to:

  • Decorate field Value. This will allow us to remove null-forgiving operator in the assignment Value = default. Compiler will not warn us about Possible null reference assignment, because now it knows that null value can be assigned to the property Value.
  • Decorate argument T value of the method SetValue. It will allow to pass null value to the method SetValue without getting compiler warning Cannot convert null literal to non-nullable reference type. (Currently if we pass null value to the method SetValue we will get this warning)

Here is final sample with suggested changes:

class Fred<T>
{
    // AllowNull attribute says that a null value
    // can be assigned to the field Value.
    [AllowNull]
    private T Value;

    public Fred()
    {
        // Now we can delete null-forgiving operator, because compiler knows
        // that null value can be assigned to the field Value.
        Value = default;
    }

    // AllowNull attribute says that a null value
    // can be passed to the method SetValue.
    public void SetValue([AllowNull] T value)
    {
        Value = value;
    }

    [return: MaybeNull]
    public T GetValue()
    {
        return Value;
    }

    public string Describe()
    {
        return (Value == null) ? "null" : (Value.ToString() ?? "ToString is null");
    }
}

class George
{
    George()
    {
        Fred<George> fredGeorge = new Fred<George>();
        George? g = fredGeorge.GetValue();

        // Compiler does not warn us "Cannot convert null literal to
        // non-nullable reference type" because it knows that a null
        // value can be passed to the method SetValue.
        fredGeorge.SetValue(null);

        Fred<float> fredFloat = new Fred<float>();
        float f = fredFloat.GetValue();
    }
}

If we use a regular property instead of the field Value with a pair of methods GetValue and SetValue then we can rewrite final sample in a clearer way:

class Fred<T>
{
    // Here we tell that:
    // 1) a null value can be assigned;
    // 2) a null value can be returned.
    [AllowNull, MaybeNull]
    public T Value { get; set; }

    public Fred()
    {
        // Compiler does not warn us "Possible null reference assignment".
        // It knows that a null value can be assigned. It is correct.
        // We can delete null-forgiving operator.
        Value = default;
    }

    public string Describe()
    {
        // If we delete null checking, then we get a warning "Dereference of
        // a possibly null reference". It is correct. Compiler helps us to avoid
        // NullReferenceException.
        return (Value == null) ? "null" : (Value.ToString() ?? "ToString is null");
    }
}

class George
{
    George()
    {
        Fred<George> fredGeorge = new Fred<George>();

        // Compiler warns us "Converting null literal or possible null
        // value to non-nullable type". It is correct.
        // We should use nullable reference type George?.
        George g = fredGeorge.Value;

        // Compiler does not warn us "Cannot convert null literal to
        // non-nullable reference type". It knows that a null value
        // can be passed to the method SetValue. It is correct.
        fredGeorge.Value = null;

        Fred<float> fredFloat = new Fred<float>();
        float f = fredFloat.Value;
    }
}
Related