Sustitución de comandos en Bash: $() o comillas invertidas
Entienda por qué $(...) pierde cd y variables en un subshell, por qué las comillas invertidas se anidan mal y cuándo usar process substitution.
Qué hace la sustitución de comandos de Bash
La sustitución de comandos de Bash reemplaza $(command) por el texto que ese comando escribió en la salida estándar. La sintaxis antigua con acentos graves hace lo mismo. Todo lo que suele sorprender se explica por dos hechos: el comando se ejecuta en un proceso independiente llamado subshell y se elimina cada salto de línea al final de su salida.
mkdir -p /tmp/subst-demo
cd /tmp/subst-demo
printf 'alpha\nbeta\ngamma\n' > three.txt
count=$(wc -l < three.txt)
echo "$count"3Eso es todo lo que hace esta función. wc -l escribió 3 seguido de un salto de línea, se eliminó el salto de línea y count contiene los dos caracteres que quería. Observe que wc -l < three.txt muestra sólo un número porque GNU wc lee la entrada estándar y no tiene ningún nombre de archivo que mostrar. Escriba wc -l three.txt en su lugar y capturará 3 three.txt, que es una cadena diferente y una causa frecuente de errores posteriores en operaciones aritméticas.
El resto de esta guía trata del comportamiento que nadie espera, porque un subshell es un proceso independiente y un proceso independiente no puede modificar el shell en el que está escribiendo.
Use $() en lugar de las comillas invertidas
Ambas formas son válidas. $() forma parte de POSIX, por lo que dash, ash y busybox sh la admiten. Ya no existe ninguna razón de portabilidad para seguir escribiendo comillas invertidas, y hay dos razones concretas para no hacerlo.
Las comillas invertidas no se pueden anidar
echo "$(echo "$(echo hi)")"
echo "`echo `echo hi``"hi
echo hiLa segunda línea imprimió las palabras literales echo hi. El shell busca la siguiente comilla invertida no escapada, por lo que la segunda comilla invertida que escribió cerró la primera. El comando que realmente ejecutó fue echo sin argumentos. Este imprimió una línea vacía, a la que después se le eliminó el salto de línea hasta dejarla en nada. Las palabras echo hi quedaron como texto normal, y el último par de comillas invertidas ejecutó un comando vacío.
Para anidar comillas invertidas debe escapar todas las comillas internas:
echo "`echo \`echo hi\``"hiCada nivel adicional vuelve a duplicar el escape. $() no necesita nada de esto, porque el analizador empareja paréntesis en lugar de buscar un carácter delimitador.
Las comillas invertidas modifican las barras inversas antes de ejecutar el comando
echo "$(echo 'a\\b')"
echo "`echo 'a\\b'`"a\\b
a\bEl mismo comando interno produjo una salida diferente. Dentro de las comillas invertidas, el shell elimina una capa de escapes de barra inversa antes de analizar el texto interno, por lo que las comillas simples no protegieron nada. Dentro de $(), el texto entre paréntesis se analiza como un script normal, por lo que las comillas simples se comportan como espera. Esto causa más problemas en los comandos de una línea de sed y awk, donde una barra inversa perdida convierte un patrón funcional en otro distinto sin mostrar ningún error.
Las comillas también empiezan de nuevo dentro de $(), lo que permite anidar comillas dobles sin escaparlas:
path=/etc/nginx/nginx.conf
echo "$(dirname "$path")"/etc/nginxLa forma equivalente con comillas invertidas necesita \" alrededor de $path. Cada escape es un posible punto de error.
Por qué cd dentro de $() no cambia el directorio de la shell
Porque $(...) crea un proceso nuevo. La subshell recibe una copia de las variables y una copia del directorio de trabajo. Cambia su copia, muestra algo y termina. La copia desaparece con ella.
cd /tmp/subst-demo
pwd
target=$(cd /etc && pwd)
echo "$target"
pwd/tmp/subst-demo
/etc
/tmp/subst-demoNo se produjo ningún error. cd funcionó y pwd dentro de la subshell realmente mostró /etc. Simplemente no existe ninguna ruta para que ese cambio regrese, porque la única información que una subshell entrega a su proceso padre es su salida estándar y un estado de salida.
Las asignaciones de variables se comportan igual:
count=0
msg=$(count=99; echo "inside: $count")
echo "$msg"
echo "outside: $count"inside: 99
outside: 0La misma regla explica la variante de este error que ocurre con mucha más frecuencia, en la que se usa una canalización en lugar de una sustitución:
n=0
cat three.txt | while read -r line; do n=$((n+1)); done
echo "$n"0Cada etapa de una canalización se ejecuta en su propia subshell, por lo que el bucle while incrementó una copia de n y después terminó. Sustituya la canalización por una redirección para que el bucle se ejecute en la shell actual:
n=0
while read -r line; do n=$((n+1)); done < three.txt
echo "$n"3Bash puede ejecutar la última etapa de una canalización en la shell actual con shopt -s lastpipe, pero sólo cuando el control de trabajos está desactivado, lo que nunca ocurre en una shell interactiva. Use la redirección.
¿Dónde quedó el indicador? Comandos interactivos dentro de $()
La sustitución de comandos redirige la salida estándar a una tubería y deja intacta la entrada estándar. Un programa que muestra su pregunta en la salida estándar y después espera una respuesta deja de mostrar la pregunta, pero sigue esperando. El terminal parece bloqueado.
ask() { printf 'Username: '; read -r u; printf '%s\n' "$u"; }
askUsername: deploy
deployLa palabra deploy de la primera línea es la que escribió. Ahora ejecute la misma función dentro de una sustitución:
v=$(ask)deploySólo aparecen las teclas que pulsa, repetidas por el controlador del terminal y no por el programa. El indicador terminó en otro lugar:
echo "$v"Username: deployAhora el indicador está dentro de la variable, unido a la respuesta, porque $() capturó todo lo que la función escribió en la salida estándar. Lo que escribe sigue llegando a read, porque la entrada estándar nunca se modificó. Esta es la firma exacta del error que indica que «mi script se bloquea y no muestra nada».
Algunas herramientas escriben los indicadores en el error estándar o directamente en /dev/tty para que sigan apareciendo. Muchas no lo hacen. Si una función debe permanecer dentro de una sustitución, envíe su indicador al error estándar:
ask() { printf 'Username: ' >&2; read -r u; printf '%s\n' "$u"; }
v=$(ask)
echo "$v"Username: deploy
deployEl error estándar no se captura, por lo que el indicador llega al terminal y sólo la respuesta termina en v.
¿Por qué ls tiene un aspecto diferente dentro de $()?
Porque ls llama a isatty en el descriptor de archivo 1 y cambia el formato de salida según la respuesta. En el indicador, ese descriptor es el terminal, por lo que ls distribuye los nombres en columnas a lo largo de la línea. Dentro de una sustitución, es una tubería, por lo que ls cambia a un nombre por línea.
mkdir -p /tmp/tty-demo
cd /tmp/tty-demo
touch alpha beta delta gamma
echo "$(ls)"alpha
beta
delta
gammaLa misma comprobación desactiva el color en grep --color=auto y el paginador en git. Es una característica. Permite que un script obtenga una salida estable y legible por máquinas sin tener que solicitarla explícitamente.
Esto también responde a una pregunta habitual sobre las tuberías. ls | sort en el indicador y $(ls | sort) muestran lo mismo porque ls tenía una tubería en su salida en ambos casos. Lo que cambia dentro de una sustitución es la última etapa de la tubería. sort nunca comprueba si hay un terminal, por lo que su salida nunca cambia. Si coloca un comando que detecta el terminal al final de la tubería, la salida sí cambia. Por eso una tubería que probó visualmente puede comportarse de otra forma en cuanto la incluye en $().
De aquí se deriva una advertencia: no analice la salida de ls en un script aunque parezca práctico. Los nombres de archivo pueden contener espacios y saltos de línea. Use un patrón glob o find -print0 con read -d ''.
Los saltos de línea finales que desaparecen silenciosamente
La sustitución de comandos elimina todos los saltos de línea que aparecen al final de la salida. No sólo el último. Los elimina todos.
cd /tmp/subst-demo
printf 'hello\n\n\n' > blanks.txt
wc -c < blanks.txt
v=$(cat blanks.txt)
printf '%s' "$v" | wc -c8
5El archivo contiene hello y tres saltos de línea, por lo que ocupa 8 bytes. La variable contiene hello, por lo que ocupa 5 bytes. Tres bytes desaparecieron sin ninguna advertencia.
La eliminación es intencionada y suele ser útil. Es lo que permite que stamp=$(date -u +%Y%m%dT%H%M%SZ) genere un fragmento de nombre de archivo utilizable en lugar de un nombre que contenga un salto de línea. Por eso el patrón es seguro en algo como un script de copia de seguridad programada de restic:
stamp=$(date -u +%Y%m%dT%H%M%SZ)
printf 'backup-%s.tar.gz\n' "$stamp"backup-20260804T031500Z.tar.gzLa marca de tiempo será diferente. Lo importante es que el nombre ocupe una sola línea.
El inconveniente es que no puede usar $() para mover los bytes exactos de un archivo. Si necesita conservar los saltos de línea finales, añada un carácter centinela dentro de la sustitución y elimínelo después:
v=$(cat blanks.txt; printf x)
v=${v%x}
printf '%s' "$v" | wc -c8x queda después de los saltos de línea, por lo que ya no quedan saltos de línea finales que eliminar. Después, ${v%x} elimina el centinela y conserva los bytes originales.
Hay otros dos detalles relacionados. Para leer un archivo completo, v=$(<blanks.txt) realiza el mismo trabajo sin ejecutar cat, porque bash abre el archivo directamente. También elimina los saltos de línea finales de la misma forma. En cambio, los here-strings añaden un salto de línea que no escribió:
wc -c <<< 'abc'4Cite el resultado o bash lo dividirá y aplicará la expansión de comodines
Una sustitución sin comillas pasa por la división en palabras y después por la expansión de nombres de ruta. Una sustitución entre comillas no pasa por ninguna de las dos.
printf 'a b\tc\nd\n' > words.txt
echo $(cat words.txt)
echo "$(cat words.txt)"a b c d
a b c
dSin comillas, bash dividió la salida usando los caracteres de IFS, que de forma predeterminada son el espacio, el tabulador y el salto de línea, y echo volvió a unir las cuatro partes con espacios simples. Entre comillas, el texto llegó como una sola palabra, con el tabulador y el salto de línea interno intactos.
La expansión de comodines es la parte más peligrosa:
mkdir -p /tmp/glob-demo
cd /tmp/glob-demo
touch one.txt two.txt
printf '*\n' > pattern.txt
p=$(cat pattern.txt)
echo $p
echo "$p"one.txt pattern.txt two.txt
*La asignación era segura porque las asignaciones no realizan división en palabras ni expansión de comodines. El problema se produjo en echo $p, donde * se expandió tomando como referencia el directorio actual. Un script que lee un patrón de un archivo de configuración y olvida las comillas actuará sin problemas sobre todos los archivos que pueda ver. Ponga comillas en todas las expansiones para eliminar toda esta clase de errores. Omita las comillas sólo cuando realmente necesite la división, lo que ocurre rara vez.
¿Por qué local x=$(cmd) siempre devuelve 0?
Porque local es un comando por sí mismo, y $? informa del estado de local, no del estado de la sustitución que contenía.
check_bad() { local out=$(false); echo "status: $?"; }
check_badstatus: 0false terminó con el código 1, local se ejecutó correctamente al declarar la variable y el código 1 se descartó. declare, export, typeset y readonly se comportan de la misma forma. set -e tampoco lo detectará, porque, desde el punto de vista del shell, no falló nada.
Separe la declaración de la asignación:
check_good() { local out; out=$(false); echo "status: $?"; }
check_goodstatus: 1Una asignación simple en el nivel superior ya informa del estado de su última sustitución de comandos:
out=$(exit 3)
echo $?3Esto es especialmente importante en una comprobación de estado que se ejecuta mediante un servicio y un temporizador de systemd, donde un código de salida oculto hace que la unidad informe de éxito en cada ejecución, aunque el trabajo que debía verificar nunca se haya realizado.
Alternativas dentro del shell que realmente necesita
La mayoría recurre a $(...) porque quiere guardar datos en una variable. A menudo, lo que realmente necesita es entrada, no captura. Estas cuatro formas mantienen el estado en el shell actual.
Redirección en el bucle
cd /tmp/subst-demo
while read -r line; do printf 'got: %s\n' "$line"; done < three.txtgot: alpha
got: beta
got: gammaNo se crea ningún proceso para la entrada, por lo que cualquier variable que establezca el cuerpo del bucle conserva su valor después del bucle.
Sustitución de procesos
while read -r line; do printf 'got: %s\n' "$line"; done < <(sort -r three.txt)got: gamma
got: beta
got: alpha<(command) le proporciona una ruta, como /dev/fd/63, desde la que se puede leer la salida del comando. El comando sigue ejecutándose en su propio proceso. El bucle while no, y ese es el objetivo. El espacio en < <( es obligatorio: <<( se interpreta como el inicio de un documento aquí y no se puede analizar. La sustitución de procesos es una función de bash, por lo que un script con #!/bin/sh en Ubuntu o Debian se ejecuta con dash y fallará. Use #!/bin/bash.
Cadenas aquí
read -r first rest <<< 'alpha beta gamma'
echo "$first"
echo "$rest"alpha
beta gamma<<< envía una cadena a la entrada estándar de un comando. read se ejecuta en el shell, por lo que ambas variables quedan establecidas donde puede usarlas. read -r a b <<< "$(some-command)" es la forma habitual de extraer dos campos de una línea de salida.
mapfile para archivos completos
mapfile -t lines < three.txt
echo "${#lines[@]}"
echo "${lines[1]}"3
betamapfile, también escrito como readarray, lee un archivo en un array del shell actual. -t elimina el salto de línea final de cada elemento. Necesita bash 4 o una versión posterior, y Ubuntu 24.04 incluye bash 5.2, por lo que está disponible en cualquier imagen de servidor actual.
Una lista de comprobación antes de confirmar el script
- Escriba
$(command)y protéjalo entre comillas como"$(command)", salvo que necesite dividirlo específicamente. - Suponga que las líneas nuevas finales han desaparecido. Añada un carácter centinela si necesita recuperarlas.
- No incluya comandos interactivos en sustituciones, o envíe sus mensajes al error estándar.
- Escriba
local outen una línea independiente cuando importe el estado de salida deout=$(command). - Para establecer variables a partir de la entrada, use una redirección o una sustitución de procesos en lugar de una tubería.
Estas estructuras aparecen en los primeros scripts pequeños que se escriben al configurar un VPS nuevo, y los fallos pasan inadvertidos. Un script de copia de seguridad que capturó un mensaje en un nombre de archivo, o una comprobación de estado que ocultó un código de salida, sigue informando de que todo funciona correctamente. El coste aumenta cuando ejecuta el mismo script en varios servidores, porque la salida que nunca leyó ahora es salida que no leyó en veinte máquinas.
FAQ
¿Por qué cd dentro de $() no cambia mi directorio actual?
$(...) ejecuta el comando en un subshell, que es un proceso independiente con una copia del directorio de trabajo y de las variables. cd cambia esa copia. Después, el proceso termina y la copia se descarta. Un subshell sólo puede devolver su salida estándar y un estado de salida. Por eso, el cambio de directorio no puede llegar al shell padre. Si necesita obtener el directorio, captúrelo con target=$(cd /etc && pwd) y úselo con "$target". Si quiere que el shell cambie de directorio, ejecute cd directamente, sin incluirlo en una sustitución.
¿Cuál es la diferencia entre $() y las comillas invertidas en bash?
Producen el mismo resultado con comandos sencillos, pero se diferencian en dos aspectos importantes. $() permite anidar sustituciones directamente porque el analizador empareja los paréntesis. Las comillas invertidas necesitan una comilla invertida escapada para cada nivel de anidamiento. Además, las comillas invertidas eliminan una capa de escape con barra inversa antes de analizar el comando interno. Por eso, ` echo 'a\\b' prints a\b while $(echo 'a\\b') prints a\\b. $() is in POSIX and works in dash and busybox sh`, de modo que no hay ningún motivo de portabilidad para usar comillas invertidas.
¿Por qué mi script se queda bloqueado sin mostrar el indicador cuando un comando hace una pregunta?
La sustitución de comandos redirige la salida estándar a una tubería, pero mantiene la entrada estándar conectada al terminal. Un programa que muestra el indicador en la salida estándar captura ese indicador en la variable, mientras que el read que está detrás sigue esperando su respuesta. El terminal sólo muestra los caracteres que escribe, repetidos por el controlador del terminal. Mueva la pregunta fuera de la sustitución o haga que el indicador se escriba en la salida de error estándar con printf 'Username: ' >&2 para que no se capture.
¿Por qué desaparecieron las líneas en blanco del final de mi variable?
La sustitución de comandos elimina todos los saltos de línea finales, no sólo el último. printf 'hello\n\n\n' > f; v=$(cat f) deja v con cinco bytes, mientras que el archivo contiene ocho. Para conservarlos, añada un marcador al final dentro de la sustitución y elimínelo después con v=$(cat f; printf x) seguido de v=${v%x}. El marcador queda después de los saltos de línea. Por tanto, no queda nada al final que bash pueda eliminar.
¿Por qué local out=$(cmd) siempre informa de que la operación se realizó correctamente?
local es un comando independiente. $? después de esa línea informa de si local pudo declarar la variable. El estado de salida de la sustitución se consume y se descarta. Esto también significa que set -e no detendrá el script. declare, export, typeset y readonly se comportan igual. Escriba local out en una línea y out=$(cmd) en la siguiente. Entonces $? informará del estado real.