直接調用、間接調用和內聯調用


一般情況下,當C或者C++編譯器遇到一個非內聯函數的定義時,它會為該函數的定義生成機器碼,並把這些機器碼存儲在一個目標文件中。同時,它還創建了一個與這些機器碼相關聯的名稱。在C中,這個名稱通常就是函數本身的名稱;而在C++中,該名稱還要加上參數類型的編碼,從而即使在出現函數重載的情況下,也能夠獲得唯一的名稱(最后這個名稱通常稱為mangled name,有時也稱為decorated name)。譬如,當編譯器看到一個如下的調用:

f()

它將會生成函數f的機器碼。對於大多數機器語言來說,調用指令本身需要例行程序f的起始位置。這時就出現了兩種情況:該起始位置可能成為指令的一部分(在這種情況下,這種指令也被稱為直接調用),也可能位於內存或機器寄存器的某處(間接調用)。事實上,大多數現代的計算機體系結構都提供了這兩種程序調用指令;但是直接調用的執行效率比間接調用要高出不少(這里不討論)。實際上,隨着計算機體系結構的不斷復雜化,直接調用和間接調用之間的效率差距也不斷增大。因此,編譯器通常都會盡可能地生成直接調用指令。

通常而言,編譯器剛開始並不知道函數究竟位於什么地址(例如,函數可以位於其他翻譯單元)。然而,如果編譯器知道了函數的名稱,那么它首先會生成一個不含地址的調用指令——或者稱為一個地址仍未確定的調用指令。另外,編譯器在目標文件中還會生成一個實體,借助這個實體,鏈接器在后面能夠更新上面創建的調用指令,使它的地址指向給定名稱的函數,從而成為一個地址確定的調用指令。鏈接器之所以能夠完成這些功能,是因為它能夠見到創建自所有翻譯單元的所有目標文件,也就是說:鏈接器在看到函數定義的位置的同時,也看到了函數調用的位置,因此能夠確定直接調用的具體位置。

遺憾的是,當函數名稱並不確定的時候,就只能使用間接調用了。使用函數指針進行調用的例子通常就都屬於這種情況:

void foo (void (*pf)() )
{
    pf(); // 通過函數指針pf進行間接調用
}

在這個例子中,鏈接器通常都不能夠知道參數pf究竟指向哪一個函數(也就是說,對於foo()的不同調用,pf所指向的函數就可能不同)。因此,編譯器並不能根據pf來匹配任何名字:而是要到代碼實際執行的時候,才能夠知道具體的調用目標是什么函數。

對於現代的計算機而言,盡管執行直接調用指令的速度和執行其他一般的指令相差無幾(例如,執行對兩個整數進行求和的指令),但是函數調用仍然是一個比較嚴重的性能障礙。考慮如下代碼:

int f1(int const& r)
{
    return ++(int&)r;        // 不合理,但卻是合法的
}

int f2(int const& r)
{
    return r;
}

int f3()
{
    return 42;
}

int foo()
{
    int param = 0;
    int answer = 0;
    f2(param);
    f3();
    return answer + param;
}

函數f1接收一個const int的引用實參,這個const關鍵字意味着函數不會修改該引用實參所引用的對象。然而,如果這個引用的對象是一個可修改的值,那么C++程序可以合法地去除這個const屬性(約束),也就是說能夠改變這個對象的值(你可能會認為這是很不合理的,但這的的確確是標准C++所允許的),函數f1的行為正是如此。由於存在這種(修改const所引用的值)的可能性,所以對於那些要對函數所生成代碼進行優化的編譯器(實際上大多數編譯器都是這樣),就必須假設:每個接收(指向對象的)引用或者指針的函數都可能修改所指向對象的值。另外我們還應該清楚一點:通常情況下,編譯器只是看到函數的聲明,而函數的定義(或者稱為實現)通常位於另一個翻譯單元。

因此,大多數編譯器都會假設上面代碼的f2()也會修改param的值(即使實際操作並沒有修改param的值)。實際上,編譯器同樣也不能假設f3()並不會修改局部變量param的值,因為函數f1()和f2()都可能會把param的地址存儲到一個全局可訪問的指針中,於是,從編譯器的角度看來,f3()是完全有可能通過這個全局可訪問指針修改param的值的。所以,這種不確定的效果令大多數編譯器都不知道應該如何對待各種對象,從而也就不能夠把這種對象的過程值(或者稱為中間值)存儲在快速寄存器中,而只能存儲於內存中。因此,涉及到機器代碼移動的優化,也就受到了很大的限制(通常而言,函數調用會對代碼移動形成一個障礙)。

另一方面,存在一些高級的C++編譯系統,它們可以跟蹤潛在別名的許多實例(潛在別名是指:f1()的作用域中的表達式r,就是foo()作用域中param所命名對象的一個別名),這種特性的代價是:編譯速度、資源使用量和代碼可靠性。

然而,通過使用內聯,就可以大大幫助普通編譯器進行優化。假設前面的f1(), f2()和f3()都被聲明為內聯函數,那么foo()的代碼就可以被轉化為大體於下面等價的代碼:

int foo_ex()
{
    int param = 0;
    int answer = 0;
    answer = ++(int&)param;
    return answer+param;
}

而一個普通的優化器可以馬上把上面的代碼變成:

int foo_ex() 
{  
     return 2; 
}

 

這就充分闡明了這里使用內聯的優點:在一個調用系列中,不但能夠避免執行這些(查找名稱的)機器代碼;而且能夠讓優化器看到函數對傳遞進來的變量進行了哪些操作。

使用基於模板的回調來生成機器碼的話,那么這些機器碼將主要涉及到直接調用和內聯調用;而如果用傳統的回調的話,那么將會導致間接調用。使用模板的回調將會大大節省程序的運行時間。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM