シェルスクリプト関数に入門してみた


きっかけ

はRustで競プロをやる環境の構築でした。ベストプラクティスかどうかはさておき (もっといい方法があれば教えてほしい) 、僕は

.
|- target
|- ContestA
   |- src1.rs
   |- src2.rs
   ...
|- ContestB
   |-src1.rs
   ...
|- .gitignore
|- Cargo.lock
|- Cargo.toml

という構成にして、srcの情報を全て

Cargo.toml
[[bin]]
name = "A_1"
path = "ContestA/src1.rs"
[[bin]]
name = "A_2"
path = "ContestA/src2.rs"
[[bin]]
name = "B_1"
path = "ContestB/src1.rs"
...

という形でCargo.tomlに記載し、

cargo run --bin A_1

で実行する方法をとることにしていました。明らかですが、この方法の欠点は

  • ファイルを追加するたびにCargo.tomlに上記の内容を手動で追記しなければならない
  • 実行コマンドがやや長い

という面倒さですが、後者はaliasで対処できるし、前者も、Cargo.tomlのnameのフォーマットを上のように決めておけば、追記する内容 (と実行コマンド) はファイル名とそのファイルを追加するディレクトリ名 (=コンテスト名) から一意に定まるので、自動化できそうです。

alias

で解決するものは先に片付けます。

.zshrc
alias cgb='cargo build --bin'
alias cgr='cargo run --bin'

今のところRustで競プロ以外をやる予定がないので、--bin をつけないパターンは最初から考慮してないです。

入門

と銘打っているので、初心者目線でシェルスクリプト関数をざっと勉強できる構成で書いていきます。そのため、本題に入る前に、今回ついでに作った簡単な関数を例に基本事項を紹介します。

mkin () {
   if [ $# = 1 ]; then
      mkdir $1; cd $1
   else
      echo '1 arg (directory name) is required.'
   fi  
}

関数の定義は

function hoge () {
   # 処理
}

が基本ですが、function は省略できます。コメントには基本的に # を使います。hoge() {} でも動きますが、関数名と () の間はスペースを空けるのが慣例のようです。呼び出すときは () をつけず

hoge
# or
hoge 引数

とします。引数はこのように単に関数名の後ろにスペースを空けて書きます (コマンド打つときと全く同じです) 。関数内で受け取るには

$1 # 1つめの引数
$2 # 2つめの引数

と書きます。ただし特殊な引数がいくつか組み込まれていて、

$0 # 関数が記述されているファイルの名前
$# # 呼び出し時にもらった引数の個数
"$@" # 呼び出し時にもらった引数全てを要素とする配列

などの情報を参照できます。一般に、変数は

変数=値 # スペースなし
# で定義
$変数
# で呼び出し

という形で使います。ちなみに = ですが、シェルスクリプトでは

var1=var2     # 代入
var1 = var2   # 評価

という使い方をします。

if 文は

if EXIT_STATUS; then
   # 処理
elif EXIT_STATUS; then
   # 処理
else
   # 処理
fi

という書き方をします。elif はPythonなどで見かけますが、fi は珍しいところです。これを if 文の最後に書かないとエラーになり、慣れないうちはよくハマります。
この EXIT_STATUS (終了ステータス) というのは 0 (真) か 1 (偽) ですが、いわゆる条件式 (変数 = 値 みたいな式) からこれを出すには

test 変数 =# or
[ 変数 =] # [ と条件式, ] と条件式 の間はスペース空ける

みたいな形で、test コマンドもしくは同じ意味の [ ] で条件式を評価します。

実は、シェルスクリプトにおける返り値はこの終了ステータスです。return 句はありますが、

exitstatus () {
   if [ $1 = '私がやりました' ]; then
      return 1
   else
      return 0
   fi
}

は1つ目の引数が '私がやりました' なら終了ステータス0 (偽)、それ以外なら終了ステータス1 (真) を返すという意味になり、たとえば

judge () {
   if exitstatus '俺はやってない'; then
      echo 'さようなら'
   fi
}

のような使い方になります。他の言語の返り値のような機能は、echo などによる標準出力で実現されます。というのは、

result=`judge '俺はやってない'`
# or
result=$(judge '俺はやってない')

という書き方で他の関数 (やコマンド) による標準出力を文字列として受け取ることができるのです。`` はネストできない、$() はできるという違いがあります。

;, && がよくコマンドの連結に使われる記号ですが、&& は左のコマンドの終了ステータスが 0 の場合のみ右のコマンドを実行、; は左のコマンドの完了を待って、終了ステータスによらず右を実行という違いがあります。したがって、

if [ $1 = '犯人' ]; then
   echo 'つかまえる'
fi
# と
[ $1 = '犯人' ] && echo 'つかまえる'
# は同義

となりますが、条件分岐の意味では if を使うのが普通でしょう。実際、elifelse を使う実用的な条件分岐を書くなら明らかに if の方がスッキリします。

これで

mkin () {
   if [ $# = 1 ]; then
      mkdir $1; cd $1
   else
      echo '1 arg (directory name) is required.'
   fi
}

は理解できると思います。

ファイルを追加するたびにCargo.tomlに追記

するにあたって、理想的な挙動は以下のような感じです (関数名は cgt とした) 。

Cargo.toml
# Desktop/atcoder/

[package]
name = "atcoder"
version = "0.1.0"
edition = "2021"

[dependencies]


zsh
kanarus@MacBookPro Desktop % cgt hoge.rs
use in a right directory.
kanarus@MacBookPro Desktop % cd atcoder/sample
kanarus@MacBookPro sample % ls
kanarus@MacBookPro sample % cgt a.rs b.rs c.rs
kanarus@MacBookPro sample % ls
a.rs b.rs c.rs
kanarus@MacBookPro sample % 
Cargo.toml
# Desktop/atcoder/

[package]
name = "atcoder"
version = "0.1.0"
edition = "2021"

[dependencies]

[[bin]]
name = "sample_a"
path = "sample/a.rs"
[[bin]]
name = "sample_b"
path = "sample/b.rs"
[[bin]]
name = "sample_c"
path = "sample/c.rs"

つまり、

  • 事前に登録したディレクトリ直下のディレクトリ (コンテスト用orそれに相当するディレクトリ) 内でしか実行されない
  • 引数はRustのファイル名 *.rs で、何個でもつけられる
  • 実行するとそのディレクトリに引数のファイルが全てtouchされ、Cargo.tomlにも各ファイルについて [[bin]] が記載される

という挙動になってほしいわけです。

では、実際に実装

していきます。

  1. 引数1個の場合の処理を cgt-single 関数で記述する
  2. cgt 関数で引数のリストを for で回して cgt-single を適用していく

という流れでいきます。まず 1. 。

.zshrc
cgt () {
   # 後述
}
cgt-single () {
   PARDIR=${PWD%/*}
   if [ ${1#*.} = 'rs' ]; then
      touch $1; echo -n '
[[bin]]
name = "'`basename $PWD`'_'${1%.rs}'"
path = "'`basename $PWD`'/'$1'"' >> $PARDIR/Cargo.toml
   else
      echo '.rs file is required.'
   fi
}

${変数名%パターン}${変数名#パターン} は文字列置換で、パターンにマッチする部分文字列を、 % は後ろから、# は前から、それぞれ1つだけ変数から削った文字列を表します。

$PWD
# カレントディレクトリの絶対パス
# atcoder なら '/Users/kanarus/Desktop/atcoder'

${PWD%/*}
# /* というパターンにマッチする部分文字列を後ろから1つだけ削ったもの
# atcoder なら '/User/kanarus/Desktop' になる
# ちょうど1つ上のディレクトリの絶対パス
$1
# ここでは *.rs という形でないと else で怒られる

${1#*.}
# *. というパターンにマッチする部分文字列を前から1つだけ削ったもの
# 'rs' になる
# hoge.hoge.rs みたいな名前はつけるつもりないので今回は考慮外
# (紹介してないが ${1##*.} で貪欲に削って対応可能)

basename パス は、パスを表す文字列から一番右のディレクトリ名orファイル名を取り出してくれます。

basename $PWD
# /USERS/kanarus/Desktop/atcoder なら 'atcoder'

basename ${PWD%/*}
# /USERS/kanarus/Desktop/atcoder なら 'Desktop'

シェルスクリプトにおいて、文字列は続けて書くだけで結合してくれます。

zsh
kanarus@MacBookPro atcoder % echo '私いま'`basename $PWD`'にいるの...'
私いまatcoderにいるの...
hoge.sh
str1='今日は休'
str2='むわ'
echo $str1$str2
# 今日は休むわ

また、改行文字は

$'\n'
# or
   '
'

で表されます。つまり、cgt-singleecho -n (-necho の最後に改行しないオプション) の中身は

                ' 
' + '[[bin]]' + '
' + 'name = "' + `basename $PWD` + '_' + ${1%.rs} + '"' + '
' + 'path = "' + `basename $PWD` + '/' + $1 + '"'

という文字列というわけです (文字列内に変数を展開する機能はあるが、'" "' という2重クオーテーションの中だとうまくいかないため、変数とそれ以外を分離して結合している。もっとうまい手段があれば教えてくださると幸いです) 。
ここまで読んでいただければ、この文字列を >> $PARDIR/Cargo.toml でCargo.tomlに追記することで

zsh
kanarus@MacBookPro atcoder % cd sample
kanarus@MacBookPro sample % cgt-single hoge.rs
kanarus@MacBookPro sample % ls
hoge.rs
Cargo.toml
# Desktop/atcoder/

# 略

[[bin]]
name = "sample_hoge"
path = "sample/hoge.rs"

という結果になることは分かると思います。

これを使って2.を実装していきます。
シェルスクリプトのfor文は

for 要素 in 配列
do
   # 処理
done

という書き方をします。if 文の fi 同様、 done も最初のうちは抜けやすいところです。これと、引数のところで紹介した "$@" (呼び出し時にもらった引数全てを要素とする配列) を組み合わせて

.zshrc
SITES='atcoder aoj'

cgt () {
   PARDIR=${PWD%/*}
   if [ $# = 0 ]; then
      echo '1 or more args (file names) are required.'
   elif echo ${SITES//' '/$'\n'} | grep -qx `basename $PARDIR`; then
      for file in "$@"
      do
         cgt-single $file
      done
   else
      echo 'use in a right directory.'
   fi
}

と実装しています。
SITES はグローバル変数として定義していて、cgt の対象とする PARDIR の名前 ( = cgt を使いたい競プロサイトの名前) を空白区切りで並べた文字列です。シェルスクリプトにも配列はありますが、後述する理由により今回はこの形式を採用する方がコードが見やすくなります。

あとは、elif 内の

echo ${SITES//' '/$'\n'} | grep -qx `basename $PARDIR`

さえ分かれば全て理解できるはずです。
else の処理を見てお察しの通り、これは実行時の basename PARDIRSITES で挙げられているかを調べています。
${変数名//パターン/代替文字列} は文字列置換で、変数に含まれる、パターンにマッチする部分文字列を全て代替文字列に置換した結果を表します。

# SITES='atcoder aoj'
  echo ${SITES//' '/$'\n'}
# atcoder
# aoj

となります。この出力を | (パイプ) で次のコマンドに引数として渡すことができます。
grep (は既知とする) の -qx フラグは、

  • -q ... grep の終了ステータス (ひとつでもあれば 0 (真), なければ 1 (偽)) は返すが、一致するものがあっても標準出力しない
  • -x ... 完全一致のみを一致とみなす

の合成です。これによって、

atcoder
aoj

の各行を見ていって実行時の basename PARDIR に完全一致するものがあるかを調べ、結果を 0 or 1 で返すことができるわけです (SITES の中身が増えても対応できます) 。

これが SITES を配列にしない理由で、配列にすると

SITES=('atcoder' 'aoj')

という表記になるのですが、これを使うなら、配列の全要素を空白区切りでつなげた文字列を表す 配列[@] という表記を使い、

dirs=`echo SITES[@]`; echo ${dirs//' '/$'\n'} | grep -qx $PARDIR

と書くという無駄なステップを踏む必要が出てくるのです (もっとスマートな手段があればぜひ教えてください) 。
また、最初から

SITES='
atcoder
aoj'

と定義すれば ${SITES//' '/$'\n'} の手間は省けますが、なんとなく見づらい気がしてやめました (このへんの感覚には個人差があると思います) 。


全体を再掲


PARDIR=${PWD%/*}
SITES='atcoder aoj'

cgt () {
   if [ $# = 0 ]; then
      echo '1 or more args are required.'
   elif echo ${SITES//' '/$'\n'} | grep -qx `basename $PARDIR`; then
      for file in "$@"
      do
         cgt-single $file
      done
   else
      echo 'use in a right directory.'
   fi
}
cgt-single () {
   if [ ${1#*.} = 'rs' ]; then
      touch $1; echo -n '
[[bin]]
name = "'`basename $PWD`'_'${1%.rs}'"
path = "'`basename $PWD`'/'$1 >> $PARDIR/Cargo.toml
   else
      echo '.rs file is required.'
   fi
}

けっこう長い記事になりましたが、読んでいただきありがとうございます。まだまだ紹介していないことはいっぱいあるので、ぜひ調べながらオリジナルの関数を作ってみてください。

https://blog.livewing.net/zsh-symbols#namepatternrepl-1
が貴重な網羅的記事で、この記事を書くにあたって辞書として何度もお世話になりました。この場を借りてお礼を申し上げつつおすすめしておきます。