STL——迭代器与traits编程技法

一、迭代器

  1. 迭代器设计思维——STL关键所在 在《Design Patterns》一书中对iterator模式定义如下:提供一种方法,使之能够依序巡访某个聚合物(容器)所含的各个元素,而又无需暴露该聚合物的内部表达方式。

STL的中心思想在于:将数据容器(containers)和算法(algorithms)分开,彼此独立设计,最后再以一贴胶着剂将它们撮合在一起。容器和算法的泛型化,从技术角度来看并不困难,C++ 的class templates 和 function templates 可分别达成目标。如何设计出两者之间的良好胶着剂,才是大难题。

  1. 迭代器(iterator)是一种smart pointer 迭代器是一种行为类似指针的对象,而指针的各种行为中最常见也最重要的便是内容提领(dereference)和成员访问(member access),因此,迭代器最重要的编程工作就是对 operator* 和 operator-> 进行重载(overloading)工作。可参考auto_ptr(源码在中)。

《STL源码剖析》P83处代码:可以看出,为了完成一个针对List 而设计的迭代器,我们无可避免地暴露了太多List实现细节:在main() 之中为了制作begin 和 end 两个迭代器,我们暴露了ListItem;在ListIter class 之中为了达成 operator++ 的目的,我们暴露了ListItem的操作函数next()。如果不是为了迭代器,ListItem原本应该完成隐藏起来不曝光的。换句话说,要设计出ListIter ,首先必须对List 的实现细节有非常丰富的了解。既然这无可避免,干脆就把迭代器的开发工作交给List 的设计者好了,如此一来,所有实现细节反而得以封装起来不被使用者看到。这正是为什么每一种STL容器都提供有专属迭代器的缘故

  1. 迭代器相应型别(associated types) 什么是相应型别?迭代器所指之物的型别便是其一。假设算法中有必要声明一个变量,以“迭代器所指对象的型别”为型别,如何是好?毕竟C++只支持sizeof(),并未支持typeof()。即便动用RTTI 性质中的typeid(),获得的也只是型别名称,不能拿来做变量声明之用。

解决方法1利用function template 的参数推导(argument deducation)机制。如下:

template <class I, class T>
void func_impl(I iter, T t)
{
    T tmp;       // 这里解决了问题。T就是迭代器所指之物的型别,本例为int
    //             .... 这里做原本func() 应该做的全部工作
};

template <class I>
inline
void func(I iter)
{
    // 函数转调用
    func_impl(iter, *iter);          // func 的工作全部移往func_impl
};
int main()
{
    int i;
    func(&i);
};

我们以func() 为对外接口,却把实际操作全部置于func_impl()之中。由于func_impl()是一个function template,一旦被调用,编译器会自动进行template参数推导。于是导出型别T,顺利解决问题。

迭代器相应型别不只是“迭代器所指对象的型别”一种而已。根据经验,最常用的相应型别有五种,然而并非任何情况下任何一种都可利用上述的template参数推导机制来取得。我们需要更全面的解法。

二、Traits 编程技法——STL源代码门钥 迭代器所指对象的型别,称为该迭代器的value type。上述的参数型别推导技巧虽然可用于value type,却非全面可用:万一value type必须用于函数的传回值,就束手无策了,毕竟函数的“template参数推导机制”推而导之的只是参数,无法推导函数的返回值型别

解决方法2声明内嵌型别似乎是个好主意,如下:

template <class T>
struct MyIter
{
    typedef T value_type;        // 内嵌型别声明(nested type)
    T* ptr;
    MyIter(T* p = 0):ptr(p) { }
    T& operator*() const { return *ptr; }
    //  ...
};

template <class T>
typename I::value_type          // 需要使用typename,这一正行是func的返回值型别
func(I ite)
{
    return *iter;
};

//....
MyIter<int> ite(new int(8));
cout << func(ite);         //输出8

注意,func() 的回返型别必须加上关键词typename,因为T是一个template参数,在它被编译器具现化之前,编译器对T一无所悉,换句话说,编译器此时并不知道MyIter::value_type代表的是一个型别或是一个member function 或是一个 data member。关键词typename的用意在于告诉编译器这是一个型别,如此才能顺利通过编译。

