Polyglot
Languages C# strings
C# § strings

Strings

C# provides string (an immutable reference type representing a sequence of UTF-16 code units), StringBuilder (a mutable builder), and a rich set of string operations in System.String. The string type is reference-typed but exhibits value-like behaviour: == compares characters, GetHashCode is content-based, and the type is immutable. The language admits several string-literal forms — regular, verbatim, interpolated, raw — each suited to different use cases. Formatting has evolved from string.Format through composite formatting to interpolation and the modern IFormatProvider-based culture handling.

string and immutability

string instances are immutable: every operation that appears to modify a string returns a new string instead:

string s = "hello";
string t = s.ToUpper();        // returns a new string; s is unchanged
Console.WriteLine(s);           // hello
Console.WriteLine(t);           // HELLO

The immutability has consequences:

  • Strings can be safely shared across threads without synchronisation.
  • Equality comparison compares the contents; two distinct string instances with the same characters are equal.
  • The CLR may intern string literals — store one shared copy of identical literals — so reference equality often holds for literals even though it is not guaranteed.
  • Building a string by concatenation in a loop allocates one new string per iteration; the cost grows quadratically. The defence is StringBuilder.
string a = "hello";
string b = "hello";
bool   eq  = a == b;            // true: content comparison
bool   ref_eq = ReferenceEquals(a, b);  // typically true (interned), but not guaranteed

String literals

Four literal forms:

Regular

"hello"
"line1\nline2"
"quote: \""

Backslash escapes are interpreted. The standard escapes are essentially C’s: \n, \t, \r, \\, \", \', \0, \xHH, \uXXXX, \UXXXXXXXX.

Verbatim

@"line1
line2"

@"C:\Users\alice"        // backslashes are literal
@"quote: """""           // doubled "" represents a single "

The @ prefix turns off backslash escapes; the only escape is the doubled quote. Verbatim literals are conventional for file paths, regular expressions, and any literal with substantial backslash content.

Interpolated

string name = "alice";
int    age  = 30;
string s    = $"{name} is {age} years old";

The $ prefix admits expressions inside {…}. The expressions are evaluated, formatted, and concatenated into the resulting string. Format specifiers may follow a colon:

double pi = 3.14159;
string s = $"pi to two places: {pi:F2}";       // "pi to two places: 3.14"
string h = $"hex: {255:X4}";                    // "hex: 00FF"

Interpolation may be combined with verbatim: $@"…" or @$"…" — both forms are accepted; C# 8 introduced @$"…" and earlier versions required $@"…".

Raw string literals

C# 11 introduced raw string literals, denoted by three or more double quotes:

string json = """
    {
        "name": "alice",
        "age": 30
    }
    """;

string regex = """\d{4}-\d{2}-\d{2}""";

Raw literals interpret no escapes, accept any number of internal quotes (as long as the delimiter has more), and (when multi-line) align the content to the closing delimiter’s indentation. They are the conventional choice for JSON, regular expressions, SQL, and any literal where backslashes or quotes would otherwise need escaping.

Interpolated raw literals combine the two forms:

string s = $$"""
    {
        "name": "{{name}}",
        "age": {{age}}
    }
    """;

The number of $ signs determines how many { braces are needed to begin an interpolation. Two $ means {{…}} is interpolation and { is literal; three $ means {{{…}}}, and so on. The mechanism is the conventional way to build strings that contain literal braces alongside interpolated expressions.

StringBuilder

Building a string by concatenation in a loop allocates one string per iteration; for large outputs the cost is substantial. StringBuilder (in System.Text) is a mutable buffer:

using System.Text;

var sb = new StringBuilder();
for (int i = 0; i < 1000; i++) {
    sb.Append("line ").Append(i).Append('\n');
}
string result = sb.ToString();

The principal operations:

  • Append(value) — append a value’s string representation.
  • AppendLine(value) — append plus a newline.
  • AppendFormat(fmt, args...) — composite formatting, like string.Format.
  • Insert(pos, value) — insert at position.
  • Remove(pos, count) — remove a range.
  • Replace(old, new) — replace all occurrences.
  • Clear() — reset to empty.
  • ToString() — produce the final immutable string.

For building strings of known final size, string.Create (with a span-based callback) is faster still:

string s = string.Create(length, state, (span, st) => {
    /* fill span using st */
});

The construction is rare in user code; the framework uses it internally for the most performance-sensitive paths.

The string operations

The System.String class exposes a substantial set of instance and static methods:

OperationEffect
s.LengthNumber of UTF-16 code units.
s[i]Element access (one code unit).
s.IndexOf(value) / s.LastIndexOf(value)Position of substring or character.
s.Contains(value)Substring containment.
s.StartsWith(value) / s.EndsWith(value)Prefix/suffix tests.
s.Substring(start, length)Returns a sub-string.
s.Replace(old, new)Replaces all occurrences.
s.Trim() / s.TrimStart() / s.TrimEnd()Strip whitespace.
s.ToUpper() / s.ToLower()Case conversion.
s.ToUpperInvariant() / s.ToLowerInvariant()Culture-independent case conversion.
s.Split(separator)Tokenise.
string.Join(sep, parts)Concatenate with a separator.
string.Concat(values)Concatenate without a separator.
string.IsNullOrEmpty(s) / string.IsNullOrWhiteSpace(s)Common null/empty tests.
s.PadLeft(n, ch) / s.PadRight(n, ch)Pad to a width.
s.CompareTo(other) / string.Compare(a, b)Comparison.
s.Equals(other, StringComparison)Comparison with explicit culture/case rules.

The conventional discipline:

  • Use StringComparison.Ordinal for symbolic comparisons (file paths, identifiers, programmatic strings).
  • Use StringComparison.OrdinalIgnoreCase for case-insensitive symbolic comparisons.
  • Use StringComparison.CurrentCulture and CurrentCultureIgnoreCase for user-facing comparisons.
  • Avoid s.ToLower() == t.ToLower() for case-insensitive comparison; the culture-specific ToLower may produce different results in different locales. Use string.Equals(s, t, StringComparison.OrdinalIgnoreCase) instead.

Encoding and the UTF-16 underlying representation

C# strings store UTF-16 code units. A char is a 16-bit value; characters outside the Basic Multilingual Plane (code points above U+FFFF) are represented by a surrogate pair — two char values that together encode one code point.

string s = "🎉";
int    len = s.Length;          // 2: the emoji is a surrogate pair
foreach (char c in s) {
    /* iterates each code unit, not each user-perceived character */
}

// Iterate code points:
TextElementEnumerator enumerator = StringInfo.GetTextElementEnumerator(s);
while (enumerator.MoveNext()) {
    string element = (string)enumerator.Current;
}

The discipline:

  • s.Length is the count of code units, not characters.
  • s[i] is a code unit; for grapheme-aware iteration use StringInfo or Rune (System.Text.Rune, C# 9).
  • Conversion to and from byte arrays uses the Encoding class: Encoding.UTF8.GetBytes(s), Encoding.UTF8.GetString(bytes).
using System.Text;

byte[]   utf8 = Encoding.UTF8.GetBytes("hello, 世界");
string   back = Encoding.UTF8.GetString(utf8);

The conventional encoding choice for new code is UTF-8; legacy code may use the system’s default encoding (Encoding.Default) or a specific code page.

Formatting

C# admits four formatting interfaces:

string.Format

The classic composite formatting:

string s = string.Format("{0} is {1} years old", name, age);
string p = string.Format("pi: {0:F2}", 3.14159);

The format string contains positional placeholders {n[:format]}. The format specifier follows the colon; the same vocabulary as Console.WriteLine’s formatting.

Interpolation

The conventional contemporary form:

string s = $"{name} is {age} years old";
string p = $"pi: {3.14159:F2}";

Interpolation is compile-time-checked (the compiler knows the argument types) and frequently faster than string.Format because the compiler can avoid the parameter-array allocation.

Console.Write and Console.WriteLine

Direct output of formatted strings:

Console.WriteLine($"hello, {name}");
Console.WriteLine("pi: {0:F2}", 3.14);

Both interpolation and composite-formatting forms are supported.

Format specifiers

The format specifier (after the colon) is type-specific. Common cases:

SpecifierTypeEffect
Dintegerdecimal
D5integerdecimal, padded to 5 digits
Xintegerhexadecimal (uppercase)
xintegerhexadecimal (lowercase)
Nnumericgrouped decimal (1,000.00)
Cnumericcurrency (locale-aware)
Pnumericpercent
F2floatingfixed, 2 decimal places
Efloatingscientific
Gfloatinggeneral (the default)
yyyy-MM-ddDateTimeISO date
HH:mm:ssDateTime24-hour time
RDateTimeround-trip format (RFC 1123)
DateTime now = DateTime.Now;
string   ts  = $"{now:yyyy-MM-dd HH:mm:ss}";

Span<char> and the modern parsing/formatting interfaces

Span<char> (C# 7.2 and later) is a stack-only view over contiguous character data:

ReadOnlySpan<char> input = "hello, world".AsSpan();
ReadOnlySpan<char> first = input.Slice(0, 5);      // "hello"; no allocation

The standard library exposes Span<char>-based parsing and formatting that avoids string allocations:

int n;
bool ok = int.TryParse("42".AsSpan(), out n);

Span<char> buffer = stackalloc char[16];
bool       wrote  = n.TryFormat(buffer, out int written);
ReadOnlySpan<char> result = buffer.Slice(0, written);

The Span<char> interface is the high-performance counterpart to the conventional string operations; production code uses it in hot paths where allocation pressure matters.

Comparison and culture

String comparison in C# admits three axes: ordinal vs culture-aware; case-sensitive vs case-insensitive; default culture vs invariant culture.

The conventional choices:

NeedStringComparison
Compare programmatic strings (file paths, identifiers)Ordinal
Compare programmatic strings, case-insensitivelyOrdinalIgnoreCase
Compare user-facing text in the user’s localeCurrentCulture
Compare user-facing text in the user’s locale, case-insensitivelyCurrentCultureIgnoreCase
Compare user-facing text in a culture-independent mannerInvariantCulture
Same, case-insensitivelyInvariantCultureIgnoreCase
bool eq = string.Equals(path1, path2, StringComparison.OrdinalIgnoreCase);
int  cmp = string.Compare(name1, name2, StringComparison.CurrentCulture);

The default s.Equals(t) and s == t use ordinal comparison. The default s.CompareTo(t) uses current culture, which is occasionally surprising — the same comparison may produce different results for different users. Production code typically specifies the comparison explicitly.