Loading

C++ STL浅析

STL是什么

standard-template-libary,标准模板库。

算法库,容器库,函数对象库,迭代器库,智能指针库等都属于STL

在C++标准中,STL被组织为下面的13个头文件:<algorithm>、<deque>、<functional>、<iterator>、<vector>、<list>、<map>、<memory>、<numeric>、<queue>、<set>、<stack>和<utility>。

我认为type_traits也是STL当中重要的一部分

OOP与GP

全局sort中使用的迭代器指针是要求支持随机访问的(RandomAccessIterator),也就是说该泛型指针支持++操作。而list是双向链表,每个节点在内存空间上的分布不是连续的,所以不支持使用全局的sort方法。

Malloc

malloc操作具有一定的开销,其申请的内存大小其实略大于程序员要求的大小,因为包含了一定大小的头部和尾部,成为Cookies,用来记录一些必要的信息(比如这块被申请的内存的长度)。所以说,当程序员申请的内存空间非常小时,Cookies的占比就会非常的高。若程序员多次申请非常小的内存,例如一百万次,那么就会产生难以忍受的额外开销。解决这种问题的方法之一是内存池(一次性申请一大块,然后自己写一个类来分配)

所以假设容器中有一百万个小元素,那么使用相应的allocator去申请一百万次内存空间,而该allocator只是对malloc的简单包装,没有做任何相关的优化,也就是说调用了一百万次malloc,那么效率会很低,导致总的Cookies占量非常高。

STL中共有几种类型的迭代器

STL中共有五种类型的迭代器(c++20前)

  • LegacyInputIterator:输入迭代器,例如std::istream_iterator
  • LegacyOutputIterator:输出迭代器,例如std::ostream_iteratorstd::back_insert_iteratorstd::front_insert_iterator
  • LegacyForwardIterator:前向迭代器,同时它也是输入迭代器
  • LegacyBidirectionalIterator:双向迭代器,同时它也是前向迭代器
  • LegacyRandomAccessIterator:随机访问迭代器,这是唯一一个支持比较大小的迭代器,同时也是双向迭代器

而这五种类型,分别代表了五种空结构体标签(通过标签调用到不同的重载函数)

遗留向前迭代器 (LegacyForwardIterator) 遗留双向迭代器 (LegacyBidirectionalIterator) 遗留随机访问迭代器 (LegacyRandomAccessIterator) 在自身的要求之外还满足遗留输出迭代器(LegacyOutputIterator)的要求时,它即被描述为可变的(mutable)

贴几张cppreference的定义





那么我们可以利用这个特性,这么干

void display_category(std::random_access_iterator_tag) 
{    
    std::cout << "random_access_iterator" << std::endl;
}
int main()
{    
    using it_tag_type = typename std::vector<int>::iterator::iterator_category;
    display_category(it_tag_type());
}

实现了一个简单的方法用于输出容器的迭代器类型

template<typename T>
void display_category()
{
    using it_tag = typename iterator_traits<T>::iterator_category;
    std::cout << typeid(it_tag).name() << std::endl;
}

int main()
{
    // struct std::bidirectional_iterator_tag
    display_category<std::list<int>::iterator>();
    // struct std::bidirectional_iterator_tag
    display_category<std::list<int>::const_iterator>();
    // struct std::forward_iterator_tag
    display_category<std::forward_list<int>::iterator>();
    // struct std::bidirectional_iterator_tag
    display_category<std::unordered_map<std::string, int>::iterator>();
    // struct std::random_access_iterator_tag
    display_category<std::deque<int>::iterator>();
}

随机访问意味着内存连续吗

不是,对于std::vector而言,由于它的本质是动态数组,所以它的iteratorrandom-access-iterator,且每两个相邻元素之间是内存连续的。对于std::deque而言,它的iterator也是random-access-iterator,但它并不是内存连续的

random-access-iterator代表我们对迭代器进行加减操作以访问元素的时间复杂度为O(1),例如数组支持随机访问;而链表(std::list)不支持,访问的时间复杂度为O(n)

逆向迭代器rbegin是否等于end

不等于,逆序迭代器重载了++--操作,逆序的++操作其实相当于正序迭代器的--

利用逆序迭代器可以反向排序数组

