quarta-feira, 6 de outubro de 2010

Introdução à Orientação a Objetos em C++

Olá estudantes de programação!

Hoje vou explicar um pouco do que é o paradigma de Programação Orientada a Objetos (POO) usando a linguagem C++ como exemplo. Não vou ensinar a sintaxe de C++ pois aqui o foco é nos conceitos de POO, só vou explicar um pouco como se implementa alguns destes conceitos em C++ pois fica mais fácil de entender com exemplos. Não quero saber de enrolação neste artigo, então vamos logo ao que interessa. Pois então, vamos começar... do começo!


O que é a Programação Orientada a Objetos (POO)?


Aqui vai um pequeno resumo sobre o que é afinal a POO:

A Programação Orientada a Objetos (POO) é um dos principais paradigmas de programação. Vamos comparar com outro paradigma: a Programação Estruturada. No paradigma estruturado os programadores se ocupam basicamente em desenvolver funções. Funções, funções e mais funções. Todo programa escrito usando o paradigma estruturado possui dezenas, centenas, milhares de funções, que juntas compõem o sistema e o fazem funcionar. No paradigma estruturado, obviamente existem os dados necessários para implementar o sistema, mas os dados e as funções não estão intimamente ligados entre si. Na Programação Estruturada, em um documento de especificação (onde estão todos os requisitos e descreve o que o programa deve fazer) os verbos indicam possíveis funções a serem implementadas no sistema.

Já na POO, os programadores se ocupam basicamente em projetar e implementar classes. Encapsulados dentro das classes estão os atributos e os métodos. Os atributos em uma classe são quase o mesmo que as variáveis num programa estruturado, com a diferença que os atributos estão intimamente ligados aos métodos que os manipulam, sendo que os métodos são quase o mesmo que as funções. Na POO existe um maior controle sobre as partes componentes do sistema e das interações entre elas. A partir das classes é possível instanciar quantos objetos (instâncias) forem necessários para implementar o sistema, e estes objetos devem trabalhar juntos para que tudo funcione. Na POO, em um documento de especificação, os substantivos indicam possíveis classes a serem projetadas e implementadas no sistema.


Na POO, o que é uma classe?


Na POO, uma classe é uma espécie de molde ou modelo, de onde podemos criar (instanciar) tantos objetos quantos forem necessários. Uma classe contém todos os seus atributos e métodos, e cada objeto criado (instanciado) a partir desta classe, terá a sua própria cópia dos atributos. Não se preocupe se não entendeu ainda o que são atributos ou métodos, vou explicar mais adiante.

É possível entender melhor o que é uma classe fazendo uma analogia com uma fábrica. Imagine uma fábrica de calçados. Na fábrica existem as informações necessárias para fabricar calçados diversos. Desta única fábrica podem sair tantos calçados quantos forem necessários. Assim é uma classe, podemos instanciar quantos objetos quizermos de uma única classe. Considere que uma classe é uma fábrica de objetos. Você constrói a fábrica (implementa a classe) e fabrica (instancia) quantos objetos precisar.

É importante lembrar que ao definir uma classe, você está na realidade definindo um tipo de dados abstrato. Podemos pensar nas instâncias como variáveis do tipo da classe a qual pertencem.

Uma classe pode ser primitiva ou derivada. Uma classe primitiva é quando ela não é derivada de nenhuma outra; uma classe é derivada quando ela é criada a partir de outra classe existente através de um mecanismo chamado herança, formando assim o que denomina-se hierarquia de classes. Mais adiante eu explico essa história de classe primitiva e derivada, herança e hierarquia de classes.


O que é uma instância ou um objeto?


Uma instância é um objeto que criamos (instanciamos) a partir de alguma classe. Geralmente usa-se as palavras "objeto" e "instância" com o mesmo sentido, mas há uma leve diferença: quando falamos de "objeto", geralmente trata-se de um objeto qualquer de qualquer classe; já quando falamos de "instância" geralmente trata-se de um objeto em particular de uma classe em particular.

Os objetos são os componentes essenciais ao sistema programado com o paradigma orientado a objetos. Cada objeto contém a sua própria cópia dos atributos definidos numa classe. É importante lembrar que quando falo em "cópia", eu me refiro a uma cópia do atributo em si, não do valor dele. Se uma classe possui um atributo (não-estático) chamado "altura", todos as suas instâncias terão um atributo chamado "altura", mas o valor deste atributo é individual para cada instância. Ao alterar o valor deste atributo em uma instância, as outras não serão afetadas. Veja mais adiante o que é um atributo estático, que funciona diferente.

