Tò mò hành vi chuyển đổi ngầm ẩn của toán tử hợp nhất


542

Lưu ý: điều này dường như đã được sửa ở Roslyn

Câu hỏi này xuất hiện khi viết câu trả lời của tôi cho câu hỏi này , trong đó nói về sự kết hợp của toán tử hợp nhất null .

Cũng như một lời nhắc nhở, ý tưởng của toán tử hợp nhất null là biểu thức của biểu mẫu

x ?? y

đầu tiên đánh giá x, sau đó:

  • Nếu giá trị xlà null, yđược ước tính và đó là kết quả cuối cùng của biểu thức
  • Nếu giá trị của xlà không null, ykhông được đánh giá, và giá trị của xlà kết quả cuối cùng của biểu thức, sau khi chuyển đổi các loại thời gian biên dịch ynếu cần thiết

Bây giờ thường không cần chuyển đổi, hoặc chỉ là từ loại không thể thành loại không thể chuyển đổi - thường là các loại giống nhau hoặc chỉ từ (nói) int?thành int. Tuy nhiên, bạn có thể tạo các toán tử chuyển đổi ẩn của riêng mình và các toán tử chuyển đổi được sử dụng khi cần thiết.

Đối với trường hợp đơn giản x ?? y, tôi chưa thấy bất kỳ hành vi kỳ quặc nào. Tuy nhiên, với (x ?? y) ?? ztôi thấy một số hành vi khó hiểu.

Đây là một chương trình thử nghiệm ngắn nhưng đầy đủ - kết quả có trong các bình luận:

using System;

public struct A
{
    public static implicit operator B(A input)
    {
        Console.WriteLine("A to B");
        return new B();
    }

    public static implicit operator C(A input)
    {
        Console.WriteLine("A to C");
        return new C();
    }
}

public struct B
{
    public static implicit operator C(B input)
    {
        Console.WriteLine("B to C");
        return new C();
    }
}

public struct C {}

class Test
{
    static void Main()
    {
        A? x = new A();
        B? y = new B();
        C? z = new C();
        C zNotNull = new C();

        Console.WriteLine("First case");
        // This prints
        // A to B
        // A to B
        // B to C
        C? first = (x ?? y) ?? z;

        Console.WriteLine("Second case");
        // This prints
        // A to B
        // B to C
        var tmp = x ?? y;
        C? second = tmp ?? z;

        Console.WriteLine("Third case");
        // This prints
        // A to B
        // B to C
        C? third = (x ?? y) ?? zNotNull;
    }
}

Vì vậy, chúng tôi có ba loại giá trị tùy chỉnh, A, BC, với các chuyển đổi từ A đến B, A đến C, và B đến C.

Tôi có thể hiểu cả trường hợp thứ hai và trường hợp thứ ba ... nhưng tại sao lại có thêm chuyển đổi từ A sang B trong trường hợp đầu tiên? Đặc biệt, tôi thực sự đã mong đợi trường hợp đầu tiên và trường hợp thứ hai giống nhau - đó chỉ là trích xuất một biểu thức thành một biến cục bộ.

Bất kỳ người tham gia vào những gì đang xảy ra? Tôi vô cùng ngần ngại khi khóc "lỗi" khi nói đến trình biên dịch C #, nhưng tôi đã bối rối vì những gì đang diễn ra ...

EDIT: Được rồi, đây là một ví dụ khó hiểu hơn về những gì đang diễn ra, nhờ câu trả lời của nhà cấu hình, điều này cho tôi thêm lý do để nghĩ rằng đó là một lỗi. EDIT: Mẫu thậm chí không cần hai toán tử hợp nhất null bây giờ ...

using System;

public struct A
{
    public static implicit operator int(A input)
    {
        Console.WriteLine("A to int");
        return 10;
    }
}

class Test
{
    static A? Foo()
    {
        Console.WriteLine("Foo() called");
        return new A();
    }

    static void Main()
    {
        int? y = 10;

        int? result = Foo() ?? y;
    }
}

Đầu ra của nó là:

