4. ESTADO & VALIDADE

“Um bom programador é alguém que sempre olha para os dois lados antes de atravessar uma rua de mão única.”

Doug Linder

Para projetar sistemas confiáveis é importantíssimo controlar o estado dos objetos. É preciso assegurar-se de que os objetos sejam consistentes e continuem válidos durante toda a execução do programa. O estado inválido de um único objeto pode levar todo o sistema ou aplicativo à falhar. Imagine o “estrago” que faria um objeto Hora com número negativo de minutos num app de agendamento. Por esse motivo, deve-se especificar quais características podem mudar, e quais não devem mudar nos objetos. Isto é, definir o estado mutável e o imutável. Também envolve escrever as regras de negócio (aquele punhado de if's, else's, for's, while's, ...) pensando se elas devem alterar estado e, caso sim, com que restrições. Nos tópicos a seguir serão abordadas as principais questões sobre estado, como implementá-lo e controlá-lo com responsabilidade.

4.1 Conceito de Estado do Objeto

Retomando o modelo de objetos, sabe-se que eles têm características codificadas na forma de atributos que guardam valores. Logo, cada objeto (ou instância) possui valores específicos para esses atributos, diferenciando as instâncias umas das outros, mesmo que compartilhem uma mesma classe. Por exemplo, enquanto um instância de Gato pode ser descrita por {nome: "Tom", idade: 6, peso: 4.8}, outra pode ter os valores {nome: "Angela", idade: 5, peso: 3.9}.

O estado é definido como o conjunto de valores armazenados em todos os atributos de um objeto em um instante no tempo. Isto é, o objeto está. Por exemplo, o gato “Tom”, por ora, está com peso: 4.8 e, conhecendo o gato doméstico, logo estará com peso: 5.0.

Tenha em mente que nem sempre o objeto está, às vezes ele é. Quer dizer, nem todos os atributos irão (ou devem) mudar de valor no tempo. Os objetos podem ter estado constante e variável, ao mesmo tempo. Por exemplo, a idade e peso do gato “Tom” pode aumentar enquanto ele mantém o mesmo nome 6. Portanto, durante o projeto da classe é preciso pensar sobre quais atributos devem ser constantes e quais exigem variabilidade. Essas questões são discutidas no tópico seguinte.

4.2 Estado constante e instantâneo

Primeiro, é importante mencionar que nem todas as linguagens de programação permitem declarar um atributo constante. Isso não é possível em JavaScript e Python7, por exemplo. As linguagens projetadas para declarar estado constante o fazem com diferentes palavras-chave (keyword). Por exemplo, em Java se adiciona a keyword final na declaração do atributo, enquanto em C# a keyword é readonly8.

Para entender a necessidade de um atributo imutável é preciso exercitar a classificação de um objeto que possua uma característica constante. Mantendo a linha de exemplos simples, considere uma Caneta. Entre várias informações sobre canetas, vamos abstrair e selecionar apenas duas: cor e carga. Isto é, consideramos o pigmento e o nível da tinta como informações úteis e imprescindíveis para o modelo. Seguindo o modelo de objeto apresentado no capítulo anterior, uma implementação possível está a seguir:

// Caneta.pseudo
classe Caneta
  atributo cor : textual
  atributo carga : numérico
fim classe

// App.pseudo
usando Caneta de Caneta.pseudo
procedimento App
  can = nova Caneta
  can.cor = "Azul"
  imprime(can.cor) // "Azul"
  can.cor = "Vermelha";
  imprime(can.cor) // "Vermelha"
fim procedimento

Embora funcional, essa implementação ignora um detalhe importante sobre as canetas. Elas possuem uma certa carga, que decresce com o uso, mas sua cor não muda. Claro, estamos ignorando a questão da troca da carga (abstração). Assim, uma caneta “Azul” será sempre “Azul”, e uma caneta “Vermelha” seria uma nova caneta. Isso pode ser resolvido declarando a cor como constante e adicionando um construtor:

