2016年11月27日 星期日

[筆記] 深入了解 switch-case

  之前在課堂的作業中第一次看到神奇的 switch-case 用法,藉著機會研究一番;剛好朋友也詢問了類似的問題,也查看更多資料,完全顛覆我以前對 switch-case 的理解。因此藉著這篇文章來記錄我所查到的知識,也順便掃掃灰塵((汗。

  最初學到的 switch-case 用途是「多重選擇」,也就是說用來取代一連串的 if-else statement 對於一個變數的連續判斷,如以下的例子:
if (value == 0)
    printf("Grade S\n");
else if (value == 1)
    printf("Grade A\n");
else if (value == 2)
    printf("Grade B\n");
else if (value == 3)
    printf("Grade C\n");
else
    printf("Grade D\n");
可以用 switch-case 取代成:
switch (value) {
    case 0:
        printf("Grade S\n");
        break;
    case 1:
        printf("Grade A\n");
        break;
    case 2:
        printf("Grade B\n");
        break;
    case 3:
        printf("Grade C\n");
        break;
    default:
        printf("Grade D\n");
        break;
}
基礎複習完了,大家可以回家了。

以下會討論到:
  • 在 switch-case 中宣告變數
    • switch 之下,case 之前
    • 在 case 中宣告變數
    • 不同 case 之間宣告相同名稱的變數
  • 不只是 if-else 的替代品 (對於一個變數的連續判斷)
而接下來的討論皆引用 C98/99 的規格書,需要一點 language syntax 的概念。
註:以下程式碼的測試環境為 Ubuntu 16.04 LTS,使用 gcc 5.4.0,編譯指令只用 gcc

在 switch-case 中宣告變數


Case 1: 宣告在 switch 之下,case 之前

看以下的例子:
switch (expr) {
    int i = 4;
    case 0:
        i = 12;
    default:
        printf("%d\n", i);
}
如果 expr 的結果是 0 的話,則輸出 12。那如果 expr 非 0 呢?
以我實際執行結果是輸出 0,但正確來說 i 的值是 indeterminate value。

  這邊產生一個疑問,無論如何 switch 至少會從 case 0 開始執行,也就是說 int i = 4; 是永遠不會被執行到的,那為甚麼後面還是可以使用 i 呢?

  先來看看規格書中的 switch 的 syntax (§6.8.4):
selection-statement:
        if ( expression ) statement
        if ( expression ) statement else statement
        switch ( expression ) statement
接著,下面解說中第三點提到:
A selection statement is a block whose scope is a strict subset of the scope of its enclosing block. Each associated substatement is also a block whose scope is a strict subset of the scope of the selection statement.
第一句提到 selection statement 是一個 block其 scope 是該 statement 所在的 scope 之中的 subset,例如在 selection statement 中宣告的變數是無法在外部被使用的。下一句則提到 selection statement 其下接的 statement 也是一個 block,而其 scope 為 selection statement 的 scope 的子 scope。

  規格書中提到:一個 object (變數也是一個 object) 的 lifetime 為程式在執行過程中幫他保留一個儲存空間的期間 (§6.2.4 第二點),而 §6.2.4 第五點提到關於在 block 中的 object 的 lifetime:
For such an object that does not have a variable length array type, its lifetime extends from entry into the block with which it is associated until execution of that block ends in any way.
可以知道當編譯器解析到 block 時,在 block 中的 object (非 static 宣告,也非長度可能會變動的 array) 會被配置一個記憶體空間,並保留到離開該 block。雖然會先被配置一個記憶體空間,但還是要注意使用變數只能在宣告之後。
再來第五點繼續提到這類 object 的初始化:
The initial value of the object is indeterminate. If an initialization is specified for the object, it is performed each time the declaration is reached in the execution of the block; otherwise, the value becomes indeterminate each time the declaration is reached.
如果該 object 的宣告有指定初始值時,當程式執行到該 block 中的初始化宣告後(以 assembly 去看)才會被初始化,否則其值是 indeterminate value。

[2018.6.10 補充]
這邊將上述例子的程式碼做編譯,並觀察產生的機械碼,結果如下圖:
指令為:gcc -c -g case.c 然後 objdump -S case.o > case.S


如果使用 gcc-7 的話,會出現 warning,警告說 i = 4 的初始化將不會被執行到:


整理一下:
  • 為甚麼沒有執行到 int i = 4; 卻可以使用 i ? 因為在 block 中的 object (非 static 宣告,也非長度可能會變動的 array) 在編譯器解析到該 block 時,會被保留一塊記憶體 (如果 i 的宣告是合法的),只是只有在執行到指定初始值的機械碼才會被初始化。只要在宣告之後、同一個 scope 之下就可以使用。(其他類型的變數之配置方式可參考 §6.2.4)
  • 為甚麼 i 的值是 indeterminate value ? 呈上的解釋,只有在執行到指定初始值的機械碼才會被初始化,否則為任意值。而在上面的例子中,switch 內是永遠不會執行到初始化這行。而同樣的程式碼在 C++ 是不會通過的,因為 C++ 不允許為初始化的變數,編譯會得到 "error: jump to case label, note: crosses initialization of 'int i'" 這樣的訊息。

Case 2: case 中宣告變數

來看在 case 中宣告變數最常遇到的錯誤程式碼:
switch (expr) {
    case 0:
        int i = 0;
        printf("%d\n", i);
        break;
    default:
        printf("Exception\n");
}
這份程式碼會編譯失敗,會得到 "error: a label can only be part of a statement and a declaration is not a statement" 這樣的訊息。通常上網搜尋 (這篇的討論很豐富,建議看完) 會得到在 case 0: 後加上 ; 或是利用 {} 將 case 中的 statement 包起來。

這裡將著重在為甚麼這樣行的通,而且為甚麼原本的寫法是錯的。

來看一下規格書中對於 case label 的 syntax 定義 (§6.8.1):
labeled-statement:
        identifier : statement
        case constant-expression : statement
        default : statement
可以知道 case label 之後要接的是 statement,而變數的宣告是 declaration,會不符合這邊的文法,編譯器也給出對應的訊息。

接著看為甚麼這樣改可以改正,我們已經知道 case label 後面要接 statement,先來看 statement 的 syntax (§6.8):
statement:
        labeled-statement
        compound-statement
        expression-statement
        selection-statement
        iteration-statement
        jump-statement
; 屬於 expression statement (§6.8.3):
expression-statement:
        expressionopt ;
{} 則屬於 compound statement (§6.8.2):
compound-statement:
        { block-item-listopt }
block-item-list:
        block-item
        block-item-list block-item
block-item:
        declaration
        statement
另外也可以發現,在 compound statement 中可以有 declaration

所以這邊討論到解決 case statement 之後不能直接宣告變數的方法有三 (當然不只這三種,看看 statement 有那麼多可以選):

  1. 延後宣告變數,把同一個 case 之下非宣告的程式碼往前移;
  2. ; 成為 case statement 的第一個 statement;
  3. {} 將同一個 case 的 statement包起來

Case 3: 在不同 case 中宣告相同名稱變數

另一個常見的問題就如下面的程式碼:
switch (expr) {
    case 0:;
        int i = 0;
        ....
        break;
    default:;
        int i = 0;
        ...
        break;
}
餵給編譯器會得到 "error: redefinition of 'i'" 的訊息。

回憶一下 switch statement 的 syntax (§6.8.4):
selection-statement:
        if ( expression ) statement
        if ( expression ) statement else statement
        switch ( expression ) statement
以及解說的第三點:
 A selection statement is a block whose scope is a strict subset of the scope of its enclosing block. Each associated substatement is also a block whose scope is a strict subset of the scope of the selection statement.
在前面的討論中知道 selection statement 的子 statement 是一個 block,其 scope 為 selection statement 的子 scope (不是所有 statement 都是 block,注意是 selection statement 的子 statement 才會是一個 block,而 block 有 block scope (scope 的一種))。雖然每個 case 都在 switch 下,但它是屬於 labeled statement,其接的 ; 是 expression statement,也不是一個 block。

  可以發現 switch 只有一個 statement,代表在裡面宣告的變數之 scope 都是處於同一層。所以例子中 case 0idefaulti 都是在同一個 scope 之下,同一個 scope 不能有兩個以上一樣的變數名稱,除非是散佈在不同的子 scope 之下。解決方法就是利用 {} 為每個 case statement 建立一個子 scope,讓變數宣告在其中。如下:
switch (expr) {
    case 0: {
        int i = 0;
        ....
        break;
    }
    default: {
        int i = 0;
        ...
        break;
    }
}

不只是 if-else 的替代品


  之前上課時因為作業的關係讀到 jserv 老師的一篇利用 macro 實作 coroutine 的文章,讓我對於 switch-case 重新理解。當初為了理解老師文章中的程式碼運作,我做了一個類似的小程式來驗證自己的想法:
static void caseInForLoop()
{
    static int __s = 0;
    switch (__s) {
        case 0:
            for (;;) {
                printf("Run to case 0.\n");
                __s = 1;
                return;
        case 1:
                printf("Run to case 1.\n");
            }   
    }
}

int main(void)
{
    caseInForLoop();
    printf("---\n");
    caseInForLoop();
    return 0;
}
得到的輸出是:
Run to case 0.
---
Run to case 1.
Run to case 0.
應該會注意到,兩個 case label 將一個 for loop 拆成兩個部分,挖 新世界阿!
執行分析:
  • 第一次執行沒有問題,一開始 __s 的值為 0,所以會印出 case 0 的訊息,然後值被改為 1,之後回傳。
  • 然而第二次執行時,__s 值為 1,從輸出可以知道他跳到 case 1 開始執行,之後又回到迴圈頭重新執行,所以又會再輸出一次 case 0 的訊息
  後來我理解到,在 switch 中會依照其 expression 的值決定從哪個 case label 開始執行,直到遇到 return;break; 或 離開 switch。而因為一開始理解為 if-else 的取代品,所以會認為 case 與 case 之間的程式碼應該要分的乾乾淨淨,但其實沒有規定一定要這樣寫。

這部分是用來幫助理解 jserv 老師那篇文章的運作原理,詳細實作就請閱讀老師的文章吧。

還有更多 switch-case 有趣的運用:



筆記就到這邊結束,感謝耐心讀完 m(_ _)m,歡迎大家討論與指正。

15 則留言:

  1. switch(x)
    {int y=20;
    case 1: printf("y is %d\n",y);
    break;
    default: printf("y is %d\n",y);
    break;
    }
    return 0;
    }
    ------------------------------------
    實際執行並非跳過,而是compile失敗,若只有宣告而沒初始化才是跳過,
    若該段敘述宣告為static(static int y = 20;),則結果正確,不明白為何如此?
    懇請樓主解答一下

    回覆刪除
    回覆
    1. 你好,不知道你是使用什麼樣的 compiler? 我在 ubuntu 環境下使用 gcc-5 跟 gcc-7 都可以編譯成功,另外 gcc-7 會出現 warning,警告說這行初始化將不會執行到 (-Wswitch-unreachable)

      至於 static variable (無論 local 或是 global) 是屬於 static storage,其 lifetime 為整個程式的執行期間,且在程式執行之前就會被初始化過 (而且只會被初始化一次)
      參見規格書 6.2.4 第 3 點:
      An object whose identifier is declared with external or internal linkage, or with the storage-class specifier static has static storage duration. Its lifetime is the entire execution of the program and its stored value is initialized only once, prior to program startup.

      至於如果沒有指定初始化的值給 static storage duration 的 object 的話,如果他是 arithmetic type (即 int ,float, double 等) 就會被初始化成 0 (規格書 6.7.8 第 10 點)

      刪除
  2. 一開始那裡看不明白,那些圖片看不明!???

    回覆刪除
  3. 請問一下case裡面可以用for迴圈嗎?
    例如:case 0x01 :
    for(i=0;i<5;i++)
    {
    ton=1600;
    for(i=0;i<30;i++)
    {
    ton=1500;
    }
    }
    break;

    回覆刪除
    回覆
    1. 可以的喔,for 迴圈是 iteration-statement

      刪除
  4. block-item-list block-item 裡的list是指什麼?

    回覆刪除
    回覆
    1. 跟據規則書是這樣:
      block-item-list:
      block-item
      block-item-list block-item
      block-item-list 是遞回的,所以它可以展開成 block-item-list block-item,直到只剩 block-item,結果會是一連串的 block-item,

      刪除
  5. 還有一個問題
    1.case 是屬於 labeled statement,其接的 statement 不是一個 block
    2.解決方法就是利用 {} 為每個 case statement 建立一個子 scope
    3. compound-statement:
    { block-item-listopt }
    那這樣看{}不就是1.所說的case 後面接的第一個statement, 怎麼會有子scope?

    回覆刪除
    回覆
    1. 使用 {} 就會產生一個 scope,為每一個 case 建立一個子 scope,在裡面宣告的變數就會在個自的 scope 下,否則原本是在 switch 的 scope 之下

      刪除
    2. {}是一個子scope,直觀上理解。
      可是這樣跟1.不衝突? 不是一個block就不會有scope 不是這樣理解?

      刪除
    3. 喔對 抱歉,我這裡指的是原本 case 1: ; 中
      ; 是屬於 expression-statement,而 expression-statement 不是一個 block

      刪除
    4. 瞭解了,謝謝烏龜大的回覆!

      刪除