Go é um castigo merecido

Ou seria “golang”?

golang golang golang golang golang golang golang golang golang golang golang golang golang golang

Pronto. Talvez assim esse artigo seja encontrado quando alguém buscar algo sobre “Go” no Google.


Briguinhas

Durante uma década inteira ficamos contentes com nossas pequenas brigas entre diversos feudos. Python (ou, “os programadores que usavam linguagem Python”) fazia graça com Java e sua sintaxe ridícula, Java gabava-se de ser “Enterprise” enquanto temia a vinda da .Net que, por sua vez, tentava ser um pouco de tudo, mas focava mesmo em C#, que por sua vez era desprezada pelo C++ por ser “muito pesada” ou “pouco portável” enquanto o velho C assistia a tudo, sentindo-se superior, e dava risada enquanto seus usuários dançavam num salão recém-encerado carregando navalhas.

Mas no fundo todo mundo meio que se gostava e a aparência de guerra escondia uma espécie de fraternidade, um sentimento de que as coisas eram do jeito que eram e assim permaneceriam e, por isso, cada um poderia gozar de paz e tranquilidade em seu próprio nicho. Se você queria desenvolver rápido, que usasse Python. Se precisava de desempenho máximo, que usasse C. Se queria desempenho, mas ainda mantendo um mínimo de abstrações úteis, poderia usar C++. E se queria jogar dinheiro no lixo ou precisava integrar com aplicações “corporate”, sempre podia usar Java.

E a vida ia seguindo, com um ou outro hipster aqui e ali apregoando o uso de Haskell ou Elixir e lá no canto do salão, encostado na parede segurando um copo de refrigerante sozinho estava alguém que defendia o uso da linguagem D. E debaixo da mesa, onde ninguém via nada, alguns programadores assembly.

E foi no meio dessa mamata que, despercebida, chegou A Praga, trazida para punir nossa falta de vigilância, nossa loucura e até nossa soberba. Chegou a linguagem Go.

SE não chegou {
    log.Fatalln("Não chegou!")
}

Necessidade desprezada

Era evidente que esse dia chegaria. Que os problemas que as principais linguagens simplesmente ignoravam ou tentavam remendar com fita crepe seriam finalmente resolvidos por alguma entidade que tivesse não somente a capacidade de pagar para isso mas também para criar hype suficiente para alavancar o uso da nova solução por meio de uma legião da fanboys ensandecidos.

Python tem uma sintaxe magnífica. E features magníficas. Mas e o tempo de execução? Uma hora ou outra o fato de os Lambda demorarem 10x mais para cada requisição do que uma linguagem compilada nativamente iria pesar na balança. Uma hora ou outra as chatices da distribuição de código com bibliotecas em repositórios privados interdependentes seria percebida por alguém.

C é rápido, mas como faz para instalar um pacote? Não tem pacote? Pessoal ainda faz “vendoring”? Sério?

SE C tem sistema de distribuição de módulos/pacotes {
    log.Println("Legal. Gostaria de conhecer")
    return err, nil
}

E Java é... “Enterprise”. Mas por mais que seja vendida como algo magnífico, por que os jovens talentos geralmente passam longe?

SE jovens talentos não passam longe {
    log.Println("Ops, desculpe. É a minha impressão")
    return err, nil
}

Em resumo, havia sim uma necessidade grande entre as empresas de uma linguagem simples o bastante para ser aprendida rapidamente, compilada nativamente e de maneira rápida, sem grandes dificuldades de gestão de memória e que tivesse excelente desempenho para todos poderem economizar em tempo de execução nessa nossa era de computação-paga-por-milissegundo.

Mas nós desprezamos isso e, soberbos, achamos que tudo iria ficar do mesmo jeito para sempre. Que engano triste! Que sonolência da percepção! Que arrogância!

SE não era arrogância {
    return nil, err
}

O grande problema de Go

A linguagem Go é uma mal terrível por dois motivos de igual importância, que são:

Go é uma atrocidade

Em termos de sintaxe

Go é uma linguagem horrenda que sabe vender-se bem. Não é? Go é uma linguagem complicada, que consegue unir a presença de garbage collection com ponteiros. Que limpa memória para você, mas te obriga a ficar pensando em alocação de memória o tempo todo. Que te dá “ferramentas simples”, como go routines e canais, mas que no fim do dia não passam de corda para você se enforcar.

Import com sintaxe de include

As linguagens que implementam módulos geralmente lidam com importação. Importação de módulos é um processo geralmente muito diferente da inclusão de arquivos. E como os módulos geralmente tem nomes seguindo algum padrão razoável, as linguagens mais comuns acabam usando uma sintaxe assim:

