Você foi enganado! Encapsulamento não é apenas sobre getters e setters

terminalcoffee

Terminal Coffee

Posted on July 7, 2024

Você foi enganado! Encapsulamento não é apenas sobre getters e setters

Introdução

Quando o assunto é encapsulamento é bem comum encontrar esse princípio tão central para a OO sendo associado ou até mesmo confundido com a ideia de getters e setters, e apesar deles terem uma certa relação, encapsulamento vai muito além de simplesmente getters e setters, por isso no artigo de hoje, iremos caçar mais esse mito da orientação a objetos.

Encapsulamento é um conceito meio complicado de pegar corretamente, visto que ele é um pouco abstrato, ainda mais se você vem só do paradigma procedural (lógica de programação) como conhecimento prévio, além disso ele tem várias definições, então a chance é que você já se deparou com uma que diz algo como:

Encapsulamento se refere a capacidade de controlar o acesso a um membro de um objeto

Nessa definição encapsulamento seria equivalente a um outro princípio que chamamos de information hiding (ocultamento de informação), além de ser, geralmente, utilizado para se referir aos mecanismos de modificadores de acesso em linguagens orientadas a objetos, o que nos leva a ideia de getters e setters visto que o primeiro (e único muitas vezes) exemplo nos mais variados conteúdos sobre OO por ai demontram o uso dessa funcionalidade por meio dos getters e setters.

Encapsulamento é sobre controlar acesso

Ao nos depararmos com as possibilidades que linguagens orientadas a objeto nos trazem, também entramos em contato com uma nova classe (piada intencional) de problemas que esse conjunto de ferramentas nos ajuda a resolver.

Por exemplo, nem todas as informações que vamos agrupar num módulo qualquer (como uma classe/objeto por exemplo) deveriam ser acessíveis fora dele, além disso existem casos onde você só quer expor ela parcialmente (por exemplo só permitir a leitura de uma informação ou só permitir a sobrescrita dela), ou controlar como ela vai ser exposta ou modificada.

Para ilustrar isso considere o seguinte exemplo:

class PositiveNumber {
  public value: number;

  constructor(value: number) {
    if (value <= 0) {
      throw new Error("Um número positivo deve ser maior do que 0");
    }

    this.value = value;
  }
}

const x = new PositiveNumber(1);
x.value = -1;
Enter fullscreen mode Exit fullscreen mode

Aqui temos uma classe que representa um número positvo, ou seja, por definição, a propriedade value não poderia ser um número negativo ou zero, afinal isso não seria um número positivo, então o fato de podermos fazer algo como x.value = -1 demonstra que é possível fazer com que esse objeto contenha um estado inválido.

Para resolver isso, podemos modificar o código para que se faça um if antes de realizar a operação de atribuir um novo valor em value:

class PositiveNumber {
  public value: number;

  constructor(value: number) {
    if (value <= 0) {
      throw new Error("Um número positivo deve ser maior do que 0");
    }

    this.value = value;
  }
}

const x = new PositiveNumber(1);
const newValue = -1;

if (newValue > 0) {
  x.value = -1;
}
Enter fullscreen mode Exit fullscreen mode

Só que isso gera um novo problema, pois não temos como garantir que um objeto PositiveNumber vai ser sempre um número positivo, afinal quem garante que ele vai continuar sendo positivo é o código que usa ele, e não ele em si. Logo, se você esquecer de colocar esse if em um local que seja, isso abrirá uma brecha para que x deixe de representar um número positivo, e se uma abstração mente sobre sua premissa, você constrói seu código sobre premissas falsas, o que certamente não é uma receita para o sucesso.

Modificadores de acesso oferecem uma solução para esse problema, dessa forma podemos dizer quem pode acessar determinado membro de um objeto:

  • public (público) - qualquer um pode acessar, é o mesmo que não ter modificador nenhum;
  • private (privado) - apenas o código dentro da própria classe pode acessar/modificar;
  • protected (protegido) - apenas o código dentro da própria classe ou de classes que herdam dela podem acessar/modificar;

Atualizando o nosso exemplo ficaria algo como:

class PositiveNumber {
  private value: number;

  constructor(value: number) {
    if (value <= 0) {
      throw new Error("Um número positivo deve ser maior do que 0");
    }

    this.value = value;
  }
}

