手把手教你實現boost::bind

haolujun發表於2018-04-16

前言

boost::bind操作想必大家都使用過,它特別神奇,能夠繫結函式與引數,繫結後能夠改變引數數量,並且還可以使用佔位符。它可以繫結普通函式也可以繫結類成員函式。好多小夥伴試圖看過boost::bind的原始碼,但是可能效果不佳。原因在於boost::bind的程式碼考慮了很多使用情況,而且還要相容各種編譯環境,所以實現的程式碼很複雜,很容易在看原始碼的時候被各種巨集定義帶跑偏,以至於亂了思路。在這裡我試圖抽出boost::bind核心骨架,適當簡化,達到簡單可理解的目的。

本文通過一個簡單的例子逐漸引出bind的模板化,並探討其引數列表、bind_t、以及必要的萃取函式的實現。本文所展示的程式碼,只用於探討交流之用,其中固然有很多不足,請勿直接搬到線上。本文末尾有我抽取的bind骨架程式碼的github地址,只有300行左右,看起來更容易理解。好了,廢話不多說,用一小段程式碼程式碼先展示一下boost::bind的神奇。

class Calculator {
    public:
        Calculator(){}
        int add(int a, int b) {
            return a + b;
        }
};
int add(int a, int b) {
    return a + b;
}
int main() {
    //繫結普通函式
    int a = boost::bind(add, 1, 2)(); //a = 3
    a = boost::bind(add, _1, _2)(1, 2); //a = 3
    //繫結類成員函式
    a = boost::bind(&Calculator::add, &caltor, 1, 2)(); //a = 3
    a = boost::bind(&Calculator::add, _1, _ 2, _3)(&caltor, 1, 2); //a = 3
    return 0;
}

boost::bind的設計思想

如何繫結函式與引數

熟悉C/C++的小夥伴都知道,有一種資料型別叫做函式指標,在C裡面經常使用函式指標呼叫函式,比如下面的例子。

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

//定義一個引數為兩個int型,返回值為int型的函式指標型別F
typedef int (*F)(int, int);
//函式指標指向具體的add函式
F f = add;
//執行f(),就相當於執行add
int a = f(1, 2); // a = 3;

我們很自然的想到可以用結構體把函式指標以及引數儲存到一起,這樣一個函式執行需要的東西都在這個結構體裡了。

struct bind_t {
    bind_t(F f, int a, int b):f_(f),a_(a),b_(b) {}
    F f_;
    int a_;
    int b_;
};

現在存放好了函式指標與引數,但是好像少了點什麼,我們要是能像呼叫函式一樣執行bind_t()就好了,很幸運我們可以用過載操作符()來完成。

struct bind_t {
    //...
    int operator()(){
        return f_(a_ , b_);
    }
    //...
};

bind_t bnd(add, 1, 2);
int a = bnd(); //呼叫過載的操作符()後,a = 3;

現在的bind_t就好像是函式一樣,達到了以假亂真的地步。但還是不夠簡潔,我們再額外封裝一下。

bind_t bind(F f, int a, int b) {
    return bind_t(f, a, b);
}

int a = bind(add, 1, 2)(); // a = 3

現在,這看起來有點像boost::bind的了,但其實還差的遠。我們只實現了對特定型別的函式的bind操作,可實際程式設計中我們面對的是不同引數數目,不同返回值的函式。不過不要怕,基本思想我們已經知道了,如下兩點。

  • 利用結構體bind_t存放函式指標以及引數
  • 對bind_t進行運算子過載,過載()運算子,可以傳入0,1,2...等數目的引數

現在我們嘗試對bind_t進行模板化,bind_t需要的資訊有函式的返回型別(operator()必須有返回值型別),函式型別,以及引數列表,那麼我們可以這麼寫。

//R 返回值型別 F 函式型別 L 引數列表
template<class R, class F, class L>
struct bind_t {
    bind_t(F f, L l): f_(f),l_(l){}
    //執行時傳入0個引數
    R operator(){ 暫時先忽略return }
    template<class A1> 
    //執行時傳入1個引數
    R operator()(A1 a1) { 暫時先忽略return }
    //執行時傳入2個引數
    template<class A1, class A2>
    R operator()(A1 a1, A2 a2) { 暫時先忽略return }
    F f_;
    L l_;
};

