O paradigma orientado a objetos é um dos paradigmas mais utilizados no mercado de trabalho. Além de ser um dos primeiros paradigmas com o qual nós temos contato quando começamos a estudar desenvolvimento de software, a maioria das linguagens utilzadas pela indústria em geral possui uma forte base orientada a objetos, o que faz com que seja essencial o domínio deste paradigma. Neste artigo, vamos verificar quais são os pontos principais do paradigma orientado a objetos.
Caso você esteja iniciando na área de desenvolvimento, indico também a leitura de nosso guia de introdução à programação.
Curso Python - Fundamentos
Conhecer o cursoClasses e objetos
Em linguagens orientadas a objeto, nós organizamos a maior parte do nosso código em estruturas chamadas classes.
Você pode entender uma classe como sendo inicialmente um molde, molde este que geralmente representa alguma estrutura do mundo real com a qual nosso código terá que lidar. Vamos imaginar que estejamos trabalhando com uma aplicação que lida com carros…
Provavelmente, nossa aplicação irá lidar com carros e, por causa disso, precisaremos de um molde para definirmos o que é um carro dentro do nosso código. Esse molde seria responsável por estabelecer o que é um carro e o que pode fazer um carro.
Se pararmos para pensar, nós podemos falar que um carro pode ser caracterizado pelos seguintes itens:
- Marca;
- Modelo;
- Cor;
- Placa;
- E várias outras características…
Também podemos dizer que um carro pode ter as seguintes ações:
- Ligar;
- Acelerar;
- Desligar;
- E várias outras ações…
Veja que, quando falamos do que um carro é, estamos falando de suas características, ou seja: falamos do que caracteriza e define um determinado carro. Já quando falamos do que um carro pode fazer, estamos falando das ações que um carro pode desempenhar.
Trazendo para termos técnicos, nós podemos chamar as características do carro de atributos, euquanto nós chamamos as ações de métodos. No final, os métodos e atributos ficam agrupados em uma classe.
Nós podemos representar estes atributos e métodos através de uma linguagem de modelagem chamada UML (Unified Modeling Language). A UML prevê alguns diagramas que visam auxiliar o processo de modelagem de um software. Entre estes diagramas, nós temos justamente o diagrama de classes.
A classe Carro, se fosse representada pelo diagrama de classes UML, poderia ficar da seguinte maneira:
Perceba que a partir deste molde, nós podemos especificar vários carros… Poderíamos ter um Fiat Línea prata com a placa ABC-1234, um Volswagen Gol preto com a placa DEF-4567 ou mesmo um Hyundai HB20 branco com a placa GHI-8901… Todos eles são carros, já que derivam do mesmo molde: todos eles têm marca, modelo, cor e placa, além de poderem ser ligados, desligados, freados e acelerados, atributos e métodos todos estabelecidos pela classe Carro. Nesse caso, o Fiat Línea, o Volkswagen Gol e o Hyundai HB20 são objetos, pois foram “fabricados” a partir do molde que definimos, que é a classe Carro. Objetos são como variáveis que criamos para utilizar as nossas classes, seja para definir seus atributos, como também para invocar seus métodos. É o encadeamento coordenado entre escritas e leituras de atributos com a invocação de métodos que dá a tônica de uma aplicação escrita com uma linguagem orientada a objetos.
Se fôssemos utilizar o Java para representar nossa classe e nossos objetos de uma maneira primitiva, teríamos o código abaixo.
// Pacotes e demais estruturas omitidas para clareza...
public class Carro {
public String modelo;
public String marca;
public String cor;
public String placa;
public void ligar() {
System.out.println("O veículo ligou!");
}
public void desligar() {
System.out.println("O veículo desligou!");
}
}
// ...
Carro gol = new Carro();
gol.modelo = "Gol";
gol.marca = "Volkswagen";
Carro linea = new Carro();
linea.modelo = "Línea";
linea.marca = "Volkswagen";
gol.ligar();
gol.desligar();
linea.ligar();
linea.desligar();
No exemplo acima, temos a classe Carro
fazendo o papel de nosso molde. As variáveis gol
e linea
são objetos que são do tipo Carro
ou, utilizando os termos técnicos corretos, gol
e linea
são instâncias da classe Carro
. Por derivarem da classe Carro
, estes objetos têm todos os atributos e métodos previstos pela classe Carro
.
Encapsulamento
Ainda levando em consideração o exemplo com a classe Carro, poderíamos imaginar um indicador para verificarmos se o carro está ligado ou não. Isso até daria mais “qualidade” à nossa classe Carro: nós poderíamos, por exemplo, garantir que o carro só pudesse ser acelerado ou freado caso estivesse ligado. Esse indicador ainda poderia ser modificado pelos métodos ligar()
e desligar()
…
Partindo dessa idéia, nossa classe Carro
ficaria da seguinte maneira:
public class Carro {
public String modelo;
public String marca;
// ...
public boolean ligado;
public Carro() {
ligado = false;
}
public void ligar() {
ligado = true;
System.out.println("O veículo ligou!");
}
public void desligar() {
ligado = false;
System.out.println("O veículo desligou!");
}
}
Veja que, além de adicionarmos um atributo chamado ligado
do tipo booleano (true
para ligado ou false
para desligado), nós temos ainda o trecho de código abaixo:
public Carro() {
ligado = false;
}
Esse trecho de código é um construtor. O construtor é invocado quando inicializamos um objeto a partir de uma classe. Nós, de maneira geral, invocamos o construtor quando chamamos a criação da instância com a palavra-chave new
. Sendo assim, quando temos o código abaixo…
Carro gol = new Carro();
… nós estamos justamente chamando este método construtor. Ao reescrevermos este método, estamos impondo uma “personalização” na inicialização dos objetos a partir da classe Carro
: todo carro será criado já possuindo o indicador ligado
como false
, ou seja, o carro já começa como desligado por padrão.
Agora, poderíamos definir, por exemplo, um método chamado acelerar()
em nosso Carro
. Nós podemos verificar o atributo ligado
para “controlar” melhor este método: um carro, obviamente, só pode ser acelerado caso esteja ligado.
Nosso código de exemplo ficaria da seguinte maneira:
public class Carro {
public String modelo;
public String marca;
// ...
public boolean ligado;
public Carro() {
ligado = false;
}
public void ligar() {
ligado = true;
System.out.println("O veículo ligou!");
}
public void desligar() {
ligado = false;
System.out.println("O veículo desligou!");
}
public void acelerar() {
if (!ligado){
throw new Exception("O carro não pode ser acelerado, pois ele está desligado."); // Erro: o carro está desligado!
}
System.out.println("O carro foi acelerado");
}
}
Assim, poderíamos ter a nossa classe Carro
sendo utilizada da seguinte maneira:
Carro gol = new Carro();
System.out.println(gol.ligado); // Vai imprimir "false" por causa do construtor personalizado
gol.modelo = "Gol";
gol.marca = "Volkswagen";
// gol.acelerar(); Se essa linha for descomentada, o código gerará um erro, pois estamos tentando acelerar um carro desligado
gol.ligar();
System.out.println(gol.ligado); // Vai imprimir "true", pois o método ligar() foi chamado
gol.acelerar();
gol.desligar();
System.out.println(gol.ligado); // Vai imprimir "false", pois o método desligar() foi chamado
Aparentemente, nossa classe Carro
está funcionando corretamente. Mas, temos um problema: o atributo ligado
é acessível para todo mundo, da mesma maneira que os atributos modelo
e marca
por exemplo. Isso quer dizer que nós podemos “deturpar” o comportamento da classe Carro
… Nós poderíamos, por exemplo, alterar manualmente o conteúdo do atributo ligado
antes de chamarmos o método ligar()
, permitindo acelerar um carro que estivesse em tese desligado:
Carro gol = new Carro();
System.out.println(gol.ligado); // Vai imprimir "false" por causa do construtor personalizado
gol.ligado = true; // Atributo que define se o carro está ligado ou não alterado "na mão"
gol.acelerar(); // Agora a linha não causará erro, mesmo que o método ligar() não tenha sido chamado
Isso certamente é uma situação problemática, pois agora nosso carro dá uma brecha para funcionar de maneira diferente de como ele foi planejado.
Para corrigirmos isso, precisamos recorrer a um pilar da orientação a objetos: o encapsulamento. O encapsulamento visa esconder atributos e métodos de nossas classes que não deveriam ser acessados por outras estruturas. É exatamente o que precisamos: o atributo ligado
deveria ser acessível em tese só pela própria classe Carro
, o que permitiria somente aos métodos ligar()
e desligar()
alterarem o indicador de funcionamento do carro da maneira correta. Isso evitaria que nós acessássemos o atributo do lado de fora, causando a falha no código que estamos discutindo aqui.
O encapsulamento nas linguagens orientadas a objetos é definido através de algo que chamamos de atributo de visibilidade. Estes atributos de visibilidade estabelecem justamente o quão acessível nossos atributos e métodos são com relação às demais estruturas do nosos código. De maneira geral, temos três atributos de visibilidade básicos e comuns às linguagens orientadas a objeto em geral:
-
public
: a estrutura é visível a partir de qualquer lugar no código, inclusive em outras classes fora a classe que define o atributo ou método em si; -
private
: a estrutura é visível somente pela classe que define a estrutura em si. Estruturas externas, como outras classes, não conseguem acessar o método ou atributo que esteja marcado com este atributo de visibilidade; -
protected
: a estrutura é visível somente na classe-mãe e nas classes-filhas.
Se considerarmos a nossa classe Carro
, podemos ver que o problema relatado acontece porque o atributo ligado
está definido como public
, o tornando acessível em qualquer lugar. Vimos que esse é o problema, pois o atributo ligado
não poderia ser acessível a partir de qualquer lugar: ele deveria ser acessível somente dentro dos métodos ligar()
e desligar()
, ambos dentro da classe Carro
. O nosso atributo ligado
não está encapsulado.
Curso Wordpress - Criação de Temas
Conhecer o cursoPoderíamos o encapsular se o tornássemos private
, fazendo com que ele fosse acessível somente dentro da classe Carro
.
public class Carro {
public String modelo;
public String marca;
// ...
private boolean ligado;
public Carro() {
ligado = false;
}
public void ligar() {
ligado = true;
System.out.println("O veículo ligou!");
}
public void desligar() {
ligado = false;
System.out.println("O veículo desligou!");
}
public void acelerar() {
if (!ligado){
throw new Exception("O carro não pode ser acelerado, pois ele está desligado."); // Erro: o carro está desligado!
}
System.out.println("O carro foi acelerado");
}
}
Assim, o erro que víamos antes não acontecerá mais, pois o atributo ligado
agora só é acessível dentro da própria classe Carro
.
Carro gol = new Carro();
gol.ligado = true; // Essa linha causará um erro de compilação, pois o atributo "ligado" não é mais acessível externamente
Agora, podemos dizer que o atributo ligado
da classe Carro
está encapsulado.
Se quiséssemos pelo menos ler o valor do atributo ligado
externamente (já que alterá-lo externamente estaria completamente errado), poderíamos criar um método que devolvesse o valor do atributo ligado
.
public class Carro {
public String modelo;
public String marca;
// ...
public boolean ligado;
public Carro() {
ligado = false;
}
public void ligar() {
ligado = true;
System.out.println("O veículo ligou!");
}
public void desligar() {
ligado = false;
System.out.println("O veículo desligou!");
}
public void acelerar() {
if (!ligado){
throw new Exception("O carro não pode ser acelerado, pois ele está desligado."); // Erro: o carro está desligado!
}
System.out.println("O carro foi acelerado");
}
public boolean estaLigado() {
return ligado;
}
}
Com o método acima, poderíamos pelo menos verificar externamente se o carro está ligado ou desligado.
Carro gol = new Carro();
System.out.println(gol.estaLigado()); // Vai imprimir "false" por causa do construtor personalizado
gol.modelo = "Gol";
gol.marca = "Volkswagen";
gol.ligar();
System.out.println(gol.estaLigado()); // Vai imprimir "true", pois o método ligar() foi chamado
gol.acelerar();
gol.desligar();
System.out.println(gol.estaLigado()); // Vai imprimir "false", pois o método desligar() foi chamado
Este tipo de método é geralmente chamado de método de acesso, já que ele provê um tipo de acesso indireto a um atributo encapsulado. Com relação ao encapsulamento, nós temos dois tipos de métodos de acesso basicamente:
-
get
: métodos que permitem ver o valor de um atributo; -
set
: métodos que permitem alterar o valor de um atributo.
Nós poderíamos falar que o método estaLigado()
é um método get
, pois ele permite a nós lermos algo que está encapsulado dentro da classe Carro
.
É uma prática recorrente em linguagens orientadas a objeto (principalmente no Java) envolver todos os atributos com métodos de acesso do tipo get
e set
, evitando o acesso direto aos atributos. Apesar de ser uma prática comum, é importante dizer que só o fato de utilizarmos métodos de acesso não garante o encapsulamento de nenhuma estrutura. O que garante este encapsulamento é a definição de visibilidade correta de cada um dos atributos e métodos e o estabelecimento dos métodos de acesso de maneira correta. Por exemplo: não criamos um método set
para o atributo ligado
, pois ele só pode ser alterado pela própria classe.
Se fôssemos aplicar esta regra a nossa classe Carro
, nós teríamos o seguinte código:
public class Carro {
private String modelo;
private String marca;
// ...
public boolean ligado;
public void setModelo(String modelo) {
this.modelo = modelo;
}
public String getModelo() {
return modelo;
}
public void setMarca(String marca) {
this.marca = marca;
}
public String getMarca() {
return marca;
}
public Carro() {
ligado = false;
}
public void ligar() {
ligado = true;
System.out.println("O veículo ligou!");
}
public void desligar() {
ligado = false;
System.out.println("O veículo desligou!");
}
public void acelerar() {
if (!ligado){
throw new Exception("O carro não pode ser acelerado, pois ele está desligado."); // Erro: o carro está desligado!
}
System.out.println("O carro foi acelerado");
}
public boolean estaLigado() {
return ligado;
}
}
Herança
O reaproveitamento de código e a possibilidade de se evitar código duplicado são objetivos das linguagens orientadas a objetos. Vamos imaginar que agora nós precisamos criar uma classe para definir um outro tipo de veículo, como uma bicicleta. Uma bicicleta possui atributos em comum com um carro: ambos possuem marca e modelo, por exemplo. Além disso, ambos não deixam de ser um tipo de veículo.
Se fôssemos escrever o código para definição da classe Bicicleta
sem considerar a classe Carro
, nós teríamos o seguinte código:
public class Bicicleta {
private String modelo;
private String marca;
public void setModelo(String modelo) {
this.modelo = modelo;
}
public String getModelo() {
return modelo;
}
public void setMarca(String marca) {
this.marca = marca;
}
public String getMarca() {
return marca;
}
public void acelerar() {
System.out.println("A bicicleta foi acelerada.");
}
}
Veja que acabamos duplicando todo o código relativo aos atributos marca
e modelo
nas classes Carro
e Bicicleta
, o que pode ser bem ruim em termos de manutenibilidade do código a longo prazo. Mas, nós temos uma maneira de evitar essa duplicidade: nós podemos utilizar o conceito de herança.
Nesse exemplo, se fôssemos aplicar o conceito de herança, nós teríamos três classes:
- A classe
Carro
, com tudo que um carro possui de características e ações; - A classe
Bicicleta
, com tudo que uma bicicleta possui de características e ações; - Uma nova classe chamada
Veiculo
, com tudo que existe de comum entre carros e bicicletas.
No exemplo acima, para que as classes Carro
e Bicicleta
conseguissem usufruir das estruturas comuns estabelecidas na classe Veiculo
, elas precisariam herdar a classe Veiculo
.
Poderíamos representar esta relação entre as classes Veiculo
, Carro
e Bicicleta
com a UML da seguinte maneira:
Também poderíamos definir as classes Veiculo
, Carro
e Bicicleta
da seguinte maneira:
public class Veiculo {
private String modelo;
private String marca;
public void setModelo(String modelo) {
this.modelo = modelo;
}
public String getModelo() {
return modelo;
}
public void setMarca(String marca) {
this.marca = marca;
}
public String getMarca() {
return marca;
}
public void acelerar() {
// ???
}
}
public class Carro extends Veiculo {
public boolean ligado;
public Carro() {
ligado = false;
}
public void ligar() {
ligado = true;
System.out.println("O veículo ligou!");
}
public void desligar() {
ligado = false;
System.out.println("O veículo desligou!");
}
public void acelerar() {
if (!ligado){
throw new Exception("O carro não pode ser acelerado, pois ele está desligado."); // Erro: o carro está desligado!
}
System.out.println("O carro foi acelerado");
}
public boolean estaLigado() {
return ligado;
}
}
public class Bicicleta extends Veiculo {
public void acelerar() {
System.out.println("A bicicleta acelerou!");
}
}
Veja que tudo que é comum entre as classes Carro
e Bicicleta
foi para a classe Veiculo
. As classes Carro
e Bicicleta
estão agora herdando a classe Veiculo
para reaproveitar estas estruturas comuns, além de que um carro e uma bicicleta são tipos de veículos. É importante que, quando formos empregar a herança, exista essa relação de “ser” ou “estar” entre as classes.
Nesse caso, nós podemos fazer com que Carro
herde Veiculo
porque um carro é um veículo; assim como também podemos fazer com que Bicicleta
herde Veiculo
, pois uma bicicleta é um tipo de veículo. Com a herança, nós evitamos a duplicidade de código e facilitamos a manutenção, além de manter a coerência, desde que a regra do “ser/estar” seja devidamente implementada.
Quando temos a herança sendo utilizada, as classes podem assumir um dos dois papéis:
- Classe-mãe, classe-base ou super-classe: é a classe que serve de base para as demais classes. Em nosso exemplo, a classe
Veiculo
é uma super-classe; - Classe-filha ou sub-classe: é a classe que herda outra determinada classe. No nosso exemplo, as classes
Carro
eBicicleta
são sub-classes.
Abstração
Quando estamos lidando com a orientação a objetos, é muito comum que nós sempre tentemos escrever código baseado em abstrações, pois isso traz flexibilidade ao código.
No exemplo anterior, nós esbarramos em um problema de abstração: todo veículo é capaz de acelerar, independente de ser um carro ou bicicleta. Sendo assim, nós não podemos remover o método acelerar()
da nossa classe Veiculo
, já que todo veículo tem esse comportamento. Mas, a própria classe Veiculo
não “sabe” como acelerar.
Quem sabe como acelerar é a classe Carro
(que sabe como um carro acelera) e a classe Bicicleta
(que sabe como uma bicicleta acelera). Para resolver esta situação, poderíamos tornar o método acelerar()
abstrato: isso vai desobrigar a classe Veiculo
a definir uma implementação para este método (já que a classe Veiculo
não sabe como acelerar), mas obriga as classes-filha (no caso, as classes Carro
e Bicicleta
) a definirem seus comportamentos de aceleração.
Além disso, se pararmos para analisar, não faz sentido nós instanciarmos objetos a partir da classe Veiculo
, já que ela serve somente como uma super-classe em nosso cenário. Nós poderíamos instanciar objetos a partir das classes Carro
e Bicicleta
, mas não a partir da classe Veiculo
. Como queremos evitar que objetos sejam instanciados a partir da classe Veiculo
, já que ela deve ser somente uma classe-base, também podemos definir a classe Veiculo
como sendo uma classe abstrata.
O código ficaria da seguinte maneira:
// Classe abstrata (não pode ser instanciada)
public abstract class Veiculo {
private String modelo;
private String marca;
public void setModelo(String modelo) {
this.modelo = modelo;
}
public String getModelo() {
return modelo;
}
public void setMarca(String marca) {
this.marca = marca;
}
public String getMarca() {
return marca;
}
// Método abstrato: a classe Veiculo sabe que ela tem que acelerar, mas não sabe como fazer isso.
// A responsabilidade passa a ser das classes-filha
public abstract void acelerar();
}
public class Carro extends Veiculo {
public boolean ligado;
public Carro() {
ligado = false;
}
public void ligar() {
ligado = true;
System.out.println("O veículo ligou!");
}
public void desligar() {
ligado = false;
System.out.println("O veículo desligou!");
}
// Aqui, a classe Carro define como ela deve exercer o ato de acelerar
@Override
public void acelerar() {
if (!ligado){
throw new Exception("O carro não pode ser acelerado, pois ele está desligado."); // Erro: o carro está desligado!
}
System.out.println("O carro foi acelerado");
}
public boolean estaLigado() {
return ligado;
}
}
public class Bicicleta extends Veiculo {
// Aqui, a classe Bicicleta define como ela deve exercer o ato de acelerar
@Override
public void acelerar() {
System.out.println("A bicicleta acelerou!");
}
}
Com a utilização da abstração nesse caso, sabemos que qualquer novo tipo de veículo que for criado a partir da classe Veiculo
terá que obrigatoriamente implementar o comportamento de aceleração. Isso faz muito sentido, já que todo veículo tem que ser capaz de acelerar.
Polimorfismo
Linguagens orientadas a objetos ainda prevêem o suporte para criação de estruturas polimórficas. Estruturas polimórficas são estruturas que conseguem mudar seu comportamento interno em determinadas circunstâncias. Essa variação comportamental pode acontecer por algumas formas, como através da sobescrita de métodos e através do LSP (Liskov Substitution Principle).
No exemplo anterior, nós temos um exemplo de polimorfismo através da sobrescrita de métodos: nós temos a classe Veiculo
, que possui o método abstrato acelerar()
. O método é abstrato porque a classe Veiculo
só sabe que tem que conter este comportamento, mas não sabe como esse comportamento deve ocorrer.
Mas, as classes Carro
e Bicicleta
foram obrigadas a implementar o método acelerar()
por herdarem a classe Veiculo
. Cada uma dessas classes implementou o método acelerar()
da maneira mais adequada para cada um dos tipos de veículos. Aqui, já temos um exemplo de polimorfismo: as classes Carro
e Bicicleta
são polimórficas, pois possuem um ancestral comum (a classe Veiculo
) que as obriga a implementar o método acelerar()
, mas cada uma delas implementa o mesmo método da maneira mais correta para cada tipo de veículo.
Essa mudança de implementação não exigiu a mudança do código da classe Veiculo
, além de que a implementação dos métodos acelerar()
em cada uma das classes é isolada: a implementação do método acelerar()
na classe Carro
não afeta a implementação do mesmo método na classe Bicicleta
, e vice-versa.
Outro exemplo de polimorfismo seria através da aplicação do Princípio da Substituição de Liskov (também conhecido como LSP - Liskov Substitution Principle). O LSP é parte de um conjunto de cinco práticas de codificação conhecido como SOLID. Estes princípios visam a produção de código com alta qualidade e alinhado com os princípios das linguagens orientadas a objeto.
Para entender o LSP, considere o código abaixo:
Carro veiculo = new Carro();
// ...
veiculo.acelerar(); // Vai escrever "O carro foi acelerado"
// ...
O código não aparenta nada de diferente: temos um objeto chamado veiculo
do tipo Carro
. Mas, pelo fato de Carro
herdar a classe Veiculo
, nós podemos deduzir que um objeto do tipo Carro
também pode ser considerado como sendo do tipo Veiculo
; afinal, todo Carro
agora é também um Veiculo
por causa da herança. Sendo assim, podemos escrever o código acima da seguinte maneira:
Veiculo veiculo = new Carro();
// ...
veiculo.acelerar(); // Vai escrever "O carro foi acelerado"
// ...
Nós podemos definir o objeto veiculo
como sendo do tipo Veiculo
, mas o criar com base em um Carro
. Nesse momento, o objeto veiculo
vai se comportar como um Carro
. No caso acima, dizemos que Veiculo
é a abstração, enquanto Carro
é a concretização.
Se quiséssemos trocar o tipo do nosso veículo para uma bicicleta, bastaria trocar a concretização.
Todo o código abaixo continuaria funcionando normalmente, já que uma Bicicleta
também é um Veiculo
. Isso torna nosso código muito mais flexível (se quisermos alterar o comportamento do nosso código, basta trocar as concretizaçãoes) e a prova de falhas de escrita de código (já que a abstração vai garantir que o código que vier logo abaixo da definição da concretização vai continuar funcionando de maneira transparente).
Veiculo veiculo = new Bicicleta();
// ...
veiculo.acelerar(); // Vai escrever "A bicicleta foi acelerada"
// ...
Neste exemplo, além de todas as vantagens que podemos notar no que diz respeito à qualidade e manutenibilidade do código, podemos dizer que o objeto veiculo
é um objeto polimórfico, pois a concretização está sendo capaz de alterar seu comportamento. Porém, todo o código subsequente não é afetado por essa troca.
Conclusão
As definições de classes, objetos, encapsulamento, herança, abstração e polimorfismo constituem os principais pilares do paradigma orientado a objetos. Estes são considerados temas pilares pois, através dele, podemos começar a obter as vantagens que o paradigma orientado a objetos visa oferecer da maneira mais correta possível. É imprescindível que nós, como desenvolvedores, conheçamos estes pilares para que consigamos escrever código orientado a objetos flexível, conciso e com um grau de qualidade superior.
Aqui, vale uma ressalva: cada linguagem oferece estes pilares de uma maneira própria, a depender da filosofia da linguagem. Existem linguagens orientadas a objeto que talvez não ofereçam o conceito de visibilidade de maneira tão explítica; algumas linguagens permitem herança múltipla, outras linguagens oferecem apenas herança simples… De fato, para o total aproveitamento dos conceitos da orientação a objetos, é imprescindível que, além de conhecer estes pilares, o desenvolvedor também conheça a filosofia e a sintaxe da linguagem que está sendo utilizada.