Control flow
A estrutura certa depende de três perguntas: quantas condições existem, se elas escolhem um valor ou disparam uma ação, e se o método pode sair antes do fim. Guard clauses (cláusulas de proteção) tratam o caso inválido no topo e devolvem o fluxo principal sem aninhamento. O pattern matching (correspondência de padrões) do C# moderno troca cadeias longas de if/else por um switch que o compilador consegue conferir.
Conceitos fundamentais
| Conceito | O que é |
|---|---|
| guard clause (cláusula de proteção) | if no topo do método que retorna cedo em caso inválido; reduz aninhamento |
| early return (retorno antecipado) | Sair do método assim que o resultado for conhecido, sem else desnecessário |
| ternary (ternário) | cond ? a : b; expressão condicional curta; legível só quando as três partes são curtas |
| switch expression (expressão switch) | Forma de C# 8+ que devolve valor: x switch { 1 => "a", _ => "b" } |
| pattern matching (correspondência de padrões) | Recurso do C# moderno para casar tipo, propriedade, tupla; usado em is e switch |
null-conditional (acesso seguro a nulo, ?.) | Operador que evita NullReferenceException ao acessar membro de null |
null-coalescing (coalescência de ausente, ??) | Operador que devolve o lado direito quando o esquerdo é null |
Retornar cedo dispensa o else
Depois de um return, o else não decide mais nada: se o programa chegou à linha seguinte, é porque a condição anterior era falsa. Escrever o else mesmo assim só acrescenta um nível de indentação a cada caso.
❌ Ruim: else desnecessário após return
public decimal GetDiscount(string customerType)
{
if (customerType == "VIP")
return 0.20m;
else if (customerType == "PREMIUM")
return 0.10m;
else
return 0.0m;
}
✅ Bom: early return elimina o else
public decimal GetDiscount(string customerType)
{
if (customerType == "VIP") return 0.20m;
if (customerType == "PREMIUM") return 0.10m;
return 0.0m;
}
Ternário para dois valores em uma linha
O ternário (cond ? a : b) cabe quando existem dois resultados possíveis e os três pedaços da expressão são curtos. A partir de três alternativas, use switch expression. Ternário dentro de ternário obriga o leitor a contar ? e : para descobrir qual condição pertence a qual resultado.
❌ Ruim: if/else imperativo para atribuição simples
string label;
if (order.IsSettled)
label = "Settled";
else
label = "Pending";
✅ Bom: ternário na atribuição
var label = order.IsSettled ? "Settled" : "Pending";
❌ Ruim: ternário aninhado para 3+ alternativas
var priority = isUrgent ? isCritical ? "Critical" : "High" : "Normal";
✅ Bom: switch expression para 3+ alternativas
var priority = (isUrgent, isCritical) switch
{
(true, true) => "Critical",
(true, false) => "High",
_ => "Normal",
};
Guard clause no topo achata o aninhamento
Quando cada validação abre um if novo dentro do anterior, o código útil vai parar no fundo de uma pirâmide de chaves, e a condição que levou até lá fica cinco linhas acima. Inverta: valide o caso inválido primeiro e saia do método. O que sobra depois das guardas é o caminho feliz, sem indentação e sem else.
❌ Ruim: lógica enterrada em múltiplos níveis
public async Task<Result<Invoice>> CheckoutAsync(CartRequest request, CancellationToken ct)
{
if (request is not null)
{
if (request.Items.Count > 0)
{
var user = await _users.FindByIdAsync(request.UserId, ct);
if (user is not null)
{
if (user.IsActive)
{
var invoice = await BuildInvoiceAsync(request, user, ct);
return Result<Invoice>.Success(invoice);
}
}
}
}
return Result<Invoice>.Fail("Checkout failed.", "INVALID_INPUT");
}
✅ Bom: guard clauses no topo, fluxo principal livre
public async Task<Result<Invoice>> CheckoutAsync(CartRequest request, CancellationToken ct)
{
if (request is null || request.Items.Count == 0)
return Result<Invoice>.Fail("Cart is empty.", "INVALID_INPUT");
var user = await _users.FindByIdAsync(request.UserId, ct);
if (user is null)
return Result<Invoice>.Fail("User not found.", "NOT_FOUND");
if (!user.IsActive)
return Result<Invoice>.Fail("User is inactive.", "UNAUTHORIZED");
var invoice = await BuildInvoiceAsync(request, user, ct);
return Result<Invoice>.Success(invoice);
}
Pattern matching testa o tipo e já entrega a variável
if (payment is CreditCardPayment creditCard) faz duas coisas de uma vez: confere o tipo e declara creditCard já convertido, válido dentro do bloco. O compilador garante que a variável só existe onde o teste passou. A alternativa antiga, testar com is e depois converter na mão com (CreditCardPayment)payment, escreve o nome do tipo duas vezes e deixa a conversão longe da checagem.
❌ Ruim: cast manual após verificação de tipo
public string SummarizePayment(object payment)
{
if (payment is CreditCardPayment)
{
var creditCard = (CreditCardPayment)payment;
return $"Cartão {creditCard.Brand} •••• {creditCard.LastFour}";
}
if (payment is PixPayment)
{
var pix = (PixPayment)payment;
return $"Pix: chave {pix.KeyType}";
}
return "Pagamento desconhecido";
}
✅ Bom: pattern matching extrai e verifica em uma expressão
public string SummarizePayment(object payment)
{
if (payment is CreditCardPayment creditCard)
{
var summary = $"Cartão {creditCard.Brand} •••• {creditCard.LastFour}";
return summary;
}
if (payment is PixPayment pix)
{
var summary = $"Pix: chave {pix.KeyType}";
return summary;
}
return "Pagamento desconhecido";
}
Switch expression quando a entrada vira um valor
Uma cadeia de if/else que só escolhe qual valor atribuir cabe melhor num switch expression. Cada opção mapeia uma entrada a um resultado, e o compilador exige o caso _ que cobre o resto: um valor de entrada sem tratamento vira erro de compilação em vez de retorno silencioso. O segundo exemplo mostra o mesmo recurso lendo propriedades de dentro do objeto ({ Error.Code: "NOT_FOUND" }) para traduzir um Result em resposta HTTP.
❌ Ruim: if/else encadeado para mapeamento de valor
public string GetStatusLabel(string status)
{
if (status == "PENDING") return "Aguardando";
else if (status == "APPROVED") return "Aprovado";
else if (status == "CANCELLED") return "Cancelado";
else return "Desconhecido";
}
✅ Bom: switch expression declarativo e exaustivo
public string GetStatusLabel(string status)
{
var label = status switch
{
"PENDING" => "Aguardando",
"APPROVED" => "Aprovado",
"CANCELLED" => "Cancelado",
_ => "Desconhecido",
};
return label;
}
❌ Ruim: if/else encadeado para mapear Result em resposta HTTP
public IResult MapResult(Result<Order> result)
{
if (result.IsSuccess)
return Results.Ok(result.Value!);
else if (result.Error.Code == "NOT_FOUND")
return Results.NotFound();
else if (result.Error.Code == "UNAUTHORIZED")
return Results.Unauthorized();
else
return Results.Problem();
}
✅ Bom: switch expression com pattern matching em Result
public IResult MapResult(Result<Order> result)
{
var response = result switch
{
{ IsSuccess: true, Value: var order } => TypedResults.Ok(order),
{ Error.Code: "NOT_FOUND" } => TypedResults.NotFound(),
{ Error.Code: "UNAUTHORIZED" } => TypedResults.Unauthorized(),
_ => Results.Problem(),
};
return response;
}
Switch statement quando cada caso executa ações
O switch expression devolve um valor. Quando o caso precisa disparar várias chamadas, como mandar e-mail e atualizar estoque, use o switch statement. Feche cada case com break explícito: sem ele, o C# passa a execução para o caso seguinte, e esse fall-through (execução que escorre para o próximo caso) é um bug que roda sem reclamar.
❌ Ruim: if/else encadeado para despacho de ações
public void ProcessOrderEvent(OrderEvent orderEvent)
{
if (orderEvent.Type == "order_confirmed")
{
SendConfirmationEmail(orderEvent.OrderId);
UpdateInventory(orderEvent.OrderId);
}
else if (orderEvent.Type == "order_shipped")
{
SendShippingNotification(orderEvent.OrderId);
UpdateTrackingStatus(orderEvent.OrderId);
}
else if (orderEvent.Type == "order_delivered")
{
SendDeliveryConfirmation(orderEvent.OrderId);
CloseOrder(orderEvent.OrderId);
}
}
✅ Bom: switch statement para despacho de comportamento
public void ProcessOrderEvent(OrderEvent orderEvent)
{
switch (orderEvent.Type)
{
case "order_confirmed":
SendConfirmationEmail(orderEvent.OrderId);
UpdateInventory(orderEvent.OrderId);
break;
case "order_shipped":
SendShippingNotification(orderEvent.OrderId);
UpdateTrackingStatus(orderEvent.OrderId);
break;
case "order_delivered":
SendDeliveryConfirmation(orderEvent.OrderId);
CloseOrder(orderEvent.OrderId);
break;
}
}
Dictionary quando as opções vêm de fora do código
switch serve para o conjunto de casos que já se conhece ao escrever o programa. Quando as opções chegam de um arquivo de configuração, do banco ou de uma API, elas mudam sem recompilação, e um Dictionary<TKey, TValue> é a estrutura que aceita essa mudança. GetValueOrDefault ainda resolve a chave ausente na mesma linha da busca.
❌ Ruim: lógica hardcoded para dados que vêm de fonte externa
public string GetCurrencyCode(string region)
{
if (region == "BR") return "BRL";
if (region == "US") return "USD";
if (region == "EU") return "EUR";
return "USD";
}
✅ Bom: Dictionary para lookup dinâmico
private readonly Dictionary<string, string> _currencyByRegion = new()
{
["BR"] = "BRL",
["US"] = "USD",
["EU"] = "EUR",
};
public string GetCurrencyCode(string region)
{
var currencyCode = _currencyByRegion.GetValueOrDefault(region, "USD");
return currencyCode;
}
As estruturas acima escolhem um caminho entre vários. As de baixo repetem um trecho de código sobre uma coleção.
Parar de percorrer assim que encontrar
Antes de escrever o loop, veja se FirstOrDefault, Any ou All já resolve. Esses métodos LINQ (Language Integrated Query · consulta integrada à linguagem) param no primeiro item que decide a resposta e ignoram o resto da coleção. Um foreach que guarda o achado numa variável e continua rodando até o fim faz trabalho que ninguém vai usar, e numa lista grande esse trabalho aparece no tempo de resposta.
❌ Ruim: percorre tudo mesmo após encontrar o resultado
public Order? FindFirstExpiredOrder(IEnumerable<Order> orders)
{
Order? expiredOrder = null;
foreach (var order in orders)
{
if (expiredOrder is null && order.IsExpired)
expiredOrder = order; // continua iterando mesmo após encontrar
}
return expiredOrder;
}
✅ Bom: foreach com return antecipado
public Order? FindFirstExpiredOrder(IEnumerable<Order> orders)
{
foreach (var order in orders)
{
if (order.IsExpired) return order;
}
return null;
}
❌ Ruim: percorre tudo com flag booleana para verificar existência
public bool HasExpiredOrders(IEnumerable<Order> orders)
{
bool hasExpired = false;
foreach (var order in orders)
{
if (order.IsExpired)
hasExpired = true; // continua iterando mesmo após encontrar
}
return hasExpired;
}
✅ Bom: LINQ declarativo com circuit break nativo
// para no primeiro match
var expiredOrder = orders.FirstOrDefault(order => order.IsExpired);
// para no primeiro true
var hasExpiredOrders = orders.Any(order => order.IsExpired);
// para no primeiro false
var allOrdersActive = orders.All(order => order.IsActive);
foreach para percorrer a coleção inteira
foreach percorre os itens sem pedir índice, sem variável de controle e sem risco de errar o limite do último elemento. Ele aceita break e continue normalmente. Reserve o for com índice para quando a posição do item importa de verdade, como percorrer de trás para frente ou saltar de dois em dois.
❌ Ruim: for com índice quando o índice nunca é usado
for (int i = 0; i < orders.Count; i++)
{
NotifyCustomer(orders[i]);
}
✅ Bom: foreach para iteração sobre valores
foreach (var order in orders)
{
NotifyCustomer(order);
}
while quando a parada depende de uma condição
Use while quando não existe uma coleção para percorrer e a repetição continua enquanto um estado for verdadeiro: a conexão ainda não subiu, a fila ainda tem item. Nesses casos o for com contador finge ter um índice que ninguém usa. Use do...while quando a primeira execução acontece sempre e a condição só decide se haverá uma segunda.
❌ Ruim: for simulando condição de parada por estado
for (int attempt = 0; attempt < maxAttempts; attempt++)
{
var connection = ConnectToDatabase();
if (connection.IsReady) break; // o índice não representa nada aqui
}
✅ Bom: while para condição de parada por estado
var attempt = 0;
while (attempt < maxAttempts)
{
var connection = ConnectToDatabase();
if (connection.IsReady) break;
attempt++;
}
❌ Ruim: while com verificação duplicada antes do loop
// verifica a condição antes de entrar, mas a primeira iteração é sempre necessária
if (taskQueue.Count > 0)
{
while (taskQueue.Count > 0)
{
var task = taskQueue.Dequeue();
ExecuteTask(task);
}
}
✅ Bom: do...while quando a primeira execução é garantida
// drena a fila: processa pelo menos um item antes de verificar
do
{
var task = taskQueue.Dequeue();
ExecuteTask(task);
} while (taskQueue.Count > 0);
DoDocs v3.7.0 · Desenvolvido por @thiagocajadev · Baseado no trabalho de pmndrs/docs · Poimandres.