Mengapa kode ini memberikan peringatan kompiler "Possible null reference return"?

71

Pertimbangkan kode berikut:

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 = "";
    }
}

Ketika saya membangun ini, garis ditandai dengan !!!memberikan peringatan compiler: warning CS8603: Possible null reference return..

Saya menemukan ini agak membingungkan, mengingat yang _testdibaca dan diinisialisasi ke non-null.

Jika saya mengubah kode menjadi yang berikut, peringatan hilang:

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

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

Adakah yang bisa menjelaskan perilaku ini?

Matthew Watson
sumber
1
Debug.Assert tidak relevan karena itu adalah pemeriksaan runtime, sedangkan peringatan kompiler adalah pemeriksaan waktu kompilasi. Kompiler tidak memiliki akses ke perilaku runtime.
Polyfun
5
The Debug.Assert is irrelevant because that is a runtime check- Ini adalah relevan karena jika Anda komentar bahwa garis keluar, peringatan itu hilang.
Matthew Watson
1
@ Polyfun: Kompiler berpotensi mengetahui (melalui atribut) yang Debug.Assertakan melempar pengecualian jika tes gagal.
Jon Skeet
2
Saya telah menambahkan banyak kasus berbeda di sini, dan ada beberapa hasil yang sangat menarik. Akan menulis jawaban nanti - pekerjaan yang harus dilakukan untuk saat ini.
Jon Skeet
2
@EricLippert: Debug.Assertsekarang memiliki anotasi ( src ) DoesNotReturnIf(false)untuk parameter kondisi.
Jon Skeet

Jawaban:

39

Analisis aliran nullable melacak keadaan nol variabel, tetapi tidak melacak keadaan lainnya, seperti nilai boolvariabel (seperti di isNullatas), dan tidak melacak hubungan antara keadaan variabel yang terpisah (misalnya isNulldan _test).

Mesin analisis statis yang sebenarnya mungkin akan melakukan hal-hal itu, tetapi juga akan menjadi "heuristik" atau "sewenang-wenang" sampai taraf tertentu: Anda tidak bisa selalu mengatakan aturan yang diikuti, dan aturan itu bahkan mungkin berubah seiring waktu.

Itu bukan sesuatu yang bisa kita lakukan langsung di kompiler C #. Aturan untuk peringatan yang tidak dapat dibatalkan cukup canggih (seperti yang ditunjukkan oleh analisis Jon!), Tetapi itu adalah aturan, dan dapat dipertimbangkan.

Saat kami meluncurkan fitur, rasanya seperti kami sebagian besar mencapai keseimbangan yang tepat, tetapi ada beberapa tempat yang tampil canggung, dan kami akan meninjau kembali untuk C # 9.0.

Mads Torgersen - MSFT
sumber
3
Anda tahu Anda ingin memasukkan teori kisi dalam spesifikasi; Teori kisi itu mengagumkan dan sama sekali tidak membingungkan! Lakukan! :)
Eric Lippert
7
Anda tahu pertanyaan Anda sah ketika manajer program untuk C # merespons!
Sam Rueby
1
@TanveerBadar: Teori Lattice adalah tentang analisis set nilai yang memiliki urutan parsial; tipe adalah contoh yang baik; jika nilai tipe X dapat diberikan ke variabel tipe Y maka itu menyiratkan bahwa Y "cukup besar" untuk menampung X, dan itu cukup untuk membentuk kisi, yang kemudian memberi tahu kita bahwa memeriksa kelayakan dalam kompiler dapat diungkapkan. dalam spesifikasi dalam hal teori kisi. Ini relevan dengan analisis statis karena banyak topik menarik bagi penganalisa selain penugasan tipe juga dapat diungkapkan dalam hal kisi-kisi.
Eric Lippert
1
@TanveerBadar: lara.epfl.ch/w/_media/sav08:schwartzbach.pdf memiliki beberapa contoh pengantar yang bagus tentang bagaimana mesin analisis statis menggunakan teori kisi.
Eric Lippert
1
@EricLippert Luar Biasa tidak mulai menggambarkan Anda. Tautan itu masuk ke daftar wajib saya baca segera.
Tanveer Badar
56

