Поделиться через


Улучшенный анализ определенного назначения

Заметка

Эта статья является спецификацией компонентов. Спецификация служит проектным документом для функции. Она включает предлагаемые изменения спецификации, а также информацию, необходимую во время проектирования и разработки функции. Эти статьи публикуются до тех пор, пока предложенные изменения спецификации не будут завершены и включены в текущую спецификацию ECMA.

Может возникнуть некоторое несоответствие между спецификацией компонентов и завершенной реализацией. Эти различия зафиксированы в соответствующем собрании по проектированию языка (LDM).

Дополнительные сведения о процессе внедрения спецификаций функций в стандарт языка C# см. в статье о спецификациях .

Проблема чемпиона: https://github.com/dotnet/csharplang/issues/4465

Сводка

Определённое задание §9.4, как указано, имеет несколько пробелов, которые вызывают неудобства у пользователей. В частности, сценарии, включающие сравнение булевых констант, условного доступа и объединения значений null.

обсуждение этого предложения на платформе csharplang: https://github.com/dotnet/csharplang/discussions/4240

Вероятно, десяток или около того пользовательских отчетов можно обнаружить с помощью этого или аналогичных запросов (т. е. найдите "определенное назначение" вместо "CS0165" или выполните поиск в csharplang). https://github.com/dotnet/roslyn/issues?q=is%3Aclosed+is%3Aissue+label%3A%22Resolution-By+Design%22+cs0165

Я включил связанные проблемы в приведенные ниже сценарии, чтобы дать представление о относительном влиянии каждого сценария.

Сценарии

В качестве точки отсчёта начнем с известного "удачного случая", который корректно работает при определенном назначении и в условиях nullable.

#nullable enable

C c = new C();
if (c != null && c.M(out object obj0))
{
    obj0.ToString(); // ok
}

public class C
{
    public bool M(out object obj)
    {
        obj = new object();
        return true;
    }
}

Сравнение с константой bool

if ((c != null && c.M(out object obj1)) == true)
{
    obj1.ToString(); // undesired error
}

if ((c != null && c.M(out object obj2)) is true)
{
    obj2.ToString(); // undesired error
}

Сравнение условного доступа и константного значения

Этот сценарий, вероятно, является самым большим. Мы поддерживаем это в отношении nullable, но не в отношении определенного присваивания.

if (c?.M(out object obj3) == true)
{
    obj3.ToString(); // undesired error
}

Условный доступ сводится к логической константе

Этот сценарий очень похож на предыдущий. Это также поддерживается в значении NULL, но не в определенном назначении.

if (c?.M(out object obj4) ?? false)
{
    obj4.ToString(); // undesired error
}

Условные выражения, в которых одна ветвь является логической константой

Следует отметить, что у нас уже есть специальное поведение для случая, когда выражение условия является константным (т. е. true ? a : b). Мы просто безусловно посещаем руку, указанную константным условием, и игнорируем другую руку.

Кроме того, обратите внимание, что мы не обрабатывали этот сценарий с nullable.

if (c != null ? c.M(out object obj4) : false)
{
    obj4.ToString(); // undesired error
}

Спецификация

?. Выражения (оператор с условным значением NULL)

Мы представляем новый раздел ?. Выражения с оператором условного значения NULL. См. спецификацию оператора null (§12.8.8) и точные правила для определения определенного присвоения §9.4.4 для контекста.

Как и в определенных правилах назначения, связанных выше, мы называем заданную изначально неназначаемую переменную как v.

Мы представляем концепцию "непосредственно содержит". Выражение E говорят, что "напрямую содержит" подвыражение E1, если оно не подлежит определяемому пользователем преобразованию §10.5, параметр которого не является ненулевым типом значения, и одно из следующих условий выполнено:

  • E это E1. Например, a?.b() напрямую содержит выражение a?.b().
  • Если E является скобочным выражением (E2), и E2 напрямую содержит E1.
  • Если E является выражением оператора, допускающим значение NULL, E2!и E2 напрямую содержит E1.
  • Если E является выражением приведения (T)E2, и приведение не предусматривает подвержения E2 пользовательскому преобразованию без поднятия, параметр которого не является ненулевым значением типа, и E2 непосредственно содержит E1.

Для выражения E формы primary_expression null_conditional_operations, пусть E0 будет выражением, полученным путем текстового удаления ведущего знака "?". от каждого null_conditional_operations элемента E, имеющего один, как указано в вышеупомянутой спецификации.

