一篇文章让你彻底明白c++11增加的变参数模板

目录
  • 前言
  • 1. 什么是变参数模板
  • 2. 变参数模板的基础-模板形参包
    • 2.1 非类型模板形参包
    • 2.2 类型模板形参包
    • 2.3 模板模板形参包
  • 3. 模板形参包的延伸-函数形参包
  • 4. 模板形参包的展开方法
  • 5. stl中使用模板形参包的案例
  • 总结

前言

本篇文章介绍一下c++11中增加的变参数模板template<typename... _Args>到底是咋回事,以及它的具体用法。

说明一下,我用的是gcc7.1.0编译器,标准库源代码也是这个版本的。

按照惯例,还是先看一下本文大纲,如下:

在之前写vector和deque容器源码剖析的过程中,经常发现这样的代码,如下:

template<typename... _Args>
void emplace_front(_Args&&... __args);

可以看到里面模板参数是template<typename... _Args>,其实这个就是变参数模板,然后它的参数也是比较特别的_Args&&... __args,去除右值引用的话,它就是一个可变参数,那么可变参数模板和可变参数到底是什么,应该怎么使用呢,我们今天就来深究一下这些事情。

1. 什么是变参数模板

c++11中新增加了一项内容,叫做变参数模板,所谓变参数模板,顾名思义就是参数个数和类型都可能发生变化的模板,要实现这一点,那就必须要使用模板形参包。

模板形参包是可以接受0个或者n个模板实参的模板形参,至少有一个模板形参包的模板就可以称作变参数模板,所以说白了,搞懂了模板形参包就明白变参数模板了,因为变参数模板就是基于模板形参包来实现的,接下来我们就来看看到底啥是模板形参包。

2. 变参数模板的基础-模板形参包

模板形参包主要出现在函数模板和类模板中,目前来讲,模板形参包主要有三种,即:非类型模板形参包、类型模板形参包、模板模板形参包。

2.1 非类型模板形参包

非类型模板形参包语法是这样的:

template<类型 ... args>

初看会很疑惑,说是非类型模板形参包,怎么语法里面一开始就是一个类型的,其实这里的非类型是针对typename和class关键字来的,都知道模板使用typename或者class关键字表示它们后面跟着的名称是类型名称,而这里的形参包里面类型其实表示一个固定的类型,所以这里其实不如叫做固定类型模板形参包。

对于上述非类型模板形参包而言,类型选择一个固定的类型,args其实是一个可修改的参数名,如下:

template<int ... data> xxxxxx;

注意,这个固定的类型是有限制的,标准c++规定,只能为整型、指针和引用。

但是这个形参包该怎么用呢,有这样一个例子,比如我想统计这个幼儿园的小朋友们的年龄总和,但是目前并不知道总共有多少个小朋友,那么此时就可以用这个非类型模板形参包,代码如下:

#include <iostream>
using namespace std;

//这里加一个空模板函数是为了编译可以通过,否则编译期间调用printAmt<int>(int&)就会找不到可匹配的函数
//模板参数第一个类型实际上是用不到的,但是这里必须要加上,否则就是调用printAmt<>(int&),模板实参为空,但是模板形参列表是不能为空的
template<class type>
void printAmt(int &iSumAge)
{
    return;
}

template<class type, int age0, int ... age>
void printAmt(int &iSumAge)
{
    iSumAge += age0;
    //这里sizeof ... (age)是计算形参包里的形参个数,返回类型是std::size_t,后续同理
    if ( (sizeof ... (age)) > 0 )
    {
        //这里的age...其实就是语法中的一种包展开,这个后续会具体说明
        printAmt<type, age...>(iSumAge);
    }
}

int main()
{
    int sumAge = 0;
    printAmt<int,1,2,3,4,5,7,6,8>(sumAge);
    cout << "the sum of age is " << sumAge << endl;
    return 0;
}

这里只是以此为例来说明一下非类型模板形参包的使用,实际项目中这么简单的事肯定是没有必要还写个模板的。

根据语法和代码的使用情况,我们对非类型模板形参包总结如下:

  • 非类型模板形参包类型是固定的,但参数名跟普通函数参数一样,是可以修改的;
  • 传递给非类型模板形参包的实参不是类型,而是实际的值。

2.2 类型模板形参包

类型模板形参包语法如下:

typename|class ... Args

