Orientação a Objetos - Introdução

Orientação a Objetos - Introdução

A orientação a objetos é um paradigma amplamente utilizado. Certamente se você é ou almeja ser um programador já se deparou ou ainda vai se deparar com este assunto, que é bem complexo, sendo motivo de diversos debates até hoje. Seja você um defensor ou um hater da OO uma coisa é certa, eventualmente você vai se deparar com ela, e por isso hoje vamos conhece-la melhor: quais os seus princípios? Como está presente no nosso dia-a-dia? E muito mais.

Introdução

Como todo paradigma de programação, a orientação a objetos é baseada em alguns princípios, características e formas de estruturar um programa. No caso, ela se baseia na ideia de que um programa pode ser modelado a partir de entidades que conhecemos como objetos, estes sendo unidades que possuem ao mesmo tempo tanto dados quanto funções em si, e que devem interagir entre si por meio da troca de mensagens, muito semelhante a como as células se comportam para formar os sistemas presentes no corpo humano. Além disso, a OO moderna é fundamentada em 4 pilares: Abstração, Encapsulamento, Herança e Polimorfismo.

Abstração

A abstração seria o conceito de focar nos detalhes importantes de um problema, muitas vezes sendo manifestado nas linguagens de programação por meio de um mecanismo que nos ajuda a esconder detalhes de alguma coisa.

Na vida real, utilizamos abstrações rotineiramente sem nem nos darmos conta, por exemplo, uma calculadora que esconde todos os detalhes de como realizar um cálculo matemático de nós, e tudo que precisamos fazer é escrever o cálculo ali e logo após obtemos o resultado da conta.

Um carro permite que uma pessoa se locomova apenas sabendo como operar os seus controles (pedais, volante, e etc), sem a necessidade de um entendimento profundo de como cada peça do carro faz ele se mover.

Linguagens de programação são uma abstração do processamento do computador. Elas permitem que nós escrevamos códigos de alto nível com estruturas mais compreensíveis, enquanto o compilador ou interpretador se encarrega de traduzir essas instruções para o nível de máquina.

Da mesma forma, isso é aplicado na programação por meio de conceitos como as funções que permitem que nós escondamos um algoritmo por de trás de um nome, e possamos utilizar ela sem termos a menor noção de como ela foi implementada, então ao invés de fazermos isso:

const point = [1, 2];
const otherPoint = [4, 6];

const x1 = point[0];
const y1 = point[1];
const x2 = otherPoint[0];
const y2 = otherPoint[1];

console.log(Math.sqrt(Math.pow((x2 - x1), 2) + Math.pow((y2 - y1), 2)));

Podemos abstrair esse algoritmo em uma função que vai esconder a complexidade de saber que cálculo seria esse, e nos permitiria focar apenas no essêncial que seria o "o quê" sendo calculado. Ex:

function distance(point: number, otherPoint: number) {
  const x1 = point[0];
  const y1 = point[1];
  const x2 = otherPoint[0];
  const y2 = otherPoint[1];

  return Math.sqrt(Math.pow((x2 - x1), 2) + Math.pow((y2 - y1), 2));
}

console.log(distance([1, 2], [4, 6]));

Na orientação a objetos, o mecanismo que nós utilizamos para manifestar nossas abstrações são os objetos, estes que seriam valores que representariam os elementos envolvidos no problema que estamos tentando resolver contendo tanto o seu estado (dados) quanto os seus comportamentos (funções) num mesmo valor. Para se definir essas abstrações do problema que estamos tentando resolver, é utilizado um mecanismo presente em diversas linguagens: a classe, assim como para termos uma função, nós precisamos primeiro ter definido ela antes, para podermos criar esses valores especiais, que seriam os nossos objetos, nós precisamos definir como eles se parecem antes, e isso é realizado por meio da criação de uma classe. Ex:

// Definição da classe
class Rect {
  width: number;
  height: number;

  area() {
    return this.width * this.height;
  }
}

// Criação do objeto por meio dela
const rect = new Rect();
rect.width = 10;
rect.height = 20;
console.log(rect.area()); // 200

Aqui temos a criação de uma classe que define duas propriedades: width, height; e um método: area. Para se referenciar algo definido na classe dentro dela (seja uma propriedade ou um método), se utiliza o conceito do this, que é um valor especial que representa a instância daquela classe no momento em que o método for chamado, assim se tivermos dois objetos da mesma classe, um A e o outro B, quando chamarmos um método em A, o this vai assumir o valor do objeto A, e quando chamarmos em B, ele vai assumir o valor de B. No caso do exemplo, quando o método area é chamado, o this vai assumir o valor do objeto rect, e width vai valer 10, enquanto height vai valer 20, portanto resultando em 200.

Note que essa é uma habilidade muito interessante que nos permite criar valores customizados além daqueles que a linguagem de programação oferece nativamente, então agora além de ter um dado que é uma texto ou um número, podemos ter um dado, na forma de um objeto, que vai ser um cliente dentro do nosso sistema, uma conexão com o banco de dados, uma data, e etc.

A aplicação desse princípio na prática se mostra na capacidade do programador de analisar o problema em questão para que então ele consiga escrever uma classe que represente bem ele. Por exemplo as características e comportamentos de um cachorro num sistema seriam muito diferentes se estivermos fazendo um sistema de adoção, ou um pet shop por exemplo. No sistema de adoção, um animal teria o nome, a idade, possíveis doenças, a raça, e poderia ter ações como ser adotado por exemplo, já no sistema de pet shop as características relevantes poderiam ser apenas o nome e a raça, e os comportamentos a ação de tomar banho, e esperar por exemplo.

Encapsulamento

O encapsulamento é o princípio que diz respeito a mantermos os dados e as funções que operam neles numa única unidade, com ela tendo a capacidade de proteger os seus membros permitindo que alguns deles possam ser acessíveis a outras partes do código e outros não. Sendo a aplicação conjunta a nível de objeto dos princípios de ocultamento de informação e coesão.

Aqui fica claro o motivo desse conceito ser um dos pílares centrais da orientação a objetos, afinal, o que seria um objeto senão uma unidade coesa que é composta tanto de dados quanto de funções. Dessa forma, descobrimos uma nova capacidade dos nossos objetos que seria a de permitir ou não que os outros acessem os seus membros, ou seja seus dados ou funções.

Uma boa forma de distinguir ele da abstração, afinal ambos os conceitos caminham juntos e geralmente são usados em conjunto, é que a abstração foca na interface pública do objeto enquanto o encapsulamento foca na implementação dele, ou simplificando: a abstração diz respeito a o que a gente vê do objeto, e o encapsulamento ao que a gente esconde dentro do objeto.

Para visualizarmos melhor tudo isso, observe o exemplo de uma possível biblioteca para operações relacionadas com a fórmula de bhaskara, a seguir:

function delta(a: number, b: number, c: number): number {
  return b * b - 4 * a * c;
}

function hasSolution(a: number, b: number, c: number): boolean {
  return delta(a, b, c) >= 0;
}

function possibleSolutions(a: number, b: number, c: number): number {
  const discriminant = delta(a, b, c);
  switch (true) {
    case discriminant < 0:
      return 0;
    case discriminant === 0:
      return 1;
    default:
      return 2;
  }
}

function x1(a: number, b: number, c: number) {
  return (-b + Math.sqrt(delta(a, b, c))) / (2 * a);
}

function x2(a: number, b: number, c: number) {
  return (-b - Math.sqrt(delta(a, b, c))) / (2 * a);
}

function bhaskara(a: number, b: number, c: number): [number, number] {
  return [x1(a, b, c), x2(a, b, c)];
}

Ao analisar esse exemplo podemos notar que temos diversas funções relacionadas, e que dependem dos mesmos dados, porém como elas são funções, os dados vivem separados delas, ou seja, sempre que formos chamar elas, teriamos que repassar a, b, e c novamente todas as vezes, além do fato de que a criação desses dados é independente da existencia dessas funções.

Para aplicarmos o encapsulamento aqui, primeiro temos que agrupar ambos dados e funções numa só unidade, então vamos criar uma classe para podermos produzir essas unidades (os objetos) depois:

class Bhaskara {
  a: number;
  b: number;
  c: number;

  constructor(a: number, b: number, c: number) {
    this.a = a;
    this.b = b;
    this.c = c;
  }

  delta(): number {
    return this.b * this.b - 4 * this.a * this.c;
  }

  hasSolution(): boolean {
    return this.delta() >= 0;
  }

