csharplang/meetings/2019/LDM-2019-09-11.md

10 KiB
Raw Permalink Blame History

C# Language Design Meeting for Sep. 11, 2019

Agenda

Discussion

Nullable attributes and flow state interaction

We started this discussion with an email with a proposal based on follow-up research from the previous meeting.

Attribute interaction proposal

Allowed inputs and outputs:

First of all, Ill try to make rules based only on “allowed inputs” and “allowed outputs” of a property. Ill use shorthands ?, ! and T for “nullable”, “nonnullable” and “unknown depends on T” respectively.

For all the different sensible combinations of attributes, here are the “allowed inputs” and “allowed outputs”:

Allowed input Allowed output
string ! !
[AllowNull]string ? !
[NotNull]string? ? !
[MaybeNull]string ! ?
[DisallowNull]string? ! ?
string? ? ?
[DisallowNull][NotNull]T ! !
[NotNull]T T !
[AllowNull][NotNull]T ? !
[DisallowNull]T ! T
T T T
[AllowNull]T ? T
[DisallowNull][MaybeNull]T ! ?
[MaybeNull]T T ?
[AllowNull][MaybeNull]T ? ?

There should be no surprises to anyone there. Now lets use these to define the different behaviors around properties: Ordering of states: T is stricter than ? and ! is stricter than both T and ?. This is a measure of relative permissiveness of states.

Initial state: The initial state of a property is its allowed output. This corresponds to us knowing nothing about the property yet, beyond what it tells us through a combination of its type and its postconditions.

State after null check:

  • On the non-null branch of a null check the state of the property is !.
  • On the null branch of a pure null check the state of the property is ?.
  • Elsewhere the state of the property is unchanged.

These rules reflect the general benefit of a null check, as well as the overriding effect of a pure null check even of a nonnull property.

Note that this rules out "dangerous" properties, meaning properties that may change outside the scope of the nullable analysis, as in fields which may be changed by a different thread, and thus the null check is unreliable. We don't consider this scenario to be in scope of our current design and if we decide to address this, we must create a new attribute or some other mechanism.

There's also some problem with the state after null checks, namely that the type may not support the ? state. For instance, in the following unconstrained generic,

T M<T>(T t)
{
    if (t != null)
        t.ToString();
    return t;
}

we should not produce a warning on the return, since the state should match the legal state of T, which is T, not ?. Similarly, for non-Nullable value types, the state cannot be ? after a null check, since the value cannot be null. The rule should use the T state.

State after assignment:

The state of the property after an assignment is

  • Its initial state if the state of the assigned value is at least as strict as the allowed input, but no stricter than the allowed output
  • The state of the assigned value otherwise

This rule reflects that a property is expected to “take care of things” when the state of an assigned value is valid as input but not as output. It does so by assuming that the resulting state in such situations is something thats valid as output.

This is probably the only rule that would differ from the rules for fields, which would continue to always use the state of the assigned value.

Warnings on assignment: A warning is yielded if the state of the assigned value is less strict than the allowed input. This is the same rule as all other input positions.

We like this new "state after assignment" rule and think it can be implemented now.

However, when looking into the solution, we found that this is the current behavior for properties when annotated:

using System;
using System.Diagnostics.CodeAnalysis;

#nullable enable

class C<T> where T : class?
{
    public C(T x) => f = x;
    T f;
                T P1 { get => f;        set => f = value; }
    [AllowNull] T P2 { get => f;        set => f = value ?? throw new ArgumentNullException(); }
    [MaybeNull] T P3 { get => default!; set => f = value; }

    void M()
    {
        P1 = null; // Warning
        P2 = null; // No warning
        P3 = null; // Warning

        f = P1;    // No warning
        f = P2;    // No warning
        f = P3;    // BUG?: No warning!
    }
}

That last line does not look right. Similar to default(T), you could be producing a potentially nullable value, when the substituted type may not permit it. We think the three state domain outlined above will solve the problem, but that would be too extensive to change to perform in such a short period of time.

