Tại sao mã này đưa ra một cảnh báo trình biên dịch lại tham chiếu null có thể có của null?


70

Hãy xem xét các mã sau đây:

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

Khi tôi xây dựng cái này, dòng được đánh dấu !!!đưa ra cảnh báo trình biên dịch : warning CS8603: Possible null reference return..

Tôi thấy điều này hơi khó hiểu, được đưa ra _testlà chỉ đọc và được khởi tạo thành không null.

Nếu tôi thay đổi mã thành như sau, cảnh báo sẽ biến mất:

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

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

Bất cứ ai có thể giải thích hành vi này?


1
Debug.Assert không liên quan vì đó là kiểm tra thời gian chạy, trong khi cảnh báo của trình biên dịch là kiểm tra thời gian biên dịch. Trình biên dịch không có quyền truy cập vào hành vi thời gian chạy.
Polyfun

5
The Debug.Assert is irrelevant because that is a runtime check- Nó liên quan vì nếu bạn nhận xét dòng đó, cảnh báo sẽ biến mất.
Matthew Watson

1
@Polyfun: Trình biên dịch có khả năng biết (thông qua các thuộc tính) Debug.Assertsẽ đưa ra một ngoại lệ nếu thử nghiệm thất bại.
Jon Skeet

2
Tôi đã thêm rất nhiều trường hợp khác nhau ở đây, và có một số kết quả thực sự thú vị. Sẽ viết lên một câu trả lời sau - công việc phải làm bây giờ.
Jon Skeet

2
@EricLippert: Debug.Asserthiện có chú thích ( src ) DoesNotReturnIf(false)cho tham số điều kiện.
Jon Skeet

Câu trả lời:


38

Phân tích luồng nullable theo dõi trạng thái null của các biến, nhưng nó không theo dõi trạng thái khác, chẳng hạn như giá trị của boolbiến (như isNulltrên) và nó không theo dõi mối quan hệ giữa trạng thái của các biến riêng biệt (ví dụ isNull_test).

Một công cụ phân tích tĩnh thực tế có thể sẽ làm những việc đó, nhưng cũng sẽ là "heuristic" hoặc "tùy ý" ở một mức độ nào đó: bạn không nhất thiết phải nói ra các quy tắc mà nó tuân theo và những quy tắc đó thậm chí có thể thay đổi theo thời gian.

Đó không phải là thứ chúng ta có thể làm trực tiếp trong trình biên dịch C #. Các quy tắc cho các cảnh báo vô giá trị khá phức tạp (như phân tích của Jon cho thấy!), Nhưng chúng là các quy tắc và có thể được lý giải.

Khi chúng tôi đưa ra tính năng, có cảm giác như chúng tôi chủ yếu đạt được sự cân bằng phù hợp, nhưng có một vài nơi xuất hiện là khó xử, và chúng tôi sẽ xem xét lại những điều đó cho C # 9.0.


3
Bạn biết bạn muốn đưa lý thuyết mạng vào đặc tả; lý thuyết mạng là tuyệt vời và hoàn toàn không khó hiểu! Làm đi! :)
Eric Lippert

7
Bạn biết câu hỏi của bạn là hợp pháp khi người quản lý chương trình cho C # trả lời!
Sam Ruither

1
@TanveerBadar: Lý thuyết mạng là về phân tích các tập hợp các giá trị có thứ tự từng phần; các loại là một ví dụ tốt; nếu một giá trị của loại X có thể gán cho một biến của loại Y thì điều đó có nghĩa là Y "đủ lớn" để giữ X và điều đó đủ để tạo thành một mạng, sau đó cho chúng ta biết rằng việc kiểm tra khả năng gán trong trình biên dịch có thể được thực hiện trong đặc điểm kỹ thuật về mặt lý thuyết mạng. Điều này có liên quan đến phân tích tĩnh bởi vì rất nhiều chủ đề đáng quan tâm đối với một máy phân tích khác ngoài khả năng gán loại cũng có thể biểu thị được về mặt mạng.
Eric Lippert

1
@TanveerBadar: lara.epfl.ch/w/_media/sav08:schwartzbach.pdf có một số ví dụ giới thiệu tốt về cách các công cụ phân tích tĩnh sử dụng lý thuyết mạng.
Eric Lippert

1
@EricLippert Awesome không bắt đầu mô tả về bạn. Liên kết đó đang đi vào danh sách phải đọc của tôi ngay lập tức.
Tanveer Badar

