Span, Memory e Slicing Sem Alocar
Objective
Span<T> permite ler e escrever parte de um array, de uma string, de um buffer
na stack ou de memória nativa sem copiar e sem alocar. O preço é um conjunto
de regras do compilador, porque um span pode apontar para um stack frame que
está prestes a sumir. Este conceito explica o que é um span, como o slicing
difere de Substring e de ranges em array, as conversões implícitas de span
do C# 14 e as surpresas que elas trazem no upgrade, as regras de ref struct,
stackalloc com fallback seguro, scoped, params com span e inline arrays,
e quando Memory<T> ou MemoryMarshal são a ferramenta certa.
Use Cases
- Fazer parsing de linhas, headers ou frames de protocolo sem o lixo de
SubstringeSplit. - Buffers temporários pequenos num caminho quente, com fallback em pool para entradas grandes.
- Uma API que aceita arrays, strings e buffers de stack por meio de um
parâmetro
ReadOnlySpan<T>. - Atualizar um código para C# 14 e entender erros de compilação novos ou overloads diferentes.
- Manter um buffer vivo através de
await, onde um span não pode ir.
Deep Dive
Uma referência e um tamanho
Um span é um readonly ref struct com um ref T para o primeiro elemento e
um tamanho. Fazer slicing cria um novo par sobre a mesma memória, então é O(1)
e nunca copia. Um range sobre array é outra coisa: ele aloca um array novo.
csharpint[] data = { 10, 20, 30, 40, 50 };
int[] copy = data[1..4]; // array novo
copy[0] = -1;
Span<int> view = data.AsSpan()[1..4]; // mesma memória
view[1] = -2;
Console.WriteLine(string.Join(",", data)); // 10,20,-2,40,50Medido por chamada no .NET 10.0.0 (Apple M4 Max, arm64, Release, depois do
aquecimento), com line = "2026-10-15;ORD-1042;199.90":
| Código | Bytes por chamada |
|---|---|
line.Substring(11, 8) |
40 |
line.AsSpan(11, 8) |
0 |
data[1..4] |
40 |
int.Parse(line.Substring(0, 4)) |
32 |
int.Parse(line.AsSpan(0, 4)) |
0 |
line.Split(';') e parse de cada campo |
176 |
SumAmounts(line) abaixo |
0 |
csharpstatic decimal SumAmounts(string line)
{
ReadOnlySpan<char> s = line;
decimal total = 0;
foreach (Range r in s.Split(';')) // .NET 9+: devolve ranges, não strings
if (decimal.TryParse(s[r], CultureInfo.InvariantCulture, out var d)) total += d;
return total;
}C# 14: arrays e strings convertem sozinhos
Os tipos de span sempre tiveram operadores de conversão, mas conversões
definidas pelo usuário são ignoradas para receivers de extension methods e não
ajudam na inferência de tipos genéricos. O C# 14 cria implicit span
conversions na própria linguagem: de T[] para Span<T> e
ReadOnlySpan<T>, de Span<T> para ReadOnlySpan<T>, conversões covariantes
para ReadOnlySpan<T> (um Span<string> para ReadOnlySpan<object>) e de
string para ReadOnlySpan<char>. O mesmo arquivo com LangVersion 13 e 14:
csharpint[] numbers = { 3, 0, 5, 0 };
numbers.CountZeros(); // 2 C# 13: CS1929, o receiver precisa ser ReadOnlySpan<int>
"a,b,,c".CountCommas(); // 3 C# 13: CS1929
C.First(numbers); // 3 C# 13: CS0411, T não pode ser inferido
Span<string> names = new[] { "ana", "bia" };
ReadOnlySpan<object> objs = names; // C# 13: CS0029
C.Pick(numbers); // "ReadOnlySpan" C# 13: CS0121, ambíguo
static class Ext
{
public static int CountZeros(this ReadOnlySpan<int> s) => s.Count(0);
public static int CountCommas(this ReadOnlySpan<char> s) => s.Count(',');
}
static class C
{
public static T First<T>(ReadOnlySpan<T> s) => s[0];
public static string Pick(IEnumerable<int> e) => "IEnumerable";
public static string Pick(ReadOnlySpan<int> s) => "ReadOnlySpan";
}A resolução de overload agora prefere a conversão de span, e ReadOnlySpan<T>
em vez de Span<T>. Isso muda código existente no upgrade:
Span<int> ints = MemoryMarshal.Cast<byte, int>(bytes);compilava no C# 13 e vira CS0029 no C# 14, porque o array agora casa com o overload deReadOnlySpan<byte>. Escrevabytes.AsSpan().- Com os overloads
R<T>(IEnumerable<T>)eR<T>(Span<T>), passar umobject[]que na verdade é umstring[]ia paraIEnumerableno C# 13 e lançaArrayTypeMismatchExceptionno C# 14, porque um span gravável sobre um array covariante não é permitido. Um overload comReadOnlySpan<T>evita o problema. - Dentro de uma expression tree,
a.Contains(n)sobre um array agora casa comMemoryExtensions.Contains. Compilada, funciona; interpretada (Compile(preferInterpretation: true)), lançouArgumentException, e providers LINQ que procuramEnumerable.Containsprecisam tratar o nó novo. array.Reverse()iria casar com oMemoryExtensions.Reverseque inverte no lugar, mas o .NET 10 adicionaEnumerable.Reverse(T[]):foreach (var x in r.Reverse())imprimiu321e deixou o array intacto.
As regras de ref struct
Como um span pode apontar para a stack, o compilador mantém todo span dentro do stack frame que o criou:
| Código | Resultado |
|---|---|
class Holder { Span<int> _s; } |
CS8345 |
object o = span; |
CS0029: sem boxing |
Func<int> f = () => span.Length; |
CS8175: sem captura |
um span usado depois de um await |
CS4007 |
retornar um span sobre stackalloc |
CS8352 |
Desde o C# 13, locais do tipo span são permitidos em métodos async e
iterators, desde que não fiquem vivos através de um await ou yield: um span
usado só antes do await compilou e rodou.
stackalloc com fallback
stackalloc atribuído a um span dispensa unsafe, mas a stack é limitada e
um overflow derruba o processo. Limite o tamanho e caia para um array do pool:
csharpstatic string Hex(ReadOnlySpan<byte> bytes)
{
const int MaxStack = 256;
char[]? rented = null;
int needed = bytes.Length * 2;
Span<char> buf = needed <= MaxStack
? stackalloc char[MaxStack]
: (rented = ArrayPool<char>.Shared.Rent(needed));
try
{
for (int i = 0; i < bytes.Length; i++)
bytes[i].TryFormat(buf.Slice(i * 2, 2), out _, "X2");
return new string(buf[..needed]);
}
finally
{
if (rented is not null) ArrayPool<char>.Shared.Return(rented);
}
}Convert.ToHexString já existe; o que interessa aqui é o padrão. Para 16 e
para 1.024 bytes de entrada, a única alocação medida foi a string de
resultado. A documentação acrescenta três regras: trate a memória nova como
não inicializada, nunca use stackalloc dentro de um loop e valide tamanhos
que vêm de entrada externa.
scoped: mantendo o span local
Um span inicializado a partir de um array no heap é considerado seguro para
retorno, então o compilador recusa apontá-lo para memória de stack depois.
scoped restringe a variável ao método atual e torna a atribuição válida:
csharpstatic int Sum(int n)
{
scoped Span<int> buf = n > 16 ? new int[n] : default;
if (n <= 16) buf = stackalloc int[n]; // sem scoped: CS8353
buf.Fill(1);
int total = 0;
foreach (var x in buf) total += x;
return total;
}Num parâmetro, scoped é a promessa de que o método não vai retornar o span
nem guardá-lo além da chamada, e o compilador cobra: retornar um parâmetro
scoped Span<int> é CS8352.
params com span e inline arrays
Um parâmetro params ReadOnlySpan<int> (C# 13) mediu 0 bytes por chamada,
contra 40 de params int[]: com argumentos não constantes o compilador monta
uma struct [InlineArray] escondida na stack, e com constantes aponta para
dados dentro do assembly. O mesmo atributo está disponível para você:
csharp[InlineArray(8)]
struct Buffer8 { private int _element0; }
var b = new Buffer8();
b[0] = 7;
Span<int> s = b; // uma view sobre o armazenamento da própria struct
s[1] = 8;
// Unsafe.SizeOf<Buffer8>() é 32; b[1] é 8Um inline array é um buffer de tamanho fixo embutido em quem o contém, sem
alocação separada. O Equals padrão dele lança NotSupportedException (.NET
9 em diante), então compare pelo span.
Memory<T> para guardar e para await
Memory<T> é uma struct comum (por dentro, uma referência de objeto, um índice
e um tamanho) que pode ser campo e pode atravessar await. Peça o .Span nas
partes síncronas:
csharpstatic async Task<int> CountLinesAsync(Stream stream)
{
Memory<byte> buffer = new byte[4096];
int lines = 0, read;
while ((read = await stream.ReadAsync(buffer)) > 0)
lines += buffer.Span[..read].Count((byte)'\n');
return lines;
}As diretrizes de uso resumem a divisão: APIs síncronas recebem Span<T>, as
assíncronas recebem Memory<T>, visões só de leitura usam as variantes
ReadOnly, e ninguém mexe num Memory<T> depois que a chamada (ou a task)
que o recebeu terminou. Async I/O and System.IO.Pipelines aplica isso a
streams.
MemoryMarshal: saindo das grades de proteção
MemoryMarshal.Cast<byte, int>(raw.AsSpan()) reinterpreta bytes como ints sem
cópia. Escrever ints[1] = 256 mudou raw para 1,0,0,0,0,1,0,0 nesta
máquina little-endian, e é exatamente por isso que formatos de arquivo e de
rede devem usar BinaryPrimitives. MemoryMarshal e CollectionsMarshal
(visto em List
Trade-offs
- Spans versus arrays e strings. Sem cópia e sem alocação, mas sem campos,
sem lambdas e sem
await. Quando o dado precisa sobreviver ao método, copie uma vez comToArray()ouToString(). stackallocversus heap. Grátis e rápido para tamanhos pequenos, fatal quando grande demais. Sempre limite e caia para oArrayPool<T>, que precisa ser devolvido numfinally.Memory<T>versusSpan<T>. Pode ser guardado e combina com async, mas cada chamada a.Spanprecisa descobrir que objeto está por trás, e as diretrizes chamamSpan<T>de tipo mais rápido. DeixeMemory<T>nas bordas e use spans por dentro.- Conversões do C# 14. Menos overloads e APIs que aceitam arrays e strings
direto, ao custo de overloads mudando no upgrade. Recompile, rode os testes e
revise código que mistura overloads de
IEnumerable<T>e de span. MemoryMarshale inline arrays. Zero cópia e zero alocação, mas passam para você a responsabilidade por layout, endianness e tempo de vida. Mantenha esse código em helpers pequenos e testados.
Documentation Links
- Memory<T> and Span<T> usage guidelines, Microsoft Learn
- What's new in C# 14: implicit span conversions, Microsoft Learn
- First-class span types, C# 14 feature specification, Microsoft Learn
- ref struct types, C# reference, Microsoft Learn
- stackalloc expression, C# reference, Microsoft Learn
- Avoid memory allocations and data copies (ref safety and scoped), Microsoft Learn
- Structure types: inline arrays, C# reference, Microsoft Learn
- MemoryMarshal class, Microsoft Learn