В последующих разделах мы будем ссылаться на E0 как на безусловный аналог для условного выражения NULL. Обратите внимание, что некоторые выражения в последующих разделах подчиняются дополнительным правилам, которые применяются только в том случае, если один из операндов непосредственно содержит выражение с условием null.

  • Определенное состояние назначения v в любой точке E совпадает с определенным состоянием назначения в соответствующей точке в E0.
  • Определенное состояние назначения v после E совпадает с определенным состоянием назначения v после первичного выражения.

Замечания

Мы используем концепцию "напрямую содержит", чтобы позволить нам пропускать относительно простые выражения "оболочки" при анализе условных доступов, которые сопоставляются с другими значениями. Например, ((a?.b(out x))!) == true, как ожидается, приведет к тому же состоянию потока, что и a?.b == true в целом.

Мы также хотим, чтобы анализ мог функционировать в условиях наличия ряда возможных преобразований при условном доступе. Распространение "состояния, когда не null" невозможно, если преобразование определяется пользователем, поскольку мы не можем полагаться на то, что такие преобразования будут соблюдать ограничение, что выходные данные будут не null только в том случае, если входные данные были не null. Единственное исключение заключается в том, что входные данные определяемого пользователем преобразования являются типом значения, не допускающего значение NULL. Например:

public struct S1 { }
public struct S2 { public static implicit operator S2?(S1 s1) => null; }

Это также включает переносимые преобразования, такие как:

string x;

S1? s1 = null;
_ = s1?.M1(x = "a") ?? s1.Value.M2(x = "a");

x.ToString(); // ok

public struct S1
{
    public S1 M1(object obj) => this;
    public S2 M2(object obj) => new S2();
}
public struct S2
{
    public static implicit operator S2(S1 s1) => default;
}

При рассмотрении того, назначена ли переменная в заданной точке в пределах условного выражения NULL, мы просто предполагаем, что все предыдущие операции с условным значением NULL в одном и том же условном выражении с значением NULL успешно выполнены.

Например, для условного выражения a?.b(out x)?.c(x), его безусловный аналог — это a.b(out x).c(x). Если мы хотим знать определенное состояние назначения x перед ?.c(x), например, мы выполняем "гипотетический" анализ a.b(out x) и используем результирующее состояние в качестве входных данных для ?.c(x).

Логические константные выражения

Мы введем новый раздел "Логические выражения констант":

Для выражения expr, где expr является константным выражением со значением bool:

  • Определенное состояние назначения v после expr определяется следующими значениями:
    • Если expr является константным выражением со значением true, а состояние v до expr "не определенно назначено", то состояние v после expr "определенно назначено, если значение false".
    • Если expr является константным выражением со значением false, и состояние v до expr — "не определенно назначено", то состояние v после expr — "определенно назначено, если true".

Замечания

Предположим, что если выражение имеет константное логическое значение false, например, невозможно попасть в какую-либо ветвь, требующую, чтобы выражение вернуло true. Поэтому предполагается, что переменные должны быть определенно назначены в таких ветвях. В результате это хорошо комбинируется с изменениями спецификаций для выражений, таких как ?? и ?:, позволяя реализовать множество полезных сценариев.

Также стоит отметить, что мы никогда не ожидаем находиться в условном состоянии перед посещением константного выражения. Поэтому мы не учитываем такие сценарии, как "expr является константным выражением со значением true, а состояние v, прежде чем expr "определенно назначается при значении true".

?? (выражения нулевого объединения)

Мы дополняем раздел §9.4.4.29 следующим образом:

Для выражения expr формы expr_first ?? expr_second:

  • ...
  • Определенное состояние назначения v после expr определяется следующими значениями:
    • ...
    • Если expr_first напрямую содержит null-условное выражение E, а v определенно назначается после того, как E0, то определенное состояние назначения v после expr совпадает с определенным состоянием назначения v после expr_second.

Замечания

Приведенное выше правило формализует то, что для выражения, например a?.M(out x) ?? (x = false), a?.M(out x) была полностью вычислена и было создано ненулевое значение, в этом случае x было присвоено; или был вычислен x = false, в этом случае x также было присвоено. Поэтому x всегда назначается после этого выражения.

Это также охватывает сценарий dict?.TryGetValue(key, out var value) ?? false, заключая, что v безусловно назначено после dict.TryGetValue(key, out var value), а v безусловно назначено, если истинно после false, и что v должно быть безусловно назначено, если истинно.