Através de um objeto podemos acessar todos os métodos públicos definidos na classe a qual ele pertence. Veja mais adiante o que são os atributos, métodos e as formas de acesso (público, privado ou protegido).


O que é um atributo?


Um atributo é um dado ou uma estrutura de dados que pertence a uma classe e a seus objetos, sendo que todo objeto instanciado a partir desta classe terá a sua própria cópia destes atributos (exceto os atributos estáticos, explico isso mais adiante).

Quando falo em "dados", eu me refiro a qualquer tipo de dados, inclusive referências a objetos de outras classes, e nesse caso temos o que chamamos de composição ou agregação. Eu explico mais adiante o que são referências, composição e agregação.


O que é um atributo estático?


Um atributo estático é um atributo que existe na classe independente de existirem objetos instanciados a partir dela, e todos as instâncias que acessarem atributos estáticos da classe a qual pertencem obterão o mesmo valor, uma vez que as instâncias não possuem uma cópia dos atributos estáticos da classe. As instâncias possuem cópias de todos os atributos não-estáticos e cada instância pode definir o valor da sua própria cópia, sem interferir nos outros objetos. Isto significa que ao alterar o valor de um atributo não-estático, apenas a instância manipulada será afetada. Já ao alterar o valor de um atributo estático, todas as instâncias serão afetadas.

Por exemplo, digamos que uma classe tenha um atributo estático chamado "quantidade". Ao alterar o valor deste atributo, todas as instâncias serão afetadas.

Pense desta forma: quando é um atributo não-estático, vai uma cópia individual para cada instância; quando é um atributo estático, não vai cópia nenhuma para nenhuma instância, o atributo é de classe e todas as instâncias têm acesso a esse mesmo atributo, não existem cópias distintas.

É importante lembrar que atributos estáticos podem ser acessados somente por métodos estáticos dentro da classe a qual pertencem.


O que é um método?


Um método é uma porção de código que executa alguma tarefa no sistema e pode ou não retornar alguma informação sobre o resultado da tarefa. Um método também pode receber argumentos, que são objetos ou referências a objetos. É como chamamos as funções na programação estruturada. Um método existe somente na classe, as instâncias apenas acessam os métodos públicos da classe a qual pertencem.


O que é um método estático?


Um método estático é um método que só pode acessar atributos estáticos da classe, e que além disso não precisam ser invocados através de referências, podem ser invocados em qualquer ponto do programa usando apenas o nome da classe. Para invocar métodos estáticos não é necessário que exista qualquer instância da classe.


O que é um método virtual em C++?


Em C++ um método virtual é útil quando temos uma hierarquia de classes e fazemos uso do polimorfismo. Mas isso é um conceito um tanto mais avançado, eu explico bem mais adiante.


O que são as formas de acesso: público, privado ou protegido?


Na POO, existe uma preocupação muito grande com o encapsulamento. Encapsulamento é como chamamos a capacidade das classes em "esconder" seus atributos e permitir a manipulação deles somente através dos métodos. Uma classe bem projetada deve esconder os detalhes da sua implementação - uma vez que eles não importam aos clientes da classe - e restringir o acesso aos dados de forma a evitar que eles sejam corrompidos, conseqüentemente levando a falhas no sistema. É justamente para permitir o encapsulamento que existem as formas de acesso público, privado e protegido.

Os atributos numa classe devem sempre ser privados. Atributos privados somente podem ser diretamente manipulados pela classe onde pertencem. Não é possível acessar de forma alguma um atributo privado através de uma instância ou referência, isso causa erros de compilação. Mas se os atributos são privados e não podem ser acessados de forma alguma pelas instâncias ou referências, então como manipular estes dados? Daí entram os métodos públicos.

Os métodos numa classe geralmente são públicos. Métodos públicos podem ser diretamente invocados pela classe onde pertencem e através de instâncias ou referências. A vantagem disso é que o método pode restringir o acesso aos atributos privados, evitando dados e valores inválidos, e os mantendo em um estado consistente, o que previne falhas no sistema.

