Elmord's Magic Valley

Software, lingüística e rock'n'roll. Às vezes em Português, sometimes in English.

Blueprints for a shell, parte 1: Funções, blocos e retorno

2015-03-11 23:15 -0300. Tags: comp, prog, pldesign, shell, lash, em-portugues

Este post é parte de uma série sobre o lash, um shell que eu estou ideando.

Hoje vamos discutir a feature que dá nome ao shell, lambdas, ou blocos. (Na verdade eu pensei no nome primeiro e fiquei com ele porque consegui pensar num significado que o justificasse, mas não vamos nos ater a esses detalhes.)

(Em diversos pontos ao longo do texto eu vou dizer que certa feature em lash "é" de tal e tal jeito. Isso só significa que essa é a minha idéia atual sobre a feature, não que eu tenha decidido definitivamente que isso vai ser assim. Comentários e sugestões são sempre bem-vindos.)

Como mencionado anteriormente, a idéia em lash é usar blocos extensivamente ao invés de sintaxe especial para estruturas de controle (if, for, while, etc.). Blocos em lash são valores de primeira classe, i.e., podem ser passados como argumento para funções, por exemplo. Um bloco instanciado é uma closure, i.e., ele lembra do ambiente de variáveis em que foi criado. No geral, variáveis em lash têm escopo léxico, e não escopo dinâmico como em (ba)sh. (A coisa não é tão simples por conta de variáveis de ambiente e outros detalhes, mas discutiremos isso no futuro.)

Blocos são escritos entre chaves ({ comandos }). Blocos podem receber parâmetros, que podem ser declarados com uma sintaxe Ruby-like: {|param1 param2 ... paramN| comandos }. O último parâmetro pode ser precedido de @; nesse caso, ele coleta em um array os argumentos restantes da chamada ao bloco.

Não sei se permitir $1, $2, etc., para acessar os argumentos de um bloco é uma boa idéia; como tudo é bloco em lash, acho que isso ia dar muita confusão ao tentar acessar um argumento de função de dentro de um if e situações similares. Melhor é requerer que os parâmetros sejam declarados. ($1 e companhia talvez possam adquirir outros usos, e.g., em matching de expressões regulares, mas esse é um tópico que eu não vou abordar any time soon.)

Now the thorny questions.

Arity mismatch

O que acontece se o número de parâmetros e de argumentos não casar? No geral o ideal é gerar um erro de execução ou um warning, mas eu me pergunto se não há situações em que pode ser interessante permitir passar um bloco sem parâmetros para uma função que chama o bloco com alguns argumentos, nos quais o bloco não tem interesse. (Por exemplo, o if poderia chamar o bloco do "then" com o resultado retornado pelo teste do if, no qual não temos interesse a maior parte do tempo.) Uma possibilidade seria não permitir mismatch, exceto no caso em que o bloco não tem declaração de parâmetros at all, i.e., {|| true; } 42 é um erro, mas { true; } 42 não é. Mas eu imagino que isso possa fazer funções declaradas sem parâmetros engolirem silenciosamente argumentos passados por engano. Por ora, acho que mismatch vai ser sempre um erro/warning mesmo, enquanto não aparecer um caso de uso que definitivamente sugira que o contrário é desejável.

Retorno

Quando eu digo return 42, quem retorna? O comportamento esperado é retornar da função em que o return se encontra, mas agora o corpo de um if ou foreach tecnicamente também é uma função, que provavelmente não é a função que o usuário tem em mente ao escrever um return.

