Цікаве непряме поведінка конверсії у користувальницькому операторі


542

Примітка. Схоже, це було виправлено у Росліні

Це запитання виникло під час написання моєї відповіді на цю , в якій йдеться про асоціативність оператора, що поєднує нуль .

Як нагадування, ідея оператора узгодження нуля - це вираз форми

x ?? y

спочатку оцінює x, потім:

  • Якщо значення xnull, yоцінюється, і це кінцевий результат виразу
  • Якщо значення xне дорівнює нуль, yце НЕ оцінюються, а значення xє кінцевим результатом виразу, після перетворення до типу компіляції часу , yякщо це необхідно

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

Для простого випадку x ?? yя не бачив жодної дивної поведінки. Однак, (x ?? y) ?? zя бачу деяку заплутану поведінку.

Ось коротка, але повна програма тестування - результати - у коментарях:

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

Таким чином, у нас є три типи спеціальних значень A, Bі Cз перетвореннями від A до B, A до C і B до C.

Я можу зрозуміти і другий випадок, і третій випадок ... але чому в першому випадку є додаткове перетворення від A до B? Зокрема, я дійсно чекав перший випадок і другий випадок бути те ж саме - це просто витягуючи вираз в локальну змінну, в кінці кінців.

Хтось приймає те, що відбувається? Я вкрай не вагаюся плакати "помилку", коли справа доходить до компілятора C #, але я наткнувся на те, що відбувається ...

EDIT: Гаразд, ось вам більш неприємний приклад того, що відбувається, завдяки відповіді конфігуратора, що дає мені додаткову причину думати, що це помилка. EDIT: Зразок зараз навіть не потребує двох операторів з узгодженням нуля ...

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

Результатом цього є:

Foo() called
Foo() called
A to int

Те, що Foo()дзвонять сюди двічі, мене надзвичайно дивує - я не бачу жодної причини, щоб вираз оцінювався двічі.


32
Б'юсь об заклад, що вони думали, що "ніхто ніколи не використає це таким чином" :)
cyberzed

57
Хочете побачити щось гірше? Спробуйте використовувати цю лінію з усіма неявними перетвореннями: C? first = ((B?)(((B?)x) ?? ((B?)y))) ?? ((C?)z);. Ви отримаєте:Internal Compiler Error: likely culprit is 'CODEGEN'
конфігуратор

5
Також зауважте, що цього не відбувається при використанні виразів Linq для складання одного і того ж коду.
конфігуратор

8
@ Петер навряд чи візерунок, але правдоподібний для(("working value" ?? "user default") ?? "system default")
Фактор Містик

23
@ yes123: Коли мова йшла лише про конверсію, я не був повністю переконаний. Побачивши його виконувати метод двічі, це стало досить очевидним, що це помилка. Ви були б вражені деякою поведінкою, яка виглядає неправильно, але насправді є абсолютно правильною. Команда C # розумніша за мене - я схильний вважати, що я дурний, поки не довів, що щось є їх виною.
Джон Скіт

Відповіді:


418

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

Я ще не визначив, де саме все піде не так, але в якийсь момент під час фази компіляції "нульового зниження" - після початкового аналізу, але до генерації коду - ми зменшуємо вираження

result = Foo() ?? y;

від наведеного вище прикладу до морального еквівалента:

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

Очевидно, що це неправильно; правильне опускання

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

Моя найкраща здогадка, що базується на моєму аналізі до цього часу, полягає в тому, що незмінний оптимізатор тут йде з рейок. У нас є оптимізатор зворотних змін, який шукає ситуації, коли ми знаємо, що певний вираз зведеного типу не може бути нульовим. Розглянемо наступний наївний аналіз: ми можемо спочатку сказати це

result = Foo() ?? y;

те саме, що

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

і тоді ми можемо це сказати

conversionResult = (int?) temp 

те саме, що

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

Але оптимізатор може вступити і сказати: «ось, почекай хвилину, ми вже перевірили, що темп не є нульовим; немає необхідності перевіряти його на нуль вдруге лише тому, що ми викликаємо знятого оператора перетворення». Ми б оптимізували їх просто для простого