// Caneta.pseudo
classe Caneta

  atributo constante cor : textual
  atributo carga : numérico

  construtor (parâmetro cor, parâmetro carga = 1000)
    atributo cor = parâmetro cor
    atributo carga = parâmetro carga
  fim construtor
fim classe

// App.pseudo
usando Caneta de Caneta.pseudo
procedimento App
  can = nova Caneta "Azul"
  imprime(can.cor) // "Azul"
  // can.cor = "Vermelha" // não é possível, cor é constante
  can2 = nova Caneta "Vermelha"
  imprime(can2.cor) // "Vermelha"
fim procedimento

Este exemplo em pseudocódigo sintetiza a ideia de estado constante. É importante frisar que não são proibidas canetas de outras cores, apenas é restrito que a mesma caneta mude de cor.

A seguir como é implementado em Java:

// Caneta.java
class Caneta {
  final String cor; // final é a keyword para constante
  int carga;
  Caneta(String cor) {
    this.cor = cor;
    this.carga = 1000;
  }
}
// Main.java
class Main {
  public static void main(String[] args) {
    Caneta can1 = new Caneta("Verde");
    System.out.println(can1.cor); // "Verde"
    // can1.cor = "Azul"; // NÃO COMPILA, a cor é imutável, constante
    Caneta can2 = new Caneta("Azul");
    System.out.println(can2.cor); // "Azul"
    System.out.println(can2.carga); // 1000
    can2.carga = 800; // carga é mutável, instantânea
    System.out.println(can2.carga); // 800
  }
}

O pequeno exemplo anterior demonstra um objeto com dois atributos, onde um é instantâneo (carga) e o outro constante (cor). Ainda assim, a caneta não está livre de “furos”. Não há restrições para o estado. Por exemplo, a instrução can.carga = -200 é válida, compila e executa sem problemas. A instrução new Caneta(""), que instancia uma caneta sem cor, também é válida. Portanto, o próximo passo é definir regras tanto para obter um estado inicial válido quanto fazê-lo permanecer válido o tempo todo.

4.3 Validade do estado, invariantes e consistência

A confiabilidade de um objeto é obtida pela consistência e validade do seu estado. Essa questão pode (deve) ser tratada em dois momentos: (1) na inicialização do objeto, (2) na interação com o objeto.

Todos os atributos imutáveis devem ser inicializados no construtor. É também no construtor onde são validados os atributos mutáveis, para garantir que objeto seja (tenha um estado) válido desde o começo.

Por exemplo, considere uma garrafa térmica. Elas possuem várias características que as diferenciam. Mas, vamos abstrair, desconsiderando a marca, modelo, cor, formato, etc, e considerando apenas duas informações: a capacidade e quantidade de líquido.

Vamos analisar: a capacidade é constante, isto é, uma garrafa com capacidade para 2L sempre terá esta capacidade, ela não está com 2L, ela é de 2L. Por outro lado, a quantidade é variável, instantânea, a garrafa pode estar vazia (0L), com líquido para apenas mais um mate (100mL), ou cheia (2L). O que é invariante na classe das garrafas térmicas? A quantidade nunca é negativa ou maior que a capacidade 9. Além dessa, também vamos adicionar uma restrição de capacidade, mínima é de 100mL e máxima de 5L, para não criar garrafas “bizarras”, como um conta-gotas de 1mL ou caixa d’água de 1000L.

As invariantes de classe são uma coleção de condições predefinidas e servem para restringir e garantir o estado do objeto. A construção e inicialização do objeto e subsequentes operações devem respeitar essas condições.

Vamos completar o exemplo com duas operações (métodos). A primeira é encher. Quanto a segunda, considere que há um botão para servir água na nossa garrafa térmica, digital claro, que dispensa 100mL de água ou emite um beep se estiver vazia. A seguir um pseudocódigo:

