CA1860 é uma regra de análise de código do .NET que sinaliza chamadas a Enumerable.Any() (sem predicado) sobre coleções que expõem Length, Count ou IsEmpty. A motivação não é mais sobre alocação, desde o .NET 5 o Enumerable.Any() tem fast path para ICollection<T> (verificável no source oficial), refatorado no .NET 8 e melhorado em .NET 9. Os meus benchmarks no .NET 10 confirmam zero alocações. O custo real é a chamada externa, o type check e o callvirt, coisas que somem completamente quando você usa o property direto. Este é o segundo artigo da sub-série warnings de performance que ninguém olha. Vamos do C# ao Assembly x64 emitido pelo JIT no .NET 10.0.1, passando pelo IL real produzido pelo compilador, com benchmark honesto medindo int[], List<int>, HashSet<int> e ImmutableArray<int>. Você vai ver que o Any() é 13 a 16 vezes mais lento que a property equivalente no caso geral, ficando idêntico apenas em ImmutableArray<T>, que tem sua própria extension otimizada. Tudo reproduzível a partir de dotnet/samples/06-ca1860/.
Insights
- CA1860 não é mais sobre alocação: a maioria dos artigos ainda diz que
Any()aloca enumerator. Era verdade até o .NET Core 3.1. Desde o .NET 5 oEnumerable.Any()tem fast path paraICollection<T>que devolveCount > 0sem alocar nada, verificável pelo source da Microsoft. Em .NET 8 o método foi refatorado (PR #78438 do Stephen Toub) para usarTryGetNonEnumeratedCount, e em .NET 9 ganhou ainda fast path paraIterator<T>(PR #99218). Os meus benchmarks no .NET 10 confirmam: zero bytes alocados. - Mas ainda é 13 a 16 vezes mais lento: o motivo verdadeiro é o custo da chamada externa, do type check
is ICollection<T>, do cast e do callvirt paraget_Count. 3 a 4 instruções inline viram uma função externa. - Property vira
mov+cmp, Any viracall: o Tier 1 do RyuJIT reduz_array.Length > 0a 4 instruções e 15 bytes, enquanto_array.Any()mantém 5 instruções e 20 bytes, todas gastas para preparar e fazer uma chamada de função externa. ImmutableArray.Any()é igual aIsEmpty: surpresa do benchmark.ImmutableArray<T>usa uma extension method própria (ImmutableArrayExtensions.Any<T>) que recebe o tipo por valor, sem boxing nem despacho por interface. O CA1860 não dispara nele e nem precisa.- CA1860 vive numa família de quatro regras complementares: junto com CA1827, CA1828 e CA1829, formam a doutrina de “use a regra certa para o tipo certo”,
Any()paraIEnumerable<T>puro, propertyLength/Count/IsEmptypara coleções materializadas.
Onde a maioria dos artigos erra
A primeira coisa que aparece quando você pesquisa “CA1860” ou “Any() vs Count” é a afirmação: Any() aloca um enumerator. Quase todo post de blog repete isso. Quase todo thread do Stack Overflow também.
Era verdade até o .NET Core 3.1. Não é mais desde o .NET 5.
A história, verificada no source oficial da Microsoft (dotnet/runtime e dotnet/corefx):
| Versão | Comportamento de Any() em ICollection<T> | Fonte |
|---|---|---|
| .NET Core 3.1 | Sempre alocava enumerator | corefx/release/3.1/AnyAll.cs |
| .NET 5 | Fast path para ICollection<T> adicionado | runtime/release/5.0/AnyAll.cs |
| .NET 6, 7 | Mesmo fast path mantido | runtime/release/7.0/AnyAll.cs |
| .NET 8 | Refactor para usar TryGetNonEnumeratedCount | PR #78438 — Stephen Toub, 16/11/2022, milestone 8.0 |
| .NET 9 | Fast path adicional para Iterator<T> | PR #99218 — 04/03/2024 |
| .NET 10 (medido aqui) | Zero alocações confirmadas | meu benchmark deste artigo |
A versão atual do método em main (que será a do .NET 11 e descendente direto do .NET 10) é literalmente esta — extraído de AnyAll.cs:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
public static bool Any<TSource>(this IEnumerable<TSource> source) { if (source is null) { ThrowHelper.ThrowArgumentNullException(ExceptionArgument.source); } if (source is ICollection<TSource> gc) { return gc.Count != 0; } if (!IsSizeOptimized && source is Iterator<TSource> iterator) { int count = iterator.GetCount(onlyIfCheap: true); if (count >= 0) { return count != 0; } iterator.TryGetFirst(out bool found); return found; } if (source is ICollection ngc) { return ngc.Count != 0; } using IEnumerator<TSource> e = source.GetEnumerator(); return e.MoveNext(); } |
Para qualquer Array, List<T>, HashSet<T>, Dictionary<K,V> e qualquer outro tipo que implemente ICollection<T>, o fluxo para no primeiro if. Nada de enumerator. Zero alocação. Para resultados de LINQ encadeado (.Where().Select()…) que produzem Iterator<T> interno, há um fast path via GetCount(onlyIfCheap: true) que tenta calcular sem materializar. Como último recurso, ICollection não-genérico é checado, e só então enumeração real acontece.
E no entanto a regra CA1860 continua existindo, ativa por padrão no .NET 10. Se a alocação não é mais o problema, o que é?
A história começa, de novo, com o agente de IA
No primeiro artigo desta sub-série eu contei sobre o agente de IA que rodamos no pipeline e que pegou várias ocorrências de CA1859 num repositório de produção. No mesmo batch de achados, o segundo warning mais frequente foi CA1860, .Any() espalhado por código de validação, guards e filtros que poderiam ter sido Length > 0 ou Count > 0.
Como o gancho do CA1859 ficou claro (custo da despachoria por interface), abrir o CA1860 pareceu repeteco. Mas quando rodei o benchmark honesto, a história foi outra: a explicação clássica está errada, e o motivo real exige olhar IL e Assembly.
O que o CA1860 detecta exatamente
A regra CA1860 está documentada como “Avoid using ‘Enumerable.Any()’ extension method”. Está ativa por padrão no .NET 10 com severidade Suggestion. A causa, no texto oficial:
Enumerable.Any is called on a type that has a Length, Count, or IsEmpty property.
Ela dispara em três cenários distintos:
- Tipo expõe
Length(caso clássico:Array,string,Span<T>). - Tipo expõe
Count(List<T>,HashSet<T>,Dictionary<K,V>,ICollection<T>). - Tipo expõe
IsEmpty(estruturas imutáveis e collections concorrentes).
A regra não dispara:
- Quando você passa um predicado:
.Any(x => x > 0)é semântica diferente. - Quando o tipo é
IEnumerable<T>puro sem nenhuma das três properties. - Quando o
.Any()resolve para uma extension method específica do tipo (caso deImmutableArray<T>, como veremos).
A mensagem literal do warning, capturada no meu build com analyzer ativo:
warning CA1860: Prefer comparing 'Length' to 0 rather than using 'Any()',
both for clarity and for performance
Repare na frase final: “both for clarity and for performance”. Clareza e performance, property é mais óbvio quanto à intenção e mais rápido. Não é só sobre velocidade.
A família CA1827, CA1828, CA1829 e CA1860
Esse warning não vive sozinho. Ele faz parte de uma família de quatro regras de performance que cobrem o mesmo terreno por ângulos diferentes:
| Regra | Cenário | Recomendação |
|---|---|---|
| CA1827 | IEnumerable<T> com .Count() > 0 ou .LongCount() > 0 | Use .Any() (evita iterar a coleção inteira) |
| CA1828 | Versão async de CA1827 — .CountAsync() > 0 | Use .AnyAsync() |
| CA1829 | Coleção materializada com .Count() LINQ | Use a property Length ou Count |
| CA1860 | Coleção materializada com .Any() | Use a property Length, Count ou IsEmpty |
A simetria é elegante. CA1827 e CA1860 dão conselhos opostos para o mesmo objetivo, checar se há elementos:
- Sobre
IEnumerable<T>puro, prefiraAny(). PorqueCount()itera tudo, eAny()para no primeiro elemento. - Sobre
Array,List<T>,HashSet<T>(coleções materializadas), prefiraLength/Count. Porque a property é O(1) direta, eAny()paga type check + chamada externa.
Não há contradição. O tipo determina qual regra aplica. Os dois analyzers sabem disso e disparam exatamente nos cenários certos.
O benchmark que muda a narrativa
Hardware: Intel Core i7-8665U @ 1,90 GHz, Windows 11. Runtime: .NET 10.0.1 (10.0.125.57005), x86-64-v3. Benchmark: BenchmarkDotNet 0.15.4 com MemoryDiagnoser ativo. Cada coleção tem 100 elementos.
Resultados literais do meu log:
| Method | Mean | Error | StdDev | Allocated |
|---|---|---|---|---|
| Array_Length | 0,052 ns | 0,031 ns | 0,039 ns | 0 B |
| Array_Any | 0,825 ns | 0,049 ns | 0,065 ns | 0 B |
| List_Count | 0,088 ns | 0,028 ns | 0,042 ns | 0 B |
| List_Any | 1,174 ns | 0,049 ns | 0,046 ns | 0 B |
| HashSet_Count | 0,086 ns | 0,033 ns | 0,041 ns | 0 B |
| HashSet_Any | 1,112 ns | 0,055 ns | 0,054 ns | 0 B |
| Immutable_IsEmpty | 0,051 ns | 0,033 ns | 0,034 ns | 0 B |
| Immutable_Any | 0,050 ns | 0,032 ns | 0,037 ns | 0 B |
Três fatos saem desse benchmark:
Fato 1 – Allocated = 0 em todas as 8 variantes. Confirmado: Any() não aloca em nenhum dos 4 tipos testados. A explicação clássica de CA1860 está errada no .NET 10.
Fato 2 – Any() é 13 a 16 vezes mais lento mesmo sem alocar. Ratio aproximada:
- Array:
0,825 / 0,052 ≈ 15,9x - List:
1,174 / 0,088 ≈ 13,3x - HashSet:
1,112 / 0,086 ≈ 12,9x - ImmutableArray:
0,050 / 0,051 ≈ 1,0x(idêntico)
Fato 3 – ImmutableArray.Any() é estatisticamente idêntico a !IsEmpty. Veja o detalhe na próxima seção, não é coincidência.
Nota metodológica: os números absolutos estão na casa do picosegundo, no limite de precisão do BenchmarkDotNet. O
BDNemite warnings de “ZeroMeasurement” porque a property é tão rápida que se confunde com método vazio. A razão entre as duas variantes é o número confiável, não os absolutos. Em hot path com milhões de chamadas por segundo, a diferença vira throughput mensurável.
Por que Any() é mais lento sem alocar, o IL revelado
O compilador C# emite opcodes diferentes para cada caso. Capturei o IL real com ilspycmd:
|
1 |
ilspycmd -il -t "IlDump.EmptinessChecker" bin/Release/net10.0/IlDump.dll |
Para ArrayLength():
|
1 2 3 4 5 6 7 8 9 10 11 12 |
.method public hidebysig instance bool ArrayLength () cil managed { // Code size: 11 (0xb) .maxstack 8 IL_0000: ldarg.0 IL_0001: ldfld int32[] IlDump.EmptinessChecker::_array IL_0006: ldlen IL_0007: ldc.i4.0 IL_0008: cgt.un IL_000a: ret } |
Repare o opcode ldlen. Esse é um opcode dedicado do CLR para “tamanho do array” — direto, sem chamada de método. 11 bytes de IL.
Para ArrayAny():
|
1 2 3 4 5 6 7 8 9 10 11 |
.method public hidebysig instance bool ArrayAny () cil managed { // Code size: 12 (0xc) .maxstack 8 IL_0000: ldarg.0 IL_0001: ldfld int32[] IlDump.EmptinessChecker::_array IL_0006: call bool [System.Linq]System.Linq.Enumerable::Any<int32>( class [System.Runtime]System.Collections.Generic.IEnumerable`1<!!0>) IL_000b: ret } |
Aqui temos um opcode call para System.Linq.Enumerable::Any<int32>. 12 bytes, mas com uma chamada externa.
Para ListCount():
|
1 2 3 4 5 6 |
IL_0000: ldarg.0 IL_0001: ldfld class List`1<int32> _list IL_0006: callvirt instance int32 List`1<int32>::get_Count() IL_000b: ldc.i4.0 IL_000c: cgt IL_000e: ret |
callvirt para get_Count da classe concreta List<int>. O JIT vai inlinear essa chamada no Tier 1, como veremos.
Para ListAny():
|
1 2 3 4 |
IL_0000: ldarg.0 IL_0001: ldfld class List`1<int32> _list IL_0006: call bool Enumerable::Any<int32>(IEnumerable`1<!!0>) IL_000b: ret |
Mesma estrutura de ArrayAny — chamada externa para Enumerable::Any.
E agora a parte mais interessante, ImmutableAny():
|
1 2 3 4 5 |
IL_0000: ldarg.0 IL_0001: ldfld valuetype ImmutableArray`1<int32> _immutable IL_0006: call bool [System.Collections.Immutable]System.Linq.ImmutableArrayExtensions::Any<int32>( valuetype ImmutableArray`1<!!0>) IL_000b: ret |
Repare no token: a chamada vai para System.Linq.ImmutableArrayExtensions::Any, não System.Linq.Enumerable::Any. É uma extension method completamente diferente, definida no assembly System.Collections.Immutable. Recebe o ImmutableArray<T> por valor, sem boxing para IEnumerable<T>.
Por isso o CA1860 não dispara em ImmutableArray, o analyzer só reconhece o Enumerable::Any. E por isso o desempenho é idêntico a IsEmpty, não há boxing, type check nem callvirt.
O Assembly x64 mostra o custo real
Capturei o Assembly nativo no .NET 10 com DOTNET_JitDisasm e MethodImplOptions.AggressiveOptimization para forçar Tier 1 (FullOpts):
|
1 2 3 4 5 |
$env:DOTNET_JitDisasm = "EmptinessChecker:Array* EmptinessChecker:List*" $env:DOTNET_TieredCompilation = "1" $env:DOTNET_TieredPGO = "0" $env:DOTNET_JitDisasmDiffable = "1" dotnet run -c Release |
Para ArrayLength (FullOpts), o JIT emitiu:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
; Assembly listing for method EmptinessChecker:ArrayLength():bool:this (FullOpts) ; FullOpts code ; optimized code G_M000_IG02: mov rax, gword ptr [rcx+0x08] ; rax = this._array cmp dword ptr [rax+0x08], 0 ; compara _array.Length (offset 0x08) com 0 setne al ; al = 1 se diferente, 0 se igual movzx rax, al ; zero-extende para retorno G_M000_IG03: ret ; Total bytes of code 15 |
4 instruções, 15 bytes, zero chamadas. A Length virou cmp dword ptr [rax+0x08], 0, compare direto na memória.
Para ArrayAny (FullOpts):
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
; Assembly listing for method EmptinessChecker:ArrayAny():bool:this (FullOpts) ; FullOpts code ; optimized code G_M000_IG01: sub rsp, 40 ; aloca shadow space para a chamada G_M000_IG02: mov rcx, gword ptr [rcx+0x08] ; rcx = this._array (primeiro arg) call [System.Linq.Enumerable:Any[int]( System.Collections.Generic.IEnumerable`1[int]):bool] nop G_M000_IG03: add rsp, 40 ret ; Total bytes of code 20 |
5 instruções, 20 bytes, e uma chamada externa. Note as instruções de prólogo e epílogo (sub rsp, 40 / add rsp, 40), necessárias para chamada de função ABI-compliant. Mais o nop de alinhamento. Nada disso aparece na versão property.
Para ListCount (FullOpts), o RyuJIT inlinou a chamada a get_Count:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
; Assembly listing for method EmptinessChecker:ListCount():bool:this (FullOpts) ; FullOpts code ; optimized code ; 0 inlinees with PGO data; 1 single block inlinees; 0 inlinees without PGO data G_M000_IG02: mov rax, gword ptr [rcx+0x10] ; rax = this._list cmp dword ptr [rax+0x10], 0 ; compara List._size (offset 0x10) com 0 setg al movzx rax, al G_M000_IG03: ret ; Total bytes of code 15 |
Repare na anotação: 1 single block inlinees. O JIT inlinou o get_Count que originalmente era um callvirt no IL, e o reduziu a cmp dword ptr [rax+0x10], 0 — acesso direto ao campo _size do List<T>. Idêntico em forma à versão ArrayLength.
Para ListAny (FullOpts):
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
; Assembly listing for method EmptinessChecker:ListAny():bool:this (FullOpts) ; FullOpts code ; optimized code G_M000_IG01: sub rsp, 40 G_M000_IG02: mov rcx, gword ptr [rcx+0x10] ; rcx = this._list call [System.Linq.Enumerable:Any[int]( System.Collections.Generic.IEnumerable`1[int]):bool] nop G_M000_IG03: add rsp, 40 ret ; Total bytes of code 20 |
Mesma estrutura de ArrayAny. Chamada externa para Enumerable.Any. O JIT não consegue inlinear essa chamada porque o método externo tem corpo grande, faz type check, cast e callvirt dentro, e o inliner do RyuJIT recusa.
Comparação direta, a história em uma tabela
| Variante | IL bytes | x64 bytes | x64 instruções | Calls |
|---|---|---|---|---|
ArrayLength | 11 | 15 | 4 | 0 |
ArrayAny | 12 | 20 | 5 | 1 |
ListCount | 15 | 15 | 4 | 0 |
ListAny | 12 | 20 | 5 | 1 |
A diferença não é o Length vs Count (esses se inlinam até virar a mesma forma). A diferença é a chamada para Enumerable.Any que persiste no Tier 1.
O que acontece em escala
Os números absolutos são ~0,05 ns vs ~1,1 ns por chamada. Em uma chamada isolada não dá para perceber. Em escala de produção:
| Cenário | Property | Any() | Diferença |
|---|---|---|---|
| 1 chamada | 0,05 ns | 1,1 ns | imperceptível |
| 1 milhão de chamadas em hot path | 50 μs | 1,1 ms | 22x mais |
Validação Any() em handler HTTP a 10k RPS | ~500 ns/req | ~11 μs/req | +10,5 μs por request |
Em um gateway fazendo 10 mil requests por segundo, com um único .Any() por request num guard, isso vira ~105 ms de CPU por segundo gastos só com isso. Não é trivial.
E em Native AOT (.NET 10 sem JIT, sem PGO), a chamada para Enumerable.Any não pode ser devirtualizada nem inlinada nunca. O gap é maior.
Quando Any() ainda é a escolha certa
Antes que você saia trocando todos os .Any() do codebase, três cenários onde Any() é correto:
1. IEnumerable<T> puro (sem Length/Count/IsEmpty):
|
1 2 3 |
IEnumerable<int> source = ProduzirNumeros(); if (source.Any()) // CORRETO — não há property disponível Process(source); |
2. Any() com predicado:
|
1 2 3 |
// Semanticamente diferente — para no primeiro match if (clientes.Any(c => c.Saldo < 0)) Alert(); |
3. LINQ to Entities / EF Core:
|
1 2 3 |
// Vira EXISTS no SQL, otimizado pelo banco if (await _db.Pedidos.Where(p => p.UserId == id).AnyAsync()) return Ok(); |
Nesses casos o CA1860 não dispara, o analyzer sabe.
Configurando severidade no .editorconfig
A severidade padrão é Suggestion. Para promover a Warning:
|
1 2 3 4 5 6 |
[*.cs] dotnet_diagnostic.CA1860.severity = warning # Considere também: dotnet_diagnostic.CA1827.severity = warning dotnet_diagnostic.CA1828.severity = warning dotnet_diagnostic.CA1829.severity = warning |
Combine com <EnableNETAnalyzers>true</EnableNETAnalyzers> e <AnalysisMode>All</AnalysisMode> no .csproj para ativar todos os analyzers da Microsoft. Para projetos novos, <TreatWarningsAsErrors>true</TreatWarningsAsErrors> quebra o build.
Conclusão
CA1860 é uma regra cuja explicação pública envelheceu. A maioria dos artigos ainda fala de alocação de enumerator, algo que não acontece mais no .NET 10 graças ao fast path do Enumerable.Any. Mas a regra continua válida porque a chamada externa em si tem custo, e property Length/Count/IsEmpty colapsa em cmp direto na memória.
O ImmutableArray<T> é a exceção elegante: tem sua própria extension method Any() que recebe por valor, não cobra type check, e por isso é idêntica a IsEmpty. O CA1860 sabe disso e respeita silenciosamente.
A sub-série continua. Próximos: CA1861 (arrays construídos como argumentos em loops) e CA1862 (StringComparison vs ToLower/ToUpper).
FAQ
1. Se Any() não aloca mais, o CA1860 ainda vale a pena?
Sim. A diferença entre 4 instruções inline e uma chamada externa de função é mensurável em hot path. Em escala de produção com milhões de chamadas, o gap de ~13-16x se traduz em CPU consumida desnecessariamente. Em Native AOT (sem JIT, sem PGO), a chamada externa fica ainda mais cara.
2. Por que ImmutableArray.Any() não dispara o CA1860?
Porque ImmutableArray<T> define sua própria extension method Any() em System.Linq.ImmutableArrayExtensions. O compilador C# resolve para essa, não para Enumerable.Any. O analyzer CA1860 só detecta Enumerable.Any, e nesse caso ele acertou em não disparar, o desempenho do ImmutableArray.Any() é idêntico ao IsEmpty.
3. CA1860 conflita com CA1827?
Não. CA1827 diz “use Any() em vez de Count() > 0“ (para IEnumerable<T> puro). CA1860 diz “use Length/Count/IsEmpty em vez de Any()“ (para coleções materializadas). O tipo do receiver determina qual regra aplica. Os dois analyzers trabalham em conjunto, não em conflito.
4. Como confirmo se a otimização vale para o meu cenário específico?
Rode o benchmark desta pasta com seu próprio T, sua própria coleção e seu próprio cenário de uso. Os números absolutos variam por hardware (cache, branch predictor, frequência), mas a razão entre property e Any() é robusta. Para inspeção mais fina, use DOTNET_JitDisasm e veja se o JIT inlinou a sua chamada ao get_Count.
5. CA1860 dispara em Span<T> e ReadOnlySpan<T>?
Sim, porque Span<T> tem Length exposto. Mas Span<T> não pode ser IEnumerable<T> (é um ref struct), então Span<T>.Any() chama uma extension method específica do MemoryExtensions que é ainda mais rápida que Enumerable.Any. Mesmo assim, Length > 0 é literalmente uma instrução, sempre vence.