  possibleSolutions(): number {
    const discriminant = this.delta();
    switch (true) {
      case discriminant < 0:
        return 0;
      case discriminant === 0:
        return 1;
      default:
        return 2;
    }
  }

  x1() {
    return (-this.b + Math.sqrt(this.delta())) / (2 * this.a);
  }

  x2() {
    return (-this.b - Math.sqrt(this.delta())) / (2 * this.a);
  }

  results(): [number, number] {
    return [this.x1(), this.x2()];
  }
}

const bhaskara = new Bhaskara(1, 2, -1);
console.log(bhaskara.results());

Aqui temos algo novo que é o construtor, note que ao criar uma instância da classe Bhaskara nós passamos os valores de a, b, e c que nós estavamos passando anteriormente como parâmetro de todas as funções, porém para onde esses valores foram? A resposta é o método constructor, que é a representação do conceito de método construtor no JS/TS, em outras linguagens como Java por exemplo ele seria um método que carrega o próprio nome da classe. E o que seria esse método construtor? Ele seria um método especial que é executado quando nós criamos a instância da classe (então quando fazemos new Classe() estamos executando o construtor), isso é útil para que nós possamos inicializar o objeto com os dados que ele precisa para funcionar direto na criação dele, e evitar que o programador se esqueça de definir alguma propriedade antes de usar um método que precisa dela, ou performar validações para gerar um erro caso o usuário esqueça de passar ou passe errado alguma propriedade que fosse essencial para o funcionamento do objeto.

Com isso o primeiro passo para o encapsulamento foi realizado que é unir os dados com as funções que operam neles, porém ainda falta a segunda parte que é garantir quem vai ser escondido;

O problema aqui é que temos membros que o cliente dessa classe não deveria acessar ou que ele não tem interesse, como todas as propriedades (dados), e os métodos (funções): x1, x2, e delta. Se lembrarmos do princípio da abstração, o ponto chave é ajudar no foco de quem vai utilizar esse código depois, portanto seria legal se pudessemos ter uma forma de só deixar ele usar os membros relevantes para ele, e esconder os que não são, dessa forma quem consumir esse código pode ter até mesmo ajuda do seu editor de código para só mostrar as opções que ela pode utilizar.

Essa parte de esconder as partes não relevantes, somada a junção das funções com os dados por meio de uma classe para produzir um objeto que manteria tudo junto, seria a aplicação do encapsulamento, e o mecanismo que a maior parte das linguagens OO provém para nós implementarmos isso seriam os modificadores de acesso, que normalmente são:

  • public: todos podem acessar esse membro;

  • private: só o próprio objeto pode acessar esse membro;

  • protected: só o próprio objeto e seus derivados (que falaremos mais quando falarmos de herança) podem acessar esse membro;

Em TypeScript, por exemplo, essa mesma classe, já aplicando esses conceitos, seria assim:

class Bhaskara {
  private a: number;
  private b: number;
  private c: number;

  constructor(a: number, b: number, c: number) {
    this.a = a;
    this.b = b;
    this.c = c;
  }

  private delta(): number {
    return this.b * this.b - 4 * this.a * this.c;
  }

  private x1() {
    return (-this.b + Math.sqrt(this.delta())) / (2 * this.a);
  }

  private x2() {
    return (-this.b - Math.sqrt(this.delta())) / (2 * this.a);
  }

  hasSolution(): boolean {
    return this.delta() >= 0;
  }

  possibleSolutions(): number {
    const discriminant = this.delta();
    switch (true) {
      case discriminant < 0:
        return 0;
      case discriminant === 0:
        return 1;
      default:
        return 2;
    }
  }

  results(): [number, number] {
    return [this.x1(), this.x2()];
  }
}

const bhaskara = new Bhaskara(1, -3, 2);
const [x1, x2] = bhaskara.results();
console.log(x1, x2);

Dessa forma conseguimos esconder com sucesso os membros não relevantes através do modificador de acesso private, porém nem todas as linguagens são assim, no JavaScript por exemplo apenas temos o modificador private usando uma sintaxe diferente, e em algumas linguagens, como o Python, esse conceito nem existe dependendo de boas práticas da comunidade para esse princípio ser mantido.

Sendo assim, o encapsulamento é importante pois a sua aplicação, se feita corretamente, nos proporciona a capacidade de trocar a implementação interna (por exemplo não ter os métodos delta, x1 e x2, trocando pelos cálculos diretamente no método result) do nosso objeto a qualquer momento sem que isso afete os códigos que usam ele, o que também facilita a aplicação do princípio do polimorfismo que veremos daqui a pouco. Consequentemente, isso aumenta a capacidade desse objeto de ser independente, e portanto, habilitando que você possa copiar uma classe de um projeto para o outro sem se preocupar, ou mesmo mover ela dentro da estrutura do próprio projeto, ou de permitir que uma pessoa possa cuidar do código dessa classe sem afetar o trabalho de outra pessoa no time durante o projeto, dessa forma podendo manter o trabalho em paralelo.

Herança

Até aqui nós vimos que tudo começa na abstração, onde nós reconhecemos as partes relevantes do problema, e representamos elas no código, depois o encapsulamento para podermos implementar os vários aspectos que essa abstração pode ter (quem fica junto com quem, e quem pode acessar quem).

Agora vamos falar da herança que vai nos ajudar a expressar outro aspecto que pode ser relevante na representação das nossas abstrações no código que seriam as relações de "é um" entre objetos.

Existem vários casos onde uma coisa é uma variação ou uma especialização de outra coisa, por exemplo:

  • Todo Quadrado é uma Forma Geométrica;

  • Todo Cachorro é um Animal;

  • Validação de E-mail, Validação de Tamanho de Campo, e Validação de CPF são Validações;

  • Etc.

Então vamos pegar um produto e um produto em promoção como exemplo:

class Product {
  private name: string;
  private _price: number;

  constructor(name: string, price: number) {
    this.name = name;
    this._price = price;
  }

  price(): number {
    return this._price;
  }

  print() {
    const price = this.price().toLocaleString("en-US", {
      style: "currency",
      currency: "USD",
    });

    console.log(`${this.name} cost ${price}`);
  }
}

class PromotionProduct {
  private name: string;
  private _price: number;
  private _discount: number;

  constructor(name: string, price: number, discount: number) {
    this.name = name;
    this._price = price;
    this._discount = discount;
  }

  price(): number {
     return this._price * (1 - this.discount);
  }

  print() {
    const price = this.price().toLocaleString("en-US", {
      style: "currency",
      currency: "USD",
    });

    console.log(`${this.name} cost ${price}`);
  }

  percentDiscount(): string {
    return `${this._discount * 100}%`;
  }
}

Note que nós tivemos que copiar a propriedade name, e os métodos price, e print para o PromotionProduct, além de que eles compartilham uma relação de "é um", afinal um produto em promoção ainda é um produto, a diferença entre eles é que o produto em promoção é um produto com umas coisas a mais que não fariam sentido de estar no produto normal, no caso o desconto e o método de mostrar o desconto na notação de porcentagem. E é aqui que entra a herança, com ela nós podemos fazer com que a classe PromotinProduct herde de Product e tenha acesso a todos os membros não privados declarados nela, assim só precisamos escrever os extras na classe PromotionProduct. Ex:

class Product {
  protected name: string;
  protected _price: number;

  constructor(name: string, price: number) {
    this.name = name;
    this._price = price;
  }

  price(): number {
    return this._price;
  }

  print() {
    const price = this.price().toLocaleString("en-US", {
      style: "currency",
      currency: "USD",
    });

    console.log(`${this.name} cost ${price}`);
  }
}

class PromotionProduct extends Product {
  private _discount: number;

  constructor(name: string, price: number, discount: number) {
    super(name, price);
    this._discount = discount;
  }

  price(): number {
    return this._price * (1 - this._discount);
  }

  percentDiscount(): string {
    return `${this._discount * 100}%`;
  }
}

Aqui temos alguns detalhes novos, primeiro sendo o uso do comando super que seria algo como o this, porém que ao invés de referenciar as coisas da classe atual, referencia as coisas da classe mãe (que foi herdada pela filha), e no contexto desse código, significa que o construtor de Product está sendo executado dentro do construtor de PromotionProduct, porém ele pode ser usado para outras coisas que serão úteis quando formos falar do polimorfismo. A segunda coisa "nova" é o uso do protected que já conhecemos antes, no caso as propriedades name e_price não são acessíveis para fora assim como se tivessemos usado private, porém se elas fossem private, elas não seriam herdadas na classe PromotionProduct, então para preservar a capacidade de não ser acessado por fora, mas ainda manter elas acessíveis na PromotionProduct nós usamos o protected para só permitir que as filhas acessem coisas definidas na mãe.

