Limitazioni dell'istruzione switch C #: perché?


141

Quando si scrive un'istruzione switch, sembrano esserci due limitazioni su cosa è possibile attivare nelle istruzioni case.

Ad esempio (e sì, lo so, se stai facendo questo genere di cose, probabilmente significa che la tua architettura orientata agli oggetti (OO) è incerta - questo è solo un esempio inventato!),

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

Qui l'istruzione switch () ha esito negativo con "Un valore di un tipo integrale previsto" e le istruzioni del caso non riescono con "È previsto un valore costante".

Perché esistono queste restrizioni e qual è la giustificazione sottostante? Non vedo alcun motivo per cui l'istruzione switch debba soccombere solo all'analisi statica e perché il valore attivato debba essere integrale (ovvero, primitivo). Qual è la giustificazione?



Un'altra opzione per l'attivazione dei tipi incorporati è l'uso dell'Enum TypeCode .
Erik Philips,

Semplicemente, crea un ENUM e usa NameOf nel caso Switch. Funzionerà come costante su una variabile dinamica.
Vaibhav.Ispirato

Risposte:


99

Questo è il mio post originale, che ha suscitato un certo dibattito ... perché è sbagliato :

L'istruzione switch non è la stessa cosa di una grande istruzione if-else. Ogni caso deve essere unico e valutato staticamente. L'istruzione switch esegue un ramo temporale costante indipendentemente da quanti casi hai. L'istruzione if-else valuta ogni condizione fino a quando non ne trova una vera.


In effetti, l'istruzione switch C # non è sempre un ramo di tempo costante.

In alcuni casi il compilatore utilizzerà un'istruzione switch CIL che è in effetti un ramo di tempo costante usando una tabella di salto. Tuttavia, in casi sparsi, come sottolineato da Ivan Hamilton, il compilatore può generare qualcos'altro interamente.

Questo è in realtà abbastanza facile da verificare scrivendo varie istruzioni switch C #, alcune sparse, altre dense e guardando il CIL risultante con lo strumento ildasm.exe.


4
Come notato in altre risposte (compresa la mia), le affermazioni fatte in questa risposta non sono corrette. Vorrei raccomandare la cancellazione (anche solo per evitare di applicare questo malinteso (probabilmente comune)).
mweerden,

Si prega di vedere il mio post qui sotto dove mostro, a mio avviso in modo conclusivo, che l'istruzione switch fa un ramo temporale costante.
Brian Ensink,