Numa classe podem existir também métodos privados (ou auxiliares). Os métodos auxiliares de uma classe não podem ser invocados através de nenhuma instância ou referência, podem ser invocados somente dentro da classe onde pertence. Os métodos auxiliares, como o próprio nome indica, auxiliam a classe a construir os objetos ou a executar alguma tarefa que não interessa aos clientes da classe.

A forma protegida de acesso é útil apenas quando se tem uma hierarquia de classes. Veja mais adiante sobre herança, superclasses e subclasses para entender mais sobre este assunto e sobre a forma protegida de acesso.


O que é herança, superclasse e subclasse?


Na POO, uma classe primitiva chama-se superclasse ou classe-pai, e uma classe derivada chama-se subclasse, classe derivada ou classe-filha. A nomenclatura mais comum é "superclasse" e "subclasse".

O conjunto de uma superclasse com suas subclasses forma uma hierarquia de classes. A superclasse não se refere somente à classe no topo da hierarquia. Se uma subclasse é derivada de outra subclasse, então a primeira é uma superclasse também. Confundiu? Confira o diagrama de uma hierarquia de classes que eu desenhei especialmente para você:



Dizemos que uma subclasse "herda" todos os atributos públicos e protegidos, e todos os métodos públicos da superclasse, pois todos estes atributos e métodos passam a existir na subclasse. É importante saber que atributos privados não são herdados e não existem na subclasse. Então como ter atributos encapsulados na classe e ainda assim permitir a herança deles pelas subclasses? Devemos declarar estes atributos como protegidos. Desta forma, apenas a superclasse e suas subclasses, ou seja, apenas a hierarquia de classes terá acesso a estes atributos.

Usamos a herança quando temos uma classe genérica e queremos criar classes mais especializadas. Por exemplo, digamos que exista uma classe "veículo". Sabemos que existem muitos tipos de veículo. Existem carros, caminhões, aviões, motocicletas, bicicletas, entre diversos outros. Estes são formas específicas de veículos. Portanto podemos ter aí diversas subclasses: uma subclasse "carro", uma subclasse "caminhão", e assim por diante. E podemos ir mais além. Podemos estender a hierarquia para incluir diversos tipos de caminhão, tendo assim diversas outras subclasses partindo da subclasse "caminhão", e por aí vai. Isso é uma hierarquia de classes, partindo sempre do mais genérico ao mais específico.

Quer outro exemplo? Imagine uma superclasse "animal". Existem milhares de tipos de animais. Podemos ter então subclasses "mamífero", "roedor" e "ave", entre outras. Partindo daí, podemos estender mais ainda. De "mamífero", podemos ter "cão", "gato" e "porco"; de "roedor" podemos ter "rato" e "paca"; de "ave" podemos ter "águia", "pombo" e "corvo". E assim por diante.


O que é uma referência ou ponteiro?


Uma referência ou ponteiro é um conceito mais complicado de entender no início. Um dos maiores pesadelos dos estudantes de C e C++ são os temidos ponteiros. Vou tentar explicar.

Em C++, um ponteiro é um tipo de variável que pode conter um endereço de memória. Pra que eu preciso disso, você vai perguntar. Bom, quando você quer instanciar um objeto a partir de uma classe, você precisa ter uma referência para poder manipular este objeto. Em C++ nós instanciamos objetos usando um operador chamado "new". O operador "new" primeiro verifica se há memória suficiente disponível, e se houver, aloca a quantidade de memória necessária para armazenar as cópias dos atributos da nova instância, e a seguir, retorna o endereço inicial deste bloco de memória onde este objeto foi armazenado. Portanto, ao ver uma instrução como a seguinte:


Caixa* minha_caixa = new Caixa;


O que acontece aí é que, "minha_caixa" é um ponteiro do tipo Caixa. Ao ser executada esta instrução, o operador "new" faz todo o processo descrito anteriormente, e retorna o endereço do novo objeto, sendo que este endereço fica armazenado no ponteiro "minha_caixa". A partir daí, é possível manipular aquele objeto através deste ponteiro, assim por exemplo:


minha_caixa->abrir();


Nesta instrução, estamos invocando o método abrir() que está definido na classe Caixa. Difícil? Essa parte é mais complicada se você ainda não está acostumado com a sintaxe de C++, mas isso se resolve com tempo e paciência.

