Primary constructor no C#: o que muda na inicialização

O primary constructor no C# parece apenas um atalho de sintaxe, mas ele muda o momento em que o valor do parâmetro passa a existir dentro do objeto. Neste conteúdo, você vai ver por que essa diferença aparece quando a classe base chama um membro virtual e o que o compilador gera em cada caso.
O que é o primary constructor no C#
O primary constructor chegou às classes e structs no C# 12 e continua disponível no C# 14, que acompanha o .NET 10, a versão LTS atual. Com ele, os parâmetros do construtor ficam declarados ao lado do nome do tipo e permanecem visíveis em todo o corpo da classe.
Antes, esse recurso existia apenas em records. Agora, qualquer classe comum pode declarar seus parâmetros dessa forma.
// Construtor tradicional
public class OrderService
{
private readonly IOrderRepository repository;
public OrderService(IOrderRepository repository)
{
this.repository = repository;
}
public Order? GetById(Guid id) => repository.Find(id);
}
// O mesmo serviço com primary constructor
public class OrderService(IOrderRepository repository)
{
public Order? GetById(Guid id) => repository.Find(id);
}
Por isso, muita gente trata o primary constructor como simples açúcar sintático. Na maior parte do tempo, de fato, as duas versões se comportam da mesma forma.
Contudo, existe um cenário em que elas divergem. Ele envolve herança, membros virtuais e a ordem em que o runtime monta o objeto.
O problema: um membro virtual no construtor da base
O exemplo a seguir vem do artigo de Steven Giesel, que serviu de base para este texto. Ele parte de uma classe abstrata que executa algum trabalho no próprio construtor.
A classe base
A classe Base recebe um delegate e, ainda no construtor, imprime o resultado dele e o valor de uma propriedade abstrata.
abstract class Base
{
protected Base(Func<int?> check)
{
// A base lê um membro virtual antes da classe derivada terminar de construir
Console.WriteLine($"{GetType().Name}: check() = {check()}, Message = {Message}");
}
protected abstract string Message { get; }
}
Sim, chamar membros virtuais dentro de um construtor é um code smell conhecido. No entanto, no mundo real o padrão nem sempre aparece de forma tão explícita.
Às vezes, a base inicia um timer, uma thread ou um callback, e esse código lê um membro virtual mais tarde. O efeito é o mesmo, só que escondido.
Se você quer revisar como virtual e override funcionam, o artigo sobre sobrescrita e sobrecarga de métodos do balta.io cobre o assunto em detalhe.
A versão com construtor tradicional
A primeira classe derivada usa um construtor clássico. Ela guarda o parâmetro em um campo readonly e passa uma lambda para a base.
class Regular : Base
{
private readonly int? count;
public Regular(int? count) : base(() => count)
{
// Esta atribuição só roda depois que o construtor da base termina
this.count = count;
}
protected override string Message => $"count = {count?.ToString() ?? "null"}";
}
À primeira vista, o código parece correto. Afinal, o campo recebe o valor no construtor, e a propriedade Message apenas lê esse campo.
A versão com primary constructor
A segunda classe faz exatamente a mesma coisa, porém com primary constructor. O parâmetro count aparece direto na declaração do tipo.
class Primary(int? count) : Base(() => count)
{
protected override string Message => $"count = {count?.ToString() ?? "null"}";
}
Além de ficar mais curta, essa versão dispensa o campo explícito. Portanto, a expectativa natural é que as duas classes imprimam a mesma saída.
O resultado da execução
Para comparar, basta instanciar as duas classes com o mesmo valor.
new Regular(3);
new Primary(3);
A saída, no entanto, mostra uma diferença clara.
Regular: check() = 3, Message = count = null
Primary: check() = 3, Message = count = 3
Nas duas classes, a lambda check() devolve o valor 3. Por outro lado, a propriedade Message mostra null na classe Regular e 3 na classe com primary constructor.
Em outras palavras, as duas versões não são equivalentes. Para entender o motivo, é preciso olhar o código que o compilador realmente gera.
O que o compilador gera para o primary constructor
O compilador do C# transforma a sintaxe de alto nível em um código mais simples antes de emitir IL. Ferramentas como o ILSpy ou o sharplab.io mostram essa versão rebaixada, também conhecida como lowered code.
O código rebaixado da classe Regular
Na classe com construtor tradicional, o resultado fica assim.
public Regular(int? count)
{
// A lambda captura o parâmetro em uma closure (display class)
var obj = new <>c__DisplayClass1_0();
obj.count = count;
// A base executa aqui, antes de o campo receber o valor
base..ctor(new Func<int?>(obj.<.ctor>b__0));
// O campo só recebe o valor depois que a base retorna
this.count = obj.count;
}
Primeiro, a lambda captura o parâmetro em uma closure, a chamada display class. Por isso, check() enxerga o valor correto.
Em seguida, o construtor da base executa. Somente depois disso o campo this.count recebe o valor, então tudo o que a base faz nesse intervalo enxerga null.
O código rebaixado da classe com primary constructor
Já na classe com primary constructor, o compilador produz outra sequência.
public Primary(int? count)
{
// O campo oculto recebe o valor antes de chamar a base
<count>P = count;
base..ctor(new Func<int?>(<.ctor>b__0_0));
}
Como a propriedade Message usa o parâmetro count, o compilador captura esse parâmetro em um campo oculto chamado <count>P. A documentação oficial sobre construtores primários descreve esse comportamento: quando um membro de instância referencia o parâmetro, ele passa a fazer parte do estado do tipo.
O detalhe decisivo está na ordem. O primary constructor atribui o campo oculto antes de chamar o construtor da base, e é por isso que Message já encontra o valor 3.
Como inspecionar o código gerado pelo primary constructor
Você não precisa confiar apenas na explicação. Com poucos passos, dá para confirmar a ordem de atribuição no seu próprio código.
No sharplab.io, cole as classes no painel da esquerda e escolha a saída em C# no painel da direita. A ferramenta mostra o construtor rebaixado, com os campos ocultos e a chamada explícita à base.
Já no ILSpy, abra o assembly compilado e escolha uma versão antiga da linguagem nas opções de descompilação. Assim, o descompilador exibe o construtor sem a sintaxe de alto nível, e a posição da chamada base..ctor fica evidente.
Em ambos os casos, observe dois pontos. Primeiro, onde fica a atribuição do campo em relação à chamada da base; depois, se existe um campo oculto com o sufixo P no nome.
Esse hábito vale para qualquer recurso novo da linguagem. Em geral, o código rebaixado responde em segundos dúvidas que levariam horas de depuração.
A ordem de inicialização de objetos no C#
Esse comportamento não é um acaso. Na prática, o primary constructor segue a mesma regra dos inicializadores de campo, que também rodam antes de base(...).
Segundo o guia de programação do C# sobre construtores, a criação de uma instância segue esta sequência:
- O runtime zera todos os campos de instância.
- Os inicializadores de campo do tipo derivado executam.
- Os inicializadores de campo do tipo base executam.
- O construtor da base executa.
- O corpo do construtor derivado executa.
Assim, um campo com inicializador já tem valor quando o construtor da base roda. Por outro lado, uma atribuição dentro do corpo do construtor só acontece no último passo.
O diagrama resume a diferença. Na classe Regular, a base executa no meio do caminho, quando o campo ainda vale null.
Enquanto isso, na classe Primary, o campo oculto já carrega o valor no momento em que a base começa a trabalhar. Ou seja, o primary constructor se comporta como um inicializador de campo, e não como uma atribuição no corpo do construtor.
O caso real do bUnit
O exemplo didático parece artificial, mas o problema surgiu em uma biblioteca real. No bUnit, a ferramenta de testes para componentes Blazor, o construtor base do WaitForHelper inicia um timer.
Quando esse timer dispara, o código lê a propriedade virtual TimeoutErrorMessage. Dessa forma, a leitura do membro virtual acontece em outra thread, possivelmente antes de o construtor derivado terminar.
Com o construtor clássico, se o timer disparasse antes do corpo do construtor derivado, a mensagem de erro saía errada. Ela dizia que a busca não encontrou nenhum elemento, quando deveria dizer que não encontrou exatamente os elementos esperados.
Com o primary constructor, o valor já estava no campo oculto antes de a base iniciar o timer. Portanto, a mensagem passou a refletir a configuração correta em qualquer cenário de corrida.
Esse caso mostra por que vale entender o código gerado. Uma troca que parece puramente estética mudou o comportamento observável de uma biblioteca.
Cuidados ao usar primary constructor
O primary constructor resolve o problema da ordem, mas traz suas próprias armadilhas. Duas delas merecem atenção especial.
O campo gerado não é readonly
O campo oculto <count>P não tem o modificador readonly. Por isso, você pode reatribuir o parâmetro dentro de qualquer método da classe, e o compilador não vai impedir.
class Counter(int count)
{
public void Reset()
{
// Compila sem erro: o parâmetro capturado é mutável
count = 0;
}
public int Current => count;
}
A documentação oficial confirma essa regra: parâmetros de um primary constructor aceitam atribuição, assim como qualquer parâmetro de método. Além disso, eles não viram membros da classe, então this.count não existe.
Se a imutabilidade importa para o seu tipo, você precisa declarar o campo manualmente. Felizmente, um inicializador de campo também roda antes do construtor da base.
class Primary(int? count) : Base(() => count)
{
// O inicializador roda antes de base(...), então a base já enxerga o valor
private readonly int? count = count;
protected override string Message => $"count = {count?.ToString() ?? "null"}";
}
Nesse caso, o nome count dentro de Message passa a referenciar o campo, e não o parâmetro. Assim, você mantém a garantia de readonly e preserva a ordem correta de inicialização.
Primary constructor e o aviso CS9124
O cuidado seguinte aparece quando você mistura as duas abordagens. Se o parâmetro aparece em um membro e também em um inicializador de campo, o objeto passa a guardar duas cópias do mesmo valor.
class Account(decimal balance)
{
// Cópia 1: o campo explícito
private readonly decimal initialBalance = balance;
// Cópia 2: o parâmetro capturado no campo oculto
public decimal Available => balance;
}
Nesse cenário, o compilador emite o aviso CS9124. A mensagem informa que o parâmetro está capturado no estado do tipo e que o mesmo valor também inicializa um campo, uma propriedade ou um evento.
O risco é sutil. Se algum método alterar balance, o campo initialBalance continua com o valor antigo, e as duas cópias divergem sem nenhum alerta em tempo de execução.
A recomendação da Microsoft é escolher uma das duas formas. Ou você usa o parâmetro capturado diretamente, ou declara o campo e passa a usar apenas ele.
Existe ainda um aviso parecido, o CS9107. Ele aparece quando o parâmetro fica capturado na classe derivada e também segue direto para o construtor da base, que pode guardar outra cópia.
Como evitar o problema na classe base
O primary constructor contorna o sintoma, mas a causa continua na classe base. Afinal, qualquer classe derivada que ainda use construtor tradicional vai cair na mesma armadilha.
Por isso, quando você controla a base, a solução mais robusta é eliminar a leitura virtual durante a construção. Uma forma simples consiste em receber o dado necessário como parâmetro.
abstract class Base
{
protected Base(Func<int?> check, string message)
{
// A base recebe a mensagem pronta, sem consultar um membro virtual
Console.WriteLine($"{GetType().Name}: check() = {check()}, Message = {message}");
}
}
class Regular : Base
{
public Regular(int? count)
: base(() => count, $"count = {count?.ToString() ?? "null"}")
{
}
}
Dessa forma, a classe derivada calcula o valor antes de chamar a base, e a ordem de inicialização deixa de importar. O mesmo raciocínio vale para timers e callbacks: a base só deve iniciar trabalho assíncrono quando o objeto estiver completo.
Outra alternativa é mover a lógica para um método explícito, como Start ou Initialize, chamado depois da construção. Embora exija uma linha a mais de quem consome a classe, essa abordagem torna o ciclo de vida previsível.
Quando a base pertence a uma biblioteca de terceiros, no entanto, você não tem essas opções. Nesse caso, o primary constructor ou um inicializador de campo readonly passam a ser as saídas mais práticas.
Quando migrar para primary constructor
Na maioria dos serviços, a troca é segura e deixa o código mais limpo. Classes que apenas recebem dependências por injeção, sem herança relevante, raramente mudam de comportamento.
No entanto, vale revisar com mais cuidado alguns cenários:
- Classes que herdam de uma base cujo construtor chama membros virtuais ou abstratos.
- Bases que iniciam timers, tasks, threads ou assinaturas de eventos no construtor.
- Tipos que dependem de
readonlypara garantir a imutabilidade do estado. - Código que usa o mesmo parâmetro em inicializadores e em membros ao mesmo tempo.
Nesses casos, a migração para primary constructor pode corrigir um bug escondido, como aconteceu no bUnit. Também pode, em outro contexto, alterar um comportamento do qual algum teste dependia.
Por isso, trate a mudança como uma alteração de comportamento, e não como formatação. Rode os testes, observe os avisos do compilador e, quando houver dúvida, abra o código rebaixado no sharplab.io.
Para quem está consolidando a base de herança, encapsulamento e polimorfismo, o curso Fundamentos da Orientação a Objetos do balta.io ajuda a entender por que a ordem de construção importa tanto.
Conclusão sobre o primary constructor
O primary constructor é mais do que açúcar sintático. Quando um membro usa o parâmetro, o compilador o captura em um campo oculto e faz a atribuição antes de chamar o construtor da base.
Já o construtor tradicional atribui o campo no corpo, depois que a base retorna. Assim, qualquer leitura virtual feita pela base enxerga o valor padrão, e não o valor recebido.
Na prática, isso significa que o primary constructor se alinha aos inicializadores de campo. Essa ordem resolveu um problema real no bUnit e pode resolver problemas parecidos no seu código.
Ainda assim, o campo gerado não é readonly, e o uso duplicado do parâmetro gera duas cópias e o aviso CS9124. Conhecer esses detalhes permite usar o recurso com segurança e escolher a forma certa para cada classe.
FAQ
O primary constructor é apenas açúcar sintático?
Não totalmente. Quando um membro da classe usa o parâmetro, o compilador o guarda em um campo oculto e atribui esse campo antes de chamar o construtor da base.
Em qual versão do C# o primary constructor surgiu para classes?
O recurso chegou às classes e structs no C# 12, junto com o .NET 8. Antes, ele existia apenas em records.
O parâmetro de um primary constructor é readonly?
Não. O campo gerado pelo compilador é mutável, então você pode reatribuir o parâmetro dentro da classe.
Como garantir imutabilidade com primary constructor?
Declare um campo readonly com inicializador, como private readonly int? count = count;. Esse inicializador também roda antes do construtor da base.
O que significa o aviso CS9124?
Ele indica que o mesmo parâmetro foi capturado no estado do tipo e também usado para inicializar um campo, uma propriedade ou um evento, gerando duas cópias do valor.
Chamar membros virtuais no construtor é uma boa prática?
Não. A classe derivada ainda não terminou de inicializar, então o membro sobrescrito pode ler valores padrão.