Elmord's Magic Valley

Computers, languages, and computer languages. Às vezes em Português, sometimes in English.

Posts com a tag: prog

Undefined behavior

2013-07-03 20:17 -0300. Tags: comp, prog, c, em-portugues

Hoje apresentei uma palestra-relâmpago no FISL sobre undefined behavior em C.

A palestra foi horrível, mas os slides ficaram decentes.

Update: Well, parece que existe um vídeo da palestra (minha parte começa aos 12:27). E foi menos pior do que eu tinha pensado...

2 comentários / comments

Coisas que você não sabe sobre a glibc

2013-05-29 11:48 -0300. Tags: comp, prog, c, unix, em-portugues

Em algum momento do ano passado, por falta de coisa melhor para fazer, eu me parei a ler o manual da GNU libc. Não cheguei a ir muito longe, mas descobri um bocado de coisas interessantes no processo.

scanf

A scanf é uma das primeiras funções que vemos quando aprendemos C. Por isso mesmo, acabamos vendo só a funcionalidade básica para sobrevivência. Aí achamos que conhecemos a scanf e nunca mais nos preocupamos com ela. Ela possui um bocado de features interessantes, entretanto:

Other I/O

Miscelânea

No más

A glibc tem muita coisa (a versão em PDF do manual tem cerca de mil páginas). Vale a pena dar uma olhada no manual, nem que seja apenas para descobrir que tipo de recursos ela fornece, caso um dia você precise de algum deles.

3 comentários / comments

fgetcsv: A cautionary tale

2013-05-18 01:52 -0300. Tags: comp, prog, web, php, rant, em-portugues

Quando eu reescrevi o blog system, eu resolvi manter o índice de posts em um arquivo CSV e usar as funções fgetcsv e fputcsv para manipulá-lo. Minha intuição prontamente me disse "isso é PHP, tu vai te ferrar", mas eu não lhe dei importância. Afinal, provavelmente seria mais eficiente usar essas funções do que ler uma linha inteira e usar a explode para separar os campos. (Sim, eu usei um txt ao invés de um SQLite. Eu sou feliz assim, ok?)

Na verdade o que eu queria era manter o arquivo como tab-separated values, de maneira que ele fosse o mais fácil possível de manipular com as ferramentas convencionais do Unix (cut, awk, etc.). As funções do PHP aceitam um delimitador como argumento, então pensei eu que bastaria mudar o delimitador para "\t" ao invés da vírgula e tudo estaria bem. Evidentemente eu estava errado: um dos argumentos da fputcsv é um "enclosure", um caractere que deve ser usado ao redor de valores que contêm espaços (ou outras situações? who knows?). O valor padrão para a enclosure é a aspa dupla. Acontece que a fputcsv exige uma enclosure: não é possível passar uma string vazia, ou NULL, por exemplo, para evitar que a função imprima uma enclosure em volta das strings que considerar dignas de serem envoltas. Lá se vai meu tab-separated file bonitinho. Mas ok, não é um problema fatal.

A segunda curiosidade é que a fgetcsv aceita um argumento "escape", que diz qual é o caractere de escape (\ por default). Evidentemente, você tem que usar um caractere de escape; a possibilidade de ler um arquivo em um formato em que todos os caracteres exceto o delimitador de campo e o "\n" tenham seus valores literais é inconcebível. Mas ok, podemos setar o escape para um caractere não-imprimível do ASCII (e.g., "\1") e esquecer da existência dele. Acontece que a fputcsv não aceita um caractere de escape, logo você não tem como usar o mesmo caractere não-imprimível nas duas funções. WTF?

