What Is a C-Style String Symbolic Constant?

A symbolic constant is a name standing in for a fixed value. When that value is text and you are working with C-style strings, C++ gives you two ways to write one, and they differ in a way that is easy to miss: one of them owns the characters, and the other only knows where they are.

#include <iostream>

int main()
{
    const char showTitle[]{ "Nebula Walk" };  // form 1: an array of const char
    const char* const domeStatus{ "Seated" }; // form 2: a const pointer to a literal

    std::cout << showTitle << '\n';
    std::cout << domeStatus << '\n';

    return 0;
}

Output:

Nebula Walk
Seated

Identical results, different objects. The rest of this lesson is about what that difference costs you and where it shows up.

Two Forms, Two Storage Stories

Every string literal you write is compiled into the program as a block of characters with a null terminator, parked somewhere the program can read but not write. What you build on top of that block is what the two forms disagree about.

const char showTitle[]{ "Nebula Walk" }; const char* const domeStatus{ "Seated" };
What the name is an array of 12 const char a pointer holding one address
Where the characters live in the array, copied in from the literal in the literal, wherever the compiler put it
What sizeof reports the size of the whole array the size of a pointer
What the definition asks for a second copy of the text an address

sizeof makes the difference visible, because it reports the size of the object rather than the length of the text:

#include <iostream>

int main()
{
    const char showTitle[]{ "Nebula Walk" };
    const char* const domeStatus{ "Seated" };

    std::cout << sizeof(showTitle) << '\n';  // the array is the characters
    std::cout << sizeof(domeStatus) << '\n'; // the pointer is not

    return 0;
}

Output:

12
8

Twelve is eleven visible characters plus the terminator, and it does not change no matter what the array holds. Eight is whatever a pointer weighs on a 64-bit build, and it would still be eight if domeStatus pointed at a thousand characters.

That is why the array form is the wasteful one for a constant. The literal has to exist so the array can be initialized from it, and then the array is a second description of the same text, which nothing will ever modify. An optimizer can often collapse the two, but the definition is asking for a copy, whereas the pointer form asks only for an address.

Two consts, Two Jobs
In const char* const domeStatus, the first const says the characters cannot be written through this pointer, and the second says the pointer cannot be aimed somewhere else afterwards. Drop the second and you still have a constant string, but a variable that can be repointed. Drop the first and the code stops compiling, because a string literal has type const char[N] and C++ has not let one initialize a plain char* since C++11.

The following is that second mistake, and it does not compile:

#include <iostream>

int main()
{
    char* domeStatus{ "Seated" }; // rejected: the literal is const char[7], not char*

    std::cout << domeStatus << '\n';

    return 0;
}

Identical Literals May Share One Address

Because literals are constant and nothing can tell them apart, the compiler is free to emit one block of characters and point every identical literal at it. Whether it does so is up to the implementation, but it is a common and easy optimization:

#include <iostream>

int main()
{
    const char* marquee1{ "Sold Out" };
    const char* marquee2{ "Sold Out" };

    std::cout << (marquee1 == marquee2 ? "one shared literal" : "two separate literals") << '\n';

    return 0;
}

On the platform runner this prints:

one shared literal

Two arrays initialized from the same literal would never behave this way, because each array is a distinct object with its own storage and its own address. Sharing is only available to the form that stores an address.

Warning
Do not lean on this either way. Comparing two const char* values with == compares addresses, not text, so the result tells you whether the compiler happened to merge the literals rather than whether the strings match. Use std::strcmp, or better, compare std::string_view values, which compare by content.

What auto Deduces From a Literal

Type deduction follows the same array-versus-pointer split, and sizeof again shows which one you got:

#include <iostream>

int main()
{
    auto caption1{ "Aurora Night" };  // deduced as const char*
    auto* caption2{ "Aurora Night" }; // deduced as const char*
    auto& caption3{ "Aurora Night" }; // deduced as const char(&)[13]

    std::cout << sizeof(caption1) << ' ' << sizeof(caption2) << ' ' << sizeof(caption3) << '\n';

    return 0;
}

Output:

8 8 13

Plain auto and auto* both decay the literal to a pointer, so both report a pointer's size. auto& binds a reference directly to the literal's array type, keeping the array intact, so it reports all thirteen bytes: twelve characters plus the terminator.

Why Streams Treat char Pointers Differently

Hand std::cout an array of int and an array of char and you get two completely different kinds of answer:

#include <iostream>

int main()
{
    int seatRows[]{ 6, 9, 14 };
    const char showTitle[]{ "Nebula Walk" };
    const char* const domeStatus{ "Seated" };

    std::cout << seatRows << '\n';   // decays to int*, so the stream prints an address
    std::cout << showTitle << '\n';  // decays to const char*, so the stream prints characters
    std::cout << domeStatus << '\n'; // already a const char*, same treatment

    return 0;
}

The first line is a hexadecimal address, and since the program is given a fresh stack every time it starts, that line is different on every run and not worth quoting. The two lines after it are the same every time:

Nebula Walk
Seated

The reason is that operator<< has a dedicated overload for const char* that walks the characters to the null terminator and prints them, while every other object pointer ends up at the overload that prints an address. The stream is guessing what you meant, and it makes the guess purely from the static type of what you handed it. A char* means "text" to a stream, and nothing else does.

When That Assumption Backfires

The guess is right almost every time, which is exactly what makes the exception dangerous. Consider a programmer who wants to see where a char variable lives. This program is broken:

#include <iostream>

int main()
{
    char rowLetter{ 'J' };

    std::cout << &rowLetter << '\n';

    return 0;
}

&rowLetter has type char*, so the stream takes it for a C-style string and starts printing characters. It prints the J, then keeps walking through whatever memory follows the variable, stopping only when it happens across a zero byte. That is undefined behavior: rowLetter is a single character, not a null-terminated string, and there is no promise about what sits after it.

What you see depends entirely on the bytes that happen to be there. It may look harmless, printing one character and stopping because the next byte was already zero. It may spill garbage. It may run off the end of the accessible memory and crash. None of those outcomes is more correct than the others, and a run that looks fine today proves nothing about tomorrow.

Asking for the Address Instead

When the address really is what you want, cast the pointer to const void* first. That takes it out of the const char* overload's reach and into the one that prints addresses:

#include <iostream>

int main()
{
    const char* const domeStatus{ "Seated" };

    std::cout << domeStatus << '\n';                           // the characters
    std::cout << static_cast<const void*>(domeStatus) << '\n'; // the address

    return 0;
}

The first line is stable:

Seated

The second is an address in hexadecimal, which depends on where the program was loaded, so there is nothing useful to quote. Reach for this cast whenever you want to know whether two char pointers refer to the same storage, or while debugging a pointer whose text you do not trust.

Reach for constexpr std::string_view

Everything above describes machinery you rarely need to operate. A std::string_view is also a non-owning view of characters, but it carries its own length instead of hunting for a terminator, and it is a distinct type rather than a pointer wearing a special meaning:

#include <iostream>
#include <string_view>

int main()
{
    constexpr std::string_view showTitle{ "Nebula Walk" };
    constexpr std::string_view domeStatus{ "Seated" };

    std::cout << showTitle << '\n';
    std::cout << domeStatus << '\n';
    std::cout << showTitle.length() << '\n';

    return 0;
}

Output:

Nebula Walk
Seated
11

There is no copy of the characters, the length is available without scanning, comparison compares text rather than addresses, and nothing about the type invites a stream to guess. It is also typically as fast as the pointer form or faster, since a length beats a search for a terminator.

Best Practice
Write string constants as constexpr std::string_view. Keep C-style string constants for the cases that force them on you, chiefly interfaces to C libraries that expect a const char*.

Key Terminology

Symbolic constant: a name that stands in for a fixed value, so the value is written once and referred to by name everywhere else.

String literal: text in quotes, compiled into the program as an array of const char ending in a null terminator, stored in memory the program may read but not write.

Literal pooling: the optimization of emitting one block of characters for several identical string literals and pointing all of them at it.

Decay: the conversion that turns an array into a pointer to its first element, which is what makes std::cout << showTitle a const char* call.

Summary

Two forms: const char showTitle[]{ "..." } builds an array that owns a copy of the characters; const char* const domeStatus{ "..." } stores only the address of the literal.

Which to prefer of the two: the pointer form, since the array form asks for the text to exist twice, while the pointer form asks for an address.

Both consts matter: the first keeps the characters read-only, the second keeps the pointer from being re-aimed. A plain char* cannot be initialized from a literal at all.

Shared literals: identical string literals may be merged into one block of characters, so two const char* values can compare equal. This is implementation-defined and is never a reason to compare strings with ==.

Type deduction: auto and auto* both deduce const char* from a literal because the array decays; auto& binds to the array itself and deduces const char(&)[N].

Stream behavior: operator<< prints characters for char* and const char* and an address for every other object pointer. It decides from the static type alone.

The trap: streaming &someChar looks like a request for an address but is read as a string, and walks past the variable until it finds a zero byte. That is undefined behavior, whatever it happens to print.

Printing an address: static_cast<const void*>(pointer) selects the address-printing overload.

Modern practice: use constexpr std::string_view for string constants. It knows its own length, compares by content, copies nothing, and has none of the pointer quirks above.