Dessa forma podemos promover o reúso de código no nosso sistema ao agrupar classes que compartilham coisas duplicadas, enquanto mantemos uma relação de "é um" (então nada de Cachorro herda de Humano pois os dois tem um método andar ou uma propriedade nome), ao extrair essas duplicatas para uma classe mãe e herdar eles de volta nas filhas.

Se você estiver atento deve estar se perguntando o que o método price está fazendo ali na classe PromotionProduct, afinal além dele já existir em Product, ele tem uma implementação diferente da usada na classe Product, e isso tem a ver com o nosso último assunto: o polimorfismo.

Polimorfismo

O polimorfismo é a capacidade de uma função de ter mais de uma implementação, assim, ao ser chamada, executar a implementação correspondente, ou seja, da mesma forma que o nome sugere, é a possibilidade da função de assumir diversas formas. Podendo, por exemplo, ter duas funções com o mesmo nome em classes diferentes, assim quando você for utilizar elas no código, a implementação que vai ser chamada vai depender de quem é o objeto que chama por ela. Ex:

class A {
  method() {
    console.log("A");
  }
}

class B {
  method() {
    console.log("B");
  }
}

const a = new A();
const b = new B();

a.method(); // A
b.method(); // B

Apesar da função method existir tanto em A quanto em B, quando um objeto que é uma instância da classe A chama por ela, a implementação de method que é executada é a de A, e o mesmo acontece para B se quem chamar por method for uma instância de B.

Entretanto esse não é todo o poder do polimorfismo, na verdade, esse é um conceito tão amplo que ele é dividido em diversos tipos, e é isso que possibilita a sua aplicação em outros paradigmas além da orientação a objetos, como na programação funcional ou na procedural, pois cada paradigma e linguagem consegue implementar um ou mais tipos de polimorfismo dentro de seus mecanismos de abstração sejam eles funções, procedimentos ou objetos. Desses vários tipos, os que interessam para gente seriam:

  • Coerção;

  • Subtipo;

  • Ad Hoc;

  • Paramétrico;

Coerção

O polimorfismo de coerção é o mais simples, ele é o tipo que acontece quando se converte um valor de um tipo para o outro, explicita ou implicitamente, por exemplo, se você colocar um número dentro de uma string no JavaScript/TypeScript, ele será convertido para string ali dentro:

const x: number = 10;
const message: string = `O número é ${x}`; // x é implícitamente convertido para string

No TypeScript podemos explorar mais das possiblidades dele em relação a orientação a objetos em um método especial que todo objeto pode implementar que é o método toString que é um método que como o nome sugere converte o objeto numa string. Ex:

class Money {
  private value: number;
  private currency: string;

  constructor(value: number, currency: string) {
    this.value = value;
    this.currency = currency;
  }

  toString(): string {
    return this.value.toLocaleString('pt-BR', {
      style: 'currency',
      currency: this.currency
    });
  }
}

const money = new Money(100, 'BRL');
console.log(money.toString()); // R$ 100,00

Porém, especificamente para o método toString, o JS/TS consegue converter o objeto para string usando ele em situações onde o JS/TS faria a conversão para string. Ex:

const money = new Money(100, 'BRL');
console.log(`${money}`); // R$ 100,00 (toString foi chamado por de baixo dos panos)

Em outras linguagens com suporte a esse tipo de polimorfismo, essa possibilidade pode aparecer como o método toString ou algo equivalente, ou até mesmo existem algumas linguagens que deixam você controlar com bastante precisão como o objeto seria convertido em diversos cenários, como é o caso do C# e a sua funcionalidade de explicit and implicit operator overloads.

Subtipo

Ele é o tipo que nós usamos quando queremos utilizar o polimorfismo em conjunto com a herança (que é o que torna ela útil na maior parte dos casos), ou seja, quando nós substituimos um método definido na mãe lá na filha, assim podendo modificar o comportamento original ao novo contexto. Um exemplo claro disso é o nosso exemplo da classe PromotionProduct que substitui o método price da classe Product de forma que agora ao invés de simplesmente retornar o preço do produto, retornar o valor do preço do produto aplicando o desconto em cima dele.

