Constructing Strings from Pieces Without a Dynamic Buffer

Say I want to make a string out of a couple different pieces. Say we’ve got some input strings a and b and I want to make some kind of debug description, like maybe a: <a>, b: <b>. Shouldn’t be too hard, right? Slap it together:

std::string result = "a: " + a + ", b: " + b;

Easy, right? How many allocations does this incur, though? It could be more than one. We can do better than that – we know up-front everything going into it, so surely we can allocate the right amount of space up-front. How would that look?

std::string result;
result.reserve("a: "sv.size() + a.size() + ", b: "sv.size() + b.size());
result += "a: ";
result += a;
result += ", b: ";
result += b;

OK, that kind of works! But it’s still kind of dissatisfying – each of those operator+= may include logic to check to see if the string is big enough, but we already know it is – we guaranteed it! OK, we can deal with that then:

std::size_t totalLength = "a: "sv.size() + a.size() + ", b: "sv.size() + b.size();
std::unique_ptr<char[]> buffer = std::make_unique<char[]>(totalLength);
char *dest = buffer.data();
std::memcpy(dest, "a: "sv.data(), "a: "sv.size());
dest += "a: "sv.size();
std::memcpy(dest, a.data(), a.size());
dest += a.size();
std::memcpy(dest, ", b: "sv.data(), ", b: "sv.size());
dest += ", b: "sv.size();
std::memcpy(dest, b.data(), b.size());
std::string_view result(buffer.data(), totalLength);

This works too, and it’s very efficient. But man, is this error-prone and verbose. There’s got to be a better way. Fortunately, there is. Let’s take this in steps. First say we’re sick of that “memcpy and then increment” pattern, and we put that in a lambda:

std::size_t totalLength = "a: "sv.size() + a.size() + ", b: "sv.size() + b.size();
std::unique_ptr<char[]> buffer = std::make_unique<char[]>(totalLength);
char *dest = buffer.data();
auto write = [&dest](std::string_view sv) {
    std::memcpy(dest, sv.data(), sv.size());
    dest += sv.size();
};
write("a: "sv);
write(a);
write(", b: "sv);
write(b);
std::string_view result(buffer.data(), totalLength);

OK, that’s looking good! Let’s make the length computation work similarly:

std::size_t totalLength = 0;
auto writeForLength = [&totalLength](std::string_view sv) {
    totalLength += sv.size();
};
writeForLength("a: "sv);
writeForLength(a);
writeForLength(", b: "sv);
writeForLength(b);
std::unique_ptr<char[]> buffer = std::make_unique<char[]>(totalLength);
char *dest = buffer.data();
auto write = [&dest](std::string_view sv) {
    std::memcpy(dest, sv.data(), sv.size());
    dest += sv.size();
};
write("a: "sv);
write(a);
write(", b: "sv);
write(b);
std::string_view result(buffer.data(), totalLength);

Now there’s a lot of parallelism (in a literary sense, not a programming sense) between the length computation and the copying steps – still room for error here. But by taking it another step, we can eliminate that:

auto writeWith = [&](auto write) {
    write("a: "sv);
    write(a);
    write(", b: "sv);
    write(b);
};
std::size_t totalLength = 0;
writeWith([&](std::string_view sv) { totalLength += sv.size(); });
std::unique_ptr<char[]> buffer = std::make_unique<char[]>(totalLength);
char *dest = buffer.data();
writeWith([&](std::string_view sv) {
    std::memcpy(dest, sv.data(), sv.size());
    dest += sv.size();
});
std::string_view result(buffer.data(), totalLength);

And that’s it! Now we’ve removed all the repetition, and we still compile down to something very efficient.

For this example, this is definitely overkill. Something like fmtlib (I assume, I haven’t tested) would probably be able to be similarly efficient with much less hassle. But this is a little more flexible than format strings. For example:

auto writeWith = [&](auto write) {
    write("a: "sv);
    for (int i = 0; i < aCount; ++i) {
        write(a);
    }
    if (!b.empty()) {
        write(", b: "sv);
        write(b);
    }
};

That would not be nearly as easy to do with a format string – you’d likely lose all the efficiency benefits it might have had in simpler cases. But this way of doing it has no problem, and remains very efficient.

This is probably not something you should pepper everywhere – it’s still pretty verbose, and kind of obscure. But I’m a perfectionist at times, and in projects I care about, I will pore over the generated assembly code until it is simple enough to please me. The generated code here pleases me.

Tags: ,

Leave a Reply