Saya dapat membuat perkiraan yang masuk akal tentang apa yang terjadi di sini, tapi itu semua sedikit rumit :) Ini melibatkan keadaan nol dan pelacakan nol yang dijelaskan dalam spesifikasi draf . Pada dasarnya, pada titik di mana kita ingin kembali, kompiler akan memperingatkan jika keadaan ekspresi "mungkin nol" bukannya "bukan nol".

Jawaban ini agak dalam bentuk naratif daripada hanya "inilah kesimpulannya" ... Saya harap itu lebih berguna seperti itu.

Saya akan menyederhanakan contoh sedikit dengan menyingkirkan bidang, dan mempertimbangkan metode dengan salah satu dari dua tanda tangan ini:

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

Dalam implementasi di bawah ini saya telah memberikan masing-masing metode nomor yang berbeda sehingga saya dapat merujuk pada contoh spesifik dengan jelas. Ini juga memungkinkan semua implementasi untuk hadir dalam program yang sama.

Dalam setiap kasus yang dijelaskan di bawah ini, kami akan melakukan berbagai hal tetapi akhirnya berusaha untuk kembali text- jadi keadaan nol textyang penting.

Pengembalian tanpa syarat

Pertama, mari kita coba mengembalikannya secara langsung:

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

Sejauh ini, sangat sederhana. Status parameter yang dapat dibatalkan pada awal metode adalah "mungkin nol" jika bertipe string?dan "tidak nol" jika bertipe string.

Pengembalian bersyarat sederhana

Sekarang mari kita periksa nol dalam ifkondisi pernyataan itu sendiri. (Saya akan menggunakan operator kondisional, yang saya percaya akan memiliki efek yang sama, tetapi saya ingin tetap lebih benar dengan pertanyaan itu.)

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
    }
}

Hebat, jadi seperti di dalam ifpernyataan di mana kondisi itu sendiri memeriksa nullity, keadaan variabel dalam setiap cabang ifpernyataan bisa berbeda: dalam elseblok, negara adalah "tidak nol" di kedua potongan kode. Jadi khususnya, dalam M3 keadaan berubah dari "mungkin nol" menjadi "tidak nol".

Pengembalian bersyarat dengan variabel lokal

Sekarang mari kita coba menaikkan kondisi itu ke variabel lokal:

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
    }
}

Baik M5 dan M6 mengeluarkan peringatan. Jadi tidak hanya kita tidak mendapatkan efek positif dari perubahan status dari "mungkin nol" menjadi "tidak nol" di M5 (seperti yang kita lakukan dalam M3) ... kita mendapatkan efek sebaliknya di M6, di mana negara beralih dari " bukan null "menjadi" mungkin null ". Itu benar-benar mengejutkan saya.

Jadi sepertinya kita telah belajar bahwa:

  • Logika seputar "bagaimana variabel lokal dihitung" tidak digunakan untuk menyebarkan informasi keadaan. Lebih lanjut tentang itu nanti.
  • Memperkenalkan perbandingan nol dapat memperingatkan kompiler bahwa sesuatu yang sebelumnya dianggap bukan nol mungkin nol.

Pengembalian tanpa syarat setelah perbandingan yang diabaikan

Mari kita lihat poin poin kedua, dengan memperkenalkan perbandingan sebelum pengembalian tanpa syarat. (Jadi kami sepenuhnya mengabaikan hasil perbandingan.):

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
}

Perhatikan bagaimana M8 terasa seperti itu harus setara dengan M2 - keduanya memiliki parameter tidak-nol yang mereka kembalikan tanpa syarat - tetapi pengenalan perbandingan dengan nol mengubah status dari "tidak nol" menjadi "mungkin nol". Kita bisa mendapatkan bukti lebih lanjut tentang ini dengan mencoba melakukan dereferensi textsebelum kondisinya:

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
}

