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; }

总结#

posted @   _FeiFei  阅读(742)  评论(2编辑  收藏  举报
编辑推荐:
· 开发者必知的日志记录最佳实践
· SQL Server 2025 AI相关能力初探
· Linux系列:如何用 C#调用 C方法造成内存泄露
· AI与.NET技术实操系列(二):开始使用ML.NET
· 记一次.NET内存居高不下排查解决与启示
阅读排行:
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 【自荐】一款简洁、开源的在线白板工具 Drawnix
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
点击右上角即可分享
微信分享提示
主题色彩