int main()
{
    std::vector<int> vec = { 3, 6, 1, -9, 4 };
    // 4 -9 1 6 3
    for (auto it = vec.rbegin(); it != vec.rend(); ++it)
        std::cout << *it << std::endl;
    // 使用greater仿函数 代表左大于右 也就是降序数组
    std::sort(vec.rbegin(), vec.rend(), greater<int>());
    // 逆序排列后结果 升序数组
    for (int num : vec)
        std::cout << num << std::endl;
}

类型萃取

iterator_traits的简单机理实现

更多相关内容可以参考我的博客:C++ 11中的可变模板参数,C++中的模板元编程(待发布)

std::remove_if,std::find_if,std::find和std::find_if的区别是什么

侯捷曾经说过,如果容器中有自己的find函数,那么它一定比<algorithm>中的std::find要更高效。一般情况下,非关联容器(比如std::setstd::unorder_map)都实现了find。这里主要介绍<algorithm>中的std::findstd::find_if

std::vector<std::string> vec{"Jelly", "Mike", "Zed", "John", "Dragon"};
// 在容器中找John 找到了则输出John
if (auto it = std::find(vec.cbegin(), vec.cend(), "John"); it != vec.cend())
    std::cout << *it << std::endl;

使用find时传进的时容器元素的类型,而使用find_if时参数传入的是函数对象

std::vector<std::string> vec{"Jelly", "Mike", "Zed", "John", "Dragon"};
if (auto it = std::find_if(vec.cbegin(), vec.cend(), [](const auto& str) { return str == "John"; }); it != vec.cend())    
    std::cout << *it << std::endl;

引用SOF上的一个问题和回答

Q: Why does the standard library have find and find_if? Couldn't find_if just be an overload of find?

A: Consider find_if is renamed find, then you have:

template <typename InputIterator, typename T>
InputIterator find(InputIterator first, InputIterator last, const T& value);
template <typename InputIterator, typename Predicate>
InputIterator find(InputIterator first, InputIterator last, Predicate pred);

If find() and find_if() had the same name, surprising abmiguities would have resulted. In general, the _if suffix is used to indicate that an algrithm takes a predicate.

再来看看removeremove_if。两者的区别和find系列类似,一个接受容器类型,一个接受函数对象。下文中的某个话题讲过,由于removeremove_if操作是<algorithm>中的方法,因此他们并不能直接删除容器里的元素,因此为了实现删除功能它们时常和成员函数erase搭配使用

std::vector<std::string> vec{"Jelly", "Mike", "Zed", "John", "Mike"};
auto it = std::remove(vec.begin(), vec.end(), "Mike");
// 输出一个空的字符串 因为移动赋值使原字符串置空
std::cout << *it << std::endl;
// "Jelly", "Zed", "John", "", "Mike"
//                         it
for (const auto& str : vec)
    std::cout << str << std::endl;

std::remove操作的返回值有三种情况

  • 当要“移除”的元素不在容器中时,会返回end
  • 当要移除的元素在最末尾时,无法执行覆盖操作,所以无法“移除”,也会返回末尾元素所在的迭代器
  • 当能正常“移除”时,会根据移除的元素的数量,返回倒数第N个元素的迭代器

那么你能猜出这两种情况的结果是什么吗,是的答案就是这么的神奇。但不管结果怎么样,它保证了数组的前部分是我们想要的数据

// "Jelly", "Zed", "John", "Jane", "", ""
std::vector<std::string> vec{"Jelly", "Mike", "Zed", "Mike", "John", "Jane"};
// "Jelly", "Zed", "John", "Jane", "Mike", ""
std::vector<std::string> vec{"Jelly", "Mike", "Zed", "John", "Mike", "Jane"};

std::removeerase搭配使用能够真正删除容器中的元素,这种搭配也称之为erase-remove_idiom

std::vector<std::string> vec{"Jelly", "Mike", "Zed", "John", "Mike", "Jane"};
vec.erase(std::remove(vec.begin(), vec.end(), "Mike"), vec.end());
// "Jelly", "Zed", "John", "Jane"
for (const auto& str : vec)
    std::cout << str << std::endl;

对于没有移动赋值的内置类型来说

// std::remove前
// 1 2 3 4 5
// std::remove第二个元素
// 1 3 4 5 5

erasestd::remove会优先调用移动赋值函数,没有的话再调用拷贝赋值函数。以std::string为例,当A移动赋值给B时,B会获得A的值,同时A置空