Alternative: whenever you introduce a value (by calling a property or a method), that produces a generic type annotated with [MaybeNull] in a substituted generic method, that would produce a warning. This matches our current behavior for default(T).

For example,

T M<T>()
{
    _ = (new List<T>).FirstOrDefault(); // this would now produce a warning
}

Conclusion

Let's implement the "state after assignment" rule as defined and implement the "Alternative" proposal outlined above. We will consider updating to use the "three state domain" above later, which may have some further changes. A sample of the expected behavior follows:

Non-Generic


using System;
using System.Diagnostics.CodeAnalysis;

class Widget {
    string _description = string.Empty;

    [AllowNull]
    string Description {
        get => _description;
        set => _description = value ?? string.Empty;
    }

    static void Test(Widget w) {
        w.Description = null; // ok
        Console.WriteLine(w.Description.ToUpper()); // ok

        if (w.Description == null) {
            Console.WriteLine(w.Description.ToUpper()); // warning
        }
    }
}

Generic

using System;
using System.Diagnostics.CodeAnalysis;

class Box<T> {
    T _value;

    [AllowNull]
    T Value {
        get => _value;
        set {
            if (value != null) {
                _value = value;
            }
        }
    }
    
    static void TestConstrained<U>(Box<U> box) where U : class {
        box.Value = null; // ok
        Console.WriteLine(box.Value.ToString()); // ok

        if (box.Value == null) {
            Console.WriteLine(box.Value.ToString()); // warning
        }
    }

    static void TestUnconstrained<U>(Box<U> box, U value) {
        box.Value = default(U); // 'default(U)' always produces a warning when U could be a non-nullable reference type
        Console.WriteLine(box.Value.ToString()); // ok

        box.Value = value; // ok
        Console.WriteLine(box.Value.ToString()); // ok

        if (box.Value == null) {
            Console.WriteLine(box.Value.ToString()); // warning
        }
    }
}

More triage

Top-level statements and member declarations

We have a variety of different use cases and experimental products (C# Interactive Window, Jupyter projects, try.net, etc) that use the current "C# scripting" language, which is already effectively a dialect of C#. There's a fair amount of concern that if adoption continues, we may produce a fracturing of the C# language.

However, adding top-level statements and reconciling scripting in C# proper would be an expensive feature, in both design and implementation. It also doesn't directly impact many of the designs we're currently considering.

But there is also significant cost to doing nothing. We have not considered the semantic for many features in C# 8, or even if they should work in scripting (using declarations, notably). There is a significant ongoing cost here, either in considering all our designs for the scripting dialect, or in risk that not doing design/implementation work will cause bad experiences for products using the C# scripting code.

Conclusion

We'll schedule this for 9.0, to at least examine options.

Primary constructors

This occupies the same design space as records, which is scheduled for 9.0, so we at least need to consider this feature while implementing records.

Conclusion

Moving to 9.0.

Negated-condition if statement

Issue #882

This overlaps significantly with a "is not" pattern. We're not confident this feature has significant value, after the "is not" pattern is implemented.

Conclusion

Move to X.X to consider after "is not" has shipped and see if there are significant use cases that are not addressed by the "is not" pattern.

Allow default in deconstruction

Issue #1394

The primary use case is (x, y, z) = default; instead of naming each variable individually. There are some issues around the written specification, specifically on what target typing default has.

Conclusion

From a consistency perspective it seems like this should work, regardless of the complexity in details of the specification. We'll take this Any Time whenever we have a solid specification and implementation.

Partial type inference

Issue #1349

Nothing that's related to type inference is a tiny feature, but this is pretty small as type inference changes are concerned. We think the hardest problem will be agreeing on the syntax. Agreed that it could be useful, though.

Conclusion

We'll take this Any Time.

Declaration expressions

Issue #973

Somewhat related is "sequence expressions". This is useful for declaring variables inline in an expression. There are places where statements are not possible, and this requires refactoring.

Conclusion

We don't think there's value in half measures here. We think going all the way to sequence expressions may have value, but then declaration expressions do not.