C++ 11中的可變模板參數


概述

大家在C++中應該見過不少函數,它們既沒有限制參數的類型,也沒有限制參數的個數,比如vector<T>::emplace()make_unique<T>()。它們都是利用C++11中的可變模板參數來實現的。對於這一新特性,需要掌握以下三點

  • 可變模板參數的語法
  • 參數包的展開
  • 實踐

前言

在講可變模板參數之前,需要先講C語言中的變長參數

#include<stdarg.h>
#define END (-1)
int sum(int num, ...)
{
    va_list list;
    va_start(list, num);
    int sum = 0;
    for (int cur = num; cur != END; cur = va_arg(list, int))
        sum += cur;
    va_end(list);
    return sum;
}


int main()
{
    // 輸出15
    cout << sum(1, 2, 3, 4, 5, END) << endl;
}

C語言中的va_arg宏並不能判斷出哪個參數參數包的末尾,所以只能通過自己設定結束位,並通過顯式判斷來截取有效的參數

而在C++11中,使用變長參數更簡單了(好吧其實也不怎么算變長)

int sum(std::initializer_list<int> initializerList)
{
    int sum = 0;
    for (const int& i : initializerList)
        sum += i;
    return sum;
}

int main()
{
    std::cout << sum({1, 2, 3, 4, 5}) << std::endl;
}

可變模板參數的語法

函數

以C++11的標准來看,聲明一個可變參數的模板函數有兩種方法

template<typename... Ts>
void AnyNumberOfParam(Ts... ars) {}

int main()
{
    // 可以接受0及以上個參數
    AnyNumberOfParam();
    AnyNumberOfParam(1, 2);
    AnyNumberOfParam("824", std::vector<int>(), 3);
}
template<typename T, typename... Ts>
void AnyNumberOfParam(T least, Ts... ars) {}

int main()
{
    // 至少需要有一個參數
    // AnyNumberOfParam();
    AnyNumberOfParam(1, 2);
    AnyNumberOfParam("824", std::vector<int>(), 3);
}

template<typename T, typename... Ts>
class TestTemplateClass {};

int main()
{
    TestTemplateClass<int, std::string> t;
}
template<typename... Ts>
class TestTemplateClass {};

int main()
{
    // 因為支持0及以上的參數 所以這么寫是合法的
    TestTemplateClass t;
}

函數參數包的展開

C++11中的展開方式

遞歸展開

假設我們通過設計一個函數,能逐個輸出它的參數

void print()
{
    std::cout << "empty" << std::endl;
}
template<typename T, typename... Ts>
void print(T first, Ts... args)
{
    std::cout << first << std::endl;
    print(args...);
}

int main()
{
    print(1, 2, "Mike", 3.21);
    std::cout << std::endl;
    print();
}

以上代碼的遞歸過程為

  • print(1, 2, "Mike", 3.21);
  • print(2, "Mike", 3.21);
  • print("Mike", 3.21);
  • print(3.21);
  • print();

通過遞歸方式展開參數包,當所有參數包展開完畢后,自然為空,所以調用到非模板的遞歸中止函數

當然還可以使用模板遞歸中止函數,這種情況就不支持空參數包了

template<typename T>
void print(T end)
{
    std::cout << end << std::endl;
}
template<typename T, typename... Ts>
void print(T first, Ts... args)
{
    std::cout << first << std::endl;
    print(args...);
}

int main()
{
    print(1, 2, "Mike", 3.21);
    // print();
}

以上代碼的遞歸過程為

  • print(1, 2, "Mike", 3.21);
  • print(2, "Mike", 3.21);
  • print("Mike", 3.21);
  • print(3.21);

而在C++17中,對遞歸展開法進行了優化(前提是將if語句聲明為常量表達式)

template<typename T, typename... Ts>
void print(T first, Ts... args)
{
    std::cout << first << std::endl;
    if constexpr(sizeof...(args) > 0)
        print(args...);
}

逗號表達式搭配initializer_list展開

在C++11中通過遞歸展開參數包的缺點很明顯,需要重載一個遞歸終止函數,同時還需要判定終止函數是否需要使用到模板;不僅如此,還需要確保帶參數包版本的函數至少包含一個類型(T fistst),可以說是很不便了。下面介紹逗號表達式結合initializer_list的展開方法

template<typename... Ts>
void print(Ts&&... args)
{
    auto lambda = [](auto&& data) { std::cout << data << std::endl; };
    std::initializer_list<int> il = { (lambda(std::forward<Ts>(args)), 0)... };
}

// C++現代教程上的做法
template<typename... Ts>
void print(Ts... args)
{
    std::initializer_list<int>{([&args]() { std::cout << args << std::endl; }(), 0)... };
}