Se o return retorna da função "esperada", também há o caso em que um bloco que contém um return é passado para uma função definida pelo usuário e chamada de dentro dessa função; nesse caso o return é um non-local exit, i.e., a função que retorna é a função onde o bloco foi definido, não a função que chamou o bloco. (Na verdade o caso do return dentro do if também é um non-local exit, mas é um caso com o qual nós já estamos acostumados.) Outros casos de controle de fluxo não-local são os comandos break e continue dentro de um while. Talvez fosse interessante introduzir uma construção mais geral a partir da qual esses casos mais específicos podem ser implementados, e que também poderia ser usada para implementar exceções. Ao mesmo tempo, eu gostaria que um return fosse uma operação "barata", então é necessário tomar algum cuidado antes de sair over-engineerando controle de fluxo. A construção que naturalmente "suggests itself" para a tarefa é continuations e call/cc, mas esse caminho me dá um certa preocupação, especialmente se continuações que retornam múltiplas vezes forem permitidas. (Incidentalmente, eu pretendo implementar as versões iniciais do shell em Chicken Scheme, o que tornaria tudo isso muito simples, mas eu quero manter aberta a possibilidade de reimplementar em alguma outra linguagem no futuro (e.g., Rust, depois que ele sair de alpha).) Além disso, seria necessário lidar com unwind-protect / dynamic-wind / interação de tratadores de exceção com continuations. Eu não estou gostando muito de toda essa complexidade que surgiu do nada enquanto eu estava tranqüilo aqui inventando meu shell.

Outra dificuldade é como fazer o return, que a princípio seria um comando como qualquer outro, retornar do bloco lexicamente apropriado, já que ele não recebe como argumento nada que lhe sirva para saber de que escopo léxico ele foi chamado. Ele não pode só retornar do contexto mais no topo da pilha de chamadas porque o return pode ser não-local. Por exemplo, em um código como:

def foo {
    bar {|x| return $x}
}

def bar {|block|
    $block 42
}

o return que será executado quando $block for invocado deve retornar de foo, não de bar. Uma solução é fazer todo comando receber implicitamente um argumento escondido que representa o escopo em que o comando foi chamado. That's kinda weird (e me lembra o &environment das macros do Common Lisp e o "dynamic environment argument" em Kernel), mas pode funcionar. Outra solução é fazer def (o comando de definição de função) introduzir uma função local return no escopo do corpo da função, i.e., cada função vê um return diferente, mas a princípio eu não pretendia nem introduzir funções nomeadas locais (more on that later).

Também dá para simplesmente tratar def, return e companhia como special operators e era isso. Eu não queria introduzir nenhum special operator na linguagem, mas talvez isso não seja muito prático. Preciso pensar melhor sobre isso. (No fim das contas, return, break e continue trabalham com escopo léxico, enquanto exceções e unwind-protect trabalham com escopo dinâmico, então a "óbvia" unificação dos conceitos não é tão direta assim.)

Funções locais

A princípio o filosoficamente correto seria que definições de função tivessem escopo léxico, assim como as variáveis. Porém, me parece que coisas do tipo:

if {whatever} {
    def foo {
        ...
    }
}

que define uma função global ou não dependendo de uma condição, são comuns em scripts e bashrcs da vida. Daria para introduzir comandos separados para definir funções locais e globais, mas realmente não vejo muita utilidade para funções locais (além de blocos anônimos) em um shell. Se você discorda, por favor se manifeste.

(Por um lado dá para argumentar que se você realmente precisar de uma função local, pode declarar uma variável local e atribuir um bloco a ela. Por outro lado, há a diferença de que o return dentro de um bloco retorna da função externa, não do bloco. Essa questão do return não vai deixar de me assombrar tão cedo.)

Sintaxe

