Programação Orientada a Objetos

Encapsulamento | Herança | Polimorfismo

Veja também: Programação Orientada a Objetos em Java

Encapsulamento

Encapsulamento é a característica da programação orientada a objetos de ocultar partes independentes da implementação, permitindo que detalhes do objeto não apareçam para o mundo exterior. É o principal pilar da Programação Orientada a Objetos.

Ao invés de criar o programa como uma entidade única, grande, monolítica, a correta aplicação do encapsulamento permite que se divida o programa em várias partes menores, inpedentes. Cada parte possui implementação e realiza seu trabalho independente das outras partes do programa. O encapsulamento mantém esta independência entre as partes, permitindo que os detalhes internos sejam mantidos ocultos, e a interação entre os objetos seja realizada a partir de uma interface externa.

A interface de um objeto funciona como um contrato, que define a forma como os outros objetos irão interagir com o objeto. Nesta interface são listados os serviços providos pelo objeto, e os parâmetros necessários para a execução do serviço. Ao contrário da interface, a implementação do objeto define a forma como ele executa um serviço. A implementação define os detalhes internos do objeto, que devem ser irrelevantes para os objetos que interagem com ele.

Entre as principais vantagens do encapsulamento estão a independência dos objetos, o que permite reutilizar os objetos em outros lugares, e a manutenção do sistema, pois os objetos podem ser alterados sem impactar o resto do sistema.

Existem três características para obter um encapsulamento eficaz, a abstração, a ocultação da implementação e a divisão da responsabilidade, que serão detalhadas a seguir.

Abstração

Abstração é o processo de simplificar o problema, removendo detalhes que não auxiliam na sua solução e apenas agregam complexidade. O uso da abstração auxilia a solucionar um problmea e a reutilizar partes da solução encontrada na solução de outros problemas.

Para obter uma abstração eficaz, deve-se tratar o caso geral do problema e não casos específicos. Na existência de vários problemas, encontre o que for comum, tente encontrar um conceito, e não um caso específico.

Pode ser difícil encontrar uma abstração na primeira vez que se tenta solucionar o problema. É comum que se erre na primeira vez, e nem sempre se encontra a abstração correta, por isso esteja preparado para errar e revisitar o problema diversas vezes.

Ocultação da Implementação

Ao utilizar objetos baseados na sua interface e no serviço oferecido, atinge-se uma melhor separação entre os objetos, uma vez que ambos estão conectados apenas pelas suas interfaces, independente da implementação oferecida. Com a interface de um objeto estável, sem alteração, é possível modificar a implementação de um objeto de forma que ele possa oferecer o mesmo serviço de forma mais eficiente, sem que isso impacte os outros objetos do sistema.

Este desacoplamento é permitido através da ocultação da implementação, que protege um objeto de seus usuários (outros objetos), e protege os usuários do objeto dele mesmo.

Em um sistema projetado com o uso da orientação a objetos, objetos irão interagir (através das relações de posse e uso) de forma a, juntos, atingirem a soluçao do problema. Essa interação cria um acoplamento entre os objetos.

Um código fortemente acoplado é um código de um objeto que depende da implementação específica de um outro objeto para funcionar, o que significa que se a implementação do objeto que presta o serviço for modificado, irá comprometer o objeto que utiliza o serviço. Um sistema que apresenta um alto grau de acoplamento é difícil de alterar e manter, uma vez que cada alteração em um objeto trará impactos em outros objetos, talvez até mesmo não diretamente relacionados com o objeto modificado.

Um código fracamente acoplado, por sua vez, faz com que um objeto possa ter sua implementação modificada, sem que os usuários do seu serviço sejam afetados, desde que a interface continue válida (o contrato entre os objetos continue válido). Sistemas com acoplamento fraco são mais fáceis de extender e manter.

Nota: O acoplamento é uma característica importante a ser controlada em sistemas orientados a objetos. Um alto acoplamento significa uma falha no encapsulamento, e pode indicar um problema no projeto.

É claro que existem situações onde as interfaces dos objetos podem sofrer alterações (devido a mudanças de requisitos, ou a correções nas primeiras versões de uma interface), e, nesse caso, os objetos serão afetados independente do seu grau de acoplamento. Sempre existirá algum grau de dependência entre os objetos, no entanto existem graus de dependência aceitáveis, que normalmente envolvem apenas a interface dos objetos.

