Почему этот код выдает предупреждение компилятора «Возможный возврат нулевой ссылки»?


71

Рассмотрим следующий код:

using System;

#nullable enable

namespace Demo
{
    public sealed class TestClass
    {
        public string Test()
        {
            bool isNull = _test == null;

            if (isNull)
                return "";
            else
                return _test; // !!!
        }

        readonly string _test = "";
    }
}

Когда я строй это линия , отмеченная !!!выдает предупреждение компилятора: warning CS8603: Possible null reference return..

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

Если я изменю код на следующее, предупреждение исчезнет:

        public string Test()
        {
            // bool isNull = _test == null;

            if (_test == null)
                return "";
            else
                return _test;
        }

Кто-нибудь может объяснить это поведение?


1
Debug.Assert не имеет значения, потому что это проверка во время выполнения, тогда как предупреждение компилятора - проверка во время компиляции. Компилятор не имеет доступа к поведению во время выполнения.
Polyfun

5
The Debug.Assert is irrelevant because that is a runtime check- Это является уместным , потому что если вы прокомментируете эту линию, предупреждение уходит.
Мэтью Уотсон

1
@Polyfun: компилятор может знать (через атрибуты), что он Debug.Assertвыдаст исключение, если тест не пройден.
Джон Скит

2
Я добавил много разных случаев здесь, и есть некоторые действительно интересные результаты. Позже напишу ответ - работа на данный момент.
Джон Скит

2
@EricLippert: Debug.Assertтеперь есть аннотация ( src ) DoesNotReturnIf(false)для параметра условия.
Джон Скит

Ответы:


39

Анализ обнуляемого потока отслеживает нулевое состояние переменных, но не отслеживает другое состояние, например, значение boolпеременной (как isNullуказано выше), и не отслеживает взаимосвязь между состоянием отдельных переменных (например, isNullи _test).

Фактический механизм статического анализа, вероятно, будет делать эти вещи, но также будет в некоторой степени «эвристическим» или «произвольным»: вы не можете обязательно сказать правила, которым он следует, и эти правила могут даже измениться со временем.

Это не то, что мы можем сделать непосредственно в компиляторе C #. Правила для обнуляемых предупреждений довольно сложны (как показывает анализ Джона!), Но они являются правилами и могут быть обоснованы.

Когда мы разворачиваем эту функцию, мы чувствуем, что в основном мы нашли правильный баланс, но есть несколько мест, которые кажутся неловкими, и мы вернемся к ним для C # 9.0.


3
Вы знаете, что хотите поместить теорию решетки в спецификацию; теория решеток удивительна и совсем не смущает! Сделай это! :)
Эрик Липперт

7
Вы знаете, что ваш вопрос правомерен, когда менеджер программы для C # отвечает!
Сэм Рюби

1
@TanveerBadar: теория решеток заключается в анализе наборов значений, имеющих частичный порядок; типы являются хорошим примером; если значение типа X присваивается переменной типа Y, то это означает, что Y «достаточно большой», чтобы содержать X, и этого достаточно, чтобы сформировать решетку, что затем говорит нам, что проверка присваиваемости в компиляторе может быть сформулирована в спецификации в терминах теории решеток. Это относится к статическому анализу, потому что очень много тем, представляющих интерес для анализатора, помимо присваивания типов, также можно выразить в терминах решеток.
Эрик Липперт

1
@TanveerBadar: lara.epfl.ch/w/_media/sav08:schwartzbach.pdf содержит несколько хороших вводных примеров того, как механизмы статического анализа используют теорию решеток.
Эрик Липперт

1
@EricLippert Awesome не начинает описывать вас. Эта ссылка сразу же входит в мой список для прочтения.
Танвеер Бадар

56

Я могу сделать разумное предположение относительно того, что здесь происходит, но все это немного сложно :) Это включает в себя нулевое состояние и нулевое отслеживание, описанные в проекте спецификации . По сути, в тот момент, когда мы хотим вернуться, компилятор предупредит, если состояние выражения «возможно, нулевое» вместо «не нулевое».

Этот ответ в несколько повествовательной форме, а не просто «вот выводы» ... Я надеюсь, что он более полезен.

Я собираюсь немного упростить пример, избавившись от полей, и рассмотрим метод с одной из этих двух подписей:

public static string M(string? text)
public static string M(string text)

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

В каждом из случаев, описанных ниже, мы будем делать разные вещи, но в конечном итоге попытаемся вернуться text- так textчто важно нулевое состояние .

Безусловный возврат

Во-первых, давайте просто попробуем вернуть его напрямую:

public static string M1(string? text) => text; // Warning
public static string M2(string text) => text;  // No warning

Пока все просто. Обнуляемое состояние параметра в начале метода имеет значение «возможно, нулевое», если оно имеет тип, string?и «не нулевое», если оно имеет тип string.

Простой условный возврат

Теперь давайте проверим нулевое значение внутри самого ifусловия оператора. (Я бы использовал условный оператор, который, как я полагаю, будет иметь тот же эффект, но я хотел бы остаться честнее с вопросом.)