Foo() called
Foo() called
A to int

Thực tế Foo()được gọi hai lần ở đây là vô cùng ngạc nhiên đối với tôi - tôi không thể thấy bất kỳ lý do nào để biểu thức được đánh giá hai lần.


32
Tôi cá là họ đã nghĩ rằng "không ai sẽ sử dụng nó theo cách đó" :)
đe doạ trực tuyến vào

57
Bạn muốn thấy một cái gì đó tồi tệ hơn? Hãy thử sử dụng dòng này với tất cả các chuyển đổi ngầm : C? first = ((B?)(((B?)x) ?? ((B?)y))) ?? ((C?)z);. Bạn sẽ nhận được:Internal Compiler Error: likely culprit is 'CODEGEN'
cấu hình

5
Cũng lưu ý rằng điều này không xảy ra khi sử dụng biểu thức Linq để biên dịch cùng mã.
cấu hình

8
@Peter mẫu không chắc chắn, nhưng hợp lý cho(("working value" ?? "user default") ?? "system default")
Yếu tố huyền bí

23
@ yes123: Khi nó chỉ xử lý việc chuyển đổi, tôi không hoàn toàn bị thuyết phục. Nhìn thấy nó thực thi một phương thức hai lần, nó khá rõ ràng đây là một lỗi. Bạn sẽ ngạc nhiên về một số hành vi có vẻ không đúng nhưng thực sự hoàn toàn chính xác. Nhóm C # thông minh hơn tôi - Tôi có xu hướng cho rằng mình thật ngu ngốc cho đến khi tôi chứng minh rằng có gì đó là lỗi của họ.
Jon Skeet

Câu trả lời:


418

Cảm ơn tất cả những người đã góp phần phân tích vấn đề này. Nó rõ ràng là một lỗi biên dịch. Nó dường như chỉ xảy ra khi có một chuyển đổi được nâng lên liên quan đến hai loại nullable ở phía bên trái của toán tử liên kết.

Tôi vẫn chưa xác định chính xác mọi thứ sai ở đâu, nhưng tại một số điểm trong giai đoạn biên dịch "hạ thấp không thể" - sau khi phân tích ban đầu nhưng trước khi tạo mã - chúng tôi giảm biểu thức

result = Foo() ?? y;

từ ví dụ trên đến tương đương đạo đức của:

A? temp = Foo();
result = temp.HasValue ? 
    new int?(A.op_implicit(Foo().Value)) : 
    y;

Rõ ràng đó là không chính xác; hạ thấp chính xác là

result = temp.HasValue ? 
    new int?(A.op_implicit(temp.Value)) : 
    y;

Dự đoán tốt nhất của tôi dựa trên phân tích của tôi cho đến nay là trình tối ưu hóa vô giá trị đang đi ra khỏi đường ray ở đây. Chúng tôi có một trình tối ưu hóa nullable tìm kiếm các tình huống mà chúng tôi biết rằng một biểu thức cụ thể của loại nullable có thể có thể là null. Hãy xem xét các phân tích ngây thơ sau đây: trước tiên chúng ta có thể nói rằng

result = Foo() ?? y;

giống như

A? temp = Foo();
result = temp.HasValue ? 
    (int?) temp : 
    y;

và sau đó chúng ta có thể nói rằng

conversionResult = (int?) temp 

giống như

A? temp2 = temp;
conversionResult = temp2.HasValue ? 
    new int?(op_Implicit(temp2.Value)) : 
    (int?) null

Nhưng trình tối ưu hóa có thể bước vào và nói "whoa, đợi một chút, chúng tôi đã kiểm tra rằng temp không phải là null; không cần phải kiểm tra nó lần thứ hai chỉ vì chúng tôi đang gọi một toán tử chuyển đổi nâng". Chúng tôi sẽ tối ưu hóa nó đi

new int?(op_Implicit(temp2.Value)) 

