C++源码剖析——string和string_view

  前言:之前看过侯老师的《STL源码剖析》但是那已经是多年以前的,现在工作中有时候查问题和崩溃都需要了解实际工作中使用到的STL的实现。因此计划把STL的源码再过一遍。
  摘要:本文描述了llvm中libcxx的std::string的实现。
  关键字compressed_pairstring
  其他:参考代码LLVM-libcxx

  std::string的实现实际上就是basic_string,我们经常使用的就是两种typedef basic_string<char> string;typedef basic_string<wchar_t>wstringstd::string使用的分配器是allocator<char>,就是简单的new char,而trait使用的是char_traits

template <class _CharT, class _Traits = char_traits<_CharT>, class _Allocator = allocator<_CharT> >
class _LIBCPP_TEMPLATE_VIS basic_string;

using string = basic_string<char>;

1 char_traits

  char_traits就是char类型的萃取器,抽象了和当前类型相关的类型集,以及类型相关的一些操作。

struct _LIBCPP_DEPRECATED_("char_traits<T> for T not equal to char, wchar_t, char8_t, char16_t or char32_t is non-standard and is provided for a temporary period. It will be removed in LLVM 18, so please migrate off of it.")
    char_traits
{
    using char_type  = _CharT;
    using int_type   = int;
    using off_type   = streamoff;
    using pos_type   = streampos;
    using state_type = mbstate_t;
};

  下面这些实现实际上是通用的char_traits的实现,因为使用的for-loop性能上完全依赖于编译器优化,如果编译器无法判断两块内存是否overlap的话,对于trival type性能可能不如memcpy等整块内存拷贝或者move的API。从源码的注释我们也能看到这个类是不建议使用的,应该使用对应的特化版本,特化版本比如char_traits<char>使用了std::copy等API可以对操作进行加速。

char_traits<T> for T not equal to char, wchar_t, char8_t, char16_t or char32_t is non-standard and is provided for a temporary period. It will be removed in LLVM 18, so please migrate off of it

assign lt eq
  这三个函数就是对operator =,operator == ,operator <的封装。

static inline void _LIBCPP_CONSTEXPR_SINCE_CXX17
        assign(char_type& __c1, const char_type& __c2) _NOEXCEPT {__c1 = __c2;}
static inline _LIBCPP_CONSTEXPR bool eq(char_type __c1, char_type __c2) _NOEXCEPT
    {return __c1 == __c2;}
static inline _LIBCPP_CONSTEXPR bool lt(char_type __c1, char_type __c2) _NOEXCEPT
    {return __c1 < __c2;}

compare
  遍历当前字符串,逐个字符比较,需要保证输入的字符串长度至少为n负责会越界。

static _LIBCPP_CONSTEXPR_SINCE_CXX17 int compare(const char_type* __s1, const char_type* __s2, size_t __n) {
        for (; __n; --__n, ++__s1, ++__s2){
            if (lt(*__s1, *__s2))
                return -1;
            if (lt(*__s2, *__s1))
                return 1;
        }
        return 0;
    }

length
  获取字符串的长度,遍历字符串,以char_type(0)为结束点,实现和strlen相同。

_LIBCPP_INLINE_VISIBILITY static _LIBCPP_CONSTEXPR_SINCE_CXX17 size_t length(const char_type* __s) {
        size_t __len = 0;
        for (; !eq(*__s, char_type(0)); ++__s)
            ++__len;
        return __len;
    }

find
  从头到尾遍历字符串,直到找到对应的值为止。

_LIBCPP_INLINE_VISIBILITY static _LIBCPP_CONSTEXPR_SINCE_CXX17
    const char_type* find(const char_type* __s, size_t __n, const char_type& __a) {
        for (; __n; --__n){
            if (eq(*__s, __a))
                return __s;
            ++__s;
        }
        return nullptr;
    }

move
  字符串移动。可以看到针对当前输入的两块内存的地址相对位置选择了不同方向的拷贝动作,这样做是为了正确处理存在内存交叉的两块内存的字符串。

