For a professional C++ developer, it’s important to understand memory organization of the data structures, especially when it comes to the containers from the C++ Standard Library. In this post of Tasty C++ series we’ll look inside of std::string, so that you can more effectively work with C++ strings and take advantage and avoid pitfalls of the C++ Standard Library you are using.

In C++ Standard Library, std::string is one of the three contiguous containers (together with std::array and std::vector). This means that a sequence of characters is stored in a contiguous area of the memory and an individual character can be efficiently accessed by its index at O(1) time. The C++ Standard imposes more requirements on the complexity of string operations, which we will briefly focus on later in this post.

If we are talking about the C++ Standard, it’s important to remember that it doesn’t impose exact implementation of std::string, nor does it specify the exact size of std::string. In practice, as we’ll see, the most popular implementations of the C++ Standard Library allocate 24 or 32 bytes for the same std::string object (excluding the data buffer). On top of that, the memory layout of string objects is also different, which is a result of a tradeoff between optimal memory and CPU utilization, as we’ll also see below.

Long Strings

For people just starting to work with strings in C++, std::string is usually associated with three data fields:

  • Buffer – the buffer where string characters are stored, allocated on the heap.
  • Size – the current number of characters in the string.
  • Capacity – the max number of character the buffer can fit, a size of the buffer.

Talking C++ language, this picture could be expressed as the following class:

1class TastyString {
2    char *    m_buffer;     //  string characters
3    size_t    m_size;       //  number of characters
4    size_t    m_capacity;   //  m_buffer size
5}

This representation takes 24 bytes and is very close to the production code.

Let’s see how this compares to the actual size of std::string. This is given by sizeof(std::string) and in the most popular implementations of the C++ Standard Library is the following:

C++ Standard Library Size of std::string()
MSVC STL 32 bytes
GCC libstdc++ 32 bytes
LLVM libc++ 24 bytes

What a surprise, only LLVM allocates expected 24 bytes for std::string. The other two, MSVC and GCC, allocate 32 bytes for the same string. (Numbers in the table are for -O3 optimization. Note that MSVC allocates 40 bytes for std::string in the debug mode.)

Let’s get some intuition about why various implementation allocate different amount of memory for the same object.

Small Strings

You have already noticed that the fields of TastyString and std::string contain only the auxiliary data, while the actual data (characters) is stored in the buffer allocated on the heap. However, when the actual data is small enough, it seems inefficient to reserve 24 or 32 bytes for the auxiliary data, isn’t it?

Small String Optimization. This is what the small string optimization (aka SSO) is used for. The idea is to store the actual data in the auxiliary region with no need to allocate the buffer on the heap, that makes std::string cheap to copy and construct (as it’s only 3x-4x times more than fundamental types such as void *, size_t, or double). This technique is popular among various implementations, but is not a part of the C++ Standard.

Now it makes sense why some implementations increase the auxiliary region to 32 bytes — to store longer small strings in the auxiliary region before switching into the regular mode with dynamically allocated buffer.

How big are small strings? Let’s see how many characters the auxiliary region can store. This is what std::string().capacity() will tell us:

C++ Standard Library Small String Capacity
MSVC STL 15 chars
GCC libstdc++ 15 chars
LLVM libc++ 22 chars

Another surprise: LLVM with its 24 bytes for std::string fits more characters than MSVC or GCC with their 32 bytes. (In fact, it’s possible to fully utilize the auxiliary region, so that n-byte area fits n-1 chars and '\0'. Watch CppCon 2016 talk for details.)

How fast are small strings? As with many things in programming, there is a tradeoff between memory utilization and code complexity. In other words, the more characters we want to squeeze into the auxiliary memory, the more complex logic we should invent. This results not only in more assembly operations, but also into branching that is not good for CPU pipelines.

To illustrate this point, let’s see what the most commonly used size() method compiles to in various standard libraries:

GCC stdlibc++. The function directly copies m_size field into the output register (see https://godbolt.org/z/7nYe9rWdE):

Example GCC libstdc++
string size C++ code string size GCC assembler

LLVM libc++. The function at first checks if the string is short and then calculates its size (see https://godbolt.org/z/xM349cG5P).

Example LLVM libc++
string size C++ code string size LLVM assembler

LLVM code remains more complex for other methods too. It’s hard to say how badly this impacts the overall performance. The best advice is to keep this knowledge at hand and, for your particular use case, benchmark and experiment with various implementations.

Memory Allocation Policy

Finally, let’s come back to a long string mode and see how m_buffer grows when our string becomes bigger. Some comments in the GCC source code, refer to the exponential growth policy. It’s not clear if this is an internal GCC decision or part of the C++ Standard. In any case, all three implementations use exponential growth, so that MSVC has 1.5x factor growth, while GCC and LLVM use 2x factor.

The code below illustrates the growth algorithm in each implementation. The capacity examples show how the capacity changes as the string grows one character at a time in a loop:

  • MSVC STL

    1size_t newCapacity(size_t newSize, size_t oldCap) {
    2    return max(newSize, oldCap + oldCap / 2);
    3}
    

    Capacity growth: 15, 31, 47, 70, 105, 157, 235, 352, 528, 792, 1'188, 1'782.

  • GCC libstdc++

    1size_t newCapacity(size_t newSize, size_t oldCap) {
    2    return max(newSize + 1, 2 * oldCap);
    3}
    

    Capacity growth: 15, 30, 60, 120, 240, 480, 960, 1'920, 3'840, 7'680, 15'360.

  • LLVM libc++

    1size_t newCapacity(size_t newSize, size_t oldCap) {
    2    return max(newSize, 2 * oldCap) + 1;
    3}
    

    Capacity growth: 22, 47, 95, 191, 383, 767, 1'535, 3'071, 6'143, 12'287.

Tha Last Word

The actual implementation of std::string varies among the most popular implementations of the C++ Standard Library. The main difference is in the Small String Optimization, which the C++ Standard doesn’t define explicitly.

The following table summarizes some key facts about std::string:

C++ Standard Library String Size Small String Capacity Growth Factor
MSVC STL 32 bytes 15 chars 1.5x
GCC libstdc++ 32 bytes 15 chars 2x
LLVM libc++ 24 bytes 22 chars 2x

These details will be useful for every professional C++ developer. They are especially important when optimizing for CPU and memory efficiency.

For sure, I’m not the only one curious about how strings are implemented in other languages. What is different from C++, what is similar? Please, share your knowledge in the comments, I’d love to hear from you.

Thanks for reading TastyCode.

Recommended Links:

TastyCode by Oleksandr Gituliar.