How to initialize a non-nullable generic type property (or field) (c# 8 or 9)

1.3k Views Asked by At

I'm converting some older code to c# 8 and 9 with nullable enabled. I've run into some trouble with generics. Here's an example. The older class has an unconstrained type parameter:

public class WeightedValue1<T>
{
    public double Weight { get; set; }
    public T Value { get; set; }
}

This gives CS8618: Non-nullable property 'Value' must contain a non-null value when exiting constructor.

That's great; it's preventing exactly the sort of bug the feature is designed to catch - users dereferencing .Value and getting a NullReferenceException. The problem is it's hard to initialize the property correctly. In the old world it would be:

public class WeightedValue2<T>
{
    public double Weight { get; set; }
    public T Value { get; set; } = default;
}

This gives CS8601 Possible null reference assignment. As well it should! I haven't actually solved the problem. Even if T is non-nullable string , callers get NRE with no warning:

[TestClass]
public class NreTest2
{
    [TestMethod]
    public void NonNullableString_ThrowsNre()
    {
        Assert.ThrowsException<NullReferenceException>(() => new WeightedValue2<string>().Value.ToLower());
    }
}

Just for completeness, notice that specifying the notnull constraint doesn't change anything.

public class WeightedValue3<T>
    where T : notnull
{
    public double Weight { get; set; }
    public T Value { get; set; } = default;
}

[TestClass]
public class NreTest3
{
    [TestMethod]
    public void ConstrainedString_ThrowsNre()
    {
        Assert.ThrowsException<NullReferenceException>(() => new WeightedValue3<string>().Value.ToLower());
    }
}

And of course using the null-forgiving operator (!) hides the warning but doesn't help the caller at all.

public class WeightedValue4<T>
    where T : notnull
{
    public double Weight { get; set; }
    public T Value { get; set; } = default!;
}
[TestClass]
public class NreTest4
{
    [TestMethod]
    public void ForgivenString_ThrowsNre()
    {
        Assert.ThrowsException<NullReferenceException>(()=> new WeightedValue4<string>().Value.ToLower());
    } 
}

Right, so the problem is really that default of string is null, whether we want it to be allowed or not. And nothing we tell the compiler will let it change that. So we have to initialize it with something. Since it's a generic unconstrained T, we can't find any valid values. We have to push this onto the caller. All my existing callers will be using the object initializer syntax, so the new c#9 "init" properties would seem a great fit.

public class WeightedValue5<T>
{
    public double Weight { get; set; }
    public T Value { get; init; }
}

Sigh. Unfortunately init doesn't do what I want. It means you can't call the setter after object creation, but it doesn't force you to actually initialize the property. It's doubly frustrating because the compiler knows it needs initialization.

[TestClass]
public class NreTest5
{
    [TestMethod]
    public void InitOnlySetter_Compiles_AndThrowsNre()
    {
        Assert.ThrowsException<NullReferenceException>(() => new WeightedValue5<string>().Value.ToLower());
    }
}

Here's a version that at least does the job of warning callers about the nullability. But it requires changing every existing call site. And it has the usual annoyances when you start having lots of properties and multiple constructors to initialize.

public class WeightedValue6<T>
{
    public double Weight { get; set; }
    public T Value { get; }

    public WeightedValue6(T value)
    {
        Value = value;
    }
}

I've also explored the attribute annotations but I don't see how they can help. And AFAIK the new record types have the same problem despite their goal of reducing boilerplate for simple reference types.

Am I crazy? Is it really true that the only way I can initialize generic fields with definitely non-null values is to make every caller specify them manually? And the only way to force that is with full-fat constructors? (and of course setting specific warnings as errors.)

If that's the case, is it wrong to wish the compiler would rewrite my version with init; as if it had the full constructor?

1

There are 1 best solutions below

0
On BEST ANSWER

You mentioned records not being able to solve the problem, but I think they reduce the boilerplate for this quite a bit.

public record WeightedValue<T>(double Weight, T Value);

If you pass null to value, the compiler is going to complain with nullable reference types on. There is also the with syntax that does give you a bit of the init feel, but you need to have a value you are copying from.

var weightedValue = new WeightedValue<string>(0, "Hello"); // fine
weightedValue = new WeightedValue<string>(0, null); // compiler warning
weightedValue = weightedValue with { Value = null }; // compiler warning

I like object initialization as well, but named parameters look pretty good to. The only place they don't work is in expression trees (which is quite annoying).

var weightedValue = new WeightedValue<string>(
    Weight: 0, 
    Value: "Hello"
);

Another potential option using the with syntax is to create a default value to init your types and then call it and change parameters you are interested in with your object.

public static WeightedValue<string> defaultWeightedString = new(0, "");

//...

var weightedValue = defaultWeightedString with { Weight = 10 }; // string Value initialized

I see lots of potential here. However, if we had some sort of { get; init required; } to force initialization on instantiation, that would be great.

Update It looks like there is a proposal for this.

https://github.com/dotnet/csharplang/issues/3630

https://github.com/dotnet/csharplang/discussions/4209