// GarrafaTérmica.pseudo
classe GarrafaTérmica

  atributo constante capacidade : numérico
  atributo quantidade : numérico

  construtor (parâmetro capacidade : numérico)
    se parâmetro capacidade < 100 então // regra
      lança a exceção "Capacidade mínima de 100mL"
    fimse

    se parâmetro capacidade > 5000 então // regra
      lança a exceção "Capacidade máxima de 5L"
    fimse

    atributo capacidade = parâmetro capacidade
    atributo quantidade = 0
  fim construtor

  método encher
    atributo quantidade = atributo capacidade
  fimmétodo

  método servir : boolean
    se atributo quantidade >= 100 // regra
      atributo quantidade = atributo quantidade - 100
      retorna Verdadeiro // foi servido
    fimse
    retorna Falso // não foi servido
  fimmétodo
fim classe

// App.pseudo
usando GarrafaTérmica de GarrafaTérmica.pseudo
procedimento App
  chimarrita = nova GarrafaTérmica 1000
  imprime(chimarrita.capacidade) // 1000
  imprime(chimarrita.quantidade) // 0
  chimarrita.encher()
  imprime(chimarrita.quantidade) // 1000
  chimarrita.servir()
  imprime(chimarrita.quantidade) // 900
fim procedimento

Esse programa ficou bem mais longo que os anteriores, mas não se preocupe, eu vou explicar os detalhes. A invariante da classe está comentada como // regra ao longo do código. Como podes ver, elas são pré e pós-condições que se esperam para a validade do objeto durante seu “uso”. Duas coisas são novas: a exceção e o método. As exceções são usadas para interromper o fluxo normal e providenciar informações sobre o motivo dessa interrupção. Neste exemplo, elas são usadas para evitar a instanciação de uma garrafa térmica inválida. As regras no construtor cumprem essa tarefa. Nunca haverão garrafas com capacidades menores que 100mL nem maiores que 5L, e isto é garantido. Os métodos são usados para definir as operações do objeto e intermediar as mudanças de estado. Métodos definem a interação com os objetos. Esses dois conceitos serão discutidos com mais detalhes no Capítulo 4: Comportamento.

O pseudocódigo anterior é implementável em quase qualquer linguagem moderna. A seguir esta lógica está escrita em Java:

// GarrafaTermica.java
class GarrafaTermica {

  final int capacidade;
  int quantidade;

  GarrafaTermica (int capacidade) {
    if (capacidade < 100) { // se inválida, lança exceção
      throw new IllegalArgumentException("Capacidade mínima de 100mL");
    }
    if (capacidade > 5000) { // se inválida, lança exceção
      throw new IllegalArgumentException("Capacidade máxima de 5L");
    }
    this.capacidade = capacidade;
    this.quantidade = 0;
  }

  void encher() { // operação/método encher
    this.quantidade = this.capacidade;
  }

  boolean servir() { // operação/método servir
    if (this.quantidade >= 100) { // é possível servir?
      this.quantidade = this.quantidade - 100;
      return true; // foi servido
    }
    return false; // não foi servido
  }
}

// Main.java
class Main {
  public static void main(String[] args) {
    GarrafaTermica chimarrita = new GarrafaTermica(1000);
    System.out.println(chimarrita.capacidade); // 1000
    System.out.println(chimarrita.quantidade); // 0
    System.out.println(chimarrita.servir()); // false
    chimarrita.encher();
    System.out.println(chimarrita.quantidade); // 1000
    System.out.println(chimarrita.servir()); // true
    System.out.println(chimarrita.quantidade) // 900
    System.out.println(chimarrita.servir()); // true
    System.out.println(chimarrita.quantidade) // 800
    while (chimarrita.servir()) {
      System.out.println(chimarrita.quantidade); // 700,600,500,400,300,200,100
    }
    System.out.println(chimarrita.quantidade); // 0
    chimarrita.encher();
    System.out.println(chimarrita.quantidade); // 1000
  }
}

