C++中的變長參數


  新參與的項目中,為了使用共享內存和自定義內存池,我們自己定義了MemNew函數,且在函數內部對於非pod類型自動執行構造函數。在需要的地方調用自定義的MemNew函數。這樣就帶來一個問題,使用stl的類都有默認構造函數,以及復制構造函數等。但使用共享內存和內存池的類可能沒有默認構造函數,而是定義了多個參數的構造函數,於是如何將參數傳入MemNew函數便成了問題。

1.變長參數函數

  首先回顧一下較多使用的變長參數函數,最經典的便是printf。

extern int printf(const char *format, ...);

  以上是一個變長參數的函數聲明。我們自己定義一個測試函數:

#include <stdarg.h>
#include <stdio.h> int testparams(int count, ...) { va_list args; va_start(args, count); for (int i = 0; i < count; ++i) { int arg = va_arg(args, int); printf("arg %d = %d", i, arg); } va_end(args); return 0; } int main() { testparams(3, 10, 11, 12); return 0; }

  變長參數函數的解析,使用到三個宏va_start,va_arg 和va_end,再看va_list的定義 typedef char* va_list; 只是一個char指針。

  這幾個宏如何解析傳入的參數呢?

  函數的調用,是一個壓棧,保存,跳轉的過程。簡單的流程描述如下:

  1. 把參數從右到左依次壓入棧;
  2. 調用call指令,把下一條要執行的指令的地址作為返回地址入棧;(被調用函數執行完后會回到該地址繼續執行)
  3. 當前的ebp(基址指針)入棧保存,然后把當前esp(棧頂指針)賦給ebp作為新函數棧幀的基址;
  4. 執行被調用函數,局部變量等入棧;
  5. 返回值放入eax,leave,ebp賦給esp,esp所存的地址賦給ebp;(這里可能需要拷貝臨時返回對象)
  6. 從返回地址開始繼續執行;(把返回地址所存的地址給eip)

   由於開始的時候從右至左把參數壓棧,va_start 傳入最左側的參數,往右的參數依次更早被壓入棧,因此地址依次遞增(棧頂地址最小)。va_arg傳入當前需要獲得的參數的類型,便可以利用 sizeof 計算偏移量,依次獲取后面的參數值。

 1 #define _INTSIZEOF(n)          ((sizeof(n) + sizeof(int) - 1) & ~(sizeof(int) - 1))
 2 
 3 #define _ADDRESSOF(v) (&const_cast<char&>(reinterpret_cast<const volatile char&>(v)))
 4 
 5 #define __crt_va_start_a(ap, v) ((void)(ap = (va_list)_ADDRESSOF(v) + _INTSIZEOF(v)))
 6 #define __crt_va_arg(ap, t)     (*(t*)((ap += _INTSIZEOF(t)) - _INTSIZEOF(t)))
 7 #define __crt_va_end(ap)        ((void)(ap = (va_list)0))
 8 
 9 #define __crt_va_start(ap, x) ((void)(__vcrt_va_start_verify_argument_type<decltype(x)>(), __crt_va_start_a(ap, x)))
10 
11 #define va_start __crt_va_start
12 #define va_arg   __crt_va_arg
13 #define va_end   __crt_va_end

  上述宏定義中,_INTSIZEOF(n) 將地址的低2位指令,做內存的4字節對齊。每次取參數時,調用__crt_va_arg(ap,t) ,返回t類型參數地址的值,同時將ap偏移到t之后。最后,調用_crt_va_end(ap)將ap置0.

  變長參數的函數的使用及其原理看了宏定義是很好理解的。從上文可知,要使用變長參數函數的參數,我們必須知道傳入的每個參數的類型。printf中,有format字符串中的特殊字符組合來解析后面的參數類型。但是當傳入類的構造函數的參數時,我們並不知道每個參數都是什么類型,雖然參數能夠依次傳入函數,但無法解析並獲取每個參數的數值。因此傳統的變長參數函數並不足以解決傳入任意構造函數參數的問題。

