Site menu Linguagens modernas: Swift, Go, Rust
e-mail icon
Site menu

Linguagens modernas: Swift, Go, Rust

e-mail icon

2015.10.14

Este artigo expressa a opinião do autor na época da sua redação. Não há qualquer garantia de exatidão, ineditismo ou atualidade nos conteúdos. É proibida a cópia na íntegra. A citação de trechos é permitida mediante referência ao autor e este sítio de origem.

Tinha mencionado no post anterior que ia dar uma olhada séria nas novas linguagens de programação que têm surgido por aí. Já houve muitas "ondas" ou modinhas tecnológicas, mas uma onda composta de novas linguagens é algo meio inédito.

Por um lado é algo incômodo, tanto para o desenvolvedor que tem de aprender a nova linguagem quanto pelo esforço coletivo de construir "bindings" ou bibliotecas. É a velha história do sucesso "inexplicável" do PHP: curva de aprendizado linear e pilhas incluídas.

Por outro lado, a tecnologia tem de andar pra frente e em algum momento as linguagens velhas têm de tirar o time de campo, e de cada 10 linguagens novas, uma vai colar; é preciso correr o risco. Se criar uma linguagem nova pode dar em nada, iniciativas reacionárias como o GObject têm potencial destrutivo muitíssimo maior.

A onda anterior, de meados dos anos 2000 até uns dias atrás, promoveu linguagens de natureza dinâmica como Ruby, Python, Javascript, Lua etc. à primeira divisão do desenvolvimento de software. Até então, elas eram consideradas linguagens de script, apenas melhores que Perl, shell, Tcl, etc. A onda não "inventou" estas linguagens (Python existe desde 1989), apenas promoveu seu uso sério.

As três novas linguagens — Swift, Go e Rust — são compiladas e estáticas. Alguns enxergam nisso uma "virada à direita", a ressaca depois da orgia dinâmica. A outra possibilidade, na qual eu acredito mais, é que o vento da mudança esteja chegando sobre o território até então dominado por C e C++, porque essas linguagens novas se propõem a resolver problemas típicos de C e C++:

Pessoalmente, sempre enxerguei a falta de um tipo String nativo como a maior lacuna do C, e responsável por um sem-número de bugs e falhas de segurança. Teria bastado padronizar os 4 ou 5 ponteiros de função para as premissas básicas (alocar, copiar, incrementar e decrementar referência, liberar), e um ambiente sem runtime como um kernel poderia implementá-las por si.

É bem verdade que quase todos os problemas da lista "já foram resolvidos" pelo C++, desde que a parte boa da linguagem (containers, unique_ptr, shared_ptr, referências, const, objetos na pilha sempre que possível, RAII [1], etc.) seja utilizada, em vez da parte ruim (o subconjunto compartilhado com C puro). Infelizmente, o C++ ainda não avançou o suficiente de modo a poder deixar completamente de lado a "parte ruim", por meio de um flag de compilação.

Um outro fator da ressurgência das linguagens compiladas é o LLVM, que facilitou muito a criação de novos compiladores. Swift e Rust são "filhotes" do LLVM.

Uma coisa que sempre se deve ter em mente: essas novas linguagens têm sido criadas, desenvolvidas e promovidas por entidades privadas — que são infinitamente mais ágeis que comitês, porém têm suas próprias agendas. O Go vem do Google, com sua dominância na Internet. O Swift vem da Apple, adepta ferrenha do modelo "jardim murado". O Rust vem da Mozilla Foundation, mais simpática porém conhecida por seus arroubos a la Lisa Simpson. Para uma dessas linguagens ganhar independência da mantenedora atual, teria de criar uma massa crítica muito, muito grande.

(Um exemplo de como a agenda influencia as coisas: o Java fornece excelente suporte a threads desde a primeira versão, e tudo no Java estimula a usar threads em vez de programação assíncrona. Por quê? Talvez porque a Sun vendesse máquinas SPARC, em que threads eram especialmente otimizadas. Um programa Java até funcionaria em Intel, mas teria performance bem superior num SPARC. Por outro lado, código Unix "tradicional", com programação assíncrona e múltiplos processos, tinha desempenho surpreendente em Intel — o que talvez tenha assegurado a sobrevivência do Unix nos anos 1990, encarnado no SCO e no Linux.)

