Operadores, Tratamento de Null e Atribuição Condicional Nula
Objective
A maioria dos operadores do C# faz o que aparenta, mas alguns trazem regras que
decidem se um trecho de código chega a rodar. && e || pulam o operando da
direita; & e | não pulam. ?. transforma um int em int? e interrompe a
cadeia inteira sem avisar. ?? tem precedência menor que +. O C# 14 soma mais
um membro a essa família: ?. e ?[] agora podem aparecer do lado esquerdo de
uma atribuição, então customer?.Name = value só grava quando customer existe
e nem avalia value quando ele não existe. Este conceito mostra como esses
operadores avaliam, o que a nova atribuição condicional nula aceita e recusa
(com os erros reais do compilador), o que o compilador gera para ela e como ela
conversa com nullable reference types.
Use Cases
- Trocar
if (x is not null) x.Prop = value;por uma única instrução quando "sem receptor, nada a fazer" é o comportamento desejado. - Assinar um evento de um objeto que talvez ainda não exista:
button?.Clicked += OnClick;. - Definir valores padrão:
name ?? "anonymous", inicialização preguiçosa comcache ??= Load()e validação de argumento com?? throw. - Ler um caminho de propriedades que pode estar quebrado em qualquer nível sem uma escada de checagens de null.
- Escolher entre
&&e&quando o operando da direita tem efeito colateral.
Deep Dive
Operadores lógicos com curto-circuito e sem curto-circuito
Com operandos bool, && e || são condicionais: o lado direito só roda se o
esquerdo ainda não decidiu o resultado. &, | e ^ sempre avaliam os dois
operandos. Com inteiros, &, | e ^ viram operadores bit a bit.
csharpint calls = 0;
bool Check(bool v) { calls++; return v; }
bool a = Check(false) && Check(true); // a = False, calls = 1
calls = 0;
bool b = Check(false) & Check(true); // b = False, calls = 2
bool c = true ^ true; // False: OU exclusivoAs formas sem curto-circuito raramente são o que você quer numa condição, mas são a escolha honesta quando as duas chamadas precisam acontecer (por exemplo, validar dois campos e reportar os dois erros).
A família que lida com null: ?., ?[], ??, ??=
a?.x avalia a uma vez. Se for null, o resultado é null e o resto da
cadeia é pulado; senão, se comporta como a.x. Daí saem duas consequências.
Primeiro, um membro de tipo por valor vira anulável: name?.Length é um int?.
Segundo, comparações sobre esse int? são "lifted", e qualquer comparação com
null dá false, o que engana nos dois sentidos:
csharpint[]? numbers = null;
Console.WriteLine(numbers?.Length < 2); // False
Console.WriteLine(numbers?.Length >= 2); // False
Console.WriteLine((numbers?.Length ?? 0) < 2); // True: diga o que null significaO curto-circuito vale para toda a cadeia de acessos a membro e a elemento, mas
parênteses a encerram. o?.Customer.Name é null quando o é null, enquanto
(o?.Customer).Name lança NullReferenceException, porque a parte entre
parênteses já é um valor pronto que o .Name de fora desreferencia.
?? devolve o operando da esquerda a menos que ele seja null. A precedência
dele é menor que a da aritmética, o que é uma leitura errada clássica:
csharpint? y = 5;
Console.WriteLine(y ?? 0 + 10); // 5, lido como y ?? (0 + 10)
Console.WriteLine((y ?? 0) + 10); // 15??= só atribui quando o lado esquerdo é null, e ?? throw transforma um
valor ausente em exceção numa única expressão. Como ?. lê o receptor uma vez
só, Handler?.Invoke(...) é a forma padrão e thread-safe de disparar um evento:
um cancelamento de inscrição concorrente não consegue tornar null a cópia que
já foi lida.
Atribuição condicional nula (C# 14)
Antes do C# 14, ?. e ?[] só podiam ler. Agora podem ser o alvo de = e de
toda atribuição composta, inclusive ??=. A regra central, escrita na
referência da linguagem e na especificação da feature, é que o lado direito só
é avaliado quando o receptor não é null. Este programa mostra isso com um
contador:
csharpint calls = 0;
int Next() { calls++; return 42; }
Customer? none = null;
Customer some = new() { Name = "Ana" };
none?.Age = Next(); // calls = 0: Next() nunca rodou
some?.Age = Next(); // Age = 42, calls = 1
some?.Age += 1; // atribuição composta: Age = 43
none?.Age += Next(); // continua calls = 1
some?.Nickname ??= "Aninha";
some?.Nickname ??= "ignored"; // Nickname continua "Aninha"
int[]? arr = null;
arr?[0] = Next(); // acesso a elemento também funciona
Dictionary<string, int>? dict = new();
dict?["a"] = 1; // e indexadores
Order? order = new() { Customer = null };
order?.Customer?.Name = "nobody"; // a cadeia para no Customer nulo
Button? button = new();
button?.Clicked += () => Console.WriteLine("clicked"); // inscrição em eventoO receptor é avaliado uma vez: GetCustomer()?.Age += 10 chama GetCustomer()
uma única vez, mesmo que a atribuição composta leia e escreva a propriedade.
Quando a atribuição é usada como valor, o tipo dela é a versão anulável do tipo
atribuído, e ela vale null quando nada foi gravado:
csharpint? r1 = none?.Age = 5; // null
int? r2 = some?.Age = 5; // 5O que não compila
A feature mantém a?.b como algo que não é variável: dá para atribuir através
dele, mas não dá para pegar uma referência a ele. Estes são os erros que o SDK
10.0.100 reporta:
csharph?.Count++; // CS1059: The operand of an increment or decrement
--h?.Count; // operator must be a variable, property or indexer
int.TryParse("1", out h?.Field); // CS1510: A ref or out value must be an assignable variable
ref int r = ref h?.Field; // CS1510 (mesma mensagem)
(h?.Count, h?.Field) = (1, 2); // CS0131: The left-hand side of an assignment must be
// a variable, property or indexer
Point p = default; // Point é uma struct
p?.X = 1; // CS0023: Operator '?' cannot be applied to operand of type 'Point'
Point? np = null;
np?.X = 1; // CS0131: np.Value é uma cópia, não uma variável
static void Use<T>(Box<T>? c, T t)
{
c?.Value = t; // ok como instrução
(c?.Value = t).ToString(); // CS8978: 'T' cannot be made nullable.
}Resumindo: nada de ++/--, nada de ref/out, nada de alvo de
desconstrução, nada de receptor de tipo por valor, e o resultado com um genérico
sem restrição não pode ser usado como valor. Com <LangVersion>13</LangVersion>
a atribuição simples é recusada com CS9260, "Feature 'null conditional
assignment' is not available in C# 13.0".
O que o compilador gera
Para uma instrução, a especificação define P?.A = B como
if (P is not null) P.A = B; com P avaliado uma vez, e o IL confirma. Este
método:
csharpstatic void Set(Customer? c, int age) => c?.Age = Compute(age);compila para um teste de c (brtrue) que pula o bloco com a chamada a
Compute e a set_Age. Não há objeto temporário, nem tratamento de exceção,
nem custo além do desvio que você escreveria à mão. Quando a expressão é usada
como valor, o código gerado monta um local Nullable<int>, exatamente como
(P is null) ? (int?)null : (P.A = B) faria.
Interação com nullable reference types
A análise de fluxo trata um ?. sobre uma variável como sinal de que ela pode
ser null. Se a variável era não anulável, a próxima desreferência comum gera
aviso:
csharpstatic void Rename(Customer customer) // parâmetro não anulável
{
customer?.Name = "Ana"; // compila, sem aviso
Console.WriteLine(customer.Name.Length); // warning CS8602: Dereference of a
} // possibly null reference.A correção não é colocar !, e sim tirar o ?. onde null é impossível. O
lado direito continua sendo checado contra o alvo: customer?.Name = null numa
propriedade string Name não anulável gera CS8625.
Trade-offs
- Um no-op silencioso pode esconder um bug.
order?.Status = Shippednão faz nada quandoorderénull, e nada te avisa. Use só quando a ausência do receptor é um caso normal; quando é defeito, deixe o.comum lançar.csharporder.Status = Status.Shipped; // order nulo é defeito: falhe alto - O lado direito pode nunca rodar. Isso economiza trabalho, mas um efeito
colateral colocado ali (log, contador,
GenerateId()) some junto com o receptor nulo. Calcule antes se ele precisa acontecer sempre. ?.espalha nulabilidade. Cada?.numa variável não anulável diz ao analisador que ela pode sernull, o que gera avisos CS8602 mais adiante. Use onde o null é real, não por defesa.??=não é atômico. É uma leitura seguida de uma escrita, então duas threads podem vernulle as duas inicializarem. UseLazy<T>ouInterlocked.CompareExchangepara estado compartilhado.csharpInterlocked.CompareExchange(ref _cache, Load(), null);- Comparações lifted e precedência enganam.
x?.Length < 2éfalseparanull, ea ?? b + csoma antes de coalescer. Use parênteses e declare o padrão com??. &e|rodam os dois lados. Certo quando os dois efeitos colaterais precisam acontecer, surpreendente em qualquer outro lugar. Em condições, use&&e||por padrão.
Documentation Links
- What's new in C# 14, Microsoft Learn
- Member access and null-conditional operators, C# reference, Microsoft Learn
- Null conditional assignment, C# feature specification, Microsoft Learn
- ?? and ??= operators, C# reference, Microsoft Learn
- Boolean logical operators, C# reference, Microsoft Learn
- C# operators and expressions (precedence), Microsoft Learn