Perhatikan bagaimana returnpernyataan itu tidak memiliki peringatan sekarang: negara setelah mengeksekusi text.Lengthadalah "tidak nol" (karena jika kita berhasil mengeksekusi ekspresi itu, itu tidak bisa menjadi nol). Jadi textparameter dimulai sebagai "bukan nol" karena jenisnya, menjadi "mungkin nol" karena perbandingan nol, kemudian menjadi "bukan nol" lagi setelahnya text2.Length.

Apa perbandingan yang memengaruhi status?

Jadi itu perbandingan text is null... apa pengaruh perbandingan serupa? Berikut adalah empat metode lagi, semua dimulai dengan parameter string yang tidak dapat dibatalkan:

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
}

Jadi meskipun x is objectsekarang merupakan alternatif yang disarankan untuk x != null, mereka tidak memiliki efek yang sama: hanya perbandingan dengan nol (dengan salah satu dari is, ==atau !=) mengubah negara dari "tidak nol" menjadi "mungkin nol".

Mengapa mengangkat kondisi itu berpengaruh?

Kembali ke poin pertama kita sebelumnya, mengapa M5 dan M6 tidak memperhitungkan kondisi yang menyebabkan variabel lokal? Ini tidak mengejutkan saya seperti halnya mengejutkan orang lain. Membangun semacam logika ke kompiler dan spesifikasi banyak pekerjaan, dan untuk manfaat yang relatif sedikit. Berikut ini contoh lain yang tidak ada hubungannya dengan nullability di mana inlining memiliki efek:

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
}

Meskipun kita tahu itu alwaysTrueakan selalu benar, itu tidak memenuhi persyaratan dalam spesifikasi yang membuat kode setelah ifpernyataan tidak terjangkau, yang merupakan apa yang kita butuhkan.

Berikut contoh lain, seputar penugasan yang pasti:

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);
}

Meskipun kita tahu bahwa kode akan memasukkan tepat salah satu dari ifbadan pernyataan itu, tidak ada dalam spesifikasi untuk menyelesaikannya. Alat analisis statis mungkin dapat melakukannya, tetapi mencoba memasukkannya ke dalam spesifikasi bahasa akan menjadi ide yang buruk, IMO - tidak apa-apa jika alat analisis statis memiliki semua jenis heuristik yang dapat berkembang seiring waktu, tetapi tidak begitu banyak untuk spesifikasi bahasa.

