STL 标准库和泛型编程
前言
C++ 的这个模板有点过于高深了……
bind
一上来的这个就够硬,把之前所有东西都串起来了……
int arr[6]={27,102,40,3,586,72};
std::vector<int,std::allocator<int>>vec(arr,arr+6);
std::cout<<std::less<int>()(3,4)<<std::endl;
std::cout<<std::less<int>()(3,3)<<std::endl;
std::cout<<std::less<int>()(3,2)<<std::endl;
using namespace std::placeholders;
std::cout<<std::count_if(vec.begin(),vec.end(),
std::bind(std::less<int>(),_1,40))<<std::endl;
先说一个很喵的东西,std::bind 这个适配器 Adapter 可以传入一个函数,然后通过 _1 等字符占位,将其组合成一个新的函数。
举个例子,在这里,std::less 是一个可以比较两数大小的仿函数 Functor。但如果我想要让数组中的每一个元素都和 40 比较,此时就没法将 std::less<int>()(40) 这样传入,因为只有一个参数。那么此时就可以这样组合出一个新函数,效果等同于 std::less<int>()(x,40),可以理解为固定了给定函数的若干参数所构造出的新函数。
当然,直接 lambda 也可以。
template<int N>
struct Placeholder{};
inline constexpr Placeholder<1> _1{};
inline constexpr Placeholder<2> _2{};
template<class T>
struct IsPlaceholder:std::integral_constant<int,0>
{};
template<int N>
struct IsPlaceholder<Placeholder<N>>:std::integral_constant<int,N>
{};
对于 std::bind 的底层实现思路,首先,由于传入函数的参数个数不同,所以就需要 Variadic Template 来实现对参数打包。那么在一个一个拆开参数时,需要判断当前参数时像 _1、_2 这样的占位符,还是真实的参数。那么就考虑设计一个判断器 IsPlaceholder,用来判断是否是占位符。
那么就还需要定义占位符 _1 为 Placeholder<1>,将常量 N 包装为模板参数,类似之前 std::get<i>() 的实现。之后,还是考虑通过 std::integral_constant<int,v> 来将编译期常量 v 包装进类型,其内部核心就是一个 static constexpr T value=v。
之后,对于 Placeholder 的判断,考虑通过模板的偏特化实现。那么对于非占位符的模板,继承的就是 std::integral_constant<int,0>,然后特化模板参数为 Placeholder<N> 时继承的是 std::integral_constant<int,N>,这样就实现了判断类型并回答是第几个占位符。
template<class F,class... Args>
auto Bind(F&& f,Args&&... args)
{
return MyBind<
std::decay_t<F>,
std::decay_t<Args>...
>(std::forward<F>(f),std::forward<Args>(args)...);
}
这里外部的仿函数 Bind 使用 forwarding reference 接收参数,然后通过 std::forward 保留原始值类别,然后由 MyBind 的成员按值保存。
template<class F,class... BoundArgs>
class MyBind
{
private:
F func_;
std::tuple<BoundArgs...>bound_args_;
public:
template<class Fn,class... Args>
MyBind(Fn&& f,Args&&... args)
:func_(std::forward<Fn>(f)),bound_args_(std::forward<Args>(args)...){}
template<class... CallArgs>
decltype(auto) operator()(CallArgs&&... call_args)
{
auto call_tuple=
std::forward_as_tuple(
std::forward<CallArgs>(call_args)...
);
return call_impl(
std::move(call_tuple),
std::index_sequence_for<BoundArgs...>{}
);
}
private:
template<class Tuple,std::size_t... I>
decltype(auto) call_impl(Tuple&& call_tuple,std::index_sequence<I...>)
{
return func_(
resolve(std::get<I>(bound_args_),
std::forward<Tuple>(call_tuple))...
);
}
};
对于构造函数,由于外部已经写了 MyBind<F,Args...>,所以不能单纯通过 F&& 来接收参数。因为这样就是一个单纯的右值引用,而不是转发引用 forwarding reference,不能完成推导的功能!!所以此时需要通过让构造函数自己再成为模板,再自己推导类型实现,注意函数模板参数和类模板参数别写错了!!之后就还是老生常谈的话题,对于外部传进来的东西,要使用 forward 保持原样而不是 move 直接当成右值,不然就有可能把用户的资源搬空!!
在调用 g(3,7) 时就去到 operator()(CallArgs&&... call_args) 里,此时考虑将这个参数包转成一个 tuple,这样就方便之后通过 std::get<i>() 拿里面的内容。又因为要尽可能保留参数的左右值信息,所以考虑通过 std::forward_as_tuple 将其打包成 tuple 然后传递。
然后对于 call_impl(),还是由于 std::get<i>() 编译期下标的问题,所以就需要通过 std::index_sequence_for 对这种编译期索引结构生成一组编译期下标。然后每次就可以通过 I... 配合拆包,每次都对绑定参数调用一次 resolve() 获取绑定或者调用参数。注意这里由于 Bound_args_ 是一直绑定的左值数据,所以 std::get<I> 时返回的是左值引用,所以之后才不会被搬走从而发生问题。
注意这里返回 auto 和返回 decltype(auto) 的区别,前者通常不保留引用,但后者会。
template<class BoundArg,class Tuple>
decltype(auto) resolve(BoundArg&& arg,Tuple&& call_args)
{
using T=std::decay_t<BoundArg>;
constexpr int N=IsPlaceholder<T>::value;
if constexpr (N!=0)
{
return std::get<N-1>(std::forward<Tuple>(call_args));
}
else
{
return std::forward<BoundArg>(arg);
}
}
之后对于 resolve() 就简单了,就是带着调用参数和绑定参数。如果是占位符就通过 value 拿到其占位的位置,然后去调用参数里拿;否则就直接返回绑定参数。这里需要注意,在之前的 call_impl() 里,因为 bound_args_ 是左值,所以 std::get<I>(bound_args_) 拿出来的是就是一个左值引用。此时就会和 IsPlaceholder<Placeholder<N>> 的偏特化不匹配,所以需要这个 std::decay_t 将其处理为按值传参后的普通类型。
分配器 Allocator
int* p=std::allocator<int>().allocate(512);
std::allocator<int>().deallocate(p,512);
std::allocator 没什么特别的,底层就是调用的 operator new 和 operator delete。但唯一的问题是,释放的时候需要写出要释放的大小,这个就很牢了,因为没有人能在写代码的过程中记住每次申请了多少。所以这个一般都是容器在用,碳基生物几乎不会去用它()
二级空间配置器 Second-Level Allocator
这里说一个题外话,由于 cookie 和 padding 的存在,申请的内存越小这些不负责存储的内存占比就越大。对于频繁申请小块内存的场景,通用分配器可能产生较大的分配管理开销,同时频繁调用底层分配机制也可能影响性能。
为了解决这个问题,二级空间配置器考虑 不要每次都申请小块内存,而是提前准备一些内存,按大小分类管理,需要的时候直接取用即可。
具体实现方法是,考虑按照 \(8\) 字节对齐的大小类别维护一个链表 free list。对于其中的 \(0\sim 15\) 号节点,每个节点再维护一个链表,上面挂着当前还没分配出去的内存,其中第 \(i\) 号节点挂着大小都是 \(8\times (i+1)\) 的内存块。
当申请 \(x\) 大小的空间时,若 \(x\) 超过了最大块 \(128\) 字节就走普通分配器。否则就从 \(\left\lfloor \frac{x-1}{8} \right\rfloor\) 号节点内拿一块整大小的内存,返回指向这块内存的指针即可。而当释放内存时,此时也不需要将其还给系统分配器,而是将其插回对应号节点内即可。
更喵的是,对于每个节点内的链表,由于只有其空闲的时候才需要维持这个结构。那么就可以直接用这块内存来维护其 next 指针,用到的时候直接用这块内存,释放的时候再让这块内存重新承担 next 指针的功能即可。
而当 free list 上某个节点里面没内存了,此时如果只申请一个那还是没有改变 cookie 的问题。所以考虑一次性取一大块内存,然后在这一大块的基础上再进行切割。然后再将第一块返回用户,这样就可以摊薄底层内存分配的管理开销。
这个结构的缺点也很明显,就是如果我一直申请 17 大小的内存,分配器会一直分配 24 大小的内存,从而造成内存碎片。而且多次申请之后会出现一个块不够了,但其他块很充足的情况。甚至在长期运行的程序里,由于是拿空间换时间,所以程序释放内存时并没有把内存还给操作系统,所以会导致很多内存一直空闲。此外,多线程时同步时锁或者并发,访问时 cache 缓存也都会造成一定问题。而且虽然平均速度很快,但申请一大块时造成的尾延迟还可能无法接受。
链表 list
list 就是一个双向链表,但需要注意其底层是一个环状的双向链表。而为了实现容器前闭后开的特点,所以最后一个节点不会直接指向第一个节点,而是设置一个虚节点作为 end()。
std::list<int>lst{1, 2, 3};
auto iter=std::next(lst.begin());
lst.push_back(4);
lst.push_front(0);
lst.insert(iter, 100);
额外需要注意的一点是,迭代器是具有稳定性的。不管前后怎么加入删除,也不管怎么在中间插入,当前迭代器 iter 指向的节点永远不会发生变化。只有当 lst.erase(iter) 时才会发生变化,此时 lst.erase() 返回的是下一个元素的迭代器。
迭代器 Iterator
迭代器本身就是一个智能指针,在设计时提供指针的常规解引用操作,额外提供前后置自增自减的操作符重载即可。
重点是,由于算法要通过迭代器去对容器进行访问或操作,所以往往需要知道很多关于迭代器或迭代器所指元素的特征,比如能否随机访问或回退等,所以就需要 Iterator Traits 这个东西。
在这里,迭代器必须要提供五种特征,分别是 iterator_category 表示该迭代器能否随机访问等、difference_type 表示两个迭代器之间距离或位移的有符号整数类型、value_type 表示该迭代器指向元素的类型、reference 表示指向元素的引用和 pointer 表示指向元素的指针。这五个就是迭代器的 associated types,用以回答算法的 “ 提问 ”。
当然,C++20 还引入了 contiguous_iterator、random_access_iterator 等表示迭代器类型,比如可以通过 template<std::contiguous_iterator It> 来限制接收的迭代器,以此来保证内存布局,便于与底层连续缓冲区接口配合。
template<class I>
struct iterator_traits{
using value_type=typename I::value_type;
};
template<class T>
struct iterator_traits<T*>{
using value_type=T;
};
template<class T>
struct iterator_traits<const T*>{
using value_type=T;
};
template<typename I,...>
void algorithm(...){
using T=typename iterator_traits<I>::value_type;
}
现在对于算法,如果传进来的是一个 class iterator,此时就可以直接拿到这个迭代器的这五个特征。而如果传进来的是普通的指针,此时就可以通过 iterator_traits<_Iter> 这个中间层,对普通指针和 class iterator 分别处理。当然,底层的实现方法肯定还是模板的偏特化。
当然,对于这种 “ 萃取 ” 的行为,也有 type traits、allocator traits、pointer traits 等。
智能指针
来都来了,简单学一下这个()
unique_ptr
A* p=new A();
...
delete p;
在传统的指针写法中,如果这样正常运行那还好说。但如果中间抛出了一场或者提前返回,此时就会导致内存泄漏。在现代 C++ 中,更推荐利用 RAII 资源获取即初始化 Resource Acquisition Is Initialization,让一个对象来管理资源,析构时自动释放资源。
#include<memory>
void func()
{
auto p=std::make_unique<A>();
...
}
这样在离开作用域时,自动执行 p.~unique_ptr() 自动释放其持有的对象。而正因为如此,就像其名字一样,同一个对象不能同时由两个 unique_ptr 拥有,因为析构时会都进行释放,从而导致错误。也正是因为这个,所以 unique_ptr 不允许拷贝构造和拷贝赋值,只支持移动构造和移动赋值。
接口方面,p.get() 是获取原始指针但不交出所有权,p.release() 是交出所有权并返回指针,p.reset() 就是直接释放该对象。需要注意 p.get() 获取指针就不能通过这个指针 delete 了,不然也会发生错误。
FILE* file=std::fopen("data.txt","r");
std::fclose(file);
using FilePtr=std::unique_ptr<std::FILE,decltype(&std::fclose)>;
FilePtr file(std::fopen("data.txt", "r"),&std::fclose);
这里,由于 file 不是 new 出来的,所以不能通过 delete 进行释放。对于这种情况,如果还想要通过 RAII 管理对象,智能指针 std::unique_ptr<T, Deleter> 可以支持自定义删除器。
shared_ptr
auto p1=std::make_shared<int>(100);
std::cout<<p1.use_count()<<std::endl;
{
auto p2=p1;
std::cout<<p1.use_count()<<std::endl;
std::cout<<p2.use_count()<<std::endl;
}
std::cout<<p1.use_count()<<std::endl;
就像之前博客里手动实现的那个智能指针一样,shared_ptr 可以允许多个智能指针共同拥有同一个对象,并通过引用计数来决定对象什么时候销毁。此时拷贝就只会增加该对象的引用计数,移动时还是直接转移,不增加引用计数。
也正因为如此,虽然管理引用计数的控制块 control block 是线程安全的,但不代表被管理对象本身也是安全的。而当多个 CPU 核心修改同一个控制块计数时,还可能带来 cache line 争用。所以如果仅仅是访问对象,不需要共享拥有,那莫不如直接传 const A& a 进去。
此外,还需要注意的是,既然有了这个共享功能 auto p1=std::make_shared<int>(100);,就别多此一举再 std::shared_ptr<int>p2(p1.get()),这样可能会建立两个互不知道对方的控制块,造成重复删除。
weak_ptr
struct B;
struct A
{
std::shared_ptr<B>b;
~A()
{
std::cout<<"~A"<<std::endl;
}
};
struct B
{
std::shared_ptr<A>a;
~B()
{
std::cout<<"~B"<<std::endl;
}
};
auto pa=std::make_shared<A>();
auto pb=std::make_shared<B>();
pa->b=pb;
pb->a=pa;
虽然 shared_ptr 可以更好地让多个对象共享拥有权,但当两个对象互相持有对方的 shared_ptr 时,此时虽然局部变量 pa 和 pb 可以被正常析构,但由于两者互相指向,所以会一直相互扶持从而一直存活下去。
struct B
{
std::weak_ptr<A>a;
~B()
{
std::cout<<"~B"<<std::endl;
}
};
为了解决这个问题,weak_ptr 可以观察由 shared_ptr 管理的对象,但不会增加其引用计数,也不会阻止这个对象析构。通过 weak_ptr 就可以用来既维护对象之间的关联关系,又不负责管理对方的生命周期(好渣)。那么就可以选择将其中一个成员改为 weak_ptr,此时 A 的引用计数就是 1 了,在析构时就会先析构 A 再析构 B。
auto owner=std::make_shared<int>(100);
std::weak_ptr<int>observer=owner;
if(auto p=observer.lock())
{
std::cout<<*p<<std::endl;
}
owner.reset();
if(auto p=observer.lock())
{
std::cout<<*p<<std::endl;
}
else
{
std::cout<<"Object died."<<std::endl;
}
既然 weak_ptr 不拥有对象,所以此时就需要一种方法来判断其指向的对象是否存活。这里,如果对象还活着,那么 lock() 会返回一个 std::shared_ptr<T> 用以暂时维持对象存活;否则就会返回空的 shared_ptr。
对于控制块,当最后一个 shared_ptr 释放时,此时被管理对象会执行析构操作。而当最后一个弱引用也消失,且没有强引用时,该控制块才会释放。但如果对象由 std::make_shared 创建,此时控制块和该对象处于同一块内存中,即使对象释放了也可能会保留这一整块内存,从而造成内存滞留。
vector
打过算竞的都知道,vector 是有 size 和 capacity 分别表示当前元素个数和容量大小。当容量不够的时候,此时 capacity 就会按一定倍数增长,然后将之前的元素移动到新的这块内存里。
需要注意的是,reserve 只预先分配内存但不改变 size 大小;resize 改变 size 并销毁多出来的元素但缩小时不会改变 capacity;assign 会替换所有元素;clear 会使 size 归零并销毁所有元素但不改变 capacity。而不管是哪个 api,只要触发了 vector 的扩容机制或对象被销毁了,原来的指针和引用就全失效了,因为元素已经被移动到了新的内存里或死亡了。
array
int a[100];
//int[100] b;
using T=int[100];
T c;
虽然 int[100] b 是不允许的,但可以通过类型别名实现。事实上,array 内部的中间层 array_traits 就是这么干的,通过多包装一层从而可以更好地处理 array<int,0> 的情况,因为是不允许声明 T a[0] 的。
template<class T,std::size_t N>
class array{
...
};
此外,这里的 std::size_t N 是非类型模板参数,允许将编译期常量作为模板实参。
deque
std::deque 的内部不是连续存储而是分段存储的,其内部也维护若干个指针的 “ 桶 “ 指向一段连续的内存 buffer。当 push_back 不够的时候就会在后面新开一个桶和一段内存,push_front 不够的时候就在前面新开一个桶和一段内存。当桶不够的时候,其也会像 vector 一样进行扩容,此时就只需要拷贝指针即可。
对于 deque 的迭代器,其先是维护一个指向当前 “ 桶 ” 的指针,再维护两个分别指向当前 buffer 的开头和结尾,然后再维护一个指向当前元素的指针。这样左右移动时就可以实现 buffer 内跳跃,走到边界时也可以跨 buffer 的跳跃了。
对于迭代器的相减和跳跃,打过 a 的应该对这种计算没什么问题,就可以看作分块中计算中间整块大小以及左右散块的长度。对于一次的前进或后退,只需要判断是否到达当前 buffer 的边界即可。而对于前进或后退指定长度,就需要判断是否跨越缓冲区了。注意对于不管真实上还是形式上的连续空间容器,还需要提供 operator[] 操作符。(我说工程代码就是一个巨大的模拟!)
有了随意头尾进出的 deque,那么 stack 和 queue 就都可以基于这个容器实现了。
hashtable
哈希表的原理很简单,就是维护一个 vector 作为 “ 桶 “,每个元素都是一个指向链表的指针。对于计算出的哈希值,就往对桶大小取模后的那个桶里放。当总的元素个数超过一定阈值后,就会对桶进行扩容,然后将所有元素打散重新分配。
对于哈希表的迭代器,也是维护一个指向链表节点的指针,再维护一个指向当前 hashtable 的指针,这样就可以在桶之间移动了。
需要注意的是,虽然哈希表均摊复杂度 \(O(1)\),但当发生大量哈希冲突时,此时查找一个元素就需要去那个桶里遍历,从而导致最坏复杂度 \(O(n)\)。此时可以选择通过控制元素个数除以桶的个数,即负载因子 mp.load_factor(),也就是平均每个桶里有多少元素来判断哈希表的平均装载程度。也可通过手动设置 mp.max_load_factor(0.7) 来控制允许的最大负载因子。除此之外,桶中最长链的长度、存有多个元素的桶的个数等都是判断哈希分布的指标。
当插入一个元素后超过了设置的负载因子,此时就会触发 rehash 将所有元素打散重新分配,这就会带来不小的开销。对于这个问题,可以通过 mp.reserve(1000) 来预留在不超过当前负载因子的条件下,装 1000 个元素需要的桶的个数。还可以通过 mp.rehash(1000) 来直接开 1000 个桶,但往往还需要考虑负载因子的约束。需要注意 rehash 后之前的 iterator 就失效了,但指向元素的指针或引用还是正常的。
虽然均摊 \(O(1)\),但毕竟底层不是 vector 那样的连续存储结构,那么就会有 cache miss 的问题。所以实际中还是需要通过性能测试来观察实际效果,然后再考虑是否需要更换容器。
Concepts
对于之前说的 Iterator Category,传统 C++ 是通过模板特化和 Iterator Traits 获取类型信息,然后再内部根据传入迭代器的类型选择不同的算法。对于要求特定模板参数的算法,只能通过模板参数命名等 “ 暗示 ” 来告诉使用者只能接受特定参数。但由于使用中仍然会导致多层模板的报错信息,所以现代 C++20 中引入了 Concepts 来解决这个问题。
#include<concepts>
#include<type_traits>
template<class T>
concept Integer=std::is_integral_v<T>;
Concept 可以理解为是一个有名字的编译期布尔表达式,表示对类型的一种 “ 要求 ”。这样,就可以声明一个名为 Integer 的 Concept,用来约束必须是整数。
template<Integer T>
T twice(T x)
{
return x*2;
}
template<class T>
requires Integer<T>
T twice(T x)
{
return x*2;
}
auto twice(Integer auto x)
{
return x*2;
}
之后,可以直接通过约束模板参数,也可以通过 requires 子句直接检查表达式是否为真,也可以通过受约束的 auto 来实现。
template<class T>
concept Addable=requires(T a,T b)
{
a+b;
a+=b;
};
template<Addable T>
auto add(const T& a,const T& b)
{
return a+b;
}
除此之外,requires 还可以直接要求表达式,用法就很像 lambda。比如若想约束一个类型必须要重载 operator+,就可以通过 requires(T a,T b) 声明两个用于检查的假象参数,注意这里不会创建对象和执行运算,只是编译器会检查 T 是否满足这个约束。
template<class T>
requires requires(T a, T b){
a+b;
}
T add(T a, T b)
{
return a+b;
}
当然,这里的第一个 requires 是约束子句,表示要满足之后的条件;第二个 requires 是要求表达式,可以理解为临时声明一条约束。
以上用法属于 requires 提供的 Simple Requirement,用于检查表达式是否合法。除此之外,还有 Type Requirement 用来检查某个类型是否存在;Compound Requirement 用来检查表达式是否合法、返回类型、是否 noexcept 等;Nested Requirement 用来检查额外的编译期条件。
template<class T>
concept HasValueType=requires{
typename T::value_type;
};
static_assert(
HasValueType<std::vector<int>>
);
这里就是 Type Requirement 用来检查模板参数 T 中是否包含一个名为 value_type 的成员类型。
template<class T>
concept Addable=requires(T a, T b){
{ a+b } -> std::same_as<T>;
};
template<class T>
concept Incrementable=requires(T a){
{ ++a } -> std::same_as<T&>;
};
template<class T>
concept NoThrowSwappable=requires(T a, T b){
{ a.swap(b) } noexcept -> std::same_as<void>;
};
Compound Requirement 不仅可以检查是否支持 operator+,还可以用来检查 a+b 的结果类型是否是 T,需要注意编译器检查的表达式结果类型会保留引用信息。除此之外,还可以通过这个方法检查 a.swap(b) 是否合法、是否不会抛出异常以及是否返回值为 void。
template<class T>
concept SmallObject=requires{
requires std::integral<T>;
requires sizeof(T)<=64;
};
template<class T>
concept SmallInteger=std::integral<T>&&(sizeof(T)<=8);
Nested Requirement 允许进行嵌套检查,与直接通过 && 连接的表达式效果一样。需要注意的是,std::integral<T>; 只会检查表达式本身是否合法,也就是能否正常写这一句。而前面加上 requires 嵌套就是检查这个表达式返回的布尔值是否为 true!
当然,既然说到了 &&,也可以通过 || 连接,效果和 if 是一样的,只不过是编译时判断。
template<std::integral T>
void print(T x)
{
std::cout<<"Integral"<<std::endl;
}
template<std::signed_integral T>
void print(T x)
{
std::cout<<"Signed Integral"<<std::endl;
}
print(10)//Signed Integral
print(10u)//Integral
print(-10)//Signed Integral
当同时满足 std::integral 和 std::signed_integral 时,由于后者的约束中也包含前者,所以 print(10) 会匹配约束更严格的模板。注意若是两个独立写出的约束,编译器不会推理背后的数学含义,只会看是否复用了已有的 concept。这一点应用在 && 和 || 的表达式上更为明显,因为本质就是包含和被包含的关系。
迭代器适配器 inserter
std::vector<int>a{1,2,3};
std::vector<int>b;
std::copy(a.begin(),a.end(),b.begin());
对于 copy 函数,其就是从开头一直拷贝到结尾,也不管拷贝过去的地方有无空间。那么对于上面这种情况,就会出现错误,因为要拷贝过去的 b 是空的。即使写了 b.reserve(100) 也不能直接向其复制,因为 reserve 不增加 size,还是会报错。那么如果想要实现每复制一个元素就自动 push_back() 的话,就需要用到 Adapter。
template<...>
... copy(...)
{
while(first!=last)
{
*result=*first;
++result;++first;
}
return result;
}
这里的做法是,对于 copy 的底层逻辑,考虑去重载这个 *result 的 operator=,让其自动完成 push_back() 的操作。
template<...>
class back_insert_iterator
{
Container* container;
... operator=(const typename Container::value_type& value)
{
container->push_back(value);
return *this;
}
back_insert_iterator&
operator*()
{ return *this; }
};
template<typename Container>
inline back_insert_iterator<Container>
back_inserter(Container& x)
{
return back_insert_iterator<Container>(x);
}
std::copy(a.begin(),a.end(),std::back_inserter(b));
这里的 back_insert_iterator 就是负责重载 operator=,通过传入 b 这个容器,然后返回这个负责插入的迭代器。此时当 copy 执行 *result= 的时候,就会去执行 b 这个容器的 push_back 操作。类似的,对于 std::front_inserter 和 std::inserter 都是类似,只不过调用的是容器的 push_front 和 insert 方法。
ostream_iterator 和 istream_iterator
std::vector<int>a{1,2,3};
std::ostream_iterator<int>out_it(std::cout,", ");
std::copy(a.begin(),a.end(),out_it);
1, 2, 3,
其实没什么特别的,就是 ostream_iterator 重载了 operator= 操作。
int x,y;
std::istream_iterator<int>eos;
std::istream_iterator<int>iit(std::cin);
if(iit!=eos)
{
x=*iit;
}
++iit;
if(iit!=eos)
{
y=*iit;
}
std::cout<<x<<" "<<y<<std::endl;
对于 istream_iterator,首先这里的 eos 默认构造为输入流的结束。之后初始化 iit 时立刻读取一次输入,然后每次将读入的内容赋值给外部对象,之后执行 operator++ 接着读下一个。
std::copy(iit,eos,std::back_inserter(a));
对于初始化时就读的问题,这个还是为了和标准库的算法结合。这里由于 std::copy 时会先把 *first 赋值给 *result,所以就是要先读一次。对于终止的问题,linux 下按 Ctrl+D 即可。
type traits
和 iterator traits 类似,type traits 也是用来在编译期回答关于 type 的一些问题,从而选择不同的策略。所以,像 is_integral_v、is_pointer_v 和 is_trivially_copyable_v 等都是字面意思,即回答一个类型的特征。
std::remove_const_t<const int> // int
像这种 type traits,其返回的就不是一个布尔值了,而是产生一个新类型。实现方法也不难,就是通过模板特化解决。
//std::is_integral<int>::value
std::is_integral_v<int>
//typename std::remove_reference<int&>::type
std::remove_reference_t<int&>
稍微一提,这里的 _v 和 _t 就是对之前写法的简化,就不用手动通过 ::value 或 ::type 了。
using A=std::remove_cvref_t<int[10]>; // int[10]
using B=std::decay_t<int[10]>; // int*
对于这个 std::remove_reference_t,还存在去掉 const 和 volatile 的 type traits。而回顾之前实现 std::bind 时的 std::decay_t,其是用来模拟函数按值传参时发生的类型调整,会去掉引用、顶层的 const 和 volatile,这部分和之前的 type traits 一样。但不一样的是,其可以将数组调整为指针、将函数类型调整为函数指针。
这样做的原因就是,若传入 int x=10,其会被转发引用推导为 int&,然后再保存到 tuple 里为引用。之后如果 x 发生了修改,那么内部的值也就一起被修改了。
template<class T>
void print_type()
{
if constexpr (std::is_integral_v<T>)
{
std::cout<<"Integral"<<std::endl;
}
else
{
std::cout<<"Other"<<std::endl;
}
}
这里,type traits 还可以和之前的 if constexpr 结合,实现在编译期时进行判断。
constexpr
来都来了()
constexpr int square(int x)
{
return x*x;
}
func<square(5)>();
constexpr int a=5;
int get()
{
return 5;
}
const int b=get();
//constexpr int c=get();
当想把一个函数的计算结果作为模板参数时,由于模板参数需要在编译期就确定下来,此时就需要 constexpr 来让这个函数或变量或表达式在编译期进行求值。此时被 constexpr 修饰的变量也是不可修改的,而且必须要满足编译期计算的要求。
需要注意的是,constexpr 可以支持编译期求值,但不代表其只能在编译期求值,像普通函数一样正常调用也是可以的。而且反过来,对于 const int N=100 这种以常量表达式初始化的 const 整数,也可以用在编译期的常量表达式中,比如 std::array<int,N>a。
consteval int square(int x)
{
return x * x;
}
constexpr int a=square(5);
int b=square(5);
int n;
std::cin>>n;
//int c=square(n);
对于 consteval,其强制函数在被调用时的求值是编译期的常量表达式,主要应用于就应该在编译期完成的计算。
int get()
{
return 10;
}
int v1=get();
constinit int v2=10;
对于 v1 这种全局变量,此时初始化时就需要依赖其他函数或变量的初始化先后关系,这个就是动态初始化。而 constinit 可以强制要求这个全局变量必须通过静态初始化完成,不依赖动态初始化。而和 constexpr 不同的是,其是可以修改初始化后的变量的!而根据这个特性,也不难想到局部变量的 constinit 是不合法的,需要搭配 static constinit。
if constexpr(std::is_pointer_v<T>){}
//if constexpr(n>0)
对于 if constexpr 编译期条件分支,需要注意的就是其条件必须是编译期求值的布尔表达式。
总结
C++ 特性太多太深了,终于理解了为什么没有说自己精通 C++ 的了……

STL 标准库和泛型编程
浙公网安备 33010602011771号