56

Tôi có thể đoán hợp lý những gì đang diễn ra ở đây, nhưng tất cả đều hơi phức tạp :) Nó liên quan đến trạng thái null và theo dõi null được mô tả trong thông số dự thảo . Về cơ bản, tại điểm mà chúng tôi muốn quay lại, trình biên dịch sẽ cảnh báo nếu trạng thái của biểu thức là "có thể null" thay vì "không null".

Câu trả lời này ở dạng tường thuật hơn là "đây là kết luận" ... Tôi hy vọng nó hữu ích hơn theo cách đó.

Tôi sẽ đơn giản hóa ví dụ một chút bằng cách loại bỏ các trường và xem xét một phương thức với một trong hai chữ ký sau:

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

Trong các triển khai dưới đây, tôi đã cung cấp cho mỗi phương thức một số khác nhau để tôi có thể tham khảo các ví dụ cụ thể rõ ràng. Nó cũng cho phép tất cả các triển khai có mặt trong cùng một chương trình.

Trong mỗi trường hợp được mô tả dưới đây, chúng tôi sẽ làm nhiều việc khác nhau nhưng cuối cùng lại cố gắng quay lại text- vì vậy đó là trạng thái textkhông quan trọng.

Hoàn trả vô điều kiện

Đầu tiên, chúng ta hãy thử trả lại trực tiếp:

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

Cho đến nay, rất đơn giản. Trạng thái nullable của tham số khi bắt đầu phương thức là "có thể null" nếu nó thuộc loại string?và "không null" nếu thuộc loại string.

Hoàn trả có điều kiện đơn giản

Bây giờ hãy kiểm tra null trong ifchính điều kiện câu lệnh. (Tôi sẽ sử dụng toán tử có điều kiện, mà tôi tin rằng sẽ có tác dụng tương tự, nhưng tôi muốn giữ nguyên câu hỏi.)

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

Tuyệt vời, vì vậy nó trông giống như trong một ifcâu lệnh trong đó điều kiện tự kiểm tra tính vô hiệu, trạng thái của biến trong mỗi nhánh của ifcâu lệnh có thể khác nhau: trong elsekhối, trạng thái "không null" trong cả hai đoạn mã. Vì vậy, đặc biệt, trong M3, trạng thái thay đổi từ "có thể null" thành "không null".

Trả về có điều kiện với một biến cục bộ

Bây giờ chúng ta hãy cố gắng đưa điều kiện đó đến một biến cục bộ:

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

Cả M5 và M6 đều đưa ra cảnh báo. Vì vậy, không chỉ chúng tôi không nhận được hiệu ứng tích cực của thay đổi trạng thái từ "có thể null" thành "không null" trong M5 (như chúng tôi đã làm trong M3) ... chúng tôi còn nhận được hiệu ứng ngược lại ở M6, nơi nhà nước đi từ " không null "thành" có thể null ". Điều đó thực sự làm tôi ngạc nhiên.

Vì vậy, có vẻ như chúng ta đã học được rằng:

  • Logic xung quanh "cách một biến cục bộ được tính toán" không được sử dụng để truyền bá thông tin trạng thái. Thêm về điều đó sau.
  • Giới thiệu một so sánh null có thể cảnh báo trình biên dịch rằng tất cả những gì trước đây nó nghĩ là null có thể là null.

Hoàn trả vô điều kiện sau khi so sánh bị bỏ qua

Chúng ta hãy nhìn vào điểm thứ hai của những gạch đầu dòng đó, bằng cách đưa ra một so sánh trước khi trở lại vô điều kiện. (Vì vậy, chúng tôi hoàn toàn bỏ qua kết quả so sánh.):

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
}

Lưu ý cách M8 cảm thấy nó tương đương với M2 - cả hai đều có tham số không null mà chúng trả về vô điều kiện - nhưng việc đưa ra so sánh với null thay đổi trạng thái từ "không null" thành "có thể null". Chúng ta có thể có thêm bằng chứng về điều này bằng cách cố gắng hủy đăng ký texttrước điều kiện:

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
}

Lưu ý cách returnbây giờ câu lệnh không có cảnh báo: trạng thái sau khi thực thi text.Lengthlà "không null" (vì nếu chúng ta thực hiện thành công biểu thức đó, nó không thể là null). Vì vậy, texttham số bắt đầu là "không null" do loại của nó, trở thành "có thể null" do so sánh null, sau đó trở thành "không null" sau đó text2.Length.