Outro exemplo mais avançado desse tipo seria a aplicação do conceito de classes/métodos abstratos que no caso das classes, seriam as que não podem ser instânciadas, logo, fazer isso aqui não seria possível:

abstract class Shape {}

// classes abstratas não podem ser instanciadas
const shape: Shape = new Shape();

E por que iriamos querer definir uma classe abstrata? Pois com ela podemos definir métodos abstratos dentro dela que são métodos que não possuem uma implementação, apenas a sua assinatura (que é o conjunto da visibilidade, nome, parâmetros e tipo de retorno se a linguagem suportar tipagem), ex:

abstract class Shape {
  abstract area(): number;
}

Note que o método area não tem o corpo da função, pois ele é um método abstrato e por isso não possui implementação, e isso é útil já que obriga as classes que herdarem a classe Shape a implementarem esse método area, dessa maneira garantindo que se a gente disser que algo é do tipo Shape, ele pode aceitar qualquer classe que herde de Shape, afinal todo mundo que herda de Shape "é um" Shape, e sabermos que pelo menos ele vai ter o método area que não recebe nenhum valor, e que retorna number. Ex:

abstract class Shape {
  abstract area(): number;
}

class Rect extends Shape {
  private width: number;
  private height: number;

  constructor(width: number, height: number) {
    this.width = width;
    this.height = height;
  }

  area(): number {
    return this.width * this.height;
  }
}

class Circle extends Shape {
  private radius: number;

  constructor(radius: number) {
    this.radius = radius;
  }

  area(): number {
    return Math.PI * this.radius * this.radius;
  }
}

function showArea(shape: Shape) {
  console.log(shape.area());
}

showArea(new Rect(5, 4)); // 20
showArea(new Circle(5)); // 78.54

Vale lembrar que uma classe abstrata pode fazer tudo que uma classse normal faz, exceto ser instânciada, ou seja, ela pode definir proprieadades, métodos não abstratos e construtores também.

Um último detalhe em relação ao polimorfismo de subtipo é que vão ter situações onde, assim como no exemplo da classe Shape, a única coisa declarada numa classe abstrata seriam métodos abstratos, e nesse caso, várias linguagens orientadas a objeto tem uma funcionalidade especial chamada de interface, que seria como uma classe abstrata, porém você só pode declarar métodos abstratos, e por isso não precisaria colocar o abstract nas coisas pois tudo ali já é abstrato (inclusive a própria interface), sendo assim podemos refatorar o nosso exemplo para usar interfaces da seguinte maneira:

interface Shape {
  area(): number;
}

class Rect implements Shape {
  private width: number;
  private height: number;

  constructor(width: number, height: number) {
    this.width = width;
    this.height = height;
  }

  area(): number {
    return this.width * this.height;
  }
}

class Circle implements Shape {
  private radius: number;

  constructor(radius: number) {
    this.radius = radius;
  }

  area(): number {
    return Math.PI * this.radius * this.radius;
  }
}

function showArea(shape: Shape) {
  console.log(shape.area());
}

showArea(new Rect(5, 4)); // 20
showArea(new Circle(5)); // 78.54

Ad Hoc

Este é o tipo de polimorfismo aplicado quando a linguagem permite que uma função possa definir várias implementações diferentes baseado no número de parâmetros e nos tipos deles. Ex:

public class Semigroup {
    public int concat(int x, int y) {
        return x + y;
    } 

    public int concat(int, x, int y, int z) {
        return x + y + z;
    }

    public String concat(String a, String b) {
        return a + b; 
    }
}

class Program {
    public static void main(String[] args) {
        var semigroup = new Semigroup();
        System.out.println(semigroup.concat(1, 1)); // 2
        System.out.println(semigroup.concat(1, 1, 1)); // 3
        System.out.println(semigroup.concat("foo", "bar")); // foobar      
    }
}

Aqui você pode perceber que o exemplo está em uma linguagem diferente da que vinhamos utilizando até agora, no caso ele está em Java, e isso acontece pois infelizmente o TypeScript não possui suporte a esse tipo de polimorfismo (apesar de suportar algo próximo). Bom, apesar deste detalhe, acredito que ainda assim seja possível perceber como esse tipo pode ser útil, já que, como observado no exemplo, nós podemos definir uma versão do nosso método que funciona para strings e outra que funciona para numbers, ou uma que funciona recebendo os dois ao mesmo tempo por exemplo.