Grazie mille per la tua risposta, Brian. Si prega di consultare la risposta di Ivan Hamilton ((48259) [ beta.stackoverflow.com/questions/44905/#48259] ). In breve: stai parlando switch dell'istruzione (del CIL) che non è la stessa dell'istruzioneswitch di C #.
mweerden,

Non credo che il compilatore generi diramazioni a tempo costante quando si accendono le stringhe.
Drew Noakes,

Questo è ancora applicabile con la corrispondenza del modello nelle istruzioni del caso switch in C # 7.0?
B. Darren Olson

114

È importante non confondere l'istruzione switch C # con l'istruzione switch CIL.

L'opzione CIL è una tabella di salto, che richiede un indice in una serie di indirizzi di salto.

Ciò è utile solo se i casi dell'interruttore C # sono adiacenti:

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

Ma di scarsa utilità se non lo sono:

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

(Avresti bisogno di una tabella di dimensioni ~ 3000 voci, con solo 3 slot utilizzati)

Con espressioni non adiacenti, il compilatore può iniziare a eseguire controlli if-else-if-else lineari.

Con insiemi di espressioni non adiacenti più grandi, il compilatore può iniziare con una ricerca ad albero binaria e infine if-else-if-else gli ultimi elementi.

Con insiemi di espressioni contenenti gruppi di elementi adiacenti, il compilatore può cercare l'albero binario e infine un interruttore CIL.

Questo è pieno di "mays" e "mights", e dipende dal compilatore (può differire con Mono o Rotor).

Ho replicato i tuoi risultati sulla mia macchina usando casi adiacenti:

tempo totale per eseguire un interruttore a 10 vie, 10000 iterazioni (ms): 25.1383
tempo approssimativo per interruttore a 10 vie (ms): 0.00251383

tempo totale per eseguire un interruttore a 50 vie, 10000 iterazioni (ms): 26.593
tempo approssimativo per interruttore a 50 vie (ms): 0.0026593

tempo totale per eseguire un interruttore a 5000 vie, 10000 iterazioni (ms): 23.7094
tempo approssimativo per interruttore a 5000 vie (ms): 0.00237094

tempo totale per eseguire un interruttore a 50000 vie, 10000 iterazioni (ms): 20.0933
tempo approssimativo per interruttore a 50000 vie (ms): 0.00200933

Quindi ho anche usato espressioni case non adiacenti:

tempo totale per eseguire un interruttore a 10 vie, 10000 iterazioni (ms): 19.6189
tempo approssimativo per interruttore a 10 vie (ms): 0.00196189

tempo totale per eseguire un interruttore a 500 vie, 10000 iterazioni (ms): 19.1664
tempo approssimativo per interruttore a 500 vie (ms): 0.00191664

tempo totale per eseguire un interruttore a 5000 vie, 10000 iterazioni (ms): 19.5871
tempo approssimativo per interruttore a 5000 vie (ms): 0.00195871

Non verrà compilata un'istruzione switch di caso 50.000 non adiacente.
"Un'espressione è troppo lunga o complessa per essere compilata vicino a" ConsoleApplication1.Program.Main (string []) "

La cosa divertente qui è che la ricerca dell'albero binario appare un po '(probabilmente non statisticamente) più veloce dell'istruzione switch CIL.

Brian, hai usato la parola " costante ", che ha un significato ben definito dal punto di vista della teoria della complessità computazionale. Mentre l'esempio intero adiacente semplicistico può produrre CIL considerato O (1) (costante), un esempio scarso è O (log n) (logaritmico), esempi raggruppati si trovano da qualche parte nel mezzo e piccoli esempi sono O (n) (lineare ).

Ciò non risolve nemmeno la situazione di String, in cui è Generic.Dictionary<string,int32>possibile creare una statica e subirà un sovraccarico definito al primo utilizzo. Le prestazioni qui dipenderanno dalle prestazioni di Generic.Dictionary.

Se controlli la specifica del linguaggio C # (non la specifica CIL) troverai "15.7.2 L'istruzione switch" non fa menzione di "tempo costante" o che l'implementazione sottostante utilizza anche l'istruzione switch CIL (fai molta attenzione a supporre tali cose).

Alla fine della giornata, un passaggio C # contro un'espressione intera su un sistema moderno è un'operazione sub-microseconda, e di solito non vale la pena preoccuparsi.


Naturalmente questi tempi dipenderanno dalle macchine e dalle condizioni. Non presterei attenzione a questi test di temporizzazione, le durate dei microsecondi di cui stiamo parlando sono sminuite da qualsiasi codice "reale" in esecuzione (e devi includere un "codice reale" altrimenti il ​​compilatore ottimizzerà il ramo di distanza), oppure jitter nel sistema. Le mie risposte si basano sull'utilizzo di IL DASM per esaminare il CIL creato dal compilatore C #. Naturalmente questo non è definitivo, poiché le istruzioni effettive eseguite dalla CPU vengono quindi create da JIT.

Ho verificato le istruzioni finali della CPU effettivamente eseguite sulla mia macchina x86 e posso confermare un semplice switch adiacente facendo qualcosa del tipo:

  jmp     ds:300025F0[eax*4]

Dove una ricerca ad albero binario è piena di:

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

I risultati dei tuoi esperimenti mi sorprendono un po '. Hai scambiato il tuo con quello di Brian? I suoi risultati mostrano un aumento con le dimensioni mentre i tuoi no. Mi manca qualcosa? In ogni caso, grazie per la chiara risposta.
mweerden,

È difficile calcolare con precisione i tempi con un'operazione così piccola. Non abbiamo condiviso codice o procedure di test. Non riesco a capire perché i suoi tempi dovrebbero aumentare per i casi adiacenti. I miei erano 10 volte più veloci, quindi gli ambienti e il codice di test possono variare notevolmente.
Ivan Hamilton,

23

Il primo motivo che viene in mente è storico :

Poiché la maggior parte dei programmatori C, C ++ e Java non sono abituati ad avere tali libertà, non li richiedono.

Un'altra ragione, più valida, è che la complessità della lingua aumenterebbe :

Prima di tutto, gli oggetti devono essere confrontati con .Equals()o con l' ==operatore? Entrambi sono validi in alcuni casi. Dovremmo introdurre una nuova sintassi per fare questo? Dovremmo consentire al programmatore di introdurre il proprio metodo di confronto?

Inoltre, consentire l'attivazione di oggetti interromperà le ipotesi sottostanti sull'istruzione switch . Esistono due regole che governano l'istruzione switch che il compilatore non sarebbe in grado di imporre se gli oggetti potessero essere attivati ​​(vedere la specifica del linguaggio C # versione 3.0 , §8.7.2):

  • Che i valori delle etichette degli interruttori siano costanti
  • Che i valori delle etichette degli interruttori siano distinti (in modo che sia possibile selezionare un solo blocco interruttore per una determinata espressione interruttore)

Considera questo esempio di codice nel caso ipotetico in cui sono consentiti valori di caso non costanti:

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

Cosa farà il codice? Cosa succede se le dichiarazioni dei casi vengono riordinate? In effetti, uno dei motivi per cui C # ha reso illegale la commutazione è che le dichiarazioni di commutazione potrebbero essere arbitrariamente riorganizzate.

Queste regole sono in vigore per un motivo - in modo che il programmatore possa, guardando un blocco maiuscolo, conoscere con certezza la condizione precisa in cui viene inserito il blocco. Quando la suddetta istruzione switch si trasforma in 100 o più righe (e lo farà), tale conoscenza è preziosa.


2
Da notare il riordino dell'interruttore. Fall over è legale se il caso non contiene alcun codice. Ad esempio, Caso 1: Caso 2: Console.WriteLine ("Ciao"); rompere;
Joel McBeth,

10

A proposito, VB, avendo la stessa architettura sottostante, consente Select Caseistruzioni molto più flessibili (il codice sopra funzionerebbe in VB) e produce ancora codice efficiente laddove ciò sia possibile, quindi l'argomento per vincolo tecnico deve essere considerato attentamente.


1
L' Select Caseen VB è molto flessibile e un risparmio di tempo eccellente. Mi manca molto
Eduardo Molteni,

@EduardoMolteni Passa quindi a F #. In confronto, gli switch di Pascal e VB sembrano bambini idioti.
Luaan,

10

Principalmente, tali restrizioni sono in atto a causa dei progettisti del linguaggio. La giustificazione alla base può essere la compatibilità con la lingua, gli ideali o la semplificazione della lingua del compilatore.

Il compilatore può (e lo fa) scegliere di:

  • creare una grande istruzione if-else
  • utilizzare un'istruzione switch MSIL (tabella di salto)
  • creare un Generic.Dictionary <stringa, int32>, popolarlo al primo utilizzo e chiamare Generic.Dictionary <> :: TryGetValue () affinché un indice passi a un'istruzione switch MSIL (tabella di salto)
  • usa una combinazione di salti "switch" if-elses e MSIL

L'istruzione switch NON è un ramo di tempo costante. Il compilatore può trovare scorciatoie (usando bucket hash, ecc.), Ma casi più complicati genereranno codice MSIL più complicato con alcuni casi che si diramano prima di altri.

Per gestire il caso String, il compilatore finirà (ad un certo punto) usando a.Equals (b) (e possibilmente a.GetHashCode ()). Penso che sarebbe trival per il compilatore utilizzare qualsiasi oggetto che soddisfi questi vincoli.

Per quanto riguarda la necessità di espressioni statiche del caso ... alcune di quelle ottimizzazioni (hash, cache, ecc.) Non sarebbero disponibili se le espressioni del caso non fossero deterministiche. Ma abbiamo già visto che a volte il compilatore sceglie comunque la semplicistica strada if-else-if-else ...

Modifica: lomaxx - La tua comprensione dell'operatore "typeof" non è corretta. L'operatore "typeof" viene utilizzato per ottenere l'oggetto System.Type per un tipo (niente a che fare con i suoi supertipi o interfacce). Il controllo della compatibilità runtime di un oggetto con un determinato tipo è il lavoro dell'operatore "is". L'uso di "typeof" qui per esprimere un oggetto è irrilevante.


6

Mentre sull'argomento, secondo Jeff Atwood, l'istruzione switch è un'atrocità di programmazione . Usali con parsimonia.

Spesso è possibile eseguire la stessa attività utilizzando una tabella. Per esempio:

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
Stai seriamente citando qualcuno fuori dal bracciale Twitter senza prove? Almeno link a una fonte affidabile.
Ivan Hamilton,

4
Viene da una fonte affidabile; il post di Twitter in questione è di Jeff Atwood, autore del sito che stai guardando. :-) Jeff ha una manciata di post sul blog su questo argomento se sei curioso.
Giuda Gabriele Himango,

Credo che sia BS totale - che Jeff Atwood lo abbia scritto o meno. È curioso quanto l'istruzione switch si presti alla gestione di macchine a stati e ad altri esempi di modifica del flusso di codice in base al valore di un enumtipo. Inoltre, non è una coincidenza che intellisense compili automaticamente un'istruzione switch quando si attiva una variabile di un enumtipo.
Jonathon Reinhart,

@JonathonReinhart Sì, penso che sia questo il punto - ci sono modi migliori per gestire il codice polimorfico rispetto all'uso dell'istruzione switch. Non sta dicendo che non dovresti scrivere macchine a stati, solo che puoi fare la stessa cosa usando tipi specifici carini. Naturalmente, questo è molto più facile in lingue come F # che hanno tipi che possono facilmente coprire stati abbastanza complessi. Per il tuo esempio, potresti usare i sindacati discriminati in cui lo stato diventa parte del tipo e sostituire il switchmodello con la corrispondenza. Oppure usa le interfacce, per esempio.
Luaan,

Vecchia risposta / domanda, ma avrei pensato che (correggimi se sbaglio) Dictionarysarebbe stato notevolmente più lento di una switchdichiarazione ottimizzata ...?
Paul,

6

Non vedo alcun motivo per cui l'istruzione switch debba succedere solo all'analisi statica

È vero, non è necessario e molte lingue utilizzano infatti istruzioni switch dinamiche. Ciò significa tuttavia che il riordino delle clausole "case" può modificare il comportamento del codice.

Ci sono alcune informazioni interessanti dietro le decisioni di progettazione che sono andate in "switch" qui: Perché l'istruzione switch C # è progettata per non consentire il fall-through, ma richiede comunque una pausa?

Consentire espressioni di casi dinamici può portare a mostruosità come questo codice PHP:

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

che francamente dovrebbe usare solo l' if-elseaffermazione.


1
Questo è ciò che amo di PHP (ora che sto passando a C #), è la libertà. Con ciò arriva la libertà di scrivere codice
errato

5

Microsoft finalmente ti ha sentito!

Ora con C # 7 puoi:

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

Questo non è un motivo, ma la sezione 8.7.2 delle specifiche C # indica quanto segue:

Il tipo di governo di un'istruzione switch è stabilito dall'espressione switch. Se il tipo dell'espressione switch è sbyte, byte, short, ushort, int, uint, long, ulong, char, string o enum-type, questo è il tipo di comando dell'istruzione switch. Altrimenti, deve esistere esattamente una conversione implicita definita dall'utente (§6.4) dal tipo dell'espressione switch a uno dei seguenti possibili tipi di governo: sbyte, byte, short, ushort, int, uint, long, ulong, char, string . Se non esiste tale conversione implicita o se esiste più di una conversione implicita, si verifica un errore di compilazione.

La specifica C # 3.0 è disponibile all'indirizzo: http://download.microsoft.com/download/3/8/8/388e7205-bc10-4226-b2a8-75351c669b09/CSharp%20Language%20Specification.doc


3

La risposta di Giuda sopra mi ha dato un'idea. È possibile "falsificare" il comportamento di commutazione dell'OP sopra usando un 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());
                    });

