Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix up some nullability annotations to remove unnecessary null-forgiving operations (!) #32186

Merged
merged 7 commits into from
Feb 14, 2020

Conversation

ahsonkhan
Copy link
Member

Leftover from #2259 (comment)

@ahsonkhan ahsonkhan added this to the 5.0 milestone Feb 12, 2020
@ahsonkhan ahsonkhan self-assigned this Feb 12, 2020
@@ -36,7 +37,7 @@ protected static JsonConverter<TElement> GetElementConverter(ref WriteStack stat
Type typeToConvert,
JsonSerializerOptions options,
ref ReadStack state,
out TCollection value)
[MaybeNullWhen(false)] out TCollection value)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With this, once #32090 is merged, we can remove the ! in classes like:
JsonIEnumerableDefaultConverter.cs
JsonDictionaryDefaultConverter.cs
etc.

Everywhere we do:
value = default!;

@ahsonkhan
Copy link
Member Author

@steveharter - can you please take another quick look.

@ahsonkhan ahsonkhan requested a review from safern February 12, 2020 23:08
@@ -751,7 +751,7 @@ public abstract partial class JsonConverter<T> : System.Text.Json.Serialization.
protected internal JsonConverter() { }
public override bool CanConvert(System.Type typeToConvert) { throw null; }
public abstract T Read(ref System.Text.Json.Utf8JsonReader reader, System.Type typeToConvert, System.Text.Json.JsonSerializerOptions options);
public abstract void Write(System.Text.Json.Utf8JsonWriter writer, T value, System.Text.Json.JsonSerializerOptions options);
public abstract void Write(System.Text.Json.Utf8JsonWriter writer, [System.Diagnostics.CodeAnalysis.NotNull] T value, System.Text.Json.JsonSerializerOptions options);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NotNull doesn't make sense here. Did you mean DisallowNull? And just so I understand, it's invalid to write nulls but read can return nulls?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And just so I understand, it's invalid to write nulls but read can return nulls?

Yes. Write won't receive null but Read can return null.

The intent is the JsonSerializer will never pass in null T values to the JsonConverter<T>.Write method and hence the implementer of that method doesn't need to do a null check on it.

They can certainly continue to write null JSON literals in the method itself or do whatever else. If an nullable object has the null value, the serializer writes null for you (and doesn't call the JsonConverter).

On the read side, the implementer should honor the nullability of the T. If T is nullable, the implementer of JsonConverter<T>.Read can certainly return null if they wish. If T is non-nullable (like string, or value type), it doesn't make sense for them to do so.

For types where null doesn't make sense (i.e. non-nullable valuetypes), the JsonSerializer can and will pass in a Utf8JsonReader with the null TokenType if the payload we are processing has the null JSON literal.
For types where null is allowed (nullable/non-nullable reference types), the JsonSerializer will not pass in a Utf8JsonReader in that state and will eagerly set the object to null up front, without calling the JsonConverter.

#nullable enable

using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using Xunit;

namespace System.Text.Json.Serialization.Tests
{
    public static partial class PropertyNameTests
    {
        [Fact]
        public static void QuickTest()
        {
            var opts = new JsonSerializerOptions
            {
                Converters =
                {
                    new MyStringConverter(),
                    new MyNullableStringConverter(),
                    new MyNullableIntConverter(),
                    new MyIntConverter()
                }
            };

            string json = "{\"foo\": null, \"bar\": null, \"baz\": null,\"nullBaz\": null}";

            MyClass output = JsonSerializer.Deserialize<MyClass>(json, opts)!;
            Assert.Null(output.foo);
            Assert.Null(output.bar);
            Assert.Equal(0, output.baz);
            Assert.Null(output.nullBaz);
        }

        public class MyClass
        {
#pragma warning disable CS8618 // Non-nullable field is uninitialized. Consider declaring as nullable.
            public string foo { get; set; }
#pragma warning restore CS8618 // Non-nullable field is uninitialized. Consider declaring as nullable.

            public string? bar { get; set; }

            public int baz { get; set; }

            public int? nullBaz { get; set; }
        }

        public class MyStringConverter : JsonConverter<string>
        {
            public override string Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
            {
                Debug.Assert(reader.TokenType != JsonTokenType.Null);

                return reader.GetString()!;
            }

            public override void Write(Utf8JsonWriter writer, [DisallowNull] string value, JsonSerializerOptions options)
            {
                throw new NotImplementedException();
            }
        }

        public class MyNullableStringConverter : JsonConverter<string?>
        {
            public override string? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
            {
                Debug.Assert(reader.TokenType != JsonTokenType.Null);

                return reader.GetString();
            }

            public override void Write(Utf8JsonWriter writer, [DisallowNull] string? value, JsonSerializerOptions options)
            {
                throw new NotImplementedException();
            }
        }

        public class MyIntConverter : JsonConverter<int>
        {
            public override int Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
            {
                if (reader.TokenType == JsonTokenType.Null)
                    return default;
                return reader.GetInt32();
            }

            public override void Write(Utf8JsonWriter writer, [DisallowNull] int value, JsonSerializerOptions options)
            {
                throw new NotImplementedException();
            }
        }

        public class MyNullableIntConverter : JsonConverter<int?>
        {
            public override int? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
            {
                if (reader.TokenType == JsonTokenType.Null)
                    return null;
                return reader.GetInt32();
            }

            public override void Write(Utf8JsonWriter writer, [DisallowNull] int? value, JsonSerializerOptions options)
            {
                throw new NotImplementedException();
            }
        }
    }
}

@@ -392,6 +393,6 @@ internal void VerifyWrite(int originalDepth, Utf8JsonWriter writer)
/// <param name="writer">The <see cref="Utf8JsonWriter"/> to write to.</param>
/// <param name="value">The value to convert.</param>
/// <param name="options">The <see cref="JsonSerializerOptions"/> being used.</param>
public abstract void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options);
public abstract void Write(Utf8JsonWriter writer, [NotNull] T value, JsonSerializerOptions options);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DisallowNull?

