inline: 我的理解還停留在20年前

張哥說技術發表於2023-02-16


你好,我是雨樂~

在上篇文章訪問私有變數——從技術實現的角度破壞"封裝"性一文中,在第二個實現示例中,用到了inline 變數,一開始,是懵逼的,因為在我的印象中inline 僅僅函式,而在此處卻用於宣告變數。於是,趕緊去查閱資料,發現自CPP17開始,引入了inline 變數,這個時候突然不是那麼自責了,畢竟我的cpp知識積累止步於cpp11。不過,為了研究那段程式碼,還是仔細研究了下,不看不要緊,一看嚇一跳,原來我對inline的理解停留在n年前。於是趕緊惡補這方面的知識,而這篇文章呢,就是我最近研究的一個知識點總結。

狹隘的理解

inline源於C,與關鍵字register作用一樣,旨在讓編譯器進行最佳化,inline的思想源自於C的預處理宏,而後者又源自組合語言。其目標是省略因為函式呼叫而引起的部分開銷。與預處理宏不一樣的是,inline支援型別檢查,而這就是inline引入C++的初衷(旨在具有宏的功能,且支援型別檢查)。

在編譯過程中,編譯器維護了一組資料結構,稱之為**符號表(Symbol Table)**。對於普通函式,編譯器只把函式名稱(對於c++來說,需要經過name mangling,畢竟執行函式過載,而C則不需要)和返回值記錄在符號表裡。而對於inline函式(編譯器確認可以inline的),除上述的函式名稱和返回值之外,也將函式的實現(究竟存放原始碼還是編譯後的彙編指令就看編譯器的實現了)放在符號表中。當遇到行內函數的呼叫時,編譯器首先檢查呼叫是否正確(引數型別檢查,返回結果是否被正確使用——對於普通函式也進行這些檢查),檢查無誤後將行內函數的函式體替換掉對它的呼叫,從而省去呼叫函式的開銷(引數入棧,彙編CALL等),這就是inline後效能優於普通函式呼叫的原因。

當然了,編譯器是否決定inline,有它自己的規則,程式碼中指定inline關鍵字也只是建議編譯器內聯,最終是否真正inline取決於具體場景。

以上,就是我對inline的理解,也就是說在之前,我的錯誤理解是inline作用僅限於inline function,即編譯時進行指令替換

概念

在閱讀本文後面的章節之前,需要先了解兩個概念ADLODR

ADL

ADL是Argument Dependent Lookup的縮寫,又稱為Koenig Lookup(最開始以發明人的名稱進行命名),一般譯為引數依賴查詢,是用於在函式呼叫表示式中查詢非限定函式名稱的規則集。

可以理解為如果在使用函式的上下文中找不到函式定義,我們可以在其引數的名字空間中查詢該函式的定義。

c++11標準對其定義如下:

When the postfix-expression in a function call (5.2.2) is an unqualified-id, other namespaces not considered during the usual unqualified lookup (3.4.1) may be searched, and in those namespaces, namespace-scope friend function declarations (11.3) not otherwise visible may be found. These modifications to the search depend on the types of the arguments (and for template template arguments, the namespace of the template argument).

這種方式其實我們經常用到,比如,在上篇文章訪問私有成員——從技術實現的角度破壞"封裝" 性友元函式那一塊已經用到了(在類內進行函式定義(引數為類型別),類外無序宣告可以直接呼叫),只是沒有留意罷了~~

透過個例子來簡單理解下,該例子來源於stackoverflow:

namespace MyNamespace {
    class MyClass {};
    void doSomething(MyClass) {}
}

MyNamespace::MyClass obj; // global object

int main() {
    doSomething(obj); // Works Fine - MyNamespace::doSomething() is called.
}

如上例,doSomething()首先在其上下文中查詢定義(namespace的除外),沒有找到,然後依賴了ADL規則,在其引數obj所在範圍(MyNamespace)內找到了定義,所以編譯正常。

ODR

ODR是One definition Rule的縮寫,中文稱之為單一定義規則

cppreference中的定義如下:

Only one definition of any variable, function, class type, enumeration type, concept (since C++20) or template is allowed in any one translation unit (some of these may have multiple declarations, but only one definition is allowed).

One and only one definition of every non-inline function or variable that is odr-used (see below) is required to appear in the entire program (including any standard and user-defined libraries). The compiler is not required to diagnose this violation, but the behavior of the program that violates it is undefined.

