2018年7月20日

Bash スクリプトの基本あれこれ #2: 区切る、まとめる、無効にする

今回は Bash の「メタキャラクタ」、意味としては「特殊な動作を行う文字」を扱う。 Bash においてはこういった文字が多数用意されており、それによって動作を制御している。 Bash は仕様を正確に理解していないと「何故そう動くのか」が解りづらいスクリプト環境であるが、前回説明したファイルディスクリプタやリダイレクションを踏まえて今回説明するメタキャラクタを理解できれば動作の理解はより容易くなると思う。

・メタキャラクタ

例えば、基本として変数の参照には $ というメタキャラクタを使う。

$ var=hello
$ echo $var
hello

他にも「ファイル名の補完」や前回説明した「リダイレクションやパイプ処理」を行うメタキャラクタ等、様々なものがあるが(詳細はリファレンスとしてこういったサイトを始め、検索して参照していただきたい)、重要なのはコマンドを「区切ったり」、「まとめたり」といった、構造的にスクリプトの動作を制御するためのものだ。こういったメタキャラクタの動作を理解できれば、 Bash においてスクリプトの読み書きの際に制御の流れを把握しやすくなる。

・区切る

1: 改行 スペース タブによる区切り

コマンドの区切りにおいて最も使うであろうメタキャラクタは「改行」である。例えば、 CUI 上でコマンドを入力した際に、 Enter キーを押すことでコマンドは実行されるが、これは改行文字によってコマンドが区切られているためだ。ファイル内でスクリプトを定義する場合、

echo one
echo two

とすれば、 echo one の行の最後の改行文字によってコマンドは区切られ、 echo one と echo two は違うコマンドであると認識される。

「スペース及びタブ」は、文字列を区切るためのメタキャラクタだ。例えば、 mkdir(ディレクトリを作成する)コマンドを実行し、 one と two というディレクトリを作成するとする。

$ mkdir -v one two
mkdir: ディレクトリ 'one' を作成しました
mkdir: ディレクトリ 'two' を作成しました
(この場合 one と two がスペースによって区切られているため、別々の引数として mkdir に渡され、 one と two の2つのディレクトリがカレントディレクトリ下に作成される)