int main()
{
    print(1, 2, "Mike", 3.21);
    print();
}

這種搭配initializer_list的解法我願稱之為黑魔法,在構造初始化列表的同時完成了參數包的展開

以下示范一個求和模板函數,如果我們直接使用參數包進行操作而不展開它,那么我們將會得到報錯

C++17中的折疊表達式

一元折疊表達式

先來看看如何在C++17中利用折疊表達式(...)展開參數包實現一個求平均數的函數

template<typename... T>
int avg(T... args) {
    // 右折疊
    return (args + ...) / sizeof...(args);
}
int main()
{
    // 輸出26
    cout << avg(1, 2, 5, 'a') << endl;
    // 編譯出錯
    // cout << avg() << endl;
}

對於+運算符,符合交換律,所以左折疊和右折疊的結果相同

對於-運算符,不符合交換律,因此左右折疊的結果不同

template<typename... Ts>
decltype(auto) sub_right(Ts... args) { return (args - ...); }

template<typename... Ts>
decltype(auto) sub_left(Ts... args) { return (... - args); }

int main()
{
    // (((10 - 5) - 2) - 8)
    std::cout << sub_left(10, 5, 2, 8) << std::endl;
    // (10 - (5 - (2 - 8)))
    std::cout << sub_right(10, 5, 2, 8) << std::endl;
}

對於一元折疊表達式而言,只有,&&||操作允許空包,其它的如果出現空包則會編譯出錯

When a unary fold is used with a pack expansion of length zero, only the following operators are allowed:

  1. Logical AND (&&). The value for the empty pack is true

  2. Logical OR (||). The value for the empty pack is false

  3. The comma operator (,). The value for the empty pack is void()

// 一元折疊逗號表達式
template<typename ...Args>
void printer(Args&&... args)
{
    (..., (std::cout << args << std::endl));	// 左折疊 且每個參數間隔輸出std::endl
    // ((std::cout << args), ...);	// 右折疊
}

int main()
{
    // (((1 << 2) << Mike) << 3.21) 左折疊
    // 1 \n 2 \n Mike \n 3.21
    printer(1, 2, "Mike", 3.21);
}

二元折疊表達式

二元折疊表達式,支持空包操作。二元折疊表達式的省略號(...)永遠在中間,特例在左還是在右決定了是左折疊還是右折疊

對於std::cout的二元表達式而言,只能使用左折疊(因為輸出必須以std::cout開頭,而這也就代表了它是左折疊)

template<typename ...Args>
void printer(Args&&... args)
{
    // 二元左折疊
    (std::cout << ... << args);
}

template<typename ... Ts>
void printer_space(Ts&&... args)
{
    auto lambda = [] (auto params)
    {
        cout << ends;
        return params;
    };
    (std::cout << ... << lambda(args));
}

int main()
{
    // 12Mike3.21
    printer(1, 2, "Mike", 3.21);
    // nothing...
    printer();
    //  1 2 Mike 3.21
    printer_space(1, 2, "Mike", 3.21);
}

拓展:以下代碼是在參數包展開完畢之后再輸出std::endl,而不是每拆一次就輸出一次

template<typename ...Args>
void printer(Args&&... args)
{
    (std::cout << ... << args) << std::endl;
}

如何評價

Fold expressions with arbitrary callable?

類參數包的展開

C++11中的函數參數包可以使用遞歸或逗號表達式來展開,C++17中則可以使用優化的遞歸或折疊表達式來展開

C++11中的類參數包的展開需要運用到類模板的特化(因為筆者也搞不清楚以下代碼是屬於偏特化還是全特化,所以統一寫成特化)

遞歸展開

// 一個支持1及多個類型的類
template<typename T, typename... Ts>
class TestClass
{
public:
    // 匿名枚舉 遞歸展開
    enum { value = TestClass<T>::value + TestClass<Ts...>::value };
};

// 對1個類型的情況進行特化 遞歸中止類
template<typename lastT>
class TestClass<lastT>
{
public:
    enum { value = sizeof(lastT) };
};

int main()
{
    // 4 + 8 + 1 = 13
    std::cout << TestClass<int, double, char>::value << std::endl;
    // 在64位環境下大小為32
    std::cout << TestClass<std::vector<int>>::value << std::endl;
    // TestClass<>::value;
}

此展開方式不支持0參數包,因此可以改寫為以下方式

// 只聲明一個支持0及以上個類型的類
template<typename... Args>
class TestClass;

