DEV Community

Gustavo Santos
Gustavo Santos

Posted on

Um devaneio sobre Classes, Objetos, Funções e organização em JavaScript

Considere os dois trechos de código abaixo:

// trecho 1
const user = new User('Gustavo', 25);
user.name();

// trecho 2
const user = new User('Gustavo', 25);
name(user);
Enter fullscreen mode Exit fullscreen mode

Qual versão você prefere?

Atenção: Este artigo contém, na sua grande maioria, as minhas opiniões pessoais sobre estilos de desenvolvimento de programas de forma sustentável.


Classes em JavaScript

JavaScript não possui suporte a classes. Ok, existe a palavra chave class adicionada no ES2015, que serve justamente para definir classes. Caso você seja novo no mundo JavaScript, esse exemplo pode ser esclarecedor:

class User {
    constructor(name, age) {
        this.name = name;
        this.age = age;
    }
}

let user = new User('Gustavo', 25);
console.log(user.name); // Gustavo
Enter fullscreen mode Exit fullscreen mode

No trecho acima, é criada uma classe chamada User com um método construtor que recebe dois parâmetros, o primeiro será atribuído a name e o segundo, a age. Em seguida é criado uma instância da classe User e o atributo público name é impresso no console.

Porém, é neste momento que começam os problemas. Toda e qualquer classe em JavaScript possuirá todos os atributos e métodos públicos, violando os conceitos de encapsulamento.

E se quisermos que o atributo age seja privado? Ou seja, que este atributo seja acessível somente dentro da classe User. Talvez algo do tipo:

class User {
    let __age;
    constructor(name, age) {
        this.name = name;
        age = age;
    }
}
Enter fullscreen mode Exit fullscreen mode

Infelizmente essa sintaxe não é válida. Porém no ES2019 foi introduzido o indicador de atributo privado e a sintaxe fica bem parecida com a nossa tentativa inválida:

class User {
    #age = null;
    constructor(name, age) {
        this.name = name;
        age = age;
    }
}
Enter fullscreen mode Exit fullscreen mode

Se tentarmos acessar o atributo age, receberemos como resultado undefined:

let user = new User('Gustavo', 25);
console.log(user.name); // Gustavo
console.log(user.age); // undefined
Enter fullscreen mode Exit fullscreen mode

De fato, usando a api de reflexão podemos ter a certeza de que o atributo age não é exposto ao mundo externo:

console.log(Reflect.has(user, 'name')) // true
console.log(Reflect.has(user, 'age')) // false
Enter fullscreen mode Exit fullscreen mode

Legal, porém o suporte de classes em JavaScript é curto. Herança múltipla não é suportada, sobrecarga não existe, polimorfismo é simulado através da natureza dinâmica dos tipos do JavaScript. Ou seja, JavaScript não é uma linguagem que suporte Orientação a Objetos.

A minha crítica ao uso de OOP em JavaScript parte principalmente do pressuposto das pessoas tentarem usar conceitos de OOP em JavaScript sem conhecer muito bem como classes funcionam por de baixo dos panos, culminando em softwares de difícil manutenção e suscetíveis a problemas de herança que surgem após algum tempo de desenvolvimento.


Uma tentativa Funcional

Não pense que JavaScript é uma linguagem funcional, pois não é. JavaScript está num limbo onde a maioria dos principais atrativos da linguagem não dão suporte completo aos paradigmas existentes. JavaScript quase implementa seu próprio paradigma de programação, um verdadeiro monstro de Frankenstein.

Linguagens que implementam o paradigma funcional possuem características únicas. Essas linguagens se apoiam em um sistema de tipos robusto e várias otimizações de performance, já que conceitos como recurção e imutabilidade caminham de mãos dadas ao paradigma funcional.

JavaScript não tem suporte a recursão de cauda, ou seja, o ato de, quando uma função chamar a si mesma e a chamada for a última instrução do corpo da função e o contexto atual não depender do próximo contexto, em vez de ser empilhado mais uma chamada, a função simplesmente “pula” para o início. Portanto é necessário o uso de iteração em JavaScript.

