Anotações Capítulo 7: Error Handling

  • As coisas podem dar errado, e quando isso ocorre, nós somos responsáveis por certificar que nosso código faça o que seja preciso fazer.
  • É quase impossível ver o que o código faz devido a tantos tratamentos de erros.
  • Esse recurso é importante, “mas se obscurecer a lógica, está errado”.

Use exceções em vez de retornar códigos

  • Antigamente muitas linguagens não suportavam exceções.
  • Assim a solução era criar uma flag de erro ou retornar um código de erro que o chamador pudesse verificar. Exemplo:
public class DeviceController {
    public void sendShutDown() {
        DeviceHandle handle = getHandle(DEV1);
        // Check the state of the device
        if (handle != DeviceHandle.INVALID) {
            // Save the device status to the record field
            retrieveDeviceRecord(handle);
            // If not suspended, shut down
            if (record.getStatus() != DEVICE_SUSPENDED) {
                pauseDevice(handle);
                clearDeviceWorkQueue(handle);
                closeDevice(handle);
            } else {
                logger.log("Device suspended. Unable to     shut down");
            }
        } else {
            logger.log("Invalid handle for: " +     DEV1.toString());
        }
    }
...
}
  • “O problema era que essas técnicas entupiam o chamador, que devia verificar erros imediatamente após a chamada”. Porém facilmente se esqueciam de fazer essa verificação. Por esse motivo, é melhor lançar uma exceção quando um erro for encontrado.
  • Assim o código de chamada fica mais limpo e sua lógica não fica ofuscada pelo tratamento de erro.
  • Exemplo anterior lançando exceções:
public class DeviceController {
    public void sendShutDown() {
        try {
            tryToShutDown();
        } catch (DeviceShutDownError e) {
            logger.log(e);
        }
    }

    private void tryToShutDown() throws DeviceShutDownError {
        DeviceHandle handle = getHandle(DEV1);
        DeviceRecord record = retrieveDeviceRecord(handle);

        pauseDevice(handle);
        clearDeviceWorkQueue(handle);
        closeDevice(handle);
    }

    private DeviceHandle getHandle(DeviceID id) {
        throw new DeviceShutDownError("Invalid handle for: " + id.toString());
    }

    ...
}
  • O código fica mais claro. O código fica melhor porque as duas coisas estão separadas: O algoritmo para o desligamento do dispositivo e o tratamento de erro.

Crie primeiro sua estrutura try-catch-finally

  • As exceções definem um escopo dentro de seu programa. Sempre que executar o try, você declara que aquela execução pode ser cancelada a qualquer momento e então continuar no catch.
  • De certa forma, os blocos try são como transações. Seu catch tem que deixar seu programa num estado consistente, não importa o que aconteça no try.
  • Assim, uma boa prática é começar com uma estrutura try...catch...finally quando for escrever um código que talvez lance exceções. Isso ajuda a definir o que o usuário do código deve esperar, independente do que ocorra de errado no código que é executado no try.
  • Exemplo:
    • Um código que acesse um arquivo e consulte alguns objetos em série:
    • Começamos com um teste de unidade que mostra como capturar uma exceção se o arquivo não existir:
@Test(expected = StorageException.class)
public void retrieveSectionShouldThrowOnInvalidFileName() {
    sectionStore.retrieveSection("invalid - file");
}
  • O teste nos leva a criar esse stub:
public List<RecordedGrip> retrieveSection(String sectionName) {
    // dummy return until we have a real implementation
    return new ArrayList<RecordedGrip>();
}
  • Nosso teste falha porque ele não lança uma exceção. Agora, mudamos nossa implementação de modo a tentar acessar um arquivo inválido, lançando uma exceção:
public List<RecordedGrip> retrieveSection(String sectionName) {
    try {
        FileInputStream stream = new FileInputStream(sectionName);
    } catch (Exception e) {
        throw new StorageException("retrieval error", e);
    }
    return new ArrayList<RecordedGrip>();
}
  • Agora o teste funciona porque captura a exceção.
  • Refatorando para a exceção que realmente é lançada pelo FileInputStream:
public List<RecordedGrip> retrieveSection(String sectionName) {
    try {
        FileInputStream stream = new FileInputStream(sectionName);
        stream.close();
    } catch (FileNotFoundException e) {
        throw new StorageException("retrieval error”, e);
    }
    return new ArrayList<RecordedGrip>();
}

Use exceções não verificadas

  • Exceções verificadas violão o princípio do Aberto-Fechado.
  • Se lançar uma exceção a ser verificada a partir de um método em seu código e o catch estiver três níveis acima, será preciso declará-la na assinatura de cada método entre você e o catch.
  • Isso significa que uma modificação em um nível mais baixo do software pode forçar a alteração de assinaturas em muitos níveis mais altos.
  • Os módulos precisaram ser alterados mesmo que nada tenha realmente mudado.
  • O propósito de exceções é que elas lhe permitem tratar erros distantes, porém as exceções verificadas quebram o encapsulamento.
  • Exceções verificadas podem às vezes ser úteis se estiver criando uma biblioteca crítica. Mas no desenvolvimento geral, os custos da dependência superam as vantagens.

Forneça exceções com contexto

  • Cada exceção lançada deve fornecer contexto suficiente para determinar a fonte e a localização de um erro.
  • No Java temos a stack trace de qualquer exceção. Porém ela não consegue dizer o motivo da falha da operação.
  • Crie mensagens de erro informativas e as passe juntamente com as exceções.
  • Mencione a operação que falhou e o tipo de falha.

Defina as classes de exceções segundo as necessidades do chamador

  • Exemplo que cobre todas as possíveis exceções:
ACMEPort port = new ACMEPort(12);

try {
    port.open();
} catch (DeviceResponseException e) {
    reportPortError(e);
    logger.log("Device response exception", e);
} catch (ATM1212UnlockedException e) {
    reportPortError(e);
    logger.log("Unlock exception", e);
} catch (GMXError e) {
    reportPortError(e);
    logger.log("Device response exception");
} finally {
}
  • A estrutura acima possui muita duplicação! E na maioria dos casos de tratamento de exceções, o que fazemos é relativamente padrão, independente da situação no momento.
  • Temos que registar um erro e nos certificar que podemos prosseguir.
  • Nesse caso podemos simplificar o código pegando um tipo comum de exceção:
LocalPort port = new LocalPort(12);
try {
    port.open();
} catch (PortDeviceFailure e) {
    reportError(e);
    logger.log(e.getMessage(), e);
} finally {
}
  • Assim temos a classe LocalPort é um simples wrapper (empacotador) que captura e traduz as exceções:
public class LocalPort {

    private ACMEPort innerPort;

    public LocalPort(int portNumber) {
        innerPort = new ACMEPort(portNumber);
    }

    public void open() {
        try {
            innerPort.open();
        } catch (DeviceResponseException e) {
            throw new PortDeviceFailure(e);
        } catch (ATM1212UnlockedException e) {
            throw new PortDeviceFailure(e);
        } catch (GMXError e) {
            throw new PortDeviceFailure(e);
        }
    }
}
  • Esses empacotadores podem ser muito úteis.
  • Essa abordagem acima é a melhor prática que existe.
  • Com isso, minimizamos as dependências nelas.
  • Assim é fácil migrar para outra biblioteca no futuro sem grandes problemas.

Defina o fluxo normal

  • Um código que soma as despesas em um aplicativo de finanças:
try {
    MealExpenses expenses = expenseReportDAO.getMeals(employee.getID());
    m_total += expenses.getTotal();
} catch(MealExpensesNotFound e) {
    m_total += getMealPerDiem();
}
  • Nesse código, se as refeições (meals) forem um custo, elas se tornam parte do total. Caso contrário, o funcionário recebe uma quantia para ajuda de custos (PerDiem) pela refeição daquele dia. Assim, a exceção confunde a lógica.
  • Nesse caso seria melhor não ter que lidar com o caso especial, da seguinte forma:
MealExpenses expenses = expenseReportDAO.getMeals(employee.getID());
m_total += expenses.getTotal();
  • E para deixar o código ainda mais simples, alterando o ExpenseReportDAO para que ele sempre retorne um objeto MealExpenses. Se não houver gastos com refeições, ele retorna um objeto PerDiemMealExpenses que retorna a diária como seu total:
public class PerDiemMealExpenses implements MealExpenses {
    public int getTotal() {
        // retorna a ajuda de custo padrão
    }
}

Isso se chama padrão Special Case (Padrão de caso especial). Você cria uma classe ou configure um objeto de modo que ele trate de um caso especial para você. Com isso o código do cliente não precisa lidar com um comportamento diferente. Este fica encapsulado num objeto de caso especial.

Não retorne null

public void registerItem(Item item) {
    if (item != null) {
        ItemRegistry registry = peristentStore.getItemRegistry();
        if (registry != null) {
            Item existing = registry.getItem(item.getID());
            if (existing.getBillingPeriod().hasRetailOwner()) {
                existing.register(item);
            }
        }
    }
}
  • Quando retornamos null, estamos criando mais trabalho para nós mesmos e jogando problemas em cima de nossos chamadores. Só basta esquecer uma verificação null para que o aplicativo fique fora de controle.
  • Percebeu que não havia uma verificação de null na segunda linha do if aninhado? E se peristentStore fosse null?
  • O problema do código acima é a quantidade de coisas que podem ser null.
  • Em vez de retornar null de um método, lance uma exceção ou um objeto SPECIAL CASE!
  • Se um terceiro método retornar null use o empacotamento ou o objeto de caso especial.
  • Outro exemplo de código que retorna null:
List<Employee> employees = getEmployees();

if (employees != null) {
    for(Employee e : employees) {
        totalPay += e.getPay();
    }
}
  • Agora usando uma lista varia em vez de null:
List<Employee> employees = getEmployees();
for(Employee e : employees) {
    totalPay += e.getPay();
}

E no método getEmployees:

public List<Employee> getEmployees() {
    if( .. there are no employees .. )
        return Collections.emptyList();
}
  • Com isso minimizamos a chande NullPointerException e o código será mais limpo.

Não passe null

  • Retornar null dos métodos é ruim, mas passar null para eles é pior ainda.
  • A menos que seja uma API que espere receber null, devemos evitar passá-lo.
  • Exemplo do porquê:
public class MetricsCalculator {
    public double xProjection(Point p1, Point p2) {
        return (p2.x – p1.x) * 1.5;
    }
}
  • Se alguém passar null para o método acima receberemos uma NullPointerException!
  • Poderíamos melhor isso lançando uma exceção:
public class MetricsCalculator {

    public double xProjection(Point p1, Point p2) {
        if (p1 == null || p2 == null) {
            throw InvalidArgumentException("Invalid argument for MetricsCalculator.xProjection");
        }
        return (p2.x – p1.x) * 1.5;
    }
}
  • Melhorou, porém passar null é sinal de problema e pode gerar mais erros por descuido.

Conclusão

  • Um código limpo é legível, mas também robusto.
  • Podemos fazer esse tipo de código se enxergarmos o tratamento de erro como uma preocupação à parte, algo visível independentemente de nossa lógica principal.
  • Com isso damos um grande passo na capacidade de manutenção do código.

18