Jon Skeet
sumber
7
Analisis hebat Jon. Hal utama yang saya pelajari ketika mempelajari pemeriksa Cakupan adalah bahwa kode adalah bukti kepercayaan penulisnya . Ketika kami melihat cek nol yang harus memberi tahu kami bahwa pembuat kode percaya bahwa cek itu perlu. Pemeriksa sebenarnya mencari bukti bahwa kepercayaan penulis tidak konsisten karena itu adalah tempat di mana kita melihat keyakinan tidak konsisten tentang, katakanlah, nullity, bahwa bug terjadi.
Eric Lippert
6
Ketika kita melihat misalnya, if (x != null) x.foo(); x.bar();kita memiliki dua bukti; yang ifpernyataan bukti dari proposisi "penulis percaya bahwa x bisa menjadi nol sebelum panggilan ke foo" dan pernyataan berikut bukti dari "penulis percaya x tidak null sebelum panggilan ke bar", dan kontradiksi ini mengarah ke kesimpulan bahwa ada bug. Bugnya adalah bug yang relatif jinak dari pemeriksaan nol yang tidak perlu, atau bug yang berpotensi menabrak. Bug mana yang merupakan bug asli tidak jelas, tetapi jelas bahwa ada bug.
Eric Lippert
1
Masalah yang relatif tidak canggih dari checker yang tidak melacak makna penduduk setempat, dan tidak memangkas "jalur salah" - mengontrol jalur aliran yang manusia katakan tidak mungkin - cenderung menghasilkan positif palsu justru karena mereka belum secara akurat memodelkan keyakinan penulis. Itu agak sulit!
Eric Lippert
3
Ketidakkonsistenan antara "adalah objek", "adalah {}" dan "! = Null" adalah item yang telah kita bahas secara internal beberapa minggu terakhir. Akan membawanya di LDM dalam waktu dekat untuk memutuskan apakah kita perlu menganggap ini sebagai cek kosong murni atau tidak (yang membuat perilaku ini konsisten).
JaredPar
1
@ArnonAxelrod Itu mengatakan itu tidak dimaksudkan untuk menjadi nol. Itu masih bisa nol, karena tipe referensi nullable hanya merupakan petunjuk kompiler. (Contoh: M8 (null!); Atau memanggilnya dari kode C # 7, atau mengabaikan peringatan.) Ini tidak seperti jenis keselamatan platform lainnya.
Jon Skeet
29

Anda telah menemukan bukti bahwa algoritma program-flow yang menghasilkan peringatan ini relatif tidak canggih ketika datang untuk melacak makna yang dikodekan dalam variabel lokal.

Saya tidak memiliki pengetahuan khusus tentang implementasi flow checker, tetapi setelah bekerja pada implementasi kode yang sama di masa lalu, saya dapat membuat beberapa tebakan yang berpendidikan. Pemeriksa aliran kemungkinan menyimpulkan dua hal dalam kasus positif palsu: (1) _testbisa nol, karena jika tidak bisa, Anda tidak akan memiliki perbandingan di tempat pertama, dan (2) isNullbisa benar atau salah - karena jika tidak bisa, Anda tidak akan memilikinya dalam if. Tetapi koneksi yang return _test;hanya berjalan jika _testbukan nol, koneksi itu tidak dibuat.

Ini adalah masalah yang sangat sulit, dan Anda harus berharap bahwa akan membutuhkan waktu bagi penyusun untuk mencapai kecanggihan alat yang telah bertahun-tahun dikerjakan oleh para ahli. Pemeriksa aliran Coverity, misalnya, tidak akan memiliki masalah sama sekali dalam menyimpulkan bahwa tak satu pun dari dua variasi Anda memiliki pengembalian nol, tetapi pemeriksa aliran Coverity biaya uang serius bagi pelanggan korporat.

Selain itu, checker Cakupan dirancang untuk berjalan pada basis kode besar dalam semalam ; analisis C # compiler harus dijalankan di antara penekanan tombol di editor , yang secara signifikan mengubah jenis analisis mendalam yang dapat Anda lakukan secara wajar.

Eric Lippert
sumber
"Tidak canggih" benar - saya menganggap itu dapat dimaafkan jika tersandung pada hal-hal seperti kondisional, seperti yang kita semua tahu masalah penghentian agak sulit dalam hal-hal seperti itu, tetapi fakta bahwa ada perbedaan sama sekali antara bool b = x != nullvs bool b = x is { }(dengan tidak ada penugasan yang benar-benar digunakan!) menunjukkan bahwa bahkan pola yang dikenal untuk pemeriksaan nol dipertanyakan. Bukan untuk meremehkan kerja keras tim yang tak diragukan lagi untuk menjadikan pekerjaan ini sebagaimana mestinya untuk basis kode yang nyata dan sedang digunakan - sepertinya analisisnya adalah pragmatis kapital-P.
Jeroen Mostert
@JeroenMostert: Jared Par menyebutkan dalam komentar tentang jawaban Jon Skeet bahwa Microsoft sedang mendiskusikan masalah itu secara internal.
Brian
8

Semua jawaban lain benar-benar tepat.

Jika ada yang penasaran, saya mencoba mengeja logika kompiler sejelas mungkin di https://github.com/dotnet/roslyn/issues/36927#issuecomment-508595947

Satu hal yang tidak disebutkan adalah bagaimana kita memutuskan apakah cek nol harus dianggap "murni", dalam arti bahwa jika Anda melakukannya, kita harus mempertimbangkan dengan serius apakah nol adalah suatu kemungkinan. Ada banyak cek nol "insidental" di C #, tempat Anda menguji nol sebagai bagian dari melakukan sesuatu yang lain, jadi kami memutuskan bahwa kami ingin mempersempit set cek ke cek yang kami yakin orang melakukannya dengan sengaja. Heuristik yang kami temukan adalah "mengandung kata null", jadi itu sebabnya x != nulldan x is objectmenghasilkan hasil yang berbeda.

Andy Gocke
sumber