現在我們來思考一下如何實現bind_t的各個不同引數數目的operator()操作。

兩種不同時態的引數列表

使用boost::bind的時候,我們可能需要在兩個階段傳入函式執行時需要的引數

  • 繫結時:在呼叫boost::bind()函式時,除了需要給出函式指標,還需要給出一些必要引數,可確定的引數直接傳引數值,不確定的引數傳佔位符。
  • 執行時:在執行bind_t的具體某個operator()時傳入的引數,這些引數都是具體值,沒有佔位符。

bind_t在建構函式中傳入的引數列表為繫結時引數列表,執行operator()時傳入的引數列表為執行時引數列表。繫結時引數列表中的引數數量與函式定義所需的引數數目相同,只不過元素分為具體引數值和佔位符兩種。執行時引數列表與函式定義所需的引數數目不一定相同,它的大小為函式定義所需的引數數目N減去已經確定引數值的引數個數M,即N-M個。

實現bind_t的operator()

現在我們在bind_t的operator()中呼叫f_()怎麼樣?很遺憾有個問題,雖然bind_t中有繫結時引數列表,但是bind_t並不知道這個引數列表有多長,長度資訊只有具體的引數列表本身知道。所以呼叫f_()時,bind_t不知道要傳給f_()幾個引數。當然這不是絕對的,可以用某些方法萃取出引數長度,然後通過多個if判斷長度來硬編碼,但是這個方法不優雅。

綜上,我們放棄在bind_t的operator()中呼叫f,而是通過呼叫bind_t的成員l_上的某些操作來實現,因為只有l_是繫結時引數列表,它知道函式f_真正的引數個數。那麼我們也過載L的operator()吧,bind_t呼叫l_()並傳入相應的函式指標以及執行引數,當然也可以用個其它函式名代替。到這裡bind_t的operator()應該大概這樣實現。

template<R> struct type{};
template<class R, class F, class L>
struct bind_t {
    //......
    //執行時傳入0個引數
    R operator(){ 
        list0 l0;
        return l_(f_, l0);
    }

    //執行時傳入1個引數 
    template<class A1> 
    R operator()(A1 a1) {
        list1<A1> l1(a1);
        //這個是錯誤的,編譯器在呼叫具體的l_()時無法確認R的型別,從而找不到具體呼叫的函式
        //return l_(f_, l1);
        //這個是對的,顯示告訴編譯器R的型別
        return l_(type<R>(), f_, l1);
    }
    //執行時傳入2個引數
    template<class A1, class A2>
    R operator()(A1 a1, A2 a2) {
        list2<A1,A2> l2(a1, a2);
        return l_(type<R>(), f_,l2);
    }
    //....
}

bind_t通過呼叫繫結時引數列表的operator()操作,傳入函式指標f_和執行時引數列表,這樣l_本身其實已經具備了執行f_的所有資訊:繫結時引數、執行時引數、函式指標。

如何實現引數列表

好了,到這裡所有問題都集中在了引數列表上,我們看看如何實現引數列表。 引數列表並不像我們認識到的STL中的list或者vector,因為STL中的list和vector中的元素都是同一種型別,而我們需要的是元素型別可能都不同的list。這就迫使我們顛覆直觀上的list或者是vector,自己實現一個儲存任意型別元素的list,可以用模板實現。由於bind_t呼叫list的operator()完成具體的函式f的呼叫,所以list必須有過載operator()。

class <int I> struct arg{};//佔位符