A ponto de curiosidade, um exemplo fictício de uma possível versão em TS talvez pudesse ser assim:

class Semigroup {
  concat(x: number, y: number): number {
    return x + y;
  }

  concat(x: number, y: number, z: number): number {
    return x + y + z;
  }

  concat(a: string, b: string): string {
    return a + b; 
  }

  concat<T>(arr: T[], otherArr: T[]): T[] {
    return arr.concat(otherArr);
  }
}

const semigroup = new Semigroup();
console.log(semigroup.concat(1, 1)); // 2
console.log(semigroup.concat([1, 2], [3, 4])); // [1, 2, 3, 4]

Novamente temos algo novo neste exemplo, que seria o <T> no concat entre dois arrays, e, bom, ele tem a ver com o último tipo de polimorfismo: o paramétrico.

Paramétrico

O polimorfismo paramétrico é o tipo utilizado quando utilizamos uma linguagem com suporte a generics em seu sistema de tipagem, nesse sentido ele é a parecido com o ad hoc já que ambos são tipos relacionados ao sistema de tipos da linguagem.

Para entendermos essa situação melhor, vamos imaginar um objeto que contém um valor de qualquer tipo, e que podemos pegar ele de volta depois, ex:

class Identity {
  private _value;

  constructor(value) {
    this._value = value;
  }

  value() {
    return this._value;
  }
}

const id = new Identity(1);
const value = id.value();

A classe Identity tem um problema que é que nós não conseguimos tipar o _value corretamente, pois qual deveria ser o tipo dele para que quando ele fosse retornado, a const value assumisse o tipo number caso criassemos a const id com um number, o tipo string se criassemos com uma string, e assim por diante para todo e qualquer tipo de valor possível, inclusive outras classes que fossemos criando ao longo do sistema.

Esse é problema muito comum no que diz respeito a implementação de arrays dentro de orientação a objetos, pois como não tem como resolver esse tipo interno de forma dinâmica, a solução até então seria ter uma classe Array base, e usar herança para ter uma para cada tipo que fossemos precisar como NumberArray, StringArray, e etc. E o mesmo teria que acontecer com a Identity para conseguirmos fazer isso, ex: NumberIdentity e StringIdentity, porém como já dá para perceber só com dois tipos, isso resultaria em diversas classes iguais só para corrigir a tipagem, e teriamos que criar elas manualmente para cada tipo que fossemos usar, algo que não é muito bom em termos escalabilidade.

Por isso, a solução para esse problema foi a criação do conceito de generics, que são tipos que só são resolvidos no momento em que eles são usados, ao invés de no momento em que eles são definidos, então para deixar essa questão mais clara, vamos adicionar eles ao nosso exemplo:

class Identity<T> {
  private _value: T;

  constructor(value: T) {
    this._value = value;
  }

  value(): T {
    return this._value;
  }
}

const id = new Identity(1);
const value = id.value();

Dessa forma criamos um generic usando o <T> e dessa forma, todo lugar onde referenciamos T só vai ter o seu tipo resolvido de fato quando instanciarmos a classe Identity, ou seja, esse T só vai ter um valor quando usamos o new Identity, que nos caso do exemplo vai ser resolvido para number, e todo lugar onde estava T vai passar a valer number, efetivamente atingindo o objetivo que nós tinhamos orinalmente, pois caso fosse passado uma string no lugar do 1 (que é um number), ele seria resolvido como string, e assim por diante.

Os generics podem ser utilizados em classes (como no exemplo acima), interfaces (funciona praticamente igual nas classes), ou em funções e métodos (igual ao método concat do exemplo de Semigroup que vimos na seção sobre o polimorfismo Ad Hoc). Essa capacidade é muito útil quando nós vamos implementar estruturas de dados por exemplo, já que nesse tipo de problema as funções lidam com a organização dos dados, e não com eles em si, logo, podem ser utilizadas indepente dos valores em que operam.

Exemplo Prático

Agora que vimos todos os pilares, vamos ver um exemplo usando todos eles para sentirmos como seria resolver um problema usando orientação a objetos. O problema que vamos resolver é bem simples para facilitar a compreensão: temos várias formas geométricas (Retangulo, Quadrado, e Circulo), e queremos mostrar a área delas.