Retomando o primeiro parágrafo, a confiabilidade de um objeto é obtida pela consistência e validade do seu estado em dois momentos: (1) inicialização e (2) na interação.

  1. O código anterior garante a consistência e validade do estado desde o início, no construtor. Preste atenção às linhas if (capacidade < 100) throw new IllegalArgumentException, elas interrompem a criação do objeto e tratam da validade no momento da inicialização;
  2. Agora, preste atenção à condição if (this.quantidade >= 100), ela protege o estado impedindo que a quantidade fique negativa ao subtrair 100mL e garante a validade do estado durante as interações com o objeto.

4.4 Estratégias para validar o estado

Um objeto válido é aquele que todos os seus atributos armazenam valores esperados, isto é, dentro das regras especificadas. Essas regras podem ser aplicadas logo na inicialização, no construtor. Neste caso, um objeto nunca é inválido, pois nunca será inicializado. Validar cedo é uma estratégia segura para o projeto de sistemas conhecida como fail-fast. O objetivo é interromper a operação normal assim que uma inconsistência é encontrada.

A segunda opção, é usar uma estratégia permissiva, tardia, ou leniente, que permite a criação de objeto inválidos e disponibiliza um meio de validá-los, ou adaptá-los, antes de uma operação final. A primeira estratégia, de validar no construtor, já foi discutida no tópico anterior. Então, este tópico trata da segunda.

Para exemplificar a validação tardia, considere uma playlist para a reprodução de músicas. É preciso que ela tenha um nome e, para reproduzi-la, é preciso criá-la com as músicas. Ou seja, não é possível reproduzir uma playlist vazia ou sem nome. Esse estado válido, no entanto, só é necessário antes da reprodução, assim que uma playlist pode começar inválida e continuar sendo trabalhada até ser possível reproduzi-la.

// Playlist.pseudo
classe Playlist

  atributo nome : textual
  atributo musicas : lista

  construtor
    atributo nome = "Sem nome"
    atributo musicas = nova Lista
  fim construtor

  método adicionarMusica(musica)
    musicas.adiciona(musica)
  fimmétodo

  método renomear(novoNome)
    atributo nome = novoNome
  fimmétodo

  método reproduzir
    se atributo musicas.comprimento > 0 // se  músicas
      para cada musica de musicas
        imprime("Reproduzino " + musica)
        player.play(musica)
      fimpara
    senão // se vazia
      lança a exceção "Playlist " + atributo nome + " está vazia"
    fimse
  fimmétodo
fim classe

// App.pseudo
usando Playlist de Playlist.pseudo
procedimento App
  nada = nova Playlist
  // nada.reproduzir() // lança uma exceção
  u2 = nova Playlist
  // u2.reproduzir() // lançaria uma exceção
  u2.renomear("Favoritas do U2")
  u2.adicionarMusica("Numb")
  u2.adicionarMusica("Where the Streets Have no Name")
  u2.adicionarMusica("City of Blinding Lights")
  u2.reproduzir() // reproduzindo Numb
fim procedimento

O pseudocódigo anterior apresenta um possível projeto de classe Playlist que valida o estado apenas no momento da sua reprodução. Assim, o código de validação está presente no método reproduzir em vez de no construtor. Esse projeto pode ser implementado com poucas adaptações em qualquer linguagem com suporte a orientação a objetos. A seguir o mesmo exemplo em Java:

// Playlist.java
import java.util.ArrayList;

class Playlist {

  String nome;
  ArrayList musicas; // ArrayList é uma estrutura de Java.Util

  Playlist() { // construtor
    this.nome = "Sem nome";
    this.musicas = new ArrayList();
  }

  void adicionarMusica(String musica) {
    musicas.add(musica);
  }

  void renomear(String novoNome) {
    this.nome = novoNome;
  }

  void reproduzir() {
    if (this.musicas.size() > 0) { // se há músicas
      for (String musica : this.musicas) {
        System.out.println("Reproduzindo " + musica);
        // player.play(musica); // não temos um player, ainda :/
      }
    } else {
      throw new RuntimeException ("Playlist " + this.nome + " está vazia");
    }
  }
}
// Main.java
class Main {
  public static void main(String[] args) {
    Playlist nada = new Playlist();
    // nada.reproduzir(); // lança uma exceção
    Playlist u2 = new Playlist();
    // u2.reproduzir() // lançaria uma exceção
    u2.renomear("Favoritas do U2");
    u2.adicionarMusica("Numb");
    u2.adicionarMusica("Where the Streets Have no Name");
    u2.adicionarMusica("City of Blinding Lights");
    u2.reproduzir(); // reproduzindo Numb, ...
  }
}