Những so sánh ảnh hưởng đến nhà nước?

Vì vậy, đó là một so sánh của text is null... những so sánh tương tự có ảnh hưởng gì? Dưới đây là bốn phương thức nữa, tất cả đều bắt đầu bằng tham số chuỗi không 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
}

Vì vậy, mặc dù x is objectbây giờ là một giải pháp thay thế được đề xuất x != null, chúng không có tác dụng tương tự: chỉ so sánh với null (với bất kỳ is, ==hoặc !=) thay đổi trạng thái từ "không null" thành "có thể null".

Tại sao cẩu nâng điều kiện có ảnh hưởng?

Quay trở lại điểm đầu tiên của chúng tôi trước đó, tại sao M5 và M6 không tính đến điều kiện dẫn đến biến cục bộ? Điều này không làm tôi ngạc nhiên nhiều như nó làm người khác ngạc nhiên. Xây dựng loại logic đó vào trình biên dịch và đặc tả là rất nhiều công việc và cho lợi ích tương đối ít. Đây là một ví dụ khác không liên quan gì đến tính vô hiệu trong đó nội tuyến có gì đó có ảnh hưởng:

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
}

Mặc dù chúng tôi biết rằng điều đó alwaysTruesẽ luôn đúng, nhưng nó không thỏa mãn các yêu cầu trong đặc tả làm cho mã sau ifcâu lệnh không thể truy cập được, đó là điều chúng tôi cần.

Đây là một ví dụ khác, xung quanh nhiệm vụ xác định:

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

Mặc dù chúng tôi biết rằng mã sẽ nhập chính xác một trong các ifcơ quan tuyên bố đó, nhưng không có gì trong thông số kỹ thuật để làm việc đó. Các công cụ phân tích tĩnh có thể có thể làm như vậy, nhưng cố gắng đưa nó vào đặc tả ngôn ngữ sẽ là một ý tưởng tồi, IMO - thật tốt khi các công cụ phân tích tĩnh có tất cả các loại heuristic có thể phát triển theo thời gian, nhưng không quá nhiều cho một đặc điểm kỹ thuật ngôn ngữ.


7
Phân tích tuyệt vời Jon. Điều quan trọng mà tôi học được khi nghiên cứu Trình kiểm tra độ phủ là mã là bằng chứng về niềm tin của các tác giả . Khi chúng tôi thấy một kiểm tra null sẽ thông báo cho chúng tôi rằng các tác giả của mã tin rằng kiểm tra là cần thiết. Người kiểm tra thực sự đang tìm kiếm bằng chứng cho thấy niềm tin của các tác giả không nhất quán bởi vì đó là nơi chúng ta thấy niềm tin không nhất quán về, nói, vô hiệu, rằng lỗi xảy ra.
Eric Lippert

6
Khi chúng ta thấy ví dụ, if (x != null) x.foo(); x.bar();chúng ta có hai mảnh bằng chứng; các iftuyên bố là bằng chứng về mệnh đề "Tác giả tin rằng x có thể được null trước khi cuộc gọi đến foo" và báo cáo kết quả sau đây là bằng chứng về "tác giả tin rằng x không là null trước khi cuộc gọi đến bar", và mâu thuẫn này dẫn đến các kết luận rằng có một lỗi. Lỗi này là lỗi tương đối lành tính của kiểm tra null không cần thiết hoặc lỗi có khả năng bị lỗi. Lỗi nào là lỗi thực sự không rõ ràng, nhưng rõ ràng là có một lỗi.
Eric Lippert

1
Vấn đề mà các công cụ kiểm tra tương đối không phức tạp không theo dõi ý nghĩa của người dân địa phương và không cắt xén "đường dẫn sai" - kiểm soát các đường dẫn dòng chảy mà con người có thể nói với bạn là không thể - có xu hướng tạo ra các kết quả dương tính chính xác vì chúng không được mô hình chính xác niềm tin của các tác giả. Đó là một chút khó khăn!
Eric Lippert

3
Sự không nhất quán giữa "là đối tượng", "là {}" và "! = Null" là một mục chúng tôi đã thảo luận trong nội bộ vài tuần qua. Sẽ đưa nó lên LDM trong tương lai rất gần để quyết định xem chúng ta có cần coi đây là kiểm tra null thuần túy hay không (điều này làm cho hành vi nhất quán).
JaredPar

