最近在看候捷的C++系列视频,其中有一个例子讲到了使用迭代器特性(iterator_traits)和使用模版模版参数(template template parameter)以及模版别名(alias template)来完成同一件事情,非常的经典,这里记录一下。

事情的起因


事情的起因是要比较在不同类型的容器类型(vector、deque、set等等)中,对象是否有moveable的相关构造函数对容器各种操作性能的影响。所以要编写一个通用的模版方法,允许接受不同的模版参数,以及不同的数据类型,并能输出调用各种操作(拷贝构造,移动构造,赋值构造)耗费的时间。

为了方便进行,这里编写了两个简化版本的String类,一个名为MyString的类带有各种移动构造,拷贝,赋值函数(Big Five)

#include <iostream>
#include <cstring>
#include <string>
class MyString
{
public:
    static size_t DCtor;   // 累计 默认构造函数 调用次数
    static size_t Ctor;    // 累计 构造函数 调用次数
    static size_t CCtor;   // 累计 拷贝构造函数 调用次数
    static size_t CAssign; // 累计 拷贝赋值构造函数 调用次数
    static size_t MCtor;   // 累计 移动构造函数 调用次数
    static size_t MAssign; // 累计 移动赋值构造函数 调用次数
    static size_t Dtor;    // 累计 析构函数 调用次数
private:
    char *_data;
    size_t _length;
    void _init_data(const char *s)
    {
        _data = new char[_length + 1];
        memcpy(_data, s, _length);
        _data[_length] = '\0';
    }
public:
    // default ctor
    MyString() : _data(nullptr), _length(0)
    {
        ++DCtor;
    }
    // ctor
    MyString(const char *p) : _length(strlen(p))
    {
        ++Ctor;
        _init_data(p);
    }
    // copy ctor
    MyString(const MyString &s) : _length(s._length)
    {
        ++CCtor;
        _init_data(s._data);
    }

    // move ctor with noexcept
    MyString(MyString &&str) noexcept : _data(str._data), _length(str._length)
    {
        ++MCtor;
        str._length = 0;
        str._data = nullptr;
    }

    // copy assignment
    MyString &operator=(const MyString &str)
    {
        ++CAssign;
        if (this != &str)
        {
            if (_data)
                delete _data;
            _length = str._length;
            _init_data(str._data);
        }
        return *this;
    }

    // move assignment
    MyString &operator=(MyString &&str) noexcept
    {
        ++MAssign;
        if (this != &str)
        {
            if (_data)
                delete _data;
            _length = str._length;
            _data = str._data;
            str._length = 0;
            str._data = nullptr;
        }
        return *this;
    }

    virtual ~MyString()
    {
        ++Dtor;
        if (_data)
            delete _data;
    }

    bool operator<(const MyString &rhs) const
    {
        return std::string(this->_data) < std::string(rhs._data);
    }

    bool operator==(const MyString &rhs) const
    {
        return std::string(this->_data) == std::string(rhs._data);
    }

    char *get() const
    {
        return _data;
    }
};

size_t MyString::DCtor = 0;
size_t MyString::Ctor = 0;
size_t MyString::CCtor = 0;
size_t MyString::CAssign = 0;
size_t MyString::MCtor = 0;
size_t MyString::MAssign = 0;
size_t MyString::Dtor = 0;

namespace std
{
    template <>
    struct hash<MyString>
    {
        size_t operator()(const MyString &s) const noexcept
        {
            return hash<string>()(string(s.get()));
        }
    };
}

另一个名为MyStringNoMove的类不带各种移动构造(Big Three)。

#include <cstring>
#include <iostream>
#include <string>
class MyStringNoMove
{
public:
    static size_t DCtor;   // 累计 默认构造函数 调用次数
    static size_t Ctor;    // 累计 构造函数 调用次数
    static size_t CCtor;   // 累计 拷贝构造函数 调用次数
    static size_t CAssign; // 累计 拷贝赋值构造函数 调用次数
    static size_t MCtor;   // 累计 移动构造函数 调用次数
    static size_t MAssign; // 累计 移动赋值构造函数 调用次数
    static size_t Dtor;    // 累计 析构函数 调用次数
private:
    char *_data;
    size_t _length;
    void _init_data(const char *s)
    {
        _data = new char[_length + 1];
        memcpy(_data, s, _length);
        _data[_length] = '\0';
    }
public:
    // default ctor
    MyStringNoMove() : _data(nullptr), _length(0)
    {
        ++DCtor;
    }
    // ctor
    MyStringNoMove(const char *p) : _length(strlen(p))
    {
        ++Ctor;
        _init_data(p);
    }

    MyStringNoMove(const MyStringNoMove &s) : _length(s._length)
    {
        ++CCtor;
        _init_data(s._data);
    }

    MyStringNoMove &operator=(const MyStringNoMove &s)
    {
        ++CAssign;
        if (this != &s)
        {
            if (_data != nullptr)
                delete _data;
            _length = s._length;
            _init_data(s._data);
        }
        return *this;
    }