这个就是很正常的模板形参了哈,typename关键字和class关键字都可以用于在模板中声明一个未知类型,只是在以前template<typename type>的基础上加了一个省略号,改成了可变形参包而已,该可变形参包可以接受无限个不同的实参类型。

现在我们先用一下这个类型模板形参包看看,假设我们有这样一种场景,我想输出一个人的姓名、性别、年龄、身高等个人信息,但是具体有哪些信息我们不能确定,那应该怎么办呢?

分析一下,具体信息不固定,类型也不固定,此时就可以使用类型模板形参包了,看下面这段代码:

#include <iostream>
using std::cout;
using std::endl;

void xprintf()
{
    cout << endl;
}

template<typename T, typename... Targs>
void xprintf(T value, Targs... Fargs)
{
    cout << value << ' ';
    if ( (sizeof ...(Fargs)) > 0 )
    {
        //这里调用的时候没有显式指定模板,是因为函数模板可以根据函数参数自动推导
        xprintf(Fargs...);
    }
    else
    {
        xprintf();
    }
}

int main()
{
    xprintf("小明个人信息:", "小明", "男", 35, "程序员", 169.5);
    return 0;
}

输出结果如下:

小明个人信息: 小明 男 35 程序员 169.5

这个就是一个类型模板形参包在函数模板里面的典型使用,可以看到,

当然啦,有人会说了,其实cout一行代码就可以搞定,但是我们这里是提供通用型接口,具体要输出哪些信息事先并不知道,这个时候使用类型模板形参包就很方便啦。

2.3 模板模板形参包

这个就有点绕了,模板模板形参包,有点不好理解,还是先看一下语法看看:

template < 形参列表 > class ... Args(可选)

其实说白了,就是说这个形参包本身它也是一个模板,在看模板模板形参包之前,我们先介绍一下模板模板形参,因为形参包说白了,就是在形参的基础上增加了省略号实现的。

我们先看一下标准库中对模板模板形参的使用,找到头文件bits/alloc_traits.h,在模板类allocator_traits的声明中有这样一个结构体,如下:

template<template<typename> class _Func, typename _Tp>
    struct _Ptr<_Func, _Tp, __void_t<_Func<_Alloc>>>
    {
      using type = _Func<_Alloc>;
    };

这里的意思就是说_Func这个模板形参本身是一个带模板的类型,使用的时候是需要声明模板实参的。

假设有这样一种场景,我们需要定义一个vector变量,但不能确定vector的元素类型,此时该怎么办呢?

看如下代码:

#include <typeinfo>
#include <cxxabi.h>
#include <iostream>
#include <vector>

//将gcc编译出来的类型翻译为真实的类型
const char* GetRealType(const char* p_szSingleType)
{
    const char* szRealType = abi::__cxa_demangle(p_szSingleType, nullptr, nullptr, nullptr);
    return szRealType;
}

//这里的func是一个模板模板形参
template<template<typename, typename> class func, typename tp, typename alloc = std::allocator<tp> >
struct temp_traits
{
    using type = func<tp, alloc>;
    type tt;//根据模板类型定义一个成员变量
};

int main()
{
    temp_traits<std::vector, int> _traits;
    //获取结构体字段tt的类型
    const std::type_info &info = typeid(_traits.tt);
    std::cout << GetRealType(info.name()) << std::endl;
    return 0;
}

输出结果如下:

std::vector<int, std::allocator<int> >

这里类型temp_tratis里面根据模板模板形参和其他模板形参来实现了我们的使用场景。

理解了模板模板形参,再来看看模板模板形参包的使用,这个与类型模板形参包没什么两样,只不过类型换成了一个带模板的类型而已,看下面这段代码:

#include <typeinfo>
#include <cxxabi.h>
#include <iostream>
#include <vector>
#include <deque>
#include <list>

