Switch Expressions: yield, Rótulos com Arrow e Exaustividade
Objective
Entenda que o switch statement clássico e a switch expression (JEP 361, finalizada no Java 14) são duas construções diferentes que por acaso compartilham a palavra-chave switch. O statement é em forma de dois-pontos (case X:), cai (fall-through) de um rótulo para o próximo a menos que um break o interrompa, e não produz valor nenhum. A expression produz um valor diretamente utilizável em uma atribuição, um return, ou uma posição de argumento — o que força duas propriedades que o statement nunca teve: todo caminho precisa produzir definitivamente um valor, então o compilador rejeita uma switch expression que não consiga provar exaustiva, e rótulos arrow (case X ->) executam exatamente um ramo, sem fall-through.
Esta é a base mecânica sobre a qual pattern-matching se constrói. Aquele conceito usa ramos -> e exaustividade o tempo todo, mas sempre a serviço de type e record patterns sobre hierarquias seladas. Aqui não há patterns nenhum — só a canalização: ramos arrow, yield, rótulos com múltiplos valores, e onde a exaustividade é checada, sobre valores comuns de enum, String e int.
Use Cases
- Atribuir uma variável ou retornar um valor a partir de um conjunto pequeno e fixo de casos (um
enum, um punhado de constantesint/String) sem declarar um local mutável e reatribuí-lo em cada ramo de um switch statement. - Remover bugs acidentais de fall-through de código que usava a forma de dois-pontos e dependia de um humano lembrar de
breakem cada ramo. - Mapear vários valores para um único resultado em uma linha só —
case SATURDAY, SUNDAY -> "Weekend"— em vez de empilhar rótuloscasesoltos que caem intencionalmente em um corpo compartilhado. - Produzir um valor a partir de um ramo que precisa de mais de um statement, via
yield.
Deep Dive
Forma de dois-pontos vs. forma arrow
A forma de dois-pontos é o switch statement original. O controle entra no rótulo correspondente e continua rodando até um break (ou o fim do bloco) o interromper — inclusive atravessando direto pelo próximo rótulo:
javastatic String size(int code) {
String label = "";
switch (code) {
case 1:
label = "small"; // no break: execution continues into case 2
case 2:
label = "medium";
break;
default:
label = "large";
}
return label;
}
size(1); // "medium", not "small"Esse é o bug clássico de fall-through: nada na linguagem sinaliza isso, e o resultado é silenciosamente errado. A forma arrow torna esse erro estruturalmente impossível — só o ramo correspondente roda, e não existe sintaxe para cair no próximo:
javastatic String size(int code) {
return switch (code) {
case 1 -> "small";
case 2 -> "medium";
default -> "large";
};
}
size(1); // "small"Duas coisas valem a pena separar. A forma arrow é uma escolha de sintaxe: switch (x) { case 1 -> doThing(); } ainda é um statement, só que não pode cair através dos casos. Ser uma expression é uma propriedade diferente — significa que o switch inteiro é avaliado para um valor, que é do que o return switch (...) acima depende. A forma de dois-pontos só pode ser um statement; a forma arrow pode ser qualquer um dos dois.
Fall-through não é puramente um risco, no entanto: é a única coisa que a forma de dois-pontos consegue expressar e a forma arrow não consegue. Deixar deliberadamente o código de um caso cair no seguinte — acumulando trabalho entre rótulos — não tem equivalente em forma arrow, e esse é o risco de migração coberto em Trade-offs.
yield: produzindo um valor a partir de um ramo com corpo em bloco
case X -> expr tem um valor óbvio: expr. Um bloco não tem esse valor implícito, então um ramo escrito como case X -> { ... } dentro de uma switch expression precisa devolver um valor explicitamente com yield:
javastatic int score(String grade) {
return switch (grade) {
case "A" -> 4; // single expression: its value is the arm's value
case "B" -> { // block: needs an explicit yield
System.out.println("logging a B");
int base = 3;
yield base;
}
default -> 0;
};
}Os ramos "A" e "B" produzem valores da mesma forma do ponto de vista de quem chama; só o ramo "B" precisa de yield, porque é um bloco. yield é obrigatório em um ramo em bloco de uma switch expression e não tem sentido em um switch statement (nada está sendo produzido ali).
yield é uma palavra-chave contextual, não uma palavra reservada, então código antigo que a usava como nome continua compilando:
javastatic int yield = 5;
static int yield() { return 7; }
System.out.println(yield); // fine: field named yield
System.out.println(Foo.yield()); // fine, but the call must be qualifiedSó um ponto estreito quebrou: uma chamada não qualificada yield() é rejeitada, porque é ambígua com a forma statement. Qualificá-la (this.yield(), Foo.yield()) resolve isso.
Rótulos case com múltiplos valores
Um único ramo arrow pode listar várias constantes separadas por vírgula que mapeiam para o mesmo resultado:
javastatic String kind(Day d) {
return switch (d) {
case MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY -> "Weekday";
case SATURDAY, SUNDAY -> "Weekend";
};
}Na forma de dois-pontos, o mesmo agrupamento exigia empilhar rótulos soltos e depender do fall-through para alcançar um corpo compartilhado:
javaswitch (d) {
case SATURDAY:
case SUNDAY:
return "Weekend";
default:
return "Weekday";
}Rótulos com múltiplos valores dizem diretamente o que o idioma de rótulos empilhados dizia indiretamente, e eles funcionam na forma de dois-pontos também — a lista separada por vírgula é um recurso do rótulo, não um recurso do arrow.
Exaustividade é um requisito da switch expression
Uma switch expression precisa produzir um valor para toda entrada possível, então o compilador verifica a cobertura. Sobre um enum com um caso para cada constante, essa checagem passa sem default algum:
javaenum Day { MONDAY, SATURDAY, SUNDAY }
static String kind(Day d) {
return switch (d) { // compiles: all three constants covered, no default needed
case MONDAY -> "Weekday";
case SATURDAY -> "Weekend";
case SUNDAY -> "Weekend";
};
}O mesmo switch idêntico usado como statement não tem esse requisito, porque um statement não produz nada — um valor não coberto é simplesmente um no-op:
javastatic void report(Day d) {
switch (d) { // compiles fine, even though SATURDAY and SUNDAY are unhandled
case MONDAY -> System.out.println("Weekday");
}
}
report(Day.SUNDAY); // prints nothing at all, no errorEsse no-op silencioso é exatamente a proteção que transformar o switch em expression garante.
Sobre um tipo cujos valores o compilador não consegue enumerar — um int, String, long comum, ou qualquer tipo de referência não selado — exaustividade não é demonstrável, então um ramo default é obrigatório:
javastatic String name(int i) {
return switch (i) { // error: the switch expression does not cover all possible input values
case 1 -> "one";
case 2 -> "two";
};
}Adicionar default -> "many"; resolve. Só seletores enum (e, conforme o próximo parágrafo, seletores selados) podem dispensar default.
O paralelo com tipos selados
O mesmo mecanismo de exaustividade tem uma segunda fonte de prova: quando o seletor é um tipo sealed e os rótulos case são type ou record patterns cobrindo todo subtipo permitido, o compilador consegue provar a cobertura sem um default, exatamente como faz para um enum completo. Esse é o assunto de pattern-matching — a mecânica desta página (ramos arrow, yield, a divisão expression/statement) é aquilo sobre o qual ele se apoia.
Trade-offs
- Um único estilo de rótulo por bloco switch — rótulos em forma de dois-pontos e em forma arrow não podem ser misturados no mesmo
switch. Não existe migração gradual, caso a caso; converter um switch significa converter tudo de uma vez:
javaswitch (i) {
case 1: System.out.println("one"); break;
case 2 -> System.out.println("two"); // error: different case kinds used in the switch
}Perder o fall-through é uma mudança real de comportamento, não só de sintaxe — uma migração mecânica que troca
:/breakpor->caso a caso quebra silenciosamente qualquer lógica que pretendia cair através dos casos, porque o código compartilhado que costumava rodar para ambos os rótulos agora roda para apenas um. Essa lógica precisa ser reescrita como um rótulo com múltiplos valores (case 1, 2 -> ...) ou duplicada nos dois ramos; o compilador não vai apontar o que você perdeu, já que o resultado continua sendo código válido.yieldse liga à switch expression mais interna que a envolve — em switches aninhados, oyieldinterno produz o valor do switch interno, e não há forma de "atravessar" para o externo; o ramo externo precisa do seu próprioyield. O único lugar onde isso é inequívoco é uma lambda dentro de um ramo: o corpo de uma lambda não faz parte do switch, entãoyieldali é um erro de compilação (yield outside of switch expression), em vez de uma surpresa silenciosa. Ainda assim, switch expressions profundamente aninhadas leem mal — extrair a interna para um método costuma ser mais claro do que depender do leitor rastrear a qual switch umyieldpertence.Um switch exaustivo sobre enum pode ficar desatualizado entre compilações — a garantia de "não precisa de
default" é checada em tempo de compilação contra o enum como ele existia naquele momento. Adicione uma constante, recompile só o enum, e o switch já compilado deixa de ser exaustivo. Ele não retorna silenciosamente um valor errado: o class file mantém um ramo sintético de não-correspondência que lança exceção (no JDK 21+ umjava.lang.MatchException; releases mais antigas lançavamIncompatibleClassChangeErroraqui, então o tipo exato não é algo para codificar contra). Este é o análogo, para enums, do risco de recompilação de hierarquias seladas, e a correção é a mesma — recompilar tudo que faz switch sobre o enum, o que transforma a falha em runtime de volta em um erro de compilação exigindo o novo caso:
javaenum Day { MONDAY, SATURDAY } // kind() compiled against this
enum Day { MONDAY, SATURDAY, SUNDAY } // enum recompiled alone
kind(Day.SUNDAY); // MatchException at runtime