A seguir, dou minhas impressões iniciais a respeito das novas linguagens estáticas.

Swift

Ela é um substituto menos verboso para Objective-C, e construída para existir dentro do contexto do Cocoa. Não é especialmente inovadora, na minha opinião. Pelo menos não teve para mim aquele fator "wow" que o primeiro contato com ObjC tinha proporcionado. Talvez esteja sendo injusto, porque ObjC já está num patamar elevado...

Mas existem melhorias aqui e ali. Os ponteiros estão "escondidos", o gerenciamento automático de memória via ARC [2] é mandatório. Um valor declarado com let é imutável. A possibilidade de um objeto nulo pode ser anotada no tipo e.g. Tipo? pode conter nil, enquanto Tipo! é garantidamente um objeto válido. Fica imediatamente claro quando é preciso tratar uma possível nulidade.

Como o Swift pode resolver os métodos estaticamente, ele é potencialmente mais rápido que Objective-C. E é claro, o velho problema do subconjunto inseguro (C puro) dentro de uma linguagem boa (Objective-C) desaparece completamente.

Apesar do Objective-C ser uma linguagem de uso geral, só tinha uso corrente na plataforma Apple, e Swift tomará este lugar. Difícil que ela se difunda para outras plataformas. Apesar do LLVM ajudar na tarefa, criar uma linguagem continua sendo tarefa não-trivial: o Xcode ainda está bem imaturo no suporte a Swift (entre quebras, lentidões e mensagens de compilador completamente despropositadas), mas isso vai se resolver com o tempo.

Go

A linguagem desenvolvida pelo Google adota a simplicidade como mantra. Um livro introdutório do Go tem 80 páginas... Claramente o Go tenta ser um "C melhor", não um "C++ melhor". Até o suporte a OOP é limitado. Mais um sintoma do pragmatismo de Go é preocupar-se com velocidade de compilação.

O gerenciamento de memória é basado em coletor de lixo (GC), o que define outras características da linguagem (sem destructors, sem RAII [1]). O tratamento de exceções é "suprido" pela possibilidade de uma função retornar múltiplos valores. O defer supre casos de uso do try/finally e do RAII.

Go não esconde a existência de ponteiros, e até usa o famigerado asterisco, mas manipulações inseguras só podem ser feitas através do pacote unsafe, o que permite a isolação da parte "insegura" do código.

Go seria uma linguagem como tantas outras, não fosse por um recurso: as goroutines. Elas lembram "threads leves", co-rotinas e programação assíncrona; e resolvem problemas típicos de programas multithreaded, programas que atendem múltiplas conexões de rede, etc. O programa pode criar quantas goroutines quiser, e o runtime as distribui para um pool de threads. Comunicação e sincronismo entre goroutines são feitos por outro recurso embutido na linguagem: os canais. Para completar, as ferramentas da linguagem incluem um detector de race condition. Concorrência eficiente também é um dos motivos alegados para o uso de coletor de lixo em vez de contagem de referências.

Como maior defeito, eu apontaria a ausência de operator overloading. Acho que uma linguagem deve fornecer ferramentas suficientes para a criação de novos tipos. Fora isso é uma linguagem que não inspira nenhuma reserva.

Go preencheu um nicho bem definido: desenvolvimento que adotaria Python, Node.js ou Ruby como primeira escolha, porém precisa de mais performance.

Rust

Rust é uma linguagem mais polêmica, mais inovadora, e a maior incógnita.

O grande "wow" do Rust é o gerenciamento de memória. É o tipo de coisa que você conhece e fica pensando "por que não botaram isso no C++11?!". Lembra bastante os unique_ptr e shared_ptr do C++, porém verificado em tempo de compilação — no C++, usar um unique_ptr invalidado só causa erro em tempo de execução.

Muitos exemplos existem para ilustrar as vantagens do modelo Rust. Um exemplo que vai além das questões triviais de gerenciamento de memória, é o seguinte:

for elemento in &colecao {
	...
	colecao.push(x)
}

Se a coleção for semelhante a matriz como Vector, Vec, etc. o código acima é errado por diversos motivos. Mas o código equivalente C++ compilaria, "funcionaria" e (um belo dia, na cara do cliente) falharia. Mexer na coleção, direta ou indiretamente (isso pode acontecer num lugar distante de loop em si) é um bug bastante comum. O código equivalente em Java, Python, Javascript, etc. também "funciona" e também é fonte de bugs.

