Command Substitution

$( <COMMANDS> )
`<COMMANDS>`

명령 치환은 subshell 에서 실행되는데 명령 실행 결과 stdout 값이 pipe 를 통해 전달됩니다. 그러므로 명령 치환도 일종의 IPC (Inter Process Communication) 입니다. 표현식은 두 가지 형태가 존재하는데 backtick 은 괄호형 보다 타입 하기가 편해서 비교적 간단한 명령을 작성할 때 많이 사용합니다. 그런데 표현식을 열고 닫는 문자가 같은 관계로 nesting 하여 사용할 수가 없고 escape sequence 가 다르게 처리됩니다. 그러므로 복잡한 명령을 작성하거나 nesting 이 필요할 때는 괄호형을 사용하는 것이 좋습니다.

stdout 이 파이프로 연결된 것을 볼 수 있습니다.

명령 치환은 subshell 에서 실행된다.

$ echo "$(echo "$(echo "$(ps jf -s $$)")")"
 PPID   PID  PGID   SID TTY      TPGID STAT   UID   TIME COMMAND
 2396 20482 20482 20482 pts/13   20482 Ss+   1000   0:02 /bin/bash
20482   708 20482 20482 pts/13   20482 S+    1000   0:00  \_ /bin/bash
  708   709 20482 20482 pts/13   20482 S+    1000   0:00      \_ /bin/bash
  709   710 20482 20482 pts/13   20482 R+    1000   0:00          \_ ps jf -s 20482

-----------------------------------------------------------------

$ \ls /bin/e*
/bin/echo  /bin/ed  /bin/efibootdump  /bin/efibootmgr  /bin/egrep

$ ARR=( /bin/e* )

# subshell 에서 실행되므로 IFS 변수를 변경해 사용하고 복구하지 않아도 된다.
$ echo $( IFS=:; echo "${ARR[*]}" )
/bin/echo:/bin/ed:/bin/efibootdump:/bin/efibootmgr:/bin/egrep

$ echo -n "$IFS" | od -a    # 기존값 출력
0000000  sp  ht  nl
0000003

다음은 명령 치환 사용예 입니다.

$ AA=$( pgrep -d, -f firefox )               $ AA=`pgrep -d, -f firefox`
$ echo $AA                                   $ echo $AA
1890281,1890369,1890425,1890467 ...          1890281,1890369,1890425,1890467 ...

$ top -c -p $( pgrep -d, -f firefox )        $ top -c -p `pgrep -d, -f firefox`

$ cat /proc/`pidof awk`/maps

$ cd /lib/modules/`uname -r`

backtick 을 사용할 때 한가지 주의할 점은 escape sequence 가 다르게 처리됩니다.

# 원본 명령
$ printf %.s\\101 {1..10}
AAAAAAAAAA

# 괄호형은 원본 명령 그대로 사용할 수 있다.
$ echo $( printf %.s\\101 {1..10} )
AAAAAAAAAA

# backtick 은 escape sequence 가 다르게 처리된다.
$ echo `printf %.s\\101 {1..10}`
101101101101101101101101101101

$ echo `printf %.s\\\\101 {1..10}`
AAAAAAAAAA

Parent 프로세스의 변수값을 변경할 수 없습니다.

subshell 은 main 프로세스의 child 프로세스에 해당합니다. 각 프로세스는 독립적인 주소 공간을 가지므로 child 프로세스에서 설정, 변경한 값은 main 프로세스의 값에 영향을 주지 못합니다. 다음 예를 보면 명령치환을 이용해 index 값을 40 으로 변경하였으나 기대했던 것과는 달리 main 프로세스의 값이 변경되지 않고 30 으로 나오는것을 볼 수 있습니다.

#!/bin/bash

index=30

change_index() {
  index=40
  echo $index
}

result=$(change_index)

echo $result
echo $index

######### output ########

40
30

Quotes 이 중첩돼도 된다

명령치환의 결과로 나온 값은 일반 변수를 사용할 때와 마찬가지로 단어분리 및 globbing 대상입니다. 그러므로 공백문자를 유지해야 하거나 globbing 이 발생하면 안될 경우 항상 double quotes 을 해야 합니다. 명령문 자체에서도 quotes 을 많이 사용하는데 명령치환 표현식에도 사용하게 되면 quotes 이 중첩되는 경우가 발생합니다. 이럴 경우를 위해서 shell 은 $( ... ) 표현식을 만나면 그 안의 내용은 별도로 분리해서 처리한다고 합니다. 그러므로 quotes 중첩문제를 피하려고 escape 하거나 할 필요 없이 그대로 명령문을 작성할 수 있습니다.

# 명령치환을 quote 하지 않은 경우
$ echo $( echo "
> I
> like
> winter     and     snow" )

I like winter and snow

# 명령치환을 quote 하여 공백과 라인개행이 유지되었다.
$ echo "$( echo "
> I
> like
> winter     and     snow" )"

I
like
winter     and     snow

---------------------------------

# quotes 이 여러번 중첩되어도 상관없다.

$ echo "$(echo "$(echo "$(date)")")"       
Thu Jul 23 18:34:33 KST 2015

변수에 값을 대입할때 마지막 newline 들은 제거된다.

$ AA=hello$'\n\n\n'
$ echo -n "$AA" | od -a
0000000   h   e   l   l   o  nl  nl  nl    # 정상적으로 newline 이 표시된다.

$ AA=$(echo -en "hello\n\n\n")
$ echo -n "$AA" | od -a
0000000   h   e   l   l   o                # 명령치환은 newline 들이 모두 제거된다.

---------------------------------------

$ cat file         # 파일 마지막에 4 개의 newline 이 존재.
111
222



$ cat file | od -a
0000000   1   1   1  nl   2   2   2  nl  nl  nl  nl
0000013

$ echo -n "$(cat file)" | od -a              # 마지막 newline 들이 모두 제거 되었다.
0000000   1   1   1  nl   2   2   2
0000007

만약에 trailing newlines 이 보존되어야 할경우는 다음과 같은 방법을 사용할 수 있습니다.

GET=$( echo "                      nl='                        GET=$( echo "
GET / HTTP/1.1                     '                           GET / HTTP/1.1
Host: 127.0.0.1:80                 GET=$( echo "               Host: 127.0.0.1:80
                                   GET / HTTP/1.1              ")$'\n\n\n'
                                   Host: 127.0.0.1:80
.")                                ")$nl$nl$nl       

GET=${GET%.}   # remove period

NUL 문자를 보낼 수 없다

변수값으로 NUL 문자를 저장할 수 없는것과 동일하게 명령치환 값으로 NUL 문자를 전달할 수 없습니다.

$ ls          # aaa, bbb, ccc  3개의 파일이 존재
aaa  bbb  ccc

# 파이프, redirection 은 NUL 문자가 정상적으로 전달된다.
$ find * -print0 | od -a
0000000   a   a   a nul   b   b   b nul   c   c   c nul
0000014

$ find * -print0 > file
$ od -a file
0000000   a   a   a nul   b   b   b nul   c   c   c nul
0000014

# 명령치환은 NUL 문자가 모두 제거됩니다.
$ echo -n "$(find * -print0)" | od -a
0000000   a   a   a   b   b   b   c   c   c
0000011

명령문에서 redirection 문자보다도 먼저 처리된다.

redirection 문자는 shell 메타문자 중에서 처리되는 우선순위가 제일 높은데 명령치환은 그보다 먼저 처리됩니다.

redirection 설정은 child process 에 상속되기 때문에 subshell 의 경우 date 명령의 오류메시지가 /dev/null 로 전달되어 표시되지 않지만 명령치환의 경우는 redirection 보다 먼저 처리되기 때문에 오류메시지가 터미널로 출력되는 것을 볼 수 있습니다.

# '-@' 는 오류를 위한 옵션
$ ( date -@ ) 2> /dev/null                   # subshell

$ echo "$( date -@ )" 2> /dev/null           # 명령치환
date: invalid option -- '@'
Try 'date --help' for more information.

FD 정보를 살펴보면 명령치환은 subshell 과 달리 FD 2 번이 터미널로 연결되어 있습니다.

$ ( ls -l /dev/fd/ ) 2> /dev/null              
total 0
lrwx------ 1 mug896 mug896 64 2019-01-27 19:17 0 -> /dev/pts/11
lrwx------ 1 mug896 mug896 64 2019-01-27 19:17 1 -> /dev/pts/11
l-wx------ 1 mug896 mug896 64 2019-01-27 19:17 2 -> /dev/null      # /dev/null

$ echo "$( ls -l /dev/fd/ )" 2> /dev/null
total 0
lrwx------ 1 mug896 mug896 64 2019-01-27 19:17 0 -> /dev/pts/11
l-wx------ 1 mug896 mug896 64 2019-01-27 19:17 1 -> pipe:[2364343]
lrwx------ 1 mug896 mug896 64 2019-01-27 19:17 2 -> /dev/pts/11     # 터미널

따라서 명령치환의 오류메시지를 /dev/null 로 전달하려면 다음과 같이 해야 합니다

$ echo "$( date -@ 2> /dev/null )"

$ { echo "$( date -@ )" ;} 2> /dev/null

기본적으로 오류로 취급되지 않는다.

명령 치환은 메인 명령 실행전에 명령문을 만드는데 사용되는 것으로 명령 치환에서 발생하는 오류는 오류로 취급되지 않습니다. 하지만 대입 연산에서 사용될 경우는 오류로 취급됩니다.

$ echo $( date -@ )
date: invalid option -- '@'
Try 'date --help' for more information.

$ echo $?
0
---------------------------------------

$ cat test.sh
#!/bin/bash -e
                        # 스크립트 실행중 오류발생시 자동 종료하는 errexit 옵션 설정
echo start ....
echo $( test -d xxx )   # xxx 는 존재하지 않는 디렉토리
echo end....

$ ./test.sh 
start ....

end....                 # test -d 명령에서 오류가 발생했지만 자동 종료되지 않고 끝까지 실행된다.

$ echo $?
0

##################  대입 연산  ##################

$ AA=$( date -@ )
date: invalid option -- '@'
Try 'date --help' for more information.

$ echo $?               # 오류로 취급된다.
1
--------------------------------------

$ cat test.sh
#!/bin/bash -e
echo start ....
AA=$( test -d xxx )     # 대입연산
echo end....

$ ./test.sh 
start ....              # 스크립트가 종료된다.

$ echo $?
1

종료 상태 값은 명령 치환 subshell 의 종료 상태 값을 따릅니다.

# test 명령에서 오류가 발생했지만 마지막 echo 명령은 정상 종료됐으므로
# 명령 치환 subshell 의 종료 상태 값은 '0' 이 됩니다.
$ AA=$( echo 111 >&2; test -d xxx; echo 222 >&2 )
111
222

$ echo $?
0
-----------------------------------------------------

# '&&' 연산자로 연결하면 종료 상태 값이 오류가 된다.
$ AA=$( echo 111 >&2 && test -d xxx && echo 222 >&2 )
111

$ echo $?
1

# 또는 errexit 옵션을 설정
$ AA=$( set -e; echo 111 >&2; test -d xxx; echo 222 >&2 )
111

$ echo $?
1

$( < filename )

이것은 bash 에서 제공하는 기능인데 $( cat filename ) 의 간단 버전입니다.

$ AA=$(< .git/config )

$ echo "$AA"
[core]
        repositoryformatversion = 0
        filemode = true
        bare = false
        logallrefupdates = true
[gitg]
        mainline = refs/heads/master

$ diff .git/config <(echo "$AA")
$

Quiz

현재 디렉토리에서 다음과 같은 파일 목록을 인수로 해서 명령을 실행하려고 합니다. 그런데 파일명에서 include 디렉토리는 제외하고 싶은데요. 어떻게 하면 될까요?

$ ls include/linux/z*.h
include/linux/z2_battery.h  include/linux/zlib.h   include/linux/zsmalloc.h
include/linux/zbud.h        include/linux/zorro.h  include/linux/zstd.h
include/linux/zconf.h       include/linux/zpool.h  include/linux/zutil.h

다음 첫번째와 같이 출력되는 파일명을 직접 수정할 수도 있지만 명령 치환의 경우 subshell 에서 실행이 된다는 점을 활용하면 cd 명령을 이용해 간단히할수 있습니다.

$ echo $( printf '%s\n' include/linux/z*.h | grep -o 'linux/.*' )
linux/z2_battery.h linux/zbud.h linux/zconf.h linux/zlib.h linux/zorro.h linux/zpool.h
linux/zsmalloc.h linux/zstd.h linux/zutil.h

......................................................................

# 명령 치환은 subshell 에서 실행되므로 명령 실행후 다시 cd 할필요가 없다.
$ echo $( cd include && echo linux/z*.h )
linux/z2_battery.h linux/zbud.h linux/zconf.h linux/zlib.h linux/zorro.h linux/zpool.h
linux/zsmalloc.h linux/zstd.h linux/zutil.h

2.

명령 치환이 직접 명령문에 포함되지 않게 작성하려면 어떻게 할까요?

파이프를 이용해 작성할 수 있습니다.

$ echo "이번달 마지막 토요일은 $( LC_ALL=C ncal | sed -n 's/^Sa .* \([0-9]\+\) *$/\1/p' ) 일 입니다."
이번달 마지막 토요일은 30 일 입니다.

# 다음과 같이하면 파이프와 cat 명령의 stdin 이 연결됩니다.
$ LC_ALL=C ncal | sed -n 's/^Sa .* \([0-9]\+\) *$/\1/p' | 
    echo "이번달 마지막 토요일은 $(cat) 일 입니다."
이번달 마지막 토요일은 30 일 입니다.