//存放0個元素
struct list0 {
  template<class T> T operator[](T v) {  return v; } //傳遞引數值則直接返回引數值本身
  template<class R, class F, class L> R operator()(typede<R>, F f, L l){ return f();}
};
//存放1個元素
template<class A1> struct list1{
  list1(A1 a1): a1_(a1) {}
  A1 operator[](arg<1>) { return a1_;}   //傳遞佔位符則返回本身儲存的具體值
  template<class T>
  T operator[] (T t) { return t;}   //傳遞引數值則直接返回引數值本身
  
  //這個是錯的,因為這會在bind_t中呼叫l_()時找不到例項化的模板,因為選擇具體的operator()時,編譯器無法確定型別R是什麼。
  /*
  template<class R, class F, class L> 
  R operator()(F f, L l) {
    return f(l[a1_]);
  }*/
  //為了使編譯器知道R的型別,必須顯示通過引數傳入type<R>(),讓編譯器從而推匯出R的型別。
  template<class R, class F, class L> 
  R operator()(type<R>, F f, L l) {
    return f(l[a1_]); //繫結時引數列表知道函式引數個數
  }
  
  A1 a1_;
};
//存放兩個元素
template<class A1, class A2> struct list2{
  list2(A1 a1, A2 a2): a1_(a1), a2_(a2) {}
  
  A1 operator[](arg<1>) { return a1_; } //傳遞佔位符則返回本身儲存的具體值
  A2 operator[](arg<2>) { return a2_; } //傳遞佔位符則返回本身儲存的具體值
  template<class T>
  T operator[] (T t) { return t;} //傳遞引數值則直接返回引數值本身
  
  template<class R, class F, class L>
  R operator() (type<R>, F f, L l) {
    return f(l[a1_], l[a2_]);  //繫結時引數列表知道函式引數個數
  }

  A1 a1_;
  A2 a2_;
};

為了支援任意長度的列表,我們可能需要照葫蘆畫瓢的實現listN,但是想想我們的用途:用作函式引數列表。如果你的程式符合規範,那麼函式引數個數絕對不會太多,比如9個引數基本就滿足需求,這樣我們只需要實現list0~list9,本文只實現到list2,足以講清楚原理。

此外,對於每個列表我們還模擬陣列實現了operator[],像用下標訪問陣列一樣訪問列表。只不過下標傳的不是座標,而是具體的佔位符arg<1>()或者arg<2>()。

我們巧妙的利用了繫結時list知道引數個數來到達到傳給f多少個引數的目的,所以呼叫bind_t的建構函式時,必須保證給出的繫結時引數個數與函式定義的引數個數相同。

在給f傳遞引數的時候,我們利用了list的operator[],把繫結時引數列表的各個引數作為執行時引數列表的下標,從而呼叫f時傳遞的引數的都是值而不是佔位符,並且根據佔位符選擇不同的引數順序,我們舉個例子看的更清楚一些。

list2 = [a1_= 1, a2_ = arg<1>() ]  //繫結時引數列表
list1 = [a1_ = 2] //執行時引數列表


list1 [ list2.a1_ ] = 1//由於list2.a1_是具體值而不是佔位符,所以直接返回list2.a1_ = 1
list1 [ list2.a2_ ] = 2//由於list2.a2_是佔位符而不是具體值,所以返回list1 [ arg<1>() ] = list1.a1_ = 2

//繫結時引數列表呼叫函式f並融合執行時引數列表獲取適當引數傳遞給f
f(list1[list2.a1_], list1[list2.a2_]) <=> f(1, 2); //等價於呼叫f(1, 2)

//再看一個例子
list2 = [a1_= arg<2>(), a2_ = arg<1>() ]  //繫結時引數列表
list1 = [a1_ = 1, a2_ = 2] //執行時引數列表

list1 [ list2.a1_ ] = 2//由於list2.a1_是佔位符,所以返回 list1 [ arg<2>() ] = list1.a2_ = 2
list1 [ list2.a2_ ] = 1//由於list2.a2_是佔位符,所以返回 list1 [ arg<1>() ] = list1.a1_ = 1
//繫結時引數列表呼叫函式f並融合執行時引數列表獲取適當引數傳遞給f
f(list1[list2.a1_], list1[list2.a2_]) <=> f(2, 1); //等價於呼叫f(2, 1),雖然執行時引數順序為<1, 2>,但是實際傳遞給函式f的引數順序為<2, 1>,這是由繫結時引數列表的佔位符順序< _2, _1>決定的,_1 = arg<1>(),_2 = arg<2>()

到此為止,我們講述的boost::bind的最最基本的兩個資料型別:bind_t以及引數列表,理論上可以使用一下。

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

