A Linguagem Nim: Arquivos, GUI e threads

Não pretendo tratar diretamente da sintaxe da linguagem, já que você pode aprendê-la facilmente na documentação disponível no site oficial. O que considero muito mais útil é apresentar exemplos de coisas que fazemos comumente no nosso dia-a-dia.

Nesse exemplo mostrarei o início de um projeto pessoal que funciona assim: o programa lê constantemente da entrada padrão ( stdin ) e a GUI (“graphical user interface”) reage de acordo com essa entrada.

Uma curiosidade de Nim é que a linguagem não tem uma “função main” como o tem C ou Java. O que se faz é o seguinte: define-se um módulo que será o principal (eu costumo chamá-lo justamente de main.nim ) e, a partir dele, o próprio compilador cuida de correr atrás dos outros eventuais arquivos (quando importa-se outros módulos). O corpo do módulo principal, em si mesmo, faz o papel da função principal.

Por isso é bem comum que o código do módulo principal simplesmente fique “esparramado” dentro do mesmo ao invés de ficar tudo contido dentro de uma int main . E eu considero isso algo muito interessante, já que os programas acabam tendo um jeitão de script, coisa que faz todo o sentido em uma porção de aplicações. E o fato de não precisar passar mais de um arquivo como argumento para o compilador é excelente.

Para a GUI, vamos usar a nigui . Para instalá-la, use o nimble . Assim:

nimble install nigui

Essa biblioteca tem duas característias que precisam ser levadas em consideração: ela (1) usa variáveis globais e (2) “bloca” o programa em seu próprio loop principal.

A segunda característica não é novidade para quem já lidou com bibliotecas de GUI antes. Geralmente configura-se o que for preciso com relação às janelas, rótulos, botões e o que mais for e depois chama-se uma função tipo “run” que inicia a exibição em tela e, “travando” o programa ali, cuida de reagir ao input do usuário e renderizar o que for necessário.

Mas alie-se isso tudo ao fato de que Nim não permite que threads compartilhem memória e você chega numa situação complicada: como fazer com que um fluxo de execução fique lendo de um arquivo, que também é um fluxo blocante, ao mesmo tempo que outro trate da GUI?

A resposta vem dos channels , que são uma forma de transmitir mensagens entre threads.

Ademais, cumpre notar que o GUI não poderá rodar numa thread separada, mas obrigatoriamente na “thread principal”, que é o processo inicial em si. O que moveremos para outra thread será a leitura do arquivo.

O programa

GUI

O único módulo que teremos que importar é o nigui :

import nigui

Nós já podemos inicializar a “app” (que é um conceito trazido pela biblioteca e bem comum em aplicações gráficas) e criar a janela principal:

app.init()
var window = newWindow()

O código final não terá exatamente essa ordem, mas prefiro seguir pela linha de raciocínio da GUI, primeiro, e depois incluir os outros conceitos.

Você pode criar um procedimento (ou “função”) chamado gui e colocar nele toda a lógica de configuração da GUI. Eu preferi jogar tudo diretamente no corpo do módulo principal, já que o que é blocante, mesmo, é o app.run() .

window.width = 800
window.height = 600
var mainContainer = newLayoutContainer(Layout_Vertical)
window.add(mainContainer)

O propósito do artigo não é ensinar como usar a nigui , então não me estenderei muito, aqui. O que importa, agora, é que para aparecer algo na tela, você precisa chamar o “run” da biblioteca:

window.show()  # Para mostrar a janela principal
app.run()      # Para iniciar o motor da GUI. Blocante.

REPL

Para ler do arquivo, criei um procedimento chamado repl , pois no contexto da minha aplicação é isso mesmo que eu quero que seja (um “read/eval/print loop”).

proc repl =
  while true:
    stdout.write "ᐳᐳᐳ "
    stdout.flushFile()
    var command = stdin.readLine()
    echo "    [", command, "]"
    if command == "q":
      quit(0)

Esse procedimento, por si, lê da entrada padrão, seja ela um pipe ou input interativo do terminal, imprime esta entrada (devidamente alinhada com o “prompt”) e, se a entrada for a letra “q”, interrompe o while true e sai do procedimento.

Threads

O problema, agora, conforme disse antes, é que temos dois “procedimentos” blocantes (repl e app.run ). E já que a nigui faz uso de variáveis globais, teremos que jogar repl para uma nova thread.

