Uma história não tão breve sobre a evolução das linguagens de programação#

Este artigo foi salvo de um longo post que fiz no Telegram, por volta de 2018! Ele foi achado quando eu tentava configurar o Helix no Windows, mas isto já é outra história :-D.

Eu fiz algumas correções ao texto original e adicionei alguns links, mas lembre-se que o texto veio de um chat e é mais informal que um texto histórico. Visite os links para ter uma visão mais ampla de cada assunto.

Batalhas aqui no sentido de comparações sobre qual é a melhor linguagem para fazer algo, qual a mais produtiva, mais rápida, mais usada, mais moderna, etc. Essas batalhas sobre linguagens de programação são frequentes em grupos e em listas. Muitos ignoram que tudo vira instruções que a máquina é capaz de executar, o que muda são os métodos para se chegar neste resultado e o desempenho final.

No início, a programação era complicada, e feita com fios (isso falando de computadores da época da Segunda Guerra Mundial).

Programando Eniac [1]

Para alterar um programa era necessário reconfigurar paneis com várias conexões. Esses painéis lembram aqueles usados para fazer ligações telefônicas do início do século XX.

Operadoras de uma cabine de comutação [2]

Devido à grande dificuldade em se reprogramar estes computadores, viu-se a vantagem de tornar a programação flexível e de se armazenar dados e programas em memória. Os programas passaram a ser um tipo especial de dados. Arquitetura de Von Neumann

Os computadores e os programas começaram a crescer. No início, os programadores programavam apenas em código de máquina, instruções que eram codificadas para cada processador.

Código de Máquina Arquitetura
6689D86689C3 Intel x86
89D889C3 AMD x64

Se você nunca havia visto um programa em código de máquina, acabou de ver dois. A primeira linha mostra o código para a arquitetura Intel x86 (32 bits) e a seguinte para a arquitetura AMD x64 (64 bits). Esses programas não fazem quase nada, apenas movimentam os dados de um registrador para outro.

Depois veio a ideia de agrupar instruções semelhantes em mnemônicos (abreviações que ajudam a memorizar o significado), aparece o assembly, ou linguagem de montagem:

0:  89 d8                   mov    eax, ebx
2:  89 c3                   mov    ebx, eax

Veja o programa anterior, para arquitetura Intel x64, mas desta vez, com as instruções em assembly a direita. A instrução mov copia dos dados de um lugar para outro. Na primeira linha, o valor do registrador ebx para o registrador eax.

Uma instrução em assembly pode gerar várias instruções de máquina diferentes:

mov registrador, memória
mov memória, registrador

São a mesma instrução em assembly (mov), mas são diferentes em linguagem de máquina, pois a CPU precisa saber precisamente qual instrução executar. Dependendo se o mov é utilizado com dois registradores ou entre um registrador e um endereço em memória, já é outra instrução em código de máquina. Quem faz essa tradução é um programa montador, o assembler. Lembre-se, a linguagem é assembly (e varia de um processador para outro, podendo inclusive ter notações diferentes) e o programa que monta (traduz, compila) de assembly para código de máquina é o assembler.

0:  8b 04 25 e8 03 00 00    mov    eax,DWORD PTR ds:0x3e8
7:  89 1c 25 e8 03 00 00    mov    DWORD PTR ds:0x3e8,ebx

Isso começou a facilitar as coisas, mas com mais memória e mais poder de processamento, precisávamos de formas mais avançadas de programação. Surgem as primeiras linguagens de programação de alto nível: Fortran (1954, 1957 compilada) e depois Algol (1958) que influenciaram linguagens que usamos até hoje.

Em Fortran eles resolveram passar para a máquina a tarefa de traduzir o código que escrevemos. A ideia era escrever os cálculos de uma forma que um matemático poderia ser treinado para codificar:

C AREA OF A TRIANGLE - HERON'S FORMULA
C INPUT - CARD READER UNIT 5, INTEGER INPUT
C OUTPUT -
C INTEGER VARIABLES START WITH I,J,K,L,M OR N
       READ(5,501) IA,IB,IC
  501  FORMAT(3I5)
       IF (IA) 701, 777, 701
  701  IF (IB) 702, 777, 702
  702  IF (IC) 703, 777, 703
  777  STOP 1
  703  S = (IA + IB + IC) / 2.0
       AREA = SQRT( S * (S - IA) * (S - IB) * (S - IC) )
       WRITE(6,801) IA,IB,IC,AREA
  801 FORMAT(4H A= ,I5,5H  B= ,I5,5H  C= ,I5,8H  AREA= ,F10.2,
     $13H SQUARE UNITS)
      STOP
      END

