nikkie-ftnextの日記

イベントレポートや読書メモを発信

Bashのコマンド置換とパイプラインの違いを知る(同じものだと思い込んでいてハマりました)

はじめに

井の中の蛙大海を知らず、されど空の青さを知る。nikkieです。

Bashについて、考え違いをしていたことに気づきました。
コマンド置換とパイプラインの違いに気づいた件を綴っていきます。

※シェルは練度に伸びしろがあるので、間違ったことを書いている場合やもっとよいやり方がある場合は、ぜひ@ftnextまでご連絡ください。

目次

用語おさらい

コマンド置換とは

マスタリングLinuxシェルスクリプト 第2版』1.9 より

コマンドの実行結果を変数内に保管すること

2つの方法が紹介されます。
バッククォートを使うやり方と$()を使うやり方です。

$ var1=`pwd`
$ var2=$(pwd)

変数に保管した値はecho $var1のようにして確認できます。

変数を使わずにコマンド置換を直接別のコマンドに渡すこともできます(イメージcommand2 $(command1))。
リファクタリングテクニックの中の「変数のインライン化」っぽいなと思います

パイプラインとは

|を使ってコマンドをつなぐことと理解しています。

今回参照したのは以下です

例えば、ファイルの重複した行を除いて何行か見るのは、パイプラインで書けます1

$ cat tenshi.txt | sort | uniq | wc -l

誤解していたために、直面した事象

macOS 12.6.3のPC(zshがデフォルト)で、bashを立ち上げて動作確認しました。

以下のようなディレクトリ構造とします。

.
├── my_lib/
│   ├── __init__.py
│   └── awesome.py
└── tests/
    ├── __init__.py
    └── test_awesome.py

findコマンドを使って.pyファイルを一覧にできます。
Man page of FIND

$ find my_lib tests -name '*.py'
my_lib/__init__.py
my_lib/awesome.py
tests/__init__.py
tests/test_awesome.py

すべての.pyファイルのパスを、Pythonで実装したコマンドラインツールに渡すことを考えます。
動作環境は、Python 3.10.9です。

import argparse

parser = argparse.ArgumentParser()
parser.add_argument("file", nargs="+")
args = parser.parse_args()

コマンド置換ではうまくいきます。

$ python command.py $(find my_lib tests -name '*.py')
$ echo $?
0

パイプラインではうまくいきません2

$ find my_lib tests -name '*.py' | python command.py
usage: command.py [-h] file [file ...]
command.py: error: the following arguments are required: file
$ echo $?
2

コマンド置換では引数として渡るが、パイプラインは標準入力として渡る

https://www.gnu.org/software/bash/manual/html_node/Pipelines.html にもあります。

The output of each command in the pipeline is connected via a pipe to the input of the next command.

意訳 パイプラインの各コマンドの出力はパイプを介して次のコマンドの入力へと繋がれる

パイプラインの場合、続くコマンドの標準入力に渡っていますが、コマンドの引数としては渡っていません
だから、必須の引数fileがなく、引数のパースでエラーとなったのです。

Pythonスクリプトを使って確認

import sys

print(f"{sys.argv=}")
if not sys.stdin.isatty():
    print(f"{sys.stdin.read().splitlines()=}")

https://stackoverflow.com/a/46907341 を参考にしました。

コマンド置換

コマンド置換で渡してみましょう。

$ python command.py $(find my_lib tests -name '*.py')
sys.argv=['command.py', 'my_lib/__init__.py', 'my_lib/awesome.py', 'tests/__init__.py', 'tests/test_awesome.py']

コマンド置換で渡したファイルパスがsys.argvに渡っています。
コマンド置換では標準入力で渡していないので、sys.stdinについての出力はありません

パイプライン

パイプラインで渡してみます。

$ find my_lib tests -name '*.py' | python command.py
sys.argv=['command.py']
sys.stdin.read().splitlines()=['my_lib/__init__.py', 'my_lib/awesome.py', 'tests/__init__.py', 'tests/test_awesome.py']

findコマンドで列挙したファイルパスは、標準入力で渡っていることが分かります。

終わりに

「コマンド置換では動くのに、パイプラインで動かないのなんでだー!」とハマっての学びをアウトプットしました。
数時間前までの私よ、この2つ同じものじゃないですから〜!

  • コマンド置換では、コマンドの出力を引数として渡せていた
  • パイプラインは、コマンドの出力を、繋いだコマンドの入力に渡す(引数としては渡さない)

違いを理解したことで、「両方に対応できるコマンドラインツールは、argparseなどでどう実装するんだろう」というのが気になっています。
このあたりは既存のライブラリなどを覗いてゆっくり考えていきたいと思います。


  1. tenshi.txtの内訳は macOSのbash(とzsh)で先頭から何行かを除いて残りの行を出力する - nikkie-ftnextの日記 にあります。また今回、『マスタリングLinuxシェルスクリプト 第2版』12.2.2より、sort | uniqsort -uと書けることを知りました
  2. argparseのnargs="+"を使って、位置引数fileに1つ以上値が渡ることを確認しています。1つも渡っていないので、パースエラーとなりました。ref: https://docs.python.org/ja/3/library/argparse.html#nargs