Na verdade, agora testando melhor (já que a documentação não nos conta muita coisa), aparentemente a fputcsv nunca produz um caractere de escape: se o delimitador aparece em um dos campos, ele é duplicado na saída (i.e., a"b vira a""b). Evidentemente, não há como eliminar esse comportamento. Mas então o que será que faz o escape character da fgetcsv?

# php -r 'while ($a = fgetcsv(STDIN, 999, ",", "\"", "\\"))
             { var_export($a); echo "\n"; }'
a,z
array (
  0 => 'a',
  1 => 'z',
)
a\tb,z
array (
  0 => 'a\\tb',
  1 => 'z',
)

Ok, o escape não serve para introduzir seqüências do tipo \t. Talvez para remover o significado especial de outros caracteres?

a\,b,z
array (
  0 => 'a\\',
  1 => 'b',
  2 => 'z',
)
a b,z
array (
  0 => 'a b',
  1 => 'z',
)
a\ b,z
array (
  0 => 'a\\ b',
  1 => 'z',
)

Muito bem. Como vimos, o escape character serve para, hã... hmm.

Mas o fatal blow eu tive hoje, olhando a lista de todos os posts do blog e constatando que o post sobre o filme π estava aparecendo com o nome vazio. Eis que:

# php -r '
    $h = fopen("entryidx.entries", "r");
    while ($a = fgetcsv($h, 9999, "\t"))
       if ($a[0]=="20120322-pi") var_export($a);'
array (
  0 => '20120322-pi',
  1 => 'π',
  2 => '2012-03-22 23:53 -0300',
  3 => 'film',
)

# LC_ALL=C php -r '
    $h = fopen("entryidx.entries", "r");
    while ($a = fgetcsv($h, 9999, "\t"))
       if ($a[0]=="20120322-pi") var_export($a);'
array (
  0 => '20120322-pi',
  1 => '',
  2 => '2012-03-22 23:53 -0300',
  3 => 'film',
)

A próxima versão do Blognir deverá usar fgets/explode.

UPDATE: Aparentemente o problema só ocorre quando um caractere não-ASCII aparece no começo de um campo. Whut?

UPDATE 2:

"a b","c d"
array (
  0 => 'a b',
  1 => 'c d',
)
"a"b","c"d"
array (
  0 => 'ab"',
  1 => 'cd"',
)
"a\"b","c d"
array (
  0 => 'a\\"b',
  1 => 'c d',
)

5 comentários / comments

Noun-centric vs. verb-centric OO

2013-05-11 01:36 -0300. Tags: comp, prog, lisp, pldesign, em-portugues

As linguagens orientadas a objetos convencionais são o que podemos chamar de noun-centric: as classes são a "unidade estrutural" mais importante, e os métodos (verbos) pertencem a classes (nomes). Um método é chamado sobre exatamente um objeto, e a escolha de que código será executado para a chamada depende da classe do objeto sobre o qual o método é chamado. A sintaxe objeto.método(args) reflete essa idéia.

Algumas outras linguagens, como Common Lisp e Dylan, entretanto, seguem um modelo "verb-centric": os métodos são definidos fora das classes. Métodos de mesmo "nome" (onde nome = símbolo + pacote ao qual pertence) são agrupados sob uma mesma generic function. As classes de todos os argumentos da generic function são consideradas na hora de escolher que código será executado. Em Common Lisp, a sintaxe (método arg1 arg2...) reflete essa ausência de preferência por um dos argumentos e a existência da função como algo externo a qualquer classe. (Não lembro mais de onde saíram os termos "noun-centric" e "verb-centric" para descrever essa diferença, mas eles não são invenção minha.)

As conseqüências na prática são várias. Uma delas é que no modelo verbocêntrico é possível criar novos métodos para classes existentes sem ter que modificá-las. Por exemplo, se você quiser criar um método novo para trabalhar com strings, você pode defini-lo e usá-lo como qualquer outro método sobre strings, ao invés de criar uma distinção artificial entre métodos nativos da classe String ("foo".method(42)) e métodos definidos somewhere else que trabalham com strings (RandomClass.method("foo", 42)). (Ruby, uma linguagem nominocêntrica, resolve esse problema permitindo "redefinições parciais" de classes. Essa solução, entretanto, tem o problema de que todos os métodos sobre uma classe compartilham o mesmo namespace, i.e., se dois arquivos diferentes quiserem definir method sobre uma mesma classe, as definições conflitarão. Em Common Lisp, cada method estaria em seu próprio pacote, o que evita esse problema.)

Outra vantagem do modelo verbocêntrico é que evitam-se decisões arbitrárias quanto a em que classe se deve incluir um método. Por exemplo, imagine que queiramos definir um método (match string regex), que retorna a primeira ocorrência de regex em string. Esse método vai na classe String, ou em RegEx? E se quisermos procurar por outras coisas além de regexes dentro da string (e.g., outras strings)? E se quisermos procurar regexes em coisas que não são strings (e.g., streams)? No modelo verbocêntrico, essa decisão simplesmente não existe; basta definir match para todas as combinações de tipos de argumentos possíveis.

Uma terceira conseqüência desse modelo é que o conceito de "interface" é desnecessário: "implementar uma interface" consiste em especializar os métodos desejados para a classe em questão. (De certa forma, pode-se imaginar cada generic function como uma interface de um método só, e cada definição de método para a generic function como a implementação da interface para uma combinação de classes. De certa forma, pode-se imaginar muitas coisas.) Uma desvantagem disso é que se perde a garantia estática provida pelas interfaces de que de uma dada classe implementa um conjunto de métodos relacionados. (Garantias estáticas não são exatamente um ponto forte do Common Lisp.)

No modelo nominocêntrico, é possível descrever chamadas de métodos em termos de troca de mensagens: quando escrevemos obj.foo(23, 42), podemos pensar que estamos enviando a mensagem foo(23, 42) para o objeto obj. De fato, em Smalltalk, uma das primeiras linguagens orientadas a objeto, as mensagens são objetos de primeira classe, e é possível fazer algumas coisas interessantes com elas (como por exemplo repassá-las para outros objetos, ou definir um método doesNotUnderstand que trata todas as mensagens que uma classe não reconhece). O modelo de troca de mensagens também é interessante em implementações de objetos distribuídos: nesse caso, uma chamada de método sobre um objeto remoto é de fato uma mensagem enviada pela rede para a máquina onde o objeto se encontra. Uma desvantagem do modelo verbocêntrico é que o a descrição em termos de troca de mensagens não é mais aplicável: um método é chamado sobre múltiplos objetos, e portanto não há um "receptor" da mensagem.

quem diga que o CLOS (Common Lisp Object System) não é de fato orientação a objetos, mas sim um paradigma diferente que é capaz de expressar orientação a objetos e (um bocado de) outras coisas (muitas delas não abordadas neste post, tais como before/after methods (que lembram programação orientada a aspectos) e method combinations). Hoje em dia eu me vejo inclinado a concordar com essa posição, já que o CLOS foge da idéia de objetos como caixinhas fechadas manipuladas apenas através dos métodos expostos pela classe correspondente. Encapsulamento é possível em Common Lisp (basta não exportar os nomes dos slots (a.k.a. propriedades, atributos, membros) no pacote onde foram definidos), mas a noção de objeto = dados + operações se perde, de certa forma. Os objetos são apenas os dados, e as operações somos nós estão contidas nas generic functions / métodos.

2 comentários / comments

So many parens

2013-04-17 17:17 -0300. Tags: comp, prog, lisp, em-portugues

Pode-se dividir as pessoas em dois grupos, segundo sua reação ao serem apresentadas a features novas em uma linguagem de programação:

(Na verdade o que provavelmente existe é um continuum de quanta justificativa é necessária para provocar a reação "whoa, que legal" em uma dada pessoa, but I digress.) O que acontece aí é que eu (que estou no primeiro grupo, mostly) freqüentemente acho complicado pensar em um exemplo de uso de uma feature que convença alguém do segundo grupo de que a feature é interessante. Por exemplo, a pessoa me pergunta "mas por que Lisp tem tantos parênteses?", e eu respondo "Macros!", e a pessoa "why macros?", e eu "code transformation!", e a pessoa "why code transformation", e eu "what do you mean, why?".

Pois, agora acho que encontrei um exemplo decentemente convincente. Meu TCC consiste, entre outras coisas, em desenvolver uma linguagem de programação e integrá-la ao ambiente de programação DrRacket. Para isso, estou implementando a linguagem em Racket, um descendente de Scheme, uma linguagem da família Lisp. Dentre as inúmeras bibliotecas que acompanham o Racket, estão a parser-tools/lex e parser-tools/yacc. Essas bibliotecas implementam funcionalidade equiparável aos famosos programas lex e yacc, que geram analisadores léxicos e sintáticos, respectivamente, a partir de arquivinhos de regras.

A diferença, entretanto, é que as parser-tools são implementadas como macros em Racket, de maneira integrada com o resto da linguagem. Por exemplo, ao invés de usar um programa separado que lê um arquivo meio-em-C, meio-em-lex e gera um arquivo em C, a parser-tools/lex provê uma macro lexer que, quando compilada, produz uma função que recebe uma stream e devolve a próxima token encontrada na stream. Algo do tipo:

(define example-lexer
  (lexer
    [expressão  valor-a-retornar]
    [expressão  valor-a-retornar]
    ...))

O parser funciona de maneira análoga. As macros, assim, permitem estender a linguagem com recursos sintáticos novos, sem que se tenha que usar ferramentas externas para fazer transformações de código (que potencialmente exigem reparsear o texto do programa, ao contrário do que acontece com as macros, que já recebem o programa pré-parseado como argumento). A vantagem da sintaxe uniforme (i.e., so-many-parens) é que os novos recursos adicionados mantêm a mesma cara do resto da linguagem, e que o parser do Lisp (que executa antes de a macro ser chamada) pode fazer o seu trabalho sem se preocupar com a semântica dos objetos que está parseando.

A conseqüência dessa integração é que o threshold a partir do qual vale a pena escrever uma transformação de código ao invés de fazer as coisas na mão é muito mais baixo em um Lisp do que em uma linguagem convencional. Por exemplo, certa vez eu estava escrevendo um typechecker/interpretador para uma linguagem simples. Logo que eu comecei a escrever, percebi que seria uma boa eu usar pattern matching para testar com que tipo de expressão o interpretador tinha se deparado. Por exemplo, ao invés de escrever algo do tipo:

;; Avalia a expressão 'expr' no ambiente de variáveis 'env'.
(defun evaluate (expr env)
  (cond 
    ;; Se a expressão for do tipo (+ x y), devolve x+y.
    ((eq (first expr) '+) (+ (evaluate (second expr) env)
                             (evaluate (third expr) env)))
    ;; Se for (if t x y), avalia o teste e a sub-expressão correspondente.
    ((eq (first expr) 'if) (if (evaluate (second expr) env)
                               (evaluate (third expr) env)
                             (evaluate (fourth expr) env)))
    ...))

eu queria poder escrever algo do tipo:

(defun evaluate (expr env)
  (match-case
    ((+ x y) (+ (evaluate x env) (evaluate y env)))
    ((if test then else) (if (evaluate test env)
                             (evaluate then env)
                           (evaluate else env)))
    ...))

Em 17 linhas (não muito bonitas, mas só porque eu não estava muito preocupado com isso quando as escrevi) de Common Lisp, eu implementei uma macro match-case e segui escrevendo o programa. Se ao invés disso eu tivesse que escrever um programa externo para transformar o código, provavelmente não teria valido a pena o esforço.

#<eof>.

9 comentários / comments

One man's gambiarra is another man's technique

2013-04-17 14:52 -0300. Tags: comp, prog, ramble, em-portugues

Certa feita (e de repente o "fecha" do espanhol não parece mais tão estranho), não me lembro mais em que contexto nem para quem, eu mostrei um código deste tipo com listas encadeadas em C:

for (item = list;
     item && item->value != searched_value;
     item = item->next) ;

Que tanto quanto me constasse, era a maneira de se procurar um valor em uma lista em C. Para minha surpresa na época, a pessoa disse que isso era gambiarra. (Eu dificilmente escreveria isso com um while, porque eu tenho uma propensão terrível a esquecer o item = item->next no final do while. Ou será que eu tenho essa propensão por não estar acostumado a fazer esse tipo de coisa com while? But I digress.)

Uma situação similar foi quando eu mostrei para alguém na monitoria de Fundamentos de Algoritmos que dava para usar "tail-recursion com acumuladores" para iterar sobre uma lista. Algo do tipo:

(define (média lista)
  (média* lista 0 0))

(define (média* lista soma conta)
  (cond [(empty? lista) (/ soma conta)]
        [else (média* (rest lista) (+ soma (first lista)) (+ conta 1))]))

Que, novamente, é a maneira padrão de se fazer esse tipo de coisa em uma linguagem funcional (talvez com a definição de média* dentro do corpo de média, ao invés de uma função externa, mas whatever), e novamente a pessoa viu isso como uma "baita gambiarra". Provavelmente, o código imperativo equivalente (com atribuição de variáveis para manter a conta e a soma) é que seria visto como gambiarra por um programador Scheme.

Outro item de contenda é usar x++ em C para usar o valor atual de x em uma expressão e incrementá-lo na mesma operação (que é, bem, exatamente a definição do operador). Ok, usar um x++ perdido no meio de uma expressão grande pode ser fonte de confusão. Mas algo como:

var1 = var2++;

ou more likely:

item->code = max_code++;

não deveria ser nenhum mistério para quem conhece a linguagem.

A impressão que eu tenho é que as pessoas consideram "gambiarra" qualquer coisa feita de uma maneira diferente do que elas estão acostumadas. Eu mesmo já fui vítima desse efeito. No terceiro semestre eu escrevi um "banco de dados" em C (basicamente um programa que aceitava queries em uma linguagem facão, mantinha uns arquivos seqüenciais e montava uns índices em memória) para o trabalho final da disciplina de Classificação e Pesquisa de Dados. Como o conteúdo dos arquivos binários era determinado em tempo de execução pelas definições das tabelas, o programa continha um bocado de casts, manipulações de ponteiros e outras "curiosidades" do C no código. Até então, tinha sido o programa mais complicado que eu tinha escrito. Durante um bom tempo, quando o assunto surgia, eu costumava comentar que esse código era "cheio de gambiarras" e coisas de "baixo-nível". Um belo dia, uns dois ou três semestres depois, eu resolvi dar uma olhada no código, porque eu já não lembrava direito o que ele fazia de tão mágico. Para minha surpresa, minha reação ao olhar as partes "sujas" do código foi algo como "pff, que brincadeira de criança". (E para minha surpresa, as partes "sujas" me pareceram bastante legíveis.)

(Um ponto relacionado é que quando estamos ensinando/explicando alguma coisa, devemos nos lembrar que o que nos parece óbvio e simples pode não o ser para a pessoa que tem menos experiência no assunto, mas esse não era o tema original do post.)

Agora dando uma olhada nesse código de novo, lembrei de uma outra coisa que eu tinha pensando quando o tinha revisto pela primeira vez: que certos trechos do código poderiam ter sido simplificados se eu tivesse usado um goto fail; da vida em pontos estratégicos. O que nos leva ao próximo tópico...

Considered harmful?

Entre muitos nesse mundo da programação existe um culto irracional às Boas Práticas de Programação™. Não me entenda mal; muitas técnicas e práticas ajudam a tornar o código mais legível e fácil de manter. O problema é quando as "boas práticas" se tornam regras fixas sem uma boa justificativa por trás.

Um exemplo é a proibição ao goto. A idéia básica é: "Alguns usos do goto produzem código ilegível; logo, o goto não deve ser usado." Isso é mais ou menos equivalente a "facas cortam os dedos; logo, não devemos usar facas". Ok, usar uma faca para desparafusar parafusos quando se tem uma chave de fenda à mão não é uma boa idéia. Mas usar uma chave de fenda para passar margarina no pão também não é. Verdade que em linguagens de mais alto nível do que C (com exceções e try/finally e labeled loops e what-not) é extremamente raro encontrar um bom motivo para se usar um goto; mas em C, há uma porção de situações em que um goto bem utilizado é capaz de tornar o código mais simples e legível.

Outro exemplo é o uso de TABLEs em HTML. O fundamento por trás da "regra" que "proíbe" certos usos de TABLE é evitar que TABLEs sejam usadas apenas para layout, com coisas que não têm a semântica de uma tabela. So far, so good. Mas no post (que eu nunca mais encontrei) pelo qual eu fiquei sabendo sobre "tableless tables" (i.e., o uso de CSS para aplicar o layout gerado pelas TABLEs do HTML a outros elementos), lembro que um indivíduo tinha postado um comentário semi-indignado dizendo que "usar CSS para simular uma tabela é só uma tabela disfarçada, e não deve ser feito". Ou seja, o camarada internalizou a noção de "usar TABLEs é errado", mas não o porquê.

Outra manifestação do "culto" são coisas do tipo:

Q: O que acontece se eu converter um ponteiro em inteiro e de volta em ponteiro?

A: Isso é uma péssima prática de programação e você não deveria fazer isso.

que eu vejo com alguma freqüência no Stack Overflow, i.e., o camarada não responde a pergunta, e ao invés disso se limita a repetir o mantra "Não Deve Ser Feito".

A freqüência com que eu vejo esse tipo de coisa me preocupa um pouco, na verdade. Acho que o princípio por trás disso é o mesmo que está por trás de zoar/condenar/excluir as pessoas que possuem algum comportamento "non-standard". Mas isso é uma discussão para outro post.

Appendix A

17.4: Is goto a good thing or a bad thing?

Yes.

17.5: No, really, should I use goto statements in my code?

Any loop control construct can be written with gotos; similarly, any goto can be emulated by some loop control constructs and additional logic.

However, gotos are unclean. For instance, compare the following two code segments:

do {                            foo();
        foo();                  if (bar())
        if (bar())                      goto SKIP;
                break;          baz();
        baz();                  quux();
        quux();
} while (1 == 0);               SKIP:
buz();                          buz();

Note how the loop control makes it quite clear that the statements inside it will be looped on as long as a condition is met, where the goto statement gives the impression that, if bar() returned a nonzero value, the statements baz() and quux() will be skipped.

Infrequently Asked Questions in comp.lang.c

_____

(O título do post é uma paráfrase de uma paráfrase de uma expressão idiomática.)

12 comentários / comments

The shell is completely wrong

2013-01-04 13:56 -0200. Tags: comp, prog, bash, unix, wrong, rant, em-portugues

Já faz algum tempo que eu estou para escrever uma série de posts sobre como os ambientes computacionais modernos estão completamente errados. Este não é um dos posts previstos. Ao contrário da maior parte das idéias que eu pretendia/pretendo expor na série, este post trata de um tópico dentro do âmbito do tranqüilamente implementável, sem envolver mudanças de paradigma. Considere este o zero-ésimo post da série. Considere este também o proto-manifesto de um shell que eu posso vir a escrever ou não durante as férias. Comentários são bem-vindos.

[Foto de John McCarthy com a legenda 'Programming: You're doing it completely wrong']

It's a fucking programming language

Although most users think of the shell as an interactive command interpreter, it is really a programming language in which each statement runs a command. Because it must satisfy both the interactive and programming aspects of command execution, it is a strange language, shaped as much by history as by design.

The Unix Programming Environment

Embora desde os primórdios o shell do Unix tenha sido reconhecido como uma linguagem de programação, as pessoas tendem a não encará-lo como uma "linguagem de verdade"; o shell serve para rodar "scripts", ao invés de "programas". Não só os usuários de shells têm essa visão, mas também os próprios desenvolvedores de shells: embora bash, zsh e companhia tenham acumulado diversas features ao longo dos anos, faltam nos shells features básicas que se espera encontrar em qualquer linguagem de programação razoável. Por exemplo, extensões do bash em relação ao Bourne shell (sh) original incluem:

  • Variáveis array (que só podem ser arrays de string);
  • Arrays associativos, i.e., arrays indexados por strings;
  • Um comando mapfile para ler o conteúdo de um arquivo para dentro de um array (mas não mapeia coisa nenhuma: alterações no array não se refletem no arquivo);
  • Sintaxe para permitir fall-through das cláusulas de um case (i.e., permitir o comportamento de um case sem break em C);

E no entanto:

  • Arrays só podem conter strings; não é possível criar estruturas de dados aninhadas;
  • Arrays não são elementos de primeira classe: não é possível passar um array como argumento para uma função, ou retornar um array; by the way...
  • Não existe um mecanismo decente para retornar valores de uma função; o jeito é usar uma variável global, ou imprimir o valor a retornar para a stdout e ler usando a sintaxe $(função args...), que cria um subprocesso só para executar a função e captura a saída.

Além disso, a sintaxe e a semântica de certas features são bastante facão. Isso em parte se deve ao fato de que o bash tentou acrescentar features novas sem quebrar compatibilidade com a sintaxe do sh, mas também por falta de princípios de design decentes. Um mecanismo para retornar valores de funções já seria um bom começo, e ajudaria a limpar outros aspectos do shell, já que seria possível forncecer mais funcionalidades através de funções, ao invés de usar sintaxe abstrusa (por exemplo, uma função lowercase string, ao invés da nova novidade, a substituição ${varname,,pattern}, onde pattern é opcional e indica quais caracteres devem ser transformados (sim, o padrão casa com cada caractere; se o padrão tiver mais de um caractere ele não casa com nada); ou uma função substr string start length, ao invés da substituição ${varname:start:length}, cujo uso de : conflita com a substituição ${varname:-string}, o que impede que start comece com -).

Se tanto desenvolvedores quanto usuários do shell tendem a concordar que o shell não foi feito para ser uma "linguagem de verdade", poder-se-ia argumentar que eu que sou o perdido e que o propósito do shell é de fato ser uma linguagem "de brincadeira". Mas que sentido faz isso? Para que termos uma linguagem de programação pela metade, se podemos ter uma linguagem mais decente adicionando umas poucas funcionalidades básicas?

É possível argumentar alguns motivos técnicos para a resistência a estruturas de dados. Um deles é que o mecanismo de invocação de programas é totalmente baseado em strings: não é possível chamar um programa externo passando um array como argumento, por exemplo, e isso não é culpa do shell, e sim da maneira como programas são chamados no Unix. (O que por sinal é lamentável; seria ótimo poder passar estruturas complexas entre os programas. Voltaremos a esse assunto mais adiante.) Isso não é um problema insuperável; só porque comandos externos não podem receber dados estruturados não quer dizer que não possamos passar dados estruturados internamente entre as funções do shell. O caso em que o usuário tenta chamar um comando externo com um array exige um tratamento especial (transformar o array em string, ou em múltiplos argumentos, ou emitir um erro), mas isso não é motivo para eliminar estruturas de dados complexas da linguagem.

(Outro possível motivo é medinho de pôr um garbage collector dentro do shell. Há quem ache ainda hoje que garbage collection é coisa do demônio. Mas dada a popularidade de linguagens garbage-collected hoje em dia (Java, Perl, Python, Ruby, C#, etc.) e dada a existência de bibliotecas de garbage collection para C, esse também não é um motivo forte.)

Shells alternativos

Houve e há diversos shells que tentam escapar da tradição do Bourne shell. Um deles (que serviu de inspiração para outros) é o rc, o shell do Plan 9, que possui versões para Unix. O rc tem uma sintaxe um pouco mais limpa (ou não) do que o sh, mas não apresenta grandes avanços em termos de features. Uma diferença não diretamente relacionada com o shell é que no Plan 9 o exit status de um programa é uma string, e não um inteiro entre 0 e 255 como no Unix, o que possibilita usar o exit status como meio de retornar valores de funções. Porém, o shell não apresenta nenhum recurso sintático para executar uma função e substituir a chamada pelo exit status.

Inspirado no rc surgiu o es, um shell com funções de primeira classe, variáveis com escopo léxico, closures e exceptions. Uma característica interessante do es é que boa parte dos internals do shell são expostos ao usuário. Por exemplo, o operador pipe (|) é apenas açúcar sintático para uma chamada a uma função %pipe, que pode ser substituída pelo usuário de modo a modificar o comportamento do pipeline (um exemplo dado no artigo linkado é calcular o tempo de execução de cada programa da pipeline). O es possui listas/arrays, mas não permite listas aninhadas. (O motivo oferecido é fazer com que passagem de arrays para comandos externos e para funções tenham a mesma semântica; porém, dado que o shell tem que lidar com a possibilidade de o usuário tentar passar uma função para um programa externo, esse argumento não é tão convincente. Não sei o que o shell faz nessa situação; pelo artigo eu suponho que ele passe o corpo da função como uma string, e de fato parece que o shell armazena as funções internamente como strings.) O es também não possui outros tipos de estruturas de dados complexas, embora seja possível implementá-las (ainda que de maneira convoluta) através de closures. O es também permite retornar valores complexos a partir de funções, com uma sintaxe para chamar a função e recuperar o valor. Um ponto levantado no artigo é que esse mecanismo de retorno e o mecanismo de exceptions não interage bem com comandos externos: um script externo não tem como retornar valores ou propagar uma exception para o script que o chamou. Voltaremos a esse tópico mais adiante.

Um shell posterior inspirado no rc e no es é o shell do Inferno. Esse shell não tem grandes novidades comparado com o es (embora o fato de ele rodar no Inferno lhe confira alguns poderes mágicos, como por exemplo realizar comunicação pela rede atavés do sistema de arquivos, e embora ele tenha alguns módulos interessantes, tais como uma interface gráfica). Entretanto, um ponto que chama a atenção é a sintaxe: comandos como if, for e while não são tratados de maneira especial sintaticamente. Ao invés disso, eles são comandos normais que recebem blocos de código como argumentos. A idéia é similar ao método each do Ruby: ao invés de se usar uma estrutura de controle especial para iterar sobre os itens de uma lista, chama-se um método que recebe um bloco de código e o chama sobre cada elemento:

# Ruby.
a = [1, 2, 3, 4, 5]
a.each {|i|
    puts i
}

Com isso, o usuário pode definir novas estruturas de controle que se comportam de maneira similar às estruturas padrão da linguagem.

Outros shells alternativos incluem o fish, um shell com diversos recursos interativos, mas (na minha humilde opinião) sem grandes avanços em termos de programação, e o xmlsh, um shell escrito em Java cujo objetivo é permitir a manipulação de estruturas de dados baseadas em XML, e que conspira em última instância substituir a comunicação baseada em texto dos programas atuais do Unix por um modelo de comunicação estruturada baseada em XML. (Voltaremos a esse assunto mais adiante (ainda).)

O que eu faria diferente

Um shell tem dois casos de uso distintos: como um interpretador de comandos interativo, e como um interpretador de programas. O fato de que o shell deve ser conveniente de usar interativamente se reflete na sintaxe de sua linguagem: strings literais geralmente não precisam ser colocadas entre aspas; valores literais são mais freqüentes do que variáveis, e portanto nomes por si sós representam strings (o caso mais freqüente), e variáveis são indicadas com um símbolo especial ($); execução de comandos externos usa uma sintaxe simples; há uma sintaxe conveniente para gerar listas de arquivos (*.txt, ao invés de uma chamada de função), combinar entrada e saída de comandos (|), redirecionar entrada e saída para arquivos, etc. Essa moldagem da sintaxe ao redor do uso interativo limita as possibilidades sintáticas das features voltadas à programabilidade (e.g., sintaxe para chamadas de função, estruturas de dados, operações aritméticas).

Conveniências lingüísticas à parte, a minha opinião é de que o núcleo do shell deva ser a linguagem de programação em si, e não as facilidades de uso interativo. Coisas como edição de linha de comando, histórico e completamento automático de nomes de arquivos e comandos não deveriam ser internas ao shell; ao invés disso, a linguagem do shell deveria ser suficientemente capaz para que essas coisas todas pudessem ser implementadas como scripts.

Em termos de features da linguagem, é possível tornar o shell mais hábil sem quebrar (muito) a compatibilidade com sh e bash. O primeiro passo é criar um mecanismo para permitir retornar valores de funções sem criar um subshell. Para isso, é necessário definir um comando para retornar valores, e uma sintaxe para chamar uma função e recuperar o valor. Eu usaria reply valor (já que return N já está em uso, para sair da função com um exit status N) e $[função args...] para isso. (Atualmente $[...] causa avaliação aritmética em bash. A sintaxe $[...] é considerada obsoleta, em favor da sintaxe do padrão POSIX, $((...)).)

O segundo passo é tornar arrays elementos de primeira classe, permitindo passá-los para funções e retorná-los, e permitindo armazená-los onde quer que se possa armazenar um valor (por exemplo, dentro de outros arrays). Acaba-se com a noção de "array variable" do bash: uma variável contém um array, não é um array. $array não retorna mais o primeiro valor do array, e sim o array inteiro. É possível passar um array literal para uma função:

função arg1 (1 2 3) arg3

Convém criar uma sintaxe para arrays associativos, possivelmente %(chave valor chave valor ...). Também convém usar um operador diferente para indexar arrays associativos e não-associativos, já que em bash, índices de arrays não-associativos sofrem avaliação aritmética:

i=0
echo ${array_comum[i]}   # Elemento da i-ésima posição
echo ${array_assoc{i}}   # Elemento cuja chave é a string "i"

(Outra alternativa seria nunca fazer avaliação aritmética sem que o programador mande explicitamente, mas isso não só quebra a compatibilidade como é inconveniente na prática.)

Word splitting implícita sobre a os valores de variáveis fora de aspas teria a morte horrível que merece. Pathname expansion sobre valores de variáveis teria as três mortes horríveis que merece.

Em bash, os elementos de uma pipeline são executados em subshells (com exceção do primeiro (ou do último se a opção lastpipe estiver ativa, outra novidade do bash 4)), o que significa que uma função usada em um pipeline não tem como alterar os valores das variáveis globais, pois as alterações que fizer serão isoladas em seu subprocesso, o que freqüemente é inconveniente. Por exemplo, código desse tipo não funciona em bash (embora seja possível contornar o problema em muitos casos):

files=()
find . -name '*.txt' | while read file; do
    files+=("$file")
done

echo "${files[@]}"   # Array continua vazio

Uma feature desejável seria que todos os itens de uma pipeline que possam ser executados internamente ao shell o sejam. Uma solução é, ao invés de criar novos processos, criar threads, e simular os redirecionamentos de entrada e saída do pipe internamente (e.g., se um comando função1 | função2 é executado, o shell executa cada função em uma thread, e faz mágica internamente para dar à função1 a ilusão de que o que ela imprime para a stdout vai para a stdout (file descriptor 1), quando no entanto os dados vão parar em um outro file descriptor usado para fazer a comunicação entre as funções (ou mesmo em uma string, evitando chamadas de sistema para leitura e escrita em file descriptors)). O mesmo mecanismo pode ser usado para executar $(função args...) sem gerar um subprocesso.

Oops! Mas o ponto de inventar a sintaxe $[...] e reply ... era justamente evitar criar um subprocesso! Uma diferença, entretanto, é que a sintaxe $(...) só serve para retornar strings (pela sua definição), enquanto $[...] serve também para retornar estruturas de dados complexas.

Dado o paralelo entre $(...) e as pipelines, ocorre a idéia interessante de termos um equivalente do pipeline para $[...], i.e., um pipeline capaz de passar dados estruturados. Com isso, poderíamos escrever "generators", a la Python e companhia. Algo do tipo:

generate_factorials() {
    local i=1 val=1
    while :; do
        yield $val # Entrega um valor para a próxima função; aqui usamos um valor simples,
                   # mas poderíamos passar qualquer estrutura de dados.
        ((i++, val*=i))
    done
}

consume() {
    local val
    while val=$[take]; do
        echo "Recebi $val"
    done
}

generate_factorials ^ consume   # Sintaxe hipotética para o "pipeline de objetos"

Hmmrgh, agora temos dois conceitos parecidíssimos mas diferentes no shell. Quem sabe se ao invés de usar um tipo especial de pipeline, usássemos o mesmo mecanismo de pipeline para as duas coisas? Conceitualmente o comando yield então escreveria o valor para a fake stdout, e take o leria da fake stdin, enquanto internamente o shell transferiria o objeto de uma função para a outra. Da mesma forma, por consistência, o comando reply escreveria o valor de retorno para a fake stdout, e $[...] o leria da fake stdin. (Não temos como unificar $(...) e $[...] porque a semântica das duas expressões é diferente: uma retorna uma string, não importa o que o subcomando faça, enquanto a outra pode retornar qualquer tipo de valor. $[...] é análogo a um take, enquanto $(...) é análogo a um read.)

A questão é: se yield escreve na fake stdout, o que é que ele escreve? A princípio, não precisaria escrever nada "real": contanto que haja um take na outra ponta, poderíamos transferir o valor do yield para o take por mágica internamente, e apenas conceitualmente escrever no file descriptor correspondente à pipeline. Porém, se ela escrevesse alguma coisa, e essa coisa representasse o valor a ser transferido, as duas pontas do pipeline não precisariam mais estar no mesmo processo! Quer dizer, da mesma forma que o nosso pipeline comum sabe distinguir se as duas pontas estão em processos diferentes ou não, e usa file descriptors de verdade ou fake stdin/stdout dependendo do caso, o nosso pipeline de objetos também poderia fazer o mesmo. Se as duas pontas estão no mesmo processo, transferimos o objeto puro e simples. Mas se as duas pontas estão em processos distintos, podemos serializar o objeto de um lado e des-serializar do outro, de modo que é possível passar uma stream de objetos para um script externo de maneira transparente. Da mesma maneira, $[...] poderia funcionar com scripts externos, lendo o valor de retorno serializado da stdout do script e des-serializando-o novamente. Assim, resolvemos parte do problema mencionado no artigo do shell es: conseguimos fazer nossos valores complexos conviverem com o mundo texto-puro do Unix.

Em parte: falta dar um jeito de podermos passar argumentos complexos para os programas externos. A princípio poderíamos passar uma representação serializada dos argumentos. Porém, precisamos arranjar um meio de permitir ao programa chamado distinguir o array (1 2 3) da string composta pelos sete caracteres (1 2 3). Uma idéia seria sempre passar as strings com aspas em volta. Mas não podemos fazer isso porque não sabemos se o programa chamado é um script ou não, e portanto não sabemos se ele está preparado para entender as aspas (e.g., o comando ls não entende a opção "-la", com aspas em volta). Somos obrigados a passar as strings literalmente. Uma solução é passar uma variável de ambiente para o programa dizendo o tipo de cada argumento; se o subprocesso for um script, ele saberá interpretar a variável e distinguir strings de representações serializadas, e se for um outro programa qualquer, ele não dará bola para a variável*, interpretará as strings como strings, e as representações serializadas como strings também; não faz sentido passar outros objetos para não-scripts, de qualquer forma.

A semântica da passagem por serialização pode ser um pouco diferente da passagem direta dentro de um mesmo processo. Se as estruturas de dados são mutáveis, as mudanças em um processo diferente não se refletirão (a princípio) no processo original (pois o subprocesso tem uma cópia, não uma referência ao objeto original). Porém, isso não há de ser problema.

Um problema com passar valores pela stdout é que o valor de uma chamada de função não é ignorado por padrão. Isto é, enquanto na maior parte das linguagens de programação o valor de retorno de uma função é descartado por padrão se não for usado, no nosso caso a chamada imprime seu valor de retorno para a stdout, e para descartá-lo temos que tomar alguma ação explícita (um >/dev/null, por exemplo). Não sei até que ponto isso é um problema.

It's text all way down (and up)

A idéia de passar objetos estruturados entre os programas não é novidade. A grande glória das pipelines é permitir que combinemos diversos programas simples de modo a realizar uma tarefa mais complexa. No Unix, grande parte dos programas lêem e emitem texto em um formato simples (campos separados por algum caractere como TAB ou :, um registro por linha). A universalidade desse formato e a grande quantidade de ferramentas para manipulá-lo permite que combinemos programas que não foram necessariamente feitos para se comunicar um com o outro. Por exemplo, se quiséssemos contar quantos usuários locais usam cada shell, poderíamos usar um comando do tipo:

# cat /etc/passwd | cut -d: -f7 | sort | uniq -c | sort -rn
     17 /bin/sh
      5 /bin/false
      2 /bin/bash
      1 /usr/sbin/nologin
      1 /bin/sync

No entanto, texto puro por vezes deixa um pouco a desejar. E se quisermos trabalhar com dados hierárquicos, ao invés de tabelas? E se quisermos usar o separador dentro de um dos campos? Esse último problema é freqüente em programas que manipulam nomes de arquivo. No Unix, um nome de arquivo pode conter qualquer caractere, com exceção do ASCII NUL (a.k.a. \0) e da /, que é usada para separar nomes de diretórios. Isso significa que nomes de arquivo podem conter espaços, asteriscos, tabs e quebras de linha, entre outros, o que atrapalha a vida de muitos scripts. Por exemplo, se você usar um comando do tipo:

find / -size +4G >lista-de-arquivos-maiores-que-4-giga.txt

você corre o risco de uma alma perversa ter criado um arquivo com um \n nome, o que vai fazer com que esse nome pareça duas entradas distintas na lista de arquivos. A maior parte das ferramentas não é preparada para lidar com terminadores de linha diferentes de \n (embora diversas ferramentas do GNU tenham opções para lidar com \0). E se ao invés de passar texto puro entre os programas pudéssemos passar dados estruturados, em um formato compreendido por todas as ferramentas?

Como disse, essa idéia não é novidade. Talvez o projeto mais famoso a explorar essa possibilidade no Unix seja o TermKit, um projeto que objetiva liberar o shell do mundo do texto puro e dos emuladores de terminal. As ferramentas do TermKit se comunicam usando JSON e headers baseados em HTTP. A idéia é que, ao invés de transmitir bytes brutos, os dados que entram e saem dos processos carreguem consigo uma indicação de seu tipo (no header), de modo que as ferramentas saibam como lidar com o conteúdo que recebem. O TermKit foca na parte de interação com o usuário, e não provê um shell completo (programável).

Há uma thread longa sobre o assunto nos fóruns do xkcd.

Outro projeto nesse sentido é o xmlsh mencionado na última seção, que utiliza XML para a comunicação entre os processos.

No mundo não-Unix, um programa interessante é o PowerShell do Windows. No caso do PowerShell, os comandos realmente passam objetos entre si (e não representações serializadas). Isso é feito com uma pequena "trapaça": os comandos capazes de lidar com objetos não executam em processos externos, mas sim são instâncias de "cmdlets", objetos que fornecem uma interface para o shell, e que são instanciados dentro do próprio shell. Um exemplo retirado do artigo é o comando:

get-childitem | sort-object extension | select extension | where { $_.extension.length -eq 4 }

get-childitem é uma versão generalizada do ls, que funciona sobre diretórios e outras estruturas hierárquicas. sort-object recebe uma stream de objetos como entrada pelo pipeline, e os ordena pelo campo passado como argumento. select é similar ao cut do Unix, mas trabalha sobre objetos. where é um comando de filtro que recebe como argumento um predicado, i.e., uma função que é executada sobre cada elemento da entrada e decide se o elemento permanece na tabela final ou não.

O que eu acharia realmente ideal seria passar objetos entre processos, não apenas como um mecanismo interno do shell. Isso era possível na Lisp Machine, primariamente porque todos processos eram executados no mesmo espaço de endereçamento (i.e., os processos na verdade são threads). (Além disso, o fato de todos os programas do sistema terem uma linguagem comum (Lisp) garante que os objetos serão entendidos por todos os programas.) Permitir isso em um ambiente com processos isolados é uma tarefa mais complicada.

Mas estamos divergindo do ponto original e começando a sair do âmbito do tranqüilamente implementável. Por enquanto eu só quero criar um shell decentemente programável. Revolucionar a maneira como os programas se comunicam é tópico para outros posts.

O que mais eu faria diferente

Até agora adicionamos features de maneira semi-compatível com o sh. Mas podemos aproveitar a oportunidade para fazer uma limpeza geral no shell. Poucos são os fãs da sintaxe do sh [citation needed], então creio que poucos se objetarão terminantemente a um shell com sintaxe diferente.

Uma coisa que eu gostaria é de seguir o exemplo do shell do Inferno e usar comandos comuns e blocos de código ao invés de estruturas de controle com sintaxe especial. Assim, o usuário poderia definir estruturas de controle novas sem necessidade de alterar a sintaxe do shell. Exemplo hipotético:

function foreach (list body) {
    local i=0
    local length=$[length $list]

    while { $i < $length } {
        $body ${list[i]}
        i += 1
    }
}

# Exemplo de uso. (Sintaxe de bloco roubada do Ruby.)
foreach (*.txt) {|file|
    echo "Eis o arquivo $file:"
    cat $file
}

Tenho outras idéias para um shell, mas acho que esse post já está ficando longo demais. Talvez eu escreva sobre isso em outra ocasião (ou se alguém demonstrar interesse). Por ora ficamos por aqui.

Conclusão

Os pontos principais deste post são:

  • O shell é uma linguagem de programação; não existe um bom motivo para ele ser uma linguagem de programação pela metade;
  • É possível adicionar capacidades que favoreçam a programabilidade do shell sem prejudicar seu uso interativo (ainda que isso force o shell a usar uma sintaxe estranha para algumas coisas, de modo a acomodar mais naturalmente o uso interativo);
  • Todos são a favor da moção.

_____

* Mas o subprocesso pode passar a variável intacta para outros processos, o que eventualmente pode fazer a variável cair em outro shell, que ficará confuso achando que a variável se refere aos argumentos que recebeu. Uma solução é incluir no valor da variável o PID do processo a quem a variável se destina. Mas ainda há o risco de o processo fazer um exec() e executar um shell com o mesmo PID atual, ou de um PID se repetir. Hmmrrgh...

1 comentário / comment

Reading and splitting

2012-12-31 13:39 -0200. Tags: comp, prog, bash, em-portugues

Durante os últimos 354 anos eu estive splitando linhas em bash assim:

line="root:x:0:0:root:/root:/bin/bash"

IFS=":"
set -- $line
echo "$1"               # root
echo "$7"               # /bin/bash

# ou:
IFS=":"
fields=($line)
echo "${fields[0]}"     # root
echo "${fields[6]}"     # /bin/bash

Turns out que faz 354 anos que eu estou fazendo isso errado.

Acontece que expansão de parâmetros (i.e., $var) fora de aspas duplas não só causa word splitting (que é o que queremos), mas também causa pathname expansion (substituição de *, ?, ~ e afins pelos nomes de arquivo que casam com o padrão):

cd /
line='a*:b*:c*'
IFS=":"
fields=($line)

echo "${fields[0]}"     # a*
echo "${fields[1]}"     # bin
echo "${fields[2]}"     # boot
echo "${fields[3]}"     # c*

Esse problema nunca me ocorreu na prática, mas me dei conta disso depois de escrever o post sobre manipulação de strings em bash. O pior é que eu já sabia há muito tempo que variáveis fora das aspas sofrem pathname expansion, mas acho que adquiri o hábito de splitar strings assim antes de saber disso. Uma solução correta é usar o comando IFS=delimitadores read -a arrayname (que lê uma linha com campos separados por delimitadores e coloca os pedaços na variável arrayname) em conjunto com o operador <<<string (que alimenta a stdin do comando com a string):

line='a*:b*:c*'
IFS=":" read -a fields <<<"$line"

echo "${fields[0]}"     # a*
echo "${fields[1]}"     # b*
echo "${fields[2]}"     # c*

De brinde agora é possível incluir : em um campo usando a seqüência \:, já que o read entende o \ como um indicador de que o próximo caractere deve ser interpretado literalmente (a menos que a opção -r (de raw) seja usada).

O último exemplo de splitting do post anterior (que procura o nome do shell de um usuário no /etc/passwd) ficaria assim:

#!/bin/bash
user="$1"

IFS=":"
while read -a campos line; do
    if [ "${campos[0]}" = "$user" ]; then
        echo "${campos[6]}"
    fi
done </etc/passwd

Outra feature interessante do read é a opção -d, que permite especificar um caractere diferente de \n como terminador de linha. Isso é útil, por exemplo, em conjunto com o find -print0, que imprime os nomes dos arquivos separados pelo caractere \0 (ASCII NUL) ao invés de \n:

find . -name '*.bkp' -print0 | while IFS= read -d $'\0' file; do
    echo "Arquivo $file será renomeado."
    mv "$file" "${file%.bkp}~"
done

Isso garante que o script vai funcionar mesmo que o nome de algum arquivo contenha quebras de linha, o que faz com que o script não seja vulnerável ao usuário malandro que criar um arquivo chamado foo\n/bin/bash\nbar.bkp em seu home.

4 comentários / comments

Manipulando strings em bash

2012-12-27 02:49 -0200. Tags: comp, prog, bash, em-portugues

Seguindo uma sugestão indireta, eis um post sobre as funções básicas de manipulação de strings do bash.

Antes de mais nada, gostaria de citar uma frase do manual do INTERCAL que me parece apropriada a respeito do bash:

Please be kind to our operators: they may not be very intelligent, but they're all we've got.

Dito isto, vamos ao ponto.

Parameter substitution

Como toda linguagem normal, bash possui variáveis.

person="Elmord"
echo "$person's Magic Valley"

A sintaxe $nome invoca o que se chama de parameter expansion. 'Parâmetro' é um termo mais geral que 'variável' em bash, e inclui variáveis, argumentos recebidos por um script ou função ($1, $2, ...), e alguns valores especiais mantidos pelo bash (e.g., $$ (PID do shell), $* (todos os argumentos do script/função em uma string só), etc.). Em bash, todos os parâmetros são strings (ou quase: $*, $@ e aparentados são meio indecisos sobre se são strings ou arrays de strings).

$nome pode ser escrito como ${nome}. Isso é útil quando se deseja isolar o nome do parâmetro de uma string adjacente que poderia ser interpretada como parte do nome:

animal="Raposa"
echo "${animal}s não dão dinheiro."

Além disso, essa sintaxe estendida permite especificar modificações sobre o valor a ser retornado pela expansão. Entre as modificações mais usadas estão as remoções de prefixos e sufixos:

(Mnemônico: # fica à esquerda (prefixo) de $ no teclado, % à direita (sufixo) de $.)

Por exemplo:

file="arquivo.feliz.html"
echo "$file"         # arquivo.feliz.html
echo "${file%.*}"    # arquivo.feliz (remove o menor sufixo do tipo .*, i.e., do último ponto em diante)
echo "${file##*.}"   # html (remove o maior prefixo do tipo *., i.e., do começo até o último ponto)
echo "${file%%.*}"   # arquivo (remove tudo do primeiro ponto em diante)
echo "${file#*.}"    # feliz.html (remove apenas até o primeiro ponto)

Exemplo levemente mais elaborado:

path="foo/bar/baz/quux/hack"
echo "${path%/*}"          # foo/bar/baz/quux
echo "${path%/*/*}"        # foo/bar/baz
echo "${path%/*/*/*}"      # foo/bar
echo "${path%/*/*/*/*}"    # foo
echo "${path%/*/*/*/*/*}"  # foo/bar/baz/quux/hack

No primeiro echo, removemos apenas o último componente do caminho. No segundo, removemos o menor sufixo que casa com /*/*, i.e., que consiste de uma barra, seguida por qualquer coisa (inclusive nada), seguido por outra barra, seguida por qualquer coisa (inclusive nada), o que resulta na eliminação dos dois últimos componentes do caminho. No último comando, o pattern não casa com nada (não há cinco barras na string), e portanto o resultado é o valor de path intacto. (Note que isso funciona porque estamos removendo o menor sufixo, com o operador % simples. Se usássemos o operador %% (i.e., ${path%%/*}, etc.) ficaríamos apenas com foo em todos os casos exceto o último.)

E se ao invés de remover os dois últimos componentes, quiséssemos ficar com apenas os dois últimos componentes? Infelizmente não existe um operador para remover o "segundo maior prefixo" que case com */. Uma maneira de conseguir isso é fazer a operação em dois passos:

path="foo/bar/baz/quux/hack"
prefix="${path%/*/*}"      # coloca "foo/bar/baz" em prefix
echo "${path#"$prefix"}"   # remove "foo/bar/baz" do começo de $path, deixando "/quux/hack"; ou
echo "${path#"$prefix/"}"  # remove "foo/bar/baz/" do começo de $path, deixando "quux/hack"

Ou, de maneira mais abreviada (e talvez menos legível):

echo "${path#"${path%/*/*}"}"

Note as aspas ao redor da sub-expressão (${path#"prefix"}). Elas são necessárias porque o valor de $prefix poderia conter caracteres como *, que seriam interpretados como wildcards se não houvesse aspas.

Os wildcards que podem aparecer nas patterns são os mesmos que podem ser usados com nomes de arquivos: * (zero ou mais caracteres quaisquer), ? (um caractere qualquer), [abcde] (qualquer um dos caracteres listados; ranges do tipo A-Z, bem como classes de caracteres do tipo [:digit:], podem ser incluídos na lista), [^abcde] (qualquer caractere não listado). Se a opção shopt -s extglob estiver ativa, patterns estendidas também são aceitas, mas isso é assunto para outro post.

Outro tipo de modificação útil são as substituições de substrings:

A pattern de substituição sempre casa com a maior ocorrência (i.e., se x="foo.bar.baz.quux.hack", ${x/.*./BOOM} expande para fooBOOMhack). Aparentemente não há um meio de substituir o menor prefixo/sufixo. Go figure.

Outra modificação útil é o slicing: ${nome:início:tamanho} seleciona tamanho caracteres de $nome, começando pelo início-ésimo (contando do zero). início e tamanho são expressões aritméticas; portanto, nomes de variáveis podem ser usados sem $, e operações aritméticas podem ser realizadas diretamente, sem necessidade de usar a sintaxe usual para expansão aritmética ($((expressão))):

x="foobarbazquux"
echo "${x:6:3}"      # baz
n=6
echo "${x:n:n/2}"    # baz

Se tamanho for omitido, o trecho de início até o final da string é usado. Se início for negativo, conta-se a partir do final da string:

echo "${x:(-4):1}"   # q
echo "${x:(-4)}"     # quux

(Note que o - deve ser separado do : que o precede (com parênteses ou espaços, por exemplo), pois ${nome:-string} significa algo completamente diferente em bash (usa o valor de $nome, ou string se $nome for vazio).)

Se um índice negativo indicar uma posição antes do começo da string, o resultado é a string vazia. (Por quê? Por quê?)

${#nome} devolve o comprimento de $nome. Há outras modificações; para mais informações, procure por Parameter Expansion na manpage do bash.

Separando em pedacinhos

Uma operação comum sobre strings é separá-la em diversos componentes segundo algum caractere separador. Essa operação existe em diversas linguagens sob o nome de split. Por exemplo, em JavaScript:

js> x = "foo bar baz"
"foo bar baz"
js> partes = x.split(" ")
["foo", "bar", "baz"]
js> partes[1]
"bar"

(By the way: já experimentou dar Ctrl-Shift-K no Firefox?)

Bash é uma linguagem muito hábil em separar strings. Por sinal, bash é hábil demais em separar strings: qualquer parâmetro não envolto em aspas está sujeito ao infame word splitting: o valor resultante da expansão do parâmetro é separado em múltiplas "palavras", e cada palavra é tratada como um argumento separado. Por exemplo:

$ file="nome com espaços"
$ ls "$file"
ls: cannot access nome com espaços: No such file or directory
$ ls $file
ls: cannot access nome: No such file or directory
ls: cannot access com: No such file or directory
ls: cannot access espaços: No such file or directory

Isso significa que você deve colocar aspas maniacamente em torno de qualquer string com variáveis que você não quer que sejam splitadas. Isso também significa que você pode se aproveitar dessa feature para obter os pedacinhos individuais de uma string. Uma maneira de fazer isso é criando um array com o resultado da expansão:

partes=($file)
echo "${partes[0]}"     # nome
echo "${partes[1]}"     # com
echo "${partes[2]}"     # espaços
echo "${#partes[@]}"    # 3 (comprimento do array)
Outra maneira é substituir os argumentos posicionais do shell pelo resultado da expansão:
set -- $file
echo "$1"               # nome
echo "$2"               # com
echo "$3"               # espaços
echo "$#"               # 3
O que define uma "palavra" são os separadores contidos na variável IFS (internal field separator): essa variável contém todos os caracteres que são considerados separadores. Por padrão, os separadores são espaços, tabs e newlines. Você pode setar essa variável para quaisquer outros caracteres, e remover a variável (com unset IFS) para restaurar os separadores originais. Por exemplo, se quiséssemos escrever um script para procurar no arquivo /etc/passwd (cujos campos são separados por :) o nome do shell de um determinado usuário, poderíamos fazer assim:
#!/bin/bash
user="$1"

IFS=":"
while read line; do
    campos=($line)
    if [ "${campos[0]}" = "$user" ]; then
        echo "${campos[6]}"
    fi
done </etc/passwd

Update: Há uma solução melhor.

Matching

Às vezes queremos saber se uma string casa com um determinado padrão. A maneira mais comum de se fazer isso é através do comando case:

case "$file" in
    *.txt)
        echo "É um arquivo de texto."
        cat "$file"
        ;;
    *.gif|*.jpg|*.png)
        echo "É uma figurinha."
        xloadimage "$file"
        ;;
    *)
        echo "Que que é isso, medeus?"
        ;;
esac

Cada cláusula do case começa com uma pattern, seguida de ). Os tipo de pattern aceitos são os mesmos usados para expansão de nomes de arquivos e para remoções de prefixo/sufixo e outras substituições de string. Cada cláusula é terminada com ;;. (O ;; na última cláusula é opcional.) Múltiplas patterns, separadas por |, podem ser especificadas.

As patterns do shell são um tanto quanto limitadas, o que exige uma certa dose de treta na hora de usá-las. Por exemplo, imagine que queiramos fazer um script que exige um número como argumento, e queremos que o script teste se o argumento foi passado corretamente. Não temos como especificar uma pattern que diga que apenas números são aceitos. A solução é especificar que se o argumento contiver algum caractere que não seja um número, o script deve emitir um erro:

case "$1" in
    *[^0-9]*)
        echo "Erro: argumento deve ser um número."
        exit 1
        ;;
    *)
        echo "Eis um número: $1."
        ;;
esac

Parece bom? Quase: se uma string vazia for passada, nenhum dos caracteres que a compõem casa com [^0-9], e portanto a primeira pattern não casa, e portanto caímos na segunda cláusula do case, embora o argumento passado não seja um número. Para resolver esse problema, temos que incluir a string vazia na primeira cláusula:

case "$1" in
    *[^0-9]*|)
        echo "Erro: argumento deve ser um número."
        exit 1
        ;;
    *)
        echo "Eis um número: $1"
        ;;
esac

Comandos externos

Até agora utilizamos apenas recursos internos do shell. Mas temos também à disposição a vasta gama de comandos de manipulação de texto do Unix, tais como cut, sed e tr. Se quisermos alimentar um desses comandos com o conteúdo de uma variável, podemos usar o comando echo em uma pipeline, e capturar o resultado com a sintaxe $(comando):

dmy="27/05/1990"
ymd="$(echo "$dmy" | sed -re 's,(..)/(..)/(....),\3-\2-\1,')"
echo "$ymd"   # 1990-05-27

A partir do bash 3, é possível alimentar a entrada padrão de um comando com uma string através da sintaxe comando <<<string:

ymd="$(sed -re 's,(..)/(..)/(....),\3-\2-\1,' <<<"$dmy")"

Outras features

Este post não cobre todas as features de manipulação de strings no bash. (Em particular, faltou cobrir o comando [[ expressão ]] e suas habilidades com expressões regulares, as quais eu ainda não experimentei direito.) Para mais informações, dê uma olhada na manpage do bash. Sinta-se à vontade para acrescentar outras mandingas ou fazer perguntas nos comentários.

7 comentários / comments

Sobre a performance de verificação de limites de vetor

2012-12-05 19:17 -0200. Tags: comp, prog, pldesign, em-portugues

Como é sabido e notório, implementações normais de C e C++ não fazem verificação de limite de vetor (bounds checking)*. Como também é sabido e notório, essa é uma fonte inexaurível de bugs obscuros e vulnerabilidades de segurança. Dado que esses problemas têm incomodado a humanidade há mais de 30 anos, pergunto: por que diabos compiladores C/C++ não implementam verificação de limites? A resposta normalmente dada é de que isso é feito em nome da performance: verificação de limites é custosa, e se o código for corretamente escrito é desnecessária, logo bounds checking é um desperdício de tempo de execução. Mas será que bounds checking é tão custoso assim?

Testemos, pois. Faremos um benchmark simples comparando a performance de uma função com e sem bounds checking. Para fazermos a verificação de limites, armazenaremos o tamanho dos vetores em uma posição de memória antes do conteúdo "útil" do vetor:

int *make_vector(int length) {
    int *vector = malloc(sizeof(int) * (length+1));
    vector[0] = length;  // Armazena o tamanho no início da região de memória.
    vector++;            // Aponta 'vector' para o resto da região.

    int i;
    for (i=0; i<length; i++)
        vector[i] = i;
        
    return vector;
}

#define lengthof(v) ((v)[-1])

Nossa função de teste recebe dois vetores e soma cada elemento do segundo ao respectivo elemento do primeiro um determinado número de vezes. (Um compilador suficientemente inteligente poderia substituir o loop de N iterações por uma multiplicação por N, distorcendo os resultados. "Felizmente" esse não é o caso com o GCC (ou provavelmente qualquer compilador C existente).)

void test(int *restrict v1, int *restrict v2, int iterations) {
    int length = lengthof(v1);
    int i;

    while (iterations-- > 0) {
        for (i=0; i<length; i++) {
            #ifdef BOUNDS_CHECK
                if (i<0 || i>=lengthof(v1)) exit(42);
                if (i<0 || i>=lengthof(v2)) exit(42);   // i<0 redundante
            #endif

            v1[i] += v2[i];
        }
    }
}

Note que, como o mesmo índice i é usado duas vezes, temos dois testes i<0. A idéia é simular como um compilador simplista inseriria as verificações de limite; um compilador mais esperto poderia juntar as duas verificações em um if só e eliminar as verificações redundantes. Porém, deixemos o teste extra aí, já que em geral não teremos necessariamente o mesmo índice para todos os acessos de array de um determinado trecho de código. (Estranhamente, o GCC é esperto o suficiente para notar que o corpo dos dois ifs é igual, mas não para notar o teste redundante, mesmo com otimizações ativadas.)

Por fim, nossa main() cria dois vetores e chama a função de teste:

int main() {
    test(make_vector(10000), make_vector(10000), 10000);
    return 0;
}

Compilemos e testemos:

# gcc -std=c99 -O2 version0.c && time ./a.out

real    0m0.385s
user    0m0.384s
sys     0m0.000s

# gcc -DBOUNDS_CHECK -std=c99 -O2 version0.c && time ./a.out

real    0m0.704s
user    0m0.700s
sys     0m0.000s

Quase duas vezes o tempo! (Como curiosidade, sem o i<0 redundante o tempo de execução com bounds checking cai para 0.660s, em "user time".) Mas não tiremos conclusões precipitadas. Quem sabe se usarmos outra representação para o par (tamanho, dados)? Uma struct, por exemplo:

typedef struct vector_t {
    int length;
    int content[0];  // GCC ftw
} vector_t;

vector_t *make_vector(int length) {
    vector_t *vector = malloc(sizeof(vector_t) + sizeof(int)*length);
    vector->length = length;

    int i;
    for (i=0; i<length; i++)
        vector->content[i] = i;

    return vector;
}

#define lengthof(v) ((v)->length)

void test(vector_t *restrict v1, vector_t *restrict v2, int iterations) {
    int length = lengthof(v1);
    int i;

    while (iterations-- > 0) {
        for (i=0; i<length; i++) {
            #ifdef BOUNDS_CHECK
                if (i<0 || i>=lengthof(v1)) exit(42);
                if (i<0 || i>=lengthof(v2)) exit(42);
            #endif

            v1->content[i] += v2->content[i];
        }
    }
}

Será que faz diferença?

# gcc -std=c99 -O2 version1.c && time ./a.out

real    0m0.386s
user    0m0.384s
sys     0m0.000s

# gcc -DBOUNDS_CHECK -std=c99 -O2 version1.c && time ./a.out

real    0m0.505s
user    0m0.504s
sys     0m0.000s

E não é que faz? Olhando o assembly gerado pelo GCC (gcc -S -fverbose-asm), a principal diferença que eu percebo é que na versão com vetor puro o GCC lê o endereço de lengthof(v2) de uma variável temporária armazenada na pilha, carrega o endereço em %eax e testa i contra %eax, enquanto na versão com struct o valor de lengthof(v2) é lido diretamente como (%esi), já que o tamanho do vetor é o primeiro item da região de memória. Sinceramente não sei por que o GCC não lê lengthof(v2) na versão com vetor como -4(%esi), já que %esi fica com o endereço de v2. Experimentei substituir a carga em %eax e comparação de i contra %eax por um teste direto de i contra -4(%esi); o resultado é o mesmo (verificado com um printf a facão) e o tempo cai dos 0.704s para 0.555s. Go figure.

Mas essa história de registradores nos dá outra idéia. O tamanho dos vetores não muda durante a execução da função. Talvez se copiarmos esses valores para variáveis locais antes de entrar no loop, o compilador os mantenha em registradores, acelerando a checagem:

void test(vector_t *restrict v1, vector_t *restrict v2, int iterations) {
    int length1 = lengthof(v1);
    int length2 = lengthof(v2);
    int i;

    while (iterations-- > 0) {
        for (i=0; i<length1; i++) {
            #ifdef BOUNDS_CHECK
                if (i<0 || i>=length1) exit(42);
                if (i<0 || i>=length2) exit(42);
            #endif

            v1->content[i] += v2->content[i];
        }
    }
}

E agora?

# gcc -std=c99 -O2 version2.c && time ./a.out

real    0m0.386s
user    0m0.384s
sys     0m0.000s

root@pts4 bounds2(0)# gcc -DBOUNDS_CHECK -std=c99 -O2 version2.c && time ./a.out

real    0m0.387s
user    0m0.388s
sys     0m0.000s

Conclusão: bounds checking é eficiente se implementado corretamente. Todos os desenvolvedores de compiladores C são tolos e ignorantes. Nós, mentes iluminadas, implementaremos um compilador C com bounds checking e recompilaremos o kernel e todo o universo com bounds checking, tornando todo o software em existência mais seguro, sem perda de performance. Brindemos à nossa glória.

Só que não

Só tem um problema: nossa verificação assume que, a partir da variável pela qual acessamos o vetor, podemos consultar seu tamanho, que armazenamos em uma posição fixa em relação à área de dados do vetor. O problema é que C permite fazer aritmética de ponteiros: um ponteiro pode apontar para qualquer ponto dentro de um vetor, não necessariamente para o seu começo. Conseqüentemente, a partir de um ponteiro arbitrário não necessariamente sabemos como encontrar o tamanho da região. Em C ainda há o agravante de que vetores e ponteiros não são tipos distintos: não se pode saber se uma função com um argumento do tipo int[] está recebendo um ponteiro para um "vetor completo" ou para uma região arbitrária dentro do vetor. (O próprio conceito de "vetor completo" não faz muito sentido em C.)

Diversas soluções já foram propostas. [Disclaimer: o que se segue são divagações sobre as possíveis soluções. Ênfase na parte "divagações".] Uma das mais simples é representar todos os ponteiros como triplas (ponteiro, limite_inferior, limite_superior), permitindo a verificação de limites para ponteiros arbitrários. O problema é que todos os ponteiros passam a ocupar o triplo do espaço, o que aumenta o tamanho de estruturas de dados com ponteiros (e.g., listas, árvores e outras estruturas recursivas), gera mais empilhamentos na passagem de argumentos para funções e possivelmente consome mais registradores. (Não sei quão válido é o ponto sobre os registradores, já que armazenar (base, length, i) ou (lower, ptr, upper) dá mais ou menos na mesma (exceto no caso em que é necessário armazenar o ponteiro para um vetor e um índice (i.e., (lower, ptr, upper, i)) (mas nada que o compilador não pudesse otimizar, I guess))).

Outro problema com essa solução é que a alteração do formato dos ponteiros implica que código com bounds checking (e ponteiros triplos, também conhecidos como "fat pointers") não pode interagir diretamente com código sem bounds checking (com pointeiros simples). Conseqüentemente, tem-se que recompilar tudo para usar bounds checking, incluindo bibliotecas. (Nossa solução original também tem esse problema, talvez em menor grau: é preciso armazenar o tamanho de cada vetor em seu início. Os ponteiros não mudam de formato, mas o código assume coisas diferentes sobre a região apontada pelos mesmos.)

Segundo estes relatos, o uso de fat pointers desse tipo provoca um aumento de mais de 100% em tempo de execução e quase duplica o tamanho dos dados nos benchmarks utilizados (o que os autores consideram "boa performance"; go figure).

Uma solução intermediária seria armazenar o tamanho de cada região no início da mesma e representar os ponteiros como um par (base, offset), a la Lisp Machine. Não vi ninguém sugerir isso entre os artigos que eu andei lendo, e não sei até que ponto isso seria melhor. Um problema de armazenar o tamanho junto com a região de memória é quando o programador deseja alocar regiões de memória com algum alinhamento, por questões de performance. Por exemplo, o programador pode fazer uma chamada a posix_memalign(&ptr, 4096, 4096), para obter uma região alinhada com uma página de memória; o tamanho da região teria que ser armazenado antes da página. Se duas dessas regiões são alocadas, duas páginas extra são alocadas apenas para conter o tamanho de cada região. (Por outro lado, uma implementação "ingênua" de malloc e posix_memalign já armazena o tamanho da região e outros dados antes da região.)

Soluções que não envolvem alterar o formato dos ponteiros e vetores trabalham armazenando os dados sobre limites em uma tabela ou outra estrutura de dados indexada pelo ponteiro cujos limites se deseja consultar. Uma das soluções mais elaboradas instrumenta todas as ocasiões de alocação e liberação de memória, tanto por malloc quanto na pilha, bem como operações aritméticas sobre ponteiros, com chamadas a funções que fazem o controle de limites. O código resultante é compatível com código sem bounds checking, mas a instrumentação tem um overhead significativo sobre o tempo de execução do programa. É uma solução útil para debugar software, já que permite instrumentar o programa sem recompilar as bibliotecas, mas não serve como um mecanismo permanente de verificação de limites.

Conclusão

A conclusão que eu tiro disso é: pense 44 vezes antes de incluir aritmética de ponteiros (ou ponteiros em geral) em uma linguagem. Além de dificultar bounds checking eficiente, ponteiros atrapalham a vida do compilador na hora de efetuar certas outras otimizações. Talvez o melhor seja acessar vetores sempre através de vetor/índice, e deixar o compilador se encarregar de substituir o par vetor/índice por um ponteiro como uma otimização. Se você incluir ponteiros em uma linguagem, considere mantê-los como um tipo distinto dos tipos de vetor (que carregam consigo o tamanho do vetor). Se performance for importante, me parece uma idéia melhor fazer bounds checking por padrão e fornecer uma declaração de compilação que permita eliminar os testes quando necessário, do que não fazer verificações por padrão e deixar o trabalho sujo nas mãos do programador (que invariavelmente esquece de fazê-lo ou comete um erro uma hora ou outra).

Notas

* Tecnicamente não é correto dizer que C não faz bounds checking, mas sim que a linguagem não exige bounds checking e as implementações da linguagem não o fazem. Por sinal, o compilador de C que vinha com o Genera fazia bounds checking (pelo simples fato de que todo acesso a memória na Lisp Machine era checked).

De maneira similar, uma linguagem que "faz bounds checking" apenas garante que todo acesso além do limite de um vetor será detectado. Uma implementação não precisa necessariamente inserir testes em tempo de execução, se ela puder provar de antemão que um acesso fora dos limites nunca ocorrerá. Por exemplo, em um loop do tipo for (i=0; i<lengthof(v); i++) em que i não é alterado no corpo do loop, acessos do tipo v[i] certamente estão dentro dos limites (i.e., i>=0 && i<lengthof(v) é sabidamente verdadeiro em tempo de compilação), e portanto verificações de limite não precisam ser inseridas no código.

Addendum: vide comentário.

13 comentários / comments

Main menu

Recent posts

Recent comments

Tags

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

Elsewhere

Quod vide


Copyright © 2010-2024 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.