bind_t<int, int (*)(int, int), list2<int, int> > bnd2(add, 1, 2);
int a = bnd2(); //a = 3;
bind_t<int, int(*)(int, int), list2<int, arg<1> > > bnd22(add, 1, arg<1>());
a = bnd22(2); // a = 3;

但是,這樣使用實在是不方便,需要顯示的指定返回值型別,函式型別,以及引數型別。我們利用模板自動萃取各種型別,達到簡化的目的。

//萃取無參函式
template<class R>
bind_t<R, R (*)(), list0> bind(R (*f)()) {
    list0 l0;
    return bind_t<R, R (*)(), list0>(f, l0);
}
//萃取引數個數為1的函式
template<class R, class B1, class A1>
bind_t<R, R (*)(B1), list1<A1> > bind(R (*f)(B1), A1 a1) {
  list1<A1> l1(a1);
  return bind_t<R, R (*)(B1), list1<A1> >(f, l1);
}
//萃取引數個數為2的函式
template<class R, class B1, class B2, class A1, class A2>
bind_t<R, R (*)(B1, B2), list2<A1, A2> > bind(R (*f)(B1, B2), A1 a1, A2 a2) {
  list2<A1, A2> l2(a1, a2);
  return bind_t<R, R (*)(B1, B2), list2<A1, A2> >(f, l2);
}

arg<1> _1;
arg<2> _2;
int a = bind(add, 1, 2)(); // a = 3
int b = bind(add, _1, 2)(1); //b = 3
int c = bind(add, 1, _1)(2); //b=3
int d = bind(add, _1, _2)(1, 2); // d= 3

這樣,我們的使用方法就基本和boost::bind一毛一樣了。

類成員函式的繫結

類成員函式的繫結與普通函式的繫結原理是一樣的,不同的地方在於:呼叫類成員函式時,第一個引數一般總是某個具體物件的指標或者引用,這就導致了模板在支援類成員函式的引數個數時比普通函式的引數個數少一個。例如:boost::bind支援普通函式不多於9個引數的函式呼叫,而類成員函式最多支援8個引數。

為了能夠像呼叫普通函式一樣呼叫類成員函式,我們可以把類成員函式封裝成一個仿函式,過載其operator()方法,使其能夠像呼叫普通函式一樣呼叫類成員函式。

//類T的一個函式返回值型別為R引數個數為0的類成員函式的仿函式
template<class R, class T> class mf0 {
public:
    typedef R result_type;
    typedef T * argument_type;
private:
    
    typedef R ( T::*F) ();
    F f_;

    template<class U> R call(U & u, T const *) const {
        return (u.*f_)();
    }

public:
    
    explicit mf0(F f): f_(f) {}
    //限制第一個引數必須為類例項指標
    R operator()(T * p) const {
        return (p->*f_)();
    }

    template<class U> R operator()(U & u) const {
        U const * p = 0;
        return call(u, p);
    }

    R operator()(T & t) const {
        return (t.*f_)();
    }
};
//類T的一個函式返回值型別為R引數個數為1,且引數型別為A1的類成員函式的仿函式
template<class R, class T, class A1> class mf1 {
public:

    typedef R result_type;
    typedef T * first_argument_type;
    typedef A1 second_argument_type;

private:
    
    typedef R ( T::*F) (A1);
    F f_;

    template<class U, class B1> R call(U & u, T const *, B1 & b1) const {
        return (u.*f_)(b1);
    }

public:
    
    explicit mf1(F f): f_(f) {}
    //限制第一個引數必須為類例項指標
    R operator()(T * p, A1 a1) const {
        return (p->*f_)(a1);
    }

    template<class U> R operator()(U & u, A1 a1) const {
        U const * p = 0;
        return call(u, p, a1);
    }

    R operator()(T & t, A1 a1) const {
        return (t.*f_)(a1);
    }
};

//和普通函式一樣,為了使用時避免手動填寫各種引數型別,利用模板自動萃取
//萃取成員函式引數個數為0的成員函式
template<class R, class T, class A1>
    bind_t<R, mf0<R, T>, list1<A1> >
    bind(R (T::*f) (), A1 a1) {
    typedef mf0<R, T> F;
    typedef list1<A1> list_type;
    return bind_t<R, F, list_type>(F(f), list_type(a1));
}
//萃取成員函式引數個數為1的成員函式
template<class R, class T,
    class B1,
    class A1, class A2>
    bind_t<R, mf1<R, T, B1>, list2<A1, A2> >
    bind(R (T::*f) (B1), A1 a1, A2 a2) {
    typedef mf1<R, T, B1> F;
    typedef list2<A1, A2> list_type;
    return bind_t<R, F, list_type>(F(f), list_type(a1, a2));
}

