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++ 的了……

END

posted @ 2026-10-30 00:30  WBluuue  阅读(25)  评论(0)    收藏  举报