Em Rust, este código não compilaria porque o operador & "toma emprestada" uma referência constante a colecao, e ela não pode ser modificada até o empréstimo ser devolvido, ao final do loop. Sem o operador & a regra fica ainda mais apertada: o loop toma posse irrevogável de colecao, que não pode ser nem mesmo acessada, seja dentro ou após o loop! Como tudo é resolvido em tempo de compilação, em tese Rust pode ser tão rápido quanto C.

O "lado B" do Rust é ser verboso e explícito ao extremo. Vide um exemplo tornado famoso por este artigo crítico ao Rust. Lendo textos didáticos sobre Rust, tive um sentimento semelhante ao Zope 3: tudo tem de ser tão explicadinho que acaba ficando no caminho do desenvolvimento ágil. Rust também tem uma visível influência do Ruby, e herdou dele uma coisa que eu pessoalmente odeio: o excesso de caracteres especiais.

Sobre a explicitação, um exemplo. Digamos que uma função retorna um ponteiro. Em C++, podemos retornar um ponteiro simples e especificar na documentação o seu comportamento (se ele é seguro para multithreading, se o chamador tem de destruí-lo, etc.). Se a convenção mudar, muda apenas a documentação, não o tipo. Isto não basta em Rust: é preciso retornar um tipo Box, Cell, RefCell, Rc, Arc, etc. que explicita as qualidades do ponteiro. Se for preciso mudar algo (e.g. de Rc para Arc), muda a assinatura da função e isto afeta todos os clientes da mesma.

Como Rust pretende substituir C++, ele tem um bom suporte a OOP e programação genérica, leia-se templates. Não há um equivalente às goroutines, apenas threads normais; mas os canais estão lá, com funcionalidade análoga aos canais do Go.

Rust versus ...

Muito se fala sobre Rust × Go, mas os dois atendem a nichos diferentes. Rust presta-se mais a desenvolvimento de base: kernels, máquinas virtuais, engines Web... Um desenvolvedor inexperiente passaria longo tempo sem conseguir sequer compilar código Rust, enquanto conseguiria produzir bastante código C++ (bugado) no mesmo período.

Em projetos de nível um pouco mais alto, produzir algum código por unidade de tempo, ainda que seja código imperfeito, é importante. Nestes casos, usar Go (ou Python, ou Javascript) é mais interessante porque as imperfeições ao menos não serão sinônimos de buffer overflow.

Uma discussão válida é se C++ realmente é obsoleto frente ao Rust, já que Rust também acabou saindo-se uma linguagem "grande", como é o C++. Muitos dos problemas de gerenciamento de memória do C++ resolvem-se usando um subconjunto seguro da linguagem. Isso já era verdade antes, e ainda mais agora com C++11.

Parte do problema é que os desenvolvedores começam a aprender C++ justamente pela parte insegura — muitos já conhecem C, outros tantos dão excessiva atenção aos recursos de OOP, alguns vão aprender C++ em algum livro velho, da época em que nem STL havia (foi o meu caso). É a "síndrome de Clipper", onde uma linguagem é rotulada por conta do seu público.

Por outro lado, no embate C++ × Rust, parte da justificação em favor do C++ é racionalização da Síndrome de Estocolmo. C++ é uma linguagem difícil de aprender direito, e quem conseguiu "chegar lá" quer proteger o investimento. Na verdade, nos dias de hoje, um engenheiro de software não tem opção; ele tem de saber C++, porque cedo ou tarde ele terá de lidar com código C/C++. E assim o Stroustrup vai arrebanhando reféns :)

Veredito e cobaia

Expedir os vereditos para Swift e Go foi fácil. Swift é sinônimo de Apple. Go é algo que, mesmo eu não tendo desenvolvido nada com ele, sinto-me completamente à vontade em "mostrar o polegar pra cima".

No caso do Rust, como eu disse antes, vejo alguns problemas. Também vi aquilo que se diz sobre uma nova linguagem de programação: ela vale a pena quando muda sua forma de pensar. Assim, ainda não bati o martelo. Estou procurando um projeto pequeno, de nível suficientemente baixo, para treinar. (Aceito sugestões; não vale algo que poderia ser feito mais rápida e apropriadamente em Python.)