// 對1及以上個類型進行特化
template<typename First, typename... Rest>
class TestClass<First, Rest...>
{
public:
    // 遞歸展開
    enum { value = TestClass<First>::value + TestClass<Rest...>::value };
};

// 對1個類型進行特化 即遞歸終止類
template<typename First>
class TestClass<First>
{
public:
    enum { value = sizeof(First) };
};

// 對0個類型進行特化
template<>
class TestClass<>
{
public:
    enum { value = 0 };
};

對上面的代碼可能會產生以下幾點疑問

  • 為什么只需要聲明TestClass主體類而不用實現它,看看以下一個簡單的實例你就明白了

    template<typename T>
    class TestTemplate;
    
    template<>
    class TestTemplate<int>
    {
    public:
        int data;
    };
    
    template<>
    class TestTemplate<std::string>
    {
    public:
        std::string name;
    };
    
    int main()
    {
        // 只能實例化出特化的int和string類型
        TestTemplate<int> it{};
        // TestTemplate<char> ct{};
    }
    
  • 為什么感覺這個特化並不是很特化的樣子

    template<typename First, typename... Rest>
    class TestClass<First, Rest...> {
        // codes...
    }
    

    因為這里是根據參數的個數進行特化,而不是根據類型進行特化

你可能會覺得用匿名enum來扮演一個編譯期常量有點撈,那么接下來使用同樣具有編譯期常量特性的std::integral_constant

注意是integral不是interger

template<typename T, typename... Ts>
class TestTemplate : public std::integral_constant<int, TestTemplate<T>::value + TestTemplate<Ts...>::value>
{
};

template<typename T>
class TestTemplate<T> : public std::integral_constant<int, sizeof(T)>
{
};

int main()
{
    cout << TestTemplate<int, double, char>::value << endl;
    // TestTemplate<>::value;
}

有人可能會說了,那你加一個無類型的特化不就得了嗎,其實不是的。因為我們模板類的主體是template<typename T, typename... Ts>,這也就意味着需要1及以上個的類型,那么此時針對一個不滿足主體的特化肯定是不正確的。至於如何實現0個類型,上文中給出了匿名enum版本的實現,這里不再贅述

// 錯誤的特化方式 編譯將出錯
template<>
class TestTemplate<> : public std::integral_constant<int, 0>
{
};

繼承展開

C++11中的std::tuple就是使用繼承展開參數包的

std::tuple可以看作是std::pair的升級版,它支持0-多個類型參數

std::tuple<int, double, std::string> myTuple{1, 3.14, "pi"};
auto& [intX, doubleY, strZ] = myTuple;
intX = 200;
// 200
std::cout << std::get<0>(myTuple) << std::endl;

手撕std::tuple的代碼將放在后文的實踐部分

實踐

手撕std::tuple

本文實現的MyTuple的功能十分有限,在實際應用中可能會出現各種不必要的拷貝,以及編譯無法通過等問題。以下代碼只當作核心功能的剖析,應當作練習看待

tuple

// 聲明一個支持0及以上個參數包的模板類
template<typename... Ts>
class MyTuple;

// 空MyTuple 偷懶不做實現
template<>
class MyTuple<> {};

// MyTuple主要實現 繼承展開參數包
template<typename T, typename... Ts>
class MyTuple<T, Ts...> : public MyTuple<Ts...>
{
private:
    T data;
    using TopTuple = MyTuple<Ts...>;
public:
    MyTuple() = default;

    // 通用引用有參構造函數
    template<typename ThisType, typename... RestTypes>
    MyTuple(ThisType&& _data, RestTypes&&... _args) : data(std::forward<ThisType>(_data)), TopTuple(std::forward<RestTypes>(_args)...) {}

    // 常函數版本get<>
    template<std::size_t index>
    constexpr auto& get() const
    {
        // 靜態斷言防止越界訪問
        static_assert(index <= sizeof...(Ts), "out of range");
        if constexpr (index == 0)
            return data;
        else
            return TopTuple::template get<index - 1>();
    }
    
    template<std::size_t index>
    constexpr auto& get()
    {
        // 調用常函數版本的get<>
        using element_type = my_tuple_element_type<index, MyTuple<T, Ts...>>;
        return const_cast<element_type&>((static_cast<const MyTuple<T, Ts...>&>(*this)).template get<index>());
    }
};

int main()
{
    MyTuple<int, std::string, double> t(1, "Jelly", 3.14);
    // 輸出Jelly
    std::cout << t.get<1>() << std::endl;
}

實現了一個非常簡單的MyTuple,沒有考慮復雜的拷貝,移動或賦值等問題。和庫中的版本一樣,都使用了繼承的方式展開參數包,每一層MyTuple儲存一個類型的數據。

