Skip to content

Conversation

@jnm2
Copy link
Collaborator

@jnm2 jnm2 commented Oct 14, 2019

Closes #579.

I tried to keep the big initial annotation commit as straightforward as possible, saving more complex fixes for later commits. For files where the changes aren't absolutely obvious, please consider reviewing single commits at a time.

➡ Please comment if you'd like any of the types in these APIs to be annotated differently. I simply annotated them as nullable if null was tolerated in previous releases and left them non-nullable if null would have caused an exception. All nullable annotations (? and attributes) are a description of my understanding of the past rather than a statement from me about the future.

@jnm2 jnm2 force-pushed the nullable_reference_types branch 4 times, most recently from facfc3e to 787f73c Compare October 14, 2019 02:16
<PackageReference Include="PublicApiGenerator" Version="8.0.1" />
<PackageReference Include="PublicApiGenerator" Version="10.0.0-beta1" />
<PackageReference Include="TestStack.ConventionTests" Version="3.0.1" />
<DotNetCliToolReference Include="dotnet-xunit" Version="2.3.1" />
Copy link
Collaborator Author

@jnm2 jnm2 Oct 15, 2019

Choose a reason for hiding this comment

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

dotnet-xunit was discontinued: https://github.com/xunit/xunit/issues/1835#issuecomment-431199286

Version 2.3.1 has no rollforward policy and can only run on .NET Core 2.0 which is EOL.

<PropertyGroup>
<TargetFrameworks>netcoreapp2.1</TargetFrameworks>
<TargetFrameworks Condition=" '$(OS)' == 'Windows_NT' ">$(TargetFrameworks);net472;</TargetFrameworks>
<LangVersion>latest</LangVersion>
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Moved to Directory.Build.props so that it could be next to <Nullable>enable</Nullable>

Comment on lines -36 to +40
if (object.Equals(x, default(T)))
return object.Equals(y, default(T));
if (object.Equals(x, null))
return object.Equals(y, null);

if (object.Equals(y, default(T)))
if (object.Equals(y, null))
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

default(T) is always null given the if condition. This is nicer than doing default(T)!.

return true;

// Null?
if (!type.IsValueType() || (type.IsGenericType() && type.GetGenericTypeDefinition().IsAssignableFrom(NullableType)))
Copy link
Collaborator Author

@jnm2 jnm2 Oct 16, 2019

Choose a reason for hiding this comment

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

public new bool Equals(object? x, object? y)
{
return _innerComparer.Equals((T) x, (T) y);
return _innerComparer.Equals((T)x!, (T)y!);
Copy link
Collaborator Author

@jnm2 jnm2 Oct 16, 2019

Choose a reason for hiding this comment

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

The cast will always succeed when T is a nullable type at runtime (either a reference type or Nullable<>). The ! operator suppresses the warning that an exception will be thrown if x or y is null and T is a non-nullable value type. Shouldly expects this, just as an exception will be thrown if an object of the wrong type is passed.

@jnm2 jnm2 force-pushed the nullable_reference_types branch from 8009c7a to 1774735 Compare October 16, 2019 23:26
internal static class Is
{
public static bool InRange<T>(T comparable, T @from, T to) where T : IComparable<T>
public static bool InRange<T>([DisallowNull] T comparable, [AllowNull] T @from, [AllowNull] T to) where T : IComparable<T>
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

[AllowNull] and [DisallowNull] are the only way to annotate these parameters since C# still does not allow T? here. (Allowing T? without a class or struct constraint would be a CLR change.)

}

public static void ShouldBeEmpty<T>(this IEnumerable<T> actual)
public static void ShouldBeEmpty<T>([NotNull] this IEnumerable<T>? actual)
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

[NotNull] initially seems to conflict with the ?, but there is no conflict. [NotNull] is indicating that if this call returns, the expression that had been passed to actual cannot have been null. At the same time, the method accepts null inputs without a compile-time warning.

You could also choose to go with ShouldBeEmpty<T>(this IEnumerable<T> actual) in which case the compiler would warn if the user does foo.ShouldBeEmpty(); without first proving to the compiler that foo can't be null (or using !).

/// <param name="obj">The object to check</param>
/// <returns>true if the object is a numeric type</returns>
public static bool IsNumericType(Object obj)
public static bool IsNumericType([NotNullWhen(true)] object? obj)
Copy link
Collaborator Author

@jnm2 jnm2 Oct 16, 2019

Choose a reason for hiding this comment

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

NotNullWhen is used to tell the compiler's flow state analysis what happens after a call to this method:

nullableThing = ...
if (IsNumericType(nullableThing))
{
    nullableThing.GetHashCode(); // No warning because IsNumericType returned true
}

nullableThing.GetHashCode(); // Compiler warning because `nullableThing` could be null at this point

System.Diagnostics.StackTrace stackTrace = null,
[CallerMemberName] string shouldlyMethod = null) : base(shouldlyMethod, expected, actual, stackTrace)
System.Diagnostics.StackTrace? stackTrace = null,
[CallerMemberName] string shouldlyMethod = null!) : base(shouldlyMethod, expected, actual, stackTrace)
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I don't think there's a situation where the default value null could be used.
https://docs.microsoft.com/en-us/dotnet/api/system.runtime.compilerservices.callermembernameattribute#remarks

var codeLines = string.Join("\n", File.ReadAllLines(FileName).Skip(LineNumber).ToArray());

var indexOf = codeLines.IndexOf(_shouldMethod);
var indexOf = codeLines.IndexOf(_shouldMethod!);
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

The way to avoid all these dammit operators (!) would be to pass the value between methods rather than making it a class field. The state of a mutable class field is also harder to reason about between methods for people as well as compilers, so I would recommend this.

