Pułapki składni języka C, część 1 – dlaczego instrukcja switch musi odejść

Składnia języka C swego czasu była bardzo zwięzła, i wpłynęła mocno na składnie innych języków. Uważam jednak, że po 40 latach można przestać wychwalać ją pod niebiosa i zacząć obiektywnie analizować względem wpływu na ilość bugów w programie.

Instrukcja switch służy do wyboru pomiędzy kilkoma opcjami – najczęściej wartościami typu wyliczeniowego lub stringami – na początku została wprowadzona, żeby umożliwić kompilatorom C implementacje „tablic skoku” dla tego rodzaju instrukcji.

skocz o 8*n bajtów do przodu
wykonaj instrukcje dla 0 (jest to 8 bajtowa instrukcja)
wykonaj instrukcje dla 1 (jest to 8 bajtowa instrukcja)
wykonaj instrukcje dla 2 (jest to 8 bajtowa instrukcja)
wykonaj instrukcje dla 3 (jest to 8 bajtowa instrukcja)

Minęło 40 lat, i sądzę, że kompilatory są wystarczająco bystre by zauważyć podobną optymalizację na drabinkach ifów.

Pierwszym problemem który można zauważyć na pierwszy rzut oka, to jest to, że switch w C działa tylko na typy numeryczne: znaki, liczby, typy wyliczeniowe (nie stringi). Ale to drobnostka, która odwraca uwagę od prawdziwych problemów. (oraz, jest to na tyle oczywiste, że Java i C# naprawili to – ale nie do końca – nie możesz sprawdzić nulla poprzez case null:, i musisz tego switcha otoczyć sprawdzaniem nulla poprzez if(a != null) jak jakiś jaskiniowiec, laffo)

Główny problem z instrukcją switch polega na tym, że tak naprawdę nie jest usprawnieniem notacji drabinki ifów. Z dobrą instrukcją typu switch mógłbym napisać coś takiego

case a {
    1, 2, 3, 4, 5: {
        ++licznik;
    }
    0: {
        print "Błąd";
    }
}
else
{
    print "Nieprawidłowa opcja" 
}

Po przepisaniu tego na C wychodzi coś takiego:

switch(a)
{
    case 1:
    case 2:
    case 3:
    case 4:
    case 5:
        ++licznik;
        break;
    case 0:
        puts("Błąd");
        break;
    default:
        puts("Nieprawidłowa opcja");
        break;
}

W tym momencie można zauważyć, że to tak naprawdę robi się mało czytelne, i można to zastąpić drabinką ifów

if(a >= 1 && a <= 5)
    ++licznik;
else if(a == 0)
    puts("Błąd");
else
    puts("Nieprawidłowa opcja");

Niestety, nie można tego zrobić zawsze. Pierwszy powód jest taki, że switch rzuca błędem gdy mamy podwójnie występujący przypadek. Ponadto, dla enumów jest nas w stanie ostrzec o nieobsłużonym przypadku. Więc

  • nie powinno się wszystkiego zrobić na drabinkach ifów, jak to jest w Pythonie.
  • istnieje wyraźna potrzeba na instrukcję przełączającą która nie ssie.

Ktoś może mi powiedzieć, że czasami przeskakiwanie pomiędzy etykietami („fall-through”) może się przydać, a drabinka if/else nie pozwoli mi na fall-through. Z mojego doświadczenia wynika jednak co innego: w 99% przypadków chcemy wyskoczyć ze switcha, a tylko w 1% przypadków chcemy przeskoczyć dalej. W ten sposób switch faworyzuje mniej używany przypadek na rzecz częściej używanego. Kompletnie nieracjonalna decyzja z punktu widzenia użytkownika języka. (wniosek – C to gloryfikowany asembler)

Dość powiedzieć, że Java i C# uniemożliwiają „fall-through” – właśnie ze względu na bugi. Widać jak Java i C# na ślepo kopiują rzeczy z C bez zastanowienia się. C++ nie miał wyboru, bo jednym z celów projektowych była kompatybilność źródłowa z C, ale zarówno Java, jak i C#, jak i język D nie mają usprawiedliwienia. To co powinni zrobić zamiast wygląda tak (hipotetyczna składnia switcha):

switch(a)
{
    case 0:
        do_this();
    case 1:
        do_that();
        continue;
    default:
        do_the_other_thing();
}

Wiele poradników stylu byłoby zachwyconym tym pomysłem, gdyż nakazują jawne pisanie komentarza gdy robimy fall-through – równoznaczny kod w C wyglądałby tak:

switch(a)
{
    case 0:
        do_this();
        break;
    case 1:
        do_that();
        // fall-through
    default:
        do_the_other_thing();
        break;
}

Ponadto, większość przypadków przeskakiwania obejmuje przypadek, gdy pierwsza etykieta jest pusta, a druga nie, w ten sposób z 1% robi się 0.01% – zamiast:

case 0:
case 1:
case 2:
    do_this();

można byłoby zrobić (styl Pascalowy)

case 0, 1, 2:
    do_this();

albo (gcc ma rozszerzenie które właśnie udostępnia taką składnie w C)

case 0..2:
    do_this();

Kolejny problem ze switchem polega na tym, że używa słowa kluczowego break, blokując jego użycie w pętlach. Poniższy kod jest częsty za każdym razem gdy mamy „pętle zdarzeń” w kodzie C, razem z typem wyliczeniowym dla zdarzenia określającego rodzaj zdarzenia – zatem w WinAPI, SDLu, Allegro i prawie każdej bibliotece C co ma swoją pętlę zdarzeń:

while(al_wait_for_event(queue, &event))
{
    switch(event.type)
    {
        case ALLEGRO_EVENT_KEY_UP:
            /* wyciśnięto klawisz */
        case ALLEGRO_EVENT_KEY_DOWN:
            /* wciśnięto klawisz */
        case ALLEGRO_EVENT_TIMER:
            /* rysuj ekran */
        case ALLEGRO_EVENT_DISPLAY_CLOSE:
            break; // <- buahahahaha, ale jak? break wyskakuje ze switcha, nie z pętli
    }
}

switch ma tą zaletę, że nie muszę pisać wielokrotnie wyrażenia – switch(++skomplikowane_wyrazenie->tablica[indeks]). Całe szczęście w drabincę ifów mogę zrobić to samo:

const auto& t = ++skomplikowane_wyrazenie->tablica[indeks];
if(t == 0)
    do_this();
else if(t == 1)
    do_that();

W C++ dochodzi kolejny problem deklaracji deklaracji zmiennych o nietrywialnym typie.

#include <vector>
#include <iostream>

int main()
{
    int wartosc = 2;
    switch(wartosc)
    {
        case 0:
            std::vector<int> vec = { 1, 2, 3, 4, 5 };
            for(auto& x : vec)
                std::cout << x << " ";
            break;
        case 2:
            std::cout << 5;
            break;
    }
}

Ponieważ switch to praktycznie obliczone goto, z etykietami (w C# możesz nawet zrobić goto case 5!) nie możesz przeskoczyć inicjalizacji, bo konstruktor mógłby się nie wywołać. Więc język to blokuje, i spowoduje wyświetlenie poniższego błędu:

switch_case_failure1.cpp: In function 'int main()':
switch_case_failure1.cpp:13:8: error: jump to case label [-fpermissive]
   case 2:
        ^
switch_case_failure1.cpp:10:21: error:   crosses initialization of 'std::vector<int> vec'
    std::vector<int> vec = { 1, 2, 3, 4, 5 };

Żeby powyższy kod się skompilował, konieczne są dodatkowe nawiasy klamrowe

case 0:
{
    std::vector<int> vec = { 1, 2, 3, 4, 5 };
    for(auto& x : vec)
        std::cout << x << " ";
    break;
}

I tu mam dylemat, i bo nie mam pojęcia jak zrobić tu czytelne wcięcia, i czy lepiej jest wstawić break wewnątrz nawiasów klamrowych, poza nimi, a jak poza nimi, to czy break ma być w osobnej linii czy nie, a jeżeli nie w osobnej linii, to czy zrobić wcięcie dla break czy nie.

Prościej jest to po prostu przerobić na drabinkę ifów. Z wyżej wymienionych powodów nie używam instrukcji switch w swoich programach w ogóle.

Podsumowując, główny problem z instrukcją switch polega na tym, że za każdym razem kiedy myślę, że jest dobry czas na jej użycie, to po jakimś czasie okazuje się, że i tak trzeba ją przerobić na drabinkę ifów.

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *

To create code blocks or other preformatted text, indent by four spaces:

    This will be displayed in a monospaced font. The first four 
    spaces will be stripped off, but all other whitespace
    will be preserved.
    
    Markdown is turned off in code blocks:
     [This is not a link](http://example.com)

To create not a block, but an inline code span, use backticks:

Here is some inline `code`.

For more help see http://daringfireball.net/projects/markdown/syntax