tuple_size

下面實現一個個數萃取機,能獲得MyTuple中存儲的類型個數

template<typename>
struct my_tuple_size;

// 對MyTuple格式的類型進行特化
template<template<typename...> typename TupleType, typename... Ts>
struct my_tuple_size<TupleType<Ts...>> : std::integral_constant<std::size_t, sizeof...(Ts)> {};

template<typename TupleType>
inline constexpr std::size_t my_tuple_size_value = my_tuple_size<TupleType>::value;

tuple_element

再來實現一個類型萃取機,能獲得第幾號元素是什么類型

template<std::size_t, typename>
struct my_tuple_element;

// 對MyTuple格式的類型進行特化
template<std::size_t index, template<typename...> typename TupleType, typename T, typename... Ts>
struct my_tuple_element<index, TupleType<T, Ts...>> : my_tuple_element<index - 1, TupleType<Ts...>> {};

// 特化出一個繼承終止類
template<template<typename...> typename TupleType, typename T, typename... Ts>
struct my_tuple_element<0, TupleType<T, Ts...>> {
    using type = T;
};

template<std::size_t index, typename TupleType>
using my_tuple_element_type = typename my_tuple_element<index, TupleType>::type;

make_tuple

再來實現一個my_make_tuple,下面給出一個錯誤實現

template<typename... Ts>
MyTuple<Ts...> my_make_tuple(Ts&&... args) {
    return MyTuple<Ts...>(std::forward<Ts>(args)...);
}

乍一看好像很對,對參數包進行展開然后完美轉發。這么想就忽略了通用引用的特性,對於左值類型,通用引用會推導出T&;對於右值類型會推導出T

那么我們使用左值類型進行my_make_tuple時,將會推導出錯誤的類型

int main()
{
    int a = 10;
    const int b = 20;
    string name = "Jelly";
    MyTuple<int&, const int&, std::string&, double> t = my_make_tuple(a, b, name, 3.14);
}

那么關鍵就是:用什么辦法能去除掉類型的constvolatilereference屬性等等呢,答案是退化

std::decay

C++ 11中的右值引用中,我提到了std::remove_reference_t,用於去除類型的引用屬性,但是std::decay要更猛一些

  • 如果是引用類型,會將其消除
  • 如果又是數組類型,會退化為指針
  • 如果又是函數,會退化為函數指針
  • 同時會消除對象的cv屬性

std::ref

但是問題又來了,這樣不分青紅皂白的退化一個類型,也使得我們沒有辦法創建一個記錄引用類型變量的元組。std::ref就是為此而生的,它會將參數包裝成std::reference_wrapper對象

最終實現

因此還需要進一步特化,正確的實現為:

template<typename T>
struct remove_ref_wrap {
    using type = T;
};
template<typename T>
struct remove_ref_wrap<std::reference_wrapper<T>> {
    using type = T&;
};

template<typename T>
using with_ref_decay_t = typename remove_ref_wrap<std::decay_t<T>>::type;

template<typename... Ts>
constexpr MyTuple<with_ref_decay_t<Ts>...> my_make_tuple(Ts&&... args) {
    return MyTuple<with_ref_decay_t<Ts>...>(std::forward<Ts>(args)...);
}

先執行一次std::decay_t<T>,然后再萃取出std::reference_wrapper對象,為其施加引用。std::decay不會去掉std::reference_wrapper,因為它是經過包裝的對象而不是類型

int main()
{
    int a = 10;
    float height = 1.7;
    MyTuple<int&, double, float> t = my_make_tuple(std::ref(a), 3.14, height);
}

不足之處

對於有隱式轉換的類型,需要顯式指明my_make_tuple的類型

MyTuple<std::string> t = my_make_tuple<std::string>("Jelly");

traverse_tuple

通過傳入函數對象進行對MyTuple的遍歷

// 利用折疊表達式執行回調
template<typename TupleType, typename FuncType, std::size_t... Index>
void call_tuple(const TupleType& t, const FuncType& f, std::index_sequence<Index...>) {
    (f(t.template get<Index>()), ...);
}

template<template<typename...> typename TupleType, typename... Ts, typename FuncType>
void my_traverse_tuple(const TupleType<Ts...>& t, const FuncType& f) {
    call_tuple(t, f, std::make_index_sequence<my_tuple_size_value<TupleType<Ts...>>>{});
}

測試代碼為

int main()
{
    MyTuple<int, std::string> t = my_make_tuple(1, "123");
    my_traverse_tuple(t, [](auto&& data) { std::cout << data << std::endl; });
}

std::apply