//将gcc编译出来的类型翻译为真实的类型
const char* GetRealType(const char* p_szSingleType)
{
    const char* szRealType = abi::__cxa_demangle(p_szSingleType, nullptr, nullptr, nullptr);
    return szRealType;
}
//泛化变参模板
template<typename tp, typename alloc, template<typename, typename> class ... types >
struct temp_traits
{
    temp_traits(tp _tp)
    {
        std::cout << "泛化模板执行" << std::endl;
    }
};
//偏特化变参模板
template< typename tp, typename alloc, template<typename, typename> class type, template<typename, typename> class ... types >
struct temp_traits<tp, alloc,type, types...>:public temp_traits<tp, alloc, types...>
{
    using end_type = type<tp, alloc>;
    end_type m_object;
    temp_traits(tp _tp)
    :temp_traits<tp, alloc, types...>(_tp)
    {
        const std::type_info &info = typeid(m_object);
        std::cout << "偏特化版本执行, 此时类型:" << GetRealType(info.name()) << std::endl;
        m_object.push_back(_tp);
    }
    void print()
    {
        auto it = m_object.begin();
        for(;it != m_object.end(); ++it)
        {
            std::cout << "类型为:" << GetRealType(typeid(end_type).name()) << ", 数据为:" << *it << std::endl;
        }
    }
};

int main()
{
    temp_traits<int, std::allocator<int>, std::vector, std::deque, std::list> _traits(100);
    _traits.print();
    return 0;
}

这段代码就相当不好理解了,我们可以认为它是一个递归继承的过程,但到底是怎么个递归继承法呢?可以先看一下执行结果,由结果来倒推递归过程。

先看一下执行结果,如下:

泛化模板执行
偏特化版本执行, 此时类型:std::__cxx11::list<int, std::allocator<int> >
偏特化版本执行, 此时类型:std::deque<int, std::allocator<int> >
偏特化版本执行, 此时类型:std::vector<int, std::allocator<int> >
类型为:std::vector<int, std::allocator<int> >, 数据为:100

根据4次构造函数的调用,我们可以得出结论:形参包包含多少个形参,它就会在此基础上有几层继承,所以现在是3个形参,3层继承,顶层基类是泛化模板,然后进行了三层派生,这个递归继承的过程是编译器根据代码自行展开的。

再看看对于成员函数print的调用,我的原意是想针对每一种容器类型,都打印出结果,但现在只打印了一种,我们可以想想,对于继承,非虚函数但函数类型相同的情况下,派生类的成员函数会覆盖基类的成员函数,所以这里结果是正常的。

那么怎么实现我们要的效果呢,答案是使用析构函数,层层析构,所以将成员函数print函数修改为如下代码:

~temp_traits()
    {
        auto it = m_object.begin();
        for(;it != m_object.end(); ++it)
        {
            std::cout << "类型为:" << GetRealType(typeid(end_type).name()) << ", 数据为:" << *it << std::endl;
        }
    }

此时输出结果如下:

泛化模板执行
偏特化版本执行, 此时类型:std::__cxx11::list<int, std::allocator<int> >
偏特化版本执行, 此时类型:std::deque<int, std::allocator<int> >
偏特化版本执行, 此时类型:std::vector<int, std::allocator<int> >
类型为:std::vector<int, std::allocator<int> >, 数据为:100
类型为:std::deque<int, std::allocator<int> >, 数据为:100
类型为:std::__cxx11::list<int, std::allocator<int> >, 数据为:100

到这里,我们对模板模板形参包应该就有了比较深的了解了。

注意,不论是哪种形参包,形参包都需要放在模板的最后面,否则编译就会有问题。

3. 模板形参包的延伸-函数形参包

我们都知道函数形参是什么,那么函数形参包呢,它到底是什么,先看看函数形参包的语法:

Args ... args

这里的Args...代表形参包类型,这个类型就是模板形参包里面声明的类型,args就是函数的形参名称了,是可以自定义的。

那么是所有的模板形参包声明类型都可以作为函数形参包类型吗,不是的,前面我们讲了三种模板形参包,这其中除了非类型的模板形参包因为类型固定且是具体的值,不能作为函数形参包以外,类型模板形参包和模板模板形参包因为声明的都是类型,所以他们是可以用作函数形参的类型的。

类型模板形参包声明函数形参我们在2.2节的代码举例里面已经说明了,这里不再举例,我们看下模板模板行参包怎么样作为函数的形参,代码如下:

#include <typeinfo>
#include <cxxabi.h>
#include <iostream>
#include <vector>
#include <list>
#include <deque>

//将gcc编译出来的类型翻译为真实的类型
const char* GetRealType(const char* p_szSingleType)
{
    const char* szRealType = abi::__cxa_demangle(p_szSingleType, nullptr, nullptr, nullptr);
    return szRealType;
}

