Loading

C++中的std::string

字符串字面量

字符串字面量位于字面量池中,字面量池位于程序的常量区

void show_address(const char* str) {
    std::cout << reinterpret_cast<const void*>(str) << std::endl;
}

int main()
{
    // 三者位于同一个地址上
    show_address("Hello");
    show_address("Hello");
    show_address("Hello");
    // C++中允许对字符串字面量取地址,即&"Hello" 得到的地址与上文相同
}

对于指针和数组,它们代表的含义不同

// pStr指针位于全局区中 指向位于常量区中的字符串字面量
const char* pStr = "Hello";

int main() 
{
    // strArr位于栈中 将数据从常量区拷贝到函数栈中
	char strArr[] = "Hello";
}

std::string的内存分配

C++对std::string的内部实现有如下约定

  • 如果传入的字符串字面量小于某阈值,那么该std::string内部在栈上分配内存(即短字符串优化——SSO);如果大于指定的阈值,那么将会根据传入的字符串的尺寸,在堆上开辟相应的空间。不管是短字符串还是长字符串,在使用字符串字面量构建std::string的时候,都会产生拷贝的操作
  • 如果后续对std::string采用了“增”操作,那么将会采用double的形式进行扩容(双倍扩容)

在通常情况下,若数据的长度小于等于15(还有一位是'\0'结束符),那么会采用短字符串优化(这主要取决于不同库的实现)

// MSVC中的实现
// length of internal buffer, [1, 16]:
static constexpr size_type _BUF_SIZE = 16 / sizeof(value_type) < 1 ? 1 : 16 / sizeof(value_type);

std::string的结构

在MSVC-Release-x64的环境下,std::string的大小是32B

using string  = basic_string<char, char_traits<char>, allocator<char>>;
using _Alty        = _Rebind_alloc_t<_Alloc, _Elem>;
using _Alty_traits = allocator_traits<_Alty>;

using _Scary_val = _String_val<conditional_t<_Is_simple_alloc_v<_Alty>, _Simple_types<_Elem>,
    _String_iter_types<_Elem, typename _Alty_traits::size_type, typename _Alty_traits::difference_type,
        typename _Alty_traits::pointer, typename _Alty_traits::const_pointer, _Elem&, const _Elem&>>>;

_Compressed_pair<_Alty, _Scary_val> _Mypair;

std::string采用std::allocator<char>作为分配器,由_Compressed_pair的EBO得,分配器并不会占用内存空间。该分配作用于std::_Is_simple_alloc_v<std::_Rebind_alloc_t<std::allocator<char>, char>>true,因此std::string的内存布局可以拆解如下

// std::string同一时间只可能是短字符串或长字符串
union _Bxty { // storage for small buffer or pointer to larger one
    char _Buf[16];
    char* _Ptr;
    char _Alias[16]; // TRANSITION, ABI: _Alias is preserved for binary compatibility (especially /clr)
} _Bx;

std::size_t _Mysize = 0; // current length of string
std::size_t _Myres = 0; // current storage reserved for string
  • std::string中记录的是短字符串时,_Buf代表栈上的字符串,如"Hello World"是存储在_Buf数组中

  • std::string中记录的是长字符串时,_Ptr代表指向堆上数据的指针,可通过该指针访问数据

当我们调用c_str()时,本质上是在调用如下方法

constexpr const value_type* _Myptr() const noexcept {
    const value_type* _Result = _Bx._Buf;
    // 判断是否是长字符串
    if (_Large_string_engaged()) {
        _Result = _Unfancy(_Bx._Ptr);
    }

    return _Result;
}

constexpr bool _Large_string_engaged() const noexcept {
#if _HAS_CXX20
    // 判断当前函数调用是否发生在常量求值场合
    if (std::is_constant_evaluated()) {
        return true;
    }
#endif // _HAS_CXX20
    return _BUF_SIZE <= _Myres;
}

SSO与移动

若无特殊说明,本小节建立在MSVC-Release-x64的环境下进行分析,且源码在便于理解的基础上略有删减。std::string在Debug和Release模式下内存分配的机理不同(Debug模式下无短字符串优化等)

// 如果是MSVC-Debug-x64环境 那么会在堆上分配2次16B的内存
std::string name = "Hello World";
std::string newName = std::move(name);

下面进行源码剖析