Более общая формулировка также позволяет нам обрабатывать некоторые более необычные сценарии, такие как:

  • if (x?.M(out y) ?? (b && z.M(out y))) y.ToString();
  • if (x?.M(out y) ?? z?.M(out y) ?? false) y.ToString();

?: (условные) выражения

Мы дополняем раздел §9.4.4.30 следующим образом:

Для выражения expr формы expr_cond ? expr_true : expr_false:

  • ...
  • Определенное состояние назначения v после expr определяется следующими значениями:
    • ...
    • Если состояние v после expr_true "определенно назначено, когда истинно", а состояние v после expr_false "определенно назначено, когда истинно", то состояние v после expr "определенно назначено, когда истинно".
    • Если состояние v после expr_true "определенно назначено, если false", а состояние v после expr_false "определенно назначено, если false", то состояние v после expr "определенно назначено, если false".

Замечания

Это приводит к тому, что когда обе ветви условного выражения приводят к условному состоянию, мы объединяем соответствующие условные состояния и распространяем его вместо восстановления состояния и формирования неусловного конечного состояния. Это позволяет выполнять такие сценарии, как показано ниже.

bool b = true;
object x = null;
int y;
if (b ? x != null && Set(out y) : x != null && Set(out y))
{
  y.ToString();
}

bool Set(out int x) { x = 0; return true; }

Это общедоступный нишевой сценарий, который компилируется без ошибок в собственном компиляторе, но был сломан в Roslyn, чтобы соответствовать спецификации в то время.

Выражения ==/!= (оператор реляционного равенства)

Мы представляем новый раздел (оператор реляционного равенства) ==/!= выражения.

Общие правила для выражений с внедренными выражениями §9.4.4.23 применяются, за исключением описанных ниже сценариев.

Для выражения экспр формы expr_first == expr_second, где == – это предопределённый оператор сравнения (§12.12) или поднятый оператор (§12.4.8), определённое состояние присвоения v после экспр определяется следующим образом:

  • Если expr_first непосредственно содержит нулевое условное выражение E и expr_second является константным выражением со значением null, а состояние v после E0 является определенно назначенным, то состояние v после expr является определенно назначенным, если ложно.
  • Если expr_first непосредственно содержит null-условное выражение E и expr_second является выражением ненулевого типа значения или константным выражением с ненулевым значением, и состояние v после того, как E0 "определенно назначено", то состояние v после expr "определённо назначено при истинном значении".
  • Если expr_first имеет тип логический, а expr_second является константным выражением со значением true, то определенное состояние назначения после экспр совпадает с определенным состоянием назначения после expr_first.
  • Если expr_first имеет тип логический, а expr_second является константным выражением со значением false, то определенное состояние назначения после экспр совпадает с определенным состоянием назначения v после выражения логического отрицания !expr_first.

Для выражения экспр формы expr_first != expr_second, где != является предопределенным оператором сравнения (§12.12) или оператором лифта ((§12.4.8)), определенное состояние назначения v после expr определяется следующим образом:

  • Если expr_first напрямую содержит условное выражение с возможностью null E и expr_second является константным выражением со значением null, а состояние v после некондиционного аналога E0 "определенно назначено", то состояние v после expr "определенно назначено, когда истинно".
  • Если expr_first непосредственно содержит null-условное выражение E и expr_second является выражением ненулевого типа значения, либо константным выражением со значением, которое не допускает NULL, и состояние v после E0 "определенно назначено", то состояние v после expr "определенно назначено, если выражение ложно".
  • Если expr_first имеет тип логический, а expr_second является константным выражением со значением true, то определенное состояние назначения после expr совпадает с определенным состоянием назначения v после выражения логического отрицания !expr_first.
  • Если expr_first имеет тип логический, а expr_second является константным выражением со значением false, то состояние после выполнения expr совпадает с состоянием после выполнения expr_first.

Все приведенные выше правила в этом разделе являются коммутативными, то есть если правило применяется при вычислении в форме expr_second op expr_first, оно также применяется в форме expr_first op expr_second.

Замечания

Общая идея, выраженная этими правилами:

  • Если условный доступ сравнивается с null, то мы знаем, что операции определенно произошли, если результат сравнения false
  • Если условный доступ сравнивается с типом ненулевого значения или ненулевой константой, то мы знаем, что операции определенно произошли, если результат сравнения равен true.
  • так как мы не можем доверять определяемым пользователем операторам, что касается предоставления надежных ответов в контексте безопасности инициализации, новые правила применяются только при использовании предопределенного оператора ==/!=.