Para tanto, algumas coisas são necessárias. Primeiramente, vamos “decorar” o procedimento repl com o “pragma” {.thread.} :

proc repl {.thread.} =

Também precisamos criar as threads:

var t: Thread[void]
t.createThread(repl)

Aquele void significa que a função da thread não recebe argumento algum ao ser chamada.

Depois, ao compilar o programa, vamos avisar o compilador que queremos trabalhar com múltiplas threads:

nim —threads:on c main.nim

Channels

Ok… mas como vamos fazer os comandos enviados via stdin e recebidos pelo procedimentorepl refletirem, de alguma forma, na nossa GUI? Afinal, Nim não nos permite compartilhar memória entre threads!

O que precisaremos são de “canais”, que existem justamente para sanar esse problema.

Curiosamente, essa implementação de canais me lembra bastante a passagem de mensagens em Erlang. Especialmente porque você pode “tipar” as mensagens, mais ou menos como o envio de tuplas. Em Erlang, claro, você não precisa fixar um “tipo”, já que irá trabalhar com pattern matching, mas, enfim…

Embora, nesse momento, só vamos transmitir strings, ainda assim vamos criar um tipo para as mensagens, por simples questão de extensibilidade.

type
 Message = string
var channel: Channel[Message]

O erro mais comum que você pode cometer, agora, é ficar tentando trabalhar com o canal sem abri-lo. O programa até irá compilar, mas na hora da execução parecerá que você está tentando escrever num endereço de memória inválido.

Lembre-se: Nim não é “orientada a objetos”. Nim é procedural. Você mesmo precisa inicializar (como a nigui ) e abrir as coisas (como os canais).

channel.open()  # Importantíssimo!

Ah, e lembre-se: você não precisa e nem deve importar explicitamente os módulos threads ou channels . O compilador, sabendo que você vai usar threads, já carregará o que for necessário.

Dentro de repl , agora, nós podemos escrever no canal:

channel.send cast[Message](command)

Repare nesse cast : em C você faria algo assim:

channel.send((Message)command)

Eu aprecio a sintaxe escolhida pela Nim para fazer isso. No fim das contas, também era razoavelmente comum usar parêntesis em C para definir o que, afinal, queríamos “castiar” para outro tipo.

Em Nim, coisas entre colchetes geralmente são argumentos para o compilador.

(Sobre a falta dos parêntesis na chamada do procedimento: eu sigo, aqui, o “sabor” do Erlang, propositadamente.)

Agora vamos criar, na thread principal, o procedimento que ficará lendo do canal:

proc react =
  var n = channel.peek()
  if n ᐸ 1:
    return
var probe = channel.tryRecv()
  if not probe.dataAvailable:
    return
echo "Message:", probe.msg

O método “peek” é uma forma de dar uma olhadela no canal para saber se há, afinal, alguma mensagem lá. Na documentação oficial, é bom notar, seu uso é desencorajado, sendo recomendado o uso de tryRecv no lugar. Mas aqui usamos ambos e há um bom motivo que será explicado em breve.

O método tryRecv retorna uma “tupla”. E aqui, se você vem de Python, é bom tomar nota: as tuplas de Nim assemelham-se mais às named tuples do Python do que às tuplas simples. probe , ali no caso, tem dois campos, dataAvailable (booleano) e msg (a mensagem em si, do tipo que você definiu previamente).

Timers

Mas agora, se você está prestando bem a atenção, você verá que, na melhor das hipóteses, responderemos a apenas uma mensagem do canal, já que a leitura de mensagens não está num laço. E, ao mesmo tempo, se botarmos um while true envolvendo tudo, acabaremos com 3 procedimentos blocantes. E se jogarmos esse novo procedimento para uma thread, torná-lo-emos inócuo, já que não poderá compartilhar memória com a thread principal…

E aí entra um macete: usar um timer da biblioteca gráfica e criar um laço com timers.

Os temporizadores são executados de forma concorrente com a renderização, de forma que demorar muito na execução de um temporizador congela a GUI. Por isso devemos prezar por intervenções muito rápidas, para que o usuário não as note.

O que eu decidi, nesse caso, foi fazer checagens no canal a cada 250ms. Dessa forma, fazemos umas 4 checagens por segundo, o que é uma boa taxa de leitura e, ao mesmo tempo, não ficamos travando a GUI a toda hora.

Na sua própria aplicação, esse valor fica a teu critério.