new int?(op_Implicit(temp2.Value)) 

Я здогадуюсь, що ми десь кешуємо той факт, що оптимізована форма (int?)Foo()є, new int?(op_implicit(Foo().Value))але це насправді не оптимізована форма, яку ми хочемо; ми хочемо, щоб оптимізована форма Foo () - замінена-з-тимчасовою, а потім перетвореною.

Багато помилок у компіляторі C # є результатом неправильних кешованих рішень. Слово для розумних: кожного разу, коли ви кешуєте факт для подальшого використання, ви потенційно створюєте невідповідність, якщо щось відповідне зміниться . У цьому випадку важливим моментом, що змінився після початкового аналізу, є те, що виклик Foo () завжди повинен бути реалізований як тимчасовий вибір.

Ми багато зробили реорганізацію нульового переписувального пропуску в C # 3.0. Помилка відтворюється в C # 3.0 та 4.0, але не в C # 2.0, а це означає, що помилка, ймовірно, була моєю поганою. Вибачте!

Я отримаю помилку в базі даних і побачимо, чи зможемо це виправити для майбутньої версії мови. Ще раз дякую всім за ваш аналіз; це було дуже корисно!

ОНОВЛЕННЯ: я переписав нульовий оптимізатор з нуля для Roslyn; тепер це робить кращу роботу і уникає подібних дивних помилок. Деякі думки про те, як працює оптимізатор у Росліні, дивіться у моїй серії статей, яка починається тут: https://ericlippert.com/2012/12/20/nullable-micro-optimizations-part-one/


1
@Eric Цікаво, чи це також пояснить: connect.microsoft.com/VisualStudio/feedback/details/642227
MarkPflug

12
Тепер, коли у мене є попередній перегляд кінцевого користувача Roslyn, я можу підтвердити, що він там виправлений. (Він все ще присутній у рідному компіляторі C # 5.)
Джон Скіт,

84

Це, безумовно, помилка.

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

Цей код виведе:

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

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

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

Виходи:

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

Це, здається, відбувається лише тоді, коли вираз вимагає перетворення між двома нульовими типами; Я пробував різні перестановки, коли одна з сторін була струною, і жодна з них не викликала такої поведінки.


11
Нічого собі - оцінювати вираз двічі справді здається дуже помилковим. Добре помічений.
Джон Скіт

Трохи простіше зрозуміти, чи є у вас лише один виклик методу у джерелі - але це все ще демонструє дуже чітко.
Джон Скіт

2
До свого запитання я додав дещо простіший приклад цього "подвійного оцінювання".
Джон Скіт

8
Чи всі ваші методи повинні виводити "X ()"? Це дещо важко сказати, який метод насправді виводиться на консоль.
jeffora

2
Здається, вона X() ?? Y()розширюється всередині Росії X() != null ? X() : Y(), отже, чому це було б оцінено двічі.
Коул Джонсон

54

Якщо ви подивитеся на згенерований код для корпусу зліва, він фактично робить щось подібне ( 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);
}

Інша знахідка, якщо ви її використовуєте first , створить ярлик, якщо обидва aі bє нульовими, і повертаються c. Тим НЕ менше , якщо aі bє ненульовим він переоцінює aяк частина неявного перетворення в Bперед поверненням з яких aабо bне дорівнює нулю.

З специфікації C # 4.0, §6.1.4:

  • Якщо обнуляє перетворення від S?до T?:
    • Якщо значенням джерела є null( HasValueвластивість є false), результатом є nullзначення типу T?.
    • В іншому випадку перетворення оцінюється як розгортання з S?на S, з подальшим основним перетворенням з Sв T, з подальшим обгортанням (§4.1.10) від Tдо T?.

Це, мабуть, пояснює другу комбіновану розмотувальну комбінацію.


Компілятор C # 2008 та 2010 видає дуже схожий код, однак це виглядає як регресія компілятора C # 2005 (8.00.50727.4927), що генерує наступний код для вищезазначеного:

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

Цікаво, чи це не пов’язано з додатковою магією, наданою системі виводу типу?


+1, але я не думаю, що це дійсно пояснює, чому конверсія виконується двічі. Слід лише один раз оцінити вираз, ІМО.
Джон Скіт

