Ограничения оператора переключения C # - почему?


143

При написании оператора switch есть два ограничения на то, что вы можете включить в операторах case.

Например (и да, я знаю, что если вы делаете такие вещи, это, вероятно, означает, что ваша объектно-ориентированная (OO) архитектура ненадежна - это просто надуманный пример!),

  Type t = typeof(int);

  switch (t) {

    case typeof(int):
      Console.WriteLine("int!");
      break;

    case typeof(string):
      Console.WriteLine("string!");
      break;

    default:
      Console.WriteLine("unknown!");
      break;
  }

Здесь оператор switch () терпит неудачу с «Ожидается значение целого типа», а операторы case терпят неудачу с «Ожидается постоянное значение».

Почему существуют эти ограничения и каково их обоснование? Я не вижу причин, по которым оператор switch должен поддаваться только статическому анализу и почему включаемое значение должно быть целым (то есть примитивным). Какое оправдание?


1
См. Возможное обходное решение. Есть ли лучшая альтернатива «включить тип»?
Майкл

Другой вариант для включения встроенных типов - использовать TypeCode Enum .
Эрик Филипс

Просто создайте ENUM и используйте NameOf в случае Switch. Он будет работать как постоянный с динамической переменной.
Vaibhav.Inspired

Ответы:


102

Это мой оригинальный пост, который вызвал некоторые дискуссии ... потому что он неверен :

Оператор switch - это не то же самое, что большой оператор if-else. Каждый случай должен быть уникальным и оцениваться статически. Оператор switch выполняет постоянную временную ветвь независимо от того, сколько случаев у вас есть. Оператор if-else оценивает каждое условие, пока не найдет истинное.


Фактически, оператор switch в C # не всегда является ветвью с постоянным временем.

В некоторых случаях компилятор будет использовать оператор переключения CIL, который действительно является ветвью с постоянным временем с использованием таблицы переходов. Однако в редких случаях, как указал Иван Гамильтон, компилятор может генерировать что-то совершенно другое.

На самом деле это довольно легко проверить, написав различные операторы переключения C #, некоторые разреженные, некоторые плотные, и посмотрев на полученный CIL с помощью инструмента ildasm.exe.


4
Как отмечено в других ответах (включая мой), утверждения, сделанные в этом ответе, неверны. Я бы рекомендовал удалить (хотя бы для того, чтобы избежать этого (вероятно, распространенного) заблуждения).
mweerden

Пожалуйста, посмотрите мой пост ниже, где я убедительно показываю, что, по моему мнению, оператор switch выполняет постоянную временную ветвь.
Брайан Энсинк,

