Dominando a POO: um guia prático para herança, interfaces e classes abstratas
Publicados: 2022-03-10Até onde sei, é incomum encontrar conteúdo educacional na área de desenvolvimento de software que forneça uma mistura apropriada de informações teóricas e práticas. Se eu fosse adivinhar o porquê, suponho que é porque os indivíduos que se concentram na teoria tendem a entrar no ensino, e os indivíduos que se concentram em informações práticas tendem a ser pagos para resolver problemas específicos, usando linguagens e ferramentas específicas.
Esta é, claro, uma ampla generalização, mas se a aceitarmos brevemente para fins de argumentação, segue-se que muitas pessoas (de modo algum todas as pessoas) que assumem o papel de professores tendem a ser pobres ou totalmente incapazes. de explicar o conhecimento prático relevante para um conceito particular.
Neste artigo, farei o meu melhor para discutir três mecanismos principais que você encontrará na maioria das linguagens de Programação Orientada a Objetos (OOP): Herança , interfaces (também conhecidas como protocolos ) e classes abstratas . Em vez de dar explicações verbais técnicas e complexas sobre o que é cada mecanismo, farei o meu melhor para me concentrar no que eles fazem e quando usá-los.
No entanto, antes de abordá-los individualmente, gostaria de discutir brevemente o que significa dar uma explicação teoricamente sólida, mas praticamente inútil. Minha esperança é que você possa usar essas informações para ajudá-lo a filtrar diferentes recursos educacionais e evitar se culpar quando as coisas não fizerem sentido.
Diferentes graus de conhecimento
Conhecendo nomes
Saber o nome de algo é sem dúvida a forma mais superficial de saber. Na verdade, um nome só é geralmente útil na medida em que é comumente usado por muitas pessoas para se referir à mesma coisa e/ou ajuda a descrever a coisa. Infelizmente, como qualquer um que passou algum tempo neste campo descobriu, muitas pessoas usam nomes diferentes para a mesma coisa (por exemplo , interfaces e protocolos ), os mesmos nomes para coisas diferentes (por exemplo, módulos e componentes ), ou nomes que são esotéricos para o ponto de ser absurdo (por exemplo, Ou Monad ). Em última análise, os nomes são apenas ponteiros (ou referências) para modelos mentais e podem ter vários graus de utilidade.
Para tornar este campo ainda mais difícil de estudar, eu arriscaria um palpite de que para a maioria das pessoas, escrever código é (ou pelo menos foi) uma experiência única. Ainda mais complicado é entender como esse código é compilado em linguagem de máquina e representado na realidade física como uma série de impulsos elétricos que mudam ao longo do tempo. Mesmo que se possa lembrar os nomes dos processos, conceitos e mecanismos que são empregados em um programa, não há garantia de que os modelos mentais criados para tais coisas sejam consistentes com os modelos de outro indivíduo; muito menos se eles são objetivamente precisos.
É por essas razões, além do fato de eu não ter uma memória naturalmente boa para o jargão, que considero os nomes o aspecto menos importante de saber alguma coisa. Isso não quer dizer que os nomes sejam inúteis, mas no passado aprendi e empreguei muitos padrões de design em meus projetos, apenas para aprender sobre o nome comumente usado meses ou mesmo anos depois.
Conhecendo definições verbais e analogias
As definições verbais são o ponto de partida natural para descrever um novo conceito. No entanto, assim como os nomes, eles podem ter vários graus de utilidade e relevância; muito disso, dependendo de quais são os objetivos finais do aluno. O problema mais comum que vejo nas definições verbais é o conhecimento presumido tipicamente na forma de jargão.
Suponha, por exemplo, que eu explique que um thread é muito parecido com um processo , exceto que os threads ocupam o mesmo espaço de endereço de um determinado processo . Para alguém que já está familiarizado com processos e espaços de endereço , eu afirmei essencialmente que as threads podem ser associadas à sua compreensão de um processo (ou seja, elas possuem muitas das mesmas características), mas podem ser diferenciadas com base em uma característica distinta.
Para alguém que não possui esse conhecimento, na melhor das hipóteses não fiz nenhum sentido e, na pior das hipóteses, fiz com que o aluno se sentisse inadequado de alguma forma por não saber as coisas que presumi que deveriam saber. Para ser justo, isso é aceitável se seus alunos realmente devem possuir tal conhecimento (como ensinar alunos de pós-graduação ou desenvolvedores experientes), mas considero uma falha monumental fazê-lo em qualquer material de nível introdutório.
Muitas vezes é muito difícil fornecer uma boa definição verbal de um conceito quando é diferente de qualquer outra coisa que o aluno tenha visto antes. Nesse caso, é muito importante que o professor selecione uma analogia que provavelmente seja familiar à pessoa comum e também relevante na medida em que transmita muitas das mesmas qualidades do conceito.
Por exemplo, é extremamente importante para um desenvolvedor de software entender o que significa quando as entidades de software (diferentes partes de um programa) são fortemente acopladas ou fracamente acopladas . Ao construir um galpão de jardim, um carpinteiro júnior pode pensar que é mais rápido e fácil montá-lo usando pregos em vez de parafusos. Isso é verdade até o ponto em que um erro é cometido, ou uma mudança no design do galpão do jardim exige a reconstrução de parte do galpão.
Neste ponto, a decisão de usar pregos para encaixar bem as partes do galpão do jardim tornou o processo de construção como um todo mais difícil, provavelmente mais lento, e extrair pregos com um martelo corre o risco de danificar a estrutura. Por outro lado, os parafusos podem demorar um pouco mais para serem montados, mas são fáceis de remover e apresentam pouco risco de danificar as partes próximas do galpão. Isto é o que quero dizer com fracamente acoplado . Naturalmente, há casos em que você realmente precisa de um prego, mas essa decisão deve ser guiada pelo pensamento crítico e pela experiência.
Como discutirei em detalhes mais adiante, existem diferentes mecanismos para conectar partes de um programa que fornecem vários graus de acoplamento ; assim como pregos e parafusos . Embora minha analogia possa ter ajudado você a entender o que esse termo extremamente importante significa, não lhe dei nenhuma ideia de como aplicá-lo fora do contexto da construção de um galpão de jardim. Isso me leva ao tipo mais importante de conhecimento e à chave para entender profundamente conceitos vagos e difíceis em qualquer campo de investigação; embora nos atenhamos a escrever código neste artigo.
Conhecendo em código
Na minha opinião, estritamente no que diz respeito ao desenvolvimento de software, a forma mais importante de conhecer um conceito vem de poder usá-lo no código da aplicação em funcionamento. Essa forma de conhecimento pode ser alcançada simplesmente escrevendo muito código e resolvendo muitos problemas diferentes; nomes de jargões e definições verbais não precisam ser incluídos.
Em minha própria experiência, lembro-me de resolver o problema de comunicação com um banco de dados remoto e um banco de dados local por meio de uma única interface (você saberá o que isso significa em breve, se ainda não souber); em vez do cliente (qualquer classe que fale com a interface ) precisando chamar o remoto e o local (ou mesmo um banco de dados de teste) explicitamente. Na verdade, o cliente não tinha ideia do que estava por trás da interface, então não precisei alterá-la, independentemente de estar sendo executado em um aplicativo de produção ou em um ambiente de teste. Cerca de um ano depois de ter resolvido esse problema, me deparei com o termo “Padrão de Fachada” e não muito depois com o termo “Padrão de Repositório”, que são ambos nomes que as pessoas usam para a solução descrita anteriormente.
Todo este preâmbulo é para iluminar algumas das falhas que são mais frequentemente feitas na explicação de tópicos como herança , interfaces e classes abstratas . Das três, a herança é provavelmente a mais simples de usar e entender. Na minha experiência, tanto como estudante de programação quanto como professor, os outros dois são quase invariavelmente um problema para os alunos, a menos que uma atenção muito especial seja dada para evitar os erros discutidos anteriormente. A partir deste ponto, farei o possível para tornar esses tópicos tão simples quanto deveriam ser, mas não mais simples.
Uma nota sobre os exemplos
Sendo mais fluente no desenvolvimento de aplicativos móveis Android, usarei exemplos retirados dessa plataforma para que eu possa ensiná-lo sobre a construção de aplicativos GUI ao mesmo tempo em que introduz os recursos de linguagem do Java. No entanto, não vou entrar em tantos detalhes que os exemplos devem ser ininteligíveis para alguém com uma compreensão superficial de Java EE, Swing ou JavaFX. Meu objetivo final ao discutir esses tópicos é ajudá-lo a entender o que eles significam no contexto da solução de um problema em praticamente qualquer tipo de aplicação.
Também gostaria de avisá-lo, caro leitor, que às vezes pode parecer que estou sendo desnecessariamente filosófico e pedante sobre palavras específicas e suas definições. A razão para isso é que realmente há uma base filosófica profunda necessária para entender a diferença entre algo que é concreto (real) e algo que é abstrato (menos detalhado do que uma coisa real). Esse entendimento se aplica a muitas coisas fora do campo da computação, mas é de grande importância para qualquer desenvolvedor de software compreender a natureza das abstrações . De qualquer forma, se minhas palavras falharem com você, os exemplos no código espero que não.
Herança e Implementação
Quando se trata de construir aplicativos com uma interface gráfica do usuário (GUI), a herança é sem dúvida o mecanismo mais importante para possibilitar a construção rápida de um aplicativo.
Embora haja um benefício menos compreendido no uso de herança a ser discutido posteriormente, o principal benefício é compartilhar a implementação entre classes . Esta palavra “implementação”, pelo menos para os propósitos deste artigo, tem um significado distinto. Para dar uma definição geral da palavra em inglês, posso dizer que implementar algo é torná-lo real .
Para dar uma definição técnica específica ao desenvolvimento de software, posso dizer que implementar um software é escrever linhas concretas de código que satisfaçam os requisitos desse software. Por exemplo, suponha que eu esteja escrevendo um método de soma : private double sum(double first, double second){
private double sum(double first, double second){ //TODO: implement }
O trecho acima, mesmo que eu tenha escrito um tipo de retorno ( double
) e uma declaração de método que especifica os argumentos ( first, second
) e o nome que pode ser usado para chamar o referido método ( sum
), ele tem não foi implementado . Para implementá -lo, devemos completar o corpo do método assim:
private double sum(double first, double second){ return first + second; }
Naturalmente, o primeiro exemplo não compilaria, mas veremos momentaneamente que as interfaces são uma maneira pela qual podemos escrever esses tipos de funções não implementadas sem erros.
Herança em Java
Presumivelmente, se você está lendo este artigo, você usou a palavra-chave extends
Java pelo menos uma vez. A mecânica dessa palavra-chave é simples e geralmente descrita usando exemplos relacionados a diferentes tipos de animais ou formas geométricas; Dog
e Cat
estendem Animal
, e assim por diante. Vou assumir que não preciso explicar a teoria rudimentar de tipos para você, então vamos direto ao principal benefício da herança em Java, por meio da palavra-chave extends
.
Construir um aplicativo “Hello World” baseado em console em Java é muito simples. Supondo que você possua um compilador Java ( javac ) e um ambiente de tempo de execução ( jre ), você pode escrever uma classe que contenha uma função main assim:
public class JavaApp{ public static void main(String []args){ System.out.println("Hello World"); } }
Construir um aplicativo GUI em Java em quase qualquer uma de suas principais plataformas (Android, Enterprise/Web, Desktop), com um pouco de ajuda de um IDE para gerar o esqueleto/código clichê de um novo aplicativo, também é relativamente fácil graças ao extends
a palavra-chave.
Suponha que tenhamos um XML Layout chamado activity_main.xml
(normalmente construímos interfaces de usuário declarativamente no Android, por meio de arquivos Layout) contendo um TextView
(como um rótulo de texto) chamado tvDisplay
:
<?xml version="1.0" encoding="utf-8"?> <FrameLayout xmlns:andro android:layout_width="match_parent" android:layout_height="match_parent"> <TextView android: android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center" /> </FrameLayout>
Além disso, suponha que gostaríamos que o tvDisplay
dissesse “Hello World!” Para fazer isso, simplesmente precisamos escrever uma classe que use a palavra-chave extends
para herdar da classe Activity
:
import android.app.Activity; import android.os.Bundle; import android.widget.TextView; public class MainActivity extends Activity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); ((TextView)findViewById(R.id.tvDisplay)).setText("Hello World"); }
O efeito de herdar a implementação da classe Activity
pode ser melhor apreciado dando uma rápida olhada em seu código-fonte. Duvido muito que o Android se tornasse a plataforma móvel dominante se fosse necessário implementar mesmo uma pequena parte das mais de 8.000 linhas necessárias para interagir com o sistema, apenas para gerar uma janela simples com algum texto. A herança é o que nos permite não ter que reconstruir a estrutura do Android , ou qualquer plataforma com a qual você esteja trabalhando, do zero.
A herança pode ser usada para abstração
Na medida em que pode ser usado para compartilhar a implementação entre classes, a herança é relativamente simples de entender. No entanto, existe outra maneira importante de usar a herança , que está conceitualmente relacionada às interfaces e classes abstratas que discutiremos em breve.
Por favor, suponha nos próximos tempos que uma abstração, usada no sentido mais geral, é uma representação menos detalhada de uma coisa . Em vez de qualificar isso com uma longa definição filosófica, tentarei apontar como as abstrações funcionam na vida cotidiana e, logo em seguida, discuti-las expressamente em termos de desenvolvimento de software.
Suponha que você esteja viajando para a Austrália e saiba que a região que está visitando abriga uma densidade particularmente alta de cobras taipan do interior (elas são aparentemente bastante venenosas). Você decide consultar a Wikipédia para saber mais sobre eles olhando imagens e outras informações. Ao fazer isso, você agora está ciente de um tipo específico de cobra que você nunca viu antes.
Abstrações, ideias, modelos, ou como você quiser chamá-los, são representações menos detalhadas de uma coisa. É importante que eles sejam menos detalhados do que a coisa real porque uma cobra real pode morder você; imagens nas páginas da Wikipédia normalmente não. As abstrações também são importantes porque tanto os computadores quanto os cérebros humanos têm uma capacidade limitada de armazenar, comunicar e processar informações. Ter detalhes suficientes para usar essas informações de maneira prática, sem ocupar muito espaço na memória, é o que possibilita que computadores e cérebros humanos resolvam problemas.
Para vincular isso à herança , todos os três tópicos principais que estou discutindo aqui podem ser usados como abstrações ou mecanismos de abstração . Suponha que no arquivo de layout do nosso aplicativo “Hello World”, decidamos adicionar um ImageView
, Button
e ImageButton
:
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:andro android:layout_width="match_parent" android:layout_height="match_parent"> <Button android: android:layout_width="wrap_content" android:layout_height="wrap_content"/> <ImageButton android: android:layout_width="wrap_content" android:layout_height="wrap_content"/> <ImageView android: android:layout_width="wrap_content" android:layout_height="wrap_content"/> </LinearLayout>
Suponha também que nossa Activity tenha implementado View.OnClickListener
para lidar com cliques:
public class MainActivity extends Activity implements View.OnClickListener { private Button b; private ImageButton ib; private ImageView iv; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); //... b = findViewById(R.id.imvDisplay).setOnClickListener(this); ib = findViewById(R.id.btnDisplay).setOnClickListener(this); iv = findViewById(R.id.imbDisplay).setOnClickListener(this); } @Override public void onClick(View view) { final int id = view.getId(); //handle click based on id... } }
O princípio chave aqui é que Button
, ImageButton
e ImageView
herdam da classe View
. O resultado é que essa função onClick
pode receber eventos de clique de elementos de interface do usuário distintos (embora hierarquicamente relacionados) referenciando-os como sua classe pai menos detalhada. Isso é muito mais conveniente do que ter que escrever um método distinto para lidar com todo tipo de widget na plataforma Android (sem mencionar widgets personalizados).
Interfaces e abstração
Você pode ter achado o exemplo de código anterior um pouco pouco inspirador, mesmo que tenha entendido por que o escolhi. Ser capaz de compartilhar a implementação em uma hierarquia de classes é incrivelmente útil, e eu diria que essa é a principal utilidade da herança . Quanto a nos permitir tratar um conjunto de classes que têm uma classe pai comum como igual em tipo (ou seja, como a classe pai ), esse recurso de herança tem uso limitado.
Por limitado, estou falando do requisito de classes filhas estarem dentro da mesma hierarquia de classes para serem referenciadas via, ou conhecidas como classe pai. Em outras palavras, a herança é um mecanismo muito restritivo para abstração . Na verdade, se eu supor que a abstração é um espectro que se move entre diferentes níveis de detalhe (ou informação), posso dizer que a herança é o mecanismo menos abstrato para abstração em Java.
Antes de continuar discutindo as interfaces , gostaria de mencionar que, a partir do Java 8 , dois recursos chamados Métodos Padrão e Métodos Estáticos foram adicionados às interfaces . Eu os discutirei eventualmente, mas no momento eu gostaria que fingíssemos que eles não existem. Isso é para facilitar a explicação do objetivo principal de usar uma interface , que foi inicialmente, e provavelmente ainda é, o mecanismo mais abstrato para abstração em Java .
Menos detalhes significa mais liberdade
Na seção sobre herança , dei uma definição da palavra implementação , que deveria contrastar com outro termo que abordaremos agora. Para ser claro, não me importo com as palavras em si, ou se você concorda com o uso delas; apenas que você entenda o que eles conceitualmente apontam.
Considerando que a herança é principalmente uma ferramenta para compartilhar a implementação em um conjunto de classes, podemos dizer que as interfaces são principalmente um mecanismo para compartilhar o comportamento em um conjunto de classes. Comportamento usado nesse sentido é realmente apenas uma palavra não técnica para métodos abstratos . Um método abstrato é um método que, de fato, não pode conter um corpo de método :
public interface OnClickListener { void onClick(View v); }
A reação natural para mim, e para vários indivíduos que eu ensinei, depois de ver uma interface pela primeira vez, foi se perguntar qual seria a utilidade de compartilhar apenas um tipo de retorno , nome de método e lista de parâmetros . À primeira vista, parece uma ótima maneira de criar trabalho extra para você mesmo, ou para qualquer outra pessoa que esteja escrevendo a classe que implements
a interface . A resposta é que as interfaces são perfeitas para situações em que você deseja que um conjunto de classes se comporte da mesma maneira (ou seja, elas possuem os mesmos métodos abstratos públicos), mas você espera que elas implementem esse comportamento de maneiras diferentes.
Para dar um exemplo simples, mas relevante, a plataforma Android possui duas classes que estão principalmente no negócio de criar e gerenciar parte da interface do usuário: Activity
e Fragment
. Segue-se que essas classes muitas vezes terão o requisito de ouvir eventos que aparecem quando um widget é clicado (ou interage de outra forma por um usuário). Para fins de argumentação, vamos parar um momento para entender por que a herança quase nunca resolverá esse problema:
public class OnClickManager { public void onClick(View view){ //Wait a minute... Activities and Fragments almost never //handle click events exactly the same way... } }
Não apenas tornar nossas atividades e fragmentos herdados do OnClickManager
tornaria impossível lidar com eventos de uma maneira diferente, mas o problema é que não poderíamos fazer isso se quiséssemos. Ambos Activity e Fragment já estendem uma classe pai , e Java não permite várias classes pai . Portanto, nosso problema é que queremos que um conjunto de classes se comporte da mesma maneira, mas devemos ter flexibilidade em como a classe implementa esse comportamento . Isso nos traz de volta ao exemplo anterior do View.OnClickListener
:
public interface OnClickListener { void onClick(View v); }
Este é o código-fonte real (que está aninhado na classe View
), e essas poucas linhas nos permitem garantir um comportamento consistente em diferentes widgets ( Views ) e controladores de UI ( Activities, Fragments, etc. ).
A abstração promove o acoplamento fraco
Espero ter respondido à pergunta geral sobre por que as interfaces existem em Java; entre muitas outras línguas. De uma perspectiva, eles são apenas um meio de compartilhar código entre classes, mas são deliberadamente menos detalhados para permitir diferentes implementações . Mas assim como a herança pode ser usada como um mecanismo para compartilhar código e abstração (embora com restrições na hierarquia de classes), segue-se que as interfaces fornecem um mecanismo mais flexível para abstração .
Em uma seção anterior deste artigo, apresentei o tópico de acoplamento solto/apertado por analogia da diferença entre usar pregos e parafusos para construir algum tipo de estrutura. Para recapitular, a ideia básica é que você desejará usar parafusos em situações em que a alteração da estrutura existente (o que pode ser resultado de erros de correção, alterações de projeto e assim por diante) provavelmente acontecerá. Os pregos são bons para usar quando você só precisa prender partes da estrutura e não está particularmente preocupado em desmontá-los em um futuro próximo.
Pregos e parafusos devem ser análogos a referências concretas e abstratas (o termo dependências também se aplica) entre classes. Para que não haja confusão, o exemplo a seguir demonstrará o que quero dizer:
class Client { private Validator validator; private INetworkAdapter networkAdapter; void sendNetworkRequest(String input){ if (validator.validateInput(input)) { try { networkAdapter.sendRequest(input); } catch (IOException e){ //handle exception } } } } class Validator { //...validation logic boolean validateInput(String input){ boolean isValid = true; //...change isValid to false based on validation logic return isValid; } } interface INetworkAdapter { //... void sendRequest(String input) throws IOException; }
Aqui, temos uma classe chamada Client
que possui dois tipos de referências . Observe que, supondo que Client
não tenha nada a ver com a criação de suas referências (na verdade, não deveria), ele é desacoplado dos detalhes de implementação de qualquer adaptador de rede específico.
Existem algumas implicações importantes desse acoplamento fraco . Para começar, posso construir Client
em isolamento absoluto de qualquer implementação de INetworkAdapter
. Imagine por um momento que você está trabalhando em uma equipe de dois desenvolvedores; um para construir o front-end, um para construir o back-end. Contanto que ambos os desenvolvedores estejam cientes das interfaces que unem suas respectivas classes, eles podem continuar com o trabalho virtualmente independentemente um do outro.
Em segundo lugar, e se eu lhe dissesse que ambos os desenvolvedores puderam verificar se suas respectivas implementações funcionaram corretamente, também independentemente do progresso um do outro? Isso é muito fácil com interfaces; apenas construa um Test Double que implements
a interface apropriada:
class FakeNetworkAdapter implements INetworkAdapter { public boolean throwError = false; @Override public void sendRequest(String input) throws IOException { if (throwError) throw new IOException("Test Exception"); } }
Em princípio, o que pode ser observado é que trabalhar com referências abstratas abre as portas para maior modularidade, testabilidade e alguns padrões de design muito poderosos, como o Facade Pattern , Observer Pattern e muito mais. Eles também podem permitir que os desenvolvedores encontrem um equilíbrio feliz de projetar diferentes partes de um sistema com base no comportamento ( Program To An Interface ), sem se atrapalhar nos detalhes da implementação .
Um ponto final sobre abstrações
Abstrações não existem da mesma forma que uma coisa concreta . Isso se reflete na linguagem de programação Java pelo fato de que classes e interfaces abstratas podem não ser instanciadas.
Por exemplo, isso definitivamente não compilaria:
public class Main extends Application { public static void main(String[] args) { launch(args); } @Override public void start(Stage primaryStage) { //ERROR x2: Foo f = new Foo(); Bar b = new Bar() } private abstract class Foo{} private interface Bar{} }
Na verdade, a ideia de esperar que uma interface não implementada ou classe abstrata funcione em tempo de execução faz tanto sentido quanto esperar que um uniforme UPS flutue ao redor da entrega de pacotes. Algo concreto deve estar por trás da abstração para que seja útil; mesmo que a classe de chamada não precise saber o que está realmente por trás das referências abstratas .
Aulas abstratas: juntando tudo
Se você chegou até aqui, fico feliz em lhe dizer que não tenho mais tangentes filosóficas ou frases de jargão para traduzir. Simplificando, classes abstratas são um mecanismo para compartilhar implementação e comportamento em um conjunto de classes. Agora, vou admitir imediatamente que não me vejo usando classes abstratas com tanta frequência. Mesmo assim, minha esperança é que, ao final desta seção, você saiba exatamente quando eles são solicitados.
Estudo de caso de registro de treino
Com aproximadamente um ano de construção de aplicativos Android em Java, eu estava reconstruindo meu primeiro aplicativo Android do zero. A primeira versão era o tipo de massa horrenda de código que você esperaria de um desenvolvedor autodidata com pouca orientação. Quando eu quis adicionar novas funcionalidades, ficou claro que a estrutura fortemente acoplada que eu havia construído exclusivamente com pregos era tão impossível de manter que eu deveria reconstruí-la inteiramente.
O aplicativo era um registro de treino projetado para permitir a gravação fácil de seus treinos e a capacidade de gerar os dados de um treino anterior como um arquivo de texto ou imagem. Sem entrar em muitos detalhes, estruturei os modelos de dados do aplicativo de forma que houvesse um objeto Workout
, que compreendia uma coleção de objetos Exercise
(entre outros campos que são irrelevantes para esta discussão).
Como eu estava implementando o recurso para enviar dados de treino para algum tipo de mídia visual, percebi que tinha que lidar com um problema: diferentes tipos de exercícios exigiriam diferentes tipos de saídas de texto.
Para lhe dar uma ideia aproximada, eu queria alterar as saídas dependendo do tipo de exercício assim:
- Barra: 10 REPS @ 100 LBS
- Haltere: 10 REPS @ 50 LBS x2
- Peso corporal: 10 REPS @ Peso corporal
- Peso corporal +: 10 REPS @ Peso corporal + 45 LBS
- Temporizado: 60 SEC @ 100 LBS
Antes de prosseguir, observe que havia outros tipos (o exercício pode se tornar complicado) e que o código que mostrarei foi reduzido e alterado para caber bem em um artigo.
De acordo com a minha definição de antes, o objetivo de escrever uma classe abstrata é implementar tudo (mesmo estado , como variáveis e constantes ) que é compartilhado por todas as classes filhas na classe abstrata . Então, para qualquer coisa que mude nas classes filhas , crie um método abstrato :
abstract class Exercise { private final String type; protected final String name; protected final int[] repetitionsOrTime; protected final double[] weight; protected static final String POUNDS = "LBS"; protected static final String SECONDS = "SEC "; protected static final String REPETITIONS = "REPS "; public Exercise(String type, String name, int[] repetitionsOrTime, double[] weight) { this.type = type; this.name = name; this.repetitionsOrTime = repetitionsOrTime; this.weight = weight; } public String getFormattedOutput(){ StringBuilder sb = new StringBuilder(); sb.append(name); sb.append("\n"); getSetData(sb); sb.append("\n"); return sb.toString(); } /** * Append data appropriately based on Exercise type * @param sb - StringBuilder to Append data to */ protected abstract void getSetData(StringBuilder sb); //...Getters }
Posso estar afirmando o óbvio, mas se você tiver alguma dúvida sobre o que deve ou não ser implementado na classe abstrata , a chave é observar qualquer parte da implementação que tenha sido repetida em todas as classes filhas.
Agora que estabelecemos o que é comum entre todos os exercícios, podemos começar a criar classes filhas com especializações para cada tipo de saída String:
Exercício com barra:
class BarbellExercise extends Exercise { public BarbellExercise(String type, String name, int[] repetitionsOrTime, double[] weight) { super(type, name, repetitionsOrTime, weight); } @Override protected void getSetData(StringBuilder sb) { for (int i = 0; i < repetitionsOrTime.length; i++) { sb.append(repetitionsOrTime[i]); sb.append(" "); sb.append(REPETITIONS); sb.append(" @ "); sb.append(weight[i]); sb.append(POUNDS); sb.append("\n"); } } }
Exercício com halteres:
class DumbbellExercise extends Exercise { private static final String TIMES_TWO = "x2"; public DumbbellExercise(String type, String name, int[] repetitionsOrTime, double[] weight) { super(type, name, repetitionsOrTime, weight); } @Override protected void getSetData(StringBuilder sb) { for (int i = 0; i < repetitionsOrTime.length; i++) { sb.append(repetitionsOrTime[i]); sb.append(" "); sb.append(REPETITIONS); sb.append(" @ "); sb.append(weight[i]); sb.append(POUNDS); sb.append(TIMES_TWO); sb.append("\n"); } } }
Exercício de peso corporal:
class BodyweightExercise extends Exercise { private static final String BODYWEIGHT = "Bodyweight"; public BodyweightExercise(String type, String name, int[] repetitionsOrTime, double[] weight) { super(type, name, repetitionsOrTime, weight); } @Override protected void getSetData(StringBuilder sb) { for (int i = 0; i < repetitionsOrTime.length; i++) { sb.append(repetitionsOrTime[i]); sb.append(" "); sb.append(REPETITIONS); sb.append(" @ "); sb.append(BODYWEIGHT); sb.append("\n"); } } }
Estou certo de que alguns leitores astutos encontrarão coisas que poderiam ter sido abstraídas de maneira mais eficiente, mas o objetivo deste exemplo (que foi simplificado da fonte original) é demonstrar a abordagem geral. Claro, nenhum artigo de programação estaria completo sem algo que possa ser executado. Existem vários compiladores Java online que você pode usar para executar este código se quiser testá-lo (a menos que você já tenha um IDE):
public class Main { public static void main(String[] args) { //Note: I actually used another nested class called a "Set" instead of an Array //to represent each Set of an Exercise. int[] reps = {10, 10, 8}; double[] weight = {70.0, 70.0, 70.0}; Exercise e1 = new BarbellExercise( "Barbell", "Barbell Bench Press", reps, weight ); Exercise e2 = new DumbbellExercise( "Dumbbell", "Dumbbell Bench Press", reps, weight ); Exercise e3 = new BodyweightExercise( "Bodyweight", "Push Up", reps, weight ); System.out.println( e1.getFormattedOutput() + e2.getFormattedOutput() + e3.getFormattedOutput() ); } }
Executing this toy application yields the following output: Barbell Bench Press
10 REPS @ 70.0LBS 10 REPS @ 70.0LBS 8 REPS @ 70.0LBS Dumbbell Bench Press 10 REPS @ 70.0LBSx2 10 REPS @ 70.0LBSx2 8 REPS @ 70.0LBSx2 Push Up 10 REPS @ Bodyweight 10 REPS @ Bodyweight 8 REPS @ Bodyweight
Further Considerations
Earlier, I mentioned that there are two features of Java interfaces (as of Java 8) which are decidedly geared towards sharing implementation , as opposed to behavior . These features are known as Default Methods and Static Methods .
I have decided not to go into detail on these features for the reason that they are most typically used in mature and/or large code bases where a given interface has many inheritors. Despite the fact that this is meant to be an introductory article, and I still encourage you to take a look at these features eventually, even though I am confident that you will not need to worry about them just yet.
I would also like to mention that there are other ways to share implementation across a set of classes (or even static methods ) in a Java application that does not require inheritance or abstraction at all. For example, suppose you have some implementation which you expect to use in a variety of different classes, but does not necessarily make sense to share via inheritance . A common pattern in Java is to write what is known as a Utility class, which is a simple class
containing the requisite implementation in a static method :
public class TimeConverterUtil { /** * Accepts an hour (0-23) and minute (0-59), then attempts to format them into an appropriate * format such as 12, 30 -> 12:30 pm */ public static String convertTime (int hour, int minute){ String unformattedTime = Integer.toString(hour) + ":" + Integer.toString(minute); DateFormat f1 = new SimpleDateFormat("HH:mm"); Date d = null; try { d = f1.parse(unformattedTime); } catch (ParseException e) { e.printStackTrace(); } DateFormat f2 = new SimpleDateFormat("h:mm a"); return f2.format(d).toLowerCase(); } }
Using this static method in an external class (or another static method ) looks like this:
public class Main { public static void main(String[] args){ //... String time = TimeConverterUtil.convertTime(12, 30); //... } }
Cheat Sheet
We have covered a lot of ground in this article, so I would like to spend a moment summarizing the three main mechanisms based on what problems they solve. Since you should possess a sufficient understanding of the terms and ideas I have either introduced or redefined for the purposes of this article, I will keep the summaries brief.
I Want A Set Of Child Classes To Share Implementation
Classic inheritance , which requires a child class to inherit from a parent class , is a very simple mechanism for sharing implementation across a set of classes. An easy way to decide if some implementation should be pulled into a parent class , is to see whether it is repeated in a number of different classes line for line. The acronym DRY ( Don't Repeat Yourself ) is a good mnemonic device to watch out for this situation.
While coupling child classes together with a common parent class can present some limitations, a side benefit is that they can all be referenced as the parent class , which provides a limited degree of abstraction .
I Want A Set Of Classes To Share Behavior
Sometimes, you want a set of classes to be capable of possessing certain abstract methods (referred to as behavior ), but you do not expect the implementation of that behavior to be repeated across inheritors.
By definition, Java interfaces may not contain any implementation (except for Default and Static Methods ), but any class which implements an interface , must supply an implementation for all abstract methods, otherwise, the code will not compile. This provides a healthy measure of flexibility and restriction on what is actually shared and does not require the inheritors to be of the same class hierarchy .
I Want A Set Of Child Classes To Share Behavior And Implementation
Although I do not find myself using abstract classes all over the place, they are perfect for situations when you require a mechanism for sharing both behavior and implementation across a set of classes. Anything which will be repeated across inheritors may be implemented directly in the abstract class
, and anything which requires flexibility may be specified as an abstract method .