Ciò consente di associare il comportamento a un tipo nello stesso stile dell'istruzione switch. Credo che abbia il vantaggio aggiuntivo di essere codificato invece di una tabella di salto in stile switch quando compilato in IL.


0

Suppongo che non vi sia alcun motivo fondamentale per cui il compilatore non possa tradurre automaticamente la tua istruzione switch in:

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

Ma non c'è molto guadagnato da quello.

Un'istruzione case sui tipi integrali consente al compilatore di effettuare una serie di ottimizzazioni:

  1. Non vi è alcuna duplicazione (a meno che non si duplicino le etichette dei casi rilevate dal compilatore). Nel tuo esempio t potrebbe corrispondere a più tipi a causa dell'ereditarietà. La prima partita deve essere eseguita? Tutti loro?

  2. Il compilatore può scegliere di implementare un'istruzione switch su un tipo integrale da una tabella di salto per evitare tutti i confronti. Se si attiva un'enumerazione con valori interi compresi tra 0 e 100, viene creato un array con 100 puntatori, uno per ogni istruzione switch. In fase di runtime cerca semplicemente l'indirizzo dall'array in base al valore intero che viene attivato. Ciò consente prestazioni di runtime molto migliori rispetto all'esecuzione di 100 confronti.


1
Un'importante complessità da notare qui è che il modello di memoria .NET ha alcune forti garanzie che rendono il tuo pseudocodice non esattamente equivalente al (ipotetico C # non valido ) switch (t) { case typeof(int): ... }perché la tua traduzione implica che la variabile t deve essere recuperata dalla memoria due volte se t != typeof(int), mentre quest'ultima dovrebbe (putativamente) leggi sempre il valore di t esattamente una volta . Questa differenza può violare la correttezza del codice concorrente che si basa su quelle eccellenti garanzie. Per maggiori informazioni, vedi Programmazione simultanea di
Glenn Slayden,