void xprintf()
{
    std::cout << "调用空函数" << std::endl;
}

template<typename tp, typename alloc, template<typename, typename> class T, template<typename, typename> class ... Targs >
void xprintf(T<tp, alloc> value, Targs<tp, alloc>... Fargs)
{
    std::cout << "容器类型:" << GetRealType(typeid(value).name()) << std::endl;
    std::cout << "容器数据:" << std::endl;
    auto it = value.begin();
    for(; it != value.end(); ++it)
    {
        std::cout << *it << ',';
    }
    std::cout << std::endl;
    if ( (sizeof ...(Fargs)) > 0 )
    {
        //这里调用的时候没有显式指定模板,是因为函数模板可以根据函数参数自动推导
        xprintf(Fargs...);
    }
    else
    {
        xprintf();
    }
}

int main()
{
    std::vector<int> vt;
    std::deque<int> dq;
    std::list<int> ls;
    for(int i =0 ; i < 10 ; ++i)
    {
        vt.push_back(i);
        dq.push_back(i);
        ls.push_back(i);
    }

    xprintf(vt, dq, ls);
    return 0;
}

这个就是一个典型的使用模板模板形参包类型作为函数形参的案例,说白了,我们要理解函数形参包的本质,它其实还是一个函数形参,既然是函数形参,就脱离不了类型加参数名的语法,形参包无非就是在类型后面加个省略号,而模板模板形参包作为函数形参类型的时候一定要记得加模板参数,比如代码里面T<tp, alloc>这样才是一个完整的类型,光是一个T,它的类型就是不完整的。

理解了以上的这一点,我们对函数形参包的使用就没有难度了。

4. 模板形参包的展开方法

到底啥是形参包展开,我们先看看语法,如下:

模式 ...

在模式后面加省略号,就是包展开了,而所谓的模式一般都是形参包名称或者形参包的引用,包展开以后就变成零个或者多个逗号分隔的实参。

比如上面的age ...和Fargs...都属于包展开,但是要知道,这种形式我们是没有办法直接使用的,那么具体该怎么使用呢,有两种办法:

  • 一是使用递归的办法把形参包里面的参数一个一个的拿出来进行处理,最后以一个默认的函数或者特化模板类来结束递归;
  • 二是直接把整个形参包展开以后传递给某个适合的函数或者类型。

递归方法适用场景:多个不同类型和数量的参数有比较相似的动作的时候,比较适合使用递归的办法。

关于递归办法的使用,前面几节有多个案例了,这里不再展开多说。

关于整个形参包传递的使用方法,看下面代码:

#include <iostream>
#include <string>
using namespace std;

class programmer
{
    string name;
    string sex;
    int age;
    string vocation;//职业
    double height;
public:
    programmer(string name, string sex, int age, string vocation, double height)
    :name(name), sex(sex), age(age), vocation(vocation), height(height)
    {
        cout << "call programmer" << endl;
    }

    void print()
    {
        cout << "name:" << name << endl;
        cout << "sex:" << sex << endl;
        cout << "age:" << age << endl;
        cout << "vocation:" << vocation << endl;
        cout << "height:" << height << endl;

    }
};

template<typename T>
class xprintf
{
    T * t;
public:
    xprintf()
    :t(nullptr)
    {}

    template<typename ... Args>
    void alloc(Args ... args)
    {
        t = new T(args...);
    }

    void print()
    {
        t->print();
    }

    void afree()
    {
        if ( t != nullptr )
        {
            delete t;
            t = nullptr;
        }
    }
};

int main()
{
    xprintf<programmer> xp;
    xp.alloc("小明", "男", 35, "程序员", 169.5);
    xp.print();
    xp.afree();
    return 0;
}

这里类型xprintf是一个通用接口,类模板中类型T是一个未知类型,我们不知道它的构造需要哪些类型、多少个参数,所以这里就可以在它的成员函数中使用变参数模板,来直接把整个形参包传递给构造函数,具体需要哪些实参就根据模板类型T的实参类型来决定。

5. stl中使用模板形参包的案例

再来说回一开始的案例,如下:

template<typename... _Args>
void emplace_front(_Args&&... __args);

