Skip to Content
Course content

73: std::string in Depth

Click on the "Edit" button in the top corner of the screen to edit your slide content.

You've been using std::string since the start of this course, and for the most part, it just works. You push characters in, you print them out, and the memory management happens behind the scenes. But as we move into more performance-critical code, "it just works" isn't enough. We need to know exactly what's happening under the hood, because std::string is doing a lot more than it lets on.

Let's look at a real scenario. Imagine we're building a system to parse usernames from a large configuration file. We need to trim whitespace and check if the username starts with a specific prefix like "admin_".

The Hidden Cost of Slicing

My first instinct might be to write a helper function that returns a "cleaned" version of the string. Let's try this:

std::string trim_prefix(std::string s) {
    if (s.substr(0, 6) == "admin_") {
        return s.substr(6);
    }
    return s;
}

// Usage
std::string user = "admin_jdoe_99";
std::string clean_name = trim_prefix(user);

It looks clean, right? But if we're doing this for ten thousand users in a loop, we have a problem. Every time I call s.substr(), C++ allocates a brand new block of memory on the heap, copies the characters into it, and returns a new std::string object. Then, when that temporary string goes out of scope, it's deleted. We're hammering the allocator for no good reason. I'm not changing the characters; I'm just changing where I'm looking at them.

Stopping the Bleeding with string_view

Since C++17, we have a tool for this: std::string_view. Think of it as a "window" into an existing string. It doesn't own the memory; it just holds a pointer to the start and a length.

Let's rewrite that function and see how the signature changes:

#include <string_view>

std::string_view trim_prefix_fast(std::string_view s) {
    if (s.substr(0, 6) == "admin_") {
        return s.substr(6);
    }
    return s;
}

// Usage
std::string user = "admin_jdoe_99";
std::string_view clean_name = trim_prefix_fast(user);

Now, s.substr() on a string_view doesn't allocate anything. It just returns a new string_view` that points slightly further into the original user string. It's incredibly fast. But here's the catch—and I've tripped over this more times than I'd like to admit: Lifetime. If the original std::string user is destroyed, clean_name becomes a dangling pointer. You're looking through a window at a house that's been demolished. Use string_view for read-only access and temporary processing, but convert it back to a std::string if you need to store the result long-term.

Where is the Data Actually Living?

I've always wondered why small strings seem to perform almost as well as stack-allocated arrays. I decided to test this by printing the memory address of the internal buffer for strings of different lengths.

std::string short_str = "Hi";
std::string long_str = "This is a much longer string to trigger allocation";

printf("Short: %p\n", (void*)short_str.data());
printf("Long:  %p\n", (void*)long_str.data());

When I ran this, I noticed something strange. The address for short_str was very close to the address of the short_str object itself. The address for long_str, however, was far away in the heap. This is Small String Optimization (SSO). Most modern C++ libraries have a small internal buffer (usually 15-22 bytes) inside the string object. If the string is short, it stays on the stack. Once it exceeds that limit, it jumps to the heap.

Why does this matter? Because if you're designing a system with millions of tiny strings, you want to keep them under that SSO threshold to avoid millions of tiny heap allocations.

Safety vs. Speed

Lastly, let's talk about accessing characters. I usually use operator[], but let's see what happens when we go out of bounds:

std::string name = "Dev";
char c1 = name[10]; // What happens here?
char c2 = name.at(10); // And here?

When I run this, name[10] doesn't crash immediately—it just reads garbage memory (undefined behavior), which is a nightmare to debug. But name.at(10) throws a std::out_of_range exception. I've learned to use .at() during the debugging phase of a project to catch logic errors, and then switch to [] in the final release for a tiny performance boost once I'm certain the indices are correct.




📋 Practical Task

Build a Zero-Allocation Log Parser

You are given a raw log string containing multiple entries separated by pipes (|). Your task is to write a function that extracts a specific field (like "Timestamp" or "Level") without creating any new std::string objects during the parsing process.

Requirements:

  • Create a function std::string_view get_field(std::string_view log_line, int field_index).
  • The function should scan the log_line, count the | delimiters, and return a string_view pointing to the requested field.
  • If the field_index is out of range, return an empty string_view.
  • Test your function with a string like: "2023-10-01|ERROR|DatabaseTimeout|Connection lost".
  • Verify that no heap allocations occur during the get_field call.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.