Essa classe considera tanto o valor padrão, como o texto "Sem nome" para uma playlist recém criada, como permite que a playlist fique vazia sem lançar exceções até o momento em que é solicitada sua reprodução no método reproduzir.

Sabendo que o estado do objeto pode ser validado adiantado, logo no construtor, ou atrasado, no método que conclui a operação, um pode ficar na dúvida: devo validar adiantado ou atrasado? Cada caso é um caso. Como sugestão, considere sempre validar adiantado, porque é mais seguro, e avalie se deve-se validar atrasado judiciosamente, isto é, pondere bem os motivos para.

A lógica de validação é fortemente associada ao que se espera da interface do usuário. Por exemplo, nas interfaces que iniciam com um modelo vazio e exigem interação do usuário para completar, é tipicamente adequado validar atrasado. Por exemplo, considere um serviço de streaming. Se é preciso selecionar uma música para criar uma playlist ele já pressupõe que a playlist não estará, nunca, vazia. Se permite primeiro criar a playlist para depois adicionar as músicas, neste caso, ele inicia com um objeto vazio, “inválido”, por um tempo. Outros sistemas que podem precisar um objeto vazio e inválido inicial incluem editores de texto (documento em branco) e carrinhos de compra (como um carrinho vazio), entre outros.

Para fechar este tópico, a seguir está a mesma classe Playlist com validação adiantada (fail-fast).

// Playlist.java
import java.util.ArrayList;

class Playlist {

  String nome;
  final ArrayList musicas = new ArrayList();

  // construtor: obrigatório nome e uma música
  Playlist(String nome, String musica) {
    this.nome = nome;
    this.adicionarMusica(musica);
  }

  void adicionarMusica(String musica) {
    musicas.add(musica);
  }

  void renomear(String novoNome) {
    this.nome = novoNome;
  }

  void reproduzir() {
    for (Object musica : this.musicas) {
      System.out.println("Reproduzindo " + musica);
      // player.play(musica); // não temos um player, ainda :/
    }
  }
}
// Main.java
class Main {
  public static void main(String[] args) {
    // Playlist nada = new Playlist(); // não compila sem um nome e música
    Playlist u2 = new Playlist("Favoritas do U2", "Numb");
    u2.adicionarMusica("Where the Streets Have no Name");
    u2.adicionarMusica("City of Blinding Lights");
    u2.reproduzir(); // reproduzindo Numb, ...
  }
}

4.5 Considerações