{
writer.WriteString(s_metadataId, referenceId!);
// TryGetOrAddReferenceOnSerialize is guaranteed to not return null.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this comment necessary? The purpose of nullable reference types is you shouldn't need a comment like this, given the signature says "out string" rather than "out string?"

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added it along with the Debug.Assert to make it explicit that referenceId shouldn't be null even if TryGetOrAddReferenceOnSerialize accidentally set referenceId to null!. Otherwise, it's a bug in the implementation detail.

I am fine removing the comment though. Should I remove the Debug.Assert as well?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should I remove the Debug.Assert as well?

The comment seemed superfluous. I'm fine with the Debug.Assert if you think it adds value.

@@ -392,6 +393,6 @@ internal void VerifyWrite(int originalDepth, Utf8JsonWriter writer)
/// <param name="writer">The <see cref="Utf8JsonWriter"/> to write to.</param>
/// <param name="value">The value to convert.</param>
/// <param name="options">The <see cref="JsonSerializerOptions"/> being used.</param>
public abstract void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options);
public abstract void Write(Utf8JsonWriter writer, [DisallowNull] T value, JsonSerializerOptions options);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure we want to always disallow null. We may have "fast path" code flows for internal converters that pass null. Also, since this may become a public API there may be cases where someone wants to pass null.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This API is already public. Having the JsonSerializer pass in null or having the user expect/handle null as the input to the Write method would be a behavioral breaking change. We can change the annotation if/when we do that.

Also, even if the JsonSerializer internally passes null for some reason, we should annotate the public surface area with the intent of the API and what the user can expect when they implement the abstract class JsonConverter<T>. What I recall from previous/offline discussions was that we never pass in null to the Write method so DisallowNull makes sense.

Btw, I added if (value == null) throw; to all the Write methods within our test converters to validate that we never pass null.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes I see this is actually on the pubic type.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that when we make "HandleNullValue" property protected (it is internal now), Write() will get nulls passed in. At that time, I believe should remove the [DisallowNull] here

@@ -11,7 +12,7 @@ namespace System.Text.Json.Serialization.Converters
/// </summary>
internal sealed class JsonObjectDefaultConverter<T> : JsonObjectConverter<T>
{
internal override bool OnTryRead(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options, ref ReadStack state, out T value)
internal override bool OnTryRead(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options, ref ReadStack state, [MaybeNullWhen(false)] out T value)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This may become protected (instead of just internal) and I don't think we want to say this shouldn't be null.

Also what's the difference between DisallowNull and [MaybeNullWhen(false)]?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DisallowNull is for inputs, MaybeNullWhen(false) is for outputs. In this case this is stating that regardless of the nullability of the generic, the OnTryRead might set it to null when it returns false.

Even if we make it protected (and expose it), the nullability is showcasing the intent of the API. We are doing T value = default! in a bunch of places.

We can revisit the annotation if the implementation/design changes as part of exposing it. This change makes sense to me as it accurately represents what the API is doing so the caller knows what to do with the out parameter.

internal static MetadataPropertyName WriteReferenceForObject(
JsonConverter jsonConverter,
object currentValue,
ref WriteStack state,
Utf8JsonWriter writer)
{
MetadataPropertyName metadataToWrite = GetResolvedReferenceHandling(jsonConverter, currentValue, ref state, out string? referenceId);
MetadataPropertyName metadataToWrite;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was the motivation here only to remove the ! in referenceId! or also other reasons (perf?)

Copy link
Member Author

@ahsonkhan ahsonkhan Feb 13, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that and perf (avoiding unnecessary/duplicate checks on the enum).

@ghost
Copy link

ghost commented Feb 14, 2020

Hello @ahsonkhan!

Because this pull request has the auto-merge label, I will be glad to assist with helping to merge this pull request once all check-in policies pass.

p.s. you can customize the way I help with merging this pull request, such as holding this pull request until a specific person approves. Simply @mention me (@msftbot) and give me an instruction to get started! Learn more here.

@ahsonkhan ahsonkhan mentioned this pull request Feb 14, 2020
@ahsonkhan
Copy link
Member Author

Failures are unrelated: #13769 (comment)

@ahsonkhan ahsonkhan merged commit d7ef909 into dotnet:master Feb 14, 2020
@ahsonkhan ahsonkhan deleted the FixNullability branch February 14, 2020 05:20
@ghost ghost locked as resolved and limited conversation to collaborators Dec 10, 2020
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants