Java: Orientação a Objetos
Introdução
A programação Orientada a Objeto tem como objetivo gerar códigos mais organizados, menos dependentes e mais reutilizáveis.
Podemos representar elementos do mundo real de forma modular e "auto suficientes". Ou seja, módulos que possuam características e comportamentos bem definidos e que funcionem de forma independente (auto suficientes). A estes módulos damos o nome de objetos.
Então o que são os objetos no contexto do Java?
Em Java, toda a programação ocorre dentro de estruturas chamadas de "Classes". Ou seja, as classes são a representação lógica dos objetos de um software. Estas possuem propriedades e métodos. As propriedades correspondem ao estado (ou característica) e os métodos correspondem ao comportamento dos elementos.
Os objetos são instâncias destas classes, ou seja, a partir de um classe podemos criar diversos objetos. Em outras palavras, os objetos são a representação física em memória, das classes. As variáveis e/ou atributos declarados em nosso código e que sejam criados a partir do comando "new", são chamados de objetos.
A partir desta breve introdução, espero que tenha sido compreendido o conceito de objetos, pois este é elemento base para a avançar na explicação do tema principal: Orientação a Objetos.
Um outro tema importante também é entender como estes objetos devem ser organizados. No Java, esta organização ocorre através dos pacotes (ou packages).
Os pacotes correspondem a uma estrutura semelhante a de diretorias. Onde todas as classes do projeto devem ficar armazenadas. Utilizamos esta estrutura de pacotes de forma a agrupar classes/elementos com funcionalidades comuns. Por exemplo: classes de mapeamento de base de dados podem estar todas em um mesmo pacote.
A maneira mais direta e fácil de entender o porque de a Orientação a Objetos nos permitir produzir um código organizado, menos dependente e reutilizável é introduzir e explicar os seus conceitos:
Herança, Encapsulamento, Abstração, Polimorfismo e Sobrecarga de Métodos
Então vamos direto ao ponto.
Herança
Corresponde a capacidade de passar características e comportamento de um objeto para seus descendentes. Assim como nossos filhos herdam nossas características, os objetos derivados de outros objetos, herdam as características do objeto pai.
Ou seja, ao criamos um objeto "Carro", que possua como característica o número de portas e a cor, podemos derivar deste objetos, por exemplo, os objetos CarroPasseio e CarroEsportivo. Ambos possuirão as características número de portas e cor, herdadas do objeto pai (Carro).
Se adicionarmos uma nova característica ao objeto Carro, como velocidade máxima. Automaticamente estaremos a atribuir esta característica aos objetos CarroPasseio e CarroEsportivo, sem a necessidade de escrever ou modificar qualquer linha de código nestes objetos.
O mesmo ocorre ao removermos coisas do objeto Carro. Ou seja, tudo que fizermos, em termos de alteração de características, no objeto Carro, afeta diretamente os seus descendentes.
O mesmo se passa com o comportamento. Imagine que adicionamos, ao objeto Carro, os métodos "ligar" e "acelerar" e escrevemos a lógica que implementa esta funcionalidade. Automaticamente estaremos a agregar estas funcionalidades ao objetos CarroPasseio e CarroEsportivo.
Então, podemos observar que, ao estruturar o código utilizando o conceito de herança, podemos reaproveitar e evitar a repetição de código. Além de organizar e centralizar rotinas comuns e globais a diversos elementos de um sistema em super objetos. Usamos o termo super para indicar classes "superiores" que deram origem a classes filho (sub classes).
Encapsulamento
No Java, as características dos objetos são armazenadas em atributos, ou varáveis, globais na Classe.
Estas variáveis possuem escopos de visualização que podem ser publicas, privadas ou protegidas (public, private ou protected).
Quando as variáveis são declaradas de maneira publica, todos os elementos externos (ou seja, outras classes em qualquer pacote) podem alterar e acessar o seu conteúdo diretamente. Os escopos privado e protegidos não permitem este tipo de acesso direto por parte dos elementos externos. Somente a programação interna da própria classe e de suas derivadas, podem utilizar estas varáveis (ou atributos), com algumas ressalvas a serem explicadas a seguir.
Então porque possuir 2 escopos de acesso restrito? Os atributos de escopo privado somente podem ser acessados (utilizados em código) na propria classe. Ou seja, mesmo sendo propagado para as classes derivadas, estes atributos não estarão disponíveis para o código escrito nas classes filho.
Já os atributos protegidos, podem ser utilizado no código da classe onde foram declarados e também nas classes derivadas (as classes filho). Estes atributos também podem ser utilizados em elementos externos, porém estes elementos devem pertencer a mesma "package" a qual a classe em questão se encontre.
O conceito de encapsulamento consiste em restringir o acesso aos atributos da classe, evitando que estes sejam visíveis e manipulados diretamente, seja por classes derivadas ou mesmo por classes externas.
Com isto o acesso a estes atributos devem ser efetuados através de métodos conhecidos como "getters" e "setters". Entretanto isto é uma convenção, não uma obrigação. O fato é que, para obter o encapsulamento, os atributos nunca devem estar disponíveis para acesso direto.
A grande vantagem desta abordagem é que podemos desenvolver lógica no processo de acesso aos atributos da classe e garantir a integridade do objeto.
Abstração
Uma abordagem para a abstração é consequência do encapsulamento. Ou seja, uma vez que a utilização dos atributos é feita através de métodos, as classes externas não possuem acesso ao que se passa ao efetuar uma ação sobre os atributos de outra classe.
Ou seja, o acesso é feito de forma transparente e o que se obtém é o resultado final. Logo durante a busca do valor de um atributo, este pode nem mesmo ser uma variável declarada na classe a ser utilizada. O valor pode vir de um acesso a uma base de dados, de um ficheiro etc. Para o utilizador, todo este processo esta "abstraído". O mesmo se passa ao guardar um valor. Podemos por exemplo gerar logs e histórico de valores prévios no processo de guardar um novo valor.
Quem usa o objeto não precisa e nem deveria saber o que se passa durante o processo de utilização.
Uma outra forma de abstração, consiste em definir métodos abstratos. Estes métodos não possuem código, possuem apenas assinaturas. Uma assinatura de um método corresponde ao seu nome + seus parâmetros.
Ao utilizar esta abordagem, a classe em questão deixa de ser capaz de produzir objetos e passam a ser somente uma superclasses. Ou sejam, não é possível instanciar estas classes abstratas (classes que possuam ao menos 1 método abstrato).
Então porque utilizar isto? A resposta esta na padronização, ou na construção de APIs e framworks. Ou seja, podemos utilizar os métodos abstratos na lógica do código principal (por exemplo em uma função), contando que receberemos como entrada na chamada do métodos, algum objeto derivado da classe abstrata que implemente o código dos respectivos métodos abstratos.
Imagine a classe abstrata "Pessoa" que possua o método abstrato "andar()". Ou seja, não existe código implementado para este método. Então derivamos a classe Homem e a classe Mulher. Nestas classes implementamos códigos distintos para o método "andar" (isto será obrigatório pela própria linguagem - a menos que estas novas classes também sejam abstratas).
Agora suponha que em uma classe externa, possuímos uma rotina que recebe como entrada um objeto do tipo "Pessoa" e em seu código possuímos a chamada ao método "andar": pessoa.andar();
Observe que podemos escrever este código antes mesmo de saber quais serão as classes derivadas de Pessoa, pois quaisquer que sejam, deverão implementar a lógica para este método e o código que utiliza abstrai o que se passará por lá. Ele simplesmente deve executar o método "andar".
Polimorfismo
O polimorfismo consiste em alterar o comportamento funcional de parte de um elemento, porém mantendo sua hierarquia. Quando falamos em polimorfismo, estamos a falar de métodos e não de atributos. Ou seja, estamos a reescrever a lógica de um ou mais métodos de classes filho, porém mantendo a estrutura de definição do método (a assinatura).
Imagine um objeto "Animal" que possua o método "emitirSom", com um código genérico. Quando derivarmos os objetos Cachorro e Gato, queremos que o som emitido por estes objetos filho sejam específicos de cada caso. Entretanto, ambos devem emitir som.
É ai que entra o polimorfismo, reescrevendo o método "emitirSom" em cada classe filho, porém mantendo a estrutura herdada, ou seja, o método "emitirSom".
Mas qual a vantagem disto? Em termos práticos, imagine que em uma classe de nosso sistema, exista um método que, ao ser executado, receba como entrada um elemento do tipo Animal. No corpo do código deste método existe a seguinte linha de código: "animal.emitirSom();"
Neste caso, a classe "Animal" possuirá código implementado para o método "emitirSom()" e esta também poderá ser instânciada, diferente do caso de classes abstratas. Ou seja, existe um comportamento padrão, definido na classe "Animal" e existem comportamentos "modificados" nas classses "Cachorro" e "Gato". Como existe implementação na superclasse, podemos reutilizar esta programação junto ao código do método que esta a ser modificado, na classe filho. Para isto temos sempre disponível o elemento "super" e que, neste exemplo, devemos escrever "super.emitirSom();" para que o código original seja processado.
Observe que caso o método receba como entrada um Cachorro, este irá executar o código existente na classe Cachorro, para o método "emitirSom". Porém se a entrada receber um Gato, o código executado será o descrito na classe Gato.
Observe que com os conceitos apresentados até agora, começamos a poder dividir nossa aplicação em camadas. Uma camada com lógica genérica (CORE), onde rotinas trabalham com métodos (abstratos ou não) definidos em superclasses , ou classes pai. E uma camada com lógica especifica, onde podemos definir o "comportamento final" que será processado ao executar a lógica genérica. Este é princípio da criação do que é chamado de API ou framework.
Esta abordagem permite a expansão de forma fácil, pois novos objetos podem ser adicionados, estendendo as superclasses e em seguida serem acoplados a o sistema. Tudo isto pode ser configurável (desde que preparado para isto) evitando em muitos casos até mesmo a necessidade de uma nova compilação do sistema.
Sobrecarga de Métodos
A sobrecarga de métodos não pode ser confundida com o polimorfismo. Este conceito consiste em possuir mais de uma definição para um "mesmo método". Ou seja, o mesmo método (nome de método) pode possuir lógicas diferentes, ou complementares, dependendo da sua assinatura (nome + parâmetros de entrada).
Em nosso exemplo "emitirSom", podemos ter 2 versões para o método: "emitirSom()" e o "emitirSom(boolean alto)". Neste caso, ao utilizar o método "emitirSom", podemos passar o parâmetro "alto", ou simplesmente utilizar a versão sem parâmetro algum.
Um exemplo prático de utilização desta abordagem é quando desejamos ajustar um código de um método (já existente) que seja utilizado em muitos locais do sistema. Isto é muito comum quando estamos a dar manutenção em códigos complexos, já existentes, em que não temos muita segurança sobre a lógica antiga. Então, mantemos tudo como era e agregamos a nova parte, sem precisar ter a preocupação com "efeitos colaterais".
Ao criar uma nova versão para um método podemos garantir que a versão original permaneça com o comportamento original. Um exemplo disto seria a criação de uma versão, de um método existente, com um parâmetro boolean. Em caso de verdade, executa a parte nova do código. E em caso falso, execute a lógica antiga.
//Método original do código existente
metodo1(){
log.("metodo1");
}
//Novo método
metodo1(boolean novo){
if (novo) {
//Faz coisas a mais (opcional)
log.("metodo1 modificado");
metodo1();
//Faz outra coisas a mais (opcional)
} else {
metodo1();
}
}
Observe que não será necessário alterar nada do código existente, que já utilizava o "método1()". Pois este não deixou de existir. Entretanto, podemos escrever novas rotinas que utilizem "metodo1(true/false)", aproveitando o código já existente e agregando novos códigos.
Interfaces
As interfaces não são definidas como parte do conceito de Orientação a Objetos, entretanto, são parte fundamental da linguagem para aumentar o "poder" de programação.
Na orientação a objetos, temos como base as classes que devem ser estruturadas da melhor forma possível para otimizar o código e agilizar o processo de produção da solução.
Observe que até o momento, podemos ter superclasses que definem as estruturas básicas da solução. Porém, as classes nos "obrigam" que os métodos sejam escritos (exceto em caso de métodos abstratos) e também existe uma limitação quanto a questão de herança. As classes filho somente podem possuir uma única classe pai.
Com a introdução das interfaces, não existe uma limitação em número de implementação. Ou seja, uma classe pode implementar diversas interfaces ao mesmo tempo.
O que significa implementar interfaces? Uma interface define a assinatura de métodos (mais recentemente também pode ter algum estado/atributo), porém nunca a implementação dos métodos. Ou seja, agem como métodos abstratos de superclasses.
As interfaces também podem ser utilizadas como parâmetros de entrada em métodos.
Então qual é a vantagem de sua utilização?
A grande vantagem é que as interfaces podem ser utilizadas para implementar uma espécie de "herança transversal", onde classes que não fazem parte de uma mesma hierarquia, possam ter partes em comum. Cada qual implementando a sua lógica especifica para os métodos definidos nas interfaces comuns.
Juntando as coisas e estendendo o exemplo das classes "Homem" e "Mulher" (particularmente a classe "Homem").
Imagine que criamos a interface "Habilidades" e esta possui a assinatura do método "saltar". Agora, a classe "Homem" passa a implementar a interface "Habilidades". Ou seja, esta classe deverá escrever o código específico para este método. Criamos também a classe "Cavalo" e esta também implementa a interface "Habilidades" e possui o código específico para o método "saltar".
Agora, imagine uma classe externa que possui um método e que este método possui como parâmetro de entrada a interface "Habilidades". No interior do seu código existe uma chamada ao método "saltar", ex.: habilidades.saltar().
Ou seja, se for fornecido como entrada um objeto do tipo "Homem", o método "saltar" da classe "Homem" será executado. Em caso de ser fornecido um objeto "Cavalo", o método "saltar" da classe "Cavalo" será executado.
Ou seja, observe que classes totalmente distintas padem conter funcionalidades transversais, em comum. E estas funcionalidades podem ser utilizadas de forma genérica na construção de uma API, ou framework.
As interfaces também podem ser utilizadas para "marcar" classes. Um exemplo comum em que isto ocorre é com a interface "Serializable" do Java. Quando possuímos objetos que devem ser serializados, precisamos "informar" ao Java esta característica. Para isto adicionamos a interface "Serializable" na lista de interfaces implementadas pela classe que é utilizada para dar origem aos objetos. O framework Java, em sua implementação, verifica se os elementos a serem serializados possuem (entre suas interfaces implementada) a interface "Serializable" antes de seguir para a serialização.
Ou seja, se estamos a desenvolver um framework ou uma API podemos, em nosso código, verificar se um objeto implementa determinada interface para definir o fluxo de execução a considerar.
ex.:
if (obj implemets Habilidades) {
....
} else {
....
}
Conclusão
Os frameworks java e as APIs utilizam os conceitos de Orientação a Objetos na implementação de seus "container" e funcionalidades CORE. Diversos padrões de desenvolvimentos também foram estruturados sobre estes conceitos. Então o conhecimento de Orientação a Objetos é essencial para um trabalho de qualidade com a linguagem Java. Estes conceitos se extendem a diversas linguagens e trata-se de um padrão.