Pensando em linguagens de arrays
(github.com/razetime)- A programação em K se concentra em levar para scripts o código experimentado no REPL e em reduzir continuamente grandes padrões imperativos a padrões de arrays menores e declarativos
- Scripts
ngn/ksão executados linha a linha como entradas no REPL, e dados e funções salvos podem ser carregados no REPL com\l file.k - Transpor diretamente a multiplicação de matrizes com três laços no estilo da Wikipedia resulta em muitas variáveis globais, laços aninhados e mutações, o que vai contra os pontos fortes de K
- O processo de melhoria passa por
+/fold,'each,/:eachright,\:eachleft, remoção de transposição e conversão tacit, condensando dematmul: {x{+/x*y}\:y}atématmul: (+/*)\: - O exemplo de multiplicação de matrizes mostra que a habilidade em K está em repetir o processo de condensação de código, transformando procedimentos complexos em expressões de arrays mais legíveis
Fluxo de desenvolvimento em K centrado no REPL
- O código-fonte completo pode ser visto no
matmul.kno GitHub - A programação em K acontece em grande parte no REPL, o que facilita experimentar e melhorar rapidamente em cima do código anterior
- A combinação de
ngn/kcomrlfeoferece histórico com as setas para cima/baixo, suficiente para desenvolver programas K maiores - O fluxo natural é testar funções primeiro no REPL e depois movê-las para o código real
- O prettyprinting de
ngn/ksempre retorna dados K válidos, então é possível pré-computar alguns valores para acelerar o programa
Modelo de execução de scripts K
- Scripts K são executados como se tivessem sido digitados no REPL
- Cada linha é executada em ordem
- Se uma linha não terminar com ponto e vírgula, o valor retornado é impresso
- Scripts permitem definições em várias linhas, melhorando a legibilidade
- Para usar dados e funções salvos no REPL, execute
\l file.k- O arquivo é executado
- Os dados do arquivo são carregados
- Carregar o mesmo arquivo várias vezes sobrescreve os dados anteriores
- Mais comandos podem ser consultados na ajuda do REPL acessada com
\
Como reduzir padrões em uma linguagem de arrays
- K e programação de arrays são um processo de simplificação contínua de padrões
- Mesmo padrões grandes e difíceis de manejar têm pelo menos uma forma de serem reduzidos a algo menor, mais declarativo e mais legível
- A discussão relacionada pode ser vista em detalhes em Patterns and Anti-patterns in APL: Escaping the Beginner's Plateau - Aaron Hsu - Dyalog '17
- Um ponto de partida comum é tentar traduzir para K algoritmos conhecidos do GeeksforGeeks ou da Wikipedia
- O exemplo usa multiplicação de matrizes
Ao transpor diretamente a multiplicação de matrizes imperativa
- O Matrix multiplication algorithm da Wikipedia preenche a matriz
Ccom três laçosi,j,ke acumulação emsum - Traduzir isso diretamente para K leva a muitas atribuições de valores globais como
A,B,n,m,p,C,i,j,kesum - Esse código usa K como se fosse uma linguagem imperativa, então não se encaixa bem no design de K
- O problema se resume a três pontos
- Muitas atribuições globais
- Vários níveis de laços aninhados permanecem
- Há mutações frequentes
Dobrar e reduzir a partir do laço mais interno
- O laço mais interno inicializa
sumcomo 0 e percorrek, acumulandoA[i;k]*B[k;j] - A primeira melhoria é usar
/, o fold, para trocar a soma por+/- A global
sumdesaparece - O código fica organizado na forma
C[i;j]::+/...
- A global
- Em seguida, usando o fato de que
'each retorna um array, é possível usar diretamente o valor retornado pelos laços aninhados sem modificarC - Depois desse estágio, restam apenas três laços sem mutação, e as variáveis centrais passam a ser
i,jek
O processo de eliminar k, j e i
- O papel das três variáveis é o seguinte
iindexa cada linha deAjindexa cada coluna deBkindexa cada coluna deAe cada linha deB
kfaz com que cada linha deAe cada coluna deBsejam pareadas e multiplicadas, então é possível remover o índice intermediário e fazer o pareamento diretamente- Nesse estágio, um laço e
mdeixam de ser necessários
- Nesse estágio, um laço e
- Para remover
j, é preciso pegar cada coluna deBe pareá-la comA[i]- Transpõe-se
Be usa-se eachright/:para parear cada elemento
- Transpõe-se
itambém pode ser eliminado da mesma forma- Usa-se eachleft
\:para parear cada linha deAcom cada coluna deB
- Usa-se eachleft
- Após esse processo, chegamos à forma abaixo, sem globais
matmul: {x{+/x*y}/:\:+y}
Remoção da transposição e forma tacit final
- A transposição
+é custosa, então pode ser removida - A abordagem existente é a forma ingênua de multiplicar cada linha de
xpor cada coluna dey - Em vez disso, ao alinhar cada linha de
BaoAinteiro, o mesmo trabalho pode ser realizado implicitamente
matmul: {x{+/x*y}\:y}
- Esta função pode ser convertida para a forma tacit aplicando as regras do Chapter 3
- O resultado final é o seguinte
matmul: (+/*)\:
Intuição de linguagem de arrays construída com prática
matmul: (+/*)\:fica organizado como uma função de multiplicação de matrizes ao estilo K- O processo de condensação pode parecer ter muitas etapas no início
- Quanto mais se pratica K, mais a condensação de código se torna uma tarefa fácil e intuitiva
- Multiplicação de matrizes é um procedimento simples que combina bem com o suporte a arrays de K
- Nos próximos capítulos, serão abordados algoritmos que não combinam bem com K e como lidar com eles
1 comentários
Comentários do Hacker News
Na prática, o que mostrou de forma mais convincente o potencial das linguagens de arrays foi um vídeo em que Aaron Hsu explica o desenvolvimento do compilador APL paralelo Co-dfns: https://www.youtube.com/watch?v=gcUWTa16Jc0&t=860s
Ele também escreveu várias vezes no HN, sob o nome arcfide, sobre densidade semântica, explicando que o código APL é projetado para permitir ver, quase sem se deslocar, o modo de funcionamento, o contexto ao redor e as dependências dentro de uma única tela: https://news.ycombinator.com/item?id=13571159
A visão é que, quando o nome de um algoritmo fica tão conciso a ponto de ter tamanho parecido com o de uma descrição por extenso do próprio algoritmo, passamos a ler o código em unidades idiomáticas, como quem lê frases em inglês, e pode ser mais rápido alterar diretamente todos os usos visíveis na tela do que criar abstrações reutilizáveis
Para quem não conhece bem programação com arrays, recomendo The Array Cast como material introdutório: https://www.arraycast.com/episodes/
O endereço RSS é https://www.arraycast.com/episodes?format=rss
map/filter/reduce já existem em praticamente todo lugar, e fiquei com a impressão de que eles ignoravam o fato de que dá para usá-los sem aprender um novo sistema de notação parecido com ideogramas
Conheci APL/APL2 nos anos 70, em terminais de papel que realmente usavam sobreimpressão, e me apaixonei de imediato, mas depois, ao conhecer programação funcional com ML e Haskell, percebi que o que eu realmente gostava em APL era mais a capacidade de composição de funções do que os arrays
Haskell é totalmente puro e tem tipos aplicados de forma abrangente, então é muito melhor nesse aspecto, além de ser mais divertido e poderoso do que APL. Fiz muitos projetos pequenos e médios, também criei um protótipo mostrando que o parser do LLVM Flang poderia ser implementado com parser combinators, e todos os anos resolvo o Advent of Code em algumas centenas de linhas no total. Se você gosta de APL, vale a pena experimentar Haskell
Hoje, o aspecto de APL como “notação como ferramenta do pensamento” me parece mais uma forma de racionalizar concisão excessiva. É bom para mostrar o poder da composição, mas também pode prejudicar a clareza
<=<já existe, e usando algo equivalente afmapa coisa realmente funciona muito bem|||,+++,&&&,***também são bons, e dá para criar seus próprios operadores UTF-8 para deixá-los mais curtos e bonitos. Ainda assim, é uma pena que código Haskell sério, em trabalho real ou publicado, raramente seja tão amigável ao espaço vertical da tela desse jeitoFiquei curioso sobre como linguagens de array geralmente lidam com problemas como “encontrar todos os números menores que N para os quais o predicado P é verdadeiro”. Por exemplo, encontrar primos menores que 1000 ou triplas pitagóricas com z menor que 1.000.000
Em uma linguagem imperativa, você verificaria o predicado em um loop; em uma linguagem funcional, usaria recursão ou
map/filtersobre uma lista preguiçosa. Mas, em linguagens de array, entendo que normalmente se cria um array1..N, aplica-se o predicado para criar um array de máscara e então se filtra o array original com essa máscaraSe N for enorme, como 1 bilhão, e o predicado quase nunca for verdadeiro, criar dois arrays temporários gigantes, o
1..Ne a máscara, parece um enorme desperdício de memória e recursos. Fiquei curioso se linguagens de array ficam lentas por continuarem criando esses arrays temporários, ou se as implementações otimizam isso com algo como avaliação preguiçosaLinguagens escalares, por outro lado, por padrão processam um valor por vez, desperdiçando o paralelismo potencial que linguagens de array aproveitam com algoritmos SIMD. Isso também só não parece um grande problema porque estamos acostumados com o estado atual, e a solução também é bloqueio
Se uma linguagem de array é de fato boa depende do problema. Na maioria dos usos práticos, desempenho não importa nem um pouco, e a reputação de k parece vir mais do fato de o kdb ser rápido como banco de dados do que de k, como implementação de linguagem, ser rápido. Ainda assim, só de focar em algoritmos de array elegantes em vez de otimizações detalhadas por máquina, dá para ficar surpreendentemente rápido: https://mlochbaum.github.io/BQN/implementation/versusc.html
Outra maneira óbvia é fazer fusão de loops em todo o corpo para evitar a criação de arrays temporários. Uma opção mais simples é dividir os arrays de entrada e saída em chunks de algumas dezenas de KB, limitando o uso de memória temporária desnecessária. Até onde sei, nenhuma linguagem de array faz isso automaticamente, e eu gostaria de tentar fazer isso algum dia no CBQN. O usuário também pode fazê-lo manualmente e, para maximizar desempenho, isso de fato precisa ser feito com frequência
!10000000, uma iota de 0 até dez milhões, como um simples intervalo, sem realmente criar um array de dez milhões de inteirosClaro, dependendo dos operadores usados, esse array pode acabar sendo criado. Também há otimizações como transformar um padrão do tipo
+|x, que inverte x e pega o primeiro elemento, em simplesmente pegar o último elementoClaro, dá para escrever de outro jeito e evitar isso, mas essas soluções podem ficar mais longas e menos bonitas. O dialeto de APL em que estou trabalhando, Kap, adia a computação até que o resultado seja necessário, tratando vários casos para permitir escrever o código da forma intuitiva sem calcular resultados que serão descartados
Os maiores aprendizados que tive usando linguagens de array, especialmente k, foram os seguintes. Verbos são algoritmos, e em linguagens imperativas e orientadas a objetos muitas vezes é preciso implementar diretamente algoritmos comuns como find, sort e group
Sequências de verbos ou advérbios foram a forma de composição mais direta que já usei, e a composição é fácil e natural. Um programa passa a parecer uma composição de algoritmos, não uma coleção de instruções e expressões
Tratar de forma consistente os conceitos de domínio e contradomínio em arrays, mapas e funções simplifica as escolhas de design, e a avaliação da direita para a esquerda evita que os olhos precisem ficar pulando de um lado para outro ao ler o código
É possível, e preferível, enviar o código para os dados em vez de trazer os dados para o código. A maioria dos grandes projetos em k, excluindo comentários, cabe dentro de uma MTU de rede, isto é, 1540 bytes. Como bônus de k, views podem implementar relações funcionais diretamente, e o carregamento de código a quente via interpretador permite aplicações que rodam “para sempre”
Minha impressão pessoal, enviesada e limitada, ao resolver problemas da linguagem K para me preparar para entrevistas de emprego, é que a linguagem é intencionalmente obscura. É uma boa linguagem para puzzles e soluções engenhosas
Mas acho que o que ensina linguagens de array e a pensar em arrays é a experiência de trabalhar com arrays NumPy em Python
Pela minha experiência usando J por cerca de 50 horas, senti, honestamente, que esse paradigma é enviesado demais para um lado só
Não sei se pensar em todos os problemas como aninhamento de arrays ajuda como ferramenta de pensamento. Se você puder criar livremente estruturas de dados que capturem bem o problema, a parte algorítmica pode ficar muito mais simples
Acho que é preciso ser mais inteligente para usar APL/J/K. Em linguagens mais flexíveis, abordagens que funcionam de imediato muitas vezes são impossíveis ali, então é preciso transformar o problema, e esse processo pode exigir muito mais reflexão
Este exemplo é baseado em K, mas outra linguagem de array é J: http://jsoftware.com
Em J, se você escrever
dot =: +/ . *,P =: 2 3 4,Q =: 1 0 2,P dot Q, isso retorna 10, o produto interno de P e Qdot←+.×. Mas, se a notação por extenso é tão curta quanto um nome razoavelmente curto, não há muita necessidade de dar um nome a ela, e talvez ainda seja preciso colocar espaços ao redor do nomedot = (sum.) . zipWith (*),p = [2, 3, 4],q = [1, 0, 2],p `dot` qAos meus olhos, a única diferença parece ser usar nomes em
sumezipWith, e o fato de que lifting ou transformação estrutural não acontecem “magicamente”dot::{+/x*y}. O formato éP::[2 3 4],Q::[1 0 2],dot(P;Q)Olhando os exemplos, não entendo qual é o sentido disso. O desempenho é melhor de alguma forma?
A sintaxe de multiplicação de matrizes é mais curta, mas isso parece ser porque é preciso manter na cabeça muito contexto embutido sobre como a linguagem K funciona
Vale a pena experimentar uma linguagem de arrays e brincar com ela até entender o paradigma. Muitas vezes código imperativo é expresso melhor em estilo de arrays, e funções longas e cheias de detalhes às vezes ficam muito mais simples usando apenas operações de arrays ou combinadas com outros estilos
Comparando, em Haskell,
(+) <$> Just 1 <*> Just 2comdo x <- Just 1; y <- Just 2; Just (x + y), nesse nível de complexidade eu sempre prefiro a primeira. A segunda ocupa mais espaço e dá a sensação de que algo mais complexo está acontecendoPara uma tarefa mais complexa, em vez de usar a segunda forma, eu preferiria decompô-la em funções pequenas para que uma variação da primeira fizesse sentido. Isso é uma troca: transformar “alguns iniciantes conseguem ler rapidamente” em “quem está acima de iniciante consegue ler”
Se “alguns iniciantes conseguem ler” for o alvo de otimização, vejo retornos decrescentes muito grandes; em vez disso, miro em algo que “acima de iniciante” — ou, em alguns casos, “nível intermediário ou acima” — consiga ler
Há muitos motivos para usar qualquer linguagem, e muitos motivos para não usar. Mas o ponto central não é a notação curta, a clareza relativa nem a capacidade de compilar para código rápido; é se o programador que vier depois conseguirá modificar e manter esse código para uso real
Com frequência demais, programadores querem mostrar suas habilidades leet e não levam em conta os pobres coitados que terão de assumir aquele código depois. Na prática, muito código leet precisa ser descartado ou completamente reescrito para se obter algo sustentável no longo prazo
Levei muito tempo para entender isso e, depois, passei a tentar escrever código limpo, simples e compreensível para que outras pessoas pudessem mantê-lo. Código descartável muitas vezes acaba virando a infraestrutura básica da organização e se cristaliza em algo incompreensível para a próxima geração