static char_type*       move(char_type* __s1, const char_type* __s2, size_t __n) {
    if (__n == 0) return __s1;
    char_type* __r = __s1;
    if (__s1 < __s2){
        for (; __n; --__n, ++__s1, ++__s2)
            assign(*__s1, *__s2);
    }
    else if (__s2 < __s1){
        __s1 += __n;
        __s2 += __n;
        for (; __n; --__n)
            assign(*--__s1, *--__s2);
    }
    return __r;
}

copy
  字符串拷贝,这里的动作基本上和move相同但是并没有进行地址交错的处理。因为move的语义是移动,旧的丢弃,而copy的语义是拷贝,旧的应该保留。

_LIBCPP_INLINE_VISIBILITY static _LIBCPP_CONSTEXPR_SINCE_CXX20 char_type*       copy(char_type* __s1, const char_type* __s2, size_t __n) {
    if (!__libcpp_is_constant_evaluated()) {
        _LIBCPP_ASSERT(__s2 < __s1 || __s2 >= __s1+__n, "char_traits::copy overlapped range");
    }
    char_type* __r = __s1;
    for (; __n; --__n, ++__s1, ++__s2)
        assign(*__s1, *__s2);
    return __r;
}

2 string

2.1 内存布局

  std::string中内存布局根据_LIBCPP_ABI_ALTERNATE_STRING_LAYOUT有所不同,就是下面列的这种,__data__域在类开头,这样在某些需要对齐的场景比较有优势。另一种是将__data___等四个成员完全反向陈列。

    struct __long{
        struct _LIBCPP_PACKED {
            size_type __is_long_ : 1;
            size_type __cap_ : sizeof(size_type) * CHAR_BIT - 1;
        };
        size_type __size_;
        pointer   __data_;
    };
    enum {__min_cap = (sizeof(__long) - 1)/sizeof(value_type) > 2 ? (sizeof(__long) - 1)/sizeof(value_type) : 2};
    struct __short{
        struct _LIBCPP_PACKED {
            unsigned char __is_long_ : 1;
            unsigned char __size_ : 7;
        };
        char __padding_[sizeof(value_type) - 1];
        value_type __data_[__min_cap];
    };

    struct __raw{
        size_type __words[__n_words];
    };

    struct __rep{
        union
        {
            __long  __l;
            __short __s;
            __raw   __r;
        };
    };

    __compressed_pair<__rep, allocator_type> __r_;

  从上面的定义中可以看出,string针对不同场景的优化,对于小字符串(一般为15或23)直接存储在栈上即SSO优化,而对于大字符串会通过堆来存储。__compressed_pair是一种可以利用编译期一些优化的组合类,可以理解为pair

