56

I want to create a generic class that has a member of type T. T may be a class, a nullable class, a struct, or a nullable struct. So basically anything. This is a simplified example that shows my problem:

#nullable enable

class Box<T> {
    public T Value { get; }

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

    public static Box<T> CreateDefault()
        => new Box<T>(default(T));
}

Due to using the new #nullable enable feature I get the following warning: Program.cs(11,23): warning CS8653: A default expression introduces a null value when 'T' is a non-nullable reference type.

This warning makes sense to me. I then tried to fix it by adding a ? to the property and constructor parameter:

#nullable enable

class Box<T> {
    public T? Value { get; }

    public Box(T? value) {
        Value = value;
    }

    public static Box<T> CreateDefault()
        => new Box<T>(default(T));
}

But now I get two errors instead:

Program.cs(4,12): error CS8627: A nullable type parameter must be known to be a value type or non-nullable reference type. Consider adding a 'class', 'struct', or type constraint.
Program.cs(6,16): error CS8627: A nullable type parameter must be known to be a value type or non-nullable reference type. Consider adding a 'class', 'struct', or type constraint.

However, I don't want to add a constraint. I don't care if T is a class or a struct.

An obvious solution is to wrap the offending members under a #nullable disable directive. However, like #pragma warning disable, I'd like to avoid doing that unless it's necessary. Is there another way in getting my code to compile without disabling the nullability checks or the CS8653 warning?

$ dotnet --info
.NET Core SDK (reflecting any global.json):
 Version:   3.0.100-preview4-011223
 Commit:    118dd862c8
Stephen Kennedy
  • 20,585
  • 22
  • 95
  • 108
Andent
  • 601
  • 5
  • 5
  • 9
    I haven't looked more closely at this problem, but I am assuming that the differences between `T?` for a value type and a reference type is simply not handled by the compiler. Why? I could *guess* it would just add *plenty* of complexity, but I also guess you would need the input from the actual compiler guys to know for sure. `T?` for a value type is handled by `Nullable` whereas `T?` for a reference type in C# 8 is handled by `T` with an attribute. Basically, I think this is simply not supported. – Lasse V. Karlsen May 03 '19 at 18:21
  • When I try to run this code in VS2019, I get the following: CS8652 C# The feature is currently in Preview and *unsupported*. To use Preview features, use the language version. Maybe try running the Preview version and see if you can get away with it? – nixkuroi May 03 '19 at 18:32
  • @LasseVågsætherKarlsen That's what I fear as well – Andent May 03 '19 at 18:35
  • @nixkuroi I'm using the preview VS and the preview .NET Core SDK – Andent May 03 '19 at 18:35
  • 1
    Hi @Andent, welcome to SO. You are essentially turning on a setting that tells the compiler **not** to allow assignment of `null` to a variable of type `T` (when `T` is a reference type), then you use `default(T)` which **can** return `null` for reference types. There is no mystery here. You must choose a path, like Neo... :-) – JuanR May 03 '19 at 18:47
  • `T?` *does* allow `null`, the problem here is that the compiler apparently requires the code be explicit about whether `T` is a reference type or a value type, because the compiled code will differ for the two. – Lasse V. Karlsen May 03 '19 at 18:57
  • 3
    You're probably looking at making a `Box where T : class` alongside a `ValueBox where T : struct` in the mean time. I don't think there's a path to unifying generic types/methods over `T?` and `Nullable` at the moment. – Jonathon Chase May 03 '19 at 19:11
  • 8
    Fundamentally, nullable types and generics don't mix terribly nicely. It's the stickiest bit of the design, in my experience. – Jon Skeet May 03 '19 at 19:41
  • 1
    I think you have some conflicting goals here. You want to have the notion of a `default` box but for reference types, what else is an appropriate `default`? The default is `null` for reference types which directly conflicts with using nullable reference types. Perhaps you will need to constrain `T` to types that could be default constructed instead (`new()`). – Jeff Mercado May 03 '19 at 21:00
  • 1
    @LasseV.Karlsen The problem is that while T? is fundamentally different for value types (Nullable), the CLR doesn't support specialization of generic methods. So if a generic method has code like `T? x = y ?? z`, the compiler needs to emit _different CIL instructions_ when T is a value type than when it is a reference type - but this is not possible, as the CLR doesn't support the existence of two versions of a method. If they had decided 15 years ago that nullables like `int?` were simply ordinary references (null or a boxed value), we wouldn't have this problem...we'd have a different one – Qwertie Jan 17 '20 at 23:43
  • @Qwertie I agree with that sentiment, that doesn't mean I have to applaud the status quo. As a library author it is exceedingly hard to create generic methods that signal their intent in some cases with regards to nullability. You're forced to tuck on attributes meant to be used by the compiler in the hopes that "it does the right thing". – Lasse V. Karlsen Jan 19 '20 at 13:35

2 Answers2

26

What to do if you are using C# 9

In C# 9, you can use T? on an unconstrained type parameter to indicate that the type is always nullable when T is a reference type. In fact, the example in the original question "just works" after adding ? to the property and constructor parameter. See the following example to understand what behaviors you may expect for different kinds of type arguments to Box<T>.

var box1 = Box<string>.CreateDefault();
// warning: box1.Value may be null
box1.Value.ToString();