fonte: https://en.wikibooks.org/wiki/Fortran/Fortran_examples#Simple_Fortran_II_program

Estávamos em 1957. Avançando rapidamente, passando pelo Basic de 1964:

10 INPUT S$
20 LET T$=""
30 FOR I=LEN S$ TO 1 STEP -1
40 LET T$=T$+S$(I)
50 NEXT I
60 PRINT T$

No exemplo acima, um programa que inverte uma string, escrito em Basic do Sinclair ZX-81. https://rosettacode.org/wiki/Reverse_a_string#BASIC

Tanto no exemplo com Fortran, quanto com Basic, observe que cada linha do programa tinha um número explícito. Isto era importante, pois era comum utilizar instruções de salto, como GOTO, que mudavam a execução de uma parte do programa para outra. Não havia o conceito de bloco que usamos hoje. Em Go To Statement Considered Harmful de Edgar Dijkstra, surgem argumentos para que as novas linguagens tentem abolir ou minimizar o uso de tais construções. Estes conceitos vinham surgindo desde 1959, mas tomaram forma com o artigo de Dijkstra em 1968. Desde 1958, um comitê formado por cientistas americanos e europeus tentava criar uma linguagem para programação ALGOL (Algorithm Language, nascida com o nome de Algebraic Language)

Chegamos no Algol 68:

PROC reverse = (REF STRING s)VOID:
  FOR i TO UPB s OVER 2 DO
    CHAR c = s[i];
    s[i] := s[UPB s - i + 1];
    s[UPB s - i + 1] := c
  OD;

main:
(
  STRING text := "Was it a cat I saw";
  reverse(text);
  print((text, new line))
)

fonte: https://rosettacode.org/wiki/Category:ALGOL_68

Algol sem dúvida foi a precursora da maioria das linguagens imperativas que usamos hoje. Blocos de código e a pedra fundamental da programação estruturada.

Já bem mais próxima do que conhecemos hoje, absurdamente próxima. O Algol 68 influenciou várias linguagens, como o Pascal, C e por consequência o Python.

Uma grande vantagem das linguagens de alto nível é permitir a criação de programas, independentemente do computador onde vão ser executados. Sempre o objetivo foi converter o programa em código de máquina e continua assim até hoje. Quando programamos hoje, não nos preocupamos com código de máquina. Usamos um programa para realizar a conversão, ora um compilador, ora um interpretador.

Existem outros ramos, completamente diferentes de linguagens de programação. Dizemos paradigmas diferentes como imperativo (C, Basic, Fortran, Algol 68), funcional (Lisp, Haskell, ML) ou lógico (Prolog).

Vejamos Lisp, também dos anos 60/70:

(defun count-change (amount coins
                    &optional
                    (length (1- (length coins)))
                    (cache (make-array (list (1+ amount) (length coins))
                           :initial-element nil)))

(cond ((< length 0) 0)
      ((< amount 0) 0)
      ((= amount 0) 1)
      (t (or (aref cache amount length)
             (setf (aref cache amount length)
                    (+ (count-change (- amount (first coins)) coins length cache)
                       (count-change amount (rest coins) (1- length) cache)))))))
