Why .NET reflection cannot read ReadOnlySpan properties

Summary

A developer attempted to build a generic object inspector using standard .NET Reflection. During testing with System.Text.RegularExpressions.Match, the system threw a System.NotSupportedException when attempting to access the ValueSpan property. The core issue is that ValueSpan returns a ReadOnlySpan<char>, which is a ref struct. Because ref structs are stack-only, they cannot be boxed into a System.Object, making them incompatible with traditional reflection patterns.

Root Cause

The failure stems from the fundamental design constraints of the .NET runtime regarding memory safety and stack allocation:

  • Boxing Prohibition: Reflection’s PropertyInfo.GetValue() returns a System.Object. To return a value as an object, the runtime must “box” it (move it from the stack to the managed heap).
  • Ref Struct Constraints: Types marked with the ref struct modifier (like ReadOnlySpan<T>) are strictly prohibited from being boxed. They cannot exist on the heap because they may contain managed pointers that are only valid for the duration of a specific stack frame.
  • Type Incompatibility: The signature object GetValue(object obj) creates a mathematical impossibility for ref structs: you cannot satisfy the return type object without violating the safety guarantees of the ref struct.

Why This Happens in Real Systems

In high-performance production systems, engineers often move away from heap-allocated objects toward Span-based APIs to reduce Garbage Collection (GC) pressure.

  • Zero-Copy Architectures: Modern .NET libraries use Span<T> and ReadOnlySpan<T> to slice buffers without allocating new memory.
  • Abstraction Leaks: When building “universal” tools like serializers, debuggers, or dependency injection containers, you eventually hit a wall where your abstractions assume everything can be treated as a System.Object.
  • Performance vs. Genericity: The very features that make a system fast (stack allocation) are the exact features that make it difficult to inspect via high-level, generic metadata APIs.

Real-World Impact

  • Tooling Failure: Debuggers, profilers, and automated inspection tools (like JSON serializers or Object Mappers) will crash or skip critical data when encountering modern, high-performance types.
  • Runtime Instability: If not handled gracefully with proper exception catching, an automated inspection loop in a production monitoring agent could cause a service to enter a crash loop.
  • Developer Friction: Engineers are forced to write “special case” code for high-performance types, breaking the DRY (Don’t Repeat Yourself) principle and increasing maintenance overhead.

Example or Code

The following demonstrates the failure and the attempt to use Expression Trees to bypass the limitation by using generics.

using System;
using System.Linq.Expressions;
using System.Text.RegularExpressions;

public class ReflectionFailure
{
    public static void Execute()
    {
        Match match = new Regex(@"\d+").Match("testing 123");

        // 1. The Failure: Standard Reflection
        try
        {
            var prop = match.GetType().GetProperty("ValueSpan");
            // This throws System.NotSupportedException because ReadOnlySpan cannot be boxed
            var value = prop.GetValue(match); 
        }
        catch (NotSupportedException ex)
        {
            Console.WriteLine($"Caught expected error: {ex.Message}");
        }

        // 2. The Partial Workaround: Expression Trees with Generics
        // This works ONLY if the caller knows the type 'T' at compile time.
        var result = GetPropertyViaExpression<ReadOnlySpan>(match, "ValueSpan");
        Console.WriteLine($"Successfully retrieved span: {result.ToString()}");
    }

    public static T GetPropertyViaExpression(object target, string name) 
        where T : allows ref struct
    {
        var arg = Expression.Parameter(typeof(object), "target");
        var castTarget = Expression.Convert(arg, target.GetType());
        var property = Expression.Property(castTarget, name);

        // We must cast the property result to T before returning to avoid boxing to object
        var lambda = Expression.Lambda<Func>(property, arg);
        return lambda.Compile()(target);
    }
}

How Senior Engineers Fix It

A senior engineer recognizes that you cannot “fix” the impossibility of boxing a ref struct. Instead, you must change the architecture of the inspection tool:

  • Avoid object as a Sink: Instead of a method that returns object, implement a Visitor Pattern. The visitor provides specific overloads for known ref struct types (e.g., Visit(ReadOnlySpan<char> span)).
  • Use Unsafe/Pointer Arithmetic: If absolute necessity dictates, use unsafe code to copy the underlying data from the ref struct into a managed buffer (like a string or byte[]) that can be boxed.
  • Code Generation (Source Generators): Instead of runtime reflection, use C# Source Generators to create type-specific inspection code at compile time. This avoids the need for boxing entirely and preserves performance.
  • Type-Specific Handlers: Implement a registry of “Type Handlers.” If the reflection engine detects a type that is a ref struct, it delegates the work to a specialized handler that doesn’t rely on the object return type.

Why Juniors Miss It

  • The “Everything is an Object” Fallacy: Juniors are taught that System.Object is the root of all types and that anything can be cast to it. They often overlook the memory safety constraints that prevent certain types from ever being treated as objects.
  • Ignoring the Stack vs. Heap: There is often a lack of deep understanding regarding how the Stack and the Heap interact. A junior sees a “type error,” whereas a senior sees a “memory layout error.”
  • Over-reliance on Reflection: Juniors often reach for System.Reflection as a silver bullet for all metadata problems, not realizing that reflection is fundamentally designed for heap-allocated, boxable types.

Leave a Comment