Enfim, um ponteiro é apenas um tipo de variável que pode armazenar o endereço de memória de algum objeto, para que você possa manipulá-lo.


O que é composição e agregação?


Composição e agregação são formas de relacionamento e interação entre objetos de um sistema orientado a objeto. Estes termos referem-se à capacidade de um objeto ser formado de diversos outros objetos. Em C++ nós implementamos composição e agregação usando ponteiros. Veja um exemplo:

Digamos que exista a classe "Computador", a classe "Monitor", a classe "Teclado" e a classe "Mouse". Estas classes não estão na mesma hierarquia, ou seja, um "Computador" não é um "Teclado" e um "Teclado" não é um "Mouse". Mas todos sabemos que um "Computador" geralmente precisa de um "Monitor", de um "Teclado" e de um "Mouse" para ser útil a alguém. Por isso, nós usamos a agregação: ligamos o "Monitor", o "Teclado" e o "Mouse" no "Computador".

Em C++, nós poderíamos implementar esta idéia da seguinte forma: a classe "Computador" terá que agregar "Teclado", "Monitor" e "Mouse". Veja no exemplo:


class Computador
{
// Ponteiros para os objetos agregados.
// São apenas ponteiros, não são objetos!

Monitor* monitor = NULL;
Teclado* teclado = NULL;
Mouse* mouse = NULL;

// Construtor da classe

Computador()
{
// Instanciando os componentes. Aqui os objetos
// são alocados, e os ponteiros então apontam para eles!

monitor = new Monitor;
teclado = new Teclado;
mouse = new Mouse;
}
};


No exemplo acima, nós criamos a classe "Computador" e incluímos ponteiros para objetos do tipo "Monitor", "Teclado" e "Mouse". Repare também que eu incluí no exemplo o construtor da classe, onde são instanciados os respectivos objetos.

Repare também numa boa prática de programação. Ao atribuir NULL aos ponteiros, nós garantimos que eles não contém lixo, isto é, que eles não contém nenhum valor indefinido. Assim evitamos o infame bug do "ponteiro selvagem", que costuma causar falhas de segmentação no sistema operacional. Este bug acontece porque ao declarar um ponteiro em C++, não se sabe qual é o valor inicial dele, pode ser qualquer valor. NULL é uma constante geralmente definida em "stdlib.h" e contém o valor 0 (zero). Atribuir NULL a um ponteiro indica que o ponteiro inicialmente não está apontando para objeto nenhum, e não corremos o risco de acessar memória alheia - memória que não foi alocada para o nosso programa, e que pode estar sendo usada por algum outro programa qualquer.

Você deve ter se perguntado qual a diferença entre composição e agregação. Bom, a diferença nem sempre é tão clara. Nós dizemos que há agregação quando os objetos existem e são úteis mesmo quando utilizados independentemente; e dizemos que há composição quando os objetos não podem existir separados, ou seja, um objeto é realmente composto de outros objetos e ao destruir um deles, todos os outros devem ser destruídos também. Na agregação, ao destruir um dos objetos agregados, o objeto agregador continua existindo. Em outras palavras: na agregação, todos os objetos agregados existem e são úteis independentemente do agregador; na composição, os objetos componentes não têm nenhuma utilidade sozinhos, eles existem exclusivamente em função de um objeto maior.

Considere novamente o exemplo anterior. Na vida real existe agregação entre os objetos "Computador", "Monitor", "Teclado" e "Mouse", pois ao destruir qualquer um deles, os outros geralmente continuam intactos e funcionando, apesar de fazerem falta. Já no caso de um "Relógio de Pulso", poderíamos ter composição, tendo como objetos componentes uma "Pulseira" e os "Ponteiros". Ao destruir o "Relógio de Pulso", os "Ponteiros" e a "Pulseira" geralmente também são destruídos.

Vamos agora considerar um exemplo de um sistema computadorizado.

Imagine uma GUI (interface gráfica) que é parte de um sistema de folha de pagamento. Esta GUI é composta por vários botões, caixas de texto, rótulos, etc. Estes objetos são componentes da GUI, eles não têm utilidade se estiverem "soltos", e ao destruir a GUI, todos os componentes dentro dela são destruídos também, por isto temos aí uma composição: os botões, caixas de texto e rótulos existem apenas em função de um objeto maior, que é a GUI.