@Jon: Я розігрувався і встановив (як @configurator), що коли робиться в дереві виразів, він працює як слід. Працюю над очищенням виразів, щоб додати його до моєї публікації. Мені б тоді доводилося говорити, що це "помилка".
user7116

@Jon: гаразд при використанні виразних дерев він перетворюється (x ?? y) ?? zна вкладені лямбда, що забезпечує оцінку порядку без подвійної оцінки. Очевидно, це не підхід компілятора C # 4.0. З того, що я можу сказати, розділ 6.1.4 підходить дуже суворо в цьому конкретному кодовому шляху, і тимчасові періоди не відхиляються в результаті подвійної оцінки.
user7116

16

Насправді я зараз назву цю помилку, яснішим прикладом. Це все ще справедливо, але подвійна оцінка, безумовно, не є хорошою.

Схоже, A ?? Bреалізується як A.HasValue ? A : B. У цьому випадку також є багато кастингу (після регулярного кастингу для термального ?:оператора). Але якщо ви все це ігноруєте, то це має сенс, виходячи з того, як це реалізовано:

  1. A ?? B розширюється до A.HasValue ? A : B
  2. Aце наше x ?? y. Розгорнути наx.HasValue : x ? y
  3. замінити всі випадки A -> (x.HasValue : x ? y).HasValue ? (x.HasValue : x ? y) : B

Тут ви бачите, що x.HasValueперевіряється двічі, а якщо x ?? yпотрібно кастинг, xбуде передано двічі.

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

Здається, це помилка компілятора, яка обертається навколо того, як ??реалізується. Взяття: не гніздиться, поєднуючи вирази з побічними ефектами.


Про , я безумовно не хотів би використовувати код , як це зазвичай, але я думаю , що це може все ще бути класифікований як компілятор помилка в тому , що ваше перше розширення повинно включати в себе «але тільки оцінках A і B один раз». (Уявіть собі, якби це були виклики методу.)
Джон Скіт

@Jon Я погоджуюся, що це могло бути і так - але я б не назвав це чітко. Ну, насправді, я бачу, що A() ? A() : B(), можливо, оцінюватимуть A()двічі, але A() ?? B()не так багато. А оскільки це відбувається лише на кастингу ... Хм .. Я щойно говорив про те, що думаю, це, безумовно, не правильно.
Філіп Рік

10

Я взагалі не експерт з C #, як ви бачите з моєї історії запитань, але, я спробував це, і я думаю, що це помилка .... але, як новачок, я повинен сказати, що я не розумію все, що відбувається тут, тож я видалю свою відповідь, якщо я відволікаюся.

Я прийшов до такого bugвисновку, зробивши іншу версію вашої програми, яка стосується того самого сценарію, але набагато менш складна.

Я використовую три нульові цілі властивості із резервними магазинами. Я встановлюю кожен на 4, а потім запускаюint? something2 = (A ?? B) ?? C;

( Повний код тут )

Це просто читає A і більше нічого.

Це твердження мені здається, що воно повинно:

  1. Почніть в дужках, подивіться на A, поверніть A і закінчіть, якщо A не є нульовим.
  2. Якщо A було нульовим, оцініть B, закінчіть, якщо B не є нульовим
  3. Якщо A і B були недійсними, оцініть C.

Отже, оскільки A не є нульовим, він дивиться лише на A і закінчує.

У вашому прикладі, якщо встановити точку перерви у "Першому випадку", видно, що x, y і z не є нульовими, і, отже, я б очікував, що до них слід поводитися так само, як мій менш складний приклад .... але я боюся, що я занадто багато новачка C # і повністю пропустили суть цього питання!


5
Приклад Джона - дещо незрозумілий кутовий випадок, оскільки він використовує нульову структуру (тип значення, який "схожий" на вбудовані типи, як int). Він штовхає справу далі в незрозумілий кут, забезпечуючи кілька непрямих перетворень типу. Це вимагає від компілятора змінити тип даних під час перевірки null. Саме через ці неявні перетворення типу його приклад відрізняється від вашого.
user7116
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.