看起来不错。但是有个隐晦的陷阱:并不是所有迭代器都是class type。原生指针就不是!如果不是class type,就无法为它定义内嵌型别。但STL(以及整个泛型思维)绝对必须接受原生指针作为一种迭代器,所以上面这样还不够。有没有办法可以让上述的一般化概念针对特定情况(例如针对原生指针)做特殊化处理呢?

解决方法3:template partial specialization (模板偏特化)可以做到 如果class template 拥有一个以上的template参数,我们可以针对其中某个(或数个,但非全部)template参数进行特化工作。换句话说,我们可以在泛化设计中提供一个特化版本(也就是将泛化版本中的某些template参数赋予明确的指定)。

假设有一个class template 如下:

template <typename U, typename V, typename T>
class C { ... };

partial specialization的字面意义容易误导我们以为,所谓“偏特化版”一定是对template参数U或V或T(或某种组合)指定某个参数值。其实不然,《泛型思维》一书对partial specialization的定义是:“针对(任何)template参数更进一步的条件限制所设计出来的一个特化版本”。由此,面对以下这么一个class template:

template<typename T>
class C { ... };    // 这个泛化版本允许(接受)T为任何型别

我们便很容易接受它有一个形式如下的partial specialization:

template<typename T>
class C<T*> { ... };        // 这个特化版本仅适用于“T 为原生指针”的情况
                                    // “T为原生指针"便是“T为任何型别”的一个更进一步的条件限制

有了这项利器,我们便可以解决前述“内嵌型别”未能解决的问题。先前的问题是,原生指针并非class,因此无法为它们定义内嵌型别。现在,我们可以针对“迭代器之template参数为指针”者,设计特化版的迭代器。

注意,我们进入关键地带了。下面这个 class template 专门用来“萃取”迭代器的特性,而value_type 正是迭代器的特性之一:

template <class I>
struct iterator_traits          // traits 意为“特性”
{
    typedef typename I::value_type value_type;
};

这个所谓的traits,其意义是,如果I定义有自己的value_type,那么通过这个traits的作用,萃取出来的value_type 就是 I::value_type。换句话说,如果I定义有自己的value_type,先前那个func() 可以改写成这样:

template <class T>
typename iterator_traits<I>::value_type         // 这一整行是函数回返型别
func(I ite)
{
    return *ite;
};

多了这一层间接性所带来的好处是:traits可以拥有特化版本(因为只有类模板有偏特化,函数模板没有偏特化,参见本人模板系列博文)。现在,我们令iterator_traites拥有一个partial specialization如下:

template <class T>
struct iterator_traits<T*>       // 偏特化版——迭代器是个原生指针
{
    typedef T value_type;
};

于是,原生指针int* 虽然不是一种class type,亦可通过traits 取其value type。这就解决了先前的问题。

但是请注意,针对“指向常数对象的指针”,下面这个式子得到什么结果:

iterator_traits<const int*>::value_type

获得的是const int 而非 int 。这可能不是我们所期望的。我们希望利用这种机制来声明一个临时变量,使其型别于迭代器的value type相同,而现在,声明一个无法赋值(因const 之故)的临时变量,没什么用!因此,如果迭代器是个pointer-to-const,我们应该设法另其value type 为一个non-const 型别。没问题,只要另外设计一个特化版本,就能解决这个问题:

template <class T>
struct iterator_traits<const T*>   // 偏特化版——当迭代器是个pointer-to-const时,
{                                                // 萃取出来的型别应该是T而非 const T
    typedef T value_type;
};

现在,不论面对的是迭代器MyIter,或是原生指针int 或 const int,都可以通过traits 取出正确的(我们所期望的)value type。

根据经验,最常用到的迭代器相应型别有五种:value type, difference type, pointer, reference, iterator category。如果你希望你所开发的容器能与STL水乳交融,一定要为你的容器的迭代器定义这五种相应型别。“特性萃取机”traits 会很忠实地将原汁原味榨取出来。

template <class T>
struct iterator_traits
{
    typedef typename I::iterator_category iterator_category;
    typedef typename I::value_type value_type;
    typedef typename I::difference_type difference_type;
    typedef typename I::pointer pointer;
    typedef typename I::reference reference;
};

iterator_traits必须针对传入之型别为pointer 及 pointer-to-const者,设计特化版本

  1. 迭代器相应型别之一——value type

如上所述。

  1. 迭代器相应型别之二——difference type