import os

Em geral, quando aplicável, a importação implica também em execução do módulo em questão.

Se você quiser dar outro nome para o módulo importado, em Python, pode fazê-lo assim:

import my_class as base_class

Ou seja: importe ᐸnome originalᐳ como ᐸnome localᐳ.

Já a inclusão é um processo mais literal: o #include do pré-processador de C, por exemplo, copia todo o conteúdo do arquivo a ser incluído dentro do arquivo em que essa diretiva é chamada.

#include "vendor/gui/window.h"

E numa espécie de meio-termo existe o conceito do require, que geralmente tem sintaxe mais similar ao include, mas comportamento muitas vezes similar ao import, ou seja, implicando execução.

User = require("../modules/user.js");

Em Nodejs, o require traz nomes que foram explicitamente expostos pelo módulo. Já em Ruby, o require tem um processo muito mais parecido com uma inclusão, o que explica, inclusive, por que Ruby permite definir módulos dentro dos arquivos (ao contrário de Python, em que os módulos são definidos pela forma como os arquivos estão dispostos no sistema de arquivos).

require 'amqp_client'

Mas aí vem Go e manda os consensos pras cucuias:

import (
    "fmt"
    "os"

    log "github.com/sirupsen/logrus"
)

Que. Parada. Grotesca.

Há N maneiras de tornar a sintaxe mais clara e até agradável, mas não, os criadores de Go são mais espertos do que nós. Eles inventaram um jeito próprio ou baseado em alguma referência obscura que por mais “inovador” que possa parecer, não acrescenta nada às nossas vidas — pelo contrário, torna a leitura muito mais confusa.

Não é porque você se dá a liberdade de inovar que precisa, necessariamente, fazer uma bela cagada. Veja Zig, por exemplo, que também é uma linguagem muito jovem mas trata as importações de maneira muito mais inteligente:

const std = @import("std");

Tudo aqui é “sintaxe normal” da linguagem, exceto o @, que deve ligar um alerta na cabeça do desenvolvedor, que pensa “hum, esse símbolo deve significar que essa instrução “import” tem algo de especial”. E tem! Instruções que começam com @ são a forma como o programador fala com o compilador.

Nim, por sua vez, mesmo sendo uma linguagem compilada, implementou um sistema magnífico de módulos (é magnífico mesmo) com a sintaxe já bem conhecida:

import threads

Mas Go não somente implementa esse jeito bizarro, com parêntesis, sem vírgulas e usando algo que aparenta ser strings, como também permite uma forma simplificada:

import "fmt"
import "log"

Curiosamente, o compilador não reclama se você importar todos os seus pacotes assim...

Weird Walrus

Uma das coisas mais esquisitas de Go é o walrus operator (:=). É esquisito, em primeiro lugar, porque gera dois jeitos completamente diferentes de se declarar variáveis.

var name string  // 1

surname := "Silva"  // 2

Considero C a sintaxe mais elegante quanto à declaração e inicialização de variáveis. Veja como é feito:

char *name; // 1

char *surname = "Silva"; // 2

(Lembre-se: não existe um tipo “string” em C. Strings são cadeias de bytes e a sintaxe "string", com as aspas, é mero açúcar sintático.)

Mas não para por aí. O walrus serve, em Go, para “declarar e inicializar”, certo? Tanto que não se pode declarar duas vezes a mesma variável:

a := 1
a := 2  // Aqui o compilador acusará um erro.

Entretanto, como uma costura bem mal-feita para o problema do tratamento imediato e constante de erros, os criadores de Go resolveram que tudo bem uma variável ser exposta ao walrus mais de uma vez, contanto que esteja no meio de um grupo em que alguma variável nova é declarada. Veja:

err := nil
result, err := function()

Viu? err passou pelo walrus duas vezes e quanto a isso o compilador não falará nada. Ou seja: o walrus operator em Go serve para “*declarar e inicializar alguma variável no grupo à esquerda*“. O que torna a sintaxe levemente confusa. Não tanto, admito, mas leva a outro grande mal de Go, que é...

Constante reescrita das coisas

Você chama N funções dentro de um mesmo bloco de código? Pois se prepare, se um dia quiser chamar mais uma antes de todas as outras, terá que meter a mão no código antigo, muito provavelmente.

Explico: lembram que o walrus operator é usado para “declarar e inicializar”? Pois é. E praticamente ninguém declara o maldito do err separadamente. O que causa o seguinte efeito. Imagine que você tenha um código assim:

_, err := function_1()
if err != nil {
    // Tratar o erro
}
z, err := function_2()
if err != nil {
    // Tratar o erro
}

