12
16

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

シェルスクリプト&PowerShellAdvent Calendar 2023

Day 15

シェルスクリプトの関数で「戻り値」を返す方法、全部まとめ(bash/zshの新機能 関数置換・値置換の使用例)

Last updated at Posted at 2024-01-17

はじめに

シェルスクリプトのシェル関数の return コマンドは他の言語のような戻り値を返すものではありません。return コマンドで返すものは終了ステータス、つまり 0 から 255 までの数値です。しかし現実には終了ステータス以外の値を返したいというのはよくあります。この記事ではシェル関数から「他の言語でいう戻り値を返す」に相当する方法をすべてまとめました。または次期 bash 5.3/zsh 5.10 で追加される予定の「関数置換 (funsub)」と「値置換 (valsub)」の機能の紹介です。

この記事では現時点で開発中の bash 5.3 と zsh 5.10 に追加される予定の「関数置換」と「値置換」の話を扱っています。ksh93、mksh で実装済みの機能を取り込んだものなので変更される可能性は少ないと思いますが正式な機能とは異なる可能性があるので注意してください。

「関数置換」と「値置換」については以下の記事にまとめ直しました。
シェルスクリプトが速くなる! forkしない新しいコマンド置換がやってくる!(次期bash/zshの新機能)

前提知識

本題の前に「シェル関数から戻り値を返す」ことについて、そもそもその発想自体が間違いである可能性があります。場合によってはコマンド置換やこの記事のテクニックを使うべきではないかもしれません。具体的に説明しようとすると例を考えなければならなくなり長くなるので割愛しますが、とりあえず前提知識として知っておくべきことを書いておきます。

なぜシェル関数は終了ステータスしか返せないのか?

シェル言語と他の言語の違いの一つは、シェル言語はコマンドを組み合わせるための言語であり、他の言語は(その言語の)関数を組み合わせる言語だという所です。コマンドとはシェルから実行可能なプログラムです。例えば C 言語でコマンドを作る場合はこのようになります。

hello.c
#include <stdio.h>
int main() {
    printf("Hello, World!\n");
    return 0; // 終了ステータス = シェル関数の戻り値と同じもの
}

同等の処理を行うコマンドを Python で書くとこのようになります。

hello.py
#!/usr/bin/env python
print("Hello, World!")
exit(0) # 省略可能

コマンドはどの言語でも作ることが出来ます。シェル関数とは上記のコマンドと本質的に同じものです。なぜこのようになってるかというと「コマンドを組み合わせることが出来るプログラミング可能な言語があればいいのに」という発想がシェル言語の設計の出発点になっているからです。シェル言語はコマンドを関数のように使う言語であり、シェル関数もコマンドと置き換え可能(ただしコマンドよりも軽量でシェル関数毎にファイルを管理する必要がない)なものとして設計されています。

コマンドはどの言語またはシェル関数で作ってもこのような使い方ができるもの
$ hello # コマンド(シェル関数)の呼び出し
Hello, World!

$ echo $? # 終了ステータスの表示
0

どの言語で作ったとしてもコマンドは終了ステータス以外を返すことはできません。コマンドの性質は標準出力へ文字列を出力するプログラムでコマンドの戻り値は終了ステータスです。それに対して他の言語の関数は任意の型の戻り値を返すものなので、コマンド(シェル関数)と他の言語の関数は性質が全く異なります。

「シェルスクリプトの関数は標準出力で戻り値を戻すもの」と言われたりしますが、時系列が逆で先に標準出力で出力するコマンドがあって、そのコマンドを組み合わせて使えるように設計された言語がシェル言語であり、コマンドと互換性があるシェル関数を追加したという流れです。

補足: シェル関数はいつ誕生したのか?

  • 1969 UNIX 誕生(当然コマンドはこの頃からある)
  • 1971 Thompson シェル誕生(プログラミング言語機能はなし)
  • 1979 Bourne シェル誕生(プログラミング言語機能が追加)
  • 1984 Bounre シェルに軽量コマンドとしてシェル関数を追加

このような違いがシェル言語が独特な言語に感じる理由の一つで、それを理解せずに書くとシェルスクリプトが読みづらくなってしまう理由で、シェルスクリプトを他の言語のプログラムに置き換えることができない理由です。シェル関数が終了ステータスしか返せないのはシェル言語が劣っているからではなく、シェル言語の設計思想によるものなのです。

算術式展開や変数展開を使う

この記事で登場するテクニックは(コマンド置換を除き)パフォーマンス問題を解決することが目的の一つとなっています。つまり関数の中で外部コマンドを呼び出していてはこの記事で紹介している方法を使う意味はあまりありません。外部コマンドを使わないということはシェルの機能で実装するということです。testコマンド、echo コマンドなどはシェルビルトインコマンドなので使っても大丈夫です。どのコマンドがシェルビルトインコマンドか分からなければ、type コマンドで調べることができます。bash を日本語ロケールにして実行すると「シェル組み込み関数」などと言われて分かりづらいですが。

$ type test
test is a shell builtin

$ type [
[ is a shell builtin

$ type expr
expr is /usr/bin/expr

数値計算に expr コマンドを使ってはいけません。それは外部コマンドです。数値の計算にはシェル内蔵の算術式展開 $((...)) を使いましょう。文字列の編集には変数展開を使います。変数展開がシェル内蔵の文字列の編集の方法です。

$ str="abcde"
$ echo "${str#?}"
bcde

$ str="abcde"
$ echo "${str%?}"
abcd

シェル関数から文字列を戻り値として返すのであれば、シェルの機能をしっかり理解する必要があります。

シェル関数から「戻り値」を返す方法

シェル関数、つまりコマンドは終了ステータスを返すものなので、他の言語のような戻り値を返すことはできませんが、それでも戻り値を返す関数は欲しいのが現実です。基本的には標準出力への出力が戻り値を返すことに相当するわけですが、(小さいデータの場合)パフォーマンスが悪くなるという問題があります。シェル開発者もその問題を認識しており代わりの手段が用意されています。

comsub: コマンド置換を使う

シェル関数から戻り値を返す最も基本的な方法はコマンド置換 (Command Substitution, comsub) を使う方法です。文法は異なりますが古き Bourne シェルの時代から使うことが出来る方法です。

fn() {
  echo "str"
}

# POSIX準拠の現在のshの書き方
ret=$(fn)

# 参考: Bourneシェル(古いsh)の書き方
#   コマンド置換がネストしたときに「`」をエスケープしなければならず
#    読みづらくなるので現在は非推奨
# ret=`cmd`

この方法には二つの問題点があります。一つは軽量なシェル関数使っていてもサブシェル (fork) のせいで無視できないパフォーマンス低下が発生することです。

サブシェルに伴うforkで大きなパフォーマンス低下が発生する
$ time bash -c 'fn() { echo s; }; i=10000; while [ $((i=i-1)) -gt 0 ]; do fn > /dev/null; done'
real    0m0.220s
user    0m0.171s
sys     0m0.048s

$ time bash -c 'fn() { echo s; }; i=10000; while [ $((i=i-1)) -gt 0 ]; do ret=$(fn); done'
real    0m3.467s
user    0m2.783s
sys     0m0.992s
補足: ksh93は例外でforkを行わない仮想サブシェルに最適化可能な場合はパフォーマンス低下がない
$ time ksh -c 'fn() { echo s; }; i=10000; while [ $((i=i-1)) -gt 0 ]; do fn > /dev/null; done'
real    0m0.191s
user    0m0.118s
sys     0m0.073s

$ time ksh -c 'fn() { echo s; }; i=10000; while [ $((i=i-1)) -gt 0 ]; do ret=$(fn); done'
real    0m0.132s
user    0m0.105s
sys     0m0.024s

コマンド置換のもう一つの問題は末尾の複数の改行が削除されてしまうという知らなければハマりかねない仕様があることです。この仕様は Perl や Ruby の chomp 関数や、Python の rstrip('\n') 相当するもので便利な仕様ではあるんですけどね。

末尾に改行をいくつ付けても削除される
fn() {
  echo "str"
  echo
  echo
  echo
}

ret=$(fn)
echo "[$ret]" # => [str]

# 回避方法
#   補足: 古いBourneシェルでは動かない(POSIX準拠の現在のshでは問題ない)
ret=$(fn; echo _) && ret=${ret%_}
echo "[$ret]" # => [str(改行)(改行)(改行)]

終了ステータスを戻り値として使う

推奨はしませんがシェル関数の戻り値が 0 から 255 までの数値で十分なのであれば、戻り値として使うこともできなくはありません。サブシェルを使わないので速度低下がないというメリットがあります。

fn() {
  return 123
}

fn
ret=$?
echo "$ret" # => 123

補足: 実はシェル関数の終了ステータスはシェルによっては符号付き32ビット値まで扱えたりします。動作保証されているかは別の話ですが、最も普及している bash が 255 までで実質使えないので調べる気が起きません。

引数で指定した名前の変数に返す

この方法は私がよく使っている方法で、POSIXシェルの範囲の機能で実装可能な方法です。

fn() {
  eval "$1=str"
}

fn ret # 戻り値は引数で指定した名前の変数に代入される

奇妙な方法と思うかもしれませんが、シェルビルトインコマンドの read コマンドや getopts コマンドでも使われているテクニックです。

read line       # line に入力文字が代入される
getopts abc OPT # OPT にオプション名が代入される

この方法のメリットは、関数の中で変数の使用を避けることが可能なので汎用的な関数に適しているという点です。POSIXシェルの範囲の機能にはローカル変数がないため特に有用です。グローバル変数がなければ再帰関数を作るのも容易です。またサブシェルを使わないので速度の低下は発生しませんし、末尾の改行が消えることもありません。eval を使うことが気になるかもしれませんが、通常変数名はシェルスクリプトの中に固定で埋め込まれるものであるため、正しく使えば任意のコードを実行できたりはしません。もちろん値の方は注意する必要があります。どうしても eval が気になる場合は文字種のチェックをすると良いでしょう。

fn() {
  # この書き方だとvarに特殊な文字が含まれていると問題が発生するので避ける
  eval "$1=$var" 
  
  # この書き方なら問題ない
  eval "$1=\$var" 
}

eval をなくすもう一つの手は printf -v を使う方法です。POSIX シェルの範囲の機能ではありませんが、bash、ksh93u+m(ksh93は非対応)、zshが対応しています。見ての通りこれもシェルに組み込まれた機能で「引数で指定した名前の変数に返す」方法を使っているという例の一つです。

# -v オプションで指定した変数に(書式を適用して)代入する
printf -v ret "%s" "str"

fn() {
  # 補足: local ret のようなことをすると
  # 指定した変数に返すことができないので少し厄介な面がある
  printf -v "$1" "%s" "str"
}

fn ret # 戻り値は引数で指定した名前の変数に代入される

この方法のデメリットは、関数の中で変数を使わずに作ることが可能とはいえ、それにこだわるとコードが複雑になりがちがということです。

グローバル変数経由で返す

おそらくコマンド置換を使わないでやろうとした場合に一番に思いつくのがこの方法ではないでしょうか。考え方としてはシンプルな方法で、POSIX シェルの範囲の機能で実装が可能です。

fn() {
  RET="str"
}

fn # 戻り値は引数で指定した名前の変数に代入される
ret=$RET

この方法のメリットは eval が無いため任意のコードを実行できる危険性がなく「引数で指定した名前の変数に返す」方法より若干速いことです。デメリットは他とかぶらないグローバル変数が必要になり、呼び出し側のコードが関数呼び出しと変数代入の二つの処理が必要になり、関数を使うのが面倒だということです。グローバル変数を他とかぶらないようにする場合、他の用途で絶対使うなよと周知するか、被る確率を下げるために長い名前にするしかなく、周知は当然面倒で、長い名前はその都度長い名前を書くのが面倒です。

funsub: 関数置換を使う

対応シェル: bash 5.3以降、zsh 5.10以降、ksh93、mksh

一言で言えば「サブシェルを作らないコマンド置換」です。この方法はあまり知られていませんが、bash と zsh の両方で実装される予定の機能なので今後は知られていくでしょう。

対応シェル: bash 5.3以降、zsh 5.10以降、mksh
fn() {
  echo "str"
}

ret=${ fn; }
#     ↑  ↑ 前スペースが必要、後ろスペースは省略可能
#          後ろセミコロンはbashは必須、mkshとzshは省略可能
# 前スペースなしの ${fn} だと変数展開と区別がつかない
# fn は外部コマンドでも良いが、外部コマンドだと結局 fork & exec することになるので意味がない

# 直接コードを書くことも出来る
ret=${ echo "str"; }

「関数置換 (Function Substitution, funsub)」は、元々は ksh93 の機能で mksh でも以前から実装されていました。関数置換という呼び名は mksh のドキュメントからです。ksh93 では区別されずにコマンド置換と呼ばれているようです。bash でもシェル開発者の間では funsub が使われていますが、コミットログから「NoFork コマンド置換」が正式な用語になるかもしれません。ちなみに OpenBSD sh はコマンド名 ksh で実行できますが、その実体は pdksh なので対応していません。

valsub: 値置換を使う

対応シェル: bash 5.3以降、zsh 5.10以降、mksh

この方法もあまり知られていませんが、bash と zsh の両方で実装される予定の機能なので今後は知られていくでしょう。

対応シェル: bash 5.3以降、zsh 5.10以降、mksh
fn() {
  REPLY="str"
}

ret=${|fn;}
#     ↑  ↑ 後ろセミコロンはbashは必須、mkshとzshは省略可能
# ${|fn a b c;} のように引数を渡すことも可能
# echo "Hello, ${|fn;}" のような使い方ができる

# このような使い方もできるが便利な使い道はあるだろうか?
echo ${| REPLY=123; }

「値置換 (Value substitution, valsub)」は関数側で戻り値を REPLY 変数に入れると、${|...;} による関数呼び出しの値として使われるという方法です。サブシェルを作らないので当然速いです。

REPLY 変数とは元々拡張 POSIX シェルの read コマンドで変数を省略したときに暗黙的に使用される変数です。おそらく似たような用途として同じ変数を再利用したのでしょう。ちなみに REPLY 変数は値置換の中で値置換を使うのように再帰的に使用しても問題ないように実装されているようです。

$ seq 3 | while read; do echo "$REPLY"; done
1
2
3

zsh では ${|param| ... } 書き方で REPLY 変数に返すのではなく param で指定した変数名に返す方法も実装されています。(呼び出し側で戻りの変数名を指定するので使い買っては良くない気がする)

対応シェル: zsh 5.10以降
fn() {
  result="str"
}

ret=${|result|fn;} # result にも値が入る

余談ですが bash のソースコードには valsub と varsub (Variable Substitution?) が同じ意味を指す言葉として混在してるようです。もし varsub(変数置換)が正式名称になってしまうと変数展開との混乱は必死です。そうならないことを祈りたいですね。

もう一つおまけですが、ksh93 ではディシプリン (discipline) (他の言語で言えばプロパティの setter/getter のようなもの)の機能を使ってシェル関数から戻り値を返すことが出来るようですが、シェル関数の引数に相当するものを配列の添字 (subscript) として表すしかないようなので微妙ですね。

対応シェル: ksh93
typeset -A valsub
function valsub.get {
  .sh.value="foo${.sh.subscript}"
}
echo "${valsub[bar]}" # => foobar

値置換をPOSIXシェル用にエミュレート

私が値置換を気に入っている理由は、POSIXシェル用にエミュレートが可能だからです。値置換で呼び出す関数、なにか気が付きませんか?そうですこれは「グローバル変数経由で返す方法」の関数と同じなのです。

# 値置換で呼び出す関数
fn() {
  REPLY="str"
}

# グローバル変数経由で返す方法
fn() {
  RET="str"
}

mksh が値置換で変数名に REPLY を使用しているという点から「グローバル変数経由で返す方法」のグローバル変数の名前はどうしようという問題が解決します。REPLY 変数で戻り値で返す方法が値置換用の関数として一般的な書き方となれば、それを扱う事が可能な POSIX シェル用の関数を作るのも自然なものとなります。ここで「引数で指定した名前の変数に返す」テクニックを使います。

# 値置換用関数と全く同じ仕様の関数
fn() {
  REPLY="str"
}

# 実証コード POSIXシェル用の値置換の関数を呼び出せる関数
valsub() {
  # $1 に現在の REPLY の値をバックアップ(値置換用関数の再帰呼び出しの対応用)
  set -- "${REPLY:-}" "$@"
  _valsub "$@"
  eval "$2=\$REPLY && REPLY=\$1 && return $?"
}

# REPLY のバックアップと戻り値の変数名を取り除いて関数を呼び出す
_valsub() { shift 2 && "$@"; }

# valsub 関数は echo "${|fn;}" のような使い方ができない点で値置換よりは劣る
valsub ret fn

# もちろん値置換に対応しているシェルではこれで良い
# ret=${|fn;}

値置換の呼び出し側は異なりますが関数自体は再利用することができます。将来作ろうとしているシェル関数ライブラリでは内部的にこの方法を採用するかもしれません。トランスレータを作れば同じコードで POSIX シェルで動作させることも可能になるでしょう。

数学関数を定義する

対応シェル: ksh93専用、zsh専用

これは算術式でしか使えない方法のうえ、ksh93 と zsh のみが異なる文法で対応しているもので補足に近いのですが、このような方法もあるという紹介です。ksh93 では以下の方法で算術式用の数学関数を使用したり、ユーザー定義の数学関数を定義して呼び出すことが出来ます。

ksh93専用
# log10 は算術式の中だけで使える組み込みの数学関数
echo $(( log10(1000) )) # => 3

# ユーザー定義の数学関数
function .sh.math.add100 arg1 {
  .sh.value=$((arg1 + 100))
}

echo $(( add100(23) )) # => 123

zsh では以下の方法で算術式用の数学関数を使用したり、ユーザー定義の数学関数を定義して呼び出すことが出来ます。

zsh専用
# log10 は数学関数モジュールで定義されています。
zmodload zsh/mathfunc
echo $(( log10(1000) )) # => 3.

# ユーザー定義の数学関数
add100() (( $1 + 100 ))
functions -M add100 1

echo $(( add100(23) )) # => 123

ksh93 または zsh 用にガッツリ依存したシェルスクリプトを書くのでなければ使うことはないでしょう。ちなみに ksh93 は商用 Unix で Bourne シェルの後継として採用されていたシェルで拡張機能が多いシェルです。bash の拡張機能の多くは ksh93 から来ています(bashisms の大半は kshisms)。そのうち数学関数も bash に取り入れられるかもしれませんが、その前に ksh93 や zsh のように小数演算対応が必要でしょうね。

パイプ+read で受け取る

ここまでの方法とは少し発想が異なる方法です。また別にシェル関数である必要もありません。

コマンドの出力はそもそも read コマンドで受け取ることができます。

bash、ksh93、zshで動作する
shopt -s lastpipe # bash の場合のみ必要
date | read now
echo "$now"

上記のコードが、それ以外のシェルで動かないのは read コマンドがサブシェルで動作するからです。read コマンドだけではなく、それを含むグループを作ってやれば read コマンドで受け取れます。

どのPOSIXシェルでも動く
date | { 
  read now
  echo "$now"
}

# 別解(関数にするとわかりやすい)
fn() {
  read now
  echo "$now"
}
date | fn

実際の所コマンド置換を使うよりもこの方が良い場合も多々あります。特に複数行を出力する場合は、この構造を使ってループを回したほうが良いです。

どのPOSIXシェルでも動く
seq 10 | {
  total=0
  while IFS= read -r line; do
    echo "$line"
    total=$((total + line))
  done
  echo "$total" # => 55
}

# 別解(関数にするとわかりやすい)
fn() {
  total=0
  while IFS= read -r line; do
    echo "$line"
    total=$((total + line))
  done
  echo "$total" # => 55
}
seq 10 | fn
bash、ksh93、zshで動作する
shopt -s lastpipe # bash の場合のみ必要
total=0
seq 10 | while IFS= read -r line; do
  echo "$line"
  total=$((total + line))
done
echo "$total" # => 55

この記事のその他の手法は、上記のようなループ処理の中でさらに一行の文字列を修正したい時に適しています。大きなループの中で外部コマンドを呼び出すと大幅なパフォーマンス低下の原因になるからです。ループの中で cut コマンドを使ってカンマ区切りの行の各フィールドを切り出すようなコードを見かけますが、read コマンドは各フィールドを分割して読み取ったりすることができます。また変数展開を使っても同様のことができます、うまく使い分けるようにしてください。

おまけ

「◯◯展開」と「◯◯置換」の違い

展開と置換の違いはコマンド(シェル関数)や任意のコードを呼び出すかどうかです。

# 展開
echo "${value} ${value#prefix}" # 変数展開(パラメータ展開)
echo *.txt                      # パス名展開
cd ~/                           # チルダ展開
echo $((100 + 200))             # 算術式展開
echo {1..10}                    # ブレース展開

# 置換(コマンドやシェル関数や任意のコードを実行する)
echo $(date)                    # コマンド置換
echo ${ func; }                 # 関数置換
echo ${|func;}                  # 値置換
wc <(date)                      # プロセス置換

ちなみに変数展開とパラメータ展開の違いは、変数展開が ${変数} の場合で、パラメーター置換は ${パラメーター} の場合、パラメーターとは位置パラメータ($1) などや特殊パラメータ ($@)のように名前が数字や記号の場合という区別のはずです。それ以外は同じなのでだいたい区別されずに使われているようです。用語の使われ方は英語ドキュメントではどうも Parameter Expansion の方が優勢なのですが日本語だと変数展開の方が優勢な気がします。

記号が多くてわからんという人へ

今回さまざまな展開や置換が登場しましたが(いくつか例外がありますが)基本的なルールを知れば簡単に覚えることができます。まず $ はその場所に「値」を当てはめる時に使う記号です。今回の記事の例は戻り値を当てはめるものなので $ がつくものばかりです。

((i = i + 10))        # $ がつかないので計算するだけ 
echo $((i = i + 10))  # 計算結果の値を echo の引数に当てはめる

カッコの意味は次のとおりです。

{ ...; }    # ブレースはサブシェルなしの意味
( ... )     # 丸カッコはサブシェルありの意味
(( ... ))   # 丸かっこ二つは数値計算や数値に関するときに使う(サブシェルなし)
            # 例: for ((i=0; i<10; i++)) はループのインデックス変数が数値関係

あとは大体これらの組み合わせです。

おまけのおまけで { ...; } にセミコロンが必要な理由ですが、() はシェルのメタ文字なのに対して {} はアルファベットなどのただの文字と同じ扱いだからです。{} は文字としては特別な意味を持っておらず dodone と同じく予約語です。

さいごに

ということでシェル関数から「戻り値」を戻す方法のまとめでした。多分これで全部だと思いますが結構ありましたね。特定のシェルの拡張機能は見落としやすいです。気づいた時は mksh やりますねぇと言った感じでした。以下にまとまった情報があります。

この記事のもう一つの目的は comsub、funsub、valsub という用語の認知度を上げるためです。comsub はシェル開発者の間で結構普通に使われていて最初見た時ナニソレ?状態でした。気づけば単に Command Substitution の略ってだけなんですけどね。

12
16
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
12
16

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?