Tôi đoán là chúng ta đang ở đâu đó lưu trữ thực tế rằng hình thức tối ưu hóa (int?)Foo()new int?(op_implicit(Foo().Value))nhưng đó không thực sự là hình thức tối ưu hóa mà chúng ta muốn; chúng tôi muốn hình thức tối ưu hóa của Foo () - được thay thế bằng tạm thời và sau đó được chuyển đổi.

Nhiều lỗi trong trình biên dịch C # là kết quả của các quyết định bộ nhớ đệm xấu. Một lời cho khôn ngoan: mỗi khi bạn lưu trữ một thực tế để sử dụng sau này, bạn có khả năng tạo ra sự không nhất quán nếu có gì đó thay đổi có liên quan . Trong trường hợp này, điều liên quan đã thay đổi phân tích ban đầu của bài viết là cuộc gọi đến Foo () phải luôn luôn được nhận ra dưới dạng tìm nạp tạm thời.

Chúng tôi đã thực hiện rất nhiều lần sắp xếp lại đường chuyền viết lại nullable trong C # 3.0. Lỗi sinh sản trong C # 3.0 và 4.0 nhưng không phải trong C # 2.0, điều đó có nghĩa là lỗi có lẽ là lỗi của tôi. Lấy làm tiếc!

Tôi sẽ gặp một lỗi được nhập vào cơ sở dữ liệu và chúng tôi sẽ xem liệu chúng tôi có thể sửa lỗi này cho phiên bản ngôn ngữ trong tương lai không. Cảm ơn một lần nữa mọi người đã phân tích của bạn; nó rất hữu ích

CẬP NHẬT: Tôi viết lại trình tối ưu hóa nullable từ đầu cho Roslyn; bây giờ nó làm một công việc tốt hơn và tránh những loại lỗi kỳ lạ. Để biết một số suy nghĩ về cách trình tối ưu hóa trong Roslyn hoạt động, hãy xem loạt bài viết của tôi bắt đầu tại đây: https://ericlippert.com/2012/12/20/nullable-micro-optimizes-part-one/


1
@Eric Tôi tự hỏi nếu điều này cũng sẽ giải thích: connect.microsoft.com/VisualStudio/feedback/details/642227
MarkPflug

