A Zoo of Strings in Your C++ Code

C++ offers a bewildering variety of string types, from raw char* to std::string_view to game engine specializations like FString and StringID. This article uses animal metaphors to guide you through choosing the right string type for each situation.

If you've ever worked on a large C++ project, you've probably encountered a bewildering variety of string types. Raw pointers, standard library strings, framework-specific strings, hash-based identifiers — the list goes on. Let's take a tour through this zoo and understand when each "animal" is the right choice.

String zoo header

char* — The Wild Wolf

The most primitive string type: a pointer to a byte of data in memory. It's fast, lightweight, and dangerously unpredictable. There's no size tracking, no bounds checking, and no automatic memory management. One wrong move and you have a buffer overflow, a dangling pointer, or a use-after-free bug.

Despite its dangers, char* remains alive because it has virtually zero overhead and provides maximum control. It's the foundation everything else is built upon.

char* illustration

char[N] — The Turtle

Fixed-size stack-allocated buffers. They're simple, predictable, and centuries old in programming terms. You know exactly how much memory they use, they never allocate on the heap, and they're perfect for small, fixed-size data like file paths or configuration keys.

The downside is obvious: fixed size means wasted space or truncation. But in embedded systems and performance-critical code, the turtle still wins races.

char[N] illustration

String Literals — The Fish in a Glass

String literals live in the .rodata section of your binary. They exist for the entire program lifetime, require no memory management, and are completely immutable. They're the safest strings you can have — as long as you never try to modify them.

String literals illustration

std::string — The Domestic Dog

std::string is the workhorse of C++ string handling. It provides RAII, automatic memory management, and a rich API. But convenience comes at a cost: heap allocations, potential iterator invalidation, and sometimes surprising performance characteristics.

The biggest trap is implicit conversions. When you pass a const char* to a function expecting const std::string&, a temporary string is created, triggering a heap allocation:

bool compare(const std::string& s1, const std::string& s2) {
    return s1 == s2;
}

std::string str = "hello";
compare(str, "world"); // Creates a temporary std::string, allocation!
std::string illustration

std::string_view — The Thin Cat

Introduced in C++17, std::string_view is a non-owning reference to a string. It eliminates unnecessary copies by simply pointing to existing data. It works with any string type — literals, std::string, char*:

std::string str = "this is my input string";
std::string_view sv(&str[11], 2); // "my" — no copying

void print(std::string_view sv) {
    std::cout << sv;
}

print("literal");           // OK
print(std::string("str"));  // OK
print(some_char_ptr);       // OK

The danger: string_view doesn't own its data. If the underlying string is destroyed, your view becomes a dangling reference.

std::string_view illustration

std::pmr::string — The Overeater

A polymorphic variant of std::string that uses memory resources, allowing flexible allocator strategies at runtime. Instead of being tied to a compile-time allocator, you can swap memory strategies through virtual functions:

class memory_resource {
public:
    void* allocate(size_t bytes, size_t alignment);
    void deallocate(void* ptr, size_t bytes, size_t alignment);
    bool is_equal(const memory_resource& other) const;
private:
    virtual void* do_allocate(size_t, size_t) = 0;
    virtual void do_deallocate(void*, size_t, size_t) = 0;
    virtual bool do_is_equal(const memory_resource&) const = 0;
};

namespace pmr {
    using string = std::basic_string<char, std::char_traits<char>,
                                     polymorphic_allocator<char>>;
}
std::pmr::string illustration

QString — The Chatty Parrot

Qt's string class uses UTF-16 internally, which gives it rich Unicode support but creates compatibility headaches when interfacing with the rest of the C++ world, which mostly speaks UTF-8. It's feature-rich — regular expressions, splitting, formatting — but ties you firmly to the Qt ecosystem.

QString illustration

NSString — The Unique Zoo Specimen

Apple's string type from Objective-C. Immutable by design, with bidirectional CFStringRef casting. It lives in a completely separate ecosystem and requires toll-free bridging to interact with C++ code.

NSString illustration

std::wstring — The Evolutionary Mistake

Wide-character strings sound good in theory but suffer from a fundamental flaw: wchar_t is 16 bits on Windows and 32 bits on Linux. This means std::wstring code that works on one platform may break on another. Converting between wide and narrow strings adds more confusion:

// UTF-8 string
std::string utf8 = u8"Hello, world! 🌍";

// Convert UTF-8 -> UTF-16 (std::wstring)
std::wstring_convert<std::codecvt_utf8_utf16<wchar_t>> converter;
std::wstring wide = converter.from_bytes(utf8);

// Convert UTF-16 -> UTF-8
std::string utf8_again = converter.to_bytes(wide);
std::wstring illustration

FrameString — The Mayfly

In game engines, some strings only live for a single frame. Using arena allocators, these strings are allocated from a pre-reserved block of memory and freed all at once when the frame ends — zero individual deallocations:

class ArenaAllocator {
    char buffer[10 * 1024 * 1024]; // 10 MB
    size_t offset = 0;

public:
    char* alloc(size_t n) {
        char* ptr = buffer + offset;
        offset += n;
        return ptr; // Instant!
    }

    void reset() { offset = 0; } // Reset in O(1)
};
String debugMsg(framemem_ptr);
debugMsg = "Player position: ";
debugMsg += to_string(x);
debugMsg += ", ";
debugMsg += to_string(y);
// At the end of the frame, all memory is automatically freed
FrameString illustration

FString — The Golden Retriever (Unreal Engine)

Unreal Engine's own string type, feature-rich and deeply integrated into the engine's ecosystem. It provides excellent tooling but makes your code entirely dependent on the Unreal runtime.

FString illustration

StringAtom — The Immortal Turtle

String interning stores each unique string exactly once in a global table and returns an ID or pointer. Subsequent comparisons become pointer equality checks — O(1) instead of O(n):

StringAtom healthTag("Health");
StringAtom manaTag("Mana");

// Comparison is instant — just ID or pointer equality
if (component.tag == healthTag) {
    // ...
}
StringAtom illustration

StringID — The Sprinting Cheetah

The ultimate optimization: convert strings to hash values at compile time. No storage, no table lookups — just a 32-bit integer. This even enables using strings in switch statements:

constexpr uint32_t operator""_sid(const char* str, size_t len) {
    return FNV1a(str);
}

constexpr uint32_t damageEvent = "DamageEvent"_sid;
switch(messageType) {
    case "PlayerDied"_sid:
        handlePlayerDeath();
        break;
    case "EnemySpawned"_sid:
        handleEnemySpawn();
        break;
}
StringID illustration

Conclusion

There is no single "best" string type in C++. Each has its niche: std::string for general use, string_view for zero-copy references, StringID for performance-critical comparisons, and framework-specific types when you're locked into an ecosystem. The key is to understand the trade-offs and choose the right tool for each job rather than forcing one solution everywhere.

Conclusion illustration Summary table Final illustration