Tudo isso foi apenas uma analogia com objetos da vida real, pois é assim que devemos pensar quando projetamos um sistema orientado a objeto. Essa é a vantagem da POO: a abstração.


O que é polimorfismo?


O polimorfismo, junto com o mecanismo de herança, é uma das maiores vantagens da POO. Este é um conceito um tanto avançado, mas que é de suma importância para quem quer realmente tirar o máximo proveito deste paradigma de programação.

A melhor forma de explicar este conceito é na prática e com exemplos. O polimorfismo na prática, em C++, é a capacidade de um ponteiro do tipo de uma superclasse apontar para uma instância de uma das subclasses desta. Assim, somente é possível invocar métodos que estiverem disponíveis tanto na superclasse como na subclasse.

Por exemplo, digamos que exista uma superclasse "FormaGeometrica" que representa formas geométricas. Daí, digamos que exista também algumas subclasses a partir de "FormaGeometrica" representando formas específicas, como "Retangulo", "Triangulo" e "Circulo". Temos aqui então uma hierarquia de classes. Uma vez que "Retangulo", "Triangulo" e "Circulo" são subclasses de "FormaGeometrica", faz sentido se fizermos o seguinte:


FormaGeometrica* minha_forma = new Retangulo;
FormaGeometrica* minha_outra_forma = new Triangulo;
FormaGeometrica* minha_forma_querida = new Circulo;


Entendeu? Um ponteiro do tipo de uma superclasse pode "apontar" para instâncias de subclasses desta. Essa capacidade é chamada de polimorfismo.

Agora imagine que a superclasse "FormaGeometrica" defina o método virtual desenhar(), sem implementá-lo. As subclasses "Retangulo", "Triangulo" e "Circulo" obviamente herdam este método, mas como originalmente ele não tem implementação na superclasse, devemos sobrescrevê-lo nas subclasses, implementando o método desenhar() da forma adequada para cada subclasse, isto é, cada subclasse vai implementar o método desenhar() para desenhar a forma correspondente. Fazendo isso, ao invocar o método desenhar() através de um ponteiro do tipo "FormaGeometrica", o sistema executará o método sobrescrito do objeto de subclasse ao qual o ponteiro se refere no momento da chamada. Complicado de entender? Veja no exemplo do código:


class FormaGeometrica
{
public:
// Sem implementacao!
virtual void desenhar();
};

class Retangulo : public FormaGeometrica
{
public:
void desenhar() { /* Codigo que desenha um retangulo */ }
};

class Triangulo : public FormaGeometrica
{
public:
void desenhar() { /* Codigo que desenha um triangulo */ }
};

class Circulo : public FormaGeometrica
{
public:
void desenhar() { /* Codigo que desenha um circulo */ }
};


No exemplo acima, definimos a hierarquia de classes citada anteriormente. Repare que declaramos o método como virtual somente na superclasse, pois nas subclasses isto já fica implícito, não há necessidade de declarar virtual nas subclasses.

Muito importante: Caso você não declare o método como virtual na superclasse, ao invocar este método através de um ponteiro desta superclasse que se refere a uma instância de subclasse, o método invocado será o da superclasse, e isso não é o resultado esperado! Queremos que ao invocar o método através do ponteiro, o método invocado seja aquele do objeto referenciado pelo ponteiro, e isso só se consegue declarando o método em questão como sendo virtual!

Agora poderíamos fazer o seguinte:


// Declara um ponteiro do tipo da superclasse
// e evita o bug do ponteiro selvagem
FormaGeometrica* minha_linda_forma = NULL;

// Instancia um objeto do tipo da subclasse
minha_linda_forma = new Retangulo;

// Desenha um retangulo
minha_linda_forma->desenhar();

// Instancia um objeto do tipo da subclasse
minha_linda_forma = new Triangulo;

// Desenha um triangulo
minha_linda_forma->desenhar();

// Instancia um objeto do tipo da subclasse
minha_linda_forma = new Circulo;

// Desenha um circulo
minha_linda_forma->desenhar();


Entendeu? Dependendo do tipo de objeto ao qual o ponteiro se refere, o método invocado será diferente! Isto é polimorfismo.



Espero que tenham gostado da aula, hehehehe...
Se tiverem dúvidas, deixe comentário que eu vou incrementando o artigo.

E vamos programar, com C++ na veia!