明確に文字列として指定すれば「one two」を一つのディレクトリと出来る(「 '(アポストロフィー)」で囲う事でメタキャラクタとしてのスペースの意味を無効にする。詳しくは下記「無効にする」を参照)。

$ mkdir -v 'one two'
mkdir: ディレクトリ 'one two' を作成しました
(この場合「one two」が一つの引数として mkdir に渡され、「one two」という一つのディレクトリが作成される)

「スペースが挿入されたディレクトリやファイル」をスクリプト上で扱いづらいのは、スペースが区切り文字として扱われるメタキャラクタであるためである。「文字列の区切り」として使う文字は組み込み変数である IFS で定義されているので、この内容を変える事で「文字列の区切り」に使う文字自体を変える事が出来る。

$ PIFS=$IFS
( IFS 変数の中身を PIFS 変数に退避する。変更した後は元に戻さないと通常の処理が行えなくなる。ちなみに標準では IFS の中身はスペース、タブ及び改行となっている)

$ for i in `echo \`ls\``; do echo $i; done
Desktop
Document
Download
Image
Internet
Music
Project
Video
(カレントディレクトリ下で ls した結果の echo を順次 echo として出力する)

$ IFS=$'\n'
( 文字列の区切りを改行のみに変更)

$ for i in `echo \`ls\``; do echo $i; done
Desktop Document Download Image Internet Music Project Video
(スペースが区切り文字として機能しないため、 ls した結果の echo は順次評価されず、一度で全てが出力される)

$ IFS=$PIFS
( IFS を元に戻す)

2: 一行で複数のコマンドを区切って実行する

一行で複数のコマンドを区切って実行する(改行を使わずにコマンドを実行する)には「 ;(セミコロン)」を使う。例えば echo one と echo two のコマンドを一行で続けて実行したい場合、下記のように書ける。

$ echo one; echo two
one
two
(echo one の後に echo two が実行される)

3: コマンドを置換する

例えば、 ls の結果を echo で表示したいとする。

$ echo ls
ls
(単純に echo ls とすると、 echo は素直に ls という文字列を出力する)

こういった場合、「コマンド置換」を使用すると ls コマンドの結果を echo に渡せる。これは「 `(バッククォート)」で置換したいコマンドを囲む事で実現出来る。

$ echo `ls`
( ls コマンドが実行結果として置換され、カレントディレクトリ下の一覧が表示される)

変数にコマンドの結果を代入したい場合はコマンド置換が使える。

$ list=`ls`
$ echo $list
( ls の実行結果を list に代入し、それを echo で表示する)

コマンド置換は実行順が明確に解りづらいコマンド群に、明確な評価順を与える場合にも利用できる。こういった意味では、コマンドの実行を実行順で「区切っている」と言える。例えば、カレントディレクトリ下に Image ディレクトリが存在するとして、次のコマンドを実行してみる。

$ echo ls | grep Image
(文字列 ls に対して Image を含む行を抽出する。結果は抽出されず、表示されない)

$ echo `ls | grep Image`
Image
(この場合 ls | grep Image の結果を echo で出力する)

$ `echo ls` | grep Image
Image
(この場合 ls の結果を echo で出力し、それをパイプで grep に渡す。結果として grep で一致した Image が強調表示する形で出力される)

このように、コマンド置換によってコマンドの実行順が変わるのが確認できる。

Bash においては、コマンド置換は「 #() 」によっても実行できる。ただし、「 `(バッククォート)」によるコマンド置換とは「 \(エスケープ文字、詳細は下記「無効にする」を参照)」の扱い方が異なる。

$ one=hello
$ two=world

$ echo `echo \$one`
hello
(コマンド置換によって評価された $one の結果を echo で出力する。結果は one 変数の中身となる。コマンド置換内の echo によってエスケープされた $one 文字列が、外側の echo に渡される。この時に one 変数が展開される)

$ echo $(echo \$two)
$two
(コマンド置換によって評価された \$two の結果を echo で出力する。結果は $two 文字列となる。コマンド置換によってエスケープ文字が評価されず、 \$two 文字列として外側の echo に渡される。この時に \$two はエスケープされて評価されるため、 $two 変数は展開されず、そのまま文字列として出力される)

・まとめる

1: コマンド群を一つのコマンドにまとめる

複数のコマンド群を一つのコマンドとしてみなしたい場合は、「 () 及び {} 」を使う。この概念を文章として説明するのは非常に難しいが、動作例を通じて説明を試みようと思う。今回は例としてカレントディレクトリ下に Image 及び Document ディレクトリが存在するとする。

$ ls | (cd `grep Image`; touch here)
( ls の結果をパイプして grep に渡し、その結果を基にして cd を実行して、その場所に touch (ファイルを作成する)コマンドで here ファイルを作成する)

$ ls Image
here
( Image ディレクトリ下に here ファイルが作成されている)

この奇妙な動作を説明するのは骨が折れる。 () メタキャラクタによって「 cd `grep Im`; touch here 」コマンド群は一つのコマンドとして定義されている。そのコマンドに対して ls の結果をパイプとして接続する。すると標準入力を入力として認識できる最初のコマンドである grep が ls の結果を受け取り、それが cd への入力として評価され、結果的に Image ディレクトリ下に here ファイルが作成される。

こういった処理はコマンドをまとめる事によって初めて実行可能となる。例えば、下記のコマンドでは期待したように動作しない。

$  ls | cd `grep Image`; touch here
(これは ls の結果を cd コマンドに渡すことになる。cd は標準入力を受け取ることが出来ないコマンドである。 grep Image は入力がないため正しく評価されず、カレントディレクトリ下に here ファイルが作成される)

つまり、 () でまとめたコマンドにパイプを接続すると、まとめたコマンド群を順次実行しつつ、なおかつ最初に標準入力を入力として評価するコマンドにパイプが接続される。

$ ls | (cd `grep Image`; touch one; cd '..'; cd `grep Document`; touch two)
(このコマンドを実行すると、 ls の結果は最初の grep Image に渡される。次点の grep Document は正しく評価されない)

$ ls Image
one
( one ファイルは期待通り Image ディレクトリ下に作成されている)

$ ls
two
( two ファイルはカレントディレクトリ下に作成されている)

$ ls | (touch one; cd `grep Document`; touch two)
(このコマンドは grep Document コマンドが標準入力を受け取ることが出来る最初のコマンドであるため、 ls の結果を受け取る。まずカレントディレクトリ下に one ファイルを作成し、そのあと ls | grep Document の結果を cd への入力とし、結果として Document ディレクトリ下に two ファイルが作成される)

$ ls Document
two

このようにコマンド群をまとめる処理は、コマンド群に対してパイプを用いる際に入力の対象を的確に指定したい場合に効果的に利用出来るものであると言える。それ以外でもコマンドのコンテキストを明示的に表現したい場合に利用できる。

2: () と {} の違い

コマンドをまとめるには () と {} が使用できるが、これらは () の場合はその内部でのみ定義した環境が有効になる、 {} の場合は定義した環境を引き継ぐという違いがある。

$ (cd Image)
(カレントディレクトリ下の Image に移動するが、コマンド実行後には環境は引き継がれず、カレントディレクトリに戻っている)

$ { cd Image; }
(カレントディレクトリ下の Image に移動する。コマンド実行後も環境が引き継がれているため、 Image ディレクトリ下にいる。 {} は文法的に { の後にスペース、 } の前に ; が必要である)

前述した IFS の例で言うと、

$ (IFS=$'\n'; for i in `echo \`ls\``; do echo $i; done)
Desktop Document Download Image Internet Music Project Video

は IFS への代入が () 外には引き継がれないため、 ()  外では標準の IFS を適用した文字列区切り処理がなされる。この場合明示的に変数を退避する必要はない。

$ for i in `echo \`ls\``; do echo $i; done
Desktop
Document
Download
Image
Internet
Music
Project
Video
(上記コマンド実行後でも通常の IFS を利用した文字列区切りで処理されている)

3: 複数行のコマンドを一行のコマンドにまとめる

複数行に渡る長いコマンドを一行のコマンドとしてまとめるには、「 \(バックスラッシュ)」を使う。文法上の注意点として、 \ の後には余計な入力をせず、直後に改行する必要がある。

$ echo one \
> two
one two
(このコマンドは echo one の後の \ によってコマンドの結果が次の行に保留される。続けて次の行に two を入力する事により、結果的に echo one two と同じ結果となる)

・無効にする

「メタキャラクタ」として処理される文字を「ただの文字として使用したい」場合は、メタキャラクタとしての機能を無効に出来る(これは一般的に「エスケープ」と称される)。

1: 一文字のメタキャラクタをエスケープ

明示的に一文字のメタキャラクタをエスケープするには、「 \ (バックスラッシュ)」を使う。

$ var=hello
$ echo \$hello
$hello
(変数参照のメタキャラクタである $ の機能が無効化される(エスケープされる)ことにより、単純に $hello 文字列が出力される)

2: 明示的な文字列の定義

Bash スクリプト内において明示的に文字列を表現したい場合は「 '(アポストロフィー)」及び「 "(ダブルクォーテーション)」を使用する。この場合囲われた文字列はメタキャラクタがエスケープされるが、 ' と " ではエスケープされる範囲が異なる。

「 '(アポストロフィー)」の場合、囲った文字列のメタキャラクタは全てエスケープされる。

$ var='hello world'

$ echo *
( * メタキャラクタはファイル名の補完において任意の文字列の合致を表す。この場合はカレントディレクトリ下に存在するディレクトリとファイルを列挙する(ただし、 . で始まる隠しファイルは除く))

$ echo '$var \$var `ls` *'
$var \$var `ls` *
( 「 '(アポストロフィー)」の処理では、メタキャラクタの処理( $ \ ` * )は全てエスケープされ、純粋な文字列となる)

「 "(ダブルクォーテーション)」の場合、 $ と \ と ` はエスケープされない。これらのメタキャラクタは評価されるが、それ以外のメタキャラクタはエスケープされる。

$ echo "$var \$var `ls` *"
hello world $var カレントディレクトリ下の表示 *
( $var の評価として var 変数の中身を評価し、 \$var の評価として $ をエスケープした  $var を文字列とし、コマンド置換による ls コマンドを評価し、 エスケープした * を文字として表示する echo が実行される)

" の特性を利用すると、変数の内容を連結して新たに変数として代入するといった柔軟な処理が可能となる。

$ one=hello
$ two= world
$ three="$one $two"
$ echo $three
hello world
( $ は "" において評価されるため、 one と two の内容を連結した文字列を three に代入できる)


0 件のコメント:

コメントを投稿