; (compile 'count-change) ; for CLISP
(print (count-change 100 '(25 10 5 1))) ; = 242
(print (count-change 100000 '(100 50 25 10 5 1))) ; = 13398445413854501
(terpri)

fonte: https://rosettacode.org/wiki/Category:Common_Lisp

Aqui a ideia de deixar o programa mais próximo da matemática é mais forte. Conceitos como não poder alterar a variável depois da primeira atribuição (imutabilidade), recursividade e outros recursos. Mas o trabalho do compilador era o mesmo: converter o programa para código de máquina, que por natureza é desestruturado. Desestruturado no sentido inverso da programação estruturada, pois em linguagem de máquina, a CPU utiliza endereços como números de linha e instruções de branch ou jump que implementam os gotos.

Tudo isso é equivalente, mas com apresentações diferentes. As linguagens são como capas que utilizamos para esconder o código final usado pelo computador. Essa ideia funciona tão bem que nem lembramos disso a maior parte do tempo!

Nos anos 80 e 90, as pessoas passaram ter acesso a mais poder de computação do que poderiam usar, claro, nunca é demais. O pensamento de super performance na tradução do programa para código de máquina começou a dar lugar para linguagens que aumentavam a produtividade do programador.

Ainda nos anos 80 e 90, o software passou a ser mais caro que o hardware e é assim até hoje. Por isso linguagens como Perl, PHP, Python entre outras ganharam tanto mercado. Elas resolveram problemas de produtividade do programador. Quem programava em C++, passou para Perl e viu um mundo novo. Um relatório que antes era um programa C++ de umas 1000 linhas passou a ser escrito com apenas 20 a 50 linhas em Perl. Isto aconteceu comigo, bem no início da internet comercial, lá por 95, 96. Eu programava um sistema de cobrança em C++ para Solaris, rodava muito rápido, mas já estava ficando muito grande. O objetivo nem era performance, mas processar grandes quantidades de arquivos de log para gerar relatórios de cobrança. PERL e depois PHP foram mágicas.

C++ (inverte a string):

#include <iostream>
#include <string>
#include <algorithm>

int main()
{
  std::string s;
  std::getline(std::cin, s);
  std::reverse(s.begin(), s.end()); // modifies s
  std::cout << s << std::endl;
  return 0;
}

fonte: https://rosettacode.org/wiki/Category:C%2B%2B

Perl:

$string = <STDIN>;
$flip = reverse $string;
print $flip;

Ok, em PERL, quase ninguém conseguia ler depois, mas eram apenas 20 ou 50 linhas! No pior dos casos, era mais rápido escrever de novo. Isso dá uma ideia do porquê a alta performance foi deixando de ser uma prioridade durante essa época. Além disso, todos já estavam acostumados a ter computadores mais rápidos todo ano e por mais rápidos, era muito mais rápido que os anteriores. Já não valia mais a pena escovar bits. O que o escovador de bits levava um mês para fazer, o script kiddie fazia em um ou dois dias e com menos bugs. Essa foi uma das escolhas que a comunidade que programa fez nos anos 2000. A coisa começou a mudar novamente quando se chegou a um teto na velocidade em GHz do processador. 4 ou 5 Ghz virou uma barreira física. Aumentar o clock da próxima geração de processadores se tornou muito difícil, pois eles geravam tanto calor a partir dessas frequências que impediam seu uso sem refrigeração apropriada.

Cada fabricante passou então a adotar várias estratégias para aumentar a velocidade de processamento de sues processadores. Uma delas foi melhorar a eficiência de processamento por ciclo de clock. Ou seja, fazer uma instrução que demorava 5 ciclos de clock passar a gastar apenas 3. Outra estratégia foi a de aumentar o número de processadores na mesma pastilha, os processadores passaram a ter vários núcleos (cores) para compensar de alguma forma a falta de aumento escalar na velocidade. Um dos problemas que surgiram com múltiplos cores é que as linguagens de programação não acompanharam esse movimento. Por isso, ainda estamos explorando novas formas de programar: assíncrona (que maximiza a utilização do processador durante o tempo de entrada e saída I/O), concorrente (vários programas sendo executados, às vezes um por vez, mas alternando-se rapidamente entre si, green threads, fibers) e paralela (que tenta executar várias tarefas ao mesmo tempo).

Hoje, estamos em um outro momento, onde pagamos a computação por segundo (como nos anos 60) no caso de hospedagem nas nuvens (Function As A Service - FaaS - AWS Lambda). A performance volta a ser interessante novamente e é por isso que as linguagens mais modernas tentam resolver o problema: como gerar código eficiente para o processador, mas sem matar a produtividade do programador? A tendência é procurar um equilíbrio entre facilidade para o programador e o desempenho do programa gerado.

Em lugares onde essa falta de desempenho não é crítica, você pode usar Python de olhos fechados. Por exemplo, um servidor de aplicações web com menos de 20 requisições por segundo pode tranquilamente rodar Django. Por outro lado, um servidor com 2000 conexões por segundo terá muita dificuldade de ficar de pé, tanto pela longa fila de espera pelos resultados, como pela elevada utilização de memória. Quando desempenho passa a ser um problema, você precisa combinar Python com outras linguagens. Existem várias formas de resolver o problema de escalabilidade com Python, como por exemplo, ter várias instâncias rodando e respondendo às requisições. Não é uma questão de ser possível, mas de ser viável economicamente. A equação é custo para rodar (máquinas, infraestrutura) x custo para desenvolver (tempo, mão-de-obra, qualidade).

Mas voltando a parte histórica das linguagens. No fim dos anos 60, inicio dos anos 70, quando surgem os primeiros ensaios de Orientação à Objetos. As necessidades e aplicações dos programas começam a se diferenciar.

Nos anos 70, partiu-se da ideia de programação desestruturada (tradicional) para estruturada com blocos de repetição, sem linhas numeradas de programa, sem goto e essa foi a máxima que resolveu vários problemas (lembrar da ALGOL). Porém, não funcionava bem para grandes programas nem com problemas mais complexos. Uma situação recorrente era tratar conjuntos de dados semelhantes pelas mesmas funções, porque na programação estruturada, funções são muito importantes, sendo a base de toda organização dos programas. Dados e funções eram entidades completamente separadas.

Uma nova forma de organizar os programas foi ganhando o mercado. Em 67, aparece Simula:

Begin

  Ref(TwinProcess) firstProc, secondProc;
  Class TwinProcess(Name);
  Text Name;
  Begin
      ! Initial coroutine entry (creation)
    Ref(TwinProcess) Twin;
    OutText(Name); OutText(": Creation"); OutImage;
      ! First coroutine exit
    Detach;
      ! Second coroutine entry
    OutText(Name); OutText(": Second coroutine entry"); OutImage;
      ! Second coroutine exit: switch to the twin's coroutine
    Resume(Twin);
      ! Last coroutine entry
    OutText(Name); OutText(": Last coroutine entry"); OutImage;
    Resume(Twin);
  End;
  Begin
    firstProc :- New TwinProcess ("1st Proc");
    secondProc :- New TwinProcess ("2nd Proc");
    firstProc.Twin :- secondProc;
    secondProc.Twin :- firstProc;
    OutText("Starting"); OutImage;
    Resume(firstProc);
    OutText("End");
  End;
End;

fonte: https://fr.wikipedia.org/wiki/Simula

Familiar? Classes, referências (ponteiros) liberaram os programadores da pilha porque funções são limitadas em escopo pela pilha (stack). Nos anos 70 e 80 já se usava muito o heap para não sobrecarregar a pilha. Mas você sabe o que é a pilha e o heap?

A pilha é uma estrutura de dados conhecida e muito utilizada. A grande vantagem da pilha é poder crescer e ser reduzida em grupos. Esta característica faz da pilha uma estrutura ideal para realizar a troca de contexto entre funções. Por exemplo, quando uma função é chamada, o endereço de retorno, assim como os parâmetros da função são colocados na pilha do processador. Quando a função retorna, o valor retornado é colocado na pilha (em um espaço que já havia sido reservado). É a pilha que faz com que cada variável local de seu programa seja única a cada chamada de função. Isso acontece porque as variáveis locais são alocadas na pilha e a função utiliza endereços relativos ao topo da pilha para encontrar suas variáveis locais. Desta forma, quando uma função A é chamada várias vezes, cada chamada tem suas próprias variáveis locais, normalmente endereçadas pelo deslocamento ou posição na pilha, uma vez que a cada chamada de A a pilha é deslocada de forma a poder acomodar o espaço necessário para as variáveis da função.

Diferentemente da estrutura de dados pilha, a pilha do processador é utilizada como um endereço base para diversas operações, ou seja, o ponteiro da pilha marca o topo corrente da pilha e acessamos não apenas o que está no topo, mas a partir do topo. A pilha tem um tamanho limitado, hoje em dia algo como um a dois megabytes no Windows, mas pode ser aumentada. Quando temos um erro de estouro de pilha, ou stack overflow, significa que ultrapassamos o espaço alocado para a pilha. Este espaço pode ser todo utilizado se colocarmos dados demais na pilha, criando, por exemplo, matrizes gigantes como variáveis locais ou quando chamamos a mesma função recursivamente várias vezes.

O heap é a solução encontrada para gerenciar a memória livre no sistema, ou a memória disponível para o processo/programa. O heap organiza a memória livre e a fraciona em blocos conforme vamos solicitando mais espaço. É o heap que organiza a memória alocada dinamicamente. O heap independe da pilha e normalmente pode acessar quantidades enormes de memória. Utilizamos o heap em C quando chamamos funções como malloc e free e em Python quando criamos novos elementos para uma lista. A gestão do heap pode ser do sistema operacional, mas algumas linguagens tem seus próprios gerenciadores de heap, alocando blocos de memória do sistema operacional, mas organizando a utilização e o fracionando desses blocos internamente.

Ainda nem cheguei no que queria falar de Orientação a Objetos (OO). Voltando ao Simula 67 e ao novo estilo que consistia em passar uma estrutura (struct ou record) como parâmetro da função. Esta nova forma de organizar o código cria uma relação entre a estrutura de dados e o programa (código) e muitos defenderam que os programas deveriam organizar os dados e código em blocos.

código + dados 🡪 objetos

Na realidade, a linguagem C++ (1979) começou sendo um mero tradutor para C (1969 ~ 1973), o nome original era C with Classes (C com Classes, mudando apenas em 1983 para C++). Você escrevia em C++ e ele traduzia as classes em estruturas e os métodos em funções que recebiam a estrutura como primeiro parâmetro e é assim que se implementam objetos até hoje. Voltando a guerra das linguagens de programação, o argumento da época era que BCPL (linguagem anterior ao C) era muito baixo nível e que Simula era muito lenta.

Mas a teoria não parou aí. Alguém teve a ideia que além dos dados, poderíamos passar uma tabela de métodos (ponteiros para funções) e trocar esses métodos para implementar novos comportamentos. É daí que vem conceitos como herança, polimorfismo, sobrecarga, etc.

O exemplo clássico de OO é um programa que precisa trabalhar com formas geométricas, mas poderia ser qualquer código com comportamento comum. Imagine que você só tem funções e não pode usar objetos. O programa tem que calcular a área de um triangulo. Você então cria a função: area(a, h)

E a função é uma beleza, pequena, rápida e clara.

Depois alguém pede para calcular a área de um retângulo. A interface é parecida: area_ret(a, b)

Mas as fórmulas são diferentes e você agora tem duas funções.

Outro programador faz uma descoberta: se eu usar uma terceira variável, para indicar se é um retângulo ou um triângulo, posso ter uma função area(tipo, a, b) que calcula a área, mudando a formula de acordo com o tipo.

area(tipo, a, b):
  if tipo == "triângulo":
    return area(a,b)

  if tipo == "retângulo":
    return area_ret(a,b)

Ok, problema resolvido por enquanto.

Uma semana depois, o programa tem que calcular áreas de outras formas geométricas, algumas com 2, outras com 3 ou 4 parâmetros. Ocorre um grande problema: a interface da função área não serve mais, pois não suporta os 3 ou 4 parâmetros!

Alguém então pensa em organizar isso de forma diferente (OO): eu vou criar uma estrutura, onde o tipo da forma é um campo e a função usada para o cálculo faz parte dessa estrutura e sabe como calcular a área.

Agora você passa a ter um mecanismo, com interface única que recebe a tal estrutura e sabe calcular sua área, um objeto.

Isso deixa o programa muito flexível, pois o mesmo programa funcionando com uma interface única pode suportar mais formas. Se novas formas precisarem ser adicionadas, basta reespecificar uma nova estrutura e modificar a forma de calcular, mas sem modificar a interface ou o código que já utiliza as formas anteriores. A interface passa a ser forma.area() onde forma é um objeto, derivado de uma classe que contém os dados e o programa responsável por calcular a área.

E esse foi um dos saltos que se deu na forma que escrevemos programas. Você só vê esse tipo de problema quando tem que alterar teu programa, quando o programa é mantido por algum tempo e precisa ser constantemente modificado. É este tipo de problema que OO resolve.

Outro exemplo, você pode criar uma estrutura que associa várias formas: círculos, triângulos e retângulos. Internamente ela calcula a área dessas formas somando a área de cada uma.

Como ter tal flexibilidade com as funções simples? Você pode ter estruturas (struct ou records) que contenham o tipo da forma e um ponteiro para função que calcula a área. Em linguagens como C, você pode passar uma estrutura e fazer uma transformação ou cast, pedindo ao compilador: acredite em mim, sei o que estou fazendo. Desta forma, novos campos podem ser adicionados a estrutura. Esta técnica é utilizada na implementação do CPython.

Vejamos como é na realidade nosso PyObject:

typedef struct _object {
  _PyObject_HEAD_EXTRA
  Py_ssize_t ob_refcnt;
  struct _typeobject *ob_type;
} PyObject;

É apenas uma estrutura com ponteiros para navegar na lista de objetos, o contador de referências e um ponteiro para o tipo do objeto. Olhar o código C do CPython é muito interessante. Recomendo para todos que estejam estudando C.

Logo a Orientação a Objetos é apenas um “sintax sugar”, apenas uma forma mais simples de escrever programas complexos. Agora, se você não entende de onde veio a necessidade de OO, não vai entender muita coisa. Entender como as classes são implementadas revelam detalhes bem interessantes.

Uma classe pode ser vista como uma estrutura com uma lista de métodos. Quando você cria um objeto, outra estrutura é criada. Ela contém a classe e uma estrutura para os dados, os dois juntos são o objeto. Os dados são utilizados pelos métodos listados na classe. Quando você utiliza herança, está manipulando essa lista de métodos de formas diferentes, alguns serão originários da superclasse, outros da classe corrente. É bem interessante ver como é feita a implementação, realmente recomendo darem uma olhada por debaixo dos panos.

Voltando para o Python, as classes são dicionários com os métodos, associados aos objetos. Isso é uma das razões do Python ser lento (ou mais lento que linguagens compiladas). Quando você compila um programa em C++, o método que um objeto chama é praticamente escrito em pedra (no código gerado pelo compilador) ou vem de uma lista dinâmica limitada. Em Python, você pode trocar os métodos de cada objeto, porque o objeto tem uma cópia da lista de métodos (dicionário) com os seus dados. É por isso que temos o self, lembram da estrutura que era o primeiro parâmetro na conversão de C++ para C?

É o nosso self. Em C++ e em Java ele se chama this, mas funciona da mesma forma, sendo que nessas linguagens o primeiro parâmetro é escondido (eu particularmente não gosto do self de Python, mas é uma escolha que o Guido tomou ao implementar a linguagem: explícito é melhor que implícito). É por isso que fazemos self.A = 1. Criamos tudo em self e não na classe em si.

Meu ponto é que entendendo um pouco de história e mesmo como compiladores são construídos, podemos ter uma melhor ideia de como a coisa funciona. Porque é lento, porque tal problema não foi resolvido e por aí vai. Linguagens continuam sendo formas de escrever um programa num formato que um compilador ou interpretador possam executar. Tanto a linguagem A ou B, uma vez traduzida, vai executar em código de máquina, o compilador faz a tradução diretamente e o interpretador faz em tempo de execução.

Mas em Python não é assim… não. O interpretador não chama código de máquina, na realidade, ele executa nosso programa linha a linha, chamando funções em C que implementam cada comando, função, etc. Essas chamadas rodam código a toda velocidade, mas essa pequena pausa entre as chamadas atrasa tudo. O interpretador é capaz de chamar código Python também. O CPython, o interpretador padrão que todos utilizamos, é um programa em C que lê seu programa Python como um arquivo texto e começa a identificar as partes do seu programa.

Como isto é lento, para acelerar a interpretação (conversão do texto em código), eles criaram o Python bytecode e a máquina virtual Python, que não é código de máquina, com instruções ainda em nível muito alto para serem executadas diretamente pelo processador. Logo, em Python, o programa é traduzido para bytecode e o programa em C, no caso do CPython, executa esse bytecode.

Ora, seu programa é na realidade executado por outro programa escrito em C, mas agora falamos de um programa que executa outro. Por isso é mais lento, por isso não se pode comparar com a performance de Rust ou C ou de outras linguagens compiladas. O interpretador é um intermediário que inibe otimizações na conversão do programa para código de máquina, mas tem um acesso mais direto e dinâmico ao programa. Em linguagens compiladas, o código gerado sofre outra rodada de otimizações, onde operações são simplificadas e instruções com maior desempenho são utilizadas. O programa compilado roda na melhor velocidade da máquina, pois não há intermediários, o código é executado diretamente pela CPU.

O Java faz algo legal com essa ideia de bytecode, eles criaram o bytecode do JVM (Java Virtual Machine) em baixo nível, é quase como um processador normal, mas com definição única (parecido com os processadores SPARC que a Sun produzia na época, exigindo o mínimo de conversões quando o código rodava em máquinas Sun). O bytecode do Java isola o compilador das diversas plataformas que rodam a máquina real. Todo o mundo do Java foi construído em volta dele, permitindo que se compile uma única vez e rode em qualquer lugar, lema do Java, pois o compilador gera um binário usando o bytecode da JVM, que é traduzido pelo Java Runtime quando executamos o programa. Logo depois, a Sun começou a criar JIT.

Just in Time Compilation (JIT) nada mais é que pegar o bytecode Java (escrito em código da máquina virtual, mas bem próximo de um processador real) e traduzi-lo em código da máquina real (que vai rodar o programa). É por isso que a VM “esquenta”, ou seja, o código vai ficando mais rápido a medida em que vai sendo chamado várias vezes. Na realidade com o tempo, parte do bytecode vai sendo convertido em código nativo e na segunda chamada, o programa convertido para código de máquina é executado diretamente, pulando as etapas de conversão.

Voltando para o Python, por que isso não é feito? Porque Python é uma linguagem dinâmica. Para ter as características do Python, como poder mudar os métodos, tudo objeto (e cada objeto podendo ser diferente), você perde a facilidade de traduzir de forma determinística esses programas para código nativo, pois o programa pode mudar. Vários já tentaram resolver esse problema. O melhor até agora é o Cython que resolveu limitando o que se podia fazer, ou seja, criando um subconjunto da linguagem mais fácil de ser traduzido em C e depois em código de máquina. O caso do PyPy é o JIT para Python.

É por isso que linguagens como go parecem, mas não são Python. Elas fizeram um ótimo trabalho mantendo parte da sintaxe do Python, mas num conjunto que pode ser compilado. Umas das diferenças é sacrificar as classes! Eles voltaram um nível para trás, ficando nas estruturas, mas ao em vez de ter código + dados em um objeto, eles têm os dados (structs) com métodos que podem ser adicionados. A interface deixa de ser definida pelo tipo da classe e passa a ser definida pela capacidade ou pela implementação de tal struct ter uma interface que implemente um método.

type rect struct {
  width, height int
}

func (r *rect) area() int {
  return r.width * r.height
}

Em Python, o objeto x com um método A, seria chamado como x.A(). Sendo x um objeto de uma classe C, para que o método A exista, C ou uma de suas super classes implementam A. A interface entre classes é mantida desde que os objetos venham da mesma origem (superclasse), no caso, uma hierarquia de classes.

Isso seria simples, mas temos o duck typing do Python: se a classe C implementa A e a classe D também, x pode ser tanto um objeto de C quanto de D, x.A() é válido tanto para um objeto de C quanto um objeto de D, ou seja, o que vele é o objeto ter a implementação do método A, pouco importa se vem de C ou D; e mesmo e as classes C e D tem alguma relação hierárquica entre elas.

Em go e Rust, C seria uma estrutura, D outra. Não há herança. Aí podemos criar um método A que trabalha com estruturas C e outro método A que trabalha com estruturas D. Lembra que o primeiro parâmetro era o objeto? Em Rust e go, o primeiro parâmetro tem o tipo da estrutura para qual você está implementando o método. Isso é resolvido em tempo de compilação e não atrapalha a velocidade de execução do código, mas é sutil. É o poliformismo da OO, em ação, mas sem ser OO. Em go e Rust você também pode declarar uma interface que implementa o método A, e criar uma função que trabalhe com objetos ou estruturas que implementam a interface do método A, desta forma podendo escrever código genérico sem usar OO ou forçar uma relação de herança entre as diversas implementações. Python usa Protocols para atingir o mesmo objetivo.

Então, qual a importância da teoria? Para que as coisas avancem, nós precisamos de novos nomes para cada conceito. Eu preciso confiar que quando falo classe você tem uma ideia precisa do conceito que estou comunicando. Sem a teoria você não consegue entender os textos e é por isso que muita gente reclama ser tão difícil, mas na realidade é falta de conhecimento teórico. Nem falamos de concorrência e outros assuntos, fica para outro dia. Mas teoria é importante e a prática também, precisamos das duas e tem espaço para programação estruturada, OO, funcional, lógica, etc.

  • Orientação a Objetos com Python: Capítulo 10
  • Exemplo de Orientação a objetos com desenho: Capítulo 13
  • Aulas de C: uma rápida introdução a linguagem C para quem já programa em Python.

Créditos#

[1] By Unknown author - U.S. Army Photo, Public Domain, https://commons.wikimedia.org/w/index.php?curid=55124

[2] By Seattle Municipal Archives from Seattle, WA - Seattle Municipal Archives, CC BY 2.0, https://commons.wikimedia.org/w/index.php?curid=5288715