Escolas de gerenciamento de memória

É interessante notar que cada uma das quatro linguagens estáticas mencionadas mais amiúde (C++, Swift, Go e Rust) implementa um modelo diferente de gerenciamento de memória heap. Isto aumenta a importância de cada linguagem, pois representa toda uma escola.

C++ representa o gerenciamento manual, cuja maior vantagem é o controle. É fácil interoperar com hardware. O uso de memória é sempre o menor possível. Como o desenvolvedor controla o momento em que um objeto é destruído, o respectivo custo também está sob controle o tempo todo.

A diferença entre C e C++ é que este último tem muito mais recursos de linguagem, o que permite abstrair e automatizar o gerenciamento da memória. Por exemplo, é possível evitar inteiramente o uso de ponteiros num programa C++. Porém esta é uma decisão voluntária, e portanto sujeita ao voluntarismo de cada desenvolvedor do time ou das bibliotecas de que seu programa depende!

Swift, assim como a versão mais moderna do Objective-C, delega o gerenciamento do heap ao compilador via ARC [2] – contagem de referências automatizada. O uso de memória também é minimizado. O controle é quase tão bom quanto em C++, basta um pouco de raciocínio para determinar o momento em que um objeto será destruído. (É bom lembrar que ARC é viável pelas convenções bastante sólidas que o Cocoa já tinha. Uma outra linguagem que pretenda usar ARC teria em primeiro lugar que sanear suas bibliotecas.)

Contagem de referências tem um custo, não óbvio, mas bastante alto. Cada vez que a contagem de referências é atualizada, acontece um acesso a uma parte remota da memória, mesmo que o objeto em si não tenha sido alterado. Em ambiente multithreaded, este custo é amplificado porque, se duas CPUs estão lidando com a mesma porção de memória e uma delas faz alterações, a outra CPU tem de invalidar o cache.

A linguagem Go representa a escola dos coletores de lixo, cujas características são opostas ao ARC. O desenvolvedor não controla o tempo de vida do objeto, portanto técnicas como RAII [1] não são possíveis. Não se pode controlar em que momento recairá o custo de destruição. O consumo de memória é maior, por outro lado economiza-se acessos a essa mesma memória — uma vantagem quando o programa usa várias CPUs ao mesmo tempo.

O grande problema da coleta de lixo é que ela "pára o mundo", ou seja, suspende a execução do programa para fazer seu trabalho. Imagine um jogo congelar por quase 1 segundo por conta da coleta de lixo... não é muito salutar. Go 1.5 melhorou muito neste quesito, a suspensão continua ocorrendo mas dura tão pouco que nem deve ser notada.

O Rust trouxe para a mesa um quarto modelo de gerenciamento de memória. Ele dá controle total ao desenvolvedor quanto à vida dos objetos e ao consumo de memória, mas o compilador impede que aconteçam os deslizes típicos em C++. Todos os "custos" do gerenciamento recaem sobre o tempo de compilação.

Tanto em C++ como em Rust, o controle da memória custa tempo extra de desenvolvimento, mas a natureza do custo é diferente. Em C++ o desenvolvedor precisa criar as convenções — e depurar as violações dessas convenções. Em Rust o código precisa ser mais explícito do que seria humanamente necessário, a fim de satisfazer o compilador.

Notas

[1] RAII - Resource Aquisition is Initialization: a posse de um recurso do sistema é associada à existência de um objeto (por exemplo uma conexão TCP/IP). RAII é um idioma útil quando o tempo de vida do objeto é controlável, por exemplo em C++ um objeto na pilha é destruído assim que sai de escopo. Em linguagens que usam coleta de lixo, nunca se sabe quando o objeto vai ser destruído e o RAII não é aplicável (a conexão TCP/IP teria de ser explicitamente fechada, em vez de esperar pelo respectivo objeto ser destruído).

[2] ARC - Automatic Reference Counting: Em Objective-C e Cocoa, a vida de um objeto é determinada por contagem de referências. Até 2011, o contador de referências tinha de ser controlado manualmente pelo programador. Com ARC, o compilador determina automaticamente onde o contador deve ser incrementado ou decrementado.

e-mail icon