4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

シェルスクリプトの関数でも名前付き引数(キーワード引数)を簡単に使いたい!

Last updated at Posted at 2022-09-19

はじめに

多くのスクリプト言語では名前付き引数(キーワード引数)や、位置引数(位置パラメータ)を変数に入れることが簡単にできます。以下は Python の例です。

#!/usr/bin/env python3

def func(arg1, arg2, *args, name1=False, name2=""):
    print(f"name1: {name1}, name2: {name2}")
    print(f"arg1: {arg1}, arg2: {arg2}")
    print(args) # 3, 4, 5

func(1, 2, 3, 4, 5, name1=True, name2="abc")

残念ながらシェルスクリプトの文法には、それを簡単に行う方法がありません。まあ実際にはなくてもそんなに不便ではありません。外部コマンドと違ってシェル関数が行う処理は極めて単純で、一つの関数で一つのことしかしないので、殆どの場合引数だけで十分です。しかしまれにそのようなことをしたい場合もあります。

では、シェルスクリプトでキーワード引数を使いたいと思った時、どのようなコードを書けばいいでしょうか?「それってオプション解析のことでは?」と思った方。はい、そのとおりです。よく見かけるオプション解析のコードを実装すれば実現可能です。ただし CLI コマンドのオプションの仕様は結構複雑でまともにやろうとすると、それなりに長いコードを必要とします。しかし、そのような複雑なオプション解析は、シェル関数のような内部で使うものには必要ありません。

この記事で紹介しているのは、シェルスクリプトの流儀を踏まえた上でシェル関数用に簡略化したオプションの仕様を定義しています。そしてシェル関数用として内部利用として必要十分なレベルなオプション引数の解析を、より短いコードで実現しています。ちなみにこの記事で言うシェルスクリプトとは POSIX 準拠シェル全てです。bash の拡張機能などは使用していないので全ての POSIX シェルで動作します。

補足 CLI コマンド用に本格的なオプション解析のコードが必要な方は、以下の記事を参照してください。

こんな風に書けるよ

もともとこの記事はシェル関数用の簡易な引数解析のコードを書くつもりだったのですが、記事を書いてるうちに、これ意外と簡単にライブラリ化出来るなと気がついたのでやってみました。ライブラリを使うと以下のようにシンプルにシェル関数の引数解析を行うことが出来ます。argparse 関数の実装は後半に載せています。

補足: オプションの形式は以下の 2 通りのみとする(GNU スタイルのサブセット)

  • --name (指定した場合、変数には true という文字列が代入される)
  • --name=value--name value 形式には対応しない)
func() {
  name1=false name2= arg1= arg2= # 変数の初期化。対応シェルでは local 変数にしても良い
  argparse --name1 --name2= :arg1 :arg2 : "$@" || shift $?

  echo "name1: $name1, name2: $name2"
  echo "arg1: $arg1, arg2: $arg2"
  echo "$@" # 残りの引数: 3 4 5
}

func --name1 --name2=abc 1 2 3 4 5

解説

argparse 関数の後ろの : "$@" || shift $? の部分は定型文です。: を区切り記号として、argparse 関数に渡す情報と、解析したい引数を分離しています。

argparse 関数のパラメーターで、対応するオプション(キーワード引数)を指定します。--name1 のような書き方で真偽値オプション、--name2= のように末尾が = の場合は値を取るオプションを意味しています。

上記のコードで --name1 オプションを指定した場合、その値は name1 変数に代入されます。別の名前の変数に入れたい場合は以下のようにします。これはロングオプションの流儀では - という変数に使えない文字を使うことに対応するためです。(自動で -_ に置換した変数名に代入することも考えましたが需要の割に面倒なので省きました)

argparse --long-name long_name : "$@" || shift $?

: で始まる名前は、位置パラメータをその変数に代入します。上記のコードではオプション引数を除いた引数の最初の二つを arg1arg2 変数に代入し「残りの引数」を作っています。

個人的に気に入ってるのが、|| shift $? の部分です。これは func -name1 -name2=abc 1 2 3 4 5 の引数 shift して、$@ = 3, 4, 5 にするためのコードです。一般的には関数の戻り値は正常終了(0)か異常終了(0 以外)を意味する終了ステータスを返しますが、ここでは代わりに shift する数を返しています。0 を返した場合は正常終了となって || の後のコードは実行されませんが、そもそも shift する数は 0 個なので問題ありません。この発想によって最小一行で引数解析を記述できるようにできました。

$? を使っているため対応可能なキーワード引数(と変数に代入する引数)の数は POSIX シェルの標準規格では最大 127 個(現実のシェルではおそらく 255 個)までに制限されます。この数に「残りの引数」の数は含まれません。したがってシェル関数の仕様としては十分でしょう。

