3 puntos por GN⁺ 2023-11-24 | 1 comentarios | Compartir por WhatsApp
  • En sistemas de la familia Unix puede existir un ejecutable llamado /bin/[, y la sintaxis que parece una expresión condicional del shell en realidad se apoya en la ejecución de comandos y sus códigos de salida
  • test evalúa expresiones y devuelve 0 si son verdaderas y 1 si son falsas; cuando se invoca como [ también verifica que el último argumento sea ]
  • Muchos shells también ofrecen test y [ como comandos integrados, por lo que los mensajes de error o el comportamiento pueden diferir entre /bin/test externo y la implementación integrada del shell
  • La extensión de Bash [[ no es un comando externo sino una sintaxis integrada, así que aplica reglas distintas a [; por ejemplo, no expande long* como glob sino que lo compara como cadena literal
  • En scripts portables conviene usar [, y si el script es exclusivo de Bash es mejor usar [[ de forma consistente, pero hay que elegir sabiendo que ambas opciones difieren en sus reglas de expansión

Qué son /bin/[ y /bin/test

  • En sistemas Unix puede existir un ejecutable con un nombre de un solo símbolo: /bin/[
    • El comando de ejemplo ls /bin/? muestra /bin/[
  • /bin/[ y /bin/test pueden apuntar al mismo binario
    • En el ejemplo, ambas rutas aparecen como archivos con el mismo inode y tamaño
    • Aun así, no tienen por qué ser enlaces duros en todos los sistemas
  • test es un programa del shell para evaluar expresiones
    • comparación de cadenas
    • comparación de números
    • verificación de condiciones de archivos
  • Si el resultado es verdadero devuelve el código de salida 0, y si es falso devuelve 1

Por qué [ funciona como si fuera un comando

  • test a = b no se ve mucho como una expresión condicional, pero la misma lógica escrita como [ a = b ] resulta más familiar
  • [ parece una sintaxis aparte, pero en realidad es una invocación de comando
    • if [ a = b ]; then ... fi ejecuta el comando [ y revisa su código de salida
    • Cuando se invoca como [, el programa comprueba si el último argumento es el corchete de cierre ]
  • La sentencia if no interpreta directamente la condición, sino que decide en función del código de salida del comando recibido
    • test a = a; echo $? da 0
    • test a = b; echo $? da 1
    • [ a = a ]; echo $? da 0
    • [ a = b ]; echo $? da 1
  • En el mismo sentido, true y false también pueden verse como binarios auxiliares que devuelven códigos de salida

Diferencia entre binarios externos y comandos integrados del shell

  • Como test y [ se usan con frecuencia en scripts de shell, la mayoría de los shells también los implementan como comandos integrados
  • Incluso con la misma entrada, la salida puede diferir entre el binario externo y el comando integrado del shell
    • /bin/test a b produce test: a: unexpected operator
    • test a b produce dash: 2: test: a: unexpected operator
  • Esta diferencia no solo ocurre con test y [, sino también con comandos aparentemente simples como echo
  • Como cada shell puede tener una implementación integrada distinta, el comportamiento del script puede variar según el shell que lo ejecute

Reglas distintas que aplica la extensión [[ de Bash

  • [[ es una extensión de Bash y puede usarse en lugar de [
  • La diferencia más grande es que [[ siempre es una sintaxis integrada
    • A diferencia de [, que puede ejecutarse como binario externo, con [[ Bash puede cambiar las reglas del lenguaje dentro de la expresión
  • En el ejemplo con globbing, [ y [[ se comportan de manera distinta
    • Después de touch long-name, [ long* = long-name ] && echo match imprime match
    • A los argumentos del comando [ se les aplican las reglas normales de expansión del shell, así que long* se expande al long-name del directorio
    • [[ long* = long-name ]] && echo match no imprime nada
    • [[ trata long* como una cadena literal y la compara tal cual con long-name, así que falla
  • Si el script es exclusivo de Bash, con [[ también se pueden usar funciones como la coincidencia con expresiones regulares =~

Qué elegir en un script

  • En scripts de shell portables, lo correcto suele ser usar [
  • También se puede usar test, pero no es la opción más común
  • Si el script es exclusivo de Bash, conviene usar [[ de forma consistente
  • El propio shell también tiene operadores de expresión como !, && y ||
    • Estos operadores funcionan en función del estado de salida de los comandos
    • grep ^hello$ ... && grep ^bye$ ... hace que el código de salida total sea 0 si ambos comandos tienen éxito
    • Si el primer grep falla, el comando después de && tampoco llega a hacer que el resultado completo sea exitoso, así que el código final será 1
  • Por eso es posible combinar expresiones test/[ con los operadores lógicos del shell dentro de una misma condición
    • Ejemplo: [ a = b ] || grep -q ^hello$ /usr/share/dict/words
  • POSIX no exige que /bin/[ y /bin/test sean enlaces duros
    • En NetBSD sí eran enlaces duros
    • macOS Catalina ofrece copias separadas del mismo binario
    • Debian testing ofrece binarios distintos
    • La especificación POSIX no exige que ambos archivos sean enlaces

1 comentarios

 
GN⁺ 2023-11-24
Opiniones de Hacker News
  • Soy el autor original. Gracias por compartirlo, y me alegra que haya llegado a la portada. Probablemente corresponde que el título lleve (2020), y “test” se refiere realmente al comando, así que sería mejor no escribirlo con mayúscula inicial.
    También tengo un artículo relacionado que escribí en 2021, donde cubro hasta el operador [[ de bash, así que creo que puede ser interesante en este contexto: https://jmmv.dev/2021/08/useless-use-of-gnu.html

    • En sentido estricto, [[ no es un comando incorporado, sino que básicamente se parece más a un elemento de sintaxis. Probablemente por dentro use algún comando incorporado casi inaccesible, pero lo curioso es que ]] también es una palabra reservada, aunque no pueda aparecer en una posición donde una palabra reservada tenga sentido.
      En algunos shells que no son bash, se necesita la palabra clave function para declarar ciertos tipos de funciones. $(shell) de make puede producir una diferencia de rendimiento medible al compilar muchos targets. Aun así, si no hace nada, es una pérdida, así que normalmente lo correcto para forzar la regeneración es usar include. Que GNU ignore POSIX es totalmente razonable: POSIX no es muy útil para resolver la mayoría de los problemas reales.
    • Muchas de las extensiones de GNU tratadas en https://jmmv.dev/2021/08/useless-use-of-gnu.html son muy útiles en el uso interactivo. También es útil buscar en el directorio actual sin especificar un . explícito, y es realmente cómodo poder agregar opciones al final del comando que acabas de escribir. Siempre me molesta ver comandos que no soportan eso.
      En scripts, por lo general tiene sentido ajustarse a POSIX sh. Como mínimo, hay que saber si se está usando sintaxis exclusiva de Bash.
    • Ese artículo se queja de que usar extensiones de GNU como --ignore-case o set -o pipefail reduce la portabilidad de los scripts. Eso, en sí, es cierto.
      Pero no explica por qué a los usuarios de Linux debería importarles mucho la portabilidad. OpenBSD y FreeBSD siguen vivos, pero tienen tan pocos usuarios que no parecen ser algo de lo que haya que preocuparse especialmente. Por justicia, se podría decir que también hay que considerar esos sistemas operativos, pero ¿dónde se detiene ese criterio? ¿También hay que considerar algo oscuro como vxWorks? Lo de BusyBox y Alpine es más interesante, pero los cambios son tan grandes que, de todos modos, casi siempre se necesita un port separado. ¿Hay alguna otra razón convincente para preocuparse por ecosistemas que no sean GNU?
    • ¿Algo como if [ a = b ] || grep -q ^hello$ /usr/share/dict/words; then echo "test failed and grep succeeded"; fi no es simplemente un uso de shell común y corriente de todos los días?
    • Como nota secundaria, parece que el software del blog arruinó el título. Por ejemplo, aparece como make $(shell …) expansion, cuando debería ser make $(shell ...) expansion.
      Como en el cuerpo está escrito correctamente con tres puntos y no con un solo signo de puntos suspensivos, mldr en sí tampoco está bien. Probablemente dos bugs no relacionados hayan influido al mismo tiempo.
  • Si llevamos el último punto un paso más allá, también se puede eliminar el bloque if en sí. if [ a = b ]; then echo "Oops!"; else echo "Expected; phew!"; fi se convierte en [ a = b ] && echo "Oops!" || echo "Expected; phew!".
    No sé con qué frecuencia conviene hacerlo, pero a veces es útil para imprimir salida de depuración condicionalmente en el error estándar, como en [ "$debug" ] && echo "what's going on" >&2. El hecho de que un bloque if evalúe comandos normales también permite cosas como if grep -q 'debug' /var/log/nginx/access.log; then echo "Debug request found!"; fi. Lo que todavía no investigué es si debería escribirse [ $(expr 1 + 1) -eq 2 ] && [ $(expr 2 + 2) -eq 3 ], o usar la conjunción lógica incorporada de test, es decir [ $(expr 1 + 1) -eq 2 -a $(expr 2 + 2) -eq 4 ]. Si el rendimiento no es un problema, ambas opciones parecen razonables por motivos similares.

    • No hay que tomar [ a = b ] && echo "Oops!" || echo "Expected; phew!" como una regla general. Probablemente bash interprete esa línea como ([ a = b ] && echo "Oops!") || echo "Expected; phew!".
      Por eso, si la secuencia de comandos después de && falla, el código después de || se ejecuta de todos modos. Por ejemplo, si >/dev/full echo "strings match" falla por un error de escritura, se imprime "strings don't match" aunque las cadenas sí coincidían. Eso no tiene el mismo significado que un bloque if.
    • Es mejor evitar ese tipo de abreviación. Si estás usando el set -e que deberías usar, if [ a = b ]; then echo "Oops!"; fi funciona como se espera, pero [ a = b ] && echo "Oops!" termina con error cuando la expresión a no es igual a b.
    • Según POSIX, las primarias binarias -a, -o y los operadores (, ) están marcados como obsoletos. Para más detalles, consulta “Application Usage” en https://pubs.opengroup.org/onlinepubs/9699919799/utilities/t....
    • Ya sea que uses -a o dos tests con &&, si puedes usar la evaluación aritmética de bash no hace falta salir a ejecutar expr: [ $((1+1)) -eq 2 ]
  • Hace algunos años dejé de usar [. test refuerza la idea de que esto no es sintaxis, sino simplemente un comando como cualquier otro. Y man test es mucho más cómodo que hurgar en man bash.

    • Eso no encaja del todo. GNU Coreutils no solo tiene la página de manual man test, sino también man [.
      Bash tiene help test como chuleta rápida. El comando [ es muy antiguo y ya estaba incluido en Version 7 Unix en 1979.
  • De acuerdo. [ y [[, que es exclusivo de bash, generan mucha confusión sobre lo que realmente ocurre, así que era difícil estar seguro.
    Aun así, el hecho de que [[ tenga garantizado ser un comando integrado claramente tenía un propósito en la época en que el rendimiento de los scripts de shell importaba, y eso no fue hace tanto.

    • Después de leer esto, creo que simplemente usaría test. Como no escribo scripts en bash con frecuencia, siempre me tropiezo con las sentencias if, sobre todo con las reglas de espacios. Al ver la razón, se volvió demasiado obvio, y usar test deja más claro que solo se están pasando argumentos.
  • La trampa más grande con [ y test es el comportamiento con un solo argumento. Por ejemplo, para comprobar que una variable no esté vacía, podrías escribir [ -n $FOO ].
    Pero si FOO no está definida, no se expande a una cadena vacía, sino a nada, y queda igual que [ -n ]. POSIX exige que, en la forma de [ con un solo argumento, si ese argumento —en este caso "-n"— no está vacío, debe tener éxito. Así que informa erróneamente que $FOO no está vacía. Las variables siempre deben ir entre comillas.

    • La última oración debería ir al principio. Pon las variables entre comillas. La trampa no está en la especificación del comando integrado test en sí, sino en el propio shell.
      El comportamiento mencionado tiene sentido. [ "$FOO" ] es una forma que comprueba si el contenido no está vacío, sin importar cuál sea, incluso si es "-n".
    • Para scripts, basta con pasar ShellCheck.
    • Si $FOO contiene espacios, se expande a varios argumentos. Simplemente siempre pon las variables entre comillas.
    • [ x"$FOO" != x"" ]
    • En este caso usaría [ -n "${FOO?}" ] para que el script se detenga de inmediato si $FOO es nula o no está definida.
  • chubot escribió documentos interesantes que profundizan en las partes más sutiles de test/[/[[. Otros artículos de ese blog también explican de forma bastante interesante las rarezas del shell.
    ¹ https://www.oilshell.org/blog/2017/08/31.html
    ² https://www.oilshell.org/blog/2016/11/18.html

  • No tenía idea de que [ fuera un programa, y me da algo de risa que verifique si el último argumento es el corchete de cierre.
    Pero eso sí explica por qué se necesitan espacios a ambos lados de los corchetes.

  • [[ es exclusivo de bash. Si sabes que solo vas a usar bash, úsalo. El artículo cubre bien los detalles.

    • zsh también lo tiene :)
      https://zsh.sourceforge.io/Doc/Release/Conditional-Expressio...
    • Siempre puedes usar [[.
      También está en zsh y ksh, y de hecho estoy casi seguro de que empezó en ksh en 1988 o antes.
    • Es decir, test y [ están especificados por POSIX y normalmente existen como binarios reales. Aunque un comando integrado del shell puede ocultarlos.
      En cambio, [[ no está especificado por POSIX y normalmente solo existe como integrado del shell.
    • Si no estás usando explícitamente un shell totalmente distinto como Fish, no sé por qué no usarías Bash.
      Apuntar solo al mínimo común denominador de shells se siente como una preocupación antiquísima.
  • No entendí bien por qué la última sentencia if resulta confusa. Si es porque, al aprender shell scripting por primera vez, uno suele asumir que [ es parte del lenguaje de scripts de bash y no simplemente otro programa, entonces ahora lo entiendo. Si no, me gustaría que explicaran por qué sorprende.

    • Incluso sin saber que [ es un binario, no veo bien por qué sería confuso. Parece bash de lo más normal.
  • Tengo opiniones fuertes sobre el shell, pero no encajan muy bien con la mayoría del mundo.
    Creo que nunca debería usarse [ y que solo debería usarse test. [ te hace creer que su mecanismo forma parte de la sintaxis del lenguaje, pero en realidad es solo otro "programa". Aquí "programa" incluye comandos integrados y funciones. if/||/&& miran el estado de salida, y un programa no puede ver el estado de salida de otra cosa salvo mirando la variable mágica $?, que después de la expansión es simplemente una cadena. case mira cadenas, pero no funciona en función del estado de salida ni establece un estado de salida como parte del funcionamiento de case ... esac. Un "programa" establece el estado de salida. Además, [/test solo deberían usarse para evaluar estructuras del sistema de archivos, como en test -f /dev/null. Creo que para evaluar cadenas debería usarse case. Naturalmente, la mayoría de los scripts me dan comezón, y los scripts que escribo les parecen raros a los demás.

    • La broma me cayó a mí. Ese era justamente el punto del artículo.
      Cuando uso shell, prefiero poner el programa después de if en una línea separada y luego pasar a then. Es para enfatizar que lo que sigue a if "mira el estado de salida del último comando antes de then". Por ejemplo, en una estructura como if; ls /tmp/goober; test -d /tmp/goober; echo "I'll always execute the then clause because echo will always return a 0 return code"; then ... fi, if al final solo ve el estado de salida de echo, así que la cláusula then siempre se ejecuta.
    • Encontré a un compañero que recorre el mismo camino. Mis 8 años de scripts con test son prueba de que estoy de acuerdo. Es un camino solitario... Culpo a la guía de estilo de shell de Google.
      Desarrollé el hábito de preferir test al escribir scripts que deben funcionar tanto en sh como en bash, pero lo sigo usando porque semánticamente me resulta más razonable que tratar el carácter [ como un comando. También es raro que ] no sea un binario aparte sino un argumento de [. Entiendo la razón técnica, pero se siente como un hack.
    • Leyendo los comentarios aquí, parece que hay decenas de personas que de alguna forma usan solo test. ¡Decenas!
  • Aprendí a usar [[ solo cuando quiero hacer matching con expresiones regulares. Ej.: if [[ "${foo}" =~ ^bar$ ]]; then echo Yes; fi
    Fuera de eso, simplemente uso "test" o "[". Ya voy por 85,000 líneas de bash. No digo que bash sea excelente, pero todavía cubre mis necesidades para muchas cosas.

    • Si quieres hacer matching de patrones relativamente simples de forma compatible con POSIX, expr puede hacer matching con expresiones regulares básicas y también puede devolver grupos de captura.

1: https://pubs.opengroup.org/onlinepubs/9699919799/utilities/e...

  • Me tuvo atrapado un buen rato el hecho de que la expresión regular se escribe tal cual y no va entre comillas. Como soy de los que ponen comillas casi religiosamente a todo, me tomó tiempo descubrir por qué una expresión regular tontamente simple no hacía match.
  • Ese ejemplo no tiene mucho sentido. Basta con [ "$foo" = bar ] && echo Yes.
    Para hacer matching de subcadenas, por lo general alcanza con [ y el glob *. Algo como [ "$bar" = extra* ] && echo '$bar began with extra'. El dialecto de expresiones regulares de Bash es primitivo, así que casi no vale la pena esforzarse en usarlo. Para cosas complejas, lo correcto es usar otras herramientas como grep, awk o perl. Si te obsesionas con intentar hacer todo en bash, incluso tareas complejas que necesitan mayor reutilización, modularidad y tipos integrados, el rendimiento cae en picada.