difference type用来表示两个迭代器之间的距离,因此它也可以用来表示一个容器的最大容量,因为对于连续空间的容器而言,头尾之间的距离就是其最大容量。

  1. 迭代器相应型别之三——reference type

从“迭代器所指之物的内容是否允许改变”的角度视之,迭代器分为两种:不允许改变“所指对象之内容”者,称为constant iterators,例如 const int pic; 允许改变“所指对象之内容”者,称为mutable iterators,例如 int pi。当我们对一个mutable iterators进行提领操作时,获得的不应该是一个右值,应该是一个左值,因为右值不允许赋值操作,左值才允许

在C++中,函数如果要传回左值,都是以by reference 的方式进行,所以当p是个mutable iterators 时,如果其value type 是T,那么p 的型别不应该是T,应该是T&。将此道理扩充,如果p是一个constant iterators,其value type 是T,那么p的型别不应该是const T,而应该是const T&。这里所讨论的*p的型别,即所谓的reference type。(其中一个例子便是"++运算子"的实现,参见C++易混淆知识点整理

  1. 迭代器相应型别之四——pointer type

pointers 和 references 在C++ 中有非常密切的关联。如果“传回一个左值,令它代表p所指之物”是可能的,那么“传回一个左值,令它代码p所指之物的地址”也一定可以。也就是说,我们能够传回一个pointer,指向迭代器所指之物。

  1. 迭代器相应型别之五——iterator_category

在讨论iterator_category之前,我们先要说一下迭代器的分类。根据移动特性与施行操作,迭代器被分为五类:

(1)Input Iterator:只读

(2)Output Iterator:只写

(3)Forward Iterator:允许“写入型”算法(例如replace())在此种迭代器所形成的区间上进行读写操作。

(4)Bidirectional Iterator:可双向移动。

(5)Random Access Iterator:前四种迭代器都只供应一部分指针算术能力(前三种支持 operator++,第四种再加上 operator--),第五种则涵盖所有指针算术能力,包括p+n, p-n, p[n], p1-p2, p1<p2。

下图可以表示这些迭代器的分类和从属关系。直线和箭头代表的并非C++的继承关系,而是所谓concept(概念)与refinement(强化)的关系。

STL——迭代器与traits编程技法

设计算法时,如果可能,我们尽量针对图3-2中的某种迭代器提供一个明确定义,并针对更强化的某种迭代器提供另一种定义,这样才能在不同情况下提供最大效率。在研究STL的过程中,效率是个重要课题。假设有个算法可接受Rorward Iterator,你以Random Access Iterator 喂给它,它当然也会接受,因为一个Random Access Iterator必然是一个Forward Iterator(见图3-2)。但是可用并不代表最佳!

以advanced()为例

这是许多算法内部常用的一个函数。该函数有两个参数,迭代器p和数值n;函数内部将p累进n次(前进n距离)。下面有三份定义,一份针对Input iterator,一份针对Bidirectional Iterator,另一份针对Random Access Iterator。倒是没有针对Forward Iterator而设计的版本,因为那和针对Input Iterator 而设计的版本完全一致。

template <class InputIterator, class Distance>
void advance_II(InputIterator& i, Distance n)
{
    // 单向,逐一前进
    while(n--) ++i;         // 或写成for (; n > 0; --n, ++i);
};

template <class BidirectionalIterator, class Distance>
void advance_BI(BidirectionalIterator& i, Distance n)
{
    // 双向,逐一前进
    if (n>=0)
        while(n--) ++i;
    else
        while(n++) --i; 
};

template <class RandomAccessIterator, class Distance>
void advance_RAI(RandomAccessIterator& i, Distance n)
{
    // 双向,跳跃前进
    i += n;
};

现在,当程序调用advance() 时,应该选用(调用)哪一份函数定义呢?如果选择advance_II(),对Random Access Iterator而言极度缺乏效率,原本O(1)的操作竟成为O(n)。如果选择advance_RAI(),则它无法接受 Input Iterator。我们需要将三者合一,如下:

template <class InputIterator, class Distance>
void advance(InputIterator& i, Distance n)
{
    if (is_random_access_iterator(i))
        advance_RAI(i, n);
    else if (is_bidirectional_iterator(i))
        advance_BI(i, n);
    else
        advance_II(i, n);
}

但是像这样在执行时期才决定使用哪一个版本,会影响程序效率。最好能够在编译期就选择正确的版本重载函数机制可以达成这个目标。

前述三个advance_x() 都有两个函数参数,型别都未定(因为都是template 参数)。为了令其同名,形成重载函数,我们必须加上一个型别已确定的函数参数,使函数重载机制得以有效运作起来。

设计考虑如下:如果traits 有能力萃取出迭代器的种类,我们便可利用这个“迭代器类型”相应型别作为advanced() 的第三参数。这个相应型别一定必须是一个class type,不能只是数值号码类的东西,因为编译器需仰赖它(一个型别)来进行重载决议(overloaded resolution)。下面定义五个classes,代表五种迭代器类型:

// 五个作为标记用的型别(tag types)
struct input_iterator_tag { };
struct output_iterator_tag { };
struct forward_iterator_tag : public input_iterator_tag { };
struct bidirectional_iterator_tag : public forward_iterator_tag { };
struct random_access_iterator_tag : public bidirectional_iterator_tag { };

这些classes 只作为标记用,所以不需要任何成员。至于为什么运用继承机制,稍后再解释。现在重新设计 __advance() (由于只在内部使用,所以函数名称加上特定的前导符),并加上第三参数,使它们形成重载:

template <class InputIterator, class Distance>
inline void __advance(InputIterator& i, Distance n,
                                forward_iterator_tag)
{
    // 单向,逐一前进
    while(n--) ++i;
};

template <class ForwardIterator, class Distance>
inline void __advance(ForwardIterator& i, Distance n,
                                forward_iterator_tag)
{
    // 单纯地进行传递调用
    __advance(i, n, input_iterator_tag());
};

template <class BidirectionalIterator, class Distance>
inline void __advance(BidirectionalIterator& i, Distance n,
                                bidirectional_iterator_tag)
{
    // 双向,逐一前进
    if (n >= 0)
        while(n--) ++i;
    else
        while(n++) --i;
};

template <class RandomAccessIterator, class Distance>
inline void __advance(RandomAccessIterator& i, Distance n,
                                random_access_iterator_tag)
{
    // 双向,跳跃前进
    i += n;
};

注意上述语法,每个__advance() 的最后一个参数都只声明型别,并未指定参数名称,因为它纯粹只是用来激活重载机制,函数之中根本不适用该参数。如果硬要加上参数名称也可以,画蛇添足罢了。

行进至此,还需要一个对外开发的上层控制接口,调用上述各个重载的__advance() 。这一上层接口只需两个参数,当它准备将工作转给上述的__advance()时,才自行加上第三参数:迭代器类型。因此,这个上层函数必须有能力从它所获得的迭代器中推导出其类型——这份工作自然是交给traits机制:

template <class InputIterator, class Distance>
inline void advance(InputIterator& i, Distance n)
{
    __advance(i, n,
                    iterator_traits<InputIterator>::iterator_category());
};

注意上述语法,iterator_traits::iterator_category() 将产生一个临时对象(道理就像int() 会产生一个int暂时对象一样),其型别应该隶属于前述四个迭代器类型(I,F, B,R)之一。然后,根据这个型别,编译器才决定调用哪一个__advance() 重载函数。

因此,为了满足上述行为,traits 必须再增加一个相应的型别:

template <class I>
struct iterator_traits
{
    ....
    typedef typename I::iterator_category iterator_category;
};

// 针对原生指针而设计的 “偏特化版”
template <class T>
struct iterator_traits<T*>
{
    ....
    // 注意,原生指针是一种Random Access Iterator
    typedef random_access_iterator_tag iterator_category;
};

// 针对原生的pointer-to-const 而设计的“偏特化版”
template <class T>
struct iterator_traits<const T*>
{
    ....
    // 注意,原生的pointer-to-const 是一种Random Access Iterator
    typedef random_access_iterator_tag iterator_category;
};

任何一个迭代器,其类型永远应该落在“该迭代器所隶属之各种类型中,最强化的那个”。例如,int*, 既是Random Access Iterator,又是Bidirectional Iterator,同时也是Forward Iterator,而且也是Input iterator,那么,其类型应该归属为random_access_iterator_tag.

注意到advance() 的template 参数名称,按说advance() 既然可以接受各种类型的迭代器,就不应该将其型别参数命名为InputIterator。这其实是STL算法的一个命名规则:以算法所能接受之最低阶迭代器类型,来为其迭代器型别参数命名

消除“单纯传递调用的函数” 以class 来定义迭代器的各种分类标签,不仅可以促成重载机制的成功运作(使编译器得以正确执行重载决议,overloaded resolution),另一个好处是,通过继承,我们可以不必再写“单纯只做传递调用”的函数(例如前述的advance() ForwardIterator 版)。当参数与参数完全吻合,则调用相应版本函数;否则,因继承关系而自动传递调用父类的相应函数。

《STL源码剖析》有另外一个关于distance()的例子,详见书籍。


三、std::iterator 的保证

为了符合规范,任何迭代器都应该提供五个内嵌相应型别,以利于traits萃取,否则便是自别于整个STL框架,为了避免忘记漏却,将事情简化,STL提供了一个iterator class如下,如果每个新设计的迭代器都继承自它,就可保证符合STL所需之规范:

template <class Category,
                class T,
                class Distance = ptrdiff_t,
                class Pointer = T*,
                class Reference = T& >
struct iterator
{
    typedef Category     iterator_category;
    typedef T                 value_type;
    typedef Distance         difference_type;
    typedef Pointer         pointer;
    typedef Reference     reference;
};

iterator class 不含任何成员,纯粹只是型别定义,所以继承它并不会招致任何额外负担。由于后三个参数皆有默认值,故新的迭代器只需提供前两个参数即可。

小结:

设计适当的相应型别是迭代器的责任,设计适当的迭代器则是容器的责任。唯容器本身,才知道该设计出怎样的迭代器来遍历自己,并执行迭代器该有的各种行为。至于算法,完成可以独立于容器和迭代器之外自行发展,只要设计时以迭代器为对外接口就行。

traits编程技法大量运用于STL实现品中。它利用“内嵌型别”的编程技巧与编译器的template 参数推导功能,增强C++为能提供的关于型别认证方面的能力,弥补C++不是强型别语言的遗憾。

所有iterator源码,请参见SGI STL文件。

四、SGI STL 的私房菜:__type_traits traits 编程技法很棒,适度弥补了C++语言本身的不足。STL只对迭代器加以规范,制定出iterator_traits 这样的东西。SGI 把这种技法进一步扩大到迭代器以外的世界,于是有了所谓的__type_traits。双底线前缀词意指这是SGI STL 内部所用的东西,不再STL标准规范之内。

iterator_traits负责萃取迭代器的特性,__type_traits则负责萃取型别(type)的特性。此处我们所关注的型别特性是指:这个型别是否具备non-trivial defalt ctor?是否具备non-trivial copy ctor?是否具备non-trivial assignment operator?是否具备non-trivial dtor?如果答案是否定的,我们在对这个型别进行构造、析构、拷贝、赋值等操作时,就可以采用最高效率的措施(例如根本不调用身居高位,不谋实事的那些constructor,destructor),而采用内存直接处理操作如malloc(),memcpy()等等,获得最高效率。这对于大规模而操作频繁的容器,有责显著的效率提升。

定义于SGI 中的__type_traits,提供了一种机制,允许针对不同的型别属性,在编译时期完成函数派送决定。这对于撰写template很有帮助。例如,当我们准备对一个“元素型别未知”的数组执行copy操作时,如果我们能事先知道其元素型别是否有一个trivialcopy constructor ,便能够帮助我们决定是否可使用快速的memcpy() 或 memmove()。

根据iterator_traits得来的经验,我们希望,程序之中可以这样运用__type_traits,T代表任意型别:

__type_traits<T>::has_trivial_default_constructor
__type_traits<T>::has_trivial_copy_constructor
__type_traits<T>::has_trivial_assignment_operator
__type_traits<T>::has_trivial_destructor
__type_traits<T>::is_POD_type        // POD : Plain Old Data

我们希望上述式子响应我们“真” 或 “假”(以便我们决定采取什么策略),但其结果不应该只是个bool值,应该是个有着 真/假 性质的“对象”,因为我们希望利用其响应结果来进行参数推导,而编译器只有面对class object 形式的参数,才会做参数推导。为此,上述式子应该传回这样的东西:

struct __true_type { };
struct __false_type { };

这两个空白classes没有任何成员,不会带来额外负担,却又能够标示真假,满足我们所需。

为了达成上述五个式子,__type_traits 内必须定义一些typedefs,其值不是__true_type 就是 __false_type。 下面是SGI的做法:

template <class type>
struct __type_traits
{
    typedef __true_type this_dummy_member_must_be_first;
    /* 不要移除这个成员,它通知“有能力自动将 __type_traits 特化”的编译器说,我们现在所看到的这个 __type_traits template 是特殊的。
    这是为了确保万一编译器也使用一个名为 __type_traits 而其实域此处定义并无任何关联的 template 时,所有事情都仍将顺利运作
    */
    /*以下条件应被遵守,因为编译器有可能自动为各型别产生专属的 __type_traits 特化版本:
        - 你可以重新排列以下的成员次序;
        - 你可以移除以下任何成员
        - 绝对不可以将以下成员重新命名而却没有改变编译器中的对应名称
        - 新加入的成员会被视为一般成员,除非你在编译器中加上适当支持
    */
    typedef __false_type has_trivial_default_constructor
    typedef __false_type has_trivial_copy_constructor
    typedef __false_type has_trivial_assignment_operator
    typedef __false_type has_trivial_destructor
    typedef __false_type is_POD_type
};

为什么SGI 把所有内嵌型别都定义为 __false_type 呢?是的,SGI 定义出最保守的值,然后再针对每一个标量型别(C++基本型别:char, signed char, unsigned char, short, unsigned short, int, unsigned int, long, unsigned long, float, double, long double提供特化版本。注意,每一个成员的值都是 __true_type,表示这些型别都是可采用最快速方式(例如memcopy)来进行拷贝(copy) 或 赋值(assign)操作。)设计适当的 __type_traits 特化版本,这样就解决了问题。

下面提供一个例子(《STL源码剖析》第2.3.3节):

template <class ForwardIterator, class Size, class T>
inline ForwardIterator uninitialized_fill_n(ForwardIterator first, Size n, const T& x)
{
    return __uninitialized_fill_n(first, n, x, value_type(first));
};

该函数以x为蓝本,自迭代器first开始构造n个元素。为求取最大效率,首先以value_type() 萃取出迭代器first 的value_type ,再利用 __type_traits 判断该型别是否为POD型别:

template <class ForwardIterator, class Size, class T, class T1>
inline ForwardIterator uninitialized_fill_n(ForwardIterator first, Size n, const T& x, T1*)
{
    typedef typename __type_traits<T1>::is_POD_type is_POD;
    return __uninitialized_fill_n_aux(first, n, x, is_POD());
};

以下就“是否为POD型别”采取最适当的措施:

// 如果不是POD型别
template <class ForwardIterator, class Size, class T>
ForwardIterator 
uninitialized_fill_n_aux(ForwardIterator first, Size n, const T& x, __false_type)
{
    ForwardIterator cur = first;
    // 为求阅读顺畅简化,以下将原本有的异常处理去除
    for ( ; n > 0 ; --n, ++cur )
        construct(&*cur, x);              // 参见 2.2.3节
    return cur;
};

// 如果是POD型别
template <class ForwardIterator, class Size, class T>
inline ForwardIterator 
uninitialized_fill_n_aux(ForwardIterator first, Size n, const T& x, __true_type)
{
    return fill_n(first, n, x);        // 交由高阶函数执行
};

// 以下是定义于<stl_algobase.h> 中的fill_n()
template <class OutputIterator, class Size, class T>
OutputIterator fill_n(OutputIterator first, Size n, const T& value)
{
    for ( ; n > 0 ; --n, ++first)
        *first = value;
    return first;
}

究竟一个class 什么时候该有自己的non-trivial default constructor, non-trivial copy constructor, non-trivial assignment operator, non-trivial destructor 呢?一个简单的判断准则是:如果class内含指针成员,并且对它进行内存动态配置,那么这个class就需要实现出自己的non-trivial-xxx.(进行深度拷贝)。
原文链接: https://www.cnblogs.com/yyxt/p/4961345.html

欢迎关注

微信关注下方公众号,第一时间获取干货硬货;公众号内回复【pdf】免费获取数百本计算机经典书籍

原创文章受到原创版权保护。转载请注明出处:https://www.ccppcoding.com/archives/224456

非原创文章文中已经注明原地址,如有侵权,联系删除

关注公众号【高性能架构探索】,第一时间获取最新文章

转载文章受原作者版权保护。转载请注明原作者出处!

(0)
上一篇 2023年2月13日 下午12:27
下一篇 2023年2月13日 下午12:27

相关推荐