Diretivas de Pré-processador e Compilação Condicional
Objective
C# não tem um pré-processador de macros como o de C, mas mantém um conjunto
pequeno de diretivas # que o compilador trata ao ler o arquivo: compilação
condicional com #define e #if, controle de diagnósticos com #pragma warning e #nullable, mapeamento de fonte com #line, diagnósticos próprios
com #error e #warning, dobra no editor com #region e, novidade do .NET 10,
as linhas #! e #: de file-based apps. Este conceito mostra cada uma no .NET
10 / C# 14, os símbolos que o SDK define (e não define), como criar os seus e
quando checagem em runtime, atributo ou feature switch é melhor que #if.
Use Cases
- Uma biblioteca para
net8.0enet10.0que só chama APIs novas onde existem. - Diagnósticos ou ganchos de teste que não podem ir para o build Release.
- Silenciar um warning conhecido em código gerado ou legado, não no projeto todo.
- Geradores de código que querem erros apontando para o template, não o
.cs.
Deep Dive
#define, #undef e #if
Um símbolo está definido ou não; ele não tem valor. #define e #undef valem
só no próprio arquivo e só no topo dele: depois de um comando, falham com
CS1032: Cannot define/undefine preprocessor symbols after first token in file. Um #endif ausente dá CS1027: #endif directive expected.
csharp#define FEATURE_A
#undef DEBUG // só neste arquivo
#if (FEATURE_A || FEATURE_B) && !DEBUG && NET10_0_OR_GREATER
Console.WriteLine("printed, even in a Debug build");
#endif#if também aceita ==, !=, #elif e true/false (#if false pula
texto que nem é C#). Símbolos diferenciam maiúsculas, e um #if FEATURE_A em
outro arquivo do projeto caiu no #else: #define não vaza entre arquivos.
Os símbolos que o SDK define
textdotnet run DEBUG TRACE NET NETCOREAPP NET10_0 NET10_0_OR_GREATER ... dotnet run -c Release RELEASE TRACE NET NETCOREAPP NET10_0 NET10_0_OR_GREATER ... dotnet run -c Staging TRACE NET NETCOREAPP NET10_0 NET10_0_OR_GREATER ...
Esses são os blocos #if que compilaram num console app net10.0. Ou seja,
RELEASE existe no Release (a página do pré-processador só cita DEBUG e
TRACE), e uma configuração própria define o próprio nome: dotnet msbuild -getProperty:DefineConstants -p:Configuration=Staging imprime TRACE;STAGING,
então um #if !DEBUG pensado como "produção" é verdadeiro em Staging. Um alvo
netstandard2.0 recebe NETSTANDARD;NETSTANDARD2_0;NETSTANDARD2_0_OR_GREATER.
Com <TargetFrameworks>net8.0;net10.0</TargetFrameworks>, um #if NET10_0_OR_GREATER ... #elif NET8_0 que imprime o ramo imprimiu NET8_0 branch, runtime 8.0.22 em bin/Debug/net8.0 e NET10_0_OR_GREATER branch, runtime 10.0.0 em bin/Debug/net10.0. dotnet run sozinho pede "Specify
which framework to run using '--framework'". Prefira checagens _OR_GREATER.
Seus próprios símbolos via MSBuild e linha de comando
plaintext<DefineConstants>$(DefineConstants);FEATURE_X</DefineConstants> <DefineConstants Condition="'$(Foo)' == 'bar'">$(DefineConstants);FOO_BAR</DefineConstants>
textdotnet run DEBUG TRACE NET10_0 FEATURE_X dotnet run -p:Foo=bar DEBUG TRACE NET10_0 FEATURE_X FOO_BAR dotnet run -p:DefineConstants=FEATURE_X NET10_0 FEATURE_X
A última linha é a armadilha: DefineConstants na linha de comando é uma
propriedade global que substitui o valor do projeto, e DEBUG e TRACE somem
(o SDK acrescenta os de framework depois, em AddImplicitDefineConstants).
Vários símbolos pedem -p:DefineConstants=A%3BB ou "A;B". Uma propriedade
própria mapeada no projeto, como -p:Foo=bar, é mais segura.
Checagem de plataforma é em runtime
Um build net10.0 não tem símbolo WINDOWS, LINUX ou OSX (pulados neste
Mac); só o alvo net10.0-windows define WINDOWS. O mesmo binário roda em
todo lugar, então cheque o SO em runtime e deixe o analisador checar a guarda:
csharpConsole.WriteLine(OperatingSystem.IsMacOS()); // True
Console.WriteLine(RuntimeInformation.IsOSPlatform(OSPlatform.Linux)); // False
WinOnly.ReadRegistry(); // CA1416: 'WinOnly.ReadRegistry()' is only supported on: 'windows'.
if (OperatingSystem.IsWindows())
WinOnly.ReadRegistry(); // sem warning: protegido
static class WinOnly { [SupportedOSPlatform("windows")] public static void ReadRegistry() { } }O custo das florestas de #if
Código num ramo inativo não é compilado, então nada o verifica:
csharp#if NET10_0_OR_GREATER
return string.GetHashCode(s.AsSpan());
#else
return s.GetHashCod(); // erro de digitação
#endifdotnet build -f net10.0 mostra Build succeeded.; o build de todos os alvos
falha no net8.0 com error CS1061: 'string' does not contain a definition for 'GetHashCod'. A CI precisa compilar toda configuração e todo alvo.
Alternativas ao #if
[Conditional("DEBUG")]remove a chamada e a avaliação dos argumentos (veja Atributos: Personalizados e Lidos pelo Compilador).Debug.Assertusa isso: umDebug.Assert(Probe(), "Probe failed")falho imprimiuProcess terminated. Assertion failed.e saiu com 134 em Debug; em ReleaseProbe()nem rodou.- Um feature switch muda o comportamento sem recompilar. O item
<RuntimeHostConfigurationOption Include="MyApp.UseNewParser" Value="true" Trim="true" />vai para oruntimeconfig.json;AppContext.TryGetSwitchretornoutrue, efalsedepois de editar o JSON. Segundo a documentação,[FeatureSwitchDefinition](.NET 9) permite que "IL rewriters and compilers" substituam o valor do switch: é assim que trimming e Native AOT cortam código. const bool On = falsefaz o compilador C# descartar o ramo (CS0162: Unreachable code detected). Umstatic readonly boollido de variável de ambiente fica no IL, mas oDOTNET_JitDisasm(Release, arm64) mostrou o JIT dobrando o valor: 104 bytes com load e desvio no Tier0, 20 bytes no Tier1 (mov w0, #2); com a variável definida, o Tier1 manteve a chamada e tirou o teste. Os dois ramos continuam verificados pelo compilador.
#error e #warning
csharp#warning DEBUG build: the fake payment gateway is wired in
#error TODO: pick a serializer before shipping
// Program.cs(1,10): warning CS1030: #warning: 'DEBUG build: the fake payment gateway is wired in'
// Program.cs(2,8): error CS1029: #error: 'TODO: pick a serializer before shipping'Um #warning mantém Build succeeded.; #error quebra o build (exit code 1),
então #if !NET10_0_OR_GREATER com #error protege um alvo. #error version
também imprime CS8304 com as versões do compilador e da linguagem.
#pragma warning e #nullable
csharpint a = 1; // linha 1: CS0219
#pragma warning disable CS0219 // "219" sem o prefixo também funciona
int b = 2;
#pragma warning restore CS0219
int c = 3; // linha 5: CS0219 de novoSó as linhas 1 e 5 geraram warning. Sem lista, disable silencia todos até um
restore sem lista, e restore significa "voltar à configuração do projeto",
não "ligar": com -p:NoWarn=CS0219 a linha 5 ficou quieta, com
-p:WarningsAsErrors=CS0219 virou erro. Ids de analisadores também funcionam
(CA1416 acima). A documentação diz que um disable vale até o fim do
arquivo; #pragma warning enable dá CS1634: Expected 'disable' or 'restore'.
#nullable é o vizinho para a análise de nulos. Num projeto com
Nullable=enable, string a = null; fica quieto depois de #nullable disable
e volta a dar CS8600 depois de #nullable restore. Os flags se separam: com
#nullable enable annotations, string? d é válido e d.Length não avisa;
com #nullable enable warnings, string? f dá CS8632 (anotação fora de um
contexto de anotações) e f.Length ainda dá CS8602.
#line, #pragma checksum e #region
#line muda o arquivo e a linha que o compilador reporta, nos diagnósticos e
no PDB. Duas linhas (int unused = 0; e uma chamada que lança exceção) entre
#line 200 "Template.tt" e #line default deram:
textTemplate.tt(200,13): warning CS0219: The variable 'unused' is assigned but its value is never used at Gen.Throw() in Program.cs:line 13 at Gen.Run() in Template.tt:line 201
#line hidden esconde linhas do depurador (uma exceção lançada ali apareceu na
{ do método). A forma de intervalo que o Razor usa, #line (3, 5) - (3, 30) 7 "Page.razor", mapeou um warning para Page.razor(3,10). Segundo a
documentação, #pragma checksum grava no PDB o checksum do arquivo original.
#region/#endregion não mudam a saída, só dobram código no editor, e devem
aninhar com #if (cruzar deu CS1027 e CS1038). Uma região de 300 linhas
pede dividir a classe; o StyleCop tem a regra SA1124 contra regiões.
#! e #: em file-based apps
No SDK 10, dotnet run tool.cs compila um arquivo só; as linhas #:
substituem o arquivo de projeto (detalhes em File-Based Apps):
csharp#!/usr/bin/env -S dotnet --
#:package [email protected]
#:property DefineConstants=$(DefineConstants);FROM_FILE
using Humanizer;
Console.WriteLine(TimeSpan.FromSeconds(4000).Humanize(2)); // 1 hour, 6 minutes
#if FROM_FILE && DEBUG
Console.WriteLine("FROM_FILE and DEBUG are defined"); // impresso
#endifCom a pasta do NuGet vazia, a primeira execução restaurou humanizer.core e os
pacotes do ILCompiler (file-based apps usam PublishAot por padrão), compilou
um projeto virtual em ~/Library/Application Support/dotnet/runfile/ em cerca
de 2,7 s; a seguinte reaproveitou o build (cerca de 0,15 s).
A página do pré-processador diz que o compilador ignora #! e #: e que
builds de projeto avisam. No SDK 10.0.100, num projeto normal, são erros:
texterror CS9314: '#!' directives can be only used in scripts or file-based programs error CS9298: '#:' directives can be only used in file-based programs ('-features:FileBasedProgram')
Trade-offs
#ifé de graça em runtime e caro em manutenção. Ramos mortos nunca são verificados, então cada símbolo precisa do próprio build na CI.- Checagem em runtime ganha de símbolo de plataforma.
IsWindows()com CA1416 mantém um binário e um caminho;#if WINDOWSexige alvo de SO. - Suprima com precisão. Um id e um
restoredocumentam uma exceção no lugar;disablesem lista ouNoWarnno projeto escondem problemas futuros.
Documentation Links
- C# preprocessor directives, Microsoft Learn
- Target frameworks in SDK-style projects (preprocessor symbols), Microsoft Learn
- C# compiler options for language features (DefineConstants), Microsoft Learn
- File-based apps reference, Microsoft Learn
- Platform compatibility analyzer, Microsoft Learn
- FeatureSwitchDefinitionAttribute class, Microsoft Learn