const x = new PositiveNumber(1);
x.value = -1; // Erro!
Enter fullscreen mode Exit fullscreen mode

Isso nos permite resolver o problema de códigos que não são o próprio objeto mexam no valor dele, se alguém tentar acessar/modificar um membro privado, isso vai gerar um erro, o que garante que estamos protegidos ao menos do código dos outros.

Só que isso gera um novo problema, não tem mais como fazer nada com o value, ele vai receber um valor durante a instanciação por meio do construtor, e vai ficar parado lá para sempre sem a gente poder mexer nele ou fazer qualquer coisa que seja com ele, certamente não parece ser o código mais útil do mundo.

Nesse cenário que getters e setters se apresentam como uma solução para tudo isso que discutimos até qui. Eles são um padrão onde substituímos as propriedades que estamos escondendo utilizando os modificadores de acesso por métodos que representam as operações de leitura e escrita nelas, assim controlando nos mínimos detalhes até mesmo os dados do objeto:

  • getter - representa a operação de leitura, então ao invés de fazer obj.prop usamos um método getter obj.getProp();
  • setter - representa a operação de escrita, então ao invés de fazer obj.prop = value usamos um método setter obj.setProp(value);

Isso permite que nós personalizemos a forma padrão como isso acontece nativamente no código, por exemplo, ao invés de só substituir o valor de uma propriedade ao realizar uma operação de escrita, um setter pode realizar uma validação ou transformar o dado antes dele substituir o valor original da propriedade.

Veja o código utilizando eles:

class PositiveNumber {
  private value: number;

  constructor(value: number) {
    this.setValue(value);
  }

  setValue(value: number): void {
    if (value <= 0) {
      throw new Error("Um número positivo deve ser maior do que 0");
    }

    this.value = value;
  }
}

const x = new PositiveNumber(1);
x.setValue(2); // Muda o value para 2
x.setValue(-1); // Erro!
Enter fullscreen mode Exit fullscreen mode

Aqui nós atingimos todos os critérios levantados até então:

  • Mesmo realizando a mesma operação (escrita) em mais de um lugar (no construtor e no código que usa o objeto x), a validação é aplicada todas as vezes sem falta;
  • Limitamos o acesso a propriedade value apenas a classe PositiveNumber;
  • Ainda é possível realizar computações com value.

Encapsulamento é sobre unir dados e funções

Até o momento parece que getters e setters são uma forma bem efetiva de se aplicar o princípio do encapsulamento, ainda mais levando em conta a definição que utilizamos inicialmente, some isso com os exemplos mais comuns serem os próprios getters/setters, algumas bibliotecas te forçarem a usar o padrão, e um conselho comum ao se aprender OO ser:

Sempre mantenha as suas propriedades privadas ou protegidas

Não é surpresa que muita gente escuta a definição formal, não entende nada, olha o mundo ao redor dela, só vê getters/setters, e chega a conclusão que, sim, encapsulamento é só usar getters e setters ou saber colocar as propriedades como private ou protected.

O problema disso é que o que discutimos até aqui é só metade da definição, como disse mais cedo, se a definição fosse apenas essa não teria motivo para termos um princípio separado apenas para se referir a isso quando já tinhamos o information hiding para descrever essa ideia. Uma definição melhor seria:

Encapsulamento se refere a juntar dados com as funções que operam neles, formando uma unidade com capacidade de controlar o acesso a seus membros
~ Adaptado da Wikipedia

Existem definições que englobam apenas a primeira parte (juntar dados e funções), então acredito que a definição mais razoável para o termo seja essa que une ambas.

A princípio pode não parecer que isso muda muito, afinal, os getters e setters, de fato, se encaixam nessa definição. O pulo do gato é quando começamos a fazer algumas reflexões sobre essa definição:

Dados e funções formarem uma unidade significa que existe uma ou mais funções que usam aquele conjunto de dados representados nas propriedades de um objeto, então ao invés de fazer todas elas receberem os mesmos parâmetros repetidas vezes, nós podemos criar um objeto que permite que todas elas compartilhem dos mesmos dados.

Para visualizar isso vamos supor que vamos criar um sistema de conta bancária simples:

type Account = { money: number };

function deposit(account: Account, amount: number): void {
  if (amount > 0) {
    account.money += amount;
  }
}

function withdraw(account: Account, amount: number): void {
  if (account.money >= amount) {
    account.money -= amount;
  }
}

