シェルスクリプト関数に入門してみた
きっかけ
はRustで競プロをやる環境の構築でした。ベストプラクティスかどうかはさておき (もっといい方法があれば教えてほしい) 、僕は
.
|- target
|- ContestA
|- src1.rs
|- src2.rs
...
|- ContestB
|-src1.rs
...
|- .gitignore
|- Cargo.lock
|- Cargo.toml
という構成にして、srcの情報を全て
[[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
で解決するものは先に片付けます。
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
を使うのが普通でしょう。実際、elif
や else
を使う実用的な条件分岐を書くなら明らかに if
の方がスッキリします。
これで
mkin () {
if [ $# = 1 ]; then
mkdir $1; cd $1
else
echo '1 arg (directory name) is required.'
fi
}
は理解できると思います。
ファイルを追加するたびにCargo.tomlに追記
するにあたって、理想的な挙動は以下のような感じです (関数名は cgt
とした) 。
# Desktop/atcoder/
[package]
name = "atcoder"
version = "0.1.0"
edition = "2021"
[dependencies]
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 %
# 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個の場合の処理を
cgt-single
関数で記述する -
cgt
関数で引数のリストをfor
で回してcgt-single
を適用していく
という流れでいきます。まず 1. 。
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'
シェルスクリプトにおいて、文字列は続けて書くだけで結合してくれます。
kanarus@MacBookPro atcoder % echo '私いま'`basename $PWD`'にいるの...'
私いまatcoderにいるの...
str1='今日は休'
str2='むわ'
echo $str1$str2
# 今日は休むわ
また、改行文字は
$'\n'
# or
'
'
で表されます。つまり、cgt-single
の echo -n
(-n
は echo
の最後に改行しないオプション) の中身は
'
' + '[[bin]]' + '
' + 'name = "' + `basename $PWD` + '_' + ${1%.rs} + '"' + '
' + 'path = "' + `basename $PWD` + '/' + $1 + '"'
という文字列というわけです (文字列内に変数を展開する機能はあるが、'" "' という2重クオーテーションの中だとうまくいかないため、変数とそれ以外を分離して結合している。もっとうまい手段があれば教えてくださると幸いです) 。
ここまで読んでいただければ、この文字列を >> $PARDIR/Cargo.toml
でCargo.tomlに追記することで
kanarus@MacBookPro atcoder % cd sample
kanarus@MacBookPro sample % cgt-single hoge.rs
kanarus@MacBookPro sample % ls
hoge.rs
# Desktop/atcoder/
# 略
[[bin]]
name = "sample_hoge"
path = "sample/hoge.rs"
という結果になることは分かると思います。
これを使って2.を実装していきます。
シェルスクリプトのfor文は
for 要素 in 配列
do
# 処理
done
という書き方をします。if 文の fi
同様、 done
も最初のうちは抜けやすいところです。これと、引数のところで紹介した "$@"
(呼び出し時にもらった引数全てを要素とする配列) を組み合わせて
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 PARDIR
が SITES
で挙げられているかを調べています。
${変数名//パターン/代替文字列}
は文字列置換で、変数に含まれる、パターンにマッチする部分文字列を全て代替文字列に置換した結果を表します。
# 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
}
けっこう長い記事になりましたが、読んでいただきありがとうございます。まだまだ紹介していないことはいっぱいあるので、ぜひ調べながらオリジナルの関数を作ってみてください。
が貴重な網羅的記事で、この記事を書くにあたって辞書として何度もお世話になりました。この場を借りてお礼を申し上げつつおすすめしておきます。Author And Source
この問題について(シェルスクリプト関数に入門してみた), 我々は、より多くの情報をここで見つけました https://zenn.dev/kanal/articles/6363abf81ae37d著者帰属:元の著者の情報は、元のURLに含まれています。著作権は原作者に属する。
Collection and Share based on the CC protocol