Propriedades, Acessores init e a Palavra-chave field
Objective
Uma propriedade parece um campo para quem chama e funciona como um par de métodos
por baixo. Durante anos isso deixou um buraco: a auto-property ({ get; set; })
não tem lugar para lógica, e no momento em que você precisa de uma linha de
validação é obrigado a declarar um campo privado, dar um nome a ele e escrever os
dois acessores na mão. O C# 14 fecha esse buraco com a palavra-chave contextual
field, que deixa o corpo de um acessor chegar direto ao campo de apoio gerado
pelo compilador. Este conceito mostra o que o compilador gera para uma
propriedade, como o acessor init restringe a escrita, e as regras exatas do
field: onde ele é palavra-chave, o que acontece com membros que já se chamam
field, atributos com alvo field:, análise de nulos e propriedades partial.
Use Cases
- Colocar validação ou normalização numa auto-property existente sem criar um campo de apoio com nome, que o resto da classe poderia usar para pular a regra.
- Calcular um valor caro uma única vez, numa só expressão.
- View models que só disparam notificação de mudança quando o valor muda de fato.
- Objetos imutáveis depois da construção, montados com object initializers (
init). - Migrar um código antigo para o C# 14 quando ele já tem um membro chamado
field.
Deep Dive
No que uma propriedade compila
A propriedade Email vira dois métodos, get_Email e set_Email (ou a variante
init), mais metadados que ligam os dois. A auto-property também ganha um campo
privado chamado <Email>k__BackingField. Os sinais de menor e maior não são
válidos em C#, e é assim que o compilador garante que nenhum código-fonte alcance
esse campo pelo nome. A reflexão mostra:
csharpclass User
{
[field: Display(Name = "backing")]
public string Email { get; init => field = value.Trim().ToLowerInvariant(); } = "";
}
foreach (var f in typeof(User).GetFields(BindingFlags.Instance | BindingFlags.NonPublic))
Console.WriteLine($"{f.Name} initonly={f.IsInitOnly} attrs=...");
// <Email>k__BackingField initonly=True attrs=CompilerGeneratedAttribute,DebuggerBrowsableAttribute,DisplayAttributeO field não muda esse formato: uma propriedade que usa field ganha o mesmo
tipo de campo de apoio oculto de uma auto-property. Só os corpos dos acessores
mudam.
Acessores init
O acessor init (C# 9) é um setter que só pode rodar durante a construção do
objeto: num object initializer, numa expressão with, num construtor do tipo via
this ou base, ou dentro de outro acessor init. Em qualquer outro lugar o
compilador barra:
csharpvar p = new Point { X = 1, Y = 2 }; // ok
p.X = 9;
// error CS8852: Init-only property or indexer 'Point.X' can only be assigned in an
// object initializer, or on 'this' or 'base' in an instance constructor or an 'init' accessor.A garantia é de compilação. O setter init é um método comum cujo tipo de
retorno carrega um modificador obrigatório, IsExternalInit, que compiladores
antigos se recusam a chamar. A reflexão não liga para isso:
csharpvar setter = typeof(Point).GetProperty("X")!.SetMethod!;
setter.ReturnParameter.GetRequiredCustomModifiers(); // [IsExternalInit]
setter.Invoke(p, [9]);
Console.WriteLine(p.X); // 9É exatamente assim que serializadores preenchem propriedades init. Então trate
init como uma promessa sobre o seu próprio código, não como barreira de
segurança. Quando a propriedade precisa ser preenchida, combine init com
required (veja Fields, Constants and Initialization Order). O init também
explica por que o campo gerado de User acima é initonly.
A palavra-chave field (C# 14)
Dentro de um acessor de propriedade, field aponta para o campo de apoio
sintetizado. Dá para misturar um acessor automático com um completo, escrever os
dois ou usar expression body:
csharpclass Temperature
{
public double Celsius
{
get;
set => field = value >= -273.15
? value
: throw new ArgumentOutOfRangeException(nameof(value), "below absolute zero");
}
}
class Cache
{
public string Report => field ??= Build(); // Build() roda uma vez
}Ler cache.Report duas vezes imprimiu o rastro de Build() uma vez só. Antes do
C# 14 o mesmo código exigia private double _celsius;, e qualquer outro método da
classe podia fazer _celsius = -500 e pular a checagem. Com field, o
armazenamento só é alcançável pelos acessores. O conceito de DDD em
dotnet-concepts (Aggregates, Entities, and Value Objects) usa isso para manter o
estado do agregado válido a cada mudança.
Inicializador versus construtor: qual caminho chama o setter
O inicializador da propriedade escreve direto no campo de apoio, sem chamar o setter. Atribuir a propriedade no construtor chama o setter. Os dois fatos saíram direto do experimento:
csharpclass ViewModel
{
public bool Pending { get; private set; }
public bool IsActive
{
get;
set { if (field == value) return; field = value; Pending = true; }
} = true;
}
// after ctor: Pending=False, IsActive=True
// after set: Pending=True, IsActive=False
class CtorSet
{
public CtorSet() { Name = " set in ctor "; }
public string Name { get; set => field = value.Trim(); } = " initializer ";
}
// new CtorSet().Name == "set in ctor" (o setter rodou, valor aparado)O inicializador é o jeito de dar um padrão sem disparar efeitos colaterais; o construtor é o jeito de forçar o padrão a passar pela validação.
Como o field é vinculado, e a quebra na migração
field só é palavra-chave como expressão primária dentro dos corpos de get,
set e init de propriedades (incluindo lambdas e funções locais aninhadas ali).
Em todo o resto é um identificador comum: em métodos, em acessores de evento e em
indexadores, onde get => field dá erro CS0103 porque esse nome não existe.
A quebra aparece em código que já tem um membro chamado field. No C# 14, um
field sem qualificação dentro de um acessor passa a significar o campo de
apoio, e o compilador avisa:
csharpclass Legacy
{
private int field = 100;
public int Value { get => field; set => field = value; }
public int Escaped => @field; // o membro
public int This => this.field; // o membro
}
// warning CS9258: In language version 14.0, the 'field' keyword binds to a synthesized
// backing field for the property. To avoid generating a synthesized backing field, and
// to refer to the existing member, use 'this.field' or '@field' instead.
// Depois de l.Value = 5: Value=5, member field=100O programa continua compilando e usa um campo novo em silêncio, então trate o
CS9258 como erro durante a migração. Declarar uma variável local ou um parâmetro
de lambda chamado field dentro de um acessor é erro de verdade, CS9273; com o
nome @field compila. nameof(field) é o erro CS8081 (Expression does not have a name), porque o campo de apoio não tem nome no código-fonte. Na validação de
argumento use nameof(value).
Atributos com alvo field:
[field: ...] coloca o atributo no campo de apoio em vez de na propriedade. Isso
já funcionava em auto-properties e agora vale para qualquer propriedade que use
field, como mostra o DisplayAttribute na saída da reflexão. Numa propriedade
sem campo de apoio (public string NoBacking => "computed";) o compilador do SDK
10 não falha: ele emite o aviso CS0657 e ignora o atributo. A especificação da
feature diz que isso seria erro, então confie no compilador e mantenha os avisos
visíveis.
Análise de nulos
Com nullable reference types ligado, o compilador decide se o getter é
null-resilient, ou seja, se ele ainda devolve um valor não nulo quando o campo
contém null. Um getter preguiçoso é, então o campo é tratado como anulável e o
construtor não precisa atribuí-lo. Um getter simples não é:
csharppublic string Lazy => field ??= "computed"; // sem aviso
public string Plain { get => field; set => field = value; } // aviso CS9264
public string Trimmed { get; set => field = value.Trim(); } // aviso CS9264
public string Empty { get; set { } } // CS8618, e CS9266 no setterO CS9264 diz Non-nullable property 'Plain' must contain a non-null value when exiting constructor ... or safely handling the case where 'field' is null in the 'get' accessor. O CS9266 aponta um setter que ignora field enquanto o outro
acessor usa.
Propriedades partial
Uma propriedade partial (C# 13) pode usar field na declaração de implementação.
O inicializador pode ficar em qualquer uma das partes, mas não nas duas (CS9263),
e a parte de implementação não pode ser só de acessores automáticos (CS9250): pelo
menos um acessor precisa de corpo:
csharppublic partial class Widget
{
public partial string Title { get; set; } = " initial ";
}
public partial class Widget
{
public partial string Title { get; set => field = value.Trim(); }
}
// new Widget().Title == " initial " (o inicializador pula o setter)É o formato que os source generators preferem: o usuário escreve a declaração e
os atributos [field: ...], e o generator escreve os acessores em cima de field.
Trade-offs
fielddeixa o armazenamento privado da propriedade. O ganho é que nenhum método consegue pular a validação. O custo é que a própria classe não lê o valor cru sem passar pelo getter, então um getter preguiçoso sempre executa a lógica.- Inicializador e construtor se comportam diferente. Fácil de esquecer no
code review:csharp
public string Name { get; set => field = value.Trim(); } = " x "; // fica " x " - A migração para o C# 14 pode mudar o significado sem barulho. Um membro
chamado
fieldcontinua compilando, só com o CS9258. Renomeie o membro ou coloque<WarningsAsErrors>CS9258</WarningsAsErrors>durante a migração. initsó vale na compilação. Ele barra o seu código, não a reflexão nem os desserializadores, e não deixa o objeto profundamente imutável: uma propriedadeinitdo tipoList<T>continua mutável por dentro.- Lógica em acessor esconde custo. Ler uma propriedade parece de graça. Mantenha os acessores baratos e sem efeito colateral além de validação e notificação, e use um método quando o trabalho for I/O ou puder falhar por motivos que quem chama não consegue resolver.
- Indexadores ficam de fora.
fieldnão existe em acessores de indexador, então um indexador ainda precisa de um campo explícito.