0

Secondo la documentazione dell'istruzione switch se esiste un modo inequivocabile per convertire implicitamente l'oggetto in un tipo integrale, sarà consentito. Penso che ti aspetti un comportamento in cui per ogni istruzione case verrebbe sostituita if (t == typeof(int)), ma ciò aprirà un'intera lattina di worm quando si arriva a sovraccaricare quell'operatore. Il comportamento cambierebbe quando cambiassero i dettagli di implementazione per l'istruzione switch se scrivessi la tua sostituzione == in modo errato. Riducendo i confronti a tipi e stringhe integrali e quelle cose che possono essere ridotte a tipi integrali (e sono destinati a) evitano potenziali problemi.


0

ha scritto:

"L'istruzione switch esegue un ramo temporale costante indipendentemente da quanti casi hai."

Dal momento che il linguaggio consente di utilizzare il tipo di stringa in un'istruzione switch, presumo che il compilatore non sia in grado di generare codice per un'implementazione del ramo di tempo costante per questo tipo e debba generare uno stile if-then.

@mweerden - Ah, capisco. Grazie.

Non ho molta esperienza in C # e .NET ma sembra che i progettisti del linguaggio non consentano l'accesso statico al sistema di tipi se non in circostanze ristrette. La parola chiave typeof restituisce un oggetto, quindi è accessibile solo in fase di esecuzione.