Para obter uma boa ocultação da implementação, só deve ser permitido o acesso ao estado do objeto por meio de métodos, que devem validar todo valor utilizado para alterar o estado do objeto. Não deve ser fornecido acesso involuntário a dados internos (por exemplo, a partir de um retorno de método). E não se deve fazer suposições sobre a implementação dos objetos e tipos de dados.

Divisão da Responsabilidade

A ocultação da implementação é apenas um passo para a escrita de código fracamente acoplado. Para obter esta característica é necessária, também, uma correta divisão da responsabilidade entre os objetos.

Uma divisão da responsabilidade correta significa que cada objeto deve ter uma responsabilidade dentro do sistema, e executá-la bem. Para executar sua responsabilidade, pode ser necessário que o objeto possua várias funções, todas associadas à sua responsabilidade. No entanto, um objeto deve ser coeso, ou seja, todas as suas funções precisam ter um forte vínculo conceitual entre si, e trabalhar no sentido de uma responsabilidade comum.

Implementando o Encapsulamento

Alguns elementos auxiliam na implementação do encapsulamento. A separação da interface de uma abstração da sua implementação, quando possível, é uma delas. O uso de ocultação da informação, também.

Algumas linguagens de programação possuem contruções para se definir apenas a interface de um objeto, seja por meio de código (inteface em Java), por meio de documentação (em linguagens dinâmicas, como Python), ou uma alternativa híbrida de documentação e código (como os templates em C++).

A ocultação de informação pode ser obtida através de construções das liguagens de programação que limitam o acesso a elementos dos objetos.

Diversas linguagens de programação implementam os conceitos de público e privado (public e private), onde os elementos são acessados a partir de outras classes ou objetos (public), ou apenas pelo próprio objeto, ou objetos da mesma classe (private). Algumas linguagens de programação ainda implementam o conceito de protegido (protected), onde apenas objetos ou classes da mesma hierarquia tem acesso aos elementos. Um quarto nível, acesso de módulo ou pacote (package) também pode existir, mas é mais raro de ser forçado por construções das linguagens.

Note que algumas linguagens de programação apresentam estes conceitos de ocultação da informação por convenção (ex.: Python), ou não forçam os conceitos, sendo que os mesmos servem apenas como documentação (ex.: Objective-C).

Outras dicas para a implementação correta do Encapsulamento:

  • Resolva o problema primeiro, encontre a abstração depois;
  • Revisite o problema e a implementação para melhorar a abstração, o acoplamento e a coesão;
  • Nunca coloque em uma classe mais do que o necessário para resolver o problema imediato;
  • Adicionar uma nova classe ou interface em um sistema é o equivalente a adicionar um novo tipo de dados ao sistema;
  • A interface pública de um objetos deve conter apenas métodos e constantes, e, mesmo assim, apenas os métodos que devem ser acessados externamente.

Herança

Herança é o mecanismo que permite a definição de uma nova classe a partir de uma classe já existente. Quando uma classe herda de outra classe, ela recebe todas as variáveis de instância e todos os métodos definidos naquela classe. Este tipo de herança de implementação também é conhecido por herança por subtipo (no desenvolvimento de linguagens é conhecido por subtyping), e se contrapõe à herança de interface (que no desenvolvimento de linguagens de programação é conhecido por inheritance).

Nota: Neste documento, será utilizada a palavra herança para identificar a herança de implementação (subtipo), por ser a forma como a linguagem Java trata o conceito de herança. O conceito de herança de interface em Java também existe, no entanto não é visto como "herança", e sim, "implementação".

Herança de Implementação

Quando ocorre a herança de implementação (por subtipo), é criada uma relação entre classes (e não entre objetos). Esta relação é uma relação do tipo É-UM, onde um objeto da classe que herda outra pode ser visto como um objeto da classe que foi herdada, pois ele, também, é um objeto da classe herdada.

Nesta relação entre classes existe uma classe base, conhecida como superclasse, que será herdada por uma classe derivada, conhecida como subclasse. A subclasse possui toda a implementação da superclasse, por isso seus objetos podem ser vistos como objetos da classe base. A vantagem de utilizar herança é que o comportamento padrão da classe base pode ser modificado na subclasse, e novos comportamentos podem ser adicionados.

Um dos problemas da herança é que os objetos de uma classe possuem conhecimento sobre outra classe, o que vai contra o que prega o encapsulamento, e, por isso, o uso de herança deve ser moderado, sendo restrito a momentos onde existe esta relação É-UM entre as classes.