function showBalance(account: Account): void {
  console.log(account.money);
}

const anAccount: Account = { money: 100 };

deposit(anAccount, 50);
showBalance(anAccount); // 150

withdraw(anAccount, 125);
showBalance(anAccount); // 25
Enter fullscreen mode Exit fullscreen mode

Note como todas as funções dependem dos dados de Account para funcionar, podemos tornar esse código bem menos repetitivo, mais limpo, e evitar os problemas de dados públicos, que exploramos anteriormente, aplicando o encapsulamento para unir todas elas numa única classe nos valendo dos modificadores de acesso ao mesmo tempo:

class Account {
  constructor(private money: number) {}

  deposit(amount: number): void {
    if (amount > 0) {
      this.money += amount;
    }
  }

  withdraw(amount: number) {
    if (this.money >= amount) {
      this.money -= amount;
    }
  }

  showBalance(): void {
    console.log(this.money);
  }
}

const anAccount = new Account(100);

anAccount.deposit(50);
anAccount.showBalance(); // 150

anAccount.withdraw(125);
anAccount.showBalance(); // 25
Enter fullscreen mode Exit fullscreen mode

Encapsulamento é a arte de esconder coisas

Isso nos permite criar uma boa abstração (trabalhar com uma conta usando termos como depositar, sacar, ou mostrar saldo, ao invés das computações realmente sendo feitas por de baixo dos panos) sem nos preocupar com como isso tudo é implementado internamente.

O fato de mantermos os dados escondidos usando os modificadores de acesso, nos dá um outro benefício que é a capacidade de esconder COMO estamos resolvendo o problema de quem usa o nosso código.

Pode não parecer, mas isso é uma grande vantagem quando se trata de dar manutenção e escalar as coisas. Isso porque força quem vai usar o código a confiar na abstração que estamos provendo, ou seja nos que estamos prometendo que vamos fazer, ao invés de confiar no que estamos fazendo de fato.

Programar com abstrações é muito mais fácil do que sem, é por isso que você usa uma linguagem de programação de alto nível como JS ao invés de programar a nível de máquina usando assembly por exemplo, cada coisa que torna sua vida mais fácil numa linguagem de programação advem do fato que ela está abstraindo algo complexo de você.

Então nos forçar a criar abstrações melhores é um ponto positivo, outra vantagem que vem quando escondermos como estamos resolvendo o problema é que isso torna o código mais independente.

Veja, se quem usa não tem acesso a como as coisas funcionam internamente, caso mudarmos completamente o código de um método, mas mantivermos ele funcionando da exata mesma forma (por exemplo trocar um algoritmo por uma versão mais performática dele), o usuário dessa função vai se beneficiar da mudança sem ter que mudar uma linha do código que ele escreveu usando ela.

Podemos demonstrar isso com uma class Bhaskara que representa uma formula para cálcular equações de segundo grau:

class Bhaskara {
  constructor(
    public a: number,
    public b: number,
    public c: number
  ) {}

  delta() {
    return this.b ** 2 -4 * this.a * this.c
  }

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

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

  result() {
    return [this.x1(), this.x2()];
  }
}
Enter fullscreen mode Exit fullscreen mode

Aqui temos uma situação onde controlar a modificação de a, b, ou c não parece impactar em nada o objeto, afinal, ao chamar qualquer um de seus métodos, simplesmente eles vão recalcular tudo com base nessa mudança.

Só que se você já ouviu a frase "se está no jogo é para usar", então talvez tenha sacado qual o problema dessa versão do código, nós podemos usar as propriedades a, b, e c para mostrar elas por exemplo:

const bhaskara = new Bhaskara(1, -5, 6);
console.log(bhaskara.a);
Enter fullscreen mode Exit fullscreen mode

Isso é um caso de uso da nossa classe que nós não previmos ao criar ela, afinal o objetivo dela é prover para quem for usar uma forma simples de calcular o resultado usando a formula de bhaskara.

Só que, e se a gente resolver renomear as propriedades da classe para tentar falar a mesma lingua dos matemáticos, tornando assim o código mais descritivo?

class Bhaskara {
  constructor(
    public quadraticCoefficient: number,
    public linearCoefficient: number,
    public constantTerm: number
  ) {}