從上述定義,可以看出,對於宣告為非inline的函式或者變數,在整個程式裡只允許有一個定義。而如果有多個的話,則會破壞ODR原則,在連結階段因為多個符號衝突而失敗。

C++程式通常由多個C++原始檔組成(.cc/.cpp等),編譯器在進行編譯的時候,通常是將這些檔案單獨編譯成模組或者目標檔案,然後透過連結器將所有模組/目標檔案連結到一個可執行檔案或共享/靜態庫中。

在連結階段,如果連結器可以找到多個同一個符號的定義,則認為是錯誤的,因為其不知道使用哪個,這個時候,就會出現連結器報錯,如下這種:

error: redefinition of 'xxx'

而這個報錯原因,就是因為沒有遵循ODR原則,下圖易於理解:

inline: 我的理解還停留在20年前

也就是說,函式或者變數在整個程式中只能定義一次(全域性,非namespace 非inline等),而這種規則,往往使得我們在編碼的時候,將宣告放到某個標頭檔案,比如header.h,而將定義放在header.cc。但是,往往在多人協作專案中,這種很難滿足,比如對於函式名相同,引數相同,而實現不同,對於這種如果不採取其他方式的話,往往就會破壞ODR原則,導致連結失敗。對於這種情況,往往使用static定義、namespace以及本文要講的inline

inline function

下面看下inline function的定義:

An inline function is one for which the compiler copies the code from the function definition directly into the code of the calling function rather than creating a separate set of instructions in memory.

從上面的定義可以看出,對於宣告為inline的函式,在呼叫該inline函式的時候,編譯器會直接進行程式碼替換,也就是說省略了函式呼叫這個步驟。

我們先看一段程式碼,如下:

inline int add(int a, int b){
    return a + b;
}

int main(){
    int x = 3
    int y = 4;
    int z = add(x, y);
    // do sth
    return 0;
}

編譯器會將上述程式碼最佳化為:

int main(){
    int x = 3
    int y = 4;
    int z = x + y; // inline 程式碼替換
    // do sth
    return 0;
}

當然,上述是從編譯器對inline函式處理的角度來理解的,往往編譯器會進行更加直接的最佳化,即最佳化成int z = 7

以上,可能就是大部分人認為的inline function,即對function 加 inline關鍵字以建議編譯器將該函式進行inline。

但是,建議往往是建議,對於編譯器來說,大部分的建議都不會被採納,它(編譯器)總是有自己的理由來決定在什麼地方進行inline,什麼地方進行函式呼叫,也就是說,編譯器比開發人員更加清楚什麼地方應該inline。或者說,大部分人認為的inline function,在理解上是狹隘的,或者說,對於Modern CPP來說,這種理解是錯誤的,是過時的。

inline 關鍵字用於函式,有兩個作用,第一個作用(相對老版本編譯器來說),就是前面說的(指令或者程式碼替換);而第二個,使得在多個翻譯單元(Translation Unit, 在此可以理解為.cc/.cpp等原始檔)定義同名同參函式成為了可能。

先看下面的程式碼:

file1.cc

int f() { 
  return 0
}

int main() { 
  return f(); 
}

file2.cc

int f() { return 0; }

使用如下命令進行編譯:

gcc file1.cpp file2.cpp

在連結的時候,報錯如下:

