Чому цей код подає попередження компілятора "Можливе повернення до нуля"?


70

Розглянемо наступний код:

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 не має значення, оскільки це перевірка часу виконання, тоді як попередження компілятора - перевірка часу компіляції. Компілятор не має доступу до поведінки часу виконання.
Поліфун

5
The Debug.Assert is irrelevant because that is a runtime check- Це є доречним , тому що якщо ви прокоментуєте цю лінію, попередження йде.
Меттью Уотсон

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

2
Я додав сюди безліч різних справ, і є кілька справді цікавих результатів. Відповідь напишу пізніше - роботу, яку зараз робити.
Джон Скіт

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

Відповіді:


38

Аналіз обнуленого потоку відстежує нульовий стан змінних, але він не відстежує інший стан, такий як значення 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 не починає тебе описувати. Це посилання переходить до мого списку обов’язково прочитаних.
Tanveer Badar

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 стан змінюється з "можливо null" на "not null".

Умовне повернення з локальною змінною

Тепер спробуємо приєднати цю умову до локальної змінної:

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 видають попередження. Таким чином, ми не тільки отримуємо позитивний ефект зміни стану з "можливо нульового" на "не нульового" в М5 (як це робилося в М3) ... ми отримуємо протилежний ефект у М6, звідки йде стан " не null "до" можливо null ". Це дійсно мене здивувало.

Так виглядає, що ми дізналися про це:

  • Логіка навколо "як була обчислена локальна змінна" не використовується для поширення інформації про стан. Детальніше про це пізніше.
  • Введення нульового порівняння може попередити компілятора про те, що те, що раніше вважалося не нульовим, може бути зрештою нульовим.

Безумовне повернення після ігнорованого порівняння

Давайте розглянемо другий з цих пунктів, вводячи порівняння перед безумовною віддачею. (Отже, ми повністю ігноруємо результат порівняння.):

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 - обидва мають ненульовий параметр, який вони повертають беззастережно - але введення порівняння з null змінює стан з "not null" на "можливо null". Ми можемо отримати додаткові докази цього, спробувавши скинути за мету 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" через порівняння з 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
Чудовий аналіз Джон. Ключове, про що я дізнався, вивчаючи шашку покриття, - це те, що код є свідченням переконань його авторів . Коли ми бачимо нульовий чек, який повинен повідомити нас про те, що автори коду вважали, що перевірка необхідна. Перевірка насправді шукає доказів того, що переконання авторів були непослідовними, оскільки саме там, де ми бачимо непослідовні вірування про, скажімо, недійсність, трапляються помилки.
Ерік Ліпперт

6
Коли ми бачимо, наприклад, у if (x != null) x.foo(); x.bar();нас є два докази; ifзаява свідчить про пропозицію «автор вважає , що х може бути порожнім перед викликом Foo» і наступне твердження є свідченням «автор вважає , х не дорівнює нулю перед викликом бар», і це протиріччя призводить до висновок про наявність помилки. Помилка - це відносно доброякісна помилка непотрібної перевірки нуля, або потенційно збіжна помилка. Яка помилка - це справжня помилка, незрозуміло, але зрозуміло, що є.
Ерік Ліпперт

1
Проблема в тому, що відносно непрофілізовані шашки, які не відслідковують значення місцевих жителів і не обрізають "помилкові шляхи" - керувати потоками потоків, про які люди можуть вам сказати, неможливі - схильні створювати помилкові позитиви саме тому, що вони не точно моделювали переконання авторів. Ось і хитрий шматочок!
Ерік Ліпперт

3
Невідповідність між "є об'єктом", "є {}" і "! = Нуль" - це предмет, про який ми обговорювали внутрішньо останні кілька тижнів. Ми збираємось піднести це в LDM найближчим часом, щоб вирішити, чи потрібно вважати це чистими нульовими перевірками чи ні (що робить поведінку послідовною).
JaredPar

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

29

Ви виявили докази того, що алгоритм програмного потоку, який створює це попередження, є відносно непрофільованим, коли мова йде про відстеження значень, закодованих в локальних змінних.

Я не маю конкретних знань про реалізацію перевірки потоку, але, працюючи над реалізацією подібного коду в минулому, я можу зробити кілька освічених здогадок. Програма перевірки потоку, ймовірно, виводить дві речі у хибному позитивному випадку: (1) _testможе бути нульовим, тому що якби це не було, ви б не мали порівняння в першу чергу, і (2) isNullмогли бути істинними чи помилковими - тому що якби це не могло, у вас його не було б if. Але з'єднання, яке return _test;працює лише тоді, коли _testвоно недійсне, це з'єднання не робиться.

Це напрочуд складна проблема, і ви повинні сподіватися, що компілятору буде потрібно якийсь час, щоб досягти витонченості інструментів, які мали багаторічну роботу експертів. Наприклад, програма перевірки потоку покриття не матиме жодних проблем у визначенні того, що жодна з ваших двох варіантів не мала нульового повернення, але перевірка потоку покриття покриває серйозні гроші для корпоративних клієнтів.

Також шашки Coverity розроблені для роботи на великих базах коду протягом ночі ; аналіз компілятора C # повинен проходити між натисканнями клавіш у редакторі , що суттєво змінює види глибоких аналізів, які ви можете розумно виконати.


"Недосвідчений" має рацію - я вважаю це вибачливим, якщо він натрапляє на такі речі, як умовні умови, оскільки всі ми знаємо, що проблема зупинки є трохи жорсткою гайкою в таких питаннях, але той факт, що взагалі є різниця між bool b = x != nullvs bool b = x is { }(з жодне призначення, яке фактично не використовується!) показує, що навіть розпізнані зразки для нульових перевірок сумнівні. Не зважати на безперечно наполегливу роботу команди, щоб зробити цю роботу здебільшого як слід для справжніх, використовуваних кодових баз - схоже, аналіз є капітально-P прагматичним.
Jeroen

@JeroenMostert: Джаред Пар згадує у коментарі до відповіді Джона Скіта, що Microsoft обговорює це питання всередині країни.
Брайан

8

Усі інші відповіді майже точно правильні.

У випадку, якщо хтось цікавий, я намагався якомога чіткіше пояснити логіку компілятора на https://github.com/dotnet/roslyn/isissue/36927#issuecomment-508595947

Один фрагмент, про який не згадується, - це те, як ми вирішуємо, чи слід вважати нульову перевірку "чистою", в тому сенсі, що якщо ви це зробите, ми повинні серйозно подумати про те, чи є нуль можливою. У C # є багато "випадкових" перевірок на нуль, де ви перевіряєте на предмет null як частину іншого, тому ми вирішили, що ми хочемо звузити набір перевірок до тих, які ми впевнені, що люди роблять навмисно. Евристика, яку ми придумали, була "містить слово null", тому саме це x != nullі x is objectдає різні результати.

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.