__compressed_pair
  __compressed_pair其实就是两个变量的集合,类似pair,只不过针对不同的场景进行了优化。将标准库中的代码简化之后主要的代码就是下面这段。对于可继承的类型,直接会通过继承的方式构造,这样可以对于空基类可以触发EBO(https://en.cppreference.com/w/cpp/language/ebo),节省一部分内存;而对于不可继承的类型直接就创建一个成员。

template <class _Tp, int _Idx, bool _CanBeEmptyBase = is_empty<_Tp>::value && !__libcpp_is_final<_Tp>::value>
struct __compressed_pair_elem {
private:
    _Tp __value_;
};

template <class _Tp, int _Idx>
struct __compressed_pair_elem<_Tp, _Idx, true> : private _Tp {};

template <class _T1, class _T2>
class __compressed_pair : private __compressed_pair_elem<_T1, 0>, private __compressed_pair_elem<_T2, 1> {};

2.2 string的构造与析构

构造
  string的第一种构造默认初始化,调用__default_init创建一个空的__rep,没有进行其他操作。第二种就是调用__init创建内存。
  首先检查当前输出参数的长度是否符合SSO优化的前提,满足的话直接使用short版本,不满足的话再通过allocator分配内存。最后通过char_traits::copy将字符拷贝到内存上。


template <class _CharT, class _Traits, class _Allocator>
_LIBCPP_CONSTEXPR_SINCE_CXX20 void basic_string<_CharT, _Traits, _Allocator>::__init(const value_type* __s, size_type __sz, size_type __reserve){
    if (__libcpp_is_constant_evaluated())
        __r_.first() = __rep();
    if (__reserve > max_size()) //长度过长
        __throw_length_error();
    pointer __p;
    if (__fits_in_sso(__reserve)){//是否满足sso优化的条件
        __set_short_size(__sz);
        __p = __get_short_pointer();
    }
    else{
        auto __allocation = std::__allocate_at_least(__alloc(), __recommend(__reserve) + 1);
        __p = __allocation.ptr;
        __begin_lifetime(__p, __allocation.count);
        __set_long_pointer(__p);    //设置long.__data__
        __set_long_cap(__allocation.count);//设置long.__cap__
        __set_long_size(__sz);              //设置long.__sz__
    }
    traits_type::copy(std::__to_address(__p), __s, __sz);//拷贝内存,逐个拷贝
    traits_type::assign(__p[__sz], value_type());//末尾插入\0
}

  能够注意到申请内存时,实际申请的大小是__recommend(__reserve) + 1,起始就是字节对齐的长度。

 size_type __recommend(size_type __s) _NOEXCEPT{
    if (__s < __min_cap) {
        if (__libcpp_is_constant_evaluated())
            return static_cast<size_type>(__min_cap);
        else
            return static_cast<size_type>(__min_cap) - 1;
    }
    //__alignment == 16
    size_type __guess = __align_it<sizeof(value_type) < __alignment ?
                    __alignment/sizeof(value_type) : 1 > (__s+1) - 1;
    if (__guess == __min_cap) ++__guess;
    return __guess;
}

  另外移动拷贝构造就是直接接管原字符的内存,并在原字符内存上构建一个空字符串。之后输入的字符串的allocator和当前字符串不同时才会逐个拷贝,也就是说move后的源字符串是会失效的,这也符合move的语义。而对于const _CharT* __s输入的构造函数和拷贝构造函数直接就是调用的__init申请拷贝内存。

  _LIBCPP_HIDE_FROM_ABI _LIBCPP_CONSTEXPR_SINCE_CXX20 
  basic_string(basic_string&& __str, const allocator_type& __a) : __r_(__default_init_tag(), __a) {
    if (__str.__is_long() && __a != __str.__alloc()) // copy, not move
    __init(std::__to_address(__str.__get_long_pointer()), __str.__get_long_size());
    else {
    if (__libcpp_is_constant_evaluated())
        __r_.first() = __rep();
    __r_.first() = __str.__r_.first();
    __str.__default_init();
    }
    std::__debug_db_insert_c(this);
    if (__is_long())
    std::__debug_db_swap(this, &__str);
}

析构
  析构就比较简单了,如果是长字符,就会调用deallocate释放内存。

basic_string<_CharT, _Traits, _Allocator>::~basic_string(){
    std::__debug_db_erase_c(this);
    if (__is_long())
        __alloc_traits::deallocate(__alloc(), __get_long_pointer(), __get_long_cap());
}

2.3 string的一些操作函数

扩容
  string的扩容是通过__grow_by_and_replace进行的,另一个实现__grow_by也是一样的逻辑,基本的流程是:

  1. 检查大小是否超出最大值;
  2. 计算期望得到的cap大小,值为std::max(2 * old_cap, new_size)
  3. 通过allocator分配一块儿新内存;
  4. 将旧内存中的值拷贝到新内存中;
  5. 如果旧内存为堆分配的则释放内存;
  6. 设置一些参数,结尾插入空字符。
void
basic_string<_CharT, _Traits, _Allocator>::__grow_by_and_replace
    (size_type __old_cap, size_type __delta_cap, size_type __old_sz,
     size_type __n_copy,  size_type __n_del,     size_type __n_add, const value_type* __p_new_stuff){
    size_type __ms = max_size();
    if (__delta_cap > __ms - __old_cap - 1)
        __throw_length_error();
    pointer __old_p = __get_pointer();
    size_type __cap = __old_cap < __ms / 2 - __alignment ?
                          __recommend(std::max(__old_cap + __delta_cap, 2 * __old_cap)) :
                          __ms - 1;
    auto __allocation = std::__allocate_at_least(__alloc(), __cap + 1);
    pointer __p = __allocation.ptr;
    __begin_lifetime(__p, __allocation.count);
    std::__debug_db_invalidate_all(this);
    if (__n_copy != 0)
        traits_type::copy(std::__to_address(__p),
                          std::__to_address(__old_p), __n_copy);
    if (__n_add != 0)
        traits_type::copy(std::__to_address(__p) + __n_copy, __p_new_stuff, __n_add);
    size_type __sec_cp_sz = __old_sz - __n_del - __n_copy;
    if (__sec_cp_sz != 0)
        traits_type::copy(std::__to_address(__p) + __n_copy + __n_add,
                          std::__to_address(__old_p) + __n_copy + __n_del, __sec_cp_sz);
    if (__old_cap+1 != __min_cap || __libcpp_is_constant_evaluated())
        __alloc_traits::deallocate(__alloc(), __old_p, __old_cap+1);
    __set_long_pointer(__p);
    __set_long_cap(__allocation.count);
    __old_sz = __n_copy + __n_add + __sec_cp_sz;
    __set_long_size(__old_sz);
    traits_type::assign(__p[__old_sz], value_type());
}

缩容
  string缩容有两种:一种是调用__null_terminate_at插入空字符,实际的大小并未发生变化;另一种是调用__shrink_or_extend(实际实现就是申请新内存拷贝旧的,释放旧的),__cap__会变化但是__sz不会变化。

basic_string& __null_terminate_at(value_type* __p, size_type __newsz) {
      __set_size(__newsz);
      __invalidate_iterators_past(__newsz);
      traits_type::assign(__p[__newsz], value_type());
      return *this;
}

  其他的实现就是在assign,move,copy以及以上函数的基础上操作,就不再罗列。算法相关的函数等看算法的时候再说。

3 string_view

  string_view用来持有字符串,也就是说他只是一个wrapper,并不对持有的字符串负责,也就没有拷贝析构等操作,相比于string性能上要好不少。

template<class _CharT, class _Traits = char_traits<_CharT> >
class _LIBCPP_TEMPLATE_VIS basic_string_view;

typedef basic_string_view<char>     string_view;

  string_view的结构非常简单,就是一个指针和长度,在进行构造时也仅仅是浅拷贝,将对应的指针传递给成员。也就是说在使用string_view时要对齐管理的内存及其小心,避免在内存已经失效时还用string_view访问。

It is the programmer’s responsibility to ensure that std::string_view does not outlive the pointed-to character array

template<class _CharT, class _Traits>
class basic_string_view {
private:
    const   value_type* __data_;
    size_type           __size_;
};

basic_string_view(const _CharT* __s, size_type __len) _NOEXCEPT
        : __data_(__s), __size_(__len){}

4 参考文献

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
string_viewstring都是C++ STL中的字符串型,但它们有着不同的特点和用途。 stringC++中常用的字符串型,它是一个可变长的字符串容器,可以动态增加或删除字符。它存储的字符串是一个连续的字符数组,可以通过下标或迭代器进行访问和修改。string支持很多字符串操作,如查找、替换、插入、删除、子串等。 string_viewC++17新增的型,它是一个不可变的字符串视图。它本质上是一个只包含指向原始字符串的指针和长度信息的结构体,它不拥有原始字符串的内存空间,也不会对原始字符串进行修改。它主要用于读取和处理字符串,可以提高程序的效率和安全性。string_view可以用于任何可以转换为const char*的型,如string、字符数组、字面量等。 下面是string_viewstring的区别和联系: 1. 内存管理方式不同:string拥有自己的内存空间,而string_view不拥有内存空间,只是指向原始字符串的一个视图。 2. 可变性不同:string是可变的,可以修改字符串内容;而string_view是不可变的,只能读取字符串内容。 3. 使用场景不同:string_view主要用于只读操作,可以提高程序效率和安全性,特别是在处理大量字符串时。而string则适用于需要频繁修改字符串的场景。 4. 接口相似:string_viewstring都支持似的操作,如查找、比较、子串等。 总之,string_viewstring都是C++中常用的字符串型,它们各有优点和适用场景。在实际编程中,可以根据需要选择合适的字符串型。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值