Strings, StringBuilder and Span<T>
Immutable strings, ordinal against culture-aware comparison, building text without quadratic copying, and slicing memory with Span<T> instead of copying it.
After this lesson you can
- Explain why string methods return new strings and when to switch to StringBuilder
- Pick ordinal or culture-aware comparison deliberately, and name the Turkish-I problem
- Slice an array or a string with Span<T> and ReadOnlySpan<char> without allocating
Strings are immutable
A string never changes after it is created. ToUpper, Replace,
Trim and Substring all return a new string and leave the
original alone. Calling one without using its result does nothing.
That also explains the cost of building text with += in a loop:
every step copies everything so far into a new string, so building a
string of n pieces copies on the order of n² characters.
StringBuilder keeps a growable buffer and appends in place; call
ToString() once at the end. For a handful of pieces, plain
concatenation or interpolation is fine and clearer.
Comparing strings
There are two families of comparison:
- Ordinal compares the UTF-16 code units. It is fast, predictable, and the right choice for identifiers, keys, file names, protocol values: anything a machine reads.
- Culture-aware follows the linguistic rules of a culture. It is right for sorting text shown to people, and wrong almost everywhere else, because the result depends on the culture the code runs under.
The famous trap is Turkish, where lowercase i upper-cases to a dotted
İ and uppercase I lower-cases to a dotless ı:
using System.Globalization;using System.Text; public static class Solution{ public static string Describe() { string s = "hello"; s.ToUpper(); // result thrown away string upper = s.ToUpper(); var tr = new CultureInfo("tr-TR"); string turkish = "title".ToUpper(tr); string invariant = "title".ToUpperInvariant(); bool ordinalIgnore = string.Equals("FILE", "file", StringComparison.OrdinalIgnoreCase); bool trIgnore = string.Compare("TITLE", "title", tr, CompareOptions.IgnoreCase) == 0; var sb = new StringBuilder(); for (int i = 0; i < 3; i++) sb.Append(i).Append(';'); sb.Length--; // drop the last separator return $"s={s} upper={upper} | tr upper={turkish} invariant upper={invariant} | " + $"OrdinalIgnoreCase FILE/file={ordinalIgnore} tr IgnoreCase TITLE/title={trIgnore} | sb={sb}"; }}s.ToUpper() on its own changed nothing. Under the Turkish culture,
"title" upper-cases to TİTLE, and a culture-aware case-insensitive
comparison says TITLE and title are different words. Code that
checks if (command.ToUpper() == "TITLE") breaks for Turkish users.
Say what you mean: string.Equals(a, b, StringComparison.OrdinalIgnoreCase), ToUpperInvariant(), and
StringComparer.Ordinal for dictionaries and sorting.
Span<T>: a window onto memory
Span<T> is a view of a contiguous region (part of an array, a
stackalloc buffer, native memory) as a pointer plus a length.
Slicing a span creates another view; nothing is copied. Writing
through a span writes into the memory underneath.
ReadOnlySpan<char> does the same for strings, so parsing code can
pick out pieces of a string without allocating a Substring for each,
and APIs such as int.Parse accept the slice directly.
public static class Solution{ public static string Describe() { int[] numbers = { 1, 2, 3, 4, 5 }; Span<int> middle = numbers.AsSpan(1, 3); // a view of elements 1..3, no copy middle[0] = 99; middle.Reverse(); string date = "2024-06-15"; ReadOnlySpan<char> span = date; int dash = span.IndexOf('-'); int year = int.Parse(span.Slice(0, dash)); // parses "2024" without a Substring ReadOnlySpan<char> rest = span.Slice(dash + 1); // "06-15", still the same memory int month = int.Parse(rest.Slice(0, rest.IndexOf('-'))); Span<char> buffer = stackalloc char[5]; "abcde".AsSpan().CopyTo(buffer); buffer[0] = 'X'; return $"array now {string.Join(",", numbers)} | year={year} month={month} rest={rest.ToString()} | buffer={buffer.ToString()}"; }}The span covered elements 1 to 3 of the array; writing 99 and reversing
the span rearranged the array itself. The year and month were parsed
straight from slices of the string, and stackalloc gave a buffer on
the stack that
needs no garbage collection.
Span<T> is a ref struct: it may only live on the stack. The
compiler forbids storing it in a field of a class, boxing it, capturing
it in a lambda, or keeping it across an await. When you need a slice
that can live on the heap, use Memory<T> and take .Span when you
work with it.
Common mistakes
- Ignoring the result of a string method.
s.Trim();alone is a no-op, ass.ToUpper()showed above. +=in a loop over many pieces. Quadratic copying; useStringBuilderorstring.Join.- Culture-sensitive comparison for machine data.
ToUpper(),ToLower()andCompareTouse the current culture; prefer theInvariantandOrdinalvariants for keys and protocol values. - does not compile: the indexer is read-only. Copy the characters into a (or a buffer), edit them through a , and make one new string at the end, as the exercise does.
Exercise · Pro
Reverse the words of a sentence inside one buffer
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade