Conteúdo

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

  1. 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 o Enumerable.Any() tem fast path para ICollection<T> que devolve Count > 0 sem alocar nada, verificável pelo source da Microsoft. Em .NET 8 o método foi refatorado (PR #78438 do Stephen Toub) para usar TryGetNonEnumeratedCount, e em .NET 9 ganhou ainda fast path para Iterator<T> (PR #99218). Os meus benchmarks no .NET 10 confirmam: zero bytes alocados.
  2. 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 para get_Count. 3 a 4 instruções inline viram uma função externa.
  3. Property vira mov+cmp, Any vira call: o Tier 1 do RyuJIT reduz _array.Length > 0 a 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.
  4. ImmutableArray.Any() é igual a IsEmpty: 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.
  5. 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() para IEnumerable<T> puro, property Length/Count/IsEmpty para 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ãoComportamento de Any() em ICollection<T>Fonte
.NET Core 3.1Sempre alocava enumeratorcorefx/release/3.1/AnyAll.cs
.NET 5Fast path para ICollection<T> adicionadoruntime/release/5.0/AnyAll.cs
.NET 6, 7Mesmo fast path mantidoruntime/release/7.0/AnyAll.cs
.NET 8Refactor para usar TryGetNonEnumeratedCountPR #78438Stephen Toub, 16/11/2022, milestone 8.0
.NET 9Fast path adicional para Iterator<T>PR #99218 — 04/03/2024
.NET 10 (medido aqui)Zero alocações confirmadasmeu 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:

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:

  1. Tipo expõe Length (caso clássico: Array, string, Span<T>).
  2. Tipo expõe Count (List<T>, HashSet<T>, Dictionary<K,V>, ICollection<T>).
  3. 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 de ImmutableArray<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:

RegraCenárioRecomendação
CA1827IEnumerable<T> com .Count() > 0 ou .LongCount() > 0Use .Any() (evita iterar a coleção inteira)
CA1828Versão async de CA1827 — .CountAsync() > 0Use .AnyAsync()
CA1829Coleção materializada com .Count() LINQUse a property Length ou Count
CA1860Coleçã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, prefira Any(). Porque Count() itera tudo, e Any() para no primeiro elemento.
  • Sobre Array, List<T>, HashSet<T> (coleções materializadas), prefira Length/Count. Porque a property é O(1) direta, e Any() 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:

MethodMeanErrorStdDevAllocated
Array_Length0,052 ns0,031 ns0,039 ns0 B
Array_Any0,825 ns0,049 ns0,065 ns0 B
List_Count0,088 ns0,028 ns0,042 ns0 B
List_Any1,174 ns0,049 ns0,046 ns0 B
HashSet_Count0,086 ns0,033 ns0,041 ns0 B
HashSet_Any1,112 ns0,055 ns0,054 ns0 B
Immutable_IsEmpty0,051 ns0,033 ns0,034 ns0 B
Immutable_Any0,050 ns0,032 ns0,037 ns0 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 BDN emite 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:

Para ArrayLength():

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():

Aqui temos um opcode call para System.Linq.Enumerable::Any<int32>. 12 bytes, mas com uma chamada externa.

Para ListCount():

callvirt para get_Count da classe concreta List<int>. O JIT vai inlinear essa chamada no Tier 1, como veremos.

Para ListAny():

Mesma estrutura de ArrayAny — chamada externa para Enumerable::Any.

E agora a parte mais interessante, ImmutableAny():

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):

Para ArrayLength (FullOpts), o JIT emitiu:

4 instruções, 15 bytes, zero chamadas. A Length virou cmp dword ptr [rax+0x08], 0, compare direto na memória.

Para ArrayAny (FullOpts):

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:

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):

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

VarianteIL bytesx64 bytesx64 instruçõesCalls
ArrayLength111540
ArrayAny122051
ListCount151540
ListAny122051

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árioPropertyAny()Diferença
1 chamada0,05 ns1,1 nsimperceptível
1 milhão de chamadas em hot path50 μs1,1 ms22x 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):

2. Any() com predicado:

3. LINQ to Entities / EF Core:

Nesses casos o CA1860 não dispara, o analyzer sabe.

Configurando severidade no .editorconfig

A severidade padrão é Suggestion. Para promover a 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.

Compartilhe:

Tiago Tartari

Tiago Tartari

Eu ajudo e capacito pessoas e organizações a transformar problemas complexos em soluções práticas usando a tecnologia para atingir resultados extraordinários.

Qual é o desafio
que você tem hoje?