2.變長參數模板

  我們需要用到C++11的新特性,變長參數模板。

  這里舉一個使用自定義內存池的例子。定義一個內存池類MemPool.h,以count個類型T為單元分配內存,默認分配一個對象。每當內存內空閑內存不夠,則一次申請MEMPOOL_NEW_SIZE個內存對象。內存池本身只負責內存分配,不做初始化工作,因此不需要傳入任何參數,只需實例化模板分配相應類型的內存即可。

 1 #ifndef UTIL_MEMPOOL_H
 2 #define UTIL_MEMPOOL_H
 3 
 4 #include <stdlib.h>
 5 
 6 #define MEMPOOL_NEW_SIZE 8
 7 
 8 template<typename T, size_t count = 1>
 9 class MemPool
10 {
11 private:
12     union MemObj {
13         char _obj[1];
14         MemObj* _freelink;
15     };
16 
17 public:
18     static void* Allocate()
19     {
20         if (!_freelist) {
21             refill();
22         }
23         MemObj* alloc_mem = _freelist;
24         _freelist = _freelist->_freelink;
25         ++_size;
26         return (void*)alloc_mem;
27     }
28 
29     static void DeAllocate(void* p)
30     {
31         MemObj* q = (MemObj*)p;
32         q->_freelink = _freelist;
33         _freelist = q;
34         --_size;
35     }
36 
37     static size_t TotalSize() {
38         return _totalsize;
39     }
40 
41     static size_t Size() {
42         return _size;
43     }
44 private:
45     static void refill()
46     {
47         size_t size = sizeof(T) * count;
48         char* new_mem = (char*)malloc(size * MEMPOOL_NEW_SIZE);
49         for (int i = 0; i < MEMPOOL_NEW_SIZE; ++i) {
50             MemObj* free_mem = (MemObj*)(new_mem + i * size);
51             free_mem->_freelink = _freelist;
52             _freelist = free_mem;
53         }
54         _totalsize += MEMPOOL_NEW_SIZE;
55     }
56 
57     static MemObj* _freelist;
58     static size_t _totalsize;
59     static size_t _size;
60 };
61 
62 template<typename T, size_t count>
63 typename MemPool<T, count>::MemObj* MemPool<T, count>::_freelist = NULL;
64 
65 template<typename T, size_t count>
66 size_t MemPool<T, count>::_totalsize = 0;
67 
68 template<typename T, size_t count>
69 size_t MemPool<T, count>::_size = 0;
70 #endif

   接下來在沒有變長參數的情況下,實現通用MemNew和MemDelete函數模板。這里不對函數模板作詳細解釋,用函數模板我們可以對不同的類型實現同樣的內存池分配操作。如下:

 1 template<class T>
 2 T *MemNew(size_t count)
 3 {
 4     T *p = (T*)MemPool<T, count>::Allocate();
 5     if (p != NULL)
 6     {
 7         if (!std::is_pod<T>::value)
 8         {
 9             for (size_t i = 0; i < count; ++i)
10             {
11                 new (&p[i]) T();
12             }
13         }
14     }
15     return p;
16 }
17 
18 template<class T>
19 T *MemDelete(T *p, size_t count)
20 {
21     if (p != NULL)
22     {
23         if (!std::is_pod<T>::value)
24         {
25             for (size_t i = 0; i < count; ++i)
26             {
27                 p[i].~T();
28             }
29         }
30         MemPool<T, count>::DeAllocate(p);
31     }
32 }

  上述實現中,使用placement new對申請的內存進行構造,使用了默認構造函數,當申請內存的類型不具備默認構造函數時,placement new將報錯。對於pod類型,可以省去調用構造函數的過程。

  引入C++11變長模板參數后MemNew修改為如下

 1 template<class T, class... Args>
 2 T *MemNew(size_t count, Args&&... args)
 3 {
 4     T *p = (T*)MemPool<T, count>::Allocate();
 5     if (p != NULL)
 6     {
 7         if (!std::is_pod<T>::value)
 8         {
 9             for (size_t i = 0; i < count; ++i)
10             {
11                 new (&p[i]) T(std::forward<Args>(args)...);
12             }
13         }
14     }
15     return p;
16 }

  以上函數定義包含了多個特性,后面我將一一解釋,其中class... Args 表示變長參數模板,函數參數中Args&& 為右值引用。std::forward<Args> 實現參數的完美轉發。這樣,無論傳入的類型具有什么樣的構造函數,都能夠完美執行placement new。

  C++11中引入了變長參數模板的概念,來解決參數個數不確定的模板。

 1 template<class... T> class Test {};
 2 Test<> test0;
 3 Test<int> test1;
 4 Test<int,int> test2;
 5 Test<int,int,long> test3;
 6 
 7 template<class... T> void test(T... args);
 8 test();
 9 test<int>(0);