std::remove的实现

template<class ForwardIt, class T >
ForwardIt remove(ForwardIt first, ForwardIt last, const T& value)
{
    first = std::find(first, last, value);
    // 如果找得到符合条件的元素
    if (first != last)
    {
        // 开始逐个遍历后续元素然后进行移动赋值
        for (ForwardIt i = first; ++i != last; )
        {
            // 跳过满足条件的元素
            if (*i != value)
                *(first++) = std::move(*i);
        }
    }
    return first;
}

std::remove_if的实现

和std::remove的实现几乎是一致的

template<class ForwardIt, class UnaryPredicate>
ForwardIt remove_if(ForwardIt first, ForwardIt last, UnaryPredicate p)
{
    first = std::find_if(first, last, p);
    if (first != last)
        for(ForwardIt i = first; ++i != last; )
            if (!p(*i))
                *first++ = std::move(*i);
    return first;
}

std::initializer_list<T>

initializer_list的浅拷贝

std::initializer_list的实现其实很简单

template <class _Elem>
class initializer_list {
public:
    using value_type      = _Elem;
    using reference       = const _Elem&;
    using const_reference = const _Elem&;
    using size_type       = size_t;

    using iterator       = const _Elem*;
    using const_iterator = const _Elem*;

    constexpr initializer_list() noexcept : _First(nullptr), _Last(nullptr) {}

    constexpr initializer_list(const _Elem* _First_arg, const _Elem* _Last_arg) noexcept
        : _First(_First_arg), _Last(_Last_arg) {}

    _NODISCARD constexpr const _Elem* begin() const noexcept {
        return _First;
    }

    _NODISCARD constexpr const _Elem* end() const noexcept {
        return _Last;
    }

    _NODISCARD constexpr size_t size() const noexcept {
        return static_cast<size_t>(_Last - _First);
    }

private:
    const _Elem* _First;
    const _Elem* _Last;
};

可以通过源码看出,std::initializer_list并没有实现拷贝构造函数,而C++默认提供的版本是浅拷贝的,也就是说在拷贝的过程中是指针的复制,而更重要的是,与STL中的其他容器不同,std::initializer_list<T>的迭代器是const T*,并且迭代器记录的数据是储存在上的,因此当它作为函数值返回的时候,访问其中的元素将会出现不确定的结果

std::initializer_list中维护了一个数组指针,而其指向的数组是储存在栈上的

std::initializer_list<int> create_inlist() {
    // 浅拷贝
    auto initList = {1, 2, 3};
    return initList;
}
int main()
{
    // 出现不确定的结果
    for (auto i : create_inlist())
        std::cout << i << std::endl;
}

如何访问initializer_list中的元素

除了for-range-loop外,我们还可以通过begin()来获取到迭代器,然后使用访问数组指针的方式来访问std::initializer_list中的元素

auto initList = { 1, 2, 3 };
for (int i = 0; i < initList.size(); i++)
    std::cout << initList.begin()[i] << std::endl;

对于std::vector而言,容器本身重载了operator[],它的迭代器(random-access-iterator)也重载了operator[],因此

std::vector<int> initList = { 1, 2, 3 };
int data1 = initList[0];	// 1
int data2 = initList.begin()[1];	// 2

C++类中的初始化顺序是什么

struct MyStruct
{
    MyStruct() { cout << "无参构造" << endl; }
    MyStruct(int x) { cout << "有参构造" << endl;}
};

struct OrderTest
{
    MyStruct m1;
    MyStruct m2;
    MyStruct m3;
    static MyStruct sm;
    OrderTest() : m2(20) {}
};

MyStruct OrderTest::sm;

int main()
{
    // 无参构造 无参构造 有参构造 无参构造
    // sm m1 m2 m3
    OrderTest o;
}

vector相关问题

为什么vector使用的是push_back而不是push_front

因为vector中的数据在内存上是连续的,vector每次扩容的时候都会以当前大小的两倍去申请,也就是说在已经分配好内存的“末尾”添加一个元素是十分方便的。如果是从头进的话,那么代表vector里头的每个元素都要往后移动一位,也就意味着要执行数组当前容量次的拷贝操作,十分耗时耗空间

执行erase()会发生什么事情