ちなみに argparse による引数解析の時間は、現在のコンピュータであれば 1ms 未満なので、パフォーマンスの点でも十分許容範囲だと思います。対応するキーワード引数の種類が増えればそれだけ時間は伸びますが、シェル関数でそれほど多くのキーワード引数がある時点でなにか間違っています。

内部実装について

内部的に以下のようなコードを生成して実行しています。argparse 関数を使いたくない人は手動でこのようなコードを書くことで実現できます。1 回しか必要にならないような場合は、手動で以下のようなコードを書いても良いでしょう。行数から考えると 3 回以上このようなコードが必要になるのであれば argparse 関数を使った方が良いと思います。

func() {
  name1=false name2= arg1= arg2=

  # argparse の行で以下のようなコードを動的に生成して実行している
  while [ $# -gt 0 ]; do
    case $1 in
      --name1) name1=true ;;
      --name2=*) name2=${1#*=} ;;
      --) shift && break ;;
      -*) echo "unknown option: ${1%%=*}" >&2 && exit 1 ;;
      *) break ;;
    esac
    shift
  done
  arg1=$1 arg2=$2
  shift 2

  echo "name1: $name1, name2: $name2"
  echo "arg1: $arg1, arg2: $arg2"
  echo "$@" # 残りの引数: 3 4 5
}

func --name1 --name2=abc 1

設計方針

  • getoptgetopts は使わない
    • 使っても対してコードはシンプルにならないから。
  • オプションはロングオプションのみとする
    • 実際にはショートオプションも使えるが、可読性が低くなるので非推奨。
  • ロングオプション名の省略記法には対応しない
    • --long-name--long と省略できてもシェルスクリプトではメリットがない。
  • 一文字のショートオプションをつなげた指定方法には対応しない
    • ls -a -l-al というようにつなげる書き方のこと
  • -- によるオプションの打ち切りには対応する
    • - から始まるオプションではない引数があり得るので対応は必須。
  • オプションはその他の引数の前でのみ指定可能
    • 後ろにオプションを追加はできない。シェルスクリプトではあまり意味がない。
  • 外部コマンドへの依存なし
    • シェルの文法とビルトインコマンドだけで実装可能。
  • わずか 30 行の短い引数解析コード
    • 少ないコードはシェルスクリプトに組み込んでもじゃまにならずパフォーマンスも良い。

argparse 関数

ライセンスは CC0 としますので自由にコピーして(独自の修正を加えたりして)使用して構いません。プロジェクトページはこちら (sh-argparse) です。

argparse() {
  argparse_index=1
  argparse="while [ \$# -gt 0 ]; do case \"\$1\" in --) shift && break ;;"
  while [ $# -gt 0 ]; do
    case "$1,$2" in
      -*=,[-:]*) argparse="$argparse $1*) ${1#"${1%%[!-]*}"}\${1#*=} ;;" ;;
      -*=,*) argparse="$argparse $1*) $2=\${1#*=} ;;" && shift ;;
      -*,[-:]*) argparse="$argparse $1) ${1#"${1%%[!-]*}"}=true ;;" ;;
      -*,*) argparse="$argparse $1) $2=true ;;" && shift ;;
      :*,*) break ;;
      *,*) echo "argparse: invalid parameter: $1" >&2 && exit 1 ;;
    esac
    argparse_index=$((argparse_index + 1)) && shift
  done
  argparse="$argparse -*) echo \"argparse: unknown option: \${1%%=*}\" >&2"
  argparse="$argparse && exit 1 ;; *) break ;; esac; shift; done;"
  while [ $# -gt 0 ]; do
    case "$1" in
      :) shift && break ;;
      :*) argparse="$argparse ${1#:}=\$1 && shift;" ;;
      *) echo "argparse: invalid parameter: $1" >&2 && exit 1 ;;
    esac
    argparse_index=$((argparse_index + 1)) && shift
  done
  eval "$argparse"
  set -- $((argparse_index - 1))
  unset argparse argparse_index
  return "$1"
}

ぱっと見、何をしているかよくわからないかもしれませんが、単にコードを組み立てて eval しているだけです。eval 直前の argparse 変数の中身を出力すると、何をしているのかわかると思います。

さいごに

シェルスクリプトによるプログラミング技法(シェルの言語機能をフルに使った技法)を使うと、このようなものだって作れるのですが、私と同じようなことをしてる人は殆ど見かけないなぁと少し残念です。外部コマンドの使いこなし方にばかり気を取られていて、シェルの言語機能を知ろうとしてない人が多いです。

シェルの言語機能の潜在能力はもっと高いです。アイデア次第でいろんな事が実現可能です。単にコマンドを羅列して実行するだけではなく、シェルスクリプトのプログラミング技法を知って、自分の手でシェルスクリプトをもっと便利に改善していきましょう!

4
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?