public static string M3(string? text)
{
    if (text is null)
    {
        return "";
    }
    else
    {
        return text; // No warning
    }
}

public static string M4(string text)
{
    if (text is null)
    {
        return "";
    }
    else
    {
        return text; // No warning
    }
}

Отлично, так выглядит в ifоператоре, где само условие проверяет на нулевое значение, состояние переменной в каждой ветви ifоператора может быть различным: в пределах elseблока состояние «не равно нулю» в обеих частях кода. Так, в частности, в M3 состояние меняется с «возможно, нулевое» на «не нулевое».

Условный возврат с локальной переменной

Теперь давайте попробуем перенести это условие в локальную переменную:

public static string M5(string? text)
{
    bool isNull = text is null;
    if (isNull)
    {
        return "";
    }
    else
    {
        return text; // Warning
    }
}

public static string M6(string text)
{
    bool isNull = text is null;
    if (isNull)
    {
        return "";
    }
    else
    {
        return text; // Warning
    }
}

И M5, и M6 выдают предупреждения. Таким образом, мы не только не получаем положительный эффект изменения состояния с «возможно, нулевого» на «не нулевой» в M5 (как мы это делали в M3) ... мы получаем противоположный эффект в M6, откуда происходит состояние « не ноль "до" может быть ноль ". Это действительно удивило меня.

Похоже, мы узнали, что:

  • Логика «как была вычислена локальная переменная» не используется для распространения информации о состоянии. Подробнее об этом позже.
  • Введение нулевого сравнения может предупредить компилятор, что то, что раньше считалось не нулевым, в конце концов может быть нулевым.

Безусловный возврат после игнорируемого сравнения

Давайте посмотрим на второй из этих пунктов, введя сравнение перед безусловным возвратом. (Таким образом, мы полностью игнорируем результат сравнения.):

public static string M7(string? text)
{
    bool ignored = text is null;
    return text; // Warning
}

public static string M8(string text)
{
    bool ignored = text is null;
    return text; // Warning
}

Обратите внимание, что M8 чувствует, что он должен быть эквивалентен M2 - оба имеют ненулевой параметр, который они возвращают безоговорочно - но введение сравнения с нулевым изменяет состояние с «не нулевое» на «возможно нулевое». Мы можем получить дополнительные доказательства этого, пытаясь разыменовать textдо условия:

public static string M9(string text)
{
    int length1 = text.Length;   // No warning
    bool ignored = text is null;
    int length2 = text.Length;   // Warning
    return text;                 // No warning
}

Обратите внимание, что у returnоператора нет предупреждения: состояние после выполнения text.Length«не равно нулю» (потому что если мы успешно выполним это выражение, оно не может быть нулевым). Таким образом, textпараметр начинается с «not null» из-за его типа, становится «возможно null» из-за нулевого сравнения, а затем снова становится «not null» text2.Length.

Какие сравнения влияют на состояние?

Так что это сравнение text is null... какой эффект имеют подобные сравнения? Вот еще четыре метода, все из которых начинаются с ненулевого строкового параметра:

public static string M10(string text)
{
    bool ignored = text == null;
    return text; // Warning
}

public static string M11(string text)
{
    bool ignored = text is object;
    return text; // No warning
}

public static string M12(string text)
{
    bool ignored = text is { };
    return text; // No warning
}

public static string M13(string text)
{
    bool ignored = text != null;
    return text; // Warning
}

Так что , хотя x is objectв настоящее время является рекомендуемым альтернативой x != null, они не имеют такой же эффект: только сравнение с нулем (с любым is, ==или !=) изменяет состояние с «не нуль» до « может быть нулем».

Почему подъем условия оказывает влияние?

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

public static int X1()
{
    if (true)
    {
        return 1;
    }
}

public static int X2()
{
    bool alwaysTrue = true;
    if (alwaysTrue)
    {
        return 1;
    }
    // Error: not all code paths return a value
}

Несмотря на то, что мы знаем, что alwaysTrueэто всегда будет правдой, это не удовлетворяет требованиям спецификации, которые делают код после ifоператора недоступным, что нам и нужно.

Вот еще один пример вокруг определенного присваивания:

public static void X3()
{
    string x;
    bool condition = DateTime.UtcNow.Year == 2020;
    if (condition)
    {
        x = "It's 2020.";
    }
    if (!condition)
    {
        x = "It's not 2020.";
    }
    // Error: x is not definitely assigned
    Console.WriteLine(x);
}

Несмотря на то, что мы знаем, что код будет входить именно в один из этих ifтел операторов, в спецификации нет ничего, что могло бы решить эту проблему. Инструменты статического анализа вполне могут это сделать, но пытаться включить это в спецификацию языка было бы плохой идеей, ИМО - для инструментов статического анализа хорошо иметь все виды эвристики, которые могут развиваться со временем, но не так сильно для спецификации языка.