1
@ArnonAxelrod Điều đó nói rằng nó không có nghĩa là null. Nó vẫn có thể là null, vì các kiểu tham chiếu nullable chỉ là một gợi ý trình biên dịch. (Ví dụ: M8 (null!); Hoặc gọi nó từ mã C # 7 hoặc bỏ qua các cảnh báo.) Nó không giống như kiểu an toàn của phần còn lại của nền tảng.
Jon Skeet

29

Bạn đã phát hiện ra bằng chứng cho thấy thuật toán dòng chương trình tạo ra cảnh báo này tương đối không phức tạp khi theo dõi các ý nghĩa được mã hóa trong các biến cục bộ.

Tôi không có kiến ​​thức cụ thể về việc triển khai trình kiểm tra lưu lượng, nhưng đã từng làm việc về việc triển khai mã tương tự trong quá khứ, tôi có thể đưa ra một số phỏng đoán có giáo dục. Trình kiểm tra lưu lượng có thể suy ra hai điều trong trường hợp dương tính giả: (1) _testcó thể là null, vì nếu không thể, bạn sẽ không so sánh ở vị trí đầu tiên và (2) isNullcó thể đúng hoặc sai - bởi vì nếu nó không thể, bạn sẽ không có nó trong một if. Nhưng kết nối return _test;duy nhất chạy nếu _testkhông phải là null, kết nối đó sẽ không được thực hiện.

Đây là một vấn đề phức tạp đáng ngạc nhiên, và bạn nên hy vọng rằng sẽ cần một thời gian để trình biên dịch đạt được sự tinh vi của các công cụ đã có nhiều năm làm việc của các chuyên gia. Ví dụ, trình kiểm tra lưu lượng Coverity sẽ không có vấn đề gì trong việc suy luận rằng cả hai biến thể của bạn đều không có lợi nhuận, nhưng trình kiểm tra lưu lượng Coverity chi phí tiền nghiêm trọng cho khách hàng doanh nghiệp.

Ngoài ra, trình kiểm tra Coverity được thiết kế để chạy trên các cơ sở mã lớn qua đêm ; phân tích của trình biên dịch C # phải chạy giữa các lần nhấn phím trong trình chỉnh sửa , điều này thay đổi đáng kể các loại phân tích chuyên sâu mà bạn có thể thực hiện một cách hợp lý.


"Không phức tạp" là đúng - Tôi cho rằng nó có thể tha thứ nếu nó vấp phải những thứ như điều kiện, vì tất cả chúng ta đều biết vấn đề tạm dừng là một chút khó khăn trong những vấn đề như vậy, nhưng thực tế là có một sự khác biệt giữa bool b = x != nullvs bool b = x is { }(với không phân công thực sự được sử dụng!) cho thấy ngay cả các mẫu được công nhận cho kiểm tra null là nghi vấn. Không chê bai công việc khó khăn chắc chắn của nhóm để làm cho công việc này chủ yếu là như vậy đối với các cơ sở mã thực tế, đang sử dụng - có vẻ như phân tích là vốn thực dụng.
Jeroen Mostert

@JeroenMostert: Jared Par đề cập trong một bình luận về câu trả lời của Jon Skeet rằng Microsoft đang thảo luận về vấn đề đó trong nội bộ.
Brian

8

Tất cả các câu trả lời khác là khá chính xác chính xác.

Trong trường hợp có ai tò mò, tôi đã cố gắng đánh vần logic của trình biên dịch một cách rõ ràng nhất có thể trong https://github.com/dotnet/roslyn/issues/36927#issuecomment-508595947

Điều duy nhất không được đề cập là cách chúng tôi quyết định xem kiểm tra null có được coi là "thuần túy" hay không, theo nghĩa là nếu bạn làm điều đó, chúng tôi nên nghiêm túc xem xét liệu null có phải là một khả năng hay không. Có rất nhiều kiểm tra null "ngẫu nhiên" trong C #, trong đó bạn kiểm tra null là một phần của việc khác, vì vậy chúng tôi quyết định rằng chúng tôi muốn thu hẹp bộ séc mà chúng tôi chắc chắn mọi người đang cố tình làm. Các heuristic chúng tôi đã đưa ra là "chứa từ null", vì vậy đó là lý do x != nullx is objecttạo ra kết quả khác nhau.

Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.