Blog

Addio byte[]: allocazioni a costo zero in .NET Framework con ReadOnlySpan

Dario Fadda Aprile 23, 2026

Uno dei pattern di ottimizzazione più semplici e meno conosciuti nel mondo .NET è la sostituzione dei campi static readonly byte[] con proprietà static ReadOnlySpan<byte>. Andrew Lock, noto per le sue analisi approfondite su ASP.NET Core e il runtime, ha pubblicato un articolo che conferma un dettaglio fondamentale: questa tecnica funziona anche su .NET Framework, basta il pacchetto NuGet System.Memory. Zero allocazioni, zero costo di startup, nessuna pressione sul garbage collector.

Il problema: allocazioni “gratuite” che non lo sono

Consideriamo un pattern che troviamo in quasi tutte le librerie che manipolano dati binari: signature di file, magic number, header fissi, tabelle di lookup. Tipicamente si scrive:

public static class MyStaticData
{
    private static readonly byte[] ByteField = new byte[] { 1, 2, 3, 4 };
}

Sembra innocuo: un singolo array, allocato una volta sola al caricamento del tipo. Ma in un processo con migliaia di tipi simili — pensiamo a un parser di formati immagine, a una libreria di crittografia, a un framework web — queste allocazioni si sommano. Ogni array è un oggetto gestito: richiede header, richiede tracciamento GC, occupa spazio sulla Gen 2 (perché sopravvive per sempre) e aumenta i tempi di startup.

La soluzione: ReadOnlySpan<byte> come proprietà

La trasformazione è quasi meccanica:

public static class MyStaticData
{
    private static ReadOnlySpan<byte> ReadOnlySpanProp => new byte[] { 1, 2, 3, 4 };
}

Sintatticamente sembra che stiamo allocando un array ogni volta che accediamo alla proprietà. In realtà è esattamente il contrario: il compilatore C# riconosce questo pattern e incorpora i byte direttamente nei metadati dell’assembly, costruendo lo span con un puntatore a quei dati. Non viene mai eseguito newarr.

L’IL generato mostra chiaramente la magia:

IL_0000: ldsflda      int32 '<PrivateImplementationDetails>'::'...'
IL_0005: ldc.i4.4
IL_0006: newobj       instance void valuetype [System.Memory]System.ReadOnlySpan`1<unsigned int8>::.ctor(void*, int32)
IL_000b: ret

I dati vivono in una sezione di sola lettura dell’assembly; lo span viene costruito on-the-fly con pointer + length. È essenzialmente gratuito.

Letterali UTF-8: lo stesso trucco, più ergonomico

A partire da C# 11 (.NET 7), la stessa ottimizzazione si ottiene con i letterali UTF-8:

private static ReadOnlySpan<byte> Utf8Hello => "Hello world"u8;

Il suffisso u8 istruisce il compilatore a codificare la stringa direttamente in UTF-8 nell’assembly. Molto utile per header HTTP, prefissi di protocollo, marker di formato binari — tutti casi in cui storicamente si manteneva una byte[] statica generata da Encoding.UTF8.GetBytes.

I vincoli da rispettare

L’ottimizzazione non si applica in modo uniforme. Vale solo per i tipi a byte singolo:

  • byte[]
  • sbyte[]
  • bool[]

Per gli altri tipi primitivi (int, long, double…) entra in gioco l’endianness: su .NET 7 e successivi c’è RuntimeHelpers.CreateSpan<T>() che la gestisce in modo trasparente, ma su .NET Framework il compilatore emette codice che cache l’array in un campo statico alla prima chiamata. Ancora efficiente, ma non zero-alloc.

Il secondo vincolo è che tutti i valori devono essere costanti a compile-time:

// Anti-pattern: alloca a ogni accesso
private static readonly byte One = 1;
private static ReadOnlySpan<byte> Bad => new byte[] { One, 2, 3, 4 };

Qui One è un campo, non una costante, quindi il compilatore deve costruire l’array a runtime. La differenza tra const byte e static readonly byte diventa improvvisamente importante.

Il terzo vincolo è usare ReadOnlySpan<T>, mai Span<T>:

// Sbagliato: alloca un array mutabile a ogni accesso
private static Span<byte> MutSpan => new byte[] { 1, 2, 3, 4 };

Uno Span<byte> potrebbe essere scritto, e modificare dati immutabili condivisi sarebbe catastrofico. Il compilatore quindi non applica l’ottimizzazione.

Il supporto su .NET Framework

Questa è la parte più interessante: il trucco funziona su .NET Framework 4.6.2+ semplicemente referenziando il pacchetto System.Memory:

<ItemGroup>
  <PackageReference Include="System.Memory" Version="4.6.3" />
</ItemGroup>

La ragione è che l’ottimizzazione è una feature del compilatore, non del runtime: serve solo che ReadOnlySpan<T> esista come tipo, e il pacchetto System.Memory lo fornisce. Chi mantiene librerie multi-target può quindi applicare questa ottimizzazione senza creare codice condizionale #if NET6_0_OR_GREATER.

Collection expressions: la rete di sicurezza

Su C# 12 e successivi le collection expressions offrono protezione a compile-time:

// Compila e non alloca
private static ReadOnlySpan<byte> Safe => [1, 2, 3, 4];

// Errore CS9203 — il compilatore rifiuta
private static Span<byte> Dangerous => [1, 2, 3, 4];

L’errore CS9203 è un salvavita: impedisce di assegnare una collection expression a un tipo Span<T> in contesti static, perché il risultato sarebbe condivisibile e mutabile. Su .NET Framework o su versioni di C# precedenti questa protezione non esiste, quindi serve attenzione in fase di code review.

Quando applicarla nel codice reale

Le candidate ideali sono costanti binarie che vivono in campi static readonly byte[]: magic number (PNG, ZIP, PDF), prefissi protocollari, tabelle di sostituzione, chiavi di test fisse, certificati embedded. Il refactoring è meccanico e non cambia l’API pubblica della classe se la visibility è private.

Attenzione invece ai metodi che accettano byte[]: non possiamo passare uno ReadOnlySpan<byte> a un’API che richiede un array. In questi casi la scelta è tra riscrivere il consumer per accettare ReadOnlySpan<byte> (preferibile) o mantenere l’array tradizionale. Molte API del BCL sono già state aggiornate negli ultimi anni: Stream.Write, HashAlgorithm.ComputeHash, Encoding.GetString accettano tutti ReadOnlySpan<byte> in overload moderni.

Conclusione

Cambiare static readonly byte[] in static ReadOnlySpan<byte> => è uno di quei refactoring che riducono allocazioni e startup con una modifica locale a costo zero. Funziona anche su .NET Framework, quindi vale la pena considerarla durante la manutenzione di codice legacy — un punto che spesso sfugge perché l’ecosistema associa Span<T> esclusivamente a .NET moderno.

Fonte: Removing byte[] allocations in .NET Framework using ReadOnlySpan<T> di Andrew Lock.

💬 Unisciti alla discussione!


Questo è un blog del Fediverso: puoi trovare quindi questo articolo ovunque con @blog@spcnet.it e ogni commento/risposta apparirà qui sotto.

Se vuoi commentare su Addio byte[]: allocazioni a costo zero in .NET Framework con ReadOnlySpan, utilizza la discussione sul Forum.
Condividi la tua esperienza, confrontati con altri professionisti e approfondisci i dettagli tecnici nel nostro 👉 forum community