Возможно, в конечном итоге мы захотим уточнить эти правила, чтобы они проходили через условное состояние, которое присутствует в конце доступа к члену или вызова функции. Такие сценарии не происходят на самом деле в определенном назначении, но они происходят в null в присутствии [NotNullWhen(true)] и аналогичных атрибутов. Для этого потребуется специальная обработка констант bool в дополнение к простой обработке констант null/non-NULL.

Некоторые последствия этих правил:

  • if (a?.b(out var x) == true)) x() else x(); выдаст ошибку в ветке else
  • if (a?.b(out var x) == 42)) x() else x(); выдает ошибку в ветви else
  • if (a?.b(out var x) == false)) x() else x(); ошибка в блоке else
  • if (a?.b(out var x) == null)) x() else x(); вызовет ошибку в ветви "then"
  • if (a?.b(out var x) != true)) x() else x(); вызовет ошибку в "затем" ветке
  • if (a?.b(out var x) != 42)) x() else x(); выдаст ошибку в ветви "then"
  • if (a?.b(out var x) != false)) x() else x(); вызовет ошибку в ветке "then"
  • if (a?.b(out var x) != null)) x() else x(); вызовет ошибку в ветви 'else'

операторы is и выражения шаблонов is

Мы представляем оператор раздела is и выражения шаблона is.

Для выражения expr вида E is T, где T может быть любым типом или шаблоном.

  • Состояние однозначного назначения v перед E совпадает с состоянием однозначного назначения v перед expr.
  • Определенное состояние назначения v после expr определяется следующими значениями:
    • Если E напрямую содержит null-условное выражение, и состояние v после неусловного аналога E0 "определенно назначено", и T является любым типом или шаблоном, который не соответствует данным входа null, то состояние v после expr "определенно назначается, если это значение истинно".
    • Если E напрямую содержит нулевое условное выражение, а состояние v после безусловного аналога E0 "определенно назначено", и T — это шаблон, соответствующий входным данным null, то состояние v после expr "определенно назначается в случае ложного значения".
    • Если E имеет тип boolean и T является шаблоном, который соответствует только входу true, то состояние гарантированного присвоения v после expr совпадает с состоянием гарантированного присвоения v после E.
    • Если E имеет булевый тип и T является шаблоном, который соответствует только входным данным false, то дефинитивное состояние присвоения v после expr совпадает с дефинитивным состоянием присвоения v после выражения логического отрицания !expr.
    • В противном случае, если после E состояние v является "определенно назначено", то после expr состояние v также является "определенно назначено".

Замечания

Этот раздел предназначен для решения аналогичных сценариев, как в приведенном выше разделе ==/!=. Эта спецификация не относится к рекурсивным шаблонам, например (a?.b(out x), c?.d(out y)) is (object, object). Такая поддержка может прийти позже, если время разрешает.

Дополнительные сценарии

Эта спецификация на данный момент не охватывает сценарии, связанные с выражениями переключателя шаблона и операторами switch. Например:

_ = c?.M(out object obj4) switch
{
    not null => obj4.ToString() // undesired error
};

Кажется, что поддержка этого может прийти позже, если время разрешает.

Существует несколько категорий ошибок, связанных с "nullable", которые требуют от нас существенного повышения уровня сложности анализа паттернов. Скорее всего, любое постановление, которое улучшает определение назначения, также будет применимо к концепции nullability.

https://github.com/dotnet/roslyn/issues/49353
https://github.com/dotnet/roslyn/issues/46819
https://github.com/dotnet/roslyn/issues/44127

Недостатки

Это кажется странным, когда анализ углубляется и включает специальное распознавание условных доступов, поскольку обычно состояние потока анализа должно распространяться вверх. Мы обеспокоены тем, как решение, подобное этому, может пересекаться болезненным образом с возможными будущими функциями языка, которые выполняют проверки null.

Альтернативы

Две альтернативы этому предложению:

  1. Добавьте "состояние, когда значение NULL" и "состояние, когда значение не NULL" в язык и компилятор. Это было сочтено слишком трудоёмким для решения рассматриваемых нами сценариев, но мы могли бы потенциально реализовать приведённое выше предложение, а затем перейти к модели «значение null/не null» позже, без причинения неудобств пользователям.
  2. Бездействовать.

Неразрешенные вопросы

Существуют последствия для выражений коммутатора, которые следует указать: https://github.com/dotnet/csharplang/discussions/4240#discussioncomment-343395

Совещания по проектированию

https://github.com/dotnet/csharplang/discussions/4243