c++17中引入的對std::tuple的一種遍歷方式,傳入的回調函數的參數為參數包

int main()
{
    std::tuple<int, std::string, float> t = std::make_tuple(1, "Jelly", 3.14);
    // 1Jelly3.14
    std::apply([](auto&&... params) { (std::cout << ... << params); }, t);
}

完善

經過不懈的努力,我終於掌握了如何解決上面中提到的不足之處

template<typename... Ts>
class MyTuple;

template<>
class MyTuple<> {};

template<typename T, typename... Ts>
class MyTuple<T, Ts...> : public MyTuple<Ts...>
{
private:
    T data;
    using TopTuple = MyTuple<Ts...>;

    template<typename TupleType, std::size_t... Indices>
    MyTuple(TupleType&& _copy, std::index_sequence<Indices...>) : MyTuple(_copy.template get<Indices>()...) {}

public:
    MyTuple() = default;

    template<typename ThisType, typename... RestTypes>
    MyTuple(ThisType&& _data, RestTypes&&... _args) : data(std::forward<ThisType>(_data)), TopTuple(std::forward<RestTypes>(_args)...) {}

    template<template<typename...> typename TupleType, typename... RestTypes>
    MyTuple(TupleType<RestTypes...>&& _copy) : MyTuple(std::forward<TupleType<RestTypes...>>(_copy), std::make_index_sequence<my_tuple_size_value<TupleType<RestTypes...>>>{}) {}

    // const MyTuple調用
    template<std::size_t index>
    constexpr auto& get() const
    {
        static_assert(index <= sizeof...(Ts), "out of range");
        if constexpr (index == 0)
            return data;
        else
            return TopTuple::template get<index - 1>();
    }

    // non-const MyTuple調用
    template<std::size_t index>
    constexpr auto& get()
    {
        using element_type = my_tuple_element_type<index, MyTuple<T, Ts...>>;
        return const_cast<element_type&>((static_cast<const MyTuple<T, Ts...>&>(*this)).template get<index>());
    }
};

太棒了,解決了一個問題之后又出現一個問題

int main()
{
    MyTuple<int, std::string, float> t1 = my_make_tuple(12, "24142", 3.14);
    cout << t1.template get<0>() << endl;
    cout << t1.template get<1>() << endl;
    cout << t1.template get<2>() << endl;

    // 一個我暫時還解決不掉的問題
    // auto temp = my_make_tuple(12, "24142", 3.14);
    // MyTuple<int, std::string, float> t2 = temp;
}

實現一個泛型delegate

眾所周知,在C#中有一個關鍵字是delegate

delegate int AddNumDelegate(int x, int y);

int Sum(int x, int y) { return x + y; }

AddNumDelegate addNum = Sum;
int z = addNum(10, 30);

如果你看不懂的話,翻譯到C++中大概是這么個玩意(圖一樂就行了)

using AddNumDelegate = std::function<int(int, int)>;
int Sum(int x, int y) { return x + y; }

int main()
{
    AddNumDelegate addSum = Sum;
    std::cout << addSum(10, 20) << std::endl;
}

上述例子中都顯式指定了函數的參數類型,那我們在C++中實現一個泛型的delegate。因為C#中的函數都是成員函數,所以只實現類成員函數的版本

template<typename T, typename ReturnType, typename... Params>
class MyDelegate
{
private:
    using MemberFuncType = ReturnType (T::*)(Params...);
    T* pClass;
    MemberFuncType pMemberFunc;
public:
    MyDelegate(T* _t, MemberFuncType _f) : pClass(_t), pMemberFunc(_f) {}
    
    template<typename... Ts>
    ReturnType operator()(Ts&&... _args) {
        return (pClass->*pMemberFunc)(std::forward<Ts>(_args)...);
    }
};
struct TestStruct
{
    template<typename T>
    int test_func(T&& x) { return std::forward<T>(x); }
};

int main()
{
    TestStruct t;
    MyDelegate d(&t, &TestStruct::test_func<int>);
    // true
    std::cout << std::boolalpha << is_rvalue_reference_v<decltype(d(10))> << std::endl;
}

模板題外話

template<auto>

這是C++17對非類型模板參數的自動類型推導

// C++17之前
template<std::size_t num>
void print() { std::cout << num << std::endl; }
// C++17之后
template<auto num>
void print() { std::cout << num << std::endl; }

而C++20又放寬了非類型模板參數的限制,C++20之前的非類型模板參數不能為浮點數,只能為int, unsigned int, long long ,char以及指針類型等等,而C++20則支持doublefloat

// C++20
template<double num>
void print() { std::cout << num << std::endl; }

總結


免責聲明!

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



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