10 test<int,int,long>(0,0,0L);

  以上分別是使用變長參數類模板和變長參數函數模板的例子。

2.1變長參數函數模板

  T... args 為形參包,其中args是模式,形參包中可以有0到任意多個參數。調用函數時,可以傳任意多個實參。對於函數定義來說,該如何使用參數包呢?在上文的MemNew中,我們使用std::forward依次將參數包傳入構造函數,並不關注每個參數具體是什么。如果需要,我們可以用sizeof...(args)操作獲取參數個數,也可以把參數包展開,對每個參數做更多的事。展開的方法有兩種,遞歸函數,逗號表達式。

  遞歸函數方式展開,模板推導的時候,一層層遞歸展開,最后到沒有參數時用定義的一般函數終止。

 1 void test()
 2 {
 3 }
 4 
 5 template<class T, class... Args> 
 6 void test(T first, Args... args)
 7 {
 8     std::cout << typeid(T).name() << " " << first << std::endl;
 9     test(args...);
10 }
11 
12 test<int, int, long>(0, 0, 0L);
13 
14 output:
15 int 0
16 int 0
17 long 0

  逗號表達式方式展開,利用數組的參數初始化列表和逗號表達式,逐一執行print每個參數。

 1 template<class T>
 2 void print(T arg)
 3 {
 4     std::cout << typeid(T).name() << " " << arg << std::endl;
 5 }
 6 
 7 template<class... Args>
 8 void test(Args... args)
 9 {
10     int arr[] = { (print(args), 0)... };
11 }
12 
13 test(0, 0, 0L);
14 
15 output:
16 int 0
17 int 0
18 long 0

 

2.2變長參數類模板

   變長參數類模板,一般情況下可以方便我們做一些編譯期計算。可以通過偏特化和遞歸推導的方式依次展開模板參數。

 1 template<class T, class... Types>
 2 class Test
 3 {
 4 public:
 5     enum {
 6         value = Test<T>::value + Test<Types...>::value,
 7     };
 8 };
 9 
10 template<class T>
11 class Test<T>
12 {
13 public:
14     enum {
15         value = sizeof(T),
16     };
17 };
18 
19 Test<int, int, long> test;
20 std::cout << test.value;
21 
22 output: 12

 

2.3右值引用和完美轉發 

  對於變長參數函數模板,需要將形參包展開逐個處理的需求不多,更多的還是像本文的MemNew這樣的需求,最終整個傳入某個現有的函數。我們把重點放在參數的傳遞上。

  要理解右值引用,需要先說清楚左值和右值。左值是內存中有確定存儲地址的對象的表達式的值;右值則是非左值的表達式的值。const左值不可被賦值,臨時對象的右值可以被賦值。左值與右值的根本區別在於是否能用&運算符獲得內存地址。

int i =0;//i 左值
int *p = &i;// i 左值
int& foo();
foo() = 42;// foo() 左值
int* p1 = &foo();// foo() 左值

int foo1();
int j = 0;
j = foo1();// foo 右值
int k = j + 1;// j + 1 右值
int *p2 = &foo1(); // 錯誤,無法取右值的地址
j = 1;// 1 右值

  理解左值和右值之后,再來看引用,對左值的引用就是左值引用,對右值(純右值和臨終值)的引用就是右值引用。

  如下函數foo,傳入int類型,返回int類型,這里傳入函數的參數0和返回值0都是右值(不能用&取得地址)。於是,未做優化的情況下,傳入參數0的時候,我們需要把右值0拷貝給param,函數返回的時候需要將0拷貝給臨時對象,臨時對象再拷貝給res。當然現在的編譯器都做了返回值優化,返回對象是直接創建在返回后的左值上的,這里只用來舉個例子

int foo(int param)
{
    printf("%d", param);
    return 0;
}