O uso de chaves para delimitar funções conflita com o uso de chaves em bash, que expande coisas como touch {1,2,3}.txt para touch 1.txt 2.txt 3.txt, bem como coisas como {01..99} para 01 02 ... 99. Uma solução para evitar a ambiguidade é, ao encontrar um {, continuar lendo até o primeiro espaço ou }, e se houver uma , ou .. não-escapado na string lida, considerar como um brace expansion, caso contrário como um bloco. Eu detesto esses look-aheads em parsing, mas talvez seja o caminho a seguir. (O próprio bash já faz alguma distinção contextual com relação às chaves, tratando chaves em comandos como cmd arg1 {arg2 arg3 como caracteres literais, mas em bash o parsing se dá em múltiplos passos, em que primeiro ocorre word splitting e depois brace expansion, o que torna esse tipo de coisa relativamente simples. No caso de blocos, não dá para realizar word splitting primeiro porque o bloco é mais do que só uma seqüência de "words" comuns.) Outra solução é mudar a sintaxe do brace expansion, que sequer é parte do sh to begin with (é uma extensão do bash). Discutiremos alternativas quando falarmos de arrays, em um post futuro.

Returning and replying

Comandos no Unix possuem duas formas primárias de retornar informação para o chamador:

Queremos um mecanismo que permita retornar quaisquer valores, inclusive dados estruturados como listas e blocos. Eu vejo algumas possibilidades:

  1. Estender o conceito de exit status para permitir quaisquer valores, não apenas inteiros entre 0 e 255. O problema com essa abordagem é conciliá-la com o conceito de verdadeiro e falso convencional do (ba)sh: quando meu valor de retorno é um dado arbitrário, eu provavelmente quero que a maioria dos valores sejam tratados como verdadeiro, e coisas como 0, a string vazia, a lista vazia, etc., sejam tratados como falso.
  2. Estender o conceito de stdout para permitir enviar valores arbitrários, não apenas bytes. Isso é uma idéia muito legal, e abre caminho para a implementação de um "pipeline de objetos", mas envolveria uma certa mandinga para tratar a stdout comum do Unix e a stdout de objetos transparentemente. Também tem a vantagem de que se a saída não é capturada, ela é impressa para o terminal, o que faz sentido em modo interativo. Por outro lado, provavelmente muitas vezes queremos rodar um comando apenas pelos side-effects e descartar a saída, e ficar redirecionando para /dev/null every now and then pode ser inconveniente (embora seja possível inventar uma sintaxe abreviada para isso). Além disso, isso impede que uma função cujo valor de retorno esteja sendo capturado possa imprimir normalmente para a stdout.
  3. Criar um novo mecanismo de retorno independente dos dois anteriores. Essa é a solução mais straightforward, e por enquanto é a minha working hypothesis, mas tem a desvantagem de criar um conceito extra. Para diferenciar o retorno de um valor do retorno de um exit status convencional, eu adotei a palavra reply ao invés de return (que continua existindo com seu significado convencional).

A sintaxe para chamar uma função e capturar o valor de retorno por enquanto é $[comando], pelo simples fato de que ela não está sendo usada para mais nada (em bash ela é uma sintaxe deprecated para avaliação aritmética, que hoje em dia se escreve $((expressão))), e, pode-se argumentar, porque lembra a função dos colchetes em Tcl. Eu me pergunto se ${comando} não seria uma escolha melhor, pois tem mais cara de "executa este bloco e pega o valor", mas essa sintaxe é usada em (ba)sh para delimitar nomes de variável (e.g., echo "Eu sabia essa com ${fruta}s), e não sei se é uma boa mudar isso.

Uma questão é se o reply de fato retorna da função, ou só "emite" o valor de retorno. Se o mecanismo de retorno escolhido for o (1) ou o (3), faz mais sentido retornar e sair da função, mas se a escolha for o (2), faz mais sentido emitir o valor, como se fosse um print, e seguir a execução, até porque seria possível imprimir múltiplos valores, no caso do pipeline de objetos (e aí fica a questão de como $[...] se comporta se o comando emite múltiplos valores).

Awey?

Por hoje ficamos por aqui. Como sempre, feedback é muito bem-vindo.

Comentários / Comments (11)

Marcus Aurelius, 2015-03-12 11:58:25 -0300 #

Eu ia responder sério com sugestões, mas aí eu vi que não tinha nada a acrescentar de verdade, porque pensar nas várias possibilidades tu já pensou, escolher uma e ir até o fim sem estragar todo o resto que é o problema.

Aí eu ia responder brincando, com um monte de soluções idiotas, das quais talvez alguma se aproveitasse, mas estava ficando muito absurdo com coisas como $_, return_from_fn, return_from_block, reply_from_fn, reply_from_block, value is, result is, yield, yield from, break, skip, [<optional_parameters>], [<named = loop1>], etc.

Então eu escrevi este metacomentário.


Vítor De Araújo, 2015-03-12 12:21:32 -0300 #

Hahaha, eu até pensei no return-from do Common Lisp, mas achei que não era uma boa. :P

Mas pode comentar à vontade, inclusive coisas como "eu gosto mais de X", ou outras conseqüências / pontos positivos/negativos de cada possibilidade que eu não tenha pensado, são bem-vindas. Até zoeira é bem-vinda. :P


Vítor De Araújo, 2015-03-12 12:38:21 -0300 #

Na real um return-from da vida não é uma idéia tão furada assim se tiver uma construção do tipo:

def foo {|x|
label quux {
...
return -from quux 42 # retorna de quux
return 42 # retorna da função
}
}

Hmm...


Vítor De Araújo, 2015-03-12 12:46:04 -0300 #

O problema é que "quux" é só uma string, e não uma referência léxica mágica, e aí teria que ter um jeito de descobrir a quem ela se refere em tempo de execução. Isso não é um problema se (1) label e return forem special operators; OU (2) os comandos em geral conseguirem "olhar" o ambiente léxico em que foram chamados. Eu tava meio relutante com o (2) porque não queria reificar o ambiente, mas acho que isso resolveria os problemas de uma maneira mais "elegante" sem introduzir complexidade gratuita na linguagem.


Vítor De Araújo, 2015-03-12 13:17:17 -0300 #

O problema de reificar o ambiente é que enquanto houver algum bloco se referindo ao ambiente tu não pode garbage-collectar nenhuma parte dele, pois não dá pra saber estaticamente quais partes são necessárias. Mas acho que closures em Python e Ruby têm esse mesmo problema também, então whatever. :P

Outra possibilidade é o 'label' passar a label como argumento para o bloco ao invés de usar uma string:

def foo {|x|
label {|quux|
return -from $quux 42
return 42
}
}

(E aí eu me dou conta de que isso é só o call/cc com um nome melhor, mas vamos fazer de conta que ninguém viu. :P)


Marcus Aurelius, 2015-03-12 13:27:56 -0300 #

Não sei se existe um "nome bonito" para esse conceito, mas sempre fui fã da ideia do:

"Pode ser até ser complicado, mas procurando um token especial por perto sempre resolve a questão".

Por exemplo:

Pergunta: De onde este return sai?
Resposta: Do def mais próximo, SEMPRE. Ou dos curly braces mais próximos, SEMPRE, sei lá. Nada de decorar 6 construções que são terminadas com return, 8 que são terminadas com break, e ter construções definidas pelo usuário que só podem pertencer a uma das categorias (argh), ou que podem pertencer a qualquer uma delas mas não fica claro qual (argh² → o leitor do código nunca saberia para onde vai o return sem conhecer a implementação de __todos__ os blocos que envolvem...).

Pergunta: De onde veio essa variável?
Resposta: Da declaração mais próxima ou de um import explícito; sem mais regras. Mas no caso de um shell, não deve ser prático, geralmente a gente quer o que for mais curto de escrever e ler.

Pergunta: O que significa este curly brace?
Resposta: Basta olhar um caracter antes ou depois e descobrirás (seja lá como!). Nada de usar gambiarras como um + unário, ou uma pontuação qualquer que parece inócua só para "dar a dica" ao parser que é um código assim e não assado.


Joris the Crapo, 2015-03-12 16:05:00 -0300 #

Something something something i can't let that go


Vítor De Araújo, 2015-03-12 18:10:41 -0300 #

Me dei conta de um problema com o "reply": a minha idéia é que ele retornasse que nem o return (i.e., saísse da função em que se encontra), mas passando um valor para o chamador. O problema é que embora em algumas situações realmente se queira retornar da função (e.g., dentro de um if), em outras só se quer que o bloco produza um valor mesmo (e.g., map $list {|x| reply $[f $x]}). Fazer o return retornar do def e o reply do bloco por default é algo que eu definitivamente não quero. [Addendum: ter que escrever o reply nesses casos já é um tanto quanto desagradável, por sinal. Talvez a solução fosse simplesmente replicar implicitamente o reply do último comando? Esse é o comportamento do exit status, anyway. Mas só pensei nisso depois que escrevi o resto do comentário.]

Uma maneira de escapar desse problema é fazer o reply não retornar at all, e só emitir o valor (que foi uma das possibilidades apresentadas). Aquela história do pipeline de objetos estava muito legal anyway, mas tinha os problemas de misturar output normal e de valores na mesma "stdout lógica", e também complicava se uma função quisesse imprimir pra stdout _e_ retornar o valor. O que daria pra fazer é:

- Ter dois tipos de "canal": streams de bytes normal do Unix (file descriptors) e streams de valores (internas ao shell). Ao invés de fundir a stdout de bytes com a saída de valores, a saída de valores seria um canal separado que estaria aberto durante a execução de um comando.

- Assim como $(...) lê da stdout do comando e põe numa string, $[...] lê da saída de valores do comando e captura o primeiro valor emitido. "reply" envia o argumento passado para a saída de valores e não tem papel de controle de fluxo (só return tem).

- E se a função executar mais de um reply? Eu fiquei muito tempo sem achar uma resposta satisfatória, mas agora eu me dei conta de que é parecido com a situação de quando um processo escreve num pipe e o outro fecha sem ler: a escrita falha. Então o que acontece é que os replies depois do primeiro falham.

- Podemos adicionar um comando dual ao reply, que lê do canal de entrada de valores (ainda não consegui pensar num nome adequado, mas chamemos de readval por enquanto). Inventamos uma sintaxe para conectar o canal de saída de um comando com o canal de entrada do outro, e habemus pipeline de objetos.

Tudo isso me parece muito bonito, mas ainda não pensei se não tem conseqüências adversas não previstas. Uma conseqüência disso é que não é necessário escrever "reply $[foo]" quando se quer retornar o que a foo retornou; só de chamar a foo ela vai escrever no mesmo canal de saída que o chamador, se não houver redirecionamento. Isso pode ser tanto bom quanto ruim...


Vítor De Araújo, 2015-03-12 18:12:58 -0300 #

(Dá pra chamar 'readval' de 'take', e renomear o 'reply' pra 'yield'...)


Marcus Aurelius, 2015-03-13 11:07:03 -0300 #

Enquanto isso, eu estou aqui me perguntando por que Go usa sempre flecha para a esquerda:

// Send:

channel <- variable

// Receive:

<-channel

// como em

variable := <-channel

Link interessante comparando envio e recebimento de mensagens em Ada, Go, Erlang, Scala, Stackless Python, e Java (java.util.concurrent):

http://d.hatena.ne.jp/moriyoshi/20091117/1258442513

Será que algum desses exemplos ajuda a organizar os diferentes canais de resposta?


Vítor De Araújo, 2015-03-13 16:48:01 -0300 #

Quanto ao <-, talvez porque -> tenha um significado convencional diferente no mundo C (não sei se ele existe em Go também).

Eu realmente não sei se essa idéia do canal de resposta pra retornar valores é uma boa mesmo. Tem a questão de ter que redirecionar a saída quando não se quer o valor, e tem a questão de que pode ter um overhead desagradável quando tudo que o cara quer é retornar um valor duma função. Não sei, não sei... Talvez só usando na prática e vendo "how it feels" pra saber.


Deixe um comentário / Leave a comment

Main menu

Posts recentes

Comentários recentes

Tags

em-portugues (213) comp (137) prog (68) in-english (50) life (47) pldesign (35) unix (34) lang (32) random (28) about (27) mind (25) lisp (23) mundane (22) fenius (20) ramble (17) web (17) img (13) rant (12) hel (12) privacy (10) scheme (10) freedom (8) copyright (7) bash (7) music (7) academia (7) lash (7) esperanto (7) home (6) mestrado (6) shell (6) conlang (5) emacs (5) misc (5) latex (4) editor (4) book (4) php (4) worldly (4) android (4) politics (4) etymology (4) wrong (3) security (3) tour-de-scheme (3) kbd (3) c (3) film (3) network (3) cook (2) poem (2) physics (2) wm (2) treta (2) philosophy (2) comic (2) lows (2) llvm (2) perl (1) en-esperanto (1) audio (1) old-chinese (1) kindle (1) german (1) pointless (1) translation (1)

Elsewhere

Quod vide


Copyright © 2010-2020 Vítor De Araújo
O conteúdo deste blog, a menos que de outra forma especificado, pode ser utilizado segundo os termos da licença Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International.

Powered by Blognir.