这个是deque容器里面的函数,函数emplace_front可以说是push_front的一个优化版本,从它的原型可以看出,这个函数就是类型模板形参包的典型使用,只不过这里多了两个符号&&,这个我们先前也讲过,它代表右值引用,对于右值引用,如果元素类型是int、double这样的原生类型,其实右值引用和直接传值,区别不是很大。

那么这里函数原型中的参数_Args&&... __args到底代表什么呢,抛开右值引用不说,它就是多个参数,难道是可以在容器中插入多个不同类型的元素吗,并不是啊,容器中的元素是必须要一致的,这里的参数其实是容器定义时元素类型构造函数的多个参数,也就是说,函数emplace_front可以直接传入元素的构造参数,下面我们看看到底是怎么使用的,代码如下:

#include <deque>
#include <string>
#include <iostream>

class CMan
{
    int age;
    std::string sex;
    double money;
public:
    CMan(int age, std::string sex, double money)
    :age(age), sex(sex), money(money)
    {
        std::cout << "call contrust" << std::endl;
    }

    CMan(CMan && other)
    :age(other.age), sex(other.sex), money(other.money)
    {
        std::cout << "call move contrust" << std::endl;
    }
};

int main()
{
    std::deque<CMan> dq;
    dq.emplace_front(30, "man", 12.3);

    return 0;
}

可以看到,它就是利用了变参数模板的特性,传入了多个不同的构造入参,那么这些构造入参是怎么传入到类CMan本身的呢,我们看看函数emplace_front的源码实现,如下:

#if __cplusplus >= 201103L
  template<typename _Tp, typename _Alloc>
    template<typename... _Args>
#if __cplusplus > 201402L
      typename deque<_Tp, _Alloc>::reference
#else
      void
#endif
      deque<_Tp, _Alloc>::
      emplace_front(_Args&&... __args)
      {
    if (this->_M_impl._M_start._M_cur != this->_M_impl._M_start._M_first)
      {
        _Alloc_traits::construct(this->_M_impl,
                                 this->_M_impl._M_start._M_cur - 1,
                         std::forward<_Args>(__args)...);
        --this->_M_impl._M_start._M_cur;
      }
    else
      _M_push_front_aux(std::forward<_Args>(__args)...);
#if __cplusplus > 201402L
    return front();
#endif
      }

可以看到,实际上是使用了std::forward来把形参包整个传递到内存分配器里面去,然后在内存分配器里面又通过调用operator new和std::forward把形参包传递给了容器的元素类型的构造函数。

std::forward意思是完美转发,可以把参数原封不动的传递下去。

这么一看,这不就是我们第4节里面说的形参包展开的第二种方法的一种实际使用案例吗,只是这里使用了std::forward实现了完美转发而已。

总结

到此这篇关于c++11变参数模板的文章就介绍到这了,更多相关c++11变参数模板内容请搜索我们以前的文章或继续浏览下面的相关文章希望大家以后多多支持我们!

(0)

