笨辦法學C 練習23:認識達夫裝置

飛龍發表於2019-05-11

練習23:認識達夫裝置

原文:Exercise 23: Meet Duff`s Device

譯者:飛龍

這個練習是一個腦筋急轉彎,我會向你介紹最著名的C語言黑魔法之一,叫做“達夫裝置”,以“發明者”湯姆·達夫的名字命名。這一強大(或邪惡?)的程式碼中,幾乎你學過的任何東西都被包裝在一個小的結構中。弄清它的工作機制也是一個好玩的謎題。

C的一部分樂趣來源於這種神奇的黑魔法,但這也是使C難以使用的地方。你最好能夠了解這些技巧,因為他會帶給你關於C語言和你計算機的深入理解。但是,你應該永遠都不要使用它們,並總是追求簡單易讀的程式碼。

達夫裝置由湯姆·達夫“發現”(或創造),它是一個C編譯器的小技巧,本來不應該能夠正常工作。我並不想告訴你做了什麼,因為這是一個謎題,等著你來思考並嘗試解決。你需要執行這段程式碼,之後嘗試弄清它做了什麼,以及為什麼可以這樣做。

#include <stdio.h>
#include <string.h>
#include "dbg.h"


int normal_copy(char *from, char *to, int count)
{
    int i = 0;

    for(i = 0; i < count; i++) {
        to[i] = from[i];
    }

    return i;
}

int duffs_device(char *from, char *to, int count)
{
    {
        int n = (count + 7) / 8;

        switch(count % 8) {
            case 0: do { *to++ = *from++;
                        case 7: *to++ = *from++;
                        case 6: *to++ = *from++;
                        case 5: *to++ = *from++;
                        case 4: *to++ = *from++;
                        case 3: *to++ = *from++;
                        case 2: *to++ = *from++;
                        case 1: *to++ = *from++;
                    } while(--n > 0);
        }
    }

    return count;
}

int zeds_device(char *from, char *to, int count)
{
    {
        int n = (count + 7) / 8;

        switch(count % 8) {
            case 0:
            again: *to++ = *from++;

            case 7: *to++ = *from++;
            case 6: *to++ = *from++;
            case 5: *to++ = *from++;
            case 4: *to++ = *from++;
            case 3: *to++ = *from++;
            case 2: *to++ = *from++;
            case 1: *to++ = *from++;
                    if(--n > 0) goto again;
        }
    }

    return count;
}

int valid_copy(char *data, int count, char expects)
{
    int i = 0;
    for(i = 0; i < count; i++) {
        if(data[i] != expects) {
            log_err("[%d] %c != %c", i, data[i], expects);
            return 0;
        }
    }

    return 1;
}


int main(int argc, char *argv[])
{
    char from[1000] = {`a`};
    char to[1000] = {`c`};
    int rc = 0;

    // setup the from to have some stuff
    memset(from, `x`, 1000);
    // set it to a failure mode
    memset(to, `y`, 1000);
    check(valid_copy(to, 1000, `y`), "Not initialized right.");

    // use normal copy to 
    rc = normal_copy(from, to, 1000);
    check(rc == 1000, "Normal copy failed: %d", rc);
    check(valid_copy(to, 1000, `x`), "Normal copy failed.");

    // reset
    memset(to, `y`, 1000);

    // duffs version
    rc = duffs_device(from, to, 1000);
    check(rc == 1000, "Duff`s device failed: %d", rc);
    check(valid_copy(to, 1000, `x`), "Duff`s device failed copy.");

    // reset
    memset(to, `y`, 1000);

    // my version
    rc = zeds_device(from, to, 1000);
    check(rc == 1000, "Zed`s device failed: %d", rc);
    check(valid_copy(to, 1000, `x`), "Zed`s device failed copy.");

    return 0;
error:
    return 1;
}

這段程式碼中我編寫了三個版本的複製函式:

normal_copy

使用普通的for迴圈來將字元從一個陣列複製到另一個。

duffs_device

這個就是成為“達夫裝置”的腦筋急轉彎,以湯姆·達夫的名字命名。這段有趣的邪惡程式碼應歸咎於他。

zeds_device

“達夫裝置”的另一個版本,其中使用了goto來讓你發現一些線索,關於duffs_device中奇怪的do-while做了什麼。

在往下學習之前仔細瞭解這三個函式,並試著自己解釋程式碼都做了什麼。

你會看到什麼

這個程式沒有任何輸出,它只會執行並退出。你應當在Valgrind下執行它並確保沒有任何錯誤。

解決謎題

首先需要了解的一件事,就是C對於它的一些語法是弱檢查的。這就是你可以將do-while的一部分放入switch語句的一部分的原因,並且在其它地方的另一部分還可以正常工作。如果你觀察帶有goto again的我的版本,它實際上更清晰地解釋了工作原理,但要確保你理解了這一部分是如何工作的。

第二件事是switch語句的預設貫穿機制可以讓你跳到指定的case,並且繼續執行知道switch結束。

最後的線索是count % 8以及頂端對n的計算。

現在,要理解這些函式的工作原理,需要完成下列事情:

  • 將程式碼抄寫在一張紙上。

  • 當每個變數在switch之前初始化時,在紙的空白區域,把每個變數列在表中。

  • 按照switch的邏輯模擬執行程式碼,之後再正確的case處跳出。

  • 更新變數表,包括tofrom和它們所指向的陣列。

  • 當你到達while或者我的goto時,檢查你的變數,之後按照邏輯返回do-while頂端,或者again標籤所在的地方。

  • 繼續這一手動的執行過程,更新變數,直到確定明白了程式碼如何運作。

為什麼寫成這樣?

當你弄明白它的實際工作原理時,最終的問題是:問什麼要把程式碼寫成這樣?這個小技巧的目的是手動程式設計“迴圈展開”。大而長的迴圈會非常慢,所以提升速度的一個方法就是找到迴圈中某個固定的部分,之後在迴圈中複製程式碼,序列化地展開。例如,如果你知道一個迴圈會執行至少20次,你就可以將這20次的內容直接寫在原始碼中。

達夫裝置通過將迴圈展開為8個迭代塊,來完成這件事情。這是個聰明的辦法,並且可以正常工作。但是目前一個好的編譯器也會為你完成這些。你不應該這樣做,除非少數情況下你證明了它的確可以提升速度。

附加題

  • 不要再這樣寫程式碼了。

  • 查詢維基百科的“達夫裝置”詞條,並且看看你能不能找到錯誤。將它與這裡的版本對比,並且閱讀文章來試著理解,為什麼維基百科上的程式碼在你這裡不能正常工作,但是對於湯姆·達夫可以。

  • 建立一些巨集,來自動完成任意長度的這種裝置。例如,你想建立32個case語句,並且不想手動把它們都寫出來時,你會怎麼辦?你可以編寫一次展開8個的巨集嗎?

  • 修改main函式,執行一些速度檢測,來看看哪個實際上更快。

  • 查詢memcpymemmovememset,並且也比較一下它們的速度。

  • 不要再這樣寫程式碼了!

相關文章