Большое спасибо за ответ, Брайан. См. Ответ Ивана Гамильтона ((48259) [ beta.stackoverflow.com/questions/44905/#48259] ). Вкратце: вы говорите об switch инструкции (CIL), которая не совпадает с switchинструкцией C #.
mweerden

Я также не верю, что компилятор генерирует ветвление с постоянным временем при включении строк.
Дрю Ноукс

Применимо ли это к сопоставлению с образцом в операторах switch case в C # 7.0?
Б. Даррен Олсон

114

Важно не путать оператор переключения C # с инструкцией переключения CIL.

Переключатель CIL - это таблица переходов, для которой требуется индекс в наборе адресов перехода.

Это полезно только в том случае, если случаи переключателя C # находятся рядом:

case 3: blah; break;
case 4: blah; break;
case 5: blah; break;

Но бесполезно, если их нет:

case 10: blah; break;
case 200: blah; break;
case 3000: blah; break;

(Вам понадобится таблица размером ~ 3000 записей, всего с 3 используемыми ячейками)

С несмежными выражениями компилятор может начать выполнять линейные проверки if-else-if-else.

С большими несмежными наборами выражений компилятор может начать с поиска по двоичному дереву и, наконец, с нескольких последних элементов if-else-if-else.

С наборами выражений, содержащими группы соседних элементов, компилятор может выполнять поиск по двоичному дереву и, наконец, переключать CIL.

Это полно "майя" и "майя", и это зависит от компилятора (может отличаться от Mono или Rotor).

Я воспроизвел ваши результаты на своей машине, используя соседние случаи:

общее время выполнения 10-позиционного переключателя, 10000 итераций (мс): 25,1383
приблизительное время на 10-позиционный переключатель (мс): 0,00251383

общее время выполнения 50-позиционного переключателя, 10000 итераций (мс): 26,593
приблизительное время на 50-позиционный переключатель (мс): 0,0026593

общее время выполнения 5000-позиционного переключателя, 10000 итераций (мс): 23,7094
приблизительное время на 5000-позиционный переключатель (мс): 0,00237094

общее время выполнения переключения на 50000 путей, 10000 итераций (мс): 20,0933
приблизительное время на переключение на 50000 направлений (мс): 0,00200933

Затем я также использовал несмежные выражения:

общее время выполнения 10-позиционного переключателя, 10000 итераций (мс): 19,6189
приблизительное время на 10-позиционный переключатель (мс): 0,00196189

общее время выполнения 500-позиционного переключателя, 10000 итераций (мс): 19,1664
приблизительное время на 500-позиционный переключатель (мс): 0,00191664

общее время выполнения 5000-позиционного переключателя, 10000 итераций (мс): 19,5871
приблизительное время на 5000-позиционный переключатель (мс): 0,00195871

Несмежный оператор переключения регистра 50 000 не будет компилироваться.
"Выражение слишком длинное или сложное для компиляции рядом с 'ConsoleApplication1.Program.Main (string [])'

Что забавно, так это то, что поиск в двоичном дереве выглядит немного (вероятно, не статистически) быстрее, чем инструкция переключения CIL.

Брайан, вы использовали слово « константа », которое имеет очень определенное значение с точки зрения теории сложности вычислений. В то время как упрощенный пример смежных целых чисел может создавать CIL, который считается O (1) (константа), разреженный пример - O (log n) (логарифмический), кластерные примеры лежат где-то посередине, а небольшие примеры - O (n) (линейный ).

Это даже не касается ситуации String, в которой Generic.Dictionary<string,int32>может быть создана статика , и при первом использовании будут возникать определенные накладные расходы. Производительность здесь будет зависеть от производительности Generic.Dictionary.

Если вы проверите спецификацию языка C # (а не спецификацию CIL), вы обнаружите, что «15.7.2 Оператор переключения» не упоминает «постоянное время» или что базовая реализация даже использует инструкцию переключения CIL (будьте очень осторожны, предполагая такие вещи).

В конце концов, переключение C # на целочисленное выражение в современной системе - это субмикросекундная операция, о которой обычно не стоит беспокоиться.


Конечно, это время будет зависеть от машин и условий. Я бы не стал обращать внимание на эти временные тесты, микросекунды, о которых мы говорим, затмеваются любым выполняемым «реальным» кодом (и вы должны включить какой-то «настоящий код», иначе компилятор оптимизирует ветвление), или джиттер в системе. Мои ответы основаны на использовании IL DASM для изучения CIL, созданного компилятором C #. Конечно, это не окончательный вариант, поскольку фактические инструкции, выполняемые ЦП, затем создаются JIT.

Я проверил последние инструкции процессора, фактически выполняемые на моем компьютере x86, и могу подтвердить, что простой смежный переключатель набора делает что-то вроде:

  jmp     ds:300025F0[eax*4]

Если поиск по бинарному дереву заполнен:

  cmp     ebx, 79Eh
  jg      3000352B
  cmp     ebx, 654h
  jg      300032BB
  …
  cmp     ebx, 0F82h
  jz      30005EEE

Результаты ваших экспериментов меня немного удивляют. Ты поменял местами свой с Брайаном? Его результаты показывают увеличение с размером, а ваши - нет. Я что-то упускаю? В любом случае спасибо за четкий ответ.
mweerden

При такой небольшой операции сложно точно рассчитать время. Мы не делились кодом или процедурами тестирования. Я не понимаю, почему его время должно увеличиваться для смежных случаев. Мои были в 10 раз быстрее, поэтому среда и тестовый код могут сильно отличаться.
Иван Гамильтон,

23

Первая причина, которая приходит на ум, - историческая :

Поскольку большинство программистов на языках C, C ++ и Java не привыкли иметь такие свободы, они не требуют их.

Другая, более веская причина состоит в том, что сложность языка увеличится :

В первую очередь следует сравнивать объекты .Equals()с ==оператором или с ним ? Оба действительны в некоторых случаях. Должны ли мы для этого ввести новый синтаксис? Должны ли мы позволить программисту вводить свой собственный метод сравнения?

Кроме того, разрешение включать объекты нарушит основные предположения об операторе switch . Есть два правила, управляющих оператором switch, которые компилятор не сможет применить, если бы объекты были разрешены для включения (см. Спецификацию языка C # версии 3.0 , §8.7.2):

  • Что значения меток переключателей постоянны
  • Значения меток переключателей различны (так что для данного выражения переключателя может быть выбран только один блок переключателя)

Рассмотрим этот пример кода в гипотетическом случае, когда разрешены непостоянные значения case:

void DoIt()
{
    String foo = "bar";
    Switch(foo, foo);
}

void Switch(String val1, String val2)
{
    switch ("bar")
    {
        // The compiler will not know that val1 and val2 are not distinct
        case val1:
            // Is this case block selected?
            break;
        case val2:
            // Or this one?
            break;
        case "bar":
            // Or perhaps this one?
            break;
    }
}

Что будет делать код? Что, если операторы case переупорядочены? В самом деле, одна из причин, по которой C # сделал провал переключения незаконным, заключается в том, что операторы переключения могут быть произвольно переставлены.

Эти правила действуют по определенной причине - так, чтобы программист мог, глядя на один блок case, точно знать точное условие, при котором блок вводится. Когда вышеупомянутый оператор switch вырастет до 100 или более строк (а так и будет), такие знания станут бесценными.


2
Обратите внимание на переупорядочивание переключателя. Падение допустимо, если в кейсе нет кода. Например, Случай 1: Случай 2: Console.WriteLine ("Hi"); перемена;
Джоэл Макбет

10

Между прочим, VB, имеющий ту же базовую архитектуру, допускает гораздо более гибкие Select Caseоператоры (приведенный выше код будет работать в VB) и по-прежнему создает эффективный код там, где это возможно, поэтому аргумент, связанный с техническими ограничениями, следует тщательно учитывать.


1
Select CaseАн VB является очень гибким и супер экономит время. Я очень по нему скучаю.
Эдуардо Молтени

@EduardoMolteni Тогда переключись на F #. По сравнению с этим переключатели Паскаля и VB кажутся идиотскими детьми.
Луаан

10

В основном эти ограничения действуют из-за дизайнеров языков. Основным оправданием может быть совместимость с историей языков, идеалами или упрощением конструкции компилятора.

Компилятор может (и делает) следующее:

  • создать большой оператор if-else
  • используйте инструкцию переключения MSIL (таблица переходов)
  • создать Generic.Dictionary <string, int32>, заполнить его при первом использовании и вызвать Generic.Dictionary <> :: TryGetValue () для передачи индекса в инструкцию переключения MSIL (таблица переходов)
  • используйте комбинацию if-elses и переходов MSIL "switch"

Оператор switch НЕ ЯВЛЯЕТСЯ ветвью постоянного времени. Компилятор может находить короткие пути (с использованием хэш-корзин и т. Д.), Но более сложные случаи будут генерировать более сложный код MSIL, причем некоторые случаи будут ветвиться раньше, чем другие.

Для обработки случая String компилятор в конечном итоге (в какой-то момент) использует a.Equals (b) (и, возможно, a.GetHashCode ()). Я думаю, что компилятору было бы тривиально использовать любой объект, удовлетворяющий этим ограничениям.

Что касается потребности в статических выражениях case ... некоторые из этих оптимизаций (хеширование, кэширование и т.д.) были бы недоступны, если бы выражения case не были детерминированными. Но мы уже видели, что иногда компилятор в любом случае просто выбирает упрощенный путь if-else-if-else ...

Изменить: lomaxx - Ваше понимание оператора "typeof" неверно. Оператор typeof используется для получения объекта System.Type для типа (не имеющего отношения к его супертипам или интерфейсам). Проверка совместимости во время выполнения объекта с заданным типом - задача оператора is. Использование «typeof» здесь для выражения объекта не имеет значения.


6

Говоря об этой теме, по словам Джеффа Этвуда, оператор switch является злодеянием программирования . Используйте их экономно.

Часто ту же задачу можно выполнить с помощью таблицы. Например:

var table = new Dictionary<Type, string>()
{
   { typeof(int), "it's an int!" }
   { typeof(string), "it's a string!" }
};

Type someType = typeof(int);
Console.WriteLine(table[someType]);

7
Вы серьезно цитируете чей-то пост в Твиттере без каких-либо доказательств? Хотя бы ссылку на надежный источник.
Иван Гамильтон,

4
Это из надежного источника; сообщение в Twitter, о котором идет речь, принадлежит Джеффу Этвуду, автору сайта, на который вы смотрите. :-) У Джеффа есть несколько сообщений на эту тему в блогах, если вам интересно.
Иуда Габриэль Химанго

Я считаю, что это полная чушь - написал это Джефф Этвуд или нет. Забавно, насколько хорошо оператор switch подходит для обработки конечных автоматов и других примеров изменения потока кода в зависимости от значения enumтипа. Также не случайно intellisense автоматически заполняет оператор switch, когда вы включаете переменную определенного enumтипа.
Джонатон Рейнхарт

@JonathonReinhart Да, я думаю, что в этом суть - есть способы лучше обрабатывать полиморфный код, чем использование switchоператора. Он не говорит, что вам не следует писать конечные автоматы, просто вы можете сделать то же самое, используя хорошие конкретные типы. Конечно, это намного проще в таких языках, как F #, у которых есть типы, которые могут легко охватывать довольно сложные состояния. В вашем примере вы можете использовать размеченные объединения, где состояние становится частью типа, и заменить switchсопоставление с образцом. Или используйте, например, интерфейсы.
Луаан

Старый ответ / вопрос, но я бы подумал, что (поправьте меня, если я ошибаюсь) Dictionaryбудет значительно медленнее, чем оптимизированный switchоператор ...?
Пол

6

Я не вижу причин, по которым оператор switch должен соответствовать только статическому анализу.

Да, это не обязательно , и многие языки действительно используют операторы динамического переключения. Однако это означает, что переупорядочение предложений case может изменить поведение кода.

Здесь есть некоторая интересная информация, лежащая в основе дизайнерских решений, которые вошли в «switch»: почему оператор switch в C # спроектирован так, чтобы не допускать провалов, но все же требует перерыва?

Разрешение динамических выражений регистра может привести к чудовищам, таким как этот PHP-код:

switch (true) {
    case a == 5:
        ...
        break;
    case b == 10:
        ...
        break;
}

который, честно говоря, должен просто использовать это if-elseутверждение.


1
Вот что мне нравится в PHP (сейчас, когда я перехожу на C #), это свобода. С этим приходит свобода писать плохой код, но это то, чего мне очень не хватает в C #
silkfire

6

Microsoft наконец-то вас услышала!

Теперь с C # 7 вы можете:

switch(shape)
{
case Circle c:
    WriteLine($"circle with radius {c.Radius}");
    break;
case Rectangle s when (s.Length == s.Height):
    WriteLine($"{s.Length} x {s.Height} square");
    break;
case Rectangle r:
    WriteLine($"{r.Length} x {r.Height} rectangle");
    break;
default:
    WriteLine("<unknown shape>");
    break;
case null:
    throw new ArgumentNullException(nameof(shape));
}

3

Это не причина, но в разделе 8.7.2 спецификации C # указано следующее:

Управляющий тип оператора switch устанавливается выражением switch. Если тип выражения switch - sbyte, byte, short, ushort, int, uint, long, ulong, char, string или enum-type, то это основной тип оператора switch. В противном случае должно существовать ровно одно определяемое пользователем неявное преобразование (§6.4) из типа выражения switch в один из следующих возможных управляющих типов: sbyte, byte, short, ushort, int, uint, long, ulong, char, string . Если такого неявного преобразования не существует или существует более одного такого неявного преобразования, возникает ошибка времени компиляции.

Спецификация C # 3.0 находится по адресу: http://download.microsoft.com/download/3/8/8/388e7205-bc10-4226-b2a8-75351c669b09/CSharp%20Language%20Specification.doc


3

Ответ Иуды выше дал мне идею. Вы можете «подделать» поведение переключателя OP, описанное выше, используя Dictionary<Type, Func<T>:

Dictionary<Type, Func<object, string,  string>> typeTable = new Dictionary<Type, Func<object, string, string>>();
typeTable.Add(typeof(int), (o, s) =>
                    {
                        return string.Format("{0}: {1}", s, o.ToString());
                    });

Это позволяет связать поведение с типом в том же стиле, что и оператор switch. Я считаю, что у него есть дополнительное преимущество, заключающееся в том, что он используется вместо таблицы переходов в стиле переключателя при компиляции в IL.


0

Я полагаю, что нет фундаментальной причины, по которой компилятор не мог автоматически преобразовать ваш оператор switch в:

if (t == typeof(int))
{
...
}
elseif (t == typeof(string))
{
...
}
...

Но от этого мало что получается.

Оператор case для целочисленных типов позволяет компилятору выполнить ряд оптимизаций:

  1. Нет дублирования (если вы не дублируете метки регистра, которые обнаруживает компилятор). В вашем примере t может соответствовать нескольким типам из-за наследования. Следует ли выполнить первый матч? Все они?

  2. Компилятор может реализовать оператор switch над целым типом с помощью таблицы переходов, чтобы избежать всех сравнений. Если вы включаете перечисление с целочисленными значениями от 0 до 100, оно создает массив со 100 указателями в нем, по одному для каждого оператора switch. Во время выполнения он просто ищет адрес в массиве на основе включенного целочисленного значения. Это обеспечивает гораздо лучшую производительность во время выполнения, чем выполнение 100 сравнений.


1
Здесь следует отметить важную сложность , заключающуюся в том, что модель памяти .NET имеет определенные надежные гарантии, которые делают ваш псевдокод не совсем эквивалентным (гипотетическому, недопустимому C # ), switch (t) { case typeof(int): ... }поскольку ваш перевод подразумевает, что переменная t должна быть извлечена из памяти дважды, если t != typeof(int), тогда как последний будет (предположительно) всегда считывайте значение t ровно один раз . Это различие может нарушить правильность параллельного кода, который полагается на эти отличные гарантии. Для получения дополнительной информации см. Джо Даффи Параллельное программирование Windows
Гленн

0

Согласно документации оператора switch, если существует однозначный способ неявного преобразования объекта в целочисленный тип, он будет разрешен. Я думаю, вы ожидаете поведения, при котором для каждого оператора case он будет заменен на него if (t == typeof(int)), но это откроет целую банку червей, когда вы перегрузите этот оператор. Поведение изменится при изменении деталей реализации для оператора switch, если вы неправильно написали переопределение ==. За счет сокращения сравнений до целочисленных типов и строк и тех вещей, которые могут быть сведены к целым типам (и предназначены для этого), они избегают потенциальных проблем.


0

написал:

«Оператор switch выполняет постоянную временную ветвь независимо от того, сколько случаев у вас есть».

Поскольку язык допускает строку тип в операторе switch, я предполагаю, что компилятор не может сгенерировать код для реализации ветвления с постоянным временем для этого типа и ему необходимо создать стиль «если-то».

@mweerden - Ага, понятно. Спасибо.

У меня нет большого опыта работы с C # и .NET, но кажется, что разработчики языка не разрешают статический доступ к системе типов, за исключением узких случаев. TypeOf ключевое слово возвращает объект , так это доступно только на время выполнения.


0

Я думаю, что Хенк добился этого, предложив «запретить статический доступ к системе типов».

Другой вариант состоит в том, что в типах нет порядка, в котором могут быть числа и строки. Таким образом, переключатель типа не может построить двоичное дерево поиска, только линейный поиск.


0

Я согласен с этим комментарием, что часто лучше использовать табличный подход.

В C # 1.0 это было невозможно, потому что в нем не было универсальных шаблонов и анонимных делегатов. В новых версиях C # есть все необходимое для этой работы. Также помогает обозначение объектных литералов.


0

Я практически ничего не знаю о C #, но подозреваю, что либо переключение было просто использовано, как в других языках, не думая о том, чтобы сделать его более общим, либо разработчик решил, что его расширять не стоит.

Строго говоря, вы абсолютно правы, что нет никаких оснований для наложения на него таких ограничений. Можно подумать, что причина в том, что для допустимых случаев реализация очень эффективна (как было предложено Брайаном Энсинком ( 44921 )), но я сомневаюсь, что реализация очень эффективна (относительно if-операторов), если я использую целые числа и некоторые случайные случаи. (например, 345, -4574 и 1234203). И в любом случае, какой вред в том, чтобы разрешить это для всего (или, по крайней мере, больше) и сказать, что это эффективно только для определенных случаев (например, (почти) последовательных чисел).

Я могу, однако, представить себе , что можно было бы хотеть , чтобы исключить типы из - за причин , таких , как один дается lomaxx ( 44918 ).

Изменить: @Henk ( 44970 ): если строки максимально разделены, строки с одинаковым содержимым также будут указателями на одно и то же место в памяти. Затем, если вы можете убедиться, что строки, используемые в случаях, последовательно сохраняются в памяти, вы можете очень эффективно реализовать переключение (т.е. с выполнением в порядке 2 сравнений, добавления и двух переходов).


0

C # 8 позволяет элегантно и компактно решить эту проблему с помощью выражения switch:

public string GetTypeName(object obj)
{
    return obj switch
    {
        int i => "Int32",
        string s => "String",
        { } => "Unknown",
        _ => throw new ArgumentNullException(nameof(obj))
    };
}

В результате вы получите:

Console.WriteLine(GetTypeName(obj: 1));           // Int32
Console.WriteLine(GetTypeName(obj: "string"));    // String
Console.WriteLine(GetTypeName(obj: 1.2));         // Unknown
Console.WriteLine(GetTypeName(obj: null));        // System.ArgumentNullException

Вы можете узнать больше о новой функции здесь .

Используя наш сайт, вы подтверждаете, что прочитали и поняли нашу Политику в отношении файлов cookie и Политику конфиденциальности.
Licensed under cc by-sa 3.0 with attribution required.