Perl
ShellScript
Bash
awk

逆順出力 tac と tail -r

More than 3 years have passed since last update.

逆順出力 tac と tail -r

ポータブルじゃない問題点

ファイルの末尾から先頭に向かって行を出力したいとき(cat の逆)、tac コマンドという便利なものがあります。
名前も cat を逆さまにしたイカしたやつです。

$ tac file
333
222
111
$ seq 1 5 | tac
5
4
3
2
1

しかし実は難点があり、tac は Linux(GNU coreutils)にしかないことです。つまり Mac では使えません。その代わり、tail -r で代用できます。tail はファイル末尾から数行を正順で出力するコマンドですが、-rreverse)フラグによってそれを反転、要するに逆順に出力させることができます。しかし、これまた不幸なことに、BSD 系の tail でしか使用できません。Linux には -r フラグがないのです。つまり、Linux では使えません。

  • Linux では tactail -r は使えない)
  • Mac では tail -rtac は使えない)

ということは、ポータブルなシェルスクリプトが書けません。which などで条件分岐すれば書けなくもないですが、どちらも使えない状況ではどうしようもありません。

if which tac >/dev/null; then
    tac="tac"
else
    tac="tail -r"
fi

自分で実装

となると自分で書いたほうがはやいです。要件はファイル内容の逆順出力です。簡単です。

同じことを考えている人もいるようで、すでに様々な先行実装があります。

参考: How can I print lines from file backwards (without using “tac”)?

shell script

#!/bin/bash
# @(#) substitutes for tac(1) or `tail -q -r`
/bin/ex -s ${@+"$@"} <<-EOF
g/^/mo0
%p
EOF

ex を使ったシェルスクリプトです。ファイル名を引数に逆順出力を実現しています。つまり標準出力を受け付けません。

$ ./tac-ex file
333
222
111
$ 
$ cat file | ./tac-ex

sed

#!/bin/bash
# @(#) substitutes for tac(1) or `tail -q -r`
sed '1!G;h;$!d' ${@+"$@"}

sedのスクリプトではなくシェルスクリプトである理由は、

Ubuntu と Mac とで sed(1) のパスが異なる上に、env を使おうとすると shebang の解析にて /usr/bin/env sed -fsed -f"sed -f" という1つの文字列としてパースしてしまい実行できない環境が多いからだ。

しかし、この sed による tac 実装には問題点があります。

この sed(1) による実装も含めて、tac(1) の代用品を実装しようとすると、基本的には入力データをほぼ丸ごとメモリにコピーする実装になる。入力がローカルディスク上のファイルなら seek して後ろから読むことができるが、標準入力や名前付きパイプでは不可能だ。

上の ex と同じ点です。

perl

#!/bin/bash
# @(#) substitutes for tac(1) or `tail -q -r`
perl -e 'print reverse <>' ${@+"$@"}

この方法は、ファイル名にも標準出力にも対応しています。ただし、このやり方は一端メモリーに全ファイルを読み込むので非効率ですね。

$ ./tac-perl file
333
222
111
$ 
$ cat file | ./tac-perl
333
222
111

冒頭のリンク(perl - tac を一行で)では、cpam モジュールなどを用いた高速実装も紹介されています。

awk

#!/bin/bash
# @(#) substitutes for tac(1) or `tail -q -r`
awk '{a[i++]=$0} END {for (j=i-1; j>=0;) print a[j--] }' ${@+"$@"}

これも、同様にファイル名にも標準出力にも対応しています。しかし、perl 同じ問題もあります。それを更に効率化したものが次の tac-awk です。

#!/bin/bash
# @(#) substitutes for tac(1) or `tail -q -r`
awk '
BEGIN {
    sort_exe = "sort -t \"\034\" -nr"
}

{
    printf("%d\034%s\n", NR, $0) |& sort_exe;
}

END {
    close(sort_exe, "to");

    while ((sort_exe |& getline var) > 0) {
        split(var, arr, /\034/);

        print arr[2];
    }
    close(sort_exe);
}' ${@+"$@"}

落とし所

様々な実装を見てきました。手軽さや汎用性(枯れている具合→どの環境でも使えるか)を考えると、

perl -e 'print reverse <>'

や、

awk '{a[i++]=$0} END {for (j=i-1; j>=0;) print a[j--] }'

くらいがちょうどいいかもしれません。perlawk も枯れていますし、行数は増えますが書き方次第ではメモリーを考慮した高速実装も出来ます。また、どちらもファイル名・標準入力に対応しているので tac らしい動きもバッチリです。

またシェルスクリプトとの親和性も高いので、簡単にスクリプト内に埋め込めます。

reverse() {
    perl -e 'print reverse <>' ${@+"$@"}
}

上のようにシェル関数化させておけば使いまわせて便利です。

$ cat file | reverse
333
222
111