static string DelimitWith<T>(this IEnumerable<T> enumerable, string? separator) where T : class?
{
return string.Join(separator, enumerable.Select(i => Equals(i, default(T)) ? null : i.ToString()).ToArray());
return string.Join(separator, enumerable.Select(i => Equals(i, null) ? null : i.ToString()).ToArray());
Copy link
Collaborator Author

@jnm2 jnm2 Oct 16, 2019

Choose a reason for hiding this comment

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

default(T) was always null already because of the class constraint. Using null is even nicer now that the other option is default(T)!.

Comment on lines +18 to +19
Debug.Assert(context.Actual is IDictionary);
Debug.Assert(context.Key is object);
Copy link
Collaborator Author

@jnm2 jnm2 Oct 16, 2019

Choose a reason for hiding this comment

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

⚠ Not sure how you'd like to handle classes like these. This method is relying on the fragile assumption that remote code will have set these fields, and these asserts silence the warnings this causes now that NRTs are enabled. The way I would handle it would be to require these values to be passed to the DictionaryShouldContainKeyAndValueMessageGenerator constructor.


[ContractAnnotation("actual:null,expected:null => halt")]
public static void ShouldNotBe<T>(this T actual, T expected)
public static void ShouldNotBe<T>([AllowNull] this T actual, [AllowNull] T expected)
Copy link
Collaborator Author

@jnm2 jnm2 Oct 16, 2019

Choose a reason for hiding this comment

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

[AllowNull] is needed on each so that generic inference doesn't get upset while deciding whether T is nullable or not when one expression is nullable and the other isn't.

{
public static partial class ShouldBeStringTestExtensions
{
[ContractAnnotation("actual:notnull => halt")]
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Side note: I think these JetBrains annotations might actually be incorrect. If actual is "", a non-null value, the method does not halt.

public static class ShouldBeBooleanExtensions
{
public static void ShouldBeTrue(this bool actual)
public static void ShouldBeTrue([DoesNotReturnIf(false)] this bool actual)
Copy link
Collaborator Author

@jnm2 jnm2 Oct 17, 2019

Choose a reason for hiding this comment

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

DoesNotReturnIf is the annotation that powers scenarios like this:

nullableThing.GetHashCode(); // Compiler warning, possible NRE
(nullableThing != null).ShouldBeTrue();
nullableThing.GetHashCode(); // No warning because ShouldBeTrue did not throw an exception

public static class ShouldBeDictionaryTestExtensions
{
public static void ShouldContainKey<TKey, TValue>(this IDictionary<TKey, TValue> dictionary, TKey key)
where TKey : notnull
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This is necessary in order to use TKey for IDictionary<TKey, TValue> due to the generic constraint added to IDictionary<,> in the BCL.

{
[ContractAnnotation("actual:notnull => halt")]
public static void ShouldBeNull<T>(this T actual)
public static void ShouldBeNull<T>([MaybeNull] this T actual)
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

If this method returns, the actual expression is definitely null. [MaybeNull] is the strongest the .NET annotations get. Threw it in for completeness.

var nonnullableThing = new object();
nonnullableThing.GetHashCode(); // No warning
nonnullableThing.ShouldBeNull();
nonnullableThing.GetHashCode(); // Warning that nonnullableThing may be null

}
}

internal class ExpectedEquvalenceShouldlyMessage : ShouldlyMessage
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This is a typo.

@jnm2 jnm2 force-pushed the nullable_reference_types branch 5 times, most recently from e0cd649 to bfff1ba Compare October 20, 2019 20:53
@jnm2 jnm2 force-pushed the nullable_reference_types branch from 4573ed5 to e74d529 Compare December 13, 2019 14:36
@jnm2
Copy link
Collaborator Author

jnm2 commented Dec 13, 2019

@josephwoodward New force push. Two of the commits have already been merged in #596 and I fixed merge conflicts caused by #595.

@josephwoodward
Copy link
Contributor

@jnm2 Nice! So is this good to go? (pending review of course)

@jnm2
Copy link
Collaborator Author

jnm2 commented Dec 13, 2019

@josephwoodward Yes!

@jnm2
Copy link
Collaborator Author

jnm2 commented Mar 2, 2020

@josephwoodward I merged with master to fix merge commits, then I recognized the build failure as being related to certain versions of Mono and Cake pre-0.37 and fixed that, then I fixed some new warnings that will crop up in VS 16.5.

I'm not sure reviewing and merging will get more appealing as time goes on, and I don't know what to do to make that easier.

@jnm2
Copy link
Collaborator Author

jnm2 commented Jul 5, 2020

@SimonCropp I was going to push a merge commit to resolve the conflicts. Would it make more sense to wait till the dust settles from the PRs you're merging, or would there be another approach altogether that would work better for you?

@SimonCropp
Copy link
Contributor

SimonCropp commented Jul 5, 2020

i can hold off for a few hours. or i can handle the conflicts and merge for u

@jnm2
Copy link
Collaborator Author

jnm2 commented Jul 5, 2020

@SimonCropp You might have better context than me for fixing the conflicts since you are familiar with what's happened on master in the meantime. That sounds best, but I'm also happy to do it. Once this PR merges, there is a good chance that most outstanding PRs will have merge conflicts too. The tradeoff is fixing things individually versus adding the equivalent work in bulk to this PR. Is one better than the other?

@SimonCropp
Copy link
Contributor

@jnm2 i manually merged here #634. there still are a few nullability warnings. i was wondering if you could have a look at the warnings on master, given you clearly know more about nullability than me

@SimonCropp SimonCropp closed this Jul 6, 2020
@jnm2
Copy link
Collaborator Author

jnm2 commented Jul 6, 2020

@SimonCropp For sure!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Provide annotations for C# 8 Nullable Reference Types

5 participants