Building Strings
Building a short label and building a document assembled from hundreds of fragments may look similar in source code, but
they place very different demands on memory.
If a growing string repeatedly runs out of capacity, each growth step can allocate a larger block and copy everything
that has already been written.
That cost is easy to miss because every individual append() still looks harmless.
This page helps you choose a construction strategy from the information your algorithm already has.
You will see how a fixed set of fragments differs from a dynamically collected set, when reserving an editor is enough,
and what AnyStringBuilder and StringFormat add beyond concatenation.
The table is a quick map; the following sections explain the reasoning behind each choice.
Situation |
Preferred approach |
Allocation behavior |
|---|---|---|
A fixed set of text fragments |
|
Calculates the complete native length and allocates final character storage once. |
A dynamic collection of fragments |
|
The list may grow, but final character storage is sized once when joined. |
A local editor already being mutated |
|
The reservation prevents append growth from repeatedly reallocating the accumulated text. |
Width-independent or directly formatted output |
Growth has the same concern unless pre-sized, but values and encodings append without temporary strings. |
|
Structured text with placeholders |
|
Appending to StringEditor with Reserved Capacity
Appending to an editor is simple, but an editor with insufficient capacity must obtain a larger allocation and copy its existing character data before continuing. As the text grows, this can happen repeatedly.
When the exact final native length is known, calculate it first and call reserve() once.
For StringEditor and U8StringEditor, capacity is measured in bytes.
For U16StringEditor, it is measured in UTF-16 data units.
For U32StringEditor, it is measured in code points.
Do not call reserve() before every append; that defeats the purpose of planning capacity once.
text/StringBuilding/JoiningStrategies.cpp
auto joinWithStringEditor() -> el::String {
const auto fragments = std::array{
el::String{"元素: "_el},
el::String{"金"_el},
el::String{" ("_el},
el::String{"Au"_el},
el::String{"), 原子番号 "_el},
el::String{"79"_el},
};
auto finalLength = el::ByteCount{};
for (const auto &fragment : fragments) {
finalLength += fragment.length();
}
auto result = el::StringEditor{};
result.reserve(finalLength);
for (const auto &fragment : fragments) {
result.append(fragment);
}
return result;
}
This approach is appropriate when the editor is already needed for later in-place editing.
For pure concatenation of fixed fragments, el::join() expresses the operation directly and performs the same size
planning internally.
Concatenating Known String Fragments
Use el::join(a, b, ...) when all fragments are present at the call site.
The function adds their native lengths, allocates the final result once, and copies each fragment into that allocation.
It avoids both a growable editor and a separate fragment container.
text/StringBuilding/JoiningStrategies.cpp
auto joinFixedFragments() -> el::String {
return el::join("元素: "_el, "金"_el, " ("_el, "Au"_el, "), 原子番号 "_el, "79"_el);
}
The argument count is fixed at the call site; el::join({a, b, ...}) is also supported.
Include <erbsland/StringJoins.hpp> and use strings, editors, or literals of the same encoding.
The result is always a read-only string of that encoding.
If a loop discovers fragments at run time, collect them in a StringList instead of rebuilding a larger result after
every iteration.
Collecting and Joining Dynamic String Fragments
StringList stores String values.
Those values are normally lightweight handles to static, shared, or sliced storage, so collecting them does not copy the
referenced character data.
The list’s own array can reallocate as it grows, but that moves small handles rather than the accumulated text.
join() first calculates the complete output length, including separators, then allocates and fills the result.
This makes it a strong choice for fragments discovered by parsing, filtering, or iteration.
text/StringBuilding/JoiningStrategies.cpp
auto joinWithStringList() -> el::String {
const auto discoveredFragments = std::array{
el::String{"元素: "_el},
el::String{"金"_el},
el::String{" ("_el},
el::String{"Au"_el},
el::String{"), 原子番号 "_el},
el::String{"79"_el},
};
auto fragments = el::StringList{};
for (const auto &fragment : discoveredFragments) {
fragments.append(fragment);
}
return fragments.join();
}
If the approximate fragment count is known and list growth itself matters, reserve list capacity before collecting. That optimization concerns the handle array, not the final character allocation.
Building UTF-8, UTF-16, and UTF-32 with AnyStringBuilder
AnyStringBuilder lets one function target UTF-8, UTF-16, or UTF-32. It accepts input from every supported string
width and converts directly into the configured output.
It can also append integers, floating-point values, and byte blocks using their format objects, avoiding a temporary
formatted string for each value.
The builder caches its current decoded code-point length.
That is useful while constructing layouts because repeatedly asking a StringEditor for its character length can
require scanning variable-width UTF-8 or UTF-16 data.
text/StringBuilding/JoiningStrategies.cpp
void appendElementLabel(el::AnyStringBuilder &builder) {
builder.append(u"元素: "_el)
.append(U'金')
.append(" ("_el)
.append("Au"_el)
.append("), 原子番号 "_el)
.appendInteger(79);
}
auto joinWithAnyStringBuilder() -> el::String {
auto builder = el::AnyStringBuilder::u8();
appendElementLabel(builder);
return builder.takeString();
}
An unreserved builder can still reallocate and copy its accumulated character data while it grows.
Use AnyStringBuilder::u8(ByteCount), u16(U16Count), or u32(CpCount) when the exact native capacity is known.
Use withCapacity() when only a conservative code-point capacity is known for a runtime-selected width.
The complete builder example below demonstrates direct numeric and byte-block formatting, cross-width input and output, and the cached code-point length.
text/AnyStringBuilder/FieldGuideCards.cpp
/// `AnyStringBuilder` efficiently builds strings in memory.
/// It appends integers, floating-point values, byte blocks, and differently
/// encoded text directly. Its cached `length()` avoids rescanning the built
/// text to count decoded code points.
void fieldGuideCards() {
const auto fern = Observation{
.symbol = "🌿"_el,
.species = "Farn im Moos"_el,
.place = "Bachufer · 水辺"_el,
.count = 7,
.moisture = 62.5,
.sampleId = el::ByteBlock{el::Byte{0x0fU}, el::Byte{0xa7U}},
.note = "New leaves curl like tiny green clocks."_el,
};
const auto oak = Observation{
.symbol = "🌳"_el,
.species = "chêne ancien"_el,
.place = "Forêt claire · północ"_el,
.count = 3,
.moisture = 48.25,
.sampleId = el::ByteBlock{el::Byte{0x10U}, el::Byte{0x3cU}},
.note = "Acorns found beside warm limestone."_el,
};
const auto cypress = Observation{
.symbol = "🌲"_el,
.species = "κυπαρίσσι"_el,
.place = "Sun trail · camino del sol"_el,
.count = 12,
.moisture = 31.75,
.sampleId = el::ByteBlock{el::Byte{0x12U}, el::Byte{0xceU}},
.note = "Resin scent after noon rain."_el,
};
// Build a UTF-8 field guide page for display in the terminal. The helper
// also receives UTF-16 input and converts it directly into the target.
auto builder = el::AnyStringBuilder{};
appendFieldGuideCard(builder, fern);
el::io::printLine("First card preview:"_el);
el::io::print(builder.toString());
el::io::printLine("Cached code-point length after first card: "_el, builder.length());
appendFieldGuideCard(builder, oak);
el::io::printLine("Cached code-point length after second card: "_el, builder.length());
// Move the completed page out, then continue with the same builder.
auto guidePage = builder.takeString();
el::io::printLine();
el::io::printLine("Taken field guide draft:"_el);
el::io::print(guidePage);
el::io::printLine("After take: "_el, builder.length());
appendFieldGuideCard(builder, cypress);
el::io::printLine();
el::io::printLine("Reused builder:"_el);
el::io::print(builder.toString());
// Use the same helper with builders that create UTF-16 and UTF-32 strings.
auto u16Builder = el::AnyStringBuilder{el::StringKind::U16};
auto u32Builder = el::AnyStringBuilder{el::StringKind::U32};
appendFieldGuideCard(u16Builder, cypress);
appendFieldGuideCard(u32Builder, cypress);
const auto u16Export = u16Builder.toU16String();
const auto u32Export = u32Builder.toU32String();
el::io::printLine();
el::io::printLine("UTF-16 export:"_el);
el::io::printLine(u16Export);
el::io::printLine("UTF-32 export:"_el);
el::io::printLine(u32Export);
}
void appendFieldGuideCard(el::AnyStringBuilder &builder, const Observation &observation) {
if (!builder.isEmpty()) {
builder.append(U'\n');
}
builder.append("╭─ "_el)
.append(observation.symbol)
.append(U' ')
.append(observation.species)
.append(U' ')
.append(U'─', el::CpCount{53} - observation.species.characterLength() - observation.symbol.characterLength())
.append("╮\n"_el);
builder.append(u"│ Place : "_el).append(observation.place).append(U'\n');
builder.append("│ Count : "_el).appendInteger(observation.count).append(U'\n');
builder.append("│ Moist.: "_el).appendFloat(observation.moisture).append(" %\n"_el);
builder.append("│ Sample: "_el).appendByteBlock(observation.sampleId, el::ByteFormat::compact()).append(U'\n');
builder.append("│ Note : "_el).append(observation.note).append(U'\n');
builder.append(U'╰').append(U'─', el::CpCount{58}).append("╯\n"_el);
}
Output
First card preview:
╭─ 🌿 Farn im Moos ────────────────────────────────────────╮
│ Place : Bachufer · 水辺
│ Count : 7
│ Moist.: 62.5 %
│ Sample: 0fa7
│ Note : New leaves curl like tiny green clocks.
╰──────────────────────────────────────────────────────────╯
Cached code-point length after first card: 239
Cached code-point length after second card: 484
Taken field guide draft:
╭─ 🌿 Farn im Moos ────────────────────────────────────────╮
│ Place : Bachufer · 水辺
│ Count : 7
│ Moist.: 62.5 %
│ Sample: 0fa7
│ Note : New leaves curl like tiny green clocks.
╰──────────────────────────────────────────────────────────╯
╭─ 🌳 chêne ancien ────────────────────────────────────────╮
│ Place : Forêt claire · północ
│ Count : 3
│ Moist.: 48.25 %
│ Sample: 103c
│ Note : Acorns found beside warm limestone.
╰──────────────────────────────────────────────────────────╯
After take: 0
Reused builder:
╭─ 🌲 κυπαρίσσι ───────────────────────────────────────────╮
│ Place : Sun trail · camino del sol
│ Count : 12
│ Moist.: 31.75 %
│ Sample: 12ce
│ Note : Resin scent after noon rain.
╰──────────────────────────────────────────────────────────╯
UTF-16 export:
╭─ 🌲 κυπαρίσσι ───────────────────────────────────────────╮
│ Place : Sun trail · camino del sol
│ Count : 12
│ Moist.: 31.75 %
│ Sample: 12ce
│ Note : Resin scent after noon rain.
╰──────────────────────────────────────────────────────────╯
UTF-32 export:
╭─ 🌲 κυπαρίσσι ───────────────────────────────────────────╮
│ Place : Sun trail · camino del sol
│ Count : 12
│ Moist.: 31.75 %
│ Sample: 12ce
│ Note : Resin scent after noon rain.
╰──────────────────────────────────────────────────────────╯
Comparing String Construction and Allocation Strategies
All four functions in the central example produce the same visible value. Their differences are the time at which the final size is known and whether the abstraction provides editing, collection, or formatted-width-independent behavior.
text/StringBuilding/JoiningStrategies.cpp
void joiningStrategies() {
el::io::printLine("StringEditor ......: "_el, joinWithStringEditor());
el::io::printLine("el::join ..........: "_el, joinFixedFragments());
el::io::printLine("StringList::join ..: "_el, joinWithStringList());
el::io::printLine("AnyStringBuilder ..: "_el, joinWithAnyStringBuilder());
}
Output
StringEditor ......: 元素: 金 (Au), 原子番号 79
el::join ..........: 元素: 金 (Au), 原子番号 79
StringList::join ..: 元素: 金 (Au), 原子番号 79
AnyStringBuilder ..: 元素: 金 (Au), 原子番号 79
Structured Output with StringFormat
Prefer StringFormat when the result has a stable structure with placeholders.
The format object makes alignment, numeric formatting, and escaping explicit and can reuse its parsed pattern.
Use build() when a standalone result is required.
Use appendTo() when adding formatted output to an existing AnyStringBuilder; this avoids creating a temporary
formatted string before appending it.
See Formatting Text with StringFormat for the format language.
When an existing API writes to a TextOutputStream, use
AnyStringBuilderStream as described in
Text Input, Output, and Encoding instead of adapting that stream workflow here.
Complete demo sources
text/StringBuilding/JoiningStrategies.cpp
// Copyright (c) 2026 Tobias Erbsland - https://erbsland.dev
// SPDX-License-Identifier: Apache-2.0
#include <DemoCommon.hpp>
#include <erbsland/AnyStringBuilder.hpp>
#include <erbsland/ByteCount.hpp>
#include <erbsland/StandardStreams.hpp>
#include <erbsland/String.hpp>
#include <erbsland/StringEditor.hpp>
#include <erbsland/StringJoins.hpp>
#include <erbsland/StringList.hpp>
#include <array>
namespace demo {
/// Build text in place when an editor is already the natural working value.
/// Calculate the exact native UTF-8 byte length first and reserve it once, so
/// the append operations do not repeatedly grow and copy the existing text.
auto joinWithStringEditor() -> el::String {
const auto fragments = std::array{
el::String{"元素: "_el},
el::String{"金"_el},
el::String{" ("_el},
el::String{"Au"_el},
el::String{"), 原子番号 "_el},
el::String{"79"_el},
};
auto finalLength = el::ByteCount{};
for (const auto &fragment : fragments) {
finalLength += fragment.length();
}
auto result = el::StringEditor{};
result.reserve(finalLength);
for (const auto &fragment : fragments) {
result.append(fragment);
}
return result;
}
/// Join a fixed set of fragments with one final allocation. `el::join()`
/// calculates the complete native size before it copies any character data.
auto joinFixedFragments() -> el::String {
return el::join("元素: "_el, "金"_el, " ("_el, "Au"_el, "), 原子番号 "_el, "79"_el);
}
/// Collect a dynamic number of lightweight shared strings, then join them.
/// Growing the list can move string handles, while `join()` calculates the
/// final character-storage size once before producing the result.
auto joinWithStringList() -> el::String {
const auto discoveredFragments = std::array{
el::String{"元素: "_el},
el::String{"金"_el},
el::String{" ("_el},
el::String{"Au"_el},
el::String{"), 原子番号 "_el},
el::String{"79"_el},
};
auto fragments = el::StringList{};
for (const auto &fragment : discoveredFragments) {
fragments.append(fragment);
}
return fragments.join();
}
/// Build through a width-independent interface and append formatted values
/// directly. The same helper can target UTF-8, UTF-16, or UTF-32 without first
/// creating a temporary string for the integer.
void appendElementLabel(el::AnyStringBuilder &builder) {
builder.append(u"元素: "_el)
.append(U'金')
.append(" ("_el)
.append("Au"_el)
.append("), 原子番号 "_el)
.appendInteger(79);
}
auto joinWithAnyStringBuilder() -> el::String {
auto builder = el::AnyStringBuilder::u8();
appendElementLabel(builder);
return builder.takeString();
}
/// Compare four construction strategies that produce the same text. The right
/// choice depends on whether the fragment count is fixed, discovered at run
/// time, already being edited, or requires width-independent formatted output.
void joiningStrategies() {
el::io::printLine("StringEditor ......: "_el, joinWithStringEditor());
el::io::printLine("el::join ..........: "_el, joinFixedFragments());
el::io::printLine("StringList::join ..: "_el, joinWithStringList());
el::io::printLine("AnyStringBuilder ..: "_el, joinWithAnyStringBuilder());
}
}
text/AnyStringBuilder/FieldGuideCards.cpp
// Copyright (c) 2026 Tobias Erbsland - https://erbsland.dev
// SPDX-License-Identifier: Apache-2.0
#include <DemoCommon.hpp>
#include <erbsland/AnyStringBuilder.hpp>
#include <erbsland/Byte.hpp>
#include <erbsland/ByteBlock.hpp>
#include <erbsland/ByteFormat.hpp>
#include <erbsland/CpCount.hpp>
#include <erbsland/StandardStreams.hpp>
#include <erbsland/String.hpp>
#include <erbsland/StringKind.hpp>
namespace demo {
struct Observation {
el::String symbol;
el::String species;
el::String place;
int count;
double moisture;
el::ByteBlock sampleId;
el::String note;
};
void appendFieldGuideCard(el::AnyStringBuilder &builder, const Observation &observation);
/// `AnyStringBuilder` efficiently builds strings in memory.
/// It appends integers, floating-point values, byte blocks, and differently
/// encoded text directly. Its cached `length()` avoids rescanning the built
/// text to count decoded code points.
void fieldGuideCards() {
const auto fern = Observation{
.symbol = "🌿"_el,
.species = "Farn im Moos"_el,
.place = "Bachufer · 水辺"_el,
.count = 7,
.moisture = 62.5,
.sampleId = el::ByteBlock{el::Byte{0x0fU}, el::Byte{0xa7U}},
.note = "New leaves curl like tiny green clocks."_el,
};
const auto oak = Observation{
.symbol = "🌳"_el,
.species = "chêne ancien"_el,
.place = "Forêt claire · północ"_el,
.count = 3,
.moisture = 48.25,
.sampleId = el::ByteBlock{el::Byte{0x10U}, el::Byte{0x3cU}},
.note = "Acorns found beside warm limestone."_el,
};
const auto cypress = Observation{
.symbol = "🌲"_el,
.species = "κυπαρίσσι"_el,
.place = "Sun trail · camino del sol"_el,
.count = 12,
.moisture = 31.75,
.sampleId = el::ByteBlock{el::Byte{0x12U}, el::Byte{0xceU}},
.note = "Resin scent after noon rain."_el,
};
// Build a UTF-8 field guide page for display in the terminal. The helper
// also receives UTF-16 input and converts it directly into the target.
auto builder = el::AnyStringBuilder{};
appendFieldGuideCard(builder, fern);
el::io::printLine("First card preview:"_el);
el::io::print(builder.toString());
el::io::printLine("Cached code-point length after first card: "_el, builder.length());
appendFieldGuideCard(builder, oak);
el::io::printLine("Cached code-point length after second card: "_el, builder.length());
// Move the completed page out, then continue with the same builder.
auto guidePage = builder.takeString();
el::io::printLine();
el::io::printLine("Taken field guide draft:"_el);
el::io::print(guidePage);
el::io::printLine("After take: "_el, builder.length());
appendFieldGuideCard(builder, cypress);
el::io::printLine();
el::io::printLine("Reused builder:"_el);
el::io::print(builder.toString());
// Use the same helper with builders that create UTF-16 and UTF-32 strings.
auto u16Builder = el::AnyStringBuilder{el::StringKind::U16};
auto u32Builder = el::AnyStringBuilder{el::StringKind::U32};
appendFieldGuideCard(u16Builder, cypress);
appendFieldGuideCard(u32Builder, cypress);
const auto u16Export = u16Builder.toU16String();
const auto u32Export = u32Builder.toU32String();
el::io::printLine();
el::io::printLine("UTF-16 export:"_el);
el::io::printLine(u16Export);
el::io::printLine("UTF-32 export:"_el);
el::io::printLine(u32Export);
}
void appendFieldGuideCard(el::AnyStringBuilder &builder, const Observation &observation) {
if (!builder.isEmpty()) {
builder.append(U'\n');
}
builder.append("╭─ "_el)
.append(observation.symbol)
.append(U' ')
.append(observation.species)
.append(U' ')
.append(U'─', el::CpCount{53} - observation.species.characterLength() - observation.symbol.characterLength())
.append("╮\n"_el);
builder.append(u"│ Place : "_el).append(observation.place).append(U'\n');
builder.append("│ Count : "_el).appendInteger(observation.count).append(U'\n');
builder.append("│ Moist.: "_el).appendFloat(observation.moisture).append(" %\n"_el);
builder.append("│ Sample: "_el).appendByteBlock(observation.sampleId, el::ByteFormat::compact()).append(U'\n');
builder.append("│ Note : "_el).append(observation.note).append(U'\n');
builder.append(U'╰').append(U'─', el::CpCount{58}).append("╯\n"_el);
}
}