var box2 = Box<string?>.CreateDefault();
// warning: box2.Value may be null
box2.Value.ToString();

var box3 = Box<int>.CreateDefault();
// no warning
box3.Value.ToString();

var box4 = Box<int?>.CreateDefault();
// warning: 'box4.Value' may be null
box4.Value.Value.ToString();

What to do if you are using C# 8

In C# 8, it is not possible to put a nullable annotation on an unconstrained type parameter (i.e. that is not known to be of a reference type or value type).

As discussed in the comments on this question, you will probably need to take some thought as to whether a Box<string> with a default value is valid or not in a nullable context and potentially adjust your API surface accordingly. Perhaps the type has to be Box<string?> in order for an instance containing a default value to be valid. However, there are scenarios where you will want to specify that properties, method returns or parameters, etc. could still be null even though they have non-nullable reference types. If you are in that category, you will probably want to make use of nullability-related attributes.

The MaybeNull and AllowNull attributes have been introduced to .NET Core 3 to handle this scenario.

Some of the specific behaviors of these attributes are still evolving, but the basic idea is:

  • [MaybeNull] means that the output of something (reading a field or property, a method return, etc.) could be null.
  • [AllowNull] means that the input to something (writing a field or property, a method parameter, etc.) could be null.
#nullable enable
using System.Diagnostics.CodeAnalysis;

class Box<T>
{
    // We use MaybeNull to indicate null could be returned from the property,
    // and AllowNull to indicate that null is allowed to be assigned to the property.
    [MaybeNull, AllowNull]
    public T Value { get; }

    // We use only AllowNull here, because the parameter only represents
    // an input, unlike the property which has both input and output
    public Box([AllowNull] T value)
    {
        Value = value;
    }

    public static Box<T> CreateDefault()
    {
        return new Box<T>(default);
    }

    public static void UseStringDefault()
    {
        var box = Box<string>.CreateDefault();
        // Since 'box.Value' is a reference type here, [MaybeNull]
        // makes us warn on dereference of it.
        _ = box.Value.Length;
    }

    public static void UseIntDefault()
    {
        // Since 'box.Value' is a value type here, we don't warn on
        // dereference even though the original property has [MaybeNull]
        var box = Box<int>.CreateDefault();
        _ = box.Value.ToString();
    }
}

Please see https://devblogs.microsoft.com/dotnet/try-out-nullable-reference-types for more information, particularly the section "the issue with T?".

Rikki Gibson
  • 4,136
  • 23
  • 34
  • Fascinating. This was my first attempt but it didn't work and I didn't know why. It turns out if I change your example to use "default(T)" instead of "default" I get the same CS8653, which I was not expecting at all. I should think they were semantically identical - in fact I thought the plain default was only legal when they were! – solublefish Sep 24 '19 at 06:21
  • AND if I change your example to omit the [MaybeNull] on Value, I get no warning at all which is even stranger to me. And calling Box.CreateDefault().Value.Length throws NRE at runtime! – solublefish Sep 24 '19 at 06:29
  • 1
    The lack of a warning with `default` in this scenario is a bug. Tracked at https://github.com/dotnet/roslyn/issues/38339. I updated my answer to go into more detail and hopefully shed some more light on the behaviors you're seeing. – Rikki Gibson Sep 25 '19 at 00:11
  • The solution C# 9 provided is still not satisfactory, because it coerces `int?` to `int` instead of `Nullable`... :'( – Good Night Nerd Pride Dec 25 '20 at 13:45
  • 2
    When @JonSkeet calls it "the stickiest bit of the design" then you know it's really a sticky problem! Personally I think we went down the wrong path here with the language, I believe non-nullable reference types should have been full types. As it is, in C#9, with T unconstrained (or even constrained to notnull), the syntax "T?" means "the defaultable type corresponding to T" instead of "the nullable type corresponding to T" as it does everywhere else in the language. However if you constrain T to class or struct then "T?" starts meaning "the nullable type corresponding to T" again. – sjb-sjb Mar 03 '21 at 13:36
4

Jeff Mercado raised a good point in the comments:

I think you have some conflicting goals here. You want to have the notion of a default box but for reference types, what else is an appropriate default? The default is null for reference types which directly conflicts with using nullable reference types. Perhaps you will need to constrain T to types that could be default constructed instead (new()).

For example, default(T) for T = string would be null, since at runtime there is no distinction between string and string?. This is a current limitation of the language feature.

I have worked around this limation by creating separate CreateDefault methods for each case:

#nullable enable

class Box<T> {
    public T Value { get; }

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

static class CreateDefaultBox
{
    public static Box<T> ValueTypeNotNull<T>() where T : struct
        => new Box<T>(default);

    public static Box<T?> ValueTypeNullable<T>() where T : struct
        => new Box<T?>(null);

    public static Box<T> ReferenceTypeNotNull<T>() where T : class, new()
        => new Box<T>(new T());

    public static Box<T?> ReferenceTypeNullable<T>() where T : class
        => new Box<T?>(null);
}

This seems type safe to me, at the cost of more ugly call sites (CreateDefaultBox.ReferenceTypeNullable<object>() instead of Box<object?>.CreateDefault()). In the example class I posted I'd just remove the methods completely and use the Box constructor directly. Oh well.

Andent
  • 601
  • 5
  • 5