  delta() {
    return this.linearCoefficient ** 2 -4 * this.quadraticCoefficient * this.constantTerm
  }

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

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

  result() {
    return [this.x1(), this.x2()];
  }
}
Enter fullscreen mode Exit fullscreen mode

Isso quebraria totalmente o nosso código que fazia console.log(bhaskara.a), visto que agora a não existe mais, virou quadraticCoefficient.

Por isso que esconder seria uma solução melhor, a gente só libera o que realmente for a nossa intenção que deveria ser utilizado, assim evitando que usos não previstos sejam realizados, o que nos dá paz de espírito para realizar uma mudança em como as coisas funcionam internamente sabendo que isso não vai afetar o código que usa o nosso código (o famoso bug que só some de um lugar para aparecer em outro), de quebra a gente ainda ganha uma abstração melhor ela vai, de fato, só mostrar o que interessa para quem for usar o código.

Podemos levar a ideia ainda mais longe, e esconder como os resultados são obtidos, assim não importaria se nós tivessemos implementado cada coisa em seu método como no exemplo, ou se fizemos tudo numa única função, ex:

class Bhaskara {
  constructor(
    public a: number,
    public b: number,
    public c: number
  ) {}

  private delta() {
    return this.b ** 2 -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);
  }

  result() {
    return [this.x1(), this.x2()];
  }
}

// ou

class Bhaskara {
  constructor(
    public a: number,
    public b: number,
    public c: number
  ) {}

  result() {
    const delta = this.b ** 2 -4 * this.a * this.c;

    return [
      -this.b + Math.sqrt(delta) / (2 * this.a),
      -this.b - Math.sqrt(delta) / (2 * this.a),
    ];
  }
}
Enter fullscreen mode Exit fullscreen mode

Em qualquer uma dessas implementações, o método result é a única operação que um cliente dessa classe poderia realizar, e como ambos se comportam da mesma forma, efetivamente, por termos deixado os métodos privados, essa refatoração foi possível, visto que podemos remover eles em favor de um código menor por conta da garantia que eles não estavam sendo utilizados em nenhum outro lugar.

Encapsulamento não é sobre getters e setters

Agora que exploramos as motivações por trás do uso de getters e setters, além das diversas aplicações do encapsulamento, vamos voltar a questão que abriu este artigo.

Getters e setters, assim como vimos no início do artigo, até podem ser uma aplicação do encapsulamento, entretanto se nós considerarmos os outros pontos que vimos até aqui, fica claro que eles são uma forma bem pobre de aplicar o princípio, ao passo de que deveriam ser a exceção ao invés da regra.

Podemos avaliar isso de forma mais pragmática analisando como eles são usados, de fato, na prática. O que vemos na grande maioria dos casos é uma aplicação do padrão que claramente viola o YAGNI, onde criamos getters e setters no automático (muitas vezes até por comodidade da IDE prover uma função de criar eles com um click) pensando que, no futuro, pode ser que tenha que adicionar uma validação ali que justifique a criação deles, ex:

class Person {
  constructor(private name: string, private age: number) {}

  getName() {
    return this.name;
  }

  setName(name: string) {
    this.name = name;
  }

  getAge() {
    return this.age;
  }

  setAge(age: number) {
    this.age = age;
  }
}
Enter fullscreen mode Exit fullscreen mode

Com esse simples código, acabamos de violar completamente o princípio do encapsulamento, perdemos tudo o que estamos tentando ganhar ao usar o princípio.

A começar que tudo o que fizemos aqui foi criar a mesma classe que teríamos se as propriedades fossem públicas, só que com 10 vezes mais código e trabalho.

Não apenas foi esforço desperdiçado como criamos potênciais problemas de abstração com isso, pois agora qualquer código fora da classe pode alterar qualquer estado interno dela sem problemas, o que volta no problema que discutimos na própria seção que justifica a existência desse padrão: a sobre controle.

Aqui você poderia argumentar que o problema não seria o padrão, mas sim quem usa errado, entretanto mesmo considerando que vamos ter setters que validam as coisas para evitar invariantes dos dados, getters que fazem algum tipo de transformação antes de retornar a propriedade, entre outras aplicações "perfeitas" do padrão, isso, novamente, ainda seria uma aplicação pobre do encapsulamento, e que potenciamente ainda viola o princípio.