    virtual ~MyStringNoMove()
    {
        ++Dtor;
        if (_data)
            delete _data;
    }

    char *get() const { return _data; }

    bool operator<(const MyStringNoMove &rhs) const
    {
        return std::string(_data) < std::string(rhs._data);
    }

    bool operator==(const MyStringNoMove &rhs) const
    {
        return std::string(_data) == std::string(rhs._data);
    }
};
size_t MyStringNoMove::DCtor = 0;
size_t MyStringNoMove::Ctor = 0;
size_t MyStringNoMove::CCtor = 0;
size_t MyStringNoMove::CAssign = 0;
size_t MyStringNoMove::MCtor = 0;
size_t MyStringNoMove::MAssign = 0;
size_t MyStringNoMove::Dtor = 0;

namespace std
{
    template <>
    struct hash<MyStringNoMove>
    {
        size_t operator()(const MyStringNoMove &s) const noexcept
        {
            return hash<string>()(string(s.get()));
        }
    };
}

这两个类,除了定义各种赋值、拷贝、移动方法之外,还定义了一些static变量用来统计各种构造的调用次数,以及重载了==和<操作符以方便能够放入到关联容器中。

有了这两个类,对于不同种容器,当然可以针对每种容器编写一个测试用例,但这样就会有很多重复代码,不够优雅,所以解决方法就是使用模版编程,编写一个模版方法,该模版方法接受一个容器对象和测试时运行的迭代次数,这就是本次问题的核心。要实现这个功能有多种方法,先看不使用模版模版参数以及模版别名的实现方式。

方法一:使用方法模版、迭代器以及迭代器特性来实现


我们在获取一个STL容器实例对象之后,如何获取该容器里面的元素类型呢?拜STL中所有的容器都实现了迭代器(iterator)这一特性所赐,我们可以使用迭代器特性(iterator_traits)来从容器的迭代器中获取元素的具体类型,这样就可以对这个获取到的类型进行实例化,这是整个方法的关键。

#include <typeinfo>
#include <iterator>
#include <iostream>

template <typename T>
void output_static_data(const T &mystr)
{
    std::cout << typeid(mystr).name() << "--" << std::endl;
    std::cout
        << "CCtor=" << T::CCtor
        << " MCtor=" << T::MCtor
        << " CAssign=" << T::CAssign
        << " MAssign=" << T::MAssign
        << " DCtor=" << T::DCtor
        << " Ctor=" << T::Ctor
        << " Dtor=" << T::Dtor
        << std::endl;
}

template <typename M, typename NM>
void test_moveable(M c1, NM c2, long &value)
{
    char buf[10];
    // test move
    typedef typename std::iterator_traits<typename M::iterator>::value_type V1Type;
    std::cout << "\n\ntest, with moveable elements" << std::endl;
    clock_t timeStart = clock();
    for (long i = 0; i < value; ++i)
    {
        snprintf(buf, 10, "%d", rand());
        auto ite = c1.end();
        c1.insert(ite, V1Type(buf));
    }
    std::cout << "construction,milli-seconds : " << (clock() - timeStart) << std::endl;
    std::cout << "size()=" << c1.size() << std::endl;
    output_static_data(*(c1.begin()));

    timeStart = clock();
    M c11(c1);
    std::cout << "copy, milli-seconds : " << (clock() - timeStart) << std::endl;
    timeStart = clock();
    M c12(std::move(c1));
    std::cout << "move copy, milli-seconds : " << (clock() - timeStart) << std::endl;
    timeStart = clock();
    c11.swap(c12);
    std::cout << "swap, milli-seconds : " << (clock() - timeStart) << std::endl;

    // test nomove
    typedef typename std::iterator_traits<typename NM::iterator>::value_type V2Type;
    std::cout << "\n\ntest, with no-moveable elements " << std::endl;
    .........//代码同上,此处省略
}

上面这个模版方法有两个模版参数M和NM,用户可以传入同一种容器类型的两种不同元素,比如像下面这样调用:

long value = 1000000;
test_moveable(std::vector<MyString>(), std::vector<MyStringNoMove>(), value);

方法在获取到了模版参数之后,通过使用迭代器特性(iterator_traits)来查询容器迭代器中的数据类型,得到了V1Type,然后通过for循环,随机生成字符串,最后通过容器的插入方法,并使用迭代器,将元素插入到容器中。方法后面的部分分别测试了拷贝构造,移动拷贝构造等的耗时时间。上面方法的运行结果如下:

test, with moveable elements
construction,milli-seconds : 1440
size()=1000000
8MyString--
CCtor=0 MCtor=2048575 CAssign=0 MAssign=0 DCtor=0 Ctor=1000000 Dtor=2048575
copy, milli-seconds : 550
move copy, milli-seconds : 0
swap, milli-seconds : 0

test, with no-moveable elements
construction,milli-seconds : 100884
size()=1000000
14MyStringNoMove--
CCtor=2048575 MCtor=0 CAssign=0 MAssign=0 DCtor=0 Ctor=1000000 Dtor=2048575
copy, milli-seconds : 427
move copy,milli-seconds : 0
swap,milli-seconds : 0