Em diversas situações, é melhor projetar o sistema utilizando composição de objetos do que herança. Por exemplo, ao criar uma estrutura do tipo LIFO (Pilha ou Stack), que armazena dados em uma estrutura do tipo vetor, não se deve herdar da classe Vetor, pois uma Pilha tem uma relação de uso com objetos da classe Vetor (ela utiliza objetos vetor para armazenar objetos), mas não existe uma relação É-UM, uma vez que a Pilha não é um Vetor.

Nota: Este erro de conceito foi cometido, por exemplo, na biblioteca Java, onde a classe Stack herda da classe Vector. O resutlado é que o uso desta classe não é recomendado, devido aos problemas de comportamento que não são adequados a uma LIFO na classe Stack.

Herança de Interface

Outra forma de ver a herança é através da herança de interface, onde uma classe herda apenas a interface de outra classe, mas não herda a implementação. Neste tipo de herança a classe que herda a interface deve prover a implementação para os métodos da interface herdada. A herança de interface evita o problema da herança com relação ao encapsulamento, no entanto, não há reaproveitamento da implementação neste caso.

A relação criada entre as classes na herança de interface é uma relação entre classe (e não entre objetos), do tipo IMPLEMENTA-UM, uma vez que a classe que herda a interface irá fornecer a implementação para os métodos definidos pela interface. A herança de interface diminui o acoplamento entre as classes e objetos.

Mecanismo da Herança

Na herança de implementação, uma classe herda toda a implementação da outra classe, suas variáveis de instância e seus métodos, o que significa que toda a implementação da superclasse está disponível para a subclasse, e que todos os métodos públicos e atributos da superclasse são métodos públicos e atributos da subclasse.

Se uma classe herda todos os comportamentos e atributos de uma outra classe, então as classes são idênticas. Porém, a partir do mecanismo de herança é possível adicionar novos comportamentos e atributos, ou modificar os comportamentos existentes.

O processo de alteração do comportamento da superclasse na subclasse é conhecido com sobrescrita ou sobreposição de método, e permite que uma classe especialize um comportamento genérico da superclasse para o seu domínio específico (por isso a herança ser muitas vezes chamada de especialização).

Uma vez que um objeto de uma subclasse pode ser utilizado onde um objeto de uma superclasse é esperado, o sistema precisa decidir qual implementação de método irá utilizar. Na maioria das linguagens de programação orientadas a objetos, essa escolha é feita a partir do objeto que irá responder a mensagem (o objeto que executará o método), e é feita a partir da subclasse do objeto, subindo na hierarquia através da superclasse, até que encontre uma implementação para o método. Desta forma, a versão mais específica de um método é a que será executada.

Outra situação que ocorre na execução de métodos sobrepostos, ocorre quando uma subclasse sobrescreve um método da superclasse mas ainda deseja contar com a implementação da superclasse. Diversas linguagens de programação permitem acessar a implementação da superclasse através de alguma construção própria (muitos utilizam a palavra chave super). Em geral, o acesso é restrito a superclasse, entretanto em algumas linguagens é possível escolher um método da hierarquia. O processo de seleção do método na superclasse segue as mesmas regras de seleção de métodos em objetos.

Controle de Acesso na Herança

Ao herdar a implementação de uma classe, são herdados também as configurações de visibilidade dos membros da superclasse, logo, elementos públicos continuam públicos, e elementos privados não são acessíveis pela subclasse.

Diversas linguagens utilizam um terceiro nível de controle de visibilidade que permite que os elementos não sejam acessiveis publicamente, mas sejam acessados por qualquer subclasse. Esse membros são os membros protegidos (de protected).

Como cada classe deve manter a sua responsabilidade, ele deveria ser responsável por manter o seu estado interno, e por isso, todas as variáveis de instância devem ser privadas. Métodos com acesso externo devem ser públicos, e métodos internos devem ser privados.

Utilizamos membros (normalmente métodos, muito raramente variáveis de instância) protegidos quando projetamos a classe especificamente para a herança.

Implementando a Herança

Apesar de ser um dos pilares da orientação a objetos, a herança atua contra o encapsulamento, e traz uma série de problemas de design para o sistema, principalmente o aumento de sua complexidade, mas mesmo assim, quando utilizada corretamente, pode melhorar a qualidade do projeto, facilitando sua expansão e manutenção.

Para utilizar corretamente a herança da melhor forma possível, você deve sempre se perguntar se um objeto da subclasse É-UM objeto da superclasse. Se a resposta for afirmativa, você deverá utilizar a herança de implementação, caso contrário você deve utilizar a composição de objetos (quando o objeto usar o outro), ou a herança de interface, quando os objetos possuem a mesma interface, mas não são o mesmo tipo de objeto (ou seja, quando a relação é IMPLEMENTA-UM).

Outras dicas importantes no uso de herança:

  • Em geral, prefira a composição de objetos.
  • Não force a afirmativa É-UM, seja sempre cauteloso.
  • Como regra geral, não crie hierarquias profundas de classes. Elas, normalmente, só adicionam complexidade.
  • Projete cuidadosamenete a sua hierarquia de classes, utilizando classes abstratas (que possuem métodos abstratos), obrigando as subclasses a aderirem a um protocolo especificado.

Nota: Classes abstratas são classes que não podem ser instanciadas. Estas classes existem para definir mensagens comuns a uma hierarquia de classes, ou comportamentos padrão para os métodos. As classes que herdam dessas classes abstratas devem implementar todos os métodos abstratos (métodos não definidos) da superclasse abstrata, criando uma classe concreta que poderá ser utilizada para instanciar objetos.

  • Não se preocupe em definir uma hierarquia de classes no início do seu projeto. Semelhanças entre objetos e classes irão surgir, e nesse momento você pode refatorar o seu código, criando ou modificando a sua hierarquia de classes.
  • Se você adicionar métodos especificamente para as subclasses sobrescreverem, faça com que estes métodos sejam protegidos, para que apenas a subclasse possa vê-los.
  • Evite abrir a implementação interna de uma superclasse para as subclasses, pois isso pode gerar dependência da implementação, o que traz um acoplamento muito forte entre as duas classes (mais do que o exigido pela herança), e como visto anteriormente, esse tipo de acoplamento não é desejado.

Polimorfismo

O encapsulamento e a herança são dois pilares da Programação Orientada a Objetos, e são o que tornam possível e útil a existência do terceiro pilar, o Polimorfismo.

Polimorfismo significa muitas formas. Em termos de programacão, o Polimorfismo permite que um único nome de classe ou método represente um código diferente, selecionado por um mecanismo automático. Como um nome pode representar códigos diferentes, com comportamentos diferentes, temos as múltiplas formas que dão significado ao Polimorfismo.

Tipos de Polimorfismo

Existem três formas pelas quais uma linguagem de programação pode prover comportamentos polimórficos. Na orientação a objetos, o mais direto, é o polimorfismo do subtipo, obtido a partir da herança. Em várias linguagens orientadas a obejos, encontramos também o polimorfismo ad hoc, implementado sob a forma de sobrecarga de funções. O terceiro tipo de polimorfismo é encontrado em linguagens funcionais, ou linguagens que provêm alguma forma de programação genérica, e é o polimorfismo paramétrico, onde uma classe ou método é escrito de forma a aceitar diversos tipos de valores, desde que estes valores se adequem a algum protocolo requerido pela implementação.

O polimorfismo de subtipo (também conhecido como polimorfismo de inclusão) ocorre quando se sobrescreve um método de uma classe base, em uma classe derivada. Nesses casos, o método deve funcionar como esperado, tanto na classe derivada, quanto na classe base. Para garantir que esse comportamento seja correto, deve ser respeitado o [princípio de substituição de Lizkov]. Neste tipo de polimorfismo, o objeto que recebe a mensagem é responsável por selecionar o método sendo executado.

No polimorfismo ad hoc, o nome da mensagem é mantido, mas seus parâmetros são diferentes para cada implementação. Tanto o número de parâmetros como o tipo dos parâmetros podem variar neste tipo de polimorfismo. Em linguagens de programação, este comportamento é, normalmente conhecido como sobrecarga de método (ou função). Este é, por exemplo, o princípio pelo qual o operador de soma ("$+$") pode ser utilizado para soma de números ou concatenação de strings.

O polimorfismo paramétrico permite que um tipo de dado, ou método, ou função seja implementado de forma genérica, tratando valores de maneira uniforme, independente do tipo desses valores. É uma forma de dar mais expressividade a uma linguagem de programação, mantendo uma tipagem estática.