相关推荐

  • 一篇文章让你彻底明白c++11增加的变参数模板

    目录 前言 1. 什么是变参数模板 2. 变参数模板的基础-模板形参包 2.1 非类型模板形参包 2.2 类型模板形参包 2.3 模板模板形参包 3. 模板形参包的延伸-函数形参包 4. 模板形参包的展开方法 5. stl中使用模板形参包的案例 总结 前言 本篇文章介绍一下c++11中增加的变参数模板template<typename... _Args>到底是咋回事,以及它的具体用法. 说明一下,我用的是gcc7.1.0编译器,标准库源代码也是这个版本的. 按照惯例,还是先看一下本文大纲,如下

  • 一篇文章看懂C#中的协变、逆变

    1. 基本概念 官方:协变和逆变都是术语,前者指能够使用比原始指定的派生类型的派生程度更大(更具体的)的类型,后者指能够使用比原始指定的派生类型的派生程度更小(不太具体的)的类型.[MSDN] 公式: 协变:IFoo<父类> = IFoo<子类>: 逆变:IBar<子类> =  IBar<父类>: 暂时不理解没关系,您接着往下看. 2. 协变(Covariance) 1) out关键字 对于泛型类型参数,out 关键字可指定类型参数是协变的. 可以在泛型接口

  • 一篇文章入门Python生态系统(Python新手入门指导)

    译者按:原文写于2011年末,虽然文中关于Python 3的一些说法可以说已经不成立了,但是作为一篇面向从其他语言转型到Python的程序员来说,本文对Python的生态系统还是做了较为全面的介绍.文中提到了一些第三方库,但是Python社区中强大的第三方库并不止这些,欢迎各位Pytonistas补充. •原文链接:http://mirnazim.org/writings/python-ecosystem-introduction/ •译文链接:http://codingpy.com/artic

  • 一篇文章带你吃透Vue生命周期(结合案例通俗易懂)

    目录 1.vue生命周期 1.0_人的-生命周期 1.1_钩子函数 1.2_初始化阶段 1.3_挂载阶段 1.4_更新阶段 1.5_销毁阶段 2.axios 2.0_axios基本使用 2.1_axios基本使用-获取数据 2.2_axios基本使用-传参 2.3_axios基本使用-发布书籍 2.4_axios基本使用-全局配置 3.nextTick和refs知识 3.0$refs-获取DOM 3.1$refs-获取组件对象 3.2$nextTick使用 3.3$nextTick使用场景 3.

  • 一篇文章就能了解Rxjava

    前言: 第一次接触RxJava是在前不久,一个新Android项目的启动,在评估时选择了RxJava.RxJava是一个基于事件订阅的异步执行的一个类库.听起来有点复杂,其实是要你使用过一次,就会大概明白它是怎么回事了!为是什么一个Android项目启动会联系到RxJava呢?因为在RxJava使用起来得到广泛的认可,又是基于Java语言的.自然会有善于组织和总结的开发者联想到Android!没错,RxAndroid就这样在RxJava的基础上,针对Android开发的一个库.今天我们主要是来讲

  • 一篇文章搞定JavaScript类型转换(面试常见)

    为啥要说这个东西?一道面试题就给我去说它的动机. 题如下: var bool = new Boolean(false); if (bool) { alert('true'); } else { alert('false'); } 运行结果是true!!! 其实啥类型转换啊,操作符优先级啊,这些东西都是最最基本的.犀牛书上有详细的介绍.但我很少去翻犀牛书的前5章... 比如说优先级那块儿,很多书都教育我们,"不用去背诵优先级顺序,不确定的话,加括号就行了."平常我们写代码时也确实这么做的

  • 一篇文章搞懂JavaScript正则表达式之方法

    咱们来看看JavaScript中都有哪些操作正则的方法. RegExp RegExp 是正则表达式的构造函数. 使用构造函数创建正则表达式有多种写法: new RegExp('abc'); // /abc/ new RegExp('abc', 'gi'); // /abc/gi new RegExp(/abc/gi); // /abc/gi new RegExp(/abc/m, 'gi'); // /abc/gi 它接受两个参数:第一个参数是匹配模式,可以是字符串也可以是正则表达式:第二个参数是

  • 一篇文章读懂Python赋值与拷贝

    变量与赋值 在 Python 中,一切皆为对象,对象通过「变量名」引用,「变量名」更确切的叫法是「名字」,好比我们每个人都有自己的名字一样,咱们通过名字来代指某个人,代码里面通过名字来指代某个对象. 变量赋值就是给对象绑定一个名字,赋值并不会拷贝对象.好比我们出生的时候父母就要给我们取一个名字一样,给人取个绰号并不来多出一个人来,只是多一个名字罢了. 两个对象做比较有两种方式,分别是:is 与 == ,is比较的是两个对象是否相同,通过对象的ID值可识别是否为相同对象,==比较的是两个对象的值是

  • 一篇文章解决Java异常处理

    前言 与异常相关的内容其实很早就想写了,但由于各种原因(懒)拖到了现在.在大二开学前夜(今天是8.31)完成这篇博客,也算完成了暑期生活的一个小心愿. 以下内容大多总结自<Java核心技术 卷Ⅰ>,同时也加上了一些华东师范大学陈良育老师在<Java核心技术>Mooc中所讲的内容. 一.引例 假定你希望完成一个read方法,它的作用是读取一个文件中的内容并进行相关处理,如果你从未学过处理异常的方法,你可能会这样写: public void read(String filename)

  • 一篇文章带你搞定 springsecurity基于数据库的认证(springsecurity整合mybatis)

    一.前期配置 1. 加入依赖 <dependency> <groupId>com.alibaba</groupId> <artifactId>druid-spring-boot-starter</artifactId> <version>1.1.10</version> </dependency> <dependency> <groupId>mysql</groupId> &

随机推荐