file2.cc:(.text+0x0): multiple definition of `f()'

相信這種報錯,大家都遇到過,而且不止一次。這是因為編譯器在進行編譯的時候,是以(.cc/cpp等)檔案為單元進行單獨編譯成.o檔案,然後在連結階段對這些.o檔案進行連結,發現有重複定義,這也就有了上面的報錯,這種錯誤的根本原因就是違反了ODR原則

這個時候,就是inline大顯身手的時候。

在定義函式的時候,前面加上inline關鍵字,就可以避免上面的重複定義錯誤,這種做法相當於告訴編譯器:在編譯的時候,遇到這種包含inline關鍵字的重複定義函式,不用再報錯了?。

仍然是上述程式碼,唯一的區別就是在函式定義部分加了inline

file1.cc

inline int f() { 
  return 0
}

int main() { 
  return f(); 
}

file2.cc

inline int f() { return 0; }

編譯,連結一切正常。

好了,現在回顧下前面那個例子報錯的原因(重複定義嘛,廢話)。編譯器在編譯的時候,只針對當前Translation Unit,也就是說編譯器無法訪問本翻譯單元之外的目標檔案(也就是說在編譯當前檔案的時候,不能查詢之前的已經編譯完成的目標檔案是否有該函式定義),因此這種錯誤往往暴露在連結階段,因為連結階段每個函式僅允許有一個定義體。而對於具有關鍵字inline的函式宣告或者定義,連結器在連結階段,一但發現具有多個定義的inline函式,其只取一個,因此,對於同名同參的inline函式,如果其實現不同,則會引起未定義行為(連結器只取其中一個,具體規則依賴於編譯器)。

對於現在的編譯器來說,inline 函式的功能更趨向於解決ODR問題,而至於傳統意義理解上的替換等則可以忽略,這個僅僅是開發人員對編譯器的一種建議,是否替換完全由編譯器決定。

No matter how you designate a function as inline, it is a request that the compiler is allowed to ignore: the compiler might inline-expand some, all, or none of the places where you call a function designated as inline.

inline variable

在C++中,類內變數的初始化經歷了多次變動,每一次的變動都是因為前一次的初始化方式太過麻煩,究根到底,還是因為類內成員的初始化不能像一般變數一樣,在宣告的同時就加以定義。

假設有一個類Test,在C++11之前,初始化其變數,往往是這種方式:

class Test {
 public:
    Test() : value_(0)  {}
 private:
     int value_;
};

如果多一個建構函式的話,我們得這樣寫:

class Test {
 public:
     Test() : value_(0)  {}
     Test(bool) : value_(0) {}
 private:
     int value_;
};

這種初始化變數的方式的缺點顯而易見,有幾個建構函式,就得初始化幾次變數,很麻煩,且一不小心就容易出錯。

為了解決上述問題,在C++11起,可以在類內直接對變數進行初始化,即支援non-static data member initializer,如下:

class Test {
 private:
     int value_ = 0;
};

這樣一來,即使有多個建構函式,成員變數初始化也僅需一次即在宣告的時候直接進行初始化,而且便於閱讀。

奈何歷史債務還是太多了,C++11支援對非靜態成員進行直接初始化,但是靜態成員呢?貌似跟pre cpp11一樣,沒啥變化,如下:

class Test {
public:
    static int value;
};

int Test::value = 1;

如果我們直接在類內對靜態變數進行定義的話,如下:

class Test {
 private:
     static int value_ = 0;
};

則會編譯失敗,報錯如下:

error: ISO C++ forbids in-class initialization of non-const static member 'Test::value_'

為了像cpp11支援類內初始化成員變數一樣,自cpp17起,對於靜態成員也支援在宣告時候進行初始化,即:

class Test {
 private:
     inline static int value_ = 0;
};

與inline function一樣,inline variable也允許在多個編譯單元對同一個變數進行定義,並且在連結時只保留其中的一份作為該變數的定義。當然,同時在多個原始檔中定義同一個inline變數必須保證它們的定義都相同,否則和inline函式一樣,你沒辦法保證連結器最終採用的是哪個定義。

inline variable除了支援類內靜態成員初始化外,也支援標頭檔案中定義全域性變數,這樣不會違反ODR規則。

inline namespace

inline namespace自c++11引入,其主要作用在於版本控制,開發人員可以在namespace內建立抽象層,進而進行不同的版本切換,而無需對程式碼進行太多更改。

假設有這樣一個場景,作為開發人員,我們需要對外提供一個庫,作為該庫的提供者,在版本升級的時候,需要做到向下相容,而不是每次都升級後都需要使用者重新使用該最新的庫編譯其專案,換句話說,庫的升級,要求對之前的版本無影響,而只有使用最新庫的專案才能使用其最新的功能

在專案最初,我們提供的庫原始碼如下:

namespace mylib {
  namespace v1 {
    void foo();
  }
  using namespace v1; 
}

將該庫交付出去後,使用者可以透過mylib::foo()(實際上呼叫的是mylib::v1::foo())這種方式進行呼叫,一切正常。

過了一段時間後,需要進行版本更迭,對該庫進行升級,程式碼如下:

namespace mylib {
  namespace v1 {
    void foo();
  }
  namespace v2 {
    void foo();
  }
  
  using namespace v2;
}

好了,截止到目前,我們版本升級正常,在c++11之前,也確實是這麼做的,不過這麼做也確實有其侷限性,也有很多場景下,使用using namespace具有其侷限性。

override

如下程式碼場景:

namespace mylib {
    namespace v1 {
      void foo() {}
      void foo(int a){}
    }

    using namespace v1;
    void foo(char *str){}
}

int main() {
  mylib::foo("abc"); // 編譯成功
  mylib::foo(1); // 編譯失敗
  mylib::foo(); // 編譯失敗
  
  return 0;
}

雖然透過using namespace讓namespace v1下的兩個函式foo()和foo(int)暴露在mylib下,但是外層的foo(char*)又把v1下的兩個foo()函式覆蓋了,這就main()中mylib::foo("abc")編譯成功,而mylib::foo(1)和mylib::foo()編譯失敗的原因。

template specialization

不能使用using namespace的場景就行模板特化。

假設我們提供了個庫,其程式碼如下:

namespace mylib {
  namespace lib {
    template<typename T> class MyClass{};
  }
  using namespace lib;
}

使用者透過mylib::MyClass

namespace mylib {
  template<> class MyClass<Object>{};
}

編譯失敗,這是因為c++98中,模板特化必須放在模板所需空間內,也就是說需要這樣做:

namespace mylib {
  namespace lib {
    template<> class MyClass<Object>{};
  }
}

上面這種做法無疑是沒問題的,但這樣做的話,會暴露程式碼命名規則(雖然只是部分)。

ADL

示例如下:

namespace mylib {
    class Class1 {};
    namespace lib {
        void foo1(Class1) {};
        class Class2 {};
    }
    
    using namespace lib;
    void foo2(Class2) {};
}

int main() {
    mylib::Class1 c1;
    mylib::Class2 c2;
    foo1(c1);
    foo2(c2);
}

上述程式碼編譯失敗,這是因為使用using namespace只能保證被看到,而ADL規則在乎的則是被定義(這塊比較抽象?)。

inline

在前面的內容中,提到了使用using namespace也有其侷限性,並不能滿足所有場景,這個時候inline閃亮出場~~

對於覆蓋問題,在namespace v1前加上inline即可,程式碼如下:

namespace mylib {
    inline namespace v1 {
      void foo() {}
      void foo(int a){}
    }
    void foo(char *str){}
}

int main() {
  mylib::foo("abc"); // 編譯成功
  mylib::foo(1); // 編譯成功
  mylib::foo(); // 編譯成功
  
  return 0;
}

對於模板特化,使用inline如下:

namespace mylib {
  inline namespace lib {
    template<typename T> class MyClass{};
  }
}

namespace mylib {
  template<> class MyClass<Object>{};
}

同樣,對於ADL,仍然可以使用inline,如下:

namespace mylib {
    class Class1 {};
    inline namespace lib {
        void foo1(Class1) {};
        class Class2 {};
    }
    void foo2(Class2) {};
}

int main() {
    mylib::Class1 c1;
    mylib::Class2 c2;
    foo1(c1);
    foo2(c2);
}

依然回到本節一開始的內容中,提到了inline namespace主要被用來做版本控制,依據上面的規則,我們很容易寫出迭代程式碼:

namespace mylib {
    namespace v1 {
        void foo();
    }
}

// 版本2
namespace mylib {
    namespace v1 {
        void foo();
    }
    
    inline namespace v2 {
        void foo();
    }
}

進退有度

示例程式碼如下:

namespace mylib {
  inline namespace lib1 {
    struct Object{};
  }
  
  namespace lib2 {
    Object a; // ok,使用A::Object
    struct Object{};
    Object c; // ok, 使用C::Object
    lib1::Object d; // ok,使用A::Object
  }
}

透過之前的內容,我們瞭解到加了inline的子namespace,對於其父namespace來說,就像在父namespace中宣告定義的一樣,即在namespace lib2中,變數a的型別是lib1::Object,而在lib2::Object物件定義後,c的型別為lib2::Object,也就是說其覆蓋了之前的lib1::Object,顯然破壞了namespace的隔離性(namespace為了隔離性而引入),而這也是Google Style建議不要使用inline namespace的原因。

俗話說,存在即合理,在使用方式上恰當的使用,往往會帶來更多的便捷,否則...

今天的文章就到這,我們下期見!

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/70024923/viewspace-2935569/,如需轉載,請註明出處,否則將追究法律責任。

相關文章