Primeiro vamos exercitar a abstração, o problema que a gente quer resolver é calcular a área dessas formas geométricas, então sabemos que pelo menos um método para calcular a área vamos ter que ter em algum lugar, fora isso, cada forma geométrica possui algumas características que são necessárias para que esse cálculo seja possível, no caso, a largura e a altura do retangulo, o tamanho do quadrado, ou o raio do círculo, outro dado importante é que todas são formas geométricas que compartilham a capacidade de calcular a sua própria área.

interface Shape {
  area(): number;
}

class Rect implements Shape {
  width: number;
  height: number;

  constructor(width: number, height: number) {
    this.width = width;
    this.height = height;
  }

  area(): number {
    return this.width * this.height;  
  }
}

class Square extends Rect {
  constructor(size: number) {
    super(size, size);
  }

  area(): number {
    return this.width * this.height;  
  }
}

class Circle implements Shape {
  radius: number;

  constructor(radius: number) {
    this.radius = radius;
  }

  area(): number {
    return Math.PI * this.radius * this.radius;  
  }
}

Aqui já podemos ver uma interseção com a herança já que a abstração que nós desenhamos é a de elementos com relações hierarquicas (todo quadrado é um retangulo), além de conseguimos até deduzir uma inteface que seria a própria forma geométrica.

O polimorfismo pode ser visto já que o método area existe em todas as classes, porém cada uma implementa ele de uma forma diferente e respeitando a interface Shape, sendo o polimorfismo de subtipo o que nós utilizamos aqui.

Sobre o encapsulamento, por enquanto, o nosso projeto tem falhas graves nesse princípio, e aqui vamos revisitar o motivo de ser uma boa manter os dados escondidos. O primeiro problema que nós temos aqui é na classe Square pois na abstração que nós criamos aqui, um Quadrado não é um Retangulo, afinal ele tem todos os lados com tamanhos iguais, e como ele herda as propriedade width, e height, se você alterar qualquer uma das duas, a outra não vai mais ter o mesmo valor, e ele vai ser um retangulo ao invés de um quadrado. Então poderíamos decidir por dar algum jeito de manter as duas propriedades sincronizadas, ou de separar de vez a classe Square numa classe independente que não herda de Rect, e mudando a sua estrutura interna para só uma propriedade size por exemplo. Outro problema é que poderiamos decidir que agora as unidades de medida são importantes e ter classes para cada unidade de medida, e isso afetaria todas as classes, porém como todas as propriedades são públicas, não conseguiriamos fazer essa mudança de forma segura, pois pode ter algum código fora das classes que acessa diretamente essas propriedades, e mudar o tipo delas poderia quebrar esse possível cliente.

Para resolver isso podemos encapsular os dados evitando que eles possam ser acessados de fora das classes, assim garantindo que as regras que regem cada um vão ser aplicadas corretamente (no caso do quadrado e do retangulo), ou que se resolvermos mudar a estrutura interna deles no futuro, que isso seja possível (no caso das unidades de medida):

interface Shape {
  area(): number;
}

class Rect implements Shape {
  protected width: number;
  protected height: number;

  constructor(width: number, height: number) {
    this.width = width;
    this.height = height;
  }

  area(): number {
    return this.width * this.height;  
  }
}

class Square extends Rect {
  constructor(size: number) {
    super(size, size);
  }

  area(): number {
    return this.width * this.height;  
  }
}

class Circle implements Shape {
  private radius: number;

  constructor(radius: number) {
    this.radius = radius;
  }

  area(): number {
    return Math.PI * this.radius * this.radius;  
  }
}

Com isso aplicamos o encapsulamento também, porém note que se na nossa abstração fosse necessário que as propriedades fossem alteradas, talvez Square não devesse herdar de Rect devido aos problemas citados anteriormente, mas no nosso caso onde o foco do problema é só no cálculo de área, então podemos manter a solução dessa forma também.

Conclusão

A orientação a objetos pode nos ajudar em diversos cenários, e muitas vezes pode ser bem complexa de entender, então se você chegou até aqui, obrigado pela leitura, espero que este artigo tenha te ajudado a compreender melhor cada um desses aspectos básicos do paradigma, e se você tem interesse nesse vasto mundo da orientação a objetos, fica ligado que vamos ter ainda mais artigos sobre o assunto no futuro.