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


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.