Também não existe um meio não muito verboso de criar dados imutáveis. Existe a palavra chave const que cria uma referência imutável a uma área de memória, porém não cria objetos imutáveis, ou seja:

const u = {
    name: 'Gustavo'
}

u.name = 'Ciclano';
console.log(u); // { name: 'Ciclano' }
Enter fullscreen mode Exit fullscreen mode

Porém, mesmo com todas as deficiências do JavaScript, essa linguagem é incrível. É excepcionalmente performática em lidar com objetos e criar funções. Operar com funções em JavaScript é uma tarefa prazerosa, é possível compor funções, misturar objetos e mesmo assim, se seguirmos as regras corretas, no final do dia ter um software extremamente estável e determinístico.


Uma mistura inusitada

Particularmente, prefiro definir um tipo por módulo (arquivo), junto de funções que operam sobre este tipo. É como criar um protocolo de acesso de informação dentro de um módulo. Mas como podemos criar tipos em JavaScript?

Classes ou funções construtoras são formas de criar novos tipos. Para criar um tipo User poderíamos tomar dois caminhos:

// função construtora
function User(name, age) {
    this.name = () => name;
    this.age = () => age;
}

// Classe
class User {
    constructor(name, age) {
        this.name = () => name;
        this.age = () => age;
    }
}
Enter fullscreen mode Exit fullscreen mode

Em ambos os casos, a instanciação de novos objetos do tipo User ocorre da mesma forma:

let u = new User('Gustavo', 25);
u.name(); // Gustavo
u.age(); // 25
Enter fullscreen mode Exit fullscreen mode

Porém, em qualquer lugar é possível acessar as funções name e age. Existem casos onde pode ser necessário transitar esta instância de usuário por bibliotecas que não são totalmente confiáveis. Também existem casos onde simplesmente não queremos deixar estas informações acessíveis pelo motivo de seguirmos boas práticas de programação.

A forma que encontrei para contornar este problema de forma particularmente “elegante” é usar símbolos e funções construtoras. Porque símbolos? Símbolos são pedaços de informação única, não são copiáveis, tampouco acessíveis caso usados como parâmetros de objetos e é neste caso que usarei os símbolos do JavaScript.

Também prefiro funções construtoras em vez de classes, pelos motivos já citados no início do artigo.

Ok, mas como é possível organizar o software usando funções e símbolos? Primeiro vamos criar um arquivo chamado user.js e definir dois símbolos:

// user.js
const nameAttr = Symbol('user/name');
const ageAttr = Symbol('user/age');
Enter fullscreen mode Exit fullscreen mode

Agora vamos definir uma função construtora que define o tipo User:

// user.js
const nameAttr = Symbol('user/name');
const ageAttr = Symbol('user/age');
export default function User(name, age) {
    this[nameAttr] = () => name;
    this[ageAttr] = () => age;
}
Enter fullscreen mode Exit fullscreen mode

Há um porém no código acima: não queremos criar uma instância de User caso o nome e a idade não sejam atribuídos. Não devem existir instâncias de usuários sem nome ou sem idade. Para isso, podemos definir algumas asserções simples de existência (não vou me preocupar com os tipos dos parâmetros neste artigo):

// user.mjs

const nameAttr = Symbol('user/name');
const ageAttr = Symbol('user/age');

export default function User(name, age) {
    if (!name) {
        throw new Error('Cannot create an User without name.');
    }
    if (!age) {
        throw new Error('Cannot create an User without age.');
    }
    this[nameAttr] = () => name;
    this[ageAttr] = () => age;
}
Enter fullscreen mode Exit fullscreen mode

O que há de especial na função User acima? Vamos criar uma instância e inspecionar:

let u = new User('Gustavo', 25);
// e agora?
Enter fullscreen mode Exit fullscreen mode