O controle do estado dos objetos é uma tarefa essencial e que merece muito atenção. Deve-se sempre procurar projetar classes que produzam objetos que não quebram. Algumas atitudes recomendadas são:

  • preferir estado constante sempre que possível: declarar atributos sempre como final (Java) ou const (C#), e tornar variáveis (instantâneos) apenas aqueles que tem um motivo para mudar e regras de mudança claras;
  • fornecer um único ponto de entrada para alteração do estado: de modo que se possa rastrear a passagem da informação em uma sessão de debug, por exemplo, interceptando e logando o parâmetro de um método/construtor;
  • validar cedo (fail-fast) sempre que possível: validar no construtor e mover as regras para uma validação tardia apenas se houver um motivo claro e regras claras de progressão do estado até atingir um método que concluir a finalidade de um objeto.

4.6 Exercícios

Os exercícios a seguir estão descritos na forma de casos de teste. São descritos os nomes de classe, construtores, atributos e métodos esperados. As instruções System.out.println( == ) declaram uma igualdade ou assertiva. A saída true significa um teste que passou, enquanto false o teste falhou. Considere implementar aos poucos, comentando as linhas que testam instruções que ainda não estão prontas, e ir “descomentando” aos poucos, a medida que avança na implementação.

O estado de um Forno

Considere um Forno sofisticado de controle via app Android/iOS. É possível ligar, desligar, ajustar temperatura e outros detalhes. Os objetos variam segundo seu volume, tensão, potência e dimensões (na forma largura, altura e profundidade em centímetros). Implemente conforme especificação a seguir:

// Main.java
class Main {
  public static void main(String[] args) {

    Forno f = new Forno(45, 220, 1700, 66, 40, 54);
    System.out.println(f.volume == 45);
    System.out.println(f.tensao == 220);
    System.out.println(f.potencia == 1700);
    System.out.println(f.largura == 66);
    System.out.println(f.altura == 40);
    System.out.println(f.profundidade == 54);
    // esse atributo não consta no construtor
    System.out.println(f.temperatura == 0);
    // todos esses atributos devem ser constantes,
    // as atribuções a seguir não podem compilar
    // verifique e comente-as
    f.volume = 450;
    f.tensao = 2200;
    f.potencia = 17000;
    f.altura = 400;
    f.largura = 660;
    f.profundidade = 540;

    Forno forno = new Forno(84, 220, 1860, 61, 58, 58);
    System.out.println(forno.volume = 84);
    System.out.println(forno.tensao = 220);
    System.out.println(forno.potencia = 1860);
    System.out.println(forno.altura = 58);
    System.out.println(forno.largura = 61);
    System.out.println(forno.profundidade = 58);

    System.out.println(forno.temperatura); // 0
    System.out.println(forno.temperatura == 0); // true
    forno.aumentarTemperatura();
    System.out.println(forno.temperatura); // 50
    System.out.println(forno.temperatura == 50); // true
    forno.aumentarTemperatura();
    System.out.println(forno.temperatura); // 100
    System.out.println(forno.temperatura == 100); // true
    forno.aumentarTemperatura();
    System.out.println(forno.temperatura); // 150
    System.out.println(forno.temperatura == 150); // true
    forno.aumentarTemperatura();
    System.out.println(forno.temperatura); // 200
    System.out.println(forno.temperatura == 200); // true
    forno.aumentarTemperatura();
    System.out.println(forno.temperatura); // 220
    System.out.println(forno.temperatura == 220); // true
    forno.aumentarTemperatura();
    System.out.println(forno.temperatura); // 250
    System.out.println(forno.temperatura == 250); // true
    forno.aumentarTemperatura();
    System.out.println(forno.temperatura); // 300
    System.out.println(forno.temperatura == 300); // true
    forno.aumentarTemperatura(); // já está no máximo
    System.out.println(forno.temperatura); // 300
    System.out.println(forno.temperatura == 300); // true
    // reduzindo
    forno.diminuirTemperatura();
    forno.diminuirTemperatura();
    forno.diminuirTemperatura();
    System.out.println(forno.temperatura); // 150
    System.out.println(forno.temperatura == 150); // true
    // desligando direto
    forno.desligar();
    System.out.println(forno.temperatura); // 0
    System.out.println(forno.temperatura == 0); // true
    // já está desligado
    forno.diminuirTemperatura();
    System.out.println(forno.temperatura == 0); // true
  }
}

Codificando o estado de uma Televisão

Considere um aparelho de televisão. Cada uma tem um fabricante, modelo, tamanho e resolução. Além disso, a operação da TV é bem simples, permitir aumentar e baixar o volume, numa escala de 0 a 100%, e mudar o canal, suportando a UHF apenas e indo, então, do canal 2 ao 69.

Dada essa especificação, projete (com pseudocódigo ou descritivo textual) e implemente uma classe TV, que guarde as características mencionadas, respeitando a imutabilidade e os métodos com as operações descritas.

Escreva pelo menos 20 Casos de Teste, para situações comuns e excepcionais.

Desafio: implementar as funcionalidades: mudo, ir para canal e voltar canal anterior.