5. COMPORTAMENTO & OPERAÇÕES
“O filho de uma programadora perguntou:
- Mãe, por que o sol nasce no leste e se põe no oeste?
A mãe respondeu:
- Tá funcionando meu filho? Então não mexe.”
Adaptada de anônimo.
O capítulo anterior tratou do estado, que são os adjetivos ou qualidades do objeto, como: nome, cor, preço, tamanho, etc, enfim, qualquer propriedade. Neste capítulo o assunto é o comportamento que, por outro lado, descreve o que o objeto faz (ou é capaz de), na forma de verbos como: salvar, mover, copiar, andar, atirar, saltar, etc. Todos os sistemas confiáveis possuem objetos com um comportamento previsível e testável. Os objetos confiam no comportamento uns dos outros para criar um sistema inteiro. O comportamento falho de uma peça pode causar falhas em cascata. Nos tópicos a seguir é discutido o que se entende por “comportamento” , como ele é projetado e implementado através de métodos, também conhecidos como operações ou funções membro do objeto.
5.1 Conceito de Comportamento do Objeto
Os objetos em um sistema fazem alguma coisa. Isto é, eles exercem uma atividade, senão seriam apenas estruturas de dados. Para declarar o que fazem, eles disponibilizam métodos (ou funções de objeto). Os métodos são declarados como verbos, que indicam uma ação do objeto. Por exemplo, considere um game de corrida, onde o veículo pode acelerar, frear, etc. A reunião dessas habilidades e o que acontece com o objeto após executá-las definem o comportamento do objeto. Qual é o comportamento esperado ao invocar o método acelerar?
Espera-se que o veículo aumente a velocidade. Assim, o comportamento do objeto está firmemente relacionado com o estado. As ações, as operações do objeto implicam no estado. Por exemplo, em um aplicativo de comércio eletrônico há a metáfora do carrinho de compras. Provavelmente haverá um objeto Carrinho no sistema para o controle dessa lógica. Inicialmente, o Carrinho não possui itens, até o método adicionar ser invocado. Isto é, adicionar, remover e alterar itens altera o estado do Carrinho, que lhe confere um comportamento.
Assim, o comportamento é definido como o conjunto de operações que podem ser exercidas em um objeto e como estas afetam seu estado. Isto é, o objeto Gato mia quando está com fome e brinca quando está feliz. Miar e brincar são comportamentos estimulados por pré-condições e que causam pós-condições. O gato come, não tem mais fome, e dorme. É um exemplo bobo, mas que tenta explicar o que se entende por comportamento.
5.2 Métodos (ou operações)
O comportamento dos objetos é descrito por métodos. Eles são como as funções, que têm entrada e saída, porém, além disso, têm acesso ao estado do objeto. Isto é, os métodos podem ler e alterar informações de uma instância.
Porém, nem todos os métodos precisam de entrada ou saída. É possível saber os desdobramentos da execução de um método pela consulta ao estado. Parece complicado a princípio, por isso, considere um exemplo bobo: uma lâmpada:
// Lâmpada.pseudo
classe Lâmpada
atributo ligada : booleano
método ligar
atributo ligada = Verdadeiro
fim método
método desligar
atributo ligada = Falso
fim método
fim classe
// App.pseudo
usando Lâmpada de Lâmpada.pseudo
procedimento App
lamp1 = nova Lâmpada
imprime(lamp1.ligada) // Falso
lamp1.ligar()
imprime(lamp1.ligada) // Verdadeiro
lamp1.desligar()
imprime(lamp1.ligada) // Falso
// lamp1.ligada = Verdadeiro // evite fazer isso, use o método
lamp1.ligar()
fim procedimento
O pseudocódigo anterior representa um objeto da classe Lâmpada, com o estado ligada. Os métodos ligar e desligar são usados para alterar o estado da lâmpada. Como é visto, não há argumento para os métodos e eles não possuem return. A função deles é de apenas alterar o estado do objeto.
O exemplo implementado em Java seria:
// Lampada.java
class Lampada {
boolean ligada; // estado
void ligar() { // altera o estado para ligada
this.ligada = true;
}
void desligar() { // altera o estado para desligada
this.ligada = false;
}
}
// App.java
class App {
public static void main(String[] args) {
Lampada lamp1 = new Lampada();
System.out.println(lamp1.ligada); // false
lamp1.ligar();
System.out.println(lamp1.ligada); // true
lamp1.desligar();
System.out.println(lamp1.ligada); // false
// lamp1.ligada = true; // evite fazer isso, use o método
lamp1.ligar();
System.out.println(lamp1.ligada); // true
}
}
Os métodos também podem ter entrada e saída. Por exemplo, considere um AltoFalante com controle de volume, de 0 (mudo) a 100 (máximo). Segue pseudocódigo:
// AltoFalante.pseudo
classe AltoFalante
atributo volume : inteiro = 0
método aumentar : inteiro
se atributo volume < 100 // cuidado com o limite
atributo volume = atributo volume + 1
fim se
retorna atributo volume
fim método
método silenciar
atributo volume = 0
// precisa retornar 0?
fim método
método ajustar(valor : inteiro)
se valor >= 0 e valor <= 100 // aceitar um valor válido
atributo volume = valor
fim se
// precisa retornar o volume?
fim método
fim classe
// App.pseudo
usando AltoFalante de AltoFalante.pseudo
procedimento App
af = novo AltoFalante
imprime(af.volume) // 0
af.aumentar()
imprime(af.volume) // 1
// pode imprimir o retorno do método aumentar
imprime(af.aumentar()) // 2
af.silenciar()
imprime(af.volume) // 0
// impossível, o método silenciar não tem retorno:
// imprime(af.silenciar())
af.ajustar(70) // o método ajustar precisa de argumento
imprime(af.volume) // 70
fim procedimento
Neste exemplo foram apresentados três métodos diferentes: o método aumentar acresce um ponto ao volume e retorna o resultado. Isto é, imagine que (no mundo real) ao pressionar aumentar é possível saber o volume resultante. O mesmo comportamento não é interessante no método silenciar, pois o resultado sempre será 0, por isso ele não tem retorno. O último método, ajustar, permite passar o valor de volume desejado. Claro, há um se para garantir que o estado seja válido, isto é, o volume esteja sempre entre 0 e 100. Neste último, também não é necessário retornar o volume resultante, já que ele será o valor passado. Poderia se argumentar que ajustar retornasse um booleano, indicando se o valor foi aceito ou não - fica para consideração e exercício a seguir.
5.3 Separação de Comando e Consulta
Existem alguns princípios para o projeto e implementação de métodos/operações. Neste tópico eu vou apresentar um, conhecido como a Separação de Comando e Consulta (do inglês Command Query Separation - CQS), o qual acredito ser de muita importância para o projeto de objetos confiáveis.
Este princípio declara que todo método deve ser um deste dois:
- um comando que realiza uma ação e que pode alterar o estado.
- ou uma consulta que retorna dados e não causa efeitos colaterais.
Nunca os dois. “Fazer uma uma pergunta não deve alterar a resposta” (Bertrand Meyer).
Para exemplificar, considere um objeto Altofalante simplificado a seguir:
// AltoFalante.pseudo
classe AltoFalante
atributo volume : inteiro = 0
método aumentar : inteiro
atributo volume = atributo volume + 1
retorna atributo volume
fim método
fim classe
// App.pseudo
usando AltoFalante de AltoFalante.pseudo
procedimento App
af = novo AltoFalante
imprime(af.aumentar()) // 1
imprime(af.aumentar()) // 2
fim procedimento
Esta classe que define o estado e comportamento de objetos alto-falante viola o princípio do comando e consulta. Costumamos dizer que um viola um princípio quando não segue sua recomendação. Perceba que o comportamento do objeto ao invocar o método aumentar() é de, ao mesmo tempo, incrementar e retornar o volume. Isto é, o mesmo método serve de comando e consulta, sem a separação. É como: para eu saber o volume preciso apertar no botão + ou - e, logo, eu sei o volume em que estava (pois ao apertar + acresce um ponto ao volume anterior).
A proposta do CQS é disponibilizar métodos separados, para alterar ou para consultar o estado. Por exemplo:
// AltoFalante.pseudo
classe AltoFalante
atributo volume : inteiro = 0
método aumentar : vazio // é um comando - sem retorno!
atributo volume = atributo volume + 1
fim método
método consultar : inteiro // é uma consulta - não altera estado!
retorna atributo volume
fim método
fim classe
// App.pseudo
usando AltoFalante de AltoFalante.pseudo
procedimento App
af = novo AltoFalante
// imprime(af.aumentar()) // instrução inválida - não se imprime um comando
af.aumentar()
imprime(af.consultar()) // 1
af.aumentar()
imprime(af.consultar()) // 2
fim procedimento
Escrito em Java este exemplo fica assim:
// AltoFalante.java
class AltoFalante {
int volume = 0;
void aumentar() { // é um comando - sem retorno!
this.volume = this.volume + 1;
}
int consultar() { // é uma consulta - não altera estado!
return this.volume;
}
}
// App.java
class App {
public static void main(String[] args) {
AltoFalante af = new AltoFalante();
// instrução inválida - não se imprime um comando
// System.out.println(af.aumentar());
af.aumentar();
System.out.println(af.consultar()); // 1
af.aumentar();
System.out.println(af.consultar()); // 2
}
}
O objetivo do CQS é de que os programadores sintam-se seguros de invocar os métodos de consulta ao estado do sistema, sabendo que não causarão nenhum efeito colateral e que a execução do sistema continuará sem interferências. Isto é, os métodos consulta, como consultar o volume no exemplo anterior, podem ser executados diversas vezes, permitindo depuração de um programa através da impressão do seu estado e sem causar bugs.
Como toda tese tem uma antítese, alguém pode argumentar que um método deva realizar uma mudança de estado e retornar, geralmente para simplificação. É um trade-off10, isto é, uma decisão difícil entre simplificar o código ou tornar mais seguro.
Considere um carrinho de compras que permite adicionar e remover produtos.
// Carrinho.pseudo
classe Carrinho
atributo produtos : lista = []
método adicionar(produto: texto) : vazio
atributo produtos.adicionar(produto)
fim método
método remover(produto : texto) : booleano
se atributo produtos.contem(produto)
atributo produtos.remover(produto)
return Verdadeiro // um produto foi removido
fim se
return Falso // o produto não estava no carrinho
fim método
fim classe
// App.pseudo
usando Carrinho de Carrinho.pseudo
procedimento App
carr = novo Carrinho
carr.adicionar("Teclado")
carr.adicionar("Mouse")
se carr.remover("Teclado")
imprime("O Teclado foi removido do carrinho")
senão
imprime("Não há Teclado no carrinho")
fim se
se carr.remover("Impressora")
imprime("A Impressora foi removida do carrinho")
senão
imprime("Não há Impressora no carrinho")
fim se
fim procedimento
O método remover viola o CQS. No entanto, ele é bastante útil para saber se um produto foi removido com sucesso ou não. Pense, como seria o mesmo programa se respeitasse o CQS? Com o método remover sem retorno (vazio ou void), como saber se um produto foi removido?
A seguir um exemplo implementado em Java com as duas versões, um método não-concordante com o CQS removeu(String):boolean e outro par concordante remover(String):void e contem(String):boolean:
// Carrinho.java
import java.util.ArrayList;
class Carrinho {
ArrayList produtos = new ArrayList();
void adicionar(String produto) {
this.produtos.add(produto);
}
boolean removeu(String produto) { // este viola o CQS
// o método remove de ArrayList funciona deste modo
boolean foiRemovido = this.produtos.remove(produto);
return foiRemovido;
}
void remover(String produto) {
this.produtos.remove(produto);
}
boolean contem(String produto) {
return this.produtos.contains(produto);
}
}
// App.java
class App {
public static void main(String[] args) {
Carrinho carr = new Carrinho();
carr.adicionar("Teclado");
carr.adicionar("Mouse");
// não-CQS: perceba que a remoção é TENTADA
if (carr.removeu("Teclado")) {
System.out.println("Teclado removido");
} else {
System.out.println("Não há Teclado");
}
// CQS: perceba que primeiro é verificado se é necessário remover
if (carr.contem("Impressora")) {
carr.remover("Impressora");
System.out.println("Impressora removida");
} else {
System.out.println("Não há Impressora");
}
}
}
Escolher entre aderir ao CQS ou não muda a forma de como os clientes (quem chama os métodos) operam sobre o objeto, isto é, impacta na ordem do algoritmo que usa o objeto. Perceba como, no exemplo anterior, no modo não-CQS a remoção é submetida sem saber se há o objeto, enquanto na versão CQS a consulta é realizada antes.
5.4 Comportamento Excepcional
E se fosse possível efetuar um retorno apenas em certas situações? Este é o conceito de execução excepcional usada para o controle do fluxo do programa em casos especiais.
A maioria das linguagens de programação modernas disponibiliza o tratamento de exceções (do inglês Exception Handling) para a implementação deste tipo de controle de fluxo. Nestes casos, procura-se tratar um comportamento anormal (ou incomum) do objeto. A lógica envolve, geralmente, lançar (to thrown) ou levantar (to raise) exceções que são, então, capturadas (to catch) ou resgatadas (to rescue).
No último exemplo do tópico anterior, sobre [Separação de Comando e Consulta]{#cqs}, há o método removeu(String):boolean, que tenta remover o produto retornando true em caso de sucesso e false no caso de não haver tal produto no carrinho. Este método é um bom candidato à implementação com exceções. Considere o exemplo reprojetado para usar exceções:
// Carrinho.pseudo
classe Carrinho
atributo produtos : lista = []
método adicionar(produto: texto) : vazio
atributo produtos.adicionar(produto)
fim método
método remover(produto : texto) : vazio // não há retorno
se não atributo produtos.contem(produto)
// situação excepcional lança uma exceção
lançar nova exceção "Não há {produto} no carrinho"
fim se
atributo produtos.remover(produto)
// nenhum retorno é necessário
fim método
fim classe
// App.pseudo
usando Carrinho de Carrinho.pseudo
procedimento App
carr = novo Carrinho
carr.adicionar("Teclado")
carr.adicionar("Mouse")
tente
carr.remover("Teclado")
capture exceção
// essa linha não será executada pois existe Teclado no carrinho
imprime(exceção)
fim tente
tente
// não há impressora, então uma exceção será capturada
carr.remover("Impressora")
capture exceção
// imprime "Não há Impressora no carrinho"
imprime(exceção)
fim tente
fim procedimento
Podem ser definidas diversas situações excepcionais no mesmo método. Por exemplo, uma transferência bancária pode falhar por falta de saldo, limite diário excedido, recusa da conta de destino, etc. Para cada uma destas situações pode ser codificada uma exceção, que poderá (ou deverá) ser tratada por quem chamou o método.
O pseudocódigo anterior pode ser implementado em Java da seguinte maneira:
// Carrinho.java
import java.util.ArrayList;
class Carrinho {
ArrayList produtos = new ArrayList();
void adicionar(String produto) {
this.produtos.add(produto);
}
void remover(String produto) {
if ( ! this.produtos.contains(produto)) {
throw new RuntimeException("Não há " + produto + " no carrinho");
}
this.produtos.remove(produto);
}
}
// App.java
class App {
public static void main(String[] args) {
Carrinho carr = new Carrinho();
carr.adicionar("Teclado");
carr.adicionar("Mouse");
try {
carr.remover("Teclado"); // ok, há teclado, vazio
} catch(Exception excecao) { // nada para capturar
System.err.println(excecao); // não chega aqui
}
try {
carr.remover("Impressora"); // lança exceção
} catch(Exception excecao) { // captura ela aqui
System.err.println(excecao); // imprime ela aqui
// Não há Impressora no carrinho
}
}
}
É possível perceber que as construções em par try {} catch {} são parecidas com os pares if {} else {}. De fato, ambos sustentam a ideia geral de um fluxo alternativo. No entanto, é preciso decidir em que situações usar um ou outro. Por isso, deve-se levar sempre em consideração que exceções são destinadas ao controle de fluxo em casos especiais e que são pouco frequentes, por isso são exceções. Digo, não seria uma boa prática lançar exceções para situações regulares.
5.5 Considerações
As questões quanto ao comportamento, operações, métodos, ainda não acabaram. Elas serão retomadas nas discussões quanto à herança, polimorfismo e outras. Os métodos estarão sempre presentes, pois definem a funcionalidade em si, garantindo que os objetos não sejam apenas um depósito de dados, mas que abriguem um corpo de conhecimento na forma de um algoritmo.
5.6 Exercícios
A seguir alguns exercícios que podem te ajudar a entender os conceitos apresentados neste capítulo e até estendê-los.
Codificando o comportamento de um ventilador
Lá vem um exemplo bobo: imagine que tenhamos um objeto ventilador, abstraindo todos os detalhes exceto a velocidade. Considere que na construção de um ventilador seja especificada o número de velocidades, como novo Ventilador 5. Ele parte da posição desligado, que é a velocidade 0 e usando as operações de mais ou menos ele alcança de 1 até a velocidade máxima (5, neste exemplo).
As questões são: o que acontece se o ventilador estiver na velocidade máxima e for invocada a operação mais? E se estiver desligado e invocada a operação menos? Como podemos saber a velocidade atual? Ou saber se o ventilador está ligado?
Implemente um que respeite o princípio da Separação Comando Consulta (CQS) e outro que seja mais prático (que viole o princípio em favor da praticidade). Ademais, considere escrever uma versão que utilize exceções para os casos especiais.
Como se comporta um cartão de crédito
Considere um cartão de crédito com uma bandeira e limite, como novo CartaoCredito "Gisa" 2000 (R$ 2000 de limite). Para simplificação vamos considerar apenas valores inteiros. Considere que possam ser realizadas compras parceladas neste cartão, informando a quantidade e valor das parcelas, como cartao.comprar(5, 100) que compromete R$ 500 (5 x 100) do limite do cartão, sobrando R$ 1500. A compra subsequente não pode ultrapassar este limite. Assim, a compra cartao.comprar(10, 151) não seria efetivada, mas cartao.comprar(10, 150) seria, comprometendo todo o limite.
Este exercício é desenvolvido em duas partes. Nesta primeira, tens de implementar a classe CartaoCredito e cumprir o comportamento esperado segundo os casos de teste a seguir:
// App.java
class App {
public static void main(String[] args) {
CartaoCredito cartao = new CartaoCredito("DoctorCard", 5000);
// Observando o estado na forma de prints
System.out.println(cartao.operadora); // DoctorCard
System.out.println(cartao.limiteTotal()); // 5000
System.out.println(cartao.limiteDisponivel()); // 5000
// Testagem
System.out.println(cartao.operadora.equals("DoctorCard")); // true
System.out.println(cartao.limiteTotal() == 5000); // true
System.out.println(cartao.limiteDisponivel() == 5000); // true
cartao.comprar(10, 100); // compromete 1000
System.out.println(cartao.limiteTotal() == 5000);
System.out.println(cartao.limiteDisponivel() == 4000);
cartao.comprar(2, 250);
System.out.println(cartao.limiteDisponivel() == 3500);
// o método compra lança uma exceção quando não há limite
try {
cartao.comprar(10, 400); // 4000, acima do limite disponível
} catch (Exception excecao) {
System.err.println(excecao); // Não há limite disponível
System.err.println(excecao.getMessage().equals("Não há limite disponível")); //
}
// o método compraSePossivel é EAFP
if (cartao.comprarSeHouverLimite(1, 500)) {
System.out.println("Comprado");
}
System.out.println(cartao.limiteDisponivel() == 3000);
System.out.println(cartao.comprarSeHouverLimite(2, 500) == true);
System.out.println(cartao.limiteDisponivel() == 2000);
System.out.println(cartao.comprarSeHouverLimite(10, 500) == false);
System.out.println(cartao.comprarSeHouverLimite(5, 500) == false);
System.out.println(cartao.comprarSeHouverLimite(4, 500) == true);
System.out.println(cartao.limiteDisponivel() == 0);
}
}
Nesta segunda parte há outras funcionalidades, como pagar para liberar o limite. Cada pagamento libera uma parcela de todas as contas agendadas que há para pagar. Implemente conforme os casos de teste a seguir:
// App.java
class App {
public static void main(String[] args) {
CartaoCredito cartao = new CartaoCredito("LunchsClub", 2000);
// Testagem
System.out.println(cartao.operadora.equals("LunchsClub")); // true
System.out.println(cartao.limiteTotal() == 2000); // true
System.out.println(cartao.limiteDisponivel() == 2000); // true
System.out.println(cartao.haFaturaParaPagar() == false);
cartao.comprar(2, 500); // compromete 1000
System.out.println(cartao.limiteTotal() == 2000);
System.out.println(cartao.limiteDisponivel() == 1000);
System.out.println(cartao.haFaturaParaPagar() == true);
cartao.comprar(4, 250); // compromete mais 1000
System.out.println(cartao.limiteTotal() == 2000);
System.out.println(cartao.limiteDisponivel() == 0);
cartao.pagar(); // paga uma parcela de 500 e uma de 250
System.out.println(cartao.limiteDisponivel() == 750);
cartao.pagar(); // paga a última parcela de 500 e mais uma de 250
System.out.println(cartao.limiteDisponivel() == 1500);
cartao.pagar(); // paga a penúltima de 250
System.out.println(cartao.limiteDisponivel() == 1750);
System.out.println(cartao.haFaturaParaPagar() == true);
cartao.pagar(); // paga a última de 250
System.out.println(cartao.limiteDisponivel() == 2000);
// tudo pago
System.out.println(cartao.haFaturaParaPagar() == false);
// Decisão sua: como deve ser comportar o objeto Cartão de Crédito
// invocando a operação pagar sem valor devedor? CQS? Não-CQS? Exception?
// Faça sua escolha e abrace o método pagar a seguir:
cartao.pagar();
}
}