Não é possível acessar as funções que retornam o nome e a idade. É simplesmente impossível acessar estas funções sem ter acesso aos símbolos que definem as propriedades que, então, definem estas funções. Foi criado um protocolo de acesso onde somente as funções que têm acesso aos símbolos conseguem invocar as funções definidas no tipo User, a sacada aqui é que os símbolos não são exportados do módulo.

Vamos então definir as funções que operam sobre instâncias de usuários:

function getName(user) {
    return user[nameAttr]();
}

function getAge(user) {
    return user[ageAttr]();
}
Enter fullscreen mode Exit fullscreen mode

Agora é possível acessar o nome e idade de um usuário usando estas funções:

getName(u); // Gustavo
getAge(u); // 25
Enter fullscreen mode Exit fullscreen mode

Legal, mas estas funções somente operam sobre instâncias de usuários. Para prevenir que estas funções sejam invocadas sob a informação incorreta, podemos definir uma asserção de tipo em cada função getter:

function getName(user) {
    if (user instanceof User) {
        return user[nameAttr]();
    }

    throw new TypeError('Provided data is not of the type User');
}
Enter fullscreen mode Exit fullscreen mode

E invocar como:

getName(u); // Gustavo
getName({ name: 'Gustavo' });
// Uncaught TypeError: Provided data is not of the type User
Enter fullscreen mode Exit fullscreen mode

Legal. Mas colocar esta validação de tipo dentro de cada função viola os princípios do DRY. Podemos usar os poderes da abstração e criar uma função que “decora” outras funções e serve como guardiã do argumento. Caso o argumento seja uma informação diferente de uma instância de User a função decorada não deve ser invocada. Podemos definir uma função chamada withGuardUser que vai lidar com o tipo do argumento das funções:

function withGuardUser(fn) {
    return function withUser(user) {
        if (user instanceof User) {
            return fn(user);
        }

        throw new TypeError(
            'Provided data is not of the type User'
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Agora podemos criar duas novas funções e exportá-las do nosso módulo user.js que vão operar de forma segura sobre instâncias de usuários:

export const name = withGuardUser(getName);
export const age = withGuardUser(getAge);
Enter fullscreen mode Exit fullscreen mode

Em um outro arquivo, por exemplo, user1.js, importe a função construtora User e as funções de projeção name e age:

// user1.js
import User, { name, age } from './user.mjs';

const user = new User('Gustavo', 25);
console.log(name(user)); // Gustavo
console.log(age(user)); // 25
Enter fullscreen mode Exit fullscreen mode

Aviso: para usar a sintaxe de ES Modules no Node.js 13, não esqueça de criar um arquivo package.json no diretório onde os seus arquivos estão armazenados contendo o parâmetro "type" cujo valor é "module". Veja mais sobre isso em https://nodejs.org/api/esm.html.


Um exemplo

Considere que é necessário construir uma interface de histórico de registro de ações de um determinado usuário em um determinado sistema, e gostaríamos de mostrar este registro da seguinte forma:

[Sun, 09 Feb 2020 04:07:11 GMT | GUSTAVO]: Action


...
Enter fullscreen mode Exit fullscreen mode

Podemos começar definindo uma função que recebe um texto e retorna este texto com todas as letras maiúsculas:

const upper = (text) => text.toUpperCase();
Enter fullscreen mode Exit fullscreen mode

Agora precisamos definir uma função que retorna aquele texto de data de ação para um determinado carimbo de tempo:

const utcString = (timestamp) =>
    (new Date(timestamp)).toUTCString();
Enter fullscreen mode Exit fullscreen mode

Certo, agora precisamos gerar esse carimbo de tempo, podemos criar uma função chamada now que faz exatamente isso:

const now = () => Date.now();
Enter fullscreen mode Exit fullscreen mode

A parte crucial é montar a string de prefixo, então podemos definir uma função chamada actionLoggerPrefix, definida como:

const actionLoggerPrefix = (userName) => {
    const ts = now();
    const date = utcString(ts);
    return `[${[date, userName].join(' | ')}]`;
}
Enter fullscreen mode Exit fullscreen mode

Usando uma função de pipeline de dados, por exemplo, a função pipe:

const pipe = (...fns) => (...args) =>
    fns.reduce((acc, fn) => fn(acc), ...args);
Enter fullscreen mode Exit fullscreen mode

Podemos então criar a função prefix que usa as funções name , upper e actionLoggerPrefix para criar uma função que recebe uma instância de um usuário do tipo User e retorna um texto com a formatação adequada:

const prefix = pipe(name, upper, actionLoggerPrefix);
console.log(prefix(user)); // [Sun, 09 Feb 2020 04:24:59 GMT | GUSTAVO]
Enter fullscreen mode Exit fullscreen mode

Veja, criamos uma função complexa que é a junção de três funções bem simples, bem fáceis de dar manutenção e independentes de contexto. A função name opera somente sobre instâncias do tipo User, portanto as chances das funções serem invocadas sobre o tipo de dado incorreto são muito menores.

Confira o código final do módulo que opera sobre o tipo User:

// user.mjs

const nameAttr = Symbol('user/name');
const ageAttr = Symbol('user/age');

export default function User(name, age) {
    if (!name) {
        throw new TypeError('Cannot create an User without name.');
    }
    if (!age) {
        throw new TypeError('Cannot create an User without age');
    }

    this[nameAttr] = () => name;
    this[ageAttr] = () => age;
}


function withGuardUser(fn) {
    return function withUser(user) {
        if (user instanceof User) {
            return fn(user);
        }

        throw new TypeError(
            'Provided data is not of the type User'
        );
    }
}


function getName(user) {
    return user[nameAttr]();
}


function getAge(user) {
    return user[ageAttr]();
}


export const name = withGuardUser(getName);
export const age = withGuardUser(getAge);
Enter fullscreen mode Exit fullscreen mode

E esse é o segundo arquivo contendo as outras funções criadas para solucionar parte do problema de listagem de registro de ações:

// user1.js

import User, { name, age } from './user.mjs';

const user = new User('Gustavo', 25);


const now = () => Date.now();

const utcString = (timestamp) =>
    (new Date(timestamp)).toUTCString();

const upper = (text) => text.toUpperCase();

const actionLoggerPrefix = (userName) => {
    const ts = now();
    const date = utcString(ts);

    return `[${[date, userName].join(' | ')}]`;
}

const pipe = (...fns) => (...args) =>
    fns.reduce((acc, fn) => fn(acc), ...args);


const prefix = pipe(name, upper, actionLoggerPrefix);

console.log(prefix(user));
// [Sun, 09 Feb 2020 14:50:02 GMT | GUSTAVO]
Enter fullscreen mode Exit fullscreen mode

Considerações finais e pessoais

No final do dia, seu software deve funcionar, ser testado, ser estável e não exigir mais energia cognitiva que o necessário para entender como as coisas funcionam. Eu acredito que adotar uma abordagem mais declarativa no dia a dia é de suma importância para desenvolver softwares estáveis e fáceis de testar. Também inibe más práticas de programação. É muito difícil violar o DRY escrevendo funções pequenas.

Mas lembre-se de que as pessoas que trabalham contigo precisam estar dispostas a abrir a mente e adotar abstrações que tornam o código mais declarativo. Isso é de uma importância inimaginável.

Portanto o meu conselho que fica é que, se você que leu este texto até o fim é uma pessoa que se considera pronta para adotar os conceitos funcionais e declarativos no dia a dia, não esqueça de conversar com seu time, saiba a opinião das pessoas quanto a programação funcional, descubra se são pessoas abertas a mudanças, talvez introduzir algumas regras no linter usado nos projetos da empresa. Forçar que todos desenvolvam de uma maneira que não estão acostumados, pode mais trazer problemas do que soluções.

Top comments (0)