constexpr basic_string(basic_string&& _Right) noexcept
    : _Mypair(_One_then_variadic_args_t{}, _STD move(_Right._Getal())) // 标签分发
{
    // 根据优化等级选择不同的分配器 在Release模式下取得_Fake_allocator 它是空类 不负责任何功能
    _Mypair._Myval2._Alloc_proxy(_GET_PROXY_ALLOCATOR(_Alty, _Getal()));
    // 拿走被移动对象中的数据
    _Take_contents(_Right);
}
constexpr void _Take_contents(basic_string& _Right) noexcept {
    // assign by stealing _Right's buffer
    auto& _My_data    = _Mypair._Myval2;
    auto& _Right_data = _Right._Mypair._Myval2;

    // We need to ask if pointer is safe to memcpy.
    // size_type must be an unsigned integral type so memcpy is safe.
    // _Elem must be trivial standard-layout, so memcpy is safe.
    // We also need to disable memcpy if the user has supplied _Traits, since they can observe traits::assign and similar.
    if constexpr (_Can_memcpy_val) {
#if _HAS_CXX20
        if (!_STD is_constant_evaluated())
#endif
        {
            // 该宏判断优化等级 Release模式下_ITERATOR_DEBUG_LEVEL为0
#if _ITERATOR_DEBUG_LEVEL != 0
            if (_Right_data._Large_string_engaged()) {
                // take ownership of _Right's iterators along with its buffer
                _Swap_proxy_and_iterators(_Right);
            } else {
                _Right_data._Orphan_all();
            }
#endif

            // memcpy右值字符串中的数据
            _Memcpy_val_from(_Right);
            // 将右值字符串置回默认状态
            _Right._Tidy_init();
            return;
        }
    }

    // 下方代码处理 when is unsafe to memcpy 的情况
    // Codes...
}
void _Memcpy_val_from(const basic_string& _Right) noexcept {
    // 添加偏移量 使memspy正常工作
    const auto _My_data_mem =
        reinterpret_cast<unsigned char*>(std::addressof(_Mypair._Myval2)) + _Memcpy_val_offset;
    const auto _Right_data_mem =
        reinterpret_cast<const unsigned char*>(std::addressof(_Right._Mypair._Myval2)) + _Memcpy_val_offset;
    // 对数据进行拷贝 Debug和Release模式的不同会导致偏移量不同 但最终拷贝的是同一份数据
    ::memcpy(_My_data_mem, _Right_data_mem, _Memcpy_val_size);
}

由于MSVC中对将存储数据的结构设计为union,因此在::memcpy的时候并不需要考虑是长字符串还是短字符串,直接对数据进行拷贝,然后再读取的时候进行判定即可即可(即上文中提到的_Myptr()以及_Large_string_engaged()

过时的COW

[标准C++类std::string的内存共享和Copy-On-Write(写时拷贝)

Legality of COW std::string implementation in C++11

std::string_view与const std::string&

对于std::string而言,当它从一个原生的c-style-string上构造时,都伴随着内存分配(可能是堆也可能是栈);但对于std::string_view而言,它内部只维护了一个原生指针和一个长度

const char* _Mydata;
std::size_t _Mysize;

这代表着std::string_view在构造的时候,只是进行一次浅拷贝,同时进行一次O(n)复杂度的长度求值

constexpr basic_string_view(const char* _Ntcts) noexcept
    : _Mydata(_Ntcts), _Mysize(_Traits::length(_Ntcts)) {}
const char* word = "Hello";

std::string_view sv1 = word;
std::string_view sv2 = word;

std::string s = word;

因此在对c-style-string进行操作时,为此构建一个std::string是一个不值当的操作,我们需要的是一个“视图”,即std::string_view

Example1

std::string extract_part(const std::string& bar) {
    return bar.substr(2, 3);
}
if (extract_part("ABCDEFG").front() == 'C') {
    // do something...
}

尽管编译器已经开启了RVO,但上述代码仍然包含了两次std::string对象的构造,若检测的字符串是长字符串,那么这代表着高额的性能开销

std::string_view extract_part(std::string_view bar) {
    return bar.substr(2, 3);
}
if (extract_part("ABCDEFG").front() == 'C') {
    // do something...
}

Problem1

但由于std::string_view执行的是浅拷贝,所以也伴随着dangling的问题

std::vector<std::string_view> elements;

// 若elem的生命周期短于elements 那么可能会访问到已经被释放的内存
void Save(const std::string& elem) {
    elements.push_back(elem);
}

Problem2

std::map<std::string, int> frequencies;

int GetFreqForKeyword(std::string_view keyword) {
    // 无法通过编译 不存在std::string_view到std::string的隐式转换
    return frequencies.at(keyword);
}

Problem3

class Sink
{
public:
    Sink(std::string_view sv) : str(std::move(sv)) {}
private:
    std::string str;
};
  • 对一个std::string_view,而言,std::move 它是无害但无用的
  • std::string_view去构造std::string,存在sv在构建时内部指针悬空的风险

总结

  • 考虑使用std::string_view代替const std::string&
  • 函数传参按值传递std::string_view即可,不需要pass-by-const-reference,也没有移动操作

手撕简易my_string

class my_string
{
protected:
    std::size_t size;
    char* pStr;

    void init_null_impl() {
        size = 0;
        pStr = new char[1]{'\0'};
    }

    void init_impl(const char* newData) {
        size = std::strlen(newData);
        pStr = new char[size + 1];
        strcpy_s(pStr, size + 1, newData);
    }

    void str_swap(my_string& _another) {
        std::swap(pStr, _another.pStr);
        std::swap(size, _another.size);
    }

public:
    my_string() {
        init_null_impl();
    }

    my_string(const char* newData) {
        if (newData == nullptr)
            init_null_impl();
        else
            init_impl(newData);
    }

    my_string(const my_string& _copy) {
        if (_copy.pStr == nullptr)
            init_null_impl();
        else
            init_impl(_copy.pStr);
    }

    my_string(my_string&& _another) : size(_another.size), pStr(_another.pStr) {
        _another.init_null_impl();
    }

    my_string& operator=(my_string _another) {
        str_swap(_another);
        return *this;
    }

    ~my_string() {
        delete[] pStr;
    }

    char operator[](std::size_t index) const { return pStr[index]; }

    const char* c_str() const { return pStr; }
};
posted @ 2022-02-28 23:50  _FeiFei  阅读(2664)  评论(0编辑  收藏  举报