int res = foo(0);

  顯然,這里的拷貝都是多余的。可能我們會想要優化,首先將參數int改為int&,傳入左值引用,於是0無法傳入了,當然我們可以改成const int&,這樣終於省去了傳參的拷貝。

int foo(const int& param)
{
    printf("%d", param);
    return 0;
}

  由於const int& 既可以是左值也可以是右值,傳入0或者int變量都能夠滿足。(但是似乎既然有左值引用的int&類型,就應該有對應的傳入右值引用的類型int&&)。另外,這里返回的右值0,似乎不通過拷貝就無法賦值給左值res。

於是有了移動語義,把臨時對象的內容直接移動給被賦值的左值對象(std::move)。和右值引用,X&&是到數據類型X的右值引用。

int result = 0;
int&& foo(int&& param)
{
    printf("%d", param);
    return std::move(result);
}

int&& res = foo(0);
int *pres = &res;

  將foo改為右值引用參數和返回值,返回右值引用,免去拷貝。這里res是具名引用,運算符右側的右值引用作為左值,可以取地址。右值引用既有左值性質,也有右值性質。

   上述例子還只存在於拷貝的性能問題。回到MemNew這樣的函數模板。

 1 template<class T>
 2 T* Test(T arg)
 3 {
 4     return new T(arg);
 5 }
 6 
 7 template<class T>
 8 T* Test(T& arg)
 9 {
10     return new T(arg);
11 }
12 
13 template<class T>
14 T* Test(const T& arg)
15 {
16     return new T(arg);
17 }
18 
19 template<class T>
20 T* Test(T&& arg)
21 {
22     return new T(std::forward<T>(arg));
23 }

  上述的前三種方式傳參,第一種首先有拷貝消耗,其次有的參數就是需要修改的左值。第二種方式則無法傳常數等右值。第三種方式雖然左值右值都能傳,卻無法對傳入的參數進行修改。第四種方式使用右值引用,可以解決參數完美轉發的問題。

  std::forward能夠根據實參的數據類型,返回相應類型的左值和右值引用,將參數完整不動的傳遞下去。

  解釋這個原理涉及到引用塌縮規則

  T& & ->T&

  T& &&->T&

      T&& &->T&

  T&& &&->T&&

 1 template< class T > struct remove_reference      {typedef T type;};
 2 template< class T > struct remove_reference<T&>  {typedef T type;};
 3 template< class T > struct remove_reference<T&&> {typedef T type;};
 4 
 5 template< class T > T&& forward( typename std::remove_reference<T>::type& t )
 6 {
 7   return static_cast<T&&>(t);
 8 }
 9 
10 template<class T>
11 typename std::remove_reference<T>::type&& move(T&& a) noexcept
12 { 
13   return static_cast<typename std::remove_reference<T>::type&&>(a);
14 }

  對於函數模板

template<class T>
T* Test(T&& arg)
{
    return new T(std::forward<T>(arg));
}

  當傳入實參為X類型左值時,T為X&,最后的類型為X&。當實參為X類型右值時,T為X,最后的類型為X&&。

  x為左值時:

X x;
Test(x);

  T為X&,實例化后

X& && std::forward(remove_reference<X&>::type& a) noexcept
{
    return static_cast<X& &&>(a);
}

X* Test(X& && arg)
{
    return new X(std::forward<X&>(arg));  
}

// 塌陷后

X& std::forward(X& a)
{
    return static_cast<X&>(a);
}

X* Test(X& arg)
{
    return new X(std::forward<X&>(arg));
}

  x為右值時:

X foo();
Test(foo());

  T為X,實例化后

X&& std::forward(remove_reference<X>::type& a) noexcept
{
    return static_cast<X&&>(a);
}

X* Test(X&& arg)
{
    return new X(std::forward<X>(arg));  
}

// 塌陷后

X&& std::forward(X& a)
{
    return static_cast<X&&>(a);
}

X* Test(X&& arg)
{
    return new X(std::forward<X>(arg));
}

  可以看到最終實參總是被推導為和傳入時相同的類型引用。

  至此,我們討論了變長參數模板,討論了右值引用和函數模板的完美轉發,完整的解釋了MemNew對任意多個參數的構造函數的參數傳遞過程。利用變長參數函數模板,右值引用和std::forward,可以完成參數的完美轉發。






免責聲明!

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



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