12
Bây giờ tôi đã có Bản xem trước người dùng cuối của Roslyn, tôi có thể xác nhận rằng nó đã được sửa ở đó. (Mặc dù vậy, nó vẫn hiện diện trong trình biên dịch C # 5 gốc.)
Jon Skeet

84

Đây chắc chắn là một lỗi.

public class Program {
    static A? X() {
        Console.WriteLine("X()");
        return new A();
    }
    static B? Y() {
        Console.WriteLine("Y()");
        return new B();
    }
    static C? Z() {
        Console.WriteLine("Z()");
        return new C();
    }

    public static void Main() {
        C? test = (X() ?? Y()) ?? Z();
    }
}

Mã này sẽ xuất ra:

X()
X()
A to B (0)
X()
X()
A to B (0)
B to C (0)

Điều đó khiến tôi nghĩ rằng phần đầu tiên của mỗi ??biểu thức hợp nhất được đánh giá hai lần. Mã này đã chứng minh điều đó:

B? test= (X() ?? Y());

đầu ra:

X()
X()
A to B (0)

Điều này dường như chỉ xảy ra khi biểu thức yêu cầu chuyển đổi giữa hai loại nullable; Tôi đã thử các hoán vị khác nhau với một trong các mặt là một chuỗi và không ai trong số chúng gây ra hành vi này.


11
Wow - đánh giá biểu thức hai lần có vẻ rất sai. Cũng phát hiện ra.
Jon Skeet

Sẽ đơn giản hơn một chút để xem nếu bạn chỉ có một cuộc gọi phương thức trong nguồn - nhưng điều đó vẫn thể hiện điều đó rất rõ ràng.
Jon Skeet

2
Tôi đã thêm một ví dụ đơn giản hơn về "đánh giá kép" này cho câu hỏi của mình.
Jon Skeet

8
Có phải tất cả các phương thức của bạn được cho là xuất ra "X ()"? Nó làm cho nó hơi khó để nói phương pháp nào thực sự xuất ra trên bàn điều khiển.
jeffora

2
Nó dường như X() ?? Y()mở rộng trong nội bộ X() != null ? X() : Y(), do đó tại sao nó sẽ được đánh giá hai lần.
Cole Johnson

54

Nếu bạn xem mã được tạo cho trường hợp nhóm trái, nó thực sự làm một cái gì đó như thế này ( csc /optimize-):

C? first;
A? atemp = a;
B? btemp = (atemp.HasValue ? new B?(a.Value) : b);
if (btemp.HasValue)
{
    first = new C?((atemp.HasValue ? new B?(a.Value) : b).Value);
}

Một tìm kiếm khác, nếu bạn sử dụng first nó sẽ tạo ra một phím tắt nếu cả hai abđều null và trả về c. Tuy nhiên, nếu ahoặc bkhông có giá trị, nó sẽ đánh giá lại anhư là một phần của chuyển đổi ngầm định Btrước khi trả về giá trị nào của ahoặc bkhông phải là null.

Từ Thông số kỹ thuật C # 4.0, §6.1.4:

  • Nếu chuyển đổi nullable là từ S?đến T?:
    • Nếu giá trị nguồn là null(thuộc HasValuetính false), kết quả là nullgiá trị của loại T?.
    • Mặt khác, chuyển đổi được đánh giá là hủy ghép nối từ S?sang S, theo sau là chuyển đổi cơ bản từ Ssang T, theo sau là gói (§4.1.10) từ Tđến T?.

Điều này xuất hiện để giải thích sự kết hợp mở gói thứ hai.


Trình biên dịch C # 2008 và 2010 tạo ra mã rất giống nhau, tuy nhiên điều này trông giống như một hồi quy từ trình biên dịch C # 2005 (8,00.50727,4927) tạo ra mã sau đây cho đoạn trên:

A? a = x;
B? b = a.HasValue ? new B?(a.GetValueOrDefault()) : y;
C? first = b.HasValue ? new C?(b.GetValueOrDefault()) : z;

Tôi tự hỏi nếu điều này không phải là do phép thuật bổ sung được đưa ra cho hệ thống suy luận kiểu?


+1, nhưng tôi không nghĩ nó thực sự giải thích tại sao việc chuyển đổi được thực hiện hai lần. Chỉ nên đánh giá biểu thức một lần, IMO.
Jon Skeet

@Jon: Tôi đã chơi xung quanh và thấy (như @configurator đã làm) rằng khi được thực hiện trong Cây biểu hiện, nó hoạt động như mong đợi. Làm việc để làm sạch các biểu thức để thêm nó vào bài viết của tôi. Tôi sẽ phải khẳng định rằng đây là một "lỗi".
user7116

@Jon: ok khi sử dụng Cây biểu hiện, nó biến (x ?? y) ?? zthành lambdas lồng nhau, đảm bảo đánh giá theo thứ tự mà không cần đánh giá kép. Đây rõ ràng không phải là cách tiếp cận được thực hiện bởi trình biên dịch C # 4.0. Từ những gì tôi có thể nói, phần 6.1.4 được tiếp cận một cách rất nghiêm ngặt trong đường dẫn mã cụ thể này và các tạm thời không bị bỏ qua dẫn đến việc đánh giá kép.
user7116

16

Trên thực tế, bây giờ tôi sẽ gọi đây là một lỗi, với ví dụ rõ ràng hơn. Điều này vẫn giữ, nhưng đánh giá kép chắc chắn là không tốt.

Có vẻ như A ?? Bđược thực hiện như A.HasValue ? A : B. Trong trường hợp này, cũng có rất nhiều lần truyền (theo sau quá trình đúc thông thường cho toán ?:tử ternary ). Nhưng nếu bạn bỏ qua tất cả, thì điều này có ý nghĩa dựa trên cách nó được thực hiện:

  1. A ?? B mở rộng đến A.HasValue ? A : B
  2. Alà của chúng tôi x ?? y. Mở rộng tớix.HasValue : x ? y
  3. thay thế tất cả các lần xuất hiện của A -> (x.HasValue : x ? y).HasValue ? (x.HasValue : x ? y) : B

Ở đây bạn có thể thấy rằng x.HasValueđược kiểm tra hai lần và nếu x ?? yyêu cầu đúc, xsẽ được đúc hai lần.

Tôi chỉ đơn giản là đưa nó xuống như một sự giả tạo về cách ??triển khai, chứ không phải là một lỗi biên dịch. Take-Away: Không tạo toán tử đúc ngầm với các tác dụng phụ.

Nó dường như là một lỗi trình biên dịch xoay quanh cách ??thực hiện. Take-Away: không lồng các biểu thức kết hợp với các tác dụng phụ.


Ồ tôi chắc chắn sẽ không muốn sử dụng mã như thế này một cách bình thường, nhưng tôi nghĩ nó vẫn có thể được phân loại là lỗi trình biên dịch trong bản mở rộng đầu tiên của bạn nên bao gồm "nhưng chỉ đánh giá A và B một lần". (Hãy tưởng tượng nếu chúng là các cuộc gọi phương thức.)
Jon Skeet

@ Tôi đồng ý rằng nó cũng có thể như vậy - nhưng tôi sẽ không gọi nó là rõ ràng. Chà, thực sự, tôi có thể thấy rằng A() ? A() : B()có thể sẽ đánh giá A()hai lần, nhưng A() ?? B()không nhiều lắm. Và vì nó chỉ xảy ra khi casting ... Hmm .. Tôi vừa nói vừa nghĩ rằng nó chắc chắn không hoạt động chính xác.
Philip Rieck

10

Tôi hoàn toàn không phải là chuyên gia về C # như bạn có thể thấy trong lịch sử câu hỏi của mình, nhưng, tôi đã thử nó và tôi nghĩ đó là một lỗi .... nhưng là một người mới, tôi phải nói rằng tôi không hiểu mọi thứ đang diễn ra ở đây vì vậy tôi sẽ xóa câu trả lời của mình nếu tôi tắt.

Tôi đã đến đây bug kết luận bằng cách tạo ra một phiên bản khác của chương trình của bạn có cùng kịch bản, nhưng ít phức tạp hơn nhiều.

Tôi đang sử dụng ba thuộc tính số nguyên null với các cửa hàng sao lưu. Tôi đặt từng cái thành 4 rồi chạyint? something2 = (A ?? B) ?? C;

( Mã đầy đủ ở đây )

Điều này chỉ đọc A và không có gì khác.

Câu nói này với tôi có vẻ như đối với tôi nó nên:

  1. Bắt đầu trong ngoặc, nhìn vào A, trả về A và kết thúc nếu A không rỗng.
  2. Nếu A là null, hãy đánh giá B, kết thúc nếu B không null
  3. Nếu A và B là null, hãy đánh giá C.

Vì vậy, vì A không phải là null, nó chỉ nhìn vào A và kết thúc.

Trong ví dụ của bạn, đặt điểm dừng ở Trường hợp thứ nhất cho thấy x, y và z đều không có giá trị và do đó, tôi hy vọng chúng sẽ được đối xử giống như ví dụ ít phức tạp hơn của tôi .... nhưng tôi sợ tôi quá nhiều của một người mới C # và đã hoàn toàn bỏ lỡ điểm của câu hỏi này!


5
Ví dụ của Jon là một trường hợp góc tối nghĩa ở chỗ anh ta đang sử dụng một cấu trúc không thể (một loại giá trị "tương tự" với các loại tích hợp như một int). Anh ta đẩy vụ việc hơn nữa vào một góc khuất bằng cách cung cấp nhiều chuyển đổi kiểu ẩn. Điều này đòi hỏi trình biên dịch thay đổi loại dữ liệu trong khi kiểm tra lại null. Chính vì những chuyển đổi kiểu ngầm định này mà ví dụ của anh ta khác với bạn.
user7116
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.