7
Отличный анализ, Джон. Ключевым моментом, который я узнал при изучении средства проверки Coverity, является то, что код является свидетельством убеждений его авторов . Когда мы видим нулевую проверку, которая должна сообщить нам, что авторы кода считают эту проверку необходимой. На самом деле проверяющий ищет доказательства того, что убеждения авторов были непоследовательными, потому что именно там мы видим противоречивые убеждения, скажем, об аннулировании, в которых возникают ошибки.
Эрик Липперт

6
Когда мы видим, например, if (x != null) x.foo(); x.bar();у нас есть два доказательства; это ifутверждение является доказательством утверждения «автор считает, что x может быть нулевым до вызова foo», а следующее утверждение является доказательством «автор считает, что x не является нулевым до вызова bar», и это противоречие приводит к Вывод, что есть ошибка. Ошибка является либо относительно доброкачественной ошибкой ненужной проверки нуля, либо потенциально сбойной ошибкой. Какая ошибка является настоящей ошибкой, не ясно, но ясно, что она есть.
Эрик Липперт

1
Проблема в том, что относительно неискушенные шашки, которые не отслеживают значения местных жителей и не обрезают «ложные пути» - невозможно контролировать пути потока, которые люди могут сказать вам, - имеют тенденцию давать ложные срабатывания именно потому, что они точно не смоделировали убеждения авторов. Это хитрый момент!
Эрик Липперт

3
Несоответствие между "is object", "is {}" и "! = Null" - это вопрос, который мы внутренне обсуждали последние несколько недель. В ближайшем будущем я расскажу об этом в LDM, чтобы решить, нужно ли нам рассматривать их как чистые нулевые проверки или нет (что делает поведение непротиворечивым).
JaredPar

1
@ArnonAxelrod Это говорит о том, что оно не должно быть нулевым. Это может все еще быть нулем, потому что обнуляемые ссылочные типы - только подсказка компилятора. (Примеры: M8 (null!); Или вызов его из кода C # 7, или игнорирование предупреждений.) Это не похоже на безопасность типов остальной части платформы.
Джон Скит

29

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

У меня нет конкретных знаний о реализации средства проверки потока, но, поработав над реализациями подобного кода в прошлом, я могу сделать некоторые обоснованные предположения. Средство проверки потока, вероятно, выводит две вещи в ложноположительном случае: (1) _testможет быть нулем, потому что если бы оно не могло быть, у вас не было бы сравнения в первую очередь, и (2) isNullмогло бы быть истинным или ложным - потому что если бы он не мог, вы бы не получили его if. Но соединение, которое return _test;работает только, если _testне является нулевым, это соединение не устанавливается .

Это удивительно сложная проблема, и вы должны ожидать, что компилятору понадобится некоторое время, чтобы освоить инструменты, которые уже несколько лет работают экспертами. Например, проверка потока Coverity не будет иметь никаких проблем с выводом, что ни один из двух вариантов не имеет нулевого возврата, но проверка потока Coverity стоит серьезных денег для корпоративных клиентов.

Кроме того, контролеры Coverity предназначены для работы на больших кодовых базах в одночасье ; анализ компилятора C # должен выполняться между нажатиями клавиш в редакторе , что значительно меняет виды углубленного анализа, который вы можете выполнять.


«Неискушенный» права - я считаю , это простительно , если он натыкается на таких вещах , как условные, так как мы все знаем , что проблема остановки является немногими жесткой гайкой в таких вопросах, но тот факт , что есть разница вообще между bool b = x != nullпротив bool b = x is { }(с ни одно присвоение фактически не используется!) показывает, что даже распознанные шаблоны для нулевых проверок сомнительны. Не принижайте несомненно тяжелую работу команды, чтобы сделать эту работу в основном так, как это должно быть для реальных, используемых баз кода - похоже, что анализ прагматичен с большой буквы.
Йерун Мостерт

@JeroenMostert: Джаред Пар упоминает в комментарии к ответу Джона Скита, что Microsoft обсуждает эту проблему внутри компании.
Брайан

8

Все остальные ответы в значительной степени верны.

На случай, если кому-нибудь интересно, я попытался изложить логику компилятора как можно более подробно в https://github.com/dotnet/roslyn/issues/36927#issuecomment-508595947.

Единственное, что не упомянуто, - это то, как мы решаем, следует ли считать нулевую проверку «чистой», в том смысле, что если вы делаете это, мы должны серьезно рассмотреть вопрос о том, возможна ли нулевая проверка. В C # существует множество «случайных» нулевых проверок, в которых вы проверяете нулевое значение как часть выполнения чего-то другого, поэтому мы решили, что хотим сузить набор проверок до тех, которые, как мы были уверены, люди делали намеренно. Эвристика, которую мы придумали, была «содержит слово null», поэтому x != nullи x is objectдает разные результаты.

Используя наш сайт, вы подтверждаете, что прочитали и поняли нашу Политику в отношении файлов cookie и Политику конфиденциальности.
Licensed under cc by-sa 3.0 with attribution required.