class Calculator {
    public:
        int add10(int a) {
            return a + 10;
        }
};

Calculator caltor;
int a = bind(&Calculator::add10, _1, _2)(&caltor, 1); // a = 11
int b = bind(&Calculator::add10, &caltor, _1)(1); // a = 11

到此,類成員函式的bind操作講解完畢。

對仿函式的bind

以上基本實現了大部分操作,包括對普通函式、成員函式的bind與萃取,對於仿函式還沒有對應的bind,我們加上對仿函式的bind。

template<class R, class F> bind_t<R, F, list0> bind(F f) {
  typedef list0 list_type;
  return bind_t<R, F, list_type>(f, list_type());
}

template<class R, class F, class A1>
    bind_t<R, F, list1<A1> >
    bind(F f, A1 a1) {
    typedef list1<A1> list_type;
    return bind_t<R, F, list_type> (f, list_type(a1));
}

template<class R, class F, class A1, class A2>
    bind_t<R, F, list2<A1, A2> >
    bind(F f, A1 a1, A2 a2) {
    typedef list2<A1, A2> list_type;
    return bind_t<R, F, list_type>(f, list_type(a1, a2));
}

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

Addop op;
//必須顯示指定仿函式的返回值型別
int a = bind<int>(op, _1, _2)(1, 2); // a = 3

注意:對於仿函式,我們處理的並不正確,因為沒有考慮到仿函式的引用。即:如果執行的仿函式的操作能夠改變仿函式內部的某些值,我們實現的bind操作並不完全正確。

boost::bind的引用問題

關於bind的引用,有很多值得玩味的地方,比如上例的仿函式。再比如繫結時傳的引數如果是引用型別會有什麼問題?比如如下程式碼:

int add10(int &a) {
    a = a + 10;
    return a;
}

int a = 1;
boost::bind(add10, a)();
printf("a = %d\n", a); //a = 1 or a = 11?

利用boost繫結執行的結果與我們直接呼叫函式得到的結果是否完全相同?篇幅問題我們就不繼續展開了,有興趣的小夥伴可以想想如何解決這個問題。

boost::bind原始碼閱讀

boost的實現要比本文實現複雜的多,因為它考慮到了各種使用場景。小夥伴們在閱讀原始碼的時候要注意,如果理順瞭如下幾個結構體,將會很有用。

  • storageXXX:引數列表基類
  • listXXX:繼承自storageXXX,引數列表
  • arg{}:佔位符
  • value{}:具體值 //想想本文說的引數有兩種型別,具體值和佔位符,作者對這兩個概念進行了封裝
  • list_av_xxx:為了方便同時使用value以及arg建立一個list
  • bind_t:類似本文的bind_t
  • 萃取函式:為了方便針對不同數目的引數的函式進行bind,通過模板萃取進行自動匹配,分別在bind_cc.hpp和bind_mf_cc.hpp中

到此為止,我們介紹完了boost::bind的基本實現原理,為了使小夥伴能夠隨時檢視本文的樣例程式碼,我把簡化程式碼放入到github上:https://github.com/haolujun/Encapsulation/blob/master/bind.cpp ,只有300行,支援最多3個引數的函式繫結,我相信大部分人都能看懂。

總結

boost::bind的實現可以說極具技巧性,很多地方都值得研究。模板程式設計比較抽象,因為我們一般的程式設計都是執行在具體硬體上,理解了計算機組成原理就可以寫程式碼。但是模板程式設計是執行在編譯器上,本質是利用模板的特殊語法促使編譯器自動生成相對應的程式碼,而這方面的研究是很小眾的,但是這一小撮人經常能弄出一些非常漂亮的東西,比如boost。有興趣的小夥伴可以對模板超程式設計進行深入的研究。

相關文章