移除单个元素的情况

class TestErase
{
public:
    int data;
    TestErase(int _data) : data(_data) { std::cout << "构造数据为" << data << "的类" << std::endl; }
    TestErase(const TestErase& _copy) : data(_copy.data)  { std::cout << "拷贝数据为" << data << "的类" << std::endl; }
    TestErase& operator=(const TestErase& _copy)
    {
        data = _copy.data;
        std::cout << "赋值数据为" << data << "的类" << std::endl;
        return *this;
    }
    ~TestErase() { std::cout << "析构数据为" << data << "的类" << std::endl; }
};

int main()
{
    std::vector<TestErase> vec;
    vec.reserve(5);
    vec.emplace_back(1);
    vec.emplace_back(2);
    vec.emplace_back(2);
    vec.emplace_back(3);
    vec.emplace_back(2);
    std::cout << "开始移除" << std::endl;
    for (auto it = vec.begin(); it != vec.end();)
    {
        // erase返回的是删除项的最后一项
        if (it->data == 2)
            it = vec.erase(it);
        else
            it++;
    }
    for (auto& t : vec)
        std::cout << t.data << std::endl;
}

执行一次erase会发生的事情

  • 获取传入的迭代器
  • 将迭代器后的元素逐个往前移,调用到operator=
  • end指针往前移,然后释放end指针所指的内存
  • 返回所清除的最后一个项的下一项(如果清除的是末尾,那么会返回end

移除多个元素的情况

int main()
{
    std::vector<TestErase> vec;
    vec.reserve(7);
    vec.emplace_back(1);
    vec.emplace_back(2);
    vec.emplace_back(20);
    vec.emplace_back(3);
    vec.emplace_back(8);
    vec.emplace_back(4);
    vec.emplace_back(5);
    std::cout << "开始移除" << std::endl;
    auto first = ::find_if(vec.begin(), vec.end(), [](const auto& t) { return t.data == 2; });
    auto last = ::find_if(vec.begin(), vec.end(), [](const auto& t) { return t.data == 4; });
    // 前闭后开的区间
    vec.erase(first, last);
    for (auto& t : vec)
        std::cout << t.data << std::endl;
}

为什么push_back会有两个版本的重载

首先先来看看源码,push_back其实会调用到emplace_back,因此push_back也是能调用到移动构造函数的,这取决于参数类型

template <class... _Valty>
inline decltype(auto) emplace_back(_Valty&&... _Val)
{
    // insert by perfectly forwarding into element at end, provide strong guarantee
    auto& _My_data   = _Mypair._Myval2;
    pointer& _Mylast = _My_data._Mylast;
    if (_Mylast != _My_data._Myend) {
        return _Emplace_back_with_unused_capacity(_STD forward<_Valty>(_Val)...);
    }

    _Ty& _Result = *_Emplace_reallocate(_Mylast, _STD forward<_Valty>(_Val)...);
    return _Result;
}

inline void push_back(const _Ty& _Val) {
    // insert element at end, provide strong guarantee
    emplace_back(_Val);
}

inline void push_back(_Ty&& _Val) {
    // insert by moving into element at end, provide strong guarantee
    emplace_back(_STD move(_Val));
}

push_back中实现了const T&以及T&&两个版本的重载,而不是使用通用引用进行完美转发,因为在push_back中,&&并不是一个通用引用,因为不存在类型推导,它其实表示的是右值引用

emplace_back中存在推导语境,因此使用搭配通用引用实现完美转发

但正是由于emplace_back是通用引用,因此也会引发下面这个问题

知乎-c++中为什么push_back({1,2})可以,emplace_back({1,2})会报错?

对于vector<vector<int>>来说,push_back这个成员函数知道容器的类型是vector<int>,那么理所应当的push_back({1, 2})成立,而emplace_back中存在推导,但是{1, 2}本身什么也不是,那么自然推导也就不成立了

以下是异想天开环节,我试图将push_back整合为一个函数。但这都是错误的实现,不具备参考学习意义

template<typename T>
inline std::enable_if_t<std::is_same_v<std::decay_t<T>, _Ty>, void> push_back(T&& _Val) {
    emplace_back(std::forward<_Ty>(_Val));
}
template<typename T>
struct vec
{
    template<std::convertible_to<T> U = T>
    inline void push_back(T&& data) {
        emplace_back(std::forward<T>(data));
    }
};

List相关问题

什么时候需要使用std::list

  • 当我们需要频繁的向头部插入或删除元素的时候
  • 且我们不需要容器支持随机访问

list的结构是怎么样的

list是一个双向循环链表

为什么list要使用循环链表

对于常规的数据结构来说,循环链表的优点是:不论从哪个节点切入,都能成功遍历整个链表,而对于STL,个人见解:为了节省空间。

以下是我在IDE中按F12扒来的源码,VS2019

// list standard header
_NODISCARD iterator begin() noexcept {
    return iterator(_Mypair._Myval2._Myhead->_Next, _STD addressof(_Mypair._Myval2));
}
_NODISCARD iterator end() noexcept {
    return iterator(_Mypair._Myval2._Myhead, _STD addressof(_Mypair._Myval2));
}

使用循环链表,不需要在类中存储额外的指针指向头和尾。STL将_Myhead指向“虚无”的尾节点,而获取头节点的操作则直接对Headnext即可

list的初始头节点指向哪里,和end指向同一块空间吗

因为beginend操作都会构造一个iterator(其实这里的iterator套了using之后的一种类型,暂且不深究)出来,从测试结果来看两者确实是相等的。

但是对于刚初始化时的_Myhead来说,它的next指向的是哪里呢?应该不是一个空类型,如果是的话,因为end的结果和begin相等,也就是说_Myhead也是个空类型,那么这个空类型是怎么获取到next的呢?本人暂时没有更深究list的源码,对这个问题并不清楚。

GCC2.9和GCC4.9中list的大小有什么区别,当前呢

从上面贴的list结构图里可以看出,GCC2.9中存的是一个指针,而指针指向的对象里头存放的是两个void类型的指针,一个是next一个是pre。不论是什么类型指针的大小都是4个字节。所以GCC2.9中list的大小是4

而GCC4.9中,通过一层一层的关系,放的其实是两个指针,所以GCC4.9中list的大小是8

而我在当前的编辑器中对list进行sizeof操作,得到的结果是12

寄!

list和forward_list的end有什么区别

listend指向的一个“虚无的”节点,也是整个循环链表的“头”,而forward_list指向的end可以看作是一个指向新建的空节点的iterator,与链表本身并无关系。forward_list是一个线形单向链表

list类似,forward_list的“头”仍然指向一个“虚无的”节点,对begin的获取则只需要取一位next即可。

GCC4.9版本中对forward_listend指针的构造是直接传递0,而现VS2019使用MSVC版本则是传递一个nullptr,本质上是一样的

如何快速获取list的尾元素

应该不会有人直接暴力遍历一遍吧?

// 假设l为一个int的链表
cout << *(--l.end()) << endl;

既然list是循环链表,那么能通过尾巴的++操作访问到头部吗

据我目前所知,不能

list的迭代器支持++操作是否代表它是random-access-iterator

其实不是的,正如上文中说过的,random-access-iterator要求能对任意元素的访问达到O(1)的时间复杂度,std::list只是实现了对++--运算符的重载

int main()
{
    std::list<int> l{ 1, 2, 3, 4, 5, 6 };
    for (auto it = l.cbegin(); it != l.cend(); ++it)
        std::cout << *it << std::endl;
}
// 使用了无意义的int标识参数 所以重载的是后置 it++ 操作
_List_const_iterator operator++(int) noexcept {
    _List_const_iterator _Tmp = *this;
    ++*this;
    return _Tmp;
}

deque相关问题

deque的优点是什么

deque结合了std::vectorstd::list的优点

  • 他能高效的从头部删除和插入元素,时间复杂度为O(1)
  • 是随机访问迭代器

同时它作为其他adaptor类型的默认容器,如std::stackstd::queue。换句话说std::stackstd::queue的底层容器都是std::deque,它们只是在底层之上做了方法的封装

adaptor:适配器,比如lighting转type-c接口的adaptor就是负责转化功能

deque的结构是什么样的(待完成)

map,set等关联式容器相关问题

std::set和std::map容器的key有什么要求

不管是单一的还是可重复的std::set/std::map,它们的底层实现都是红黑树,所以它们都是有序的。所以该容器要求其key类型实现operator<

std::set和std::map两者的迭代器有什么区别

共同点是它们都不是随机访问迭代器

std::setstd::multiset的对迭代器进行解引用,得到的是元素的引用

std::map, std::multimap对迭代器进行解引用,得到的是std::pair<key_type, value_type>的引用。std::pair比较时是字典序的

using Date = std::pair<int, std::pair<int, int>>;

// 可以实现一个有序的时期集合 升序
std::set<Date> dates;
dates.emplace(std::make_pair(1998, std::make_pair(12, 2)));

关联式容器的迭代器类型是什么

std::map为例,它的迭代器解引用后的类型为

std::map<std::string, int> m;
// const volatile std::pair<const std::string, int>
*m.begin();

因此修改关联式容器中的键值对或key都是非法行为

API时间复杂度

insert/erase/count/find/lower_bound/upper_bound操作的时间复杂度都是O(log n)

insert操作返回的是什么

对于唯一元素容器来说(非multi,例如std::mapstd::unordered_set),insert操作会返回std::pair<iterator, bool>

std::map<int, std::string> myMap;
std::map<int, std::string>::iterator iterator;
bool myBool;

auto returnPair = myMap.insert({ 1, "10" });
std::cout << std::boolalpha << returnPair.second << std::ends;
std::tie(iterator, myBool) = myMap.insert(std::make_pair(1, "100"));	// 使用tie解包pair
std::cout << std::boolalpha << myBool << std::ends;
std::tie(std::ignore, myBool) = myMap.insert(std::make_pair(2, "100"));	// 使用占位符
std::cout << std::boolalpha << myBool << std::ends;
// C++ 17
auto [newIterator, newBool] = myMap.insert(std::make_pair(3, "300"));
std::cout << std::boolalpha << newBool << std::endl;

输出结果为true false true true

注意,对于唯一容器而言,插入失败并不会返回{end(), false},而是会返回{oldIt, false}oldIt是操作的key的迭代器

std::map<int, int> m;
auto it1 = m.insert({1, 1});
auto it2 = m.insert({1, 2});
// 成立
if (it1.first == it2.first)
    std::cout << "equal" << std::endl;

对于multi容器而言,insert操作会返回插入的新元素的迭代器

题外话

std::tie一般是和std::tuple搭配使用的,但是它也兼容std::pairstd::pair具有firstsecond能用于访问第一和第二元素,而std::tuple没有。

int myInt;
std::string myStr;
bool myBool;
// 通过上面三个变量访问tuple的数据 较为麻烦
std::tie(myInt, myStr, myBool) = std::make_tuple(1, "123", true);
// C++17
auto [newInt, newStr, newBool] = std::make_tuple(2, "456", false);

unorded_multimap和multimap怎么遍历他们中重复的元素

unorded_multimap

std::unordered_multimap<std::string, int> peopleMap = {{"Mike", 10}, {"Mike", 20}, {"Jelly", 18}, {"Mike", 15}, {"Zed", 23}};
using umpIterator = std::unordered_multimap<std::string, int>::iterator;
std::pair<umpIterator , umpIterator> range = peopleMap.equal_range("Mike");
for (auto it = range.first; it != range.second; ++it)
    std::cout << it->first << std::ends << it->second << std::endl;

multimap

std::multimap<std::string, int> peopleMap = {{"Mike", 10}, {"Mike", 20}, {"Jelly", 18}, {"Mike", 15}, {"Zed", 23}};
std::iterator_traits<decltype(peopleMap.begin())>::value_type::first_type name = "Mike";
for (auto it = peopleMap.lower_bound(name); it != peopleMap.upper_bound(name); ++it)
    std::cout << it->first << std::ends << it->second << std::endl;

关联容器都含有成员函数lower_boundupper_bound

一句话系列

std::vector使用建议

  • 当容器的size不大时,经过测试,即使有大量的插入操作,仍然建议使用std::vector,因为相比std::list而言它不需要内存的分配
  • 如果想使用固定大小的数组,可以使用std::array
  • std::vector<bool>的实现是经过特化的,所以它的性能非常的好,可以当作bitmap来使用
  • STL中的容易已经实现了很好的移动构造以及移动赋值,所以可以放心的以值返回,不必担心有多余的拷贝操作
posted @ 2021-09-26 14:39  _FeiFei  阅读(435)  评论(2编辑  收藏  举报