Blog

Controle de fluxo em Java: laços, if e switch expressions

O if e os laços de Java parecem os de C, com algumas regras que pegam bugs reais. O switch antigo cai para o próximo case por padrão, e as switch expressions corrigem isso e fazem o compilador conferir cada valor do enum.

Controle de fluxo é como um programa decide o que rodar em seguida e quantas vezes. O if, o while e o for de Java são quase iguais aos de C, mas algumas regras mudam, e cada uma existe para barrar um bug que você acabaria colocando em produção. A maior mudança está no switch, que agora vem em dois formatos bem diferentes.

Este post cobre o if e o operador ternário, o && e o || com curto-circuito, todo tipo de laço, break e continue com rótulos, a antiga instrução switch e o seu bug de fall-through, e as switch expressions com -> e yield. Ele termina com uma pequena calculadora de notas que usa tudo isso. Todo programa abaixo rodou no Java 25, e a saída foi colada da execução. Para rodar um você mesmo, salve como Main.java e rode java Main.java.

O if precisa de um boolean de verdade

Um if em Java executa o seu bloco quando uma condição é verdadeira, e essa condição precisa ter o tipo boolean. Este é o formato completo de if / else if / else:

String describe(int celsius) {
    if (celsius < 0) {
        return "freezing";
    } else if (celsius < 20) {
        return "cool";
    } else {
        return "warm";
    }
}

void main() {
    IO.println(describe(-5));
    IO.println(describe(12));
    IO.println(describe(31));
}

Ele imprime:

freezing
cool
warm

As verificações rodam de cima para baixo, e a primeira verdadeira vence. 12 não é menor que 0, então o primeiro ramo é pulado. É menor que 20, então "cool" volta e o else nunca roda.

Em C, qualquer número serve como condição, e zero significa falso. Java não permite isso. O erro de digitação clássico de C, = onde você queria ==, não passa pelo compilador:

void main() {
    int x = 3;
    if (x = 5) {
        IO.println("x is five");
    }
}

O build falha com:

Main.java:3: error: incompatible types: int cannot be converted to boolean
    if (x = 5) {
          ^

x = 5 é uma atribuição, e o valor dela é o int 5. Um int não é um boolean, então o javac para. Em C essa linha compila, coloca 5 em x e sempre entra no ramo.

Tem um caso que a regra não pega. Quando a variável já é um boolean, a atribuição tem tipo boolean, e compila:

void main() {
    boolean done = false;
    if (done = true) {
        IO.println("the branch ran, and done is now " + done);
    }
}

Ele imprime:

the branch ran, and done is now true

A gente esperava pelo menos um aviso de lint aqui, e o javac -Xlint:all não deu nenhum. A solução é não comparar booleanos. Escreva if (done) ou if (!done), e não sobra nenhum = para digitar errado.

O operador ternário escolhe um de dois valores

O operador ternário, condition ? a : b, é um if que produz um valor. Use quando os dois ramos são curtos e você quer atribuir ou retornar o resultado:

void main() {
    int items = 1;
    String label = items == 1 ? "item" : "items";
    IO.println(items + " " + label);

    items = 3;
    IO.println(items + " " + (items == 1 ? "item" : "items"));
}

Ele imprime:

1 item
3 items

A condição continua precisando ser um boolean. Mantenha os ternários simples. Um ternário dentro de outro é permitido, mas um if ou uma switch expression fica mais fácil de ler.

&& e || param cedo, & e | não

&& e || pulam o lado direito quando o lado esquerdo já decide a resposta. Isso se chama curto-circuito (short-circuit). & e | também funcionam com booleanos, mas sempre avaliam os dois lados. A diferença aparece quando o lado direito faz alguma coisa:

boolean check(String name, boolean result) {
    IO.println("  checked " + name);
    return result;
}

void main() {
    IO.println("&&:");
    boolean a = check("left", false) && check("right", true);
    IO.println("&:");
    boolean b = check("left", false) & check("right", true);
    IO.println("||:");
    boolean c = check("left", true) || check("right", false);
    IO.println(a + " " + b + " " + c);
}

Ele imprime:

&&:
  checked left
&:
  checked left
  checked right
||:
  checked left
false false true

Com &&, quando o lado esquerdo é falso a expressão inteira é falsa, então right nunca é verificado. & dá a mesma resposta, mas roda os dois. || para assim que o lado esquerdo é verdadeiro.

É o curto-circuito que deixa seguro checar null e depois chamar um método. Troque && por & e a proteção deixa de proteger:

String name = null;

void main() {
    if (name != null && name.length() > 3) {
        IO.println("long name");
    }
    IO.println("the && version is fine");
    if (name != null & name.length() > 3) {
        IO.println("long name");
    }
}

Ele imprime e para:

the && version is fine
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.length()" because "this.name" is null

A versão com & chama name.length() mesmo com name != null sendo falso. No código do dia a dia, use && e ||. Guarde & e | para operações de bits em inteiros.

while, do-while e o for clássico

Java tem três laços que repetem enquanto uma condição vale, e eles diferem em quando a condição é verificada. O while verifica antes de cada passada, o do-while verifica depois, e o for coloca o início, a verificação e o passo numa linha só:

void main() {
    int n = 3;
    while (n > 0) {
        IO.println("while: " + n);
        n--;
    }

    int tries = 10;
    do {
        IO.println("do-while ran with tries = " + tries);
        tries++;
    } while (tries < 5);

    for (int i = 0; i < 3; i++) {
        IO.println("for: i = " + i);
    }
}

Ele imprime:

while: 3
while: 2
while: 1
do-while ran with tries = 10
for: i = 0
for: i = 1
for: i = 2

O corpo do do-while rodou uma vez, mesmo com tries < 5 falso desde o começo. Essa é a única coisa que o do-while faz diferente. Ele serve para “faça isso, depois pergunte se é para repetir”, como pedir uma entrada até ela ser válida.

O i do laço for só existe dentro do laço. Use depois da chave de fechamento e o javac reporta cannot find symbol.

O for aprimorado passa por todo elemento

O for aprimorado (enhanced for), escrito for (var x : things), visita cada elemento de um array ou de uma coleção em ordem, sem índice para controlar:

void main() {
    int[] scores = {72, 95, 88};
    int total = 0;
    for (int score : scores) {
        total += score;
    }
    IO.println("total: " + total);

    var names = List.of("Ana", "Ben", "Chen");
    for (var name : names) {
        IO.println("hello, " + name);
    }
}

Ele imprime:

total: 255
hello, Ana
hello, Ben
hello, Chen

Leia os dois-pontos como “em”: para cada score em scores. Funciona com arrays e com qualquer coisa que implemente Iterable, o que inclui List, Set e as outras coleções. Quando você precisa da posição além do valor, volte para o for clássico.

Um erro de um, e a correção

Um erro de um (off-by-one) é um laço que roda uma vez a mais ou uma vez a menos. Com arrays, a causa comum é <= onde você precisava de <:

void main() {
    String[] days = {"Mon", "Tue", "Wed"};
    for (int i = 0; i <= days.length; i++) {
        IO.println(i + ": " + days[i]);
    }
}

Ele imprime e para:

0: Mon
1: Tue
2: Wed
Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: Index 3 out of bounds for length 3

Um array de tamanho 3 tem os índices 0, 1 e 2. A condição i <= days.length deixa i chegar a 3, e Java confere todo acesso a array, então lança uma exceção em vez de ler a memória que estiver depois do fim. C não confere, então o mesmo laço lá pode ler lixo sem avisar.

A correção é <:

void main() {
    String[] days = {"Mon", "Tue", "Wed"};
    for (int i = 0; i < days.length; i++) {
        IO.println(i + ": " + days[i]);
    }
}

Ele imprime:

0: Mon
1: Tue
2: Wed

Comece em 0 e pare antes do tamanho. Se você não precisa de i, o for aprimorado não tem como errar isso, porque não tem índice.

break, continue e break com rótulo

break sai de um laço na hora, e continue pula o resto da passada atual e começa a próxima. Os dois agem sobre o laço mais interno, o que é um problema quando você está dois laços para dentro. Um rótulo (label) dá nome a um laço externo, para que o break saia desse:

void main() {
    for (int i = 1; i <= 6; i++) {
        if (i % 2 == 0) {
            continue;
        }
        if (i == 5) {
            break;
        }
        IO.println("odd: " + i);
    }

    int[][] grid = {{1, 2, 3}, {4, 42, 6}, {7, 8, 9}};

    for (int[] row : grid) {
        for (int value : row) {
            if (value == 42) {
                IO.println("plain break: found 42");
                break;
            }
        }
        IO.println("plain break: still scanning after a row");
    }

    search:
    for (int r = 0; r < grid.length; r++) {
        for (int c = 0; c < grid[r].length; c++) {
            if (grid[r][c] == 42) {
                IO.println("labeled break: found 42 at row " + r + ", column " + c);
                break search;
            }
        }
    }
    IO.println("done");
}

Ele imprime:

odd: 1
odd: 3
plain break: still scanning after a row
plain break: found 42
plain break: still scanning after a row
plain break: still scanning after a row
labeled break: found 42 at row 1, column 1
done

O primeiro laço pula os números pares com continue e para no 5 com break, então só 1 e 3 são impressos.

O break simples achou o 42 na linha do meio e saiu só do laço interno. O laço externo seguiu em frente, imprimiu a sua linha e percorreu a última linha à toa. Um break simples não alcança além de um laço.

search: rotula o laço externo, então break search sai dos dois laços de uma vez. A próxima linha a rodar é a que vem depois do laço externo, que imprime done. O continue também aceita rótulo: continue search pularia para a próxima linha da grade.

Rótulos são raros na prática. Se você precisar de um, costuma ficar mais limpo mover os laços aninhados para um método e dar return quando achar o valor.

A antiga instrução switch cai para o próximo case

A antiga instrução switch pula para o case que casa e depois continua rodando todas as linhas abaixo dele, atravessando os cases seguintes, até encontrar um break. Isso se chama fall-through, e um break esquecido transforma isso num bug:

@SuppressWarnings("fallthrough")
String dayType(int day) {
    String result = "";
    switch (day) {
        case 6:
            result += "Saturday ";
        case 7:
            result += "Sunday ";
            break;
        default:
            result += "weekday ";
    }
    return result;
}

void main() {
    IO.println("6 -> " + dayType(6).strip());
    IO.println("7 -> " + dayType(7).strip());
    IO.println("2 -> " + dayType(2).strip());
}

Ele imprime:

6 -> Saturday Sunday
7 -> Sunday
2 -> weekday

O dia 6 casou com case 6, adicionou "Saturday " e seguiu direto para o case 7, adicionando "Sunday " também. Nada o parou até o break. O dia 7 começou mais abaixo, então só pegou "Sunday ".

A linha @SuppressWarnings("fallthrough") está ali porque todo programa desta série é compilado com javac -Xlint:all -Werror. Tire a linha e esse build pega o bug:

String dayType(int day) {
    String result = "";
    switch (day) {
        case 6:
            result += "Saturday ";
        case 7:
            result += "Sunday ";
            break;
        default:
            result += "weekday ";
    }
    return result;
}

void main() {
    IO.println(dayType(6));
}

O build falha com:

Main.java:6: warning: [fallthrough] possible fall-through into case
        case 7:
        ^
error: warnings found and -Werror specified

Esse aviso vem desligado por padrão. Um javac Main.java simples não imprime nada, e o java Main.java também não, que rodou a versão com bug acima sem dizer uma palavra. Se você mantém código com switches antigos, ligue o -Xlint:fallthrough.

De vez em quando o fall-through é o que você quer. case 6: case 7: sem nada no meio é o jeito antigo de fazer dois rótulos dividirem um bloco. O switch novo tem um jeito mais limpo de dizer isso.

Switch expressions: -> e nada de fall-through

Um switch com -> roda exatamente um case e nunca cai no seguinte. Ele também pode ser uma expressão: o switch inteiro produz um valor que você pode atribuir ou retornar. As switch expressions viraram recurso final no Java 14. Com javac --release 13, o javac as rejeita com switch expressions are not supported in -source 13.

enum Day { MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY }

String dayType(Day day) {
    return switch (day) {
        case SATURDAY, SUNDAY -> "weekend";
        case MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY -> "weekday";
    };
}

void main() {
    IO.println("SATURDAY -> " + dayType(Day.SATURDAY));
    IO.println("SUNDAY -> " + dayType(Day.SUNDAY));
    IO.println("TUESDAY -> " + dayType(Day.TUESDAY));
}

Ele imprime:

SATURDAY -> weekend
SUNDAY -> weekend
TUESDAY -> weekday

Compare com a versão antiga:

  • Nada de break. O lado direito do -> roda, e o switch termina. Não tem nada para esquecer.
  • Vários rótulos num case. case SATURDAY, SUNDAY substitui a pilha antiga case 6: case 7:.
  • Sai um valor. return switch (...) { ... }; retorna o que o case escolhido produziu. Repare no ponto e vírgula depois da chave de fechamento, porque o switch faz parte de uma instrução return.
  • Nada de default. Os cases citam os sete dias, e o compilador conferiu isso. Mais sobre isso abaixo.

Você não pode misturar os dois estilos. Um case 6: e um case 7 -> no mesmo switch falham com different case kinds used in the switch.

A seta também funciona numa instrução switch, sem valor, quando cada case só faz alguma coisa. Você continua sem fall-through.

yield devolve um valor de um bloco

Quando um case precisa de mais de uma expressão, dê a ele um bloco entre chaves e termine o bloco com yield:

enum Day { MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY }

int openingHour(Day day) {
    return switch (day) {
        case SATURDAY -> 10;
        case SUNDAY -> {
            IO.println("  (Sunday: checking the holiday rota)");
            yield 12;
        }
        default -> 9;
    };
}

void main() {
    IO.println("MONDAY opens at " + openingHour(Day.MONDAY));
    IO.println("SUNDAY opens at " + openingHour(Day.SUNDAY));
}

Ele imprime:

MONDAY opens at 9
  (Sunday: checking the holiday rota)
SUNDAY opens at 12

O yield entrega um valor de volta ao switch, do jeito que o return entrega um de volta de um método. Você não pode usar return para isso, porque o return sairia do próprio openingHour. Deixe o yield fora do bloco e o javac reporta switch rule completes without providing a value.

O default está certo aqui, porque “todo outro dia abre às 9” é a regra de verdade. A próxima seção mostra quando o default esconde um bug.

Explicado como se você tivesse dez anos

Imagine o switch antigo como um escorregador alto com uma plataforma em cada andar: um andar para o sábado, um abaixo dele para o domingo, um abaixo desse para os dias úteis. Você sobe no seu andar. Aí você escorrega, e continua escorregando por todos os andares de baixo, pegando o que tiver em cada um, até um break te segurar como uma rede. Esqueça a rede e você escorrega até o chão.

O switch novo é um corredor de portas separadas. Tem uma porta do sábado, uma do domingo e uma dos dias úteis. Você abre a que tem o seu nome e entra naquela sala. A sala não tem buraco no chão, então você não tem como parar na sala ao lado.

A versão precisa

Numa instrução switch antiga, os rótulos case são só pontos de entrada num único bloco de instruções. O switch pula para o rótulo que casa e roda as instruções em ordem a partir dali. Um break pula para fora do bloco. Sem ele, a execução segue para as instruções do próximo rótulo, porque para o compilador elas são só as próximas linhas.

Num switch com ->, cada case é uma regra separada. O lado direito é uma expressão, um bloco ou um throw, e quando ele termina, o controle sai do switch. Numa switch expression, toda regra também precisa produzir um valor, direto ou com yield, ou lançar uma exceção.

Onde a analogia falha: você pode construir de propósito um escorregador com uma plataforma dividida entre dois andares, e o switch novo cobre isso com case SATURDAY, SUNDAY, uma porta com dois nomes. As portas também têm uma regra que a analogia não mostra: numa switch expression, o compilador confere se existe uma porta para cada valor possível antes de o programa rodar.

Switch com strings e enums

Sem patterns, um switch funciona com int, short, byte, char, as classes wrapper deles, String e enums. long não está na lista: no Java 25, o javac rejeita um switch sobre long com primitive patterns are a preview feature and are disabled by default. Strings são comparadas com equals, então o case casa pelo texto, não pelo objeto:

String run(String command) {
    return switch (command) {
        case "start", "go" -> "starting";
        case "stop" -> "stopping";
        default -> "unknown command: " + command;
    };
}

void main() {
    IO.println(run("go"));
    IO.println(run("stop"));
    IO.println(run("jump"));
    IO.println(run(null));
}

Ele imprime e para:

starting
stopping
unknown command: jump
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.hashCode()" because "<local2>" is null

As três primeiras chamadas se comportam como você espera. O null não chega ao default. Ele lança uma exceção, e a mensagem entrega como um switch de string funciona. O javac copia command para uma variável escondida, chama hashCode() nela para escolher um case candidato e depois confirma o casamento com equals. A variável escondida não tem nome, então a mensagem a chama de <local2>. Se null é uma entrada real, verifique antes do switch. A parte sobre sealed types e pattern matching mostra case null em switches com patterns.

Uma switch expression sobre enum precisa cobrir toda constante

Uma switch expression sobre um enum sem default precisa listar toda constante. Se faltar uma, o código não compila. Aqui SUNDAY ficou de fora:

enum Day { MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY }

String dayType(Day day) {
    return switch (day) {
        case SATURDAY -> "weekend";
        case MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY -> "weekday";
    };
}

void main() {
    IO.println(dayType(Day.SUNDAY));
}

O build falha com:

Main.java:4: error: the switch expression does not cover all possible input values
    return switch (day) {
           ^

Uma switch expression precisa produzir um valor para toda entrada, e o compilador conhece a lista completa de constantes de Day, então ele vê que SUNDAY não tem para onde ir. Por isso o dayType anterior não precisou de default. É também por isso que você normalmente deve deixar o default fora de uma switch expression sobre enum. Adicione uma constante HOLIDAY no ano que vem, e todo switch que a esqueceu para de compilar. Um default daria a HOLIDAY a resposta padrão sem avisar.

Essa verificação vale para switch expressions. Uma instrução switch no estilo antigo sobre um enum, com -> ou com :, ainda pode pular constantes, e um valor pulado simplesmente não roda nada. A parte sobre sealed types e pattern matching trata de switches sobre tipos, onde a mesma verificação de exaustividade faz mais trabalho.

Um exemplo completo: uma calculadora de notas

Uma calculadora de notas junta quase todo este post num programa só. Ela percorre uma lista de notas, transforma cada uma numa letra com uma switch expression, rejeita notas fora de 0 a 100 e conta quantas de cada letra apareceram:

char grade(int score) {
    if (score < 0 || score > 100) {
        throw new IllegalArgumentException("score out of range: " + score);
    }
    return switch (score / 10) {
        case 10, 9 -> 'A';
        case 8 -> 'B';
        case 7 -> 'C';
        case 6 -> 'D';
        default -> 'F';
    };
}

void main() {
    var scores = List.of(91, 78, 100, 59, 84, 67, 88, 73, 45, 95);
    var counts = new TreeMap<Character, Integer>();
    int passed = 0;

    for (int score : scores) {
        char letter = grade(score);
        counts.put(letter, counts.getOrDefault(letter, 0) + 1);
        if (letter != 'F') {
            passed++;
        }
    }

    for (var entry : counts.entrySet()) {
        IO.println(entry.getKey() + ": " + "#".repeat(entry.getValue()));
    }
    IO.println(passed + " of " + scores.size() + " passed");

    try {
        grade(101);
    } catch (IllegalArgumentException e) {
        IO.println("rejected: " + e.getMessage());
    }
}

Ele imprime:

A: ###
B: ##
C: ##
D: #
F: ##
8 of 10 passed
rejected: score out of range: 101

Agora as partes:

  • score / 10 é divisão inteira, então 91 vira 9 e 78 vira 7. Isso transforma 101 notas possíveis em 11 cases.
  • case 10, 9 -> 'A' dá A para o 100 sem um if separado.
  • default -> 'F' está certo aqui. Um int tem bilhões de valores, e “tudo abaixo de 60” é uma regra genérica de verdade. A verificação de faixa acima do switch garante que nada absurdo chegue até ele.
  • O laço for conta dentro de um TreeMap, que mantém as chaves ordenadas. É por isso que as letras saem de A a F.
  • O segundo laço for percorre o map e desenha um # por nota com String.repeat.
  • grade(101) falha na verificação de faixa, lança a exceção, e o catch imprime a mensagem. Sem a verificação, 101 / 10 dá 10, e o 101 teria ganhado um A sem ninguém perceber.

Percorrer um Map quando a ordem importa

Você percorre um map com for (var entry : map.entrySet()), e a ordem que você recebe depende do tipo de map. Um HashMap devolve as entradas numa ordem baseada nos hash codes, que não é a ordem em que você as inseriu. Map.of(...) é pior para imprimir: a ordem dele muda de uma execução da JVM para outra, e quatro execuções do mesmo programa imprimiram quatro ordens diferentes quando testamos. Quando a ordem importa, escolha o map que promete uma. Um LinkedHashMap mantém a ordem de inserção, e um TreeMap mantém as chaves ordenadas:

void main() {
    var byInsertion = new LinkedHashMap<String, Integer>();
    byInsertion.put("pears", 3);
    byInsertion.put("apples", 5);
    byInsertion.put("figs", 1);

    for (var entry : byInsertion.entrySet()) {
        IO.println("inserted: " + entry.getKey() + " = " + entry.getValue());
    }

    var sorted = new TreeMap<>(byInsertion);
    for (var entry : sorted.entrySet()) {
        IO.println("sorted:   " + entry.getKey() + " = " + entry.getValue());
    }
}

Ele imprime:

inserted: pears = 3
inserted: apples = 5
inserted: figs = 1
sorted:   apples = 5
sorted:   figs = 1
sorted:   pears = 3

A parte sobre equals, hashCode e coleções explica por que um HashMap não tem uma ordem útil.

O que lembrar

  • A condição de um if precisa ser um boolean. if (x = 5) não compila, mas if (done = true) compila, então escreva if (done).
  • && e || pulam o lado direito quando o lado esquerdo decide a resposta. & e | sempre rodam os dois lados.
  • Percorra arrays com i < array.length, não <=, ou use o for aprimorado e dispense o índice.
  • Um break simples sai só do laço mais interno. Rotule o laço externo para sair dos dois.
  • A antiga instrução switch cai para o próximo case sem break, e nada te avisa, a não ser que você ligue o -Xlint:fallthrough.
  • Um switch com -> nunca cai para o próximo case. Como expressão, ele retorna um valor, usa yield dentro de um bloco e precisa cobrir toda constante do enum, então deixe o default de fora quando o enum é a lista inteira.
  • Use um LinkedHashMap ou um TreeMap quando a ordem em que você percorre um map importa.

Prefira o switch com ->: cada case roda sozinho, e o compilador confere que nenhum está faltando.

Quanto este post te ajudou?

Clique em um coração para avaliar!

Média das avaliações 0 / 5. Total de votos: 0

Nenhum voto até agora. Seja o primeiro a avaliar este post.