Não somente é irritante ter que tratar todos os erros imediatamente (e uma decisão de design patética, sinceramente), mas se você quiser adicionar uma chamada a outra função antes de function_1, terá que reescrever sua chamada:

x, err := function_0()  // err é declarado pelo walrus, aqui
if err != nil {
    // Tratar o erro
}
_, err := function_1()  // Agora ESSE walrus ficou errado! Tem que reescrever
if err != nil {
    // Tratar o erro
}
z, err := function_2(y)
if err != nil {
    // Tratar o erro
}

E quando você está lidando em um problema razoavelmente complexo, ou quando terminou de implementar e está melhorando o design da sua função, trocando algumas chamadas de lugar para tornar tudo mais claro, é de arrancar os cabelos ter que ficar tratando esses erros estúpidos de walrus/not-walrus a toda hora.

A “solução”? Declarar err antecipadamente.

var err error

x, err := function_0()
if err != nil {
    // Tratar o erro
}
_, err = function_1()
if err != nil {
    // Tratar o erro
}
z, err := function_2(y)
if err != nil {
    // Tratar o erro
}

Além da horripilância que é ter que dizer var err error (parece um cachorro rosnando) no topo da função, chamando a atenção para um aspecto secundário da vida da mesma (vital, mas secundário), ainda assim você tem que prestar muita atenção no uso ou não do walrus operator, porque se quiser descartar algum valor, terá que usar atribuição simples.

(Ou seja: não tem como ficar bom.)

Como a linguagem poderia corrigir isso? Simples: implementando apenas atribuição simples. Mas precisaria ser implementado na linguagem, porque tentar simular isso é praticamente impossível, hoje, já que o desenvolvedor geralmente quer aproveitar a mesma variável err ao invés de piorar ainda mais sua situação criando um rastro de err1, err2, err3...

var a, b = 10, 20  // Funciona!
var a, err = f(b)   // Não compila, porque `err` não foi declarada.
a, err := f(b)  // Não compila, porque `a` já foi declarada. (E nem queremos usar o walrus!)

// Solução medonha:
var err Error
a, err = f(b)  // Compila

// Mas, agora, somos obrigados a sempre declarar previamente cada variável usada:
var c int
c, err = f(b)

Mas é desse jeito porque os erros não podem ser ignorados. É uma feature da linguagem!”, alguém dirá. Ao que respondo: não confunda funcionalidade com sintaxe. Zig também te proíbe de ignorar erros, mas resolveu a questão toda simplesmente com o uso de tipos:

const std = @import("std");
pub fn main() void {
    f = std.fs.cwd().openFile("does_not_exist/foo.txt", .{}) catch |err| switch (err) {/* handle all errors here */};
}

Cada função pode retornar tanto um tipo “comum” quanto algum tipo derivado do tipo base de erro. A linguagem é capaz de detectar um retorno do tipo erro. E para evitar que o desenvolvedor fique repetindo código a toda hora, especialmente nos casos em que quer que os erros “popem para o chamador”, há o seguinte atalho:

const std = @import("std");
pub fn main() void {
    f = try std.fs.cwd().openFile("does_not_exist/foo.txt", .{});
}

(try do Zig não tem nada a ver com exceções ou com a famosa dupla try/catch de outras linguagens, que fique bem claro desde já.)

O que esse try faz é justamente isso: ele tenta. Se não der certo (ou seja: se o retorno da função é um valor cujo tipo representa um erro), retorna imediatamente esse mesmo erro.

Isso é um atalho para o seguinte:

const std = @import("std");
pub fn main() void {
    f = std.fs.cwd().openFile("does_not_exist/foo.txt", .{}) catch |err| return err;
}

E eis aí um mesmo problema sendo resolvido de forma patética e magnífica por duas linguagens diferentes, e ambas sem recorrer ao lançamento de exceções.

Tratar erros precocemente é ridículo

Ao ponto de ter se criado o costume de criar a função DoSomething() (int, error) e sua irmã MustDoSomething() int, ou seja, a primeira retorna um valor em err caso tenha ocorrido algum erro, enquanto a outra já tem um comportamento embutido que é matar o programa caso algo de errado aconteça.

Há décadas sabemos que, a não ser que seja o caso de uma saída imediata e forçada, tratar erros precocemente é um anti-pattern, uma coisa ruim que não deve ser feita.

Mas Go, na cabeça genial de quem criou a linguagem, considera que o mundo é o oposto do que é e que a maioria dos casos de erro significa saída imediata, sem tratamento algum por parte da pilha de chamadores. E o problema é que, no mundo real, teu código ficará repleto dessa construção particular:

y, err := f(x)
if err != nil {
    return nil, err
}

Go é tão chato que não permite nem que você use um atalho mais simpático, como isso:

y, err := f(x)
if err != nil return nil, err  // Cuéum! Não pode.

Tampouco como isso:

y, err := f(x)
if err return nil, err  // Cuéum! Menos ainda!

Ou seja: se acontecer de você chamar N funções, haverá no mínimo 3N linhas servindo simplesmente para retornar o código de erro para o chamador.

Chama 5 funções? Serão 15 linhas gastas apenas para propagar os erros.

Mas, espere!, você pode economizar 1 linha, sabia? Sim. E o preço a pagar é simplesmente abrir mão de um mínimo de legibilidade.

if y, err := f(x); err != nil {
    return nil, err
}

Pronto. Agora não dá pra entender mais nada. :–)

Mas fica pior! Lembra que Zig trata os erros elegantemente com o uso de tipos? Pois é. Em Go você não é obrigado a usar somente o error do sistema, já que este é uma interface. Excelente, não? Exceto que o tratamento de acordo com o tipo de erro é assim:

if err := dec.Decode(&val); err != nil {
    if serr, ok := err.(*json.SyntaxError); ok {
        line, col := findLine(f, serr.Offset)
        return fmt.Errorf("%s:%d:%d: %v", f.Name(), line, col, err)
    }
    return err
}

Entendeu alguma coisa? Pois é. Um horror. Acontece que type assertion em Go tem essa sintaxe bizarra:

extracted_value, ok := analyzed_value.(T)

Ou seja: ok será verdadeiro se o tipo de analyzed_value for igual a T.

O problema disso? A sintaxe é horrenda. E por que eu digo que é horrenda? Bom, experimente ler o seguinte código em voz alta, um conceito depois do outro, sem pular nenhum:

if value, ok := err.(JsonSyntaxError); ok {
    log.Fatalf("Error: JSON is in incorrect format: %s\n", value.details)
}

Se valor, ok, recebe err ponto JsonSyntaxError entre parêntesis e ok é verdadeiro, então...

Horrível!

Zig permite criar estruturas muito mais legíveis:

if (parseU64(str, 10)) |number| {
    doSomethingWithNumber(number);
} else |err| switch (err) {
    error.Overflow =ᐳ {
        // handle overflow...
    },
    ...
}

Novamente, experimente ler em voz alta: se ParseU64 de str, 10 retornar number, faça algo com number, se não, com o erro retornado, caso este erro seja Overflow, faça....

Muito melhor, não? Mas você não é obrigado a fazer isso e pode usar a sintaxe que mencionei anteriormente, ainda:

const number = parseU64(str, 10) catch |err| switch (err) {
    error.Overflow =ᐳ {
        // handle overflow...
    }
}

O primeiro jeito pode ser melhor caso um erro seja um acontecimento esperado, fazendo parte do fluxo considerado normal da função. Já o segundo faz com que o fato de ter acontecido um erro pareça algo fora do comum e inesperado. E você tem a liberdade de escolher a forma que achar mais adequada.

Em termos de ferramental

As bibliotecas tratam certos casos incomuns com um desleixo bizarro e, no geral, “corretude” é uma palavra estranha à biblioteca padrão da linguagem.

Confira o post I want off Mr. Golang's Wild Ride quando tiver um tempo.

Ademais, os modules são uma história complicada, a implementação de versionamento gerou uns casos bizarros, GOPATH é um conceito que ainda complica a vida de muita gente e simplesmente compilar um pacote é um trabalho bem mais complicado do que poderia ser, especialmente tratando-se de uma linguagem tão nova.

(Mas não tratarei sobre cada caso desses aqui. Talvez em outro artigo.)

Go cumpre os requisitos para desenvolvimento profissional

E, apesar de todos os problemas de sintaxe e de implementação, absolutamente não há um candidato à altura de Go para essa tarefa de desenvolver software que chamo de commodity.

Software commodity é aquele serviço de autenticação que pega usuário e senha, bate o olho no banco, faz um hash, compara strings e retorna um token ou um código de erro adequado. Inovação? Zero. E é implementado todo santo dia em N empresas mundo afora.

Para esse tipo de trabalho, Go é a linguagem perfeita, infelizmente.

É perfeita porque funciona, é perfeita porque é, sim, fácil de aprender (você entende todos os motivos para odiar a linguagem em dois ou três dias, apenas) e é perfeita porque tem *hype*, o que faz com que haja um ecossistema vivo o bastante para não obrigar as empresas a implementar in-house a maioria das coisas que precisa para fazer software commodity.