Afinal, o benefício de centralizar o controle na classe não foi o único que perdemos ao fazer isso, nós também perdemos as vantagens discutadas no tópico sobre como encapsulamento seria a arte de esconder coisas, pois como temos a mesma coisa que teríamos caso as propriedades fossem públicas, nada do objeto foi escondido, como criamos cada par representando uma propriedade, se ela mudar, vamos ter que mudar o par junto, além de expor como o objeto faz as coisas internamente, dessa forma temos uma clara noção de como os seus dados estão estruturados.

Note como isso é um problema que permanece mesmo com os getters e setters cumprindo os seus papéis, assim por mais que tenhamos garantido que o objeto não vai aceitar estados inválidos, nada realmente foi escondido ali, o que vai contra um dos objetos de se usar o encapsualmento em primeiro lugar.

Além disso, eles não são lá uma forma muito interessante de abstração, visto que não traduzem nenhum conceito do problema sendo modelado na forma de uma classe, mas sim um problema seu como desenvolvedor. Por exemplo, numa classe Personagem, o que seria mais interessante um método setVida ou mais de um como tomarDano e curar?

Esse exemplo ilustra bem a natureza do encapsulamento como a união entre dados e funções, um dos motivos para criarmos funções seria abstrair alguma alteração que um dado ou um conjunto deles deveria sofrer, e se algo precisa ser alterado, é porque reflete uma necessidade do problema que existe na vida real.

De fato, existem casos onde a operação na vida real seria redefinir o valor de algo, como mudar uma configuração num painel ou o seu nome de usuário num formulário por exemplo, tornando um setter uma abstração correta no contexto do domínio.

Só que o mais provável é que a operação tenha um significado maior do que a ação que é executada na maioria dos casos, é só que não tentamos pensar nisso muitas das vezes, o que leva a uma abstração fraca do problema, indo contra o propósito de se tentar criar uma abstração em primeiro lugar.

A operação de dar um like num comentário por exemplo, apesar de no fim das contas ser a operação de pegar o número de likes de um post, e incrementar ela, carrega um significado importante para o contexto do problema, então ao invés de ter um setLikes que é usado por uma outra classe para atualizar o valor de uma classe Post, a própria classe Post poderia prover um método para se atualizar chamado curtir por exemplo.

A importância dessa abordagem fica ainda mais clara quando temos operações que envolvem atualizar múltiplas propriedades de um objeto de uma vez só, pois reflita comigo se tivermos um setter para cada propriedade, estamos protegidos de estados inválidos ao atualizar uma propriedade diretamente, só que nesse caso não temos nada que garante que vamos atualizar outra propriedade B como resultado de atualizar uma propriedade A em primeiro lugar. Assim, poderíamos considerar criar um "setter geral" que atualiza todas as propriedades de uma vez só, o que nesse caso só provaria o ponto que eu estou fazendo de que isso seria uma operação complexa, e portanto provavelmente existe um nome mais apropriedade para ela do que setTudo por exemplo.

E para fechar, quando se tem um setter, isso pressupõe a existência de pelo menos uma operação que vai alterar os dados do objeto, caso contrário isso é só código inútil que nunca vai ser usado, visto que o objeto seria imutável, fora isso vimos que se uma operação existe, ela provavelmente tem um nome melhor que setAlgumaCoisa, mas existe uma situações onde, de fato, você teria que pensar em métodos com nomes de verdade, que seria o caso da classe Personagem citada anteriormente, nela, existem pelo menos duas operações envolvendo a propriedade vida, sendo assim não poderíamos ter um método setter apenas, teríamos que diferenciar um do outro, e nesse ponto já provamos que cada ação deveria ter um nome mais apropriado.

Conclusão

Quando somos introduzidos a orientação a objetos, no que diz respeito ao encapsulamento, muitas vezes os getters e setters acabam sendo o nosso todo em relação a esse assunto, mas espero que com o artigo de hoje, você, caro leitor, tenha entendido as várias nuances que o princípio possui, além de ter entendido o motivo de muitas pessoas, eu incluso, considerarem getters e setters ruins, e sempre recomendarem ou a evitar eles completamente, ou usar apenas em último caso onde as outras estratégias para criar uma classe com bom encapsulamento já falharam.

Ass: Suporte cansado...

Links que podem te interessar

💖 💪 🙅 🚩
terminalcoffee
Terminal Coffee

Posted on July 7, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related