CA1859 é uma regra de análise de código do .NET que sinaliza variáveis locais, parâmetros privados e tipos de retorno declarados como interface quando o tipo concreto poderia ser usado. A motivação é puramente de performance: declarar IList<int> em vez de List<int> impede o RyuJIT de aplicar devirtualização e inlining, deixando chamadas indiretas via Virtual Stub Dispatch dentro do loop quente. O warning existe desde o .NET 7, mas a severidade padrão Suggestion faz com que a maioria dos times o ignore. Este artigo abre uma sub-série sobre os warnings de performance do .NET 10 que ninguém olha. Vamos dissecar o CA1859 do código C# até o Assembly x64 real emitido pelo JIT no .NET 10.0.1, passando pelo IL real produzido pelo compilador, pelo mecanismo interno de despacho de interfaces no CoreCLR e por benchmark real com BenchmarkDotNet 0.15.4.
Insights
- CA1859 é um warning silencioso: o Roslyn analyzer já entrega o diagnóstico desde o .NET 7, mas a severidade padrão é Suggestion. Ele aparece como rabisco discreto no editor e quase nunca é tratado. O custo é real e mensurável.
- O IL gerado é praticamente idêntico, o JIT é quem decide: tanto a versão por interface quanto a versão por tipo concreto compilam para o mesmo opcode
callvirt, e o tamanho do IL é exatamente o mesmo (38 bytes nos meus dois métodos). A diferença explosiva acontece no RyuJIT, e fica visível apenas no Assembly x64 final. - Despachar por interface custa um Virtual Stub Dispatch: o CoreCLR resolve interfaces via uma máquina de três estágios — lookup stub, dispatch stub e resolve stub. É uma chamada indireta com cache, não uma chamada direta. Não dá para inlinear.
- Tipo concreto desbloqueia inlining e elimina chamadas redundantes: no meu benchmark, a versão interface fica 1,98x mais lenta que a versão concreta. Pior: o JIT não consegue provar que
IList<T>.Counté invariante e chamaget_Counta cada iteração do loop. Na versão concreta, vira ummovúnico. - CA1859 continua valendo na era do Dynamic PGO: o .NET 10 traz PGO ligado por padrão e ajuda em call sites quentes. Mas isso não cobre Native AOT, startup, código frio ou call sites polimórficos. A regra estática ainda paga.
A história começa com um agente de IA
Construímos um agente de IA para revisar pull requests automaticamente no nosso pipeline. Nada sofisticado, um workflow que roda no momento do build de validação, lê o diff, executa os Roslyn analyzers configurados no projeto e comenta achados relevantes na PR. Na primeira execução em um repositório de produção, ele apontou múltiplas ocorrências de CA1859 que estavam passando despercebidas.
Ninguém tinha tratado. Ninguém estava olhando.
O Visual Studio mostra esse warning como rabisco amarelo discreto, severidade Suggestion, embaixo do tipo. Não quebra build. Não aparece no output do dotnet build por padrão. É exatamente o tipo de diagnóstico que o desenvolvedor aprende a ignorar.
Esse artigo abre uma sub-série dentro da série .NET deste blog: warnings de performance que ninguém olha. A proposta é simples — pegar cada regra de análise de código com prefixo CA18xx (a faixa de performance do Microsoft.CodeAnalysis.NetAnalyzers), reproduzir o cenário, mostrar o IL gerado pelo compilador, o Assembly x64 emitido pelo JIT e medir o impacto real com BenchmarkDotNet. Sem achismo, sem “isso é mais rápido porque sim”. O CA1859 é o primeiro porque é o mais comum e o mais mal compreendido.
Sobre reprodutibilidade: todo o código, output de IL, output do
DOTNET_JitDisasme o log completo do benchmark deste artigo estão emdotnet/samples/05-ca1859/. Você pode rodar localmente e comparar com a sua máquina.
O que o CA1859 detecta exatamente
A regra CA1859 está documentada como “Use concrete types when possible for improved performance”. Foi introduzida no pacote Microsoft.CodeAnalysis.NetAnalyzers 7.0, distribuído com o .NET 7 SDK e ativo por padrão em todos os SDKs subsequentes, incluindo o .NET 10.
Ela dispara em três cenários:
- Variável local declarada como interface quando o construtor visível atribui um tipo concreto.
- Parâmetro de método privado ou interno tipado como interface quando todas as chamadas passam o mesmo tipo concreto.
- Tipo de retorno de método privado ou interno declarado como interface quando o método sempre retorna o mesmo tipo concreto.
A regra não dispara para APIs públicas. Trocar o tipo de retorno público de IList<int> para List<int> é uma quebra de contrato — o analyzer respeita isso.
O exemplo mínimo que dispara o warning (este é o código real que rodei):
|
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 |
namespace IlDump; public class Calculadora { private readonly List<int> _list; public Calculadora() { _list = new List<int>(); for (int i = 0; i < 10; i++) _list.Add(i); } public int SomarConcreto() { List<int> numeros = _list; int total = 0; for (int i = 0; i < numeros.Count; i++) total += numeros[i]; return total; } public int SomarInterface() { IList<int> numeros = _list; int total = 0; for (int i = 0; i < numeros.Count; i++) total += numeros[i]; return total; } } |
Ao executar dotnet build -c Release com analyzers ativados, o output literal do .NET 10 SDK 10.0.101 é:
warning CA1859: Change type of variable 'numeros' from
'System.Collections.Generic.IList<int>' to
'System.Collections.Generic.List<int>' for improved performance
Visualmente os dois métodos são quase idênticos. A diferença mora no que o JIT pode fazer com cada versão.
O IL gerado é praticamente o mesmo
Pode parecer contraintuitivo, mas o IL produzido pelo compilador C# para as duas versões tem exatamente o mesmo tamanho — 38 bytes cada. Vamos confirmar usando o ilspycmd, a CLI oficial do ILSpy:
|
1 2 |
dotnet tool install -g ilspycmd ilspycmd -il -t "IlDump.Calculadora" bin/Release/net10.0/IlDump.dll |
Para SomarConcreto, o output literal é:
|
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 32 33 34 35 36 37 38 39 40 41 |
.method public hidebysig instance int32 SomarConcreto () cil managed { // Code size: 38 (0x26) .maxstack 3 .locals init ( [0] class [System.Collections]System.Collections.Generic.List`1<int32>, [1] int32, [2] int32 ) IL_0000: ldarg.0 IL_0001: ldfld class [System.Collections]System.Collections.Generic.List`1<int32> IlDump.Calculadora::_list IL_0006: stloc.0 IL_0007: ldc.i4.0 IL_0008: stloc.1 IL_0009: ldc.i4.0 IL_000a: stloc.2 IL_000b: br.s IL_001b // loop IL_000d: ldloc.1 IL_000e: ldloc.0 IL_000f: ldloc.2 IL_0010: callvirt instance !0 class [System.Collections]System.Collections.Generic.List`1<int32>::get_Item(int32) IL_0015: add IL_0016: stloc.1 IL_0017: ldloc.2 IL_0018: ldc.i4.1 IL_0019: add IL_001a: stloc.2 IL_001b: ldloc.2 IL_001c: ldloc.0 IL_001d: callvirt instance int32 class [System.Collections]System.Collections.Generic.List`1<int32>::get_Count() IL_0022: blt.s IL_000d IL_0024: ldloc.1 IL_0025: ret } |
Para SomarInterface:
|
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 32 33 34 35 36 37 38 39 40 41 |
.method public hidebysig instance int32 SomarInterface () cil managed { // Code size: 38 (0x26) .maxstack 3 .locals init ( [0] class [System.Runtime]System.Collections.Generic.IList`1<int32>, [1] int32, [2] int32 ) IL_0000: ldarg.0 IL_0001: ldfld class [System.Collections]System.Collections.Generic.List`1<int32> IlDump.Calculadora::_list IL_0006: stloc.0 IL_0007: ldc.i4.0 IL_0008: stloc.1 IL_0009: ldc.i4.0 IL_000a: stloc.2 IL_000b: br.s IL_001b // loop IL_000d: ldloc.1 IL_000e: ldloc.0 IL_000f: ldloc.2 IL_0010: callvirt instance !0 class [System.Runtime]System.Collections.Generic.IList`1<int32>::get_Item(int32) IL_0015: add IL_0016: stloc.1 IL_0017: ldloc.2 IL_0018: ldc.i4.1 IL_0019: add IL_001a: stloc.2 IL_001b: ldloc.2 IL_001c: ldloc.0 IL_001d: callvirt instance int32 class [System.Runtime]System.Collections.Generic.ICollection`1<int32>::get_Count() IL_0022: blt.s IL_000d IL_0024: ldloc.1 IL_0025: ret } |
Três observações que valem ouro:
- Tamanho idêntico: 38 bytes em ambos. O compilador C# emite estritamente o que você escreveu.
callvirtem ambos os casos: o C# emitecallvirtmesmo para métodos não-virtuais — um truque para garantir null check automático no receptor. O JIT tratacallvirtem método não-virtual como chamada direta.- Detalhe sutil: na versão interface,
numeros.Countresolve paraICollection\1::get_Count, nãoIList\1::get_Count. Isso porqueCounté definido emICollection<T>, eIList<T>apenas herda. O JIT precisa fazer a resolução completa de interface para chegar lá.
A diferença real está no token de método referenciado em cada callvirt: List\1::get_Item vs IList\1::get_Item, e List\1::get_Count vs ICollection\1::get_Count. Esse detalhe parece pequeno, mas é tudo o que o JIT precisa para tomar decisões radicalmente diferentes.
Como o CoreCLR despacha chamadas de interface
Para entender por que IList<int>::get_Count custa caro, precisamos olhar como o CoreCLR resolve chamadas de interface em runtime. O mecanismo se chama Virtual Stub Dispatch (VSD) e está documentado no repositório dotnet/runtime.
O VSD opera em três estágios para cada call site:
flowchart TD
A["Call site emite callvirt em IList<T>::get_Count"] --> B[Primeira execução]
B --> C[Lookup Stub]
C --> D[Resolve método na MethodTable do tipo recebido]
D --> E[Patch do call site para Dispatch Stub monomórfico]
E --> F[Executa método resolvido]
G[Execução posterior] --> H{"Tipo do receptor<br/>bate com o cache?"}
H -->|Sim, sempre o mesmo tipo| I["Dispatch Stub<br/>type check + call direto"]
I --> F
H -->|Não, vários tipos| J["Resolve Stub<br/>cache de N entradas"]
J --> K{Hit no cache?}
K -->|Sim| F
K -->|Não| L["Resolução completa<br/>via MethodTable"]
L --> FA primeira chamada é a mais cara — passa por um lookup stub que faz a resolução completa. Depois, o call site é “patcheado” para apontar direto para um dispatch stub monomórfico, que faz uma comparação de tipo simples e chama o método. Se o call site receber tipos diferentes ao longo do tempo, ele é promovido para resolve stub, que mantém um cache pequeno de tipos vistos recentemente.
Em todos os casos, há indireção. O JIT não tem como inlinear a chamada porque a tabela de despacho só é resolvida em runtime.
Como o RyuJIT trata o tipo concreto
Quando o IL referencia List\1::get_Count em vez de IList\1::get_Count, o jogo muda. O JIT executa a fase de devirtualização e tenta provar que a chamada pode ser substituída por uma chamada direta. Se conseguir, ele aciona inlining logo em seguida.
Para List<T>::get_Count, o JIT enxerga:
List<T>é selada de fato no contexto do call site (não há subclasses observáveis).get_Counté um método trivial que apenas retorna o campo_size.- A property tem corpo curto e é bom candidato a inlining.
Resultado: a chamada some. O JIT a substitui por um acesso direto ao campo _size da instância de List<T>. Vira um mov único.
flowchart LR
A["callvirt em tipo concreto List<T>"] --> B{"JIT consegue<br/>devirtualizar?"}
B -->|Sim, tipo exato| C[Devirtualização total]
C --> D[Inlining se método pequeno]
D --> E["Acesso direto a campo<br/>mov r8d, [rax+0x10]"]
B -->|Não, sem PGO| F["callvirt comum<br/>vtable lookup"]
B -->|Não, com PGO ativo| G["Guarded Devirtualization<br/>type check + direct call + fallback"]
G --> DInspecionando o Assembly x64 com DOTNET_JitDisasm
A forma direta de ver o que o JIT emite no .NET 10 é via a variável de ambiente DOTNET_JitDisasm. O runtime imprime o Assembly nativo para os métodos especificados no momento da compilação JIT.
Crie um console mínimo (Disasm/Program.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 32 33 34 35 36 37 38 39 40 41 42 43 44 |
using System.Runtime.CompilerServices; var calc = new Calculadora(); for (int i = 0; i < 100_000; i++) { calc.SomarConcreto(); calc.SomarInterface(); } Console.WriteLine($"Concreto: {calc.SomarConcreto()}"); Console.WriteLine($"Interface: {calc.SomarInterface()}"); public class Calculadora { private readonly List<int> _list; public Calculadora() { _list = new List<int>(); for (int i = 0; i < 1000; i++) _list.Add(i); } [MethodImpl(MethodImplOptions.NoInlining)] public int SomarConcreto() { List<int> numeros = _list; int total = 0; for (int i = 0; i < numeros.Count; i++) total += numeros[i]; return total; } [MethodImpl(MethodImplOptions.NoInlining)] public int SomarInterface() { IList<int> numeros = _list; int total = 0; for (int i = 0; i < numeros.Count; i++) total += numeros[i]; return total; } } |
E execute (PowerShell):
|
1 2 3 4 5 |
$env:DOTNET_JitDisasm = "Calculadora:Somar*" $env:DOTNET_TieredCompilation = "1" $env:DOTNET_TieredPGO = "0" $env:DOTNET_JitDisasmDiffable = "1" dotnet run -c Release |
Por que
TieredPGO=0? Quero o caso “puro” da chamada por interface, sem a Devirtualização Guiada por Perfil mascarando a indireção. PGO entra na próxima seção. Por queJitDisasmDiffable=1? Substitui endereços absolutos por placeholders (0xD1FFAB1E), deixando o output estável entre execuções e fácil de comparar.
O output literal do meu .NET 10.0.1 para SomarConcreto (Tier 1, otimizado) é:
|
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 32 33 34 35 36 37 38 39 40 41 42 43 44 |
; Assembly listing for method Calculadora:SomarConcreto():int:this (Tier1) ; Tier1 code ; optimized code ; No PGO data ; 1 inlinees with PGO data; 1 single block inlinees; 0 inlinees without PGO data G_M000_IG01: sub rsp, 40 G_M000_IG02: mov rax, gword ptr [rcx+0x08] ; rax = this._list xor ecx, ecx ; total = 0 xor edx, edx ; i = 0 mov r8d, dword ptr [rax+0x10] ; r8d = numeros._size (Count INLINADO e HOISTADO) test r8d, r8d jle SHORT G_M000_IG04 align [11 bytes for IG03] G_M000_IG03: cmp edx, r8d jae SHORT G_M000_IG06 mov r10, gword ptr [rax+0x08] ; r10 = numeros._items cmp edx, dword ptr [r10+0x08] ; bounds check do array jae SHORT G_M000_IG07 add ecx, dword ptr [r10+4*rdx+0x10] ; total += _items[i] inc edx cmp edx, r8d jl SHORT G_M000_IG03 G_M000_IG04: mov eax, ecx G_M000_IG05: add rsp, 40 ret G_M000_IG06: call [System.ThrowHelper:ThrowArgumentOutOfRange_IndexMustBeLessException()] int3 G_M000_IG07: call CORINFO_HELP_RNGCHKFAIL int3 ; Total bytes of code 79 |
Repare: nenhuma chamada indireta dentro do loop. numeros.Count virou mov r8d, [rax+0x10] (acesso ao campo _size) e foi hoistado para fora do loop. numeros[i] virou add ecx, [r10+4rdx+0x10] — acesso direto ao array _items com bounds check* embutido. O JIT até identificou que List<T> armazena os itens em um T[] interno chamado _items e expandiu o indexador inteiramente.
O output literal para SomarInterface (Tier 1, otimizado) é radicalmente diferente:
|
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 32 33 34 35 36 37 38 39 40 41 42 43 44 45 |
; Assembly listing for method Calculadora:SomarInterface():int:this (Tier1) ; Tier1 code ; optimized code ; No PGO data G_M000_IG01: push rdi push rsi push rbx sub rsp, 32 G_M000_IG02: mov rbx, gword ptr [rcx+0x08] ; rbx = this._list xor esi, esi ; total = 0 xor edi, edi ; i = 0 mov rcx, rbx mov r11, 0xD1FFAB1E ; endereço do dispatch stub call [r11]System.Collections.Generic.ICollection`1[int]:get_Count():int:this test eax, eax jle SHORT G_M000_IG04 G_M000_IG03: mov rcx, rbx mov edx, edi mov r11, 0xD1FFAB1E call [r11]System.Collections.Generic.IList`1[int]:get_Item(int):int:this add esi, eax inc edi mov rcx, rbx mov r11, 0xD1FFAB1E ; <-- recarrega o stub a cada iteração call [r11]System.Collections.Generic.ICollection`1[int]:get_Count():int:this cmp eax, edi jg SHORT G_M000_IG03 G_M000_IG04: mov eax, esi G_M000_IG05: add rsp, 32 pop rbx pop rsi pop rdi ret ; Total bytes of code 87 |
Cada iteração do loop tem duas chamadas indiretas via VSD: uma para get_Item e outra para get_Count. get_Count está sendo chamado a cada iteração porque o JIT não consegue provar que o resultado é invariante para uma IList<T> — o tipo concreto é desconhecido em tempo de compilação, então qualquer chamada a um método pode mutar o estado da coleção.
Na versão concreta, o JIT prova que List<T>.Count lê apenas um campo e hoista para fora do loop. Na versão interface, o JIT é obrigado a chamar a cada iteração.
Importante: a saída exata do
DOTNET_JitDisasmvaria entre versões do .NET, arquiteturas (x64, arm64) e estado do PGO. Os trechos acima foram capturados em .NET 10.0.1, Windows 11, x86-64-v3, comTieredPGO=0eMethodImpl(NoInlining)para que os métodos não fossem absorvidos peloMain.
O Dynamic PGO mitiga, mas não elimina
O .NET 10 mantém o Dynamic PGO ligado por padrão, recurso introduzido no .NET 8 que coleta dados de execução em runtime e usa para recompilar métodos quentes com decisões mais agressivas. Para chamadas de interface, o PGO faz devirtualização guiada por perfil (Guarded Devirtualization ou GDV).
Como funciona:
- Na compilação tier-0, o JIT instrumenta o call site de interface para gravar quais tipos passam por ele.
- Após algumas centenas de execuções, o método é promovido para tier-1.
- Se o call site foi monomórfico (sempre o mesmo tipo concreto, ex.
List<int>), o JIT emite um type check + chamada direta + fallback para VSD em caso de tipo inesperado.
O resultado se aproxima do código com tipo concreto, mas com três ressalvas importantes:
1. Custo do warm-up. O método precisa rodar centenas de vezes em tier-0 antes de ser recompilado. Em endpoints HTTP de baixa frequência, isso pode nunca acontecer. Em startup, você paga o custo total da interface.
2. Native AOT não tem JIT. No modo Native AOT do .NET 10, todo o código é compilado ahead-of-time sem JIT. Não existe Dynamic PGO. A devirtualização precisa ser estática, e o CA1859 vira essencial.
3. Call sites polimórficos não se beneficiam. Se o seu método recebe ora List<T>, ora T[], ora Collection<T>, o GDV não consegue escolher um tipo dominante. Você fica com o VSD eterno.
A regra simples: declare tipos concretos quando souber o tipo concreto. PGO é um mecanismo de mitigação, não uma desculpa para escrever código pior.
Medindo com BenchmarkDotNet
Vamos quantificar com um benchmark honesto. Crie o projeto:
|
1 2 3 |
dotnet new console -n Bench -f net10.0 cd Bench dotnet add package BenchmarkDotNet --version 0.15.4 |
E o código (Program.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 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 |
using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Configs; using BenchmarkDotNet.Diagnosers; using BenchmarkDotNet.Jobs; using BenchmarkDotNet.Running; var config = ManualConfig.Create(DefaultConfig.Instance) .AddDiagnoser(MemoryDiagnoser.Default) .AddJob(Job.Default .WithRuntime(BenchmarkDotNet.Environments.CoreRuntime.Latest) .WithId("net10.0")); BenchmarkRunner.Run<DispatchBench>(config); [MemoryDiagnoser] public class DispatchBench { private List<int> _list = null!; [GlobalSetup] public void Setup() { _list = new List<int>(); for (int i = 0; i < 1000; i++) _list.Add(i); } [Benchmark(Baseline = true)] public int SomarConcreto() { List<int> numeros = _list; int total = 0; for (int i = 0; i < numeros.Count; i++) total += numeros[i]; return total; } [Benchmark] public int SomarInterface() { IList<int> numeros = _list; int total = 0; for (int i = 0; i < numeros.Count; i++) total += numeros[i]; return total; } } |
Execute em modo Release:
|
1 |
dotnet run -c Release |
Resultado real na minha máquina (Intel Core i7-8665U @ 1,90 GHz, Windows 11, .NET 10.0.1, BenchmarkDotNet 0.15.4, 14 amostras, PGO ligado por padrão):
| Method | Mean | Error | StdDev | Ratio | RatioSD | Allocated |
|---|---|---|---|---|---|---|
| SomarConcreto | 523,0 ns | 6,58 ns | 5,84 ns | 1,00 | 0,02 | – |
| SomarInterface | 1.035,8 ns | 15,98 ns | 14,16 ns | 1,98 | 0,03 | – |
A versão por interface é 1,98 vez mais lenta — quase exatamente o dobro. Esse número é com Dynamic PGO ativo (default no .NET 10). Sem PGO, o gap aumenta porque o GDV deixa de transformar uma das chamadas em direta. O log completo do benchmark, com todos os percentis, estatísticas e outliers, está em samples/05-ca1859/Bench/.
Nota metodológica: rode você mesmo no seu hardware. Resultados de benchmark dependem de CPU, frequência, cache, estado térmico e versão exata do runtime. O ponto não é o número absoluto — é a presença consistente de uma diferença substancial que o CA1859 está apontando. Em uma CPU mais recente (geração Tiger Lake ou superior, com melhor branch predictor), o gap pode ser menor; em ARM ou em CPUs com cache menor, maior.
Quando o CA1859 não dispara e por quê
O analyzer é deliberadamente conservador. Ele não dispara nestes casos:
- API pública: tipo de retorno ou parâmetro de método público, ou propriedade pública. Trocar quebraria contrato com chamadores.
- Tipo concreto desconhecido: se o método pode retornar várias implementações dependendo do flow, o analyzer respeita a abstração.
- Campos: a regra foca em variáveis locais, parâmetros e retornos. Campos têm regras separadas (CA1051, CA1819 para arrays).
- Quando o tipo concreto não tem o membro chamado: se o código usa só métodos da interface e o tipo concreto não os expõe diretamente, trocar não compila.
Esses limites existem para evitar falsos positivos. Quando o CA1859 dispara, ele tem alta confiança. Trate.
Configurando severidade no .editorconfig
A severidade padrão é Suggestion, o que significa rabisco discreto e nada no output do build. Para que ele apareça no console e quebre builds de CI, promova a severidade no .editorconfig:
|
1 2 3 4 5 6 |
# .editorconfig na raiz do repositório root = true [*.cs] # CA1859: Use concrete types when possible for improved performance dotnet_diagnostic.CA1859.severity = warning |
Combinado com <EnableNETAnalyzers>true</EnableNETAnalyzers> e <AnalysisMode>All</AnalysisMode> no .csproj, o warning aparece no output do dotnet build exatamente como capturado neste artigo.
Para ser mais agressivo e quebrar o build:
|
1 2 |
[*.cs] dotnet_diagnostic.CA1859.severity = error |
Combine com <TreatWarningsAsErrors>true</TreatWarningsAsErrors> no .csproj para que o CI falhe automaticamente.
Em projetos com legado pesado, comece com warning para todo o código novo:
|
1 2 3 4 5 6 |
[*.cs] dotnet_diagnostic.CA1859.severity = warning # Suprime no código legado [Legado/**.cs] dotnet_diagnostic.CA1859.severity = none |
E vá removendo o supressor por pasta conforme refatora. Não tente corrigir todas as ocorrências em uma PR só — vire um movimento gradual.
Quando interfaces ainda fazem sentido
Esse artigo defende tipos concretos onde fazem sentido. Não está dizendo para abandonar interfaces. Use interface quando:
- Inversão de dependência: o consumidor não deve conhecer o produtor. Padrão clássico em arquitetura limpa.
- Mocking em testes: substituir a implementação real por um mock exige interface ou classe virtual.
- Múltiplas implementações reais: quando o código de fato precisa rodar com diferentes tipos concretos.
- Boundary entre módulos ou assemblies: APIs públicas devem expor abstrações.
O CA1859 não viola nenhum desses cenários — porque ele não dispara em APIs públicas e não dispara quando o tipo concreto é ambíguo. Ele vive no espaço onde você abstraiu por hábito, sem necessidade real.
Conclusão
CA1859 é o exemplo perfeito de um warning de performance que custa pouco para corrigir e paga muito em hot paths. A diferença entre IList<int> e List<int> em variáveis locais não é estética — ela determina se o JIT emite duas chamadas indiretas via VSD por iteração ou um acesso direto a campo no Assembly gerado, com Count hoistado para fora do loop.
Esse foi o primeiro artigo da sub-série warnings de performance que ninguém olha. Os próximos vão atacar CA1860 (preferir Length/Count/IsEmpty em vez de Any() para enumeração), CA1861 (não construir arrays como argumentos em loops), CA1862 (usar StringComparison em vez de ToLower/ToUpper para comparação) e seguir pela faixa CA18xx. Em todos, vamos do C# ao Assembly real, mostrando que o JIT é a parte mais interessante e mais ignorada do .NET.
Se você usa um agente de IA no seu pipeline, deixe os Roslyn analyzers configurados como warning ou error. Se não usa, considere. Foi o agente que me fez parar e olhar para o que o JIT estava fazendo com o nosso código — algo que eu deveria ter feito sozinho há tempos.
FAQ
1. Por que o compilador C# não emite o tipo concreto automaticamente quando vê o construtor?
O compilador C# trabalha em nível de tipos declarados, não inferidos do flow. Quando você escreve IList<int> x = new List<int>(), o tipo da variável é IList<int> por declaração — o compilador respeita o que você pediu. O Roslyn analyzer CA1859 atua em uma fase posterior, fazendo análise de data flow para identificar quando o tipo concreto poderia ser usado sem perda de generalidade. A separação é proposital: o compilador não toma decisões de performance por você.
2. O Dynamic PGO do .NET 10 não resolve isso automaticamente em runtime?
Resolve parcialmente. No meu benchmark, com PGO ativo (default), a versão interface ficou 1,98x mais lenta — o PGO mitigou bastante, mas não eliminou. PGO precisa de warm-up (centenas a milhares de execuções), não funciona em Native AOT, não ajuda em código frio ou de startup e não resolve call sites polimórficos. CA1859 é uma garantia estática que vale em todos os cenários.
3. Vale corrigir CA1859 em um sistema sem performance crítica?
Vale, com moderação. O custo de corrigir é baixíssimo — geralmente é trocar uma palavra. O ganho individual em código não-quente é pequeno, mas o efeito acumulado em um codebase grande é mensurável em métricas de throughput e latency tail. Mais importante: corrigir sistematicamente reduz o ruído no output dos analyzers e treina o time a tratar avisos. Diagnóstico ignorado vira diagnóstico desativado.
4. CA1859 quebra o princípio de programar para interfaces?
Não. O analyzer respeita explicitamente APIs públicas e cenários onde a abstração é necessária. Ele só dispara em variáveis locais, parâmetros privados e retornos privados, escopos onde “programar para interface” não traz benefício de desacoplamento, apenas custo de performance. Inversão de dependência continua válida onde faz sentido arquitetural.
5. Como sei se o JIT realmente devirtualizou meu código?
Use DOTNET_JitDisasm apontando para o método em questão e inspecione o Assembly emitido. Procure por call [r11]<NomeDaInterface> (chamada indireta, sinal de VSD) versus mov direto (devirtualizado e inlinado) ou call <endereço-direto> (devirtualizado mas não inlinado). Para uma análise mais ampla, ferramentas como Disasmo (extensão do Visual Studio) e o SharpLab facilitam a leitura. Em CI, você pode capturar o output do JitDisasm em test runs específicos para detectar regressões.