Repository navigation
Expose top-level nullability information from reflection #29723
Description
With C# 8, developers will be able to express whether a given reference type can be null:
public void M(string? nullable, string notNull, IEnumerable<string?> nonNullCollectionOfPotentiallyNullEntries);(Please note that existing code that wasn't compiled using C# 8 and nullable turned on is considered to be unknown.)
This information isn't only useful for the compiler but also attractive for reflection-based tools to provide a better experience. For example:
-
MVC
- Provides a way to automatically deserialize inputs to controller methods ("model binding")
- Would like to provide model validation so that the existing pattern would allow code to bail early
- Without it, customers would have to apply a custom attribute, such as
[Required], or resort to additional null-checks - Only needs top-level annotations, i.e.
string?but not nested, such asIEnumerable<string?>
-
EF
- Provides a way to generate database schemas from user classes ("code first")
- Would like use nullable information to infer whether columns should be null or non-null (they already do that for nullable value types).
- Without it, customers would have to apply a custom attribute to repeat that information.
- Also only needs top-level annotations
The nullable information is persisted in metadata using custom attributes. In principle, any interested party can already read the custom attributes without additional work from the BCL. However, this is not ideal because the encoding is somewhat non-trivial:
- Custom attribute might be generated. The custom attribute might have been generated (meaning is embedded in the user's assembly) or might use the to-be-provided attribute.
- Encoded as a byte array. The tri-state is encoded as a linearized version of the constructed generic type.
- Compressed. Right now, each member will have the attribute when nullability is turned on but this causes metadata bloat. We're working on a proposal that allows the containing member, type, or assembly to state a default to reduce the number of attribute applications.
It's tempting to think of nullable information as additional information on System.Type. However, we can't just expose an additional property on Type because at runtime there is no difference between string (unknown), string? (nullable), and string (non-null). So we'd have to expose some sort of API that allows consumers to walk the type structure and getting information.
Unifying nullable-value types and nullable-reference types
It was suggested that these APIs also return NullableState.MaybeNull for nullable value types, which seems desirable indeed. Boxing a nullable value type causes the non-nullable representation to be boxed. Which also means you can always cast a boxed non-nullable value type to its nullable representation. Since the reflection API surface is exclusively around object it seems logical to unify these two models. For customers that want to differentiate the two, they can trivially check the top-level type to see whether it's a reference type or not.
API proposal
namespace System.Reflection
{
public sealed class NullabilityInfoContext
{
public NullabilityInfo Create(ParameterInfo parameterInfo);
public NullabilityInfo Create(PropertyInfo propertyInfo);
public NullabilityInfo Create(EventInfo eventInfo);
public NullabilityInfo Create(FieldInfo parameterInfo);
}
public sealed class NullabilityInfo
{
public Type Type { get; }
public NullableState ReadState { get; }
public NullableState WriteState { get; }
public NullabilityInfo? ElementType { get; }
public ReadOnlyCollection<NullabilityInfo>? GenericTypeArguments { get; }
}
public enum NullableState
{
Unknown,
NotNull,
MaybeNull
}
}Sample usage
Getting top-level nullability information
private NullabilityInfoContext _nullabilityContext = new NullabilityInfoContext();
private void DeserializePropertyValue(PropertyInfo p, object instance, object? value)
{
if (value == null)
{
var nullabilityInfo = _nullabilityContext.Create(p);
var allowsNull = nullabilityInfo.WriteState != NullableState.NotNull;
if (!allowsNull)
throw new MySerializerException($"Property '{p.GetType().Name}.{p.Name}'' cannot be set to null.");
}
p.SetValue(instance, value);
}Getting nested nullability information
class Data
{
public string?[] ArrayField;
public (string?, object) TupleField;
}
private void Print()
{
Type type = typeof(Data);
FieldInfo arrayField = type.GetField("ArrayField");
FieldInfo tupleField = type.GetField("TupleField");
NullabilityInfoContext context = new ();
NullabilityInfo arrayInfo = context.Create(arrayField);
Console.WriteLine(arrayInfo.ReadState); // NotNull
Console.WriteLine(arrayInfo.Element.ReadState); // MayBeNull
NullabilityInfo tupleInfo = context.Create(tupleField);
Console.WriteLine(tupleInfo.ReadState); // NotNull
Console.WriteLine(tupleInfo.GenericTypeArgument[0].ReadState); // MayBeNull
Console.WriteLine(tupleInfo.GenericTypeArgument[1].ReadState); // NotNull
}Custom Attributes
The following custom attributes in System.Diagnostics.CodeAnalysis are processed and combined with type information:
[AllowNull][DisallowNull][MaybeNull][NotNull]
The following attributes aren't processed because they don't annotate static state but information related to dataflow:
[DoesNotReturn][DoesNotReturnIf][MaybeNullWhen][MemberNotNull][MemberNotNullWhen][NotNullIfNotNull][NotNullWhen]
@dotnet/nullablefc @dotnet/ldm @dotnet/fxdc @rynowak @divega @ajcvickers @roji @steveharter
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsDone
Activity
rynowak commentedon May 31, 2019
What's the behaviour with a value type? Does this include reflecting the effect of
Nullable<>as well?mattwar commentedon May 31, 2019
Are these really static methods? Shouldn't they have a parameter then?
safern commentedon May 31, 2019
For, PropertyInfo, we can have a NullableState on the setter and on the getter depending if we have an attribute. I.e
That will have a nullable input but a non-null output.
Also what about generic constraints or interface implementations? Those can also have nullability information and to when getting the generic constraints or interface implementations we get an array of
Type.terrajobst commentedon May 31, 2019
@rynowak
They would return
Unknownright now. We could add an enum memberNotApplicablethat we return for non-reference types or throwInvalidOperationException. We could also handle those, but this feels wrong because nullable value types are actually different types.@mattwar
Sigh. No, I wrote up a sample project to test this and I used extension methods and then I didn't fix it when copy and pasting. Fixed.
@safern
We need to think about the higher-level attributes. I only thought about NullableAttribute so far.
This wouldn't change the API shape. People would have to go to
PropertyInfo.GetGetMethod()and ask the correspondingMethodInfodirectly.See intro. I've deliberately scoped them out.
chucker commentedon May 31, 2019
They are, but on a source level, C# abstracts the difference away. Maybe introduce something like ValueNull and ValueNotNull as additional enum values. That way, the distinction is still prominent, but it’s also not a runtime surprise (a.k.a. exception) that these won’t be handled.
MichalStrehovsky commentedon May 31, 2019
Why not just make these extension methods and stash them away somewhere in the
System.Diagnostics.CodeAnalysisnamespace? Or better yet, in a NuGet package shipped out of the Roslyn repo - since their meaning is defined by the C# compiler?These annotations don't have a meaning to the runtime and reflection itself doesn't respect them in
MethodBase.InvokeorFieldInfo.SetValue- it feels odd to burn knowledge of custom attributes that are only meaningful to the C# compiler into the runtime reflection stack. These are not that different from e.g. the custom attributes forSystem.ComponentModel- they're just guidelines.E.g. we added a
IsByRefLikeproperty toSystem.Type, forref structbecause byref-like types have a meaning to the runtime. We didn't add any API forunmanaged struct, because that constraint only means something to the C# compiler.roji commentedon May 31, 2019
See dotnet/csharplang#2384 for this at the language level. @terrajobst users could call
PropertyInfo.GetGetMethod()and check there, but what would be the result for a call toGetNullableState()directly on aPropertyInfowhich has different nullability settings for getter and setter?I think the idea here is that these attributes are meaningful to various reflection-based consumers at runtime (EF, ASP.NET, 3rd-party products). If it were only for the compiler an API would probably not be needed at all.
They are different in that they are emitted by the compiler and interpreted by it. They are also potentially much more complex to interpret (a context-based scheme may be introduced to combat the bloat they introduce), hence the desire to encapsulate that interpretation logic.
tannergooding commentedon May 31, 2019
These attributes are not only going to be respected by the C# compiler, they are also going to be understood and consumed by other nullable aware languages (such as F#).
I would also say that, regardless of whether or not the runtime treats these attributes specially, the overarching ecosystem ultimately will, which I believe is the important point here. These attributes (and others like the
unmanagedconstraint) convey additional metadata that is generally (but not always) enforced by some user code in the method and it is useful to have an easy/logical way of querying it so that you can perform the appropriate checks when/if needed.We are already burning this information into all of our assemblies, because of its perceived benefit and exposing that metadata in a well-defined way in the reflection type system will only help fulfill that story and make it easier for users who want to utilize them.
MichalStrehovsky commentedon May 31, 2019
To be clear, my question is not whether we need an API that provides this functionality - but whether such API should be on
ParameterInfoand friends. Since this is adding non-virtual methods (not properties), my expectation is that this API is not going to do any caching - so it doesn't really need to be an instance method.We could easily stash this API away in e.g. System.Reflection.TypeExtensions and even make it available on lower versions of NetStandard (doesn't have to be exclusive to .NET Core 3.0).
tannergooding commentedon May 31, 2019
Nullable reference types is a C# 8 feature which is only officially supported on .NET Standard 2.1 and higher.
I think discussion on where in the reflection stack it goes is reasonable, although I think most people would expect and look for it to be an instance (or extension) method
MichalStrehovsky commentedon May 31, 2019
Not that I want to make this my main argument (my main argument is to not pollute the instance member surface area with convenience APIs), but runtime reflection is not the only flavor of reflection. A .NET Standard 2.1 assembly can be loaded for inspection in all sorts of ways on downlevel runtimes (reflection only load is one way, and things like our own System.Reflection.MetadataLoadContext is another). Some people might find this API in the form of an extension method that runs downlevel still valuable.
terrajobst commentedon May 31, 2019
@chucker
Syntactically, you could make the argment that they look similar. But C# doesn't abstract them away.
int?andintare different types and this has real observable language behavior. For example, this is valid:While this is not:
In other words, you can't overload based on nullability of reference types (because they are fundamentally the same type at runtime). Also, the types have different members. So no, I don't think we should make the argument that "C# abstracts the differences away".
That being said, that doesn't mean we cannot expose the nullability information for nullable types. Boxing a nullable value type causes the non-nullable representation to be boxed. Which also means you can always cast a boxed non-nullable value type to its nullable representation. Given that the reflection API surface is exclusively around
object, this might actually work just fine.So I think I'm coming around agreeing with you 😄
terrajobst commentedon May 31, 2019
@MichalStrehovsky
But they aren't specific to C#. In order for the C in CLR to be meaningful, other languages, so they support nullability, should use the same underlying encoding. Otherwise exchanging types no longer works. Hence, the reflection API can be considered general purpose.
Reflection is about inspecting the program at runtime. While I understand that there good reasons to separate concerns and C# semantics and runtime semantics are different, it totally makes sense to expose static information as people have an expectation that they can access that at runtime. And they already can, but it's error prone. As my examples above show, I'd argue these aren't contrived scenarios but fairly compelling and very much in line with expectation for the .NET development experience.
I don't like shipping core functionality like this on NuGet as this is yet another thing that can go out of sync between the various pieces. Also, given our strategy to stronger align C# with the release cycle of the .NET Core platform, this is also against what we want to achieve as a product.
That is a good point -- I should probably mark these APIs as virtual so that other implementations (such as the new metadata based reflection layer) can provide specialized implementations.
I'd rather not. We don't want to optimize for out-of-band or downlevel. We want to foremost optimize for what's right for the .NET platform moving forward. I'd argue making them part of the reflection model to encapsulate them in a central way is the right thing to do.
Should we also see a need for this functionality downlevel, we could also ship a bridge pack that defines them as extension methods. But let's not default to frankendesigns just because.
I'd argue that the reverse is true today: the reflection APIs are polluted with a ton of plumbing APIs when the vast majority of the consumers want simple methods to get stuff done. And moving forward I think reasoning about declared nullability will be important to many consumers of reflection.
terrajobst commentedon May 31, 2019
@roji
Right now, this issue is still open so there is no spec. In general, at the metadata level the property itself has a signature (and thus a return type). Similar, the C# compiler emits the nullable attribute for the property itself, as well as for all accessors:
So in other words I don't think exposing this on the property is a bad idea; depending on context it might just not be sufficient for the consumer.
chucker commentedon May 31, 2019
That’s an interesting edge case, yes.
But I stand by my assertion — the syntax of
string?wasn’t just picked because other languages do that, but also because, by and large, it expresses a sufficiently similar intent toint?. And if someone were to design .NET today, those subtle behavioral differences would have been avoided. They exist by and large for legacy reasons.(IANA C# language designer, though.)
Yup. I think higher-level additions to Reflection are welcome.
118 remaining items
bartonjs commentedon Jun 10, 2021
Video
[MaybeNullWhen]should count as a "maybe null" return answer.NullabilityInfo.GenericTypeArgumentsto a non-nullable array to match standard reflection API practices.NullabilityInfoobjects are expected to be freshly returned each time there's no parallel-caller mutation concern.MaybeNullmember toNullableNullabilityState.danmoseley commentedon Jun 10, 2021
Nit, it still says NullableState above.
RikkiGibson commentedon Jun 10, 2021
I'm listening to the recording and I feel compelled to mention that if you want to handle this in combination with
Nullable<T>, then the scenario[NotNull] int? Prop { get; }should give "not null" for its ReadState and[DisallowNull] int? Prop { get; set; }should give "not null" for its WriteState.terrajobst commentedon Jun 10, 2021
Fixed, thanks!
davidfowl commentedon Jul 6, 2021
Is this happening in .NET 6? We'd like to take a dependency on it.
jeffhandley commentedon Jul 6, 2021
Yes, #54985 is intended to be included in Preview 7.