E é aí que entra a minha crítica a “programadores esnobes” como eu, que valorizam a clareza magnífica da sintaxe de Python ou Nim ou mesmo Zig mas não mexeram um dedo para implementar em alguma linguagem que seja uma potencial candidata a arrancar Go do trono (como Nim ou Zig) aquela biblioteca que serve todo santo dia de desculpa para não escolher Nim ou Zig como nova “linguagem principal” da empresa e ir com Go que “é mais garantido”.

Eu encontrei uma biblioteca para lidar com GraphQL em Nim, que é baseada na libgraphql escrita em C! Entende quantos pontos isso tira de uma linguagem? A compatibilidade com C é incrível se você usa alguma biblioteca em C, mas ter que instalar a lib em Nim e também a biblioteca em C, provavelmente usando “vendoring” (porque não achei no repositório da minha distro) é de matar.

O post que citei reclama que as bibliotecas Go não tratam direito o caso de haver um arquivo chamado, veja só, \xbd\xb2\x3d\xbc\x20\xe2\x8c\x98 (um conjuntão de bytes meio aleatórios). E a reclamação é válida caso você esteja criando algum software que trabalhe primariamente com o sistema de arquivos e precise lidar com casos assim “complexos” (não é nem um pouco complexo, mas certamente é pouco habitual, certo?), mas na maioria dos sofwares commodity isso absolutamente não faz diferença. E é por isso que, no fim do dia, “Go compila rápido” ganha de “Rust é correto”. E de goleada!

É óbvio que a realidade é completamente distinta quando se trata de software de valor (ao contrário do sofware commodity). Veja o Ray tracing in Nim, por exemplo, para entender como as features da linguagem ajudam muitão o desenvolvedor a chegar num excelente resultado. Mas esse é o tipo de caso em que os fatores levados em consideração são absolutamente outros.

No dia-a-dia de 90% das empresas que postam vagas nos boards de emprego Brasil afora o que se faz majoritariamente é sofware commodity que compõe algum sistema que, ele sim, entrega algum valor real. Mas não os componentes! Os componentes são simples e pouco importantes individualmente, o que faz com que Go seja a escolha perfeita para elas.

É claro que isso não se aplica a todas as empresas. Tampouco a “todas as grandes empresas”. A Basecamp está lá, firme e forte com seu belo monolito escrito em Rails. é importante que a linguagem seja rica em funcionalidades, clara e correta. Mas lá até mesmo as exigências para contratação são muito diferentes.

A Basecamp pode rejeitar candidatos e ainda assim haverá uma fila de gente querendo trabalhar lá. A Josécamp EIRELI não pode se dar a esse luxo e tem muita dificuldade de encontrar interessados! E lá, se a linguagem absolutamente não conseguir tratar um arquivo com nome \xbd\xb2\x3d\xbc\x20\xe2\x8c\x98, o que se faz é simplesmente enviar um e-mail para o cliente dizendo “por favor, use caracteres normais ou o sistema não vai funcionar” e, pronto!, o problema está resolvido e podemos gastar energia em algo mais lucrativo.

E a culpa é nossa

Eu, particularmente, torço para que uma linguagem decente destrone Go nesses casos de uso específicos. E não acho que será Nim ou Zig que conseguirão (apesar de achar que Zig, quando tiver um ecossistema bem rico, chegará muito, muito próximo disso). Mas, enquanto isso não acontece, é responsabilidade nossa, dos desenvolvedores que conseguem ver a confusão miserável que é a sintaxe e a implementação de Go, enriquecer os ecossistemas das linguagens que apreciamos e que podem ser magníficas, rápidas, belas, portáveis e repletas de funcionalidades interessantíssimas, mas carecem de boas bibliotecas para coisas triviais como HTTP, REST, GraphQL, ORMs, templating, comunicação com inúmeros protocolos, etc.

E nós temos plena capacidade de fazer isso, certo?

Prosseguindo

Nos próximos episódios dessa série pretendo implementar uma biblioteca de GraphQL em diversas linguagens, entre elas Go (não acho as implementações atuais muito boas, particularmente), Zig, Nim e outras, em parte para explorar essa capacidade de preencher a mesma lacuna que Go preenche, em parte para demonstrar o quão interessantes e poderosas certas linguagens são (por puro prazer, sem nenhum objetivo realmente prático). E aos poucos quero ir construindo a ideia do que seria uma linguagem de programação “ideal” (o conceito é amplo, mas vamos definindo-o, também).

Até!


Este artigo foi escrito originalmente lá por 2021...