A primeira chamada ao procedimento react é feita diretamente no corpo principal:

startTimer(500, react)

Ou seja: chamá-la-emos dentro de meio segundo.

Agora, para criar um laço feito com temporizadores, vamos alterar um pouco nosso procedimento react :

proc react(event: TimerEvent) =
  var n = channel.peek()
  if n ᐸ 1:
    startTimer(250, react)
    return
var probe = channel.tryRecv()
  if not probe.dataAvailable:
    startTimer(250, react)
    return
echo "Message:", probe.msg
  startTimer(50, react)

O que fazemos, aqui, é o seguinte:

Se peek disser que não há nada no canal, chame de volta em 250ms.

Se tentarmos receber algo mas não houver nada lá, chame de volta em 250ms.

Se recebermos algo, vamos imprimir a mensagem e chamar de volta em 50ms.

A segunda opção serve para o caso de, eventualmente, termos novos consumidores no canal.

Já o timer de 50ms segue a seguinte lógica: se há uma mensagem no canal, vamos considerar bem provável que também haja uma segunda mensagem. Por isso não vamos esperar muito para tratá-la também.

Se não houver nova mensagem, tudo bem: na próxima iteração, a espera será de 250ms.

Agora explico por que usar o peek no canal: tryRecv é blocante. Se não houver mensagens, este último método acabará travando o programa até aparecer algo por lá. Então usamos peek para evitar que isso aconteça.

Timers…

Lidar com temporizadores é geralmente ruim (exceto quando a modelagem do problema naturalmente os requer), porque a maioria deles é um temporizador burro: não importa muito, por exemplo, se ninguém está mandando mensagens no canal: seu programa sempre passará lá para dar uma olhada. E a forma de resolver isso geralmente envolve usar ainda mais temporizadores…

Se Nim permitisse compartilhar memória entre threads, esse problema seria mitigado. Mas, ao mesmo tempo, a própria implementação da biblioteca de GUI poderia ser mais orientada à leitura de canais e criar uma forma de se comunicar com ela enviando “comandos” (lembra Tk, não?).

(Isso me lembra um pouco a GUI do sistema operacional Haiku, inclusive, em que cada elemento tem sua thread separada. No caso, cada elemento da GUI poderia ter um canal separado. (Não faço ideia, agora, de quão caro ou barato isso seria.))

Resultado final

import nigui
type
  Message = string
var channel: Channel[Message]
app.init()
var window = newWindow()
window.width = 800
window.height = 600
var mainContainer = newLayoutContainer(Layout_Vertical)
window.add(mainContainer)
###---{{{Crie sua GUI aqui}}}---###
echo "GUI done"
# --------------------------
# "REPL"
proc repl {.thread.} =
  while true:
    stdout.write "ᐳᐳᐳ "
    stdout.flushFile()
    var command = stdin.readLine()
    echo "    [", command, "]"
    if command == "q":
      quit(0)
channel.send cast[Message](command)
channel.open()
var t: Thread[void]
t.createThread(repl)
# --------------------------
proc react(event: TimerEvent) =
  var n = channel.peek()
  if n ᐸ 1:
    startTimer(250, react)
    return
var probe = channel.tryRecv()
  if not probe.dataAvailable:
    startTimer(250, react)
    return
echo "Message:", probe.msg
  startTimer(50, react)
startTimer(500, react)
window.show()
app.run()

Considerações finais

O estilo do código está bem feioso, eu sei. Foi coisa “hacked together” rapidamente, mais para experimentar cada funcionalidade do que para distribuição.

Existem outras alternativas para a construção de GUIs com Nim. A que me parece mais interessante, no momento, é a nimx .

Nim também suporta async/await , caso esteja se perguntando. Mas não seria a solução mais adequada para essa situação específica, a meu ver.

Sobre a linguagem em si, o que me incomodava bastante, inicialmente, era a sintaxe dos {.pragma.} . Mas era mais questão de costumem, tanto que agora não me importo mais. (E é bem melhor que os [pragma] de C#, pelo menos…)

Ademais, o não-compartilhamento de memória entre threads e a implementação dos canais me faz lembrar bastante, novamente, do “actor model” de Erlang, o que eu considero algo muito positivo, especialmente porque permite que, no futuro, com novas implementações na linguagem, essas threads tornem-se um tipo de co-rotinas (como em Go) ou “processes” (como em Erlang).