0

Penso che Henk l'abbia inchiodato con la cosa "nessun accesso statico al sistema dei tipi"

Un'altra opzione è che non vi è alcun ordine di tipi in cui possono essere numerici e stringhe. Pertanto, un interruttore di tipo non può creare un albero di ricerca binario, ma solo una ricerca lineare.


0

Concordo con questo commento sul fatto che l'utilizzo di un approccio basato sulla tabella è spesso migliore.

In C # 1.0 questo non era possibile perché non aveva generici e delegati anonimi. Le nuove versioni di C # hanno l'impalcatura per far funzionare questo. Anche avere una notazione per letterali di oggetti aiuta.


0

Non ho praticamente alcuna conoscenza di C #, ma sospetto che uno dei due switch sia stato semplicemente preso come accade in altre lingue senza pensare a renderlo più generale o lo sviluppatore ha deciso che l'estensione non ne valeva la pena.

A rigor di termini hai assolutamente ragione sul fatto che non vi è motivo di imporre queste restrizioni. Si potrebbe sospettare che il motivo sia che per i casi consentiti l'implementazione è molto efficiente (come suggerito da Brian Ensink ( 44921 )), ma dubito che l'implementazione sia molto efficiente (dichiarazioni if scritte ) se uso numeri interi e alcuni casi casuali (ad es. 345, -4574 e 1234203). E in ogni caso, qual è il danno nel permetterlo per tutto (o almeno di più) e nel dire che è efficace solo per casi specifici (come (quasi) numeri consecutivi).

Posso, tuttavia, immaginare che si potrebbe voler escludere i tipi a causa di ragioni come quella data da lomaxx ( 44918 ).

Modifica: @Henk ( 44970 ): se le stringhe sono condivise al massimo, anche le stringhe con lo stesso contenuto saranno puntatori alla stessa posizione di memoria. Quindi, se puoi assicurarti che le stringhe utilizzate nei casi siano memorizzate consecutivamente in memoria, puoi implementare in modo molto efficiente l'interruttore (cioè con l'esecuzione nell'ordine di 2 confronti, un'aggiunta e due salti).


0

C # 8 consente di risolvere questo problema in modo elegante e compatto usando un'espressione switch:

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

Di conseguenza, ottieni:

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

Puoi leggere di più sulla nuova funzionalità qui .

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.