可以看到,对于vector这种会自动扩容的容器,容器中的对象是否具有移动构造和移动赋值,对程序性能的影响很大。比如在初始化插入对象的时候,如果对象有移动构造函数,那么就直接调用移动构造函数,如果没有,就需要先调用拷贝构造函数生成临时对象,然后将该对象调用拷贝构造函数放到容器中,然后调用该临时对象的析构函数。可以看到,单单在构造阶段的速度差了将近100倍。

方法二:使用模版模版方法和模版别名来实现


上面的方法中,是使用的迭代器特性来从模版参数中查询到的容器里面元素的类型,假如某个容器不支持迭代器,或者说没有实现迭代器特性呢。那么就没有办法使用以上方法了。

那能不能将容器中的元素类型作为模版参数传进去呢?接下来就是几种尝试

尝试一:普通方法


第一种尝试就是使用普通的方法,将容器名称和元素类型直接传进去:

void test_move(Container c, T element, long &value)
{
    Container<T> c;
    for (long i = 0; i < value; ++i)
    {
        c.insert(c.end(), T());
        output_static_data(T());
    }
    Container<T> c1(c);
    Container<T> c2(std::move(c));
    c1.swap(c2);
}

然后使用如下方法调用:

test_move(std::vector,MyString,100000);
test_move(std::vector,MyStringNoMove,100000);

但这只是异想天开,以上代码都无法通过编译:

错误有很多,比如没有定义Container类型,传进去的std::vector跟Containner没有任何关系,T类型也没有定义,因为这是一个普通的方法,而不是模版方法。

将该方法改为模版方法,这就是方案一的解决方案。

template<typename Container>
void test_move(Container c, long &value)
{
    typedef typename std::iterator_traits<typename Container::iterator>::value_type ValType;
    for (long i = 0; i < value; ++i)
    {
        c.insert(c.end(), ValType());
        output_static_data(*(c.begin()));
    }
    Container c1(c);
    Container c2(std::move(c));
    c1.swap(c2);
}

使用如下:

test_move(std::vector<MyString>(),value);
test_move(std::vector<MyStringNoMove>(),value);

尝试二:模版模版参数方法


还是希望尝试走前面那条路,如果模版参数本身就是一个模版,有没有办法获取这个模版模版参数的类型呢?这个就是模版模版参数的用法:

template <template <class> class Container, typename T>
class XCls
{
private:
    Container<T> c;

public:
    XCls(long &value)
    {
        for (long i = 0; i < value; ++i)
        {
            c.insert(c.end(), T());
            output_static_data(T());
        }
        Container<T> c1(c);
        Container<T> c2(std::move(c));
        c1.swap(c2);
    }
};

省略了一些计时的操作。这段代码相当完美,这应该就是我们想要的,并且能编译通过。用法如下:

XCls<std::vector,MyString> c1(value);

这段语句也能运行,结果如下:

8MyString--
CCtor=0 MCtor=2048575 CAssign=0 MAssign=0 DCtor=1000001 Ctor=0 Dtor=2048575

但在候捷老师的课里,这里能编译成功,但是运行的时候是会报错的🤣,他说原因在与标准模板库的定义中除了定义元素类型T之外,还有一个分配器参数Allocator,其中的类型也是T。比如std::vector的签名如下:

template<typename _Tp, typename _Alloc = std::allocator<_Tp> >
class vector : protected _Vector_base<_Tp, _Alloc>
{
}

这个模版类有两个参数,且第二个模版参数的类型依赖于前一个模版参数,虽然第二个模版参数有默认值,但在候捷老师上课的那个时候,编译器是会报错的,不能直接给vector提供一个参数,因为第二个默认的参数没有办法推导出第一个模版参数,故而编译器无法推导出它的分配器,分配器必须显示声明。

当然奇怪的是,在我这里是可以运行且能成功的。我们假装不成功,看接下来的解决方法。

尝试三:模版模版参数和模版别名


在候捷老师当时的编译器下,标准模版库的第二个默认的分配器类型无法获取第一个模版参数,编译器无法推导出它的分配器,所以需要用到模版别名(alias template),就是使用using关键字。

template <typename T>
using Vec = std::vector<T, std::allocator<T>>;

template <typename T>
using Lst = std::list<T, std::allocator<T>>;

template <typename T>
using Deq = std::deque<T, std::allocator<T>>;

这里将Vec指定为了vector的别名,注意这里的模版参数是未固定的,接下来使用Vec<int>相当于使用了std::vector<int,std::allocator<int>>,这是模版别名不同于typedef的地方,typedef不支持模板化,但模板别名可以。

有了以上模版别名,尝试二里面定义的模版模版参数就能正常运行,调用方式变为了如下:

XCls<Vec,MyString> c1(value);
XCls<Lst,MyString> c2(value);
XCls<Deq,MyString> c3(value);

完美解决问题。

 

参考: