📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

When should you use `ReadOnlySpan<T>` instead of `Span<T>`, and what modern features build on it? Explain read-only views over strings and constant data, u8 literals, static ReadOnlySpan properties backed by assembly data, SearchValues, overload preference, and the C# 14 first-class span conversions.

ReadOnlySpan<T>: read-only views, constant data, u8 literals, SearchValues and C# 14 span conversions.

Mid-Level Theory Advanced csharp-14 performance readonlyspan span utf8

🎯 Key points

  • ReadOnlySpan<T> has the same layout and ref struct rules as Span<T> but prevents writes
  • Use ReadOnlySpan<T> for inputs and Span<T> for output buffers
  • It is the only span type that can view strings and constant assembly data
  • Static ReadOnlySpan<byte> properties with constant initialisers allocate nothing
  • u8 literals give compile-time UTF-8 ReadOnlySpan<byte> values
  • SearchValues<T> (.NET 8) precomputes fast IndexOfAny searches and should be static
  • C# 14 first-class span conversions let arrays and strings convert implicitly, including for extension receivers
  • Span overloads are preferred in overload resolution, so adding them benefits callers

⏱ 30-second answer

ReadOnlySpan<T> is the read-only version of Span<T>: same layout and ref struct rules, but the indexer returns ref readonly, so callers cannot modify the memory. Use it for inputs to parsing, searching and hashing, and Span<T> only for outputs you write to. It is the only span type that can view a string (text.AsSpan()) and constant data: static ReadOnlySpan<byte> Magic => [0x50, 0x4B, 0x03, 0x04]; points straight at data in the assembly with no allocation, and "GET "u8 gives UTF-8 bytes encoded at compile time. .NET 8's SearchValues<T> precomputes fast lookups for IndexOfAny. C# 14 makes span conversions first-class, so arrays and strings convert implicitly in more places, including extension method receivers and generic inference.

🗣 Full answer (senior)

How I would answer this in an interview

Use ReadOnlySpan<T> whenever code only reads the data — which is most of the time. It accepts more sources (strings, constant data, read-only memory, ImmutableArray<T>), documents intent, prevents accidental writes, and unlocks compiler optimisations. Use Span<T> only when the method must write into the buffer. I'd cover read-only views over strings and constant data, u8 literals, static ReadOnlySpan<byte> properties, SearchValues, overload preference, and C# 14's first-class span conversions.

1. Read-only views over strings

static bool IsValidPostcode(ReadOnlySpan<char> s) => s.Length == 4 && s.IndexOfAnyExceptInRange('0', '9') < 0;

IsValidPostcode("2000");                 // string -> ReadOnlySpan<char>, no allocation
IsValidPostcode(input.AsSpan(5, 4));     // slice of a larger string
IsValidPostcode(charArray);              // char[] works too

Strings are immutable, so they can only be viewed as ReadOnlySpan<char> — a method taking Span<char> couldn't accept a string at all. Accepting ReadOnlySpan<char> makes an API work with strings, arrays, stack buffers and slices.

2. Constant data, u8 literals and static properties

ReadOnlySpan<byte> contentType = "application/json"u8;     // UTF-8 bytes baked into the assembly

private static ReadOnlySpan<byte> PngSignature => [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
private static ReadOnlySpan<char> Hex => "0123456789ABCDEF";
  • A static ReadOnlySpan<byte> property returning a constant array/collection expression of primitives compiles to a span pointing directly into the assembly's data section — no allocation, no static array, no initialisation. (It must be a property returning ReadOnlySpan<T>, not a field, because spans can't be stored in static fields.) Since .NET 7/C# 11 this works for all primitive element types (with correct endianness handling), not just bytes.
  • "..."u8 literals (C# 11) produce ReadOnlySpan<byte> UTF-8 data at compile time — ideal for HTTP headers, JSON property names with Utf8JsonWriter, protocol constants.
  • These are read-only by necessity: the memory is part of the loaded image.

3. SearchValues

private static readonly SearchValues<char> CsvSpecial = SearchValues.Create(",\"\r\n");
private static readonly SearchValues<string> BannedWords = SearchValues.Create(["drop", "delete"], StringComparison.OrdinalIgnoreCase);  // .NET 9

bool needsQuoting = value.AsSpan().ContainsAny(CsvSpecial);
int index = text.AsSpan().IndexOfAny(BannedWords);

SearchValues (.NET 8) precomputes an optimised, often vectorised strategy for repeated searches over ReadOnlySpan<T> — much faster than naive loops or IndexOfAny(char[]) for repeated use. Store them in static readonly fields.

4. Overload preference

When APIs have overloads for string, T[], ReadOnlySpan<T> and Span<T>:

  • Passing an array or span to an API with both Span<T> and ReadOnlySpan<T> overloads picks the read-only one when the method only reads (API authors usually provide just ReadOnlySpan<T> for input).
  • Collection expressions and params ReadOnlySpan<T> (C# 13) prefer span overloads, avoiding array allocations.
  • [OverloadResolutionPriority] (C# 13) lets library authors steer callers to new span overloads without breaking existing ones.

API guideline: input buffers → ReadOnlySpan<T>, output buffers → Span<T> (with a TryXxx(..., out int written) pattern).

5. C# 14 first-class span conversions

Before C# 14, spans weren't first-class in the type system: conversions like T[] → ReadOnlySpan<T> were user-defined implicit operators, so they didn't work for extension method receivers, type inference or some generic scenarios:

int[] numbers = [1, 2, 3];
numbers.ContainsAny(...)    // MemoryExtensions on ReadOnlySpan<T> - previously needed .AsSpan()

C# 14 adds implicit span conversions recognised by the language: T[] → Span<T>/ReadOnlySpan<T>, Span<T> → ReadOnlySpan<T>, string → ReadOnlySpan<char>, including covariant conversions for reference-type elements (string[] → ReadOnlySpan<object>). These participate in extension method lookup and generic type inference, so span-based extension methods work directly on arrays and strings, and overload resolution prefers span overloads more consistently. (Some existing code saw overload changes — e.g. Enumerable vs MemoryExtensions methods on arrays — which the .NET team mitigated with priorities.)

6. A production example

An HTTP middleware compared request header values with strings and wrote constant headers via Encoding.UTF8.GetBytes("...") per request. Converting constants to "..."u8 and static ReadOnlySpan<byte> properties, header checks to ReadOnlySpan<char> with SearchValues, and the API signatures to accept ReadOnlySpan<char> removed per-request allocations from the middleware and made the code accept slices without copying.

🔍 What the interviewer is checking

The interviewer checks whether you know when read-only spans are the right type and the modern features around them.

Green flags

  • Uses ReadOnlySpan<T> for inputs and Span<T> for outputs.
  • Knows static ReadOnlySpan<byte> properties point at assembly data.
  • Mentions u8 literals and SearchValues.
  • Aware of C# 14 first-class span conversions and their impact.

Red flags

  • Uses mutable static arrays for constants.
  • Writes to string memory through unsafe tricks.
  • Has not heard of SearchValues while claiming performance expertise.

📚 Learn in depth

1. How static ReadOnlySpan properties avoid allocation

private static ReadOnlySpan<byte> Magic => [0x50, 0x4B, 0x03, 0x04];

The compiler emits the bytes into a static data blob in the PE file (<PrivateImplementationDetails>) and the getter returns new ReadOnlySpan<byte>(pointer, 4) (via RuntimeHelpers.CreateSpan<T> for non-byte types, handling alignment and endianness). No heap array is ever created. If you write static readonly byte[] Magic = { ... };, you get a real array allocated at type initialisation — mutable and on the heap.

Pitfall: returning a span over a new array (=> new byte[] { ... }) allocates every call; the compiler optimisation applies to constant initialisers directly in the expression.

2. Read-only vs mutable conversions

  • Span<T> → ReadOnlySpan<T>: implicit (safe).
  • ReadOnlySpan<T> → Span<T>: not allowed (would allow writes). MemoryMarshal.CreateSpan(ref MemoryMarshal.GetReference(ro), ro.Length) can do it unsafely — writing to string or constant data this way is undefined behaviour.

3. UTF-8 literals

"abc"u8 is a ReadOnlySpan<byte> (not a constant), with a null terminator stored after the data (not included in length) for interop convenience. It can't be interpolated. Utf8JsonWriter.WriteString("name"u8, value) avoids transcoding property names.

4. SearchValues internals

SearchValues.Create analyses the set: small sets use vectorised IndexOfAny with packed comparisons; ASCII sets use bitmaps with SIMD shuffles; ranges use range checks; SearchValues<string> (.NET 9) uses algorithms like Aho-Corasick/Teddy for multi-substring search. The result is orders of magnitude faster than naive loops on large inputs.

5. First-class spans and overload resolution changes

With C# 14 span conversions, calls like array.Contains(x) may bind to MemoryExtensions.Contains(ReadOnlySpan<T>, T) instead of Enumerable.Contains, which is usually faster but could differ in edge cases (e.g. custom equality comparers). The BCL added [OverloadResolutionPriority] and specific overloads to keep behaviour consistent.

Try it yourself

  1. Compare IL for static ReadOnlySpan<byte> P => [1,2,3]; vs static readonly byte[] P = {1,2,3};.
  2. Benchmark IndexOfAny(char[]) vs SearchValues<char> on long strings.
  3. Write an API taking ReadOnlySpan<char> and call it with a string, slice and stackalloc buffer.
  4. With C# 14, call a MemoryExtensions method directly on an array without AsSpan().

💻 Code

using System.Buffers;
using System.Text;

public static class Html
{
    // Built once; IndexOfAny uses an optimised (often vectorised) search
    private static readonly SearchValues<char> Special = SearchValues.Create("<>&\"'");

    public static string Escape(string input)
    {
        ReadOnlySpan<char> text = input;                     // string -> ReadOnlySpan<char>
        int first = text.IndexOfAny(Special);
        if (first < 0) return input;                         // common case: no allocation

        var sb = new StringBuilder(input.Length + 16);
        while (!text.IsEmpty)
        {
            int i = text.IndexOfAny(Special);
            if (i < 0) { sb.Append(text); break; }
            sb.Append(text[..i]);
            sb.Append(text[i] switch { '<' => "&lt;", '>' => "&gt;", '&' => "&amp;", '"' => "&quot;", _ => "&#39;" });
            text = text[(i + 1)..];
        }
        return sb.ToString();
    }
}

public static class FileSniffer
{
    // Points at data embedded in the assembly: no allocation, cannot be modified
    private static ReadOnlySpan<byte> PngSignature => [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
    private static ReadOnlySpan<byte> PdfSignature => "%PDF-"u8;

    public static string Detect(ReadOnlySpan<byte> header) => header switch
    {
        _ when header.StartsWith(PngSignature) => "image/png",
        _ when header.StartsWith(PdfSignature) => "application/pdf",
        _ => "application/octet-stream",
    };
}

public static class SpanExtensions
{
    public static int CountOf<T>(this ReadOnlySpan<T> span, T value) where T : IEquatable<T>
    {
        int count = 0;
        foreach (T item in span) if (item.Equals(value)) count++;
        return count;
    }
}

public static class Program
{
    public static void Main()
    {
        Console.WriteLine(Html.Escape("Tom & Jerry <3"));        // Tom &amp; Jerry &lt;3
        byte[] pdf = [.. "%PDF-1.7"u8];
        Console.WriteLine(FileSniffer.Detect(pdf));               // application/pdf

        int[] ratings = [5, 3, 5, 4, 5];
        Console.WriteLine(ratings.CountOf(5));                    // 3: C# 14 first-class span conversion
    }
}

⚠ Common mistakes

1. Taking Span<T> for read-only input

What happens: parsing methods declared with Span<char> parameters. Why it is wrong: can't accept strings or read-only data; implies mutation. Do instead: ReadOnlySpan<T> for inputs.

2. static readonly byte[] for constant tables

What happens: constant lookup tables as arrays. Why it is wrong: allocated at start-up and mutable. Do instead: static ReadOnlySpan<T> properties with constant initialisers.

3. Returning spans over new arrays from properties

What happens: static ReadOnlySpan<byte> P => new byte[] { ... } with non-constant content. Why it is wrong: allocates each access. Do instead: constant initialisers directly, or cache a static array.

4. Encoding constant strings per request

What happens: Encoding.UTF8.GetBytes("Content-Type") in hot paths. Why it is wrong: allocation and work each time. Do instead: "Content-Type"u8.

5. Creating SearchValues per call

What happens: SearchValues.Create(...) inside methods. Why it is wrong: expensive set-up repeated. Do instead: cache in static readonly fields.

6. Writing through casts of read-only spans

What happens: MemoryMarshal used to write into string or constant data. Why it is wrong: undefined behaviour; can corrupt interned strings or crash. Do instead: copy into a writable buffer.

7. Ignoring overload changes with C# 14

What happens: upgrading language version changes which overload is called. Why it is wrong: subtle behavioural differences in edge cases. Do instead: run tests after upgrades; be explicit where semantics matter.

8. Using span APIs in non-hot code for no reason

What happens: span-based rewrites of rarely executed code. Why it is wrong: complexity without measurable gain. Do instead: focus on measured hot paths.

🏛 Senior insight: trade-offs

ReadOnlySpan<T> is the universal input type of modern .NET: one overload accepts arrays, slices, strings, stack buffers and embedded constants. Designing APIs around it gives callers flexibility and avoids allocations without exposing mutation.

API design question Answer
Input data, synchronous ReadOnlySpan<T>
Output buffer Span<T> with a TryX(..., out int written) pattern
Needs to be stored or awaited ReadOnlyMemory<T>
Repeated character or byte search static SearchValues<T>
Constant tables static ReadOnlySpan<T> properties

C# 14's first-class spans continue the direction: span-based APIs become the natural path, and upgrades can change overload choices, which a senior engineer tests rather than assumes.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate slices strings with AsSpan. Why: Checks ReadOnlySpan<char> for strings.
    How to answer

    ReadOnlySpan<char> is a view over contiguous characters, which can be a string, an array or a stack buffer, made of a reference plus a length. Slicing it is free (no new string), and almost every parsing API now takes spans, so I can split a CSV line, trim fields and parse numbers and dates with zero allocations until I really need a string. It's a ref struct, so it can't be stored in a class field, boxed, captured by a lambda or held across an await. For those cases I use ReadOnlyMemory<char> and call .Span when processing. I reach for it in measured hot paths such as parsers, protocol code and high-volume ingestion.

  2. When: Candidate distinguishes inputs and outputs. Why: Compare with writable spans.
    How to answer

    Span<T> is a ref struct holding a reference and a length: a typed, bounds-checked view over contiguous memory, whether that memory is an array, a stackalloc buffer, native memory or part of a string (through ReadOnlySpan<char>). Slicing (span[2..6], Slice) creates a new view without copying, so parsers and formatters can work on pieces of a buffer with no allocations. Because it can point at the stack, it is stack-only: it cannot be a field of a class, be boxed, be captured by a lambda, or live across await/yield (C# 13 allows span locals in async and iterator methods as long as they do not cross those points). Helpers in MemoryExtensions (IndexOf, SequenceEqual, Sort, Trim) are vectorised. CollectionsMarshal.AsSpan(list) exposes a list's backing array, but adding to the list invalidates it.

  3. When: Candidate mentions u8 literals. Why: u8 literals connect to UTF-8 encoding.
    How to answer

    In memory a .NET string is UTF-16; an Encoding converts it to and from bytes at the boundaries (files, HTTP, databases). I default to UTF-8 without a BOM (new UTF8Encoding(false)), and I use throwOnInvalidBytes: true when bad input should fail rather than silently become �. Legacy formats like Windows-1252 need CodePagesEncodingProvider registered. For speed there are "OK"u8 literals and the Utf8 class for span transcoding. For text correctness, Rune handles code points beyond the BMP, StringInfo counts grapheme clusters (what users see), and I normalise to NFC before comparing or storing user input.

🛠 Real-world problems: how you handle them

HTML escaping hot path sped up with SearchValues
Situation
A CMS rendered [2,000] pages per second and escaped user content with a custom loop using IndexOfAny(char[]) on strings.
Signals
Profiling showed [18%] of render CPU in the escaping helper on large pages.
Investigation
A BenchmarkDotNet comparison of the existing helper, a SearchValues version and the framework HtmlEncoder on real page content.
Resolution
Switched the helper to ReadOnlySpan<char> input with a static SearchValues<char>, returning the original string when nothing needs escaping.
Prevention
Kept the benchmarks in the repository and added a performance test gate for the rendering pipeline.

How to tell it (STAR)

**Situation:** our [CMS] spent nearly a fifth of render CPU escaping HTML. **Task:** I needed a safe speed-up without changing output. **Action:** I benchmarked options, then rewrote the helper to take ReadOnlySpan<char>, use a static SearchValues for the special characters, and skip allocation when no escaping was needed, with tests comparing output to the old version. **Result:** the helper became [4x] faster, render CPU dropped [14%], and the output stayed byte-for-byte identical.

↩ Usually asked after

More in Memory-Oriented C#

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me