getline の問題点

Ed Morton による getline の問題点の指摘 を翻訳してみました。少し強引なところはありますが、初心者が getline の罠にかかって comp.lang.awk に投稿して、また Ed を怒らすくらいなら読んでおいて損はないと思います。一方、awker であっても getline の回避方法を知っておいた方が良いので、ぜひ一読ください。ただし、翻訳が分かりにくいのは私のせいでもあるのですが、時々原文でも分かりにくい点があるので、両方を見比べて下さると助かります。

以下の要約は、getline の (間違った) 使用を繰り返さないため Ed Morton が書い
たものであり、主に Arnold Robbins の "Effective Awk Programming" 第 3 版 
(http://www.oreilly.com/catalog/awkprog3) のレビューや comp.lang.awk のレギ
ュラー陣の方々、Steve Calfee, Martin Cohen, Manuel Collado, Ju"rgen Kahrs,
Kenny McCormack, Janis Papanagnou, Anton Treuenfels, Thomas Weidenfeller, 
John LaBadie そして Edward Rosten に助言をいただいたものをベースにしていま
す。

Getline
-------
getline は正しく使用すれば素晴らしい (それらのリストは後述します) のですが、
通常は避けるのがベストです。その理由は以下のとおりです:

a) 人々は awk が入力を読み込むようにデザインされているような簡単な方法を学
ぶよりも、どうやってプログラムするかを考えてしまうとそのアイデアを押し通そ
うとします。C のプログラマが、新しい実例を学んだり言語構造をサポートしたり
するよりも、C++ で手続き型プログラムをやりたがるのと似ています。

b) すぐにそして将来的にも不快になる嫌らしい警告を多く発します。継続した議論
の中でそれらを記録して、getline がふさわしい時の説明をしていきます。

Arnold Robbins の "Effective Awk Programming" 第 3 版 (http://www.oreilly.
com/catalog/awkprog3) によると、この議論のために非常に多くのソースを提供し
ていると書かれています。

「getline コマンドは間違って使われているし初心者は使うべきではありません。
・・・時間をかけて十分に検討した後に getline コマンドに戻ったり学んだりして
・・・そうすることで、どのように awk が動作するかを理解するための良い知識に
なります」

変化形
------
以下の要約は getline の使用の 8 つの変化形で、それぞれどの変数がセットされ
るかを示しています。

    変化形                  変数のセット
    getline                 $0, ${1...NF}, NF, FNR, NR, FILENAME
    getline var             var, FNR, NR, FILENAME
    getline < file          $0, ${1...NF}, NF
    getline var < file      var
    command | getline       $0, ${1...NF}, NF
    command | getline var   var
    command |& getline      $0, ${1...NF}, NF
    command |& getline var  var

"command |& ..." の変化形は GNU awk (gawk) の拡張です。gawk では getline が
失敗した時に組み込み変数 ERRNO をセットします。

また、getline を呼び出す時には正しく使われることは稀であり (以下を参照)、も
しそうしたければ getline を呼び出すのに最も安全な方法を使う必要があります。

    if/while ( (getline var < file) > 0)
    if/while ( (command | getline var) > 0)
    if/while ( (command |& getline var) > 0)

これらの方法は組み込み変数に影響を与えませんし、正しく getline が成功したか
失敗したかをテストすることもできます。もし、レコードを分割されたフィールド
にする必要があるのであれば、"split()" を呼び出せば良いのです。

警告
----
使用する場合に getline のユーザーは以下の影に潜んでいる影響を知ってしかるべ
きです。

a) 通常 BEGIN セクションの中で FILENAME はセットされませんが、getline の非
リダイレクション呼び出しの場合にはセットされてしまいます。

b) "getline < FILENAME" は "getline" の呼び出しとは全く異なっています。2 番
目の形式は FINENAME から次のレコードを読み込みますが、1 番目のものは再び最
初から読み込みます。

c) 変数 var を伴わず getline を呼び出すと $0, $NF をアップデートしてしまい、
それに続く処理に対して異なった値になることで同じ条件 / アクションブロックで
優先して処理されてしまいます。

d) 前述の getline の変化形の多くは組み込み変数のいくつかをセットしますが全
てがそうなるわけではありません。したがって、必要としたり / 予期したりしてい
るものがセットされているかについては十分に注意する必要があります。

e) POSIX によって、表現に `$' 以外の挿入できない演算子が含まれている場合に
は `getline < expression' 不明瞭なものとなります。例えば、`getline < dir "
/" file' は、連接演算子が挿入されていませんので、不明瞭なものとなります。も
し他の awk の実装でも動作するようにプログラムするのであれば、`getline < (dir
"/" file)' と書かなければなりません。

f) POSIX 準拠の awk (例えば、gawk --posix) では getline の失敗 (例えば、読
み込み不可のファイルからの読み込み) はプログラムにとって致命的なものになり、
そうでなければ動作しません。

g) 非リダイレクトの getline は入力ファイルの遷移を扱う通常の簡単なルールを
破壊してしまいます:

   FNR==1 { ... start of file actions ... }

ファイルの遷移は getline によって引き起こされますので、FNR==1 はそれぞれの
リダイレクトされない (特定のファイル名からの) getline の後にチェックする必
要があります。例えば、それぞれのファイルの最初の行を表示したいとします:

$ cat file1
a
b
$ cat file2
c
d

通常は以下のようにします:

$ awk 'FNR==1{print}' file1 file2
a
c

しかし "getline" が不意に仕組まれていると、FNR==1 の評価をスキップさせる予
期しない連続があると 2 つ目のファイルの最初の行を表示しなかったりします。

$ awk 'FNR==1{print}/b/{getline}' file1 file2
a

h) BEGIN セクションで getline を使うことで行をスキップするのに複数ファイル
に適用するのが難しくなります。例えば、こんなデータの場合です・・・

   some header line
   ----------------
   data line 1
   data line 2
   ...
   data line 10000

こんな使用を考えたとしましょう・・・

   BEGIN { getline header; getline }
   { whatever_using_header_and_data_on_the_line() }

これは以下の代わりです・・・・

   FNR == 1 { header = $0 }
   FNR < 3 { next }
   { whatever_using_header_and_data_on_the_line() }

しかし getline 版では BEGIN セクションは一度、しかも最初のファイルの前しか
動作しないため、複数ファイルでは動作しませんが、非 getline 版はそのまま動作
します。これは getline コマンドそのものが直接プログラムに影響しない一般的な
ケースの一例ですが、getline でのアプローチを選択して最後までデザインしてし
まうのは理想的ではありません。

適用してよいもの
----------------
getline は以下のような場合に対して適切な解になります:

a) パイプからの読み込み、例えば:

         command = "ls"
         while ( (command | getline var) > 0) {
            print var
         }
         close(command)

b) コプロセスからの読み込み、例えば:

         command = "LC_ALL=C sort"
         n = split("abcdefghijklmnopqrstuvwxyz", a, "")

         for (i = n; i > 0; i--)
            print a[i] |& command
         close(command, "to")

         while ((command |& getline var) > 0)
            print "got", var
         close(command)

c) BEGIN セクションで、複数のサブシーケンスの入力ファイルを処理させながら参
照する初期データの読み込み、例えば:

         BEGIN {
            while ( (getline var < ARGV[1]) > 0) {
               data[var]++
            }
            close(ARGV[1])
            ARGV[1]=""
         }
         $0 in data

d) 入力ファイルを再帰的に減少させるパース、例えば:

     awk 'function read(file) {
            while ( (getline < file) > 0) {
                if ($1 == "include") {
                     read($2)
                } else {
                     print > ARGV[2]
                }
            }
            close(file)
        }
        BEGIN{
           read(ARGV[1])
           ARGV[1]=""
           close(ARGV[2])
        }1' file1 tmp

これらのケースは全て、awk のレコードを読むという通常のテキストプロセスを行
わせてメンテナンスを行う時にクリアであり、単純であり、エラーが出にくく、簡
単なものです。"c" の場合、BEGIN+getline というアプローチを用いるか、最初の
ファイル用に評価した後に awk の条件 / アクション部分でデータを集めるかはほ
とんどスタイルの選択の問題です。

"a" は UNIX コマンド "ls" を呼び出しカレントディレクトリの内容のリストを作
成し、同時に一行づつ結果を出力します。

"b" はアルファベットの文字を逆順で書き出し、一行に 1 つづつ UNIX の "sort"
コマンドの双方向パイプに渡します。パイプの最後に書き込みをクローズしますが、
これは sort が end-of-file を受け取れるようにするためです。こうしてデータを
ソートして、ソートされたデータを gawk のプログラムに戻します。全てのデータ
が読み込まれたら、gawk はコプロセスを停止して終了します。UNIX の "sort" ユー
ティリティをコプロセスの一部として使う際には sort は全ての入力データを出力
する前に読み込む必要があります。sort プログラムは gawk がパイプの最後を書き
込むまでは end-of-file を受け取りません。この際に他のプログラムは以下のよう
に動作させることができます:

      command = "program"
      do {
          print data |& command
          command |& getline var
      } while (data left to process)
      close(command)

第 2 引数なしで close() を呼び出すのは gawk 限定です。

"c" は awk の引数としてパスした最初のファイルの全てのレコードを配列として読
み込んで、引数としてパスした全てのサブシーケンスファイルとして最初のファイ
ルの中に現れたレコードにマッチするファイルのレコードを表示します (そして配
列 data に保管します)。これは別解として以下のように実装することもできます。

     # fails if first file is empty
     NR==FNR{ data[$0]++; next }
     $0 in data

または:

     FILENAME==ARGV[1] { data[$0]++; next }
     $0 in data

または:

     FILENAME=="specificFileName" { data[$0]++; next }
     $0 in data

または (gawk のみ):

     ARGIND==1 { data[$0]++; next }
     $0 in data

"d" は "入れ子ファイル (include subfile)" と呼ばれ行の全てを展開しますが、
結果を tmp ファイルに書き出します。ARGV[1] をリセットし (入力ファイルの最も
高いレベル)、ARGV[2] (tmp ファイル) をリセットしません。結果として tmp ファ
イルに格納されているので、awk に通常のレコードのパースを行わせます。もし必
要ないのであれば、" print" を標準出力にして他の tmp ファイルまたは ARGV[2]
への参照を取り除きます。この場合、$1 や $2 を使うことが便利ですが、これはプ
ログラムのほかの部分からの組み込み変数への参照がなく、getline は明示的な変
数を生成することなく使えます。この方法は同時に OS が開くことができるファイ
ルの数による再帰の深さで限定されます。

Tips
----
以下の tips は、前述のものを読み、getline に適切な用法を使っていることを気
づかせたり、getline を使った他の解法を探すのに助けになるでしょう:

a) もし通常の EOF の間または読み込みまたはオープンエラーを区別する必要があ
る場合、gawk の変数 ERRNO を使うか以下のようにコードを組みます:

         if/while ( (e = (getline var < file)) > 0) { ... }
         close(file)
         if(e < 0) some_error_handling

b) 読み込んだどんなファイルに対しても close() を忘れてはいけません。getline
の一般的な慣用と他のファイル / ストリームのオープンは以下のようになります:

         cmd="some command"
         do something with cmd
         close(cmd)

c) getline の一般的な誤用は入力ファイルの数行をスキップすることです。以下で
は前述のように実装するのに全ての getline を使わないようにすることを議論して
いきます。この議論は変数自体の最初の部分をつかって "and" 条件 (&&) で第 2 
項の変数にデクリメント演算子を置くことで「変数をゼロにデクリメント」すると
いう一般的な awk の慣用の上で組み上げていきます。変数がゼロでない場合にのみ
デクリメントを行っていきます:

i) あるパターンの後の N 番目レコードの表示:

    awk 'c&&!--c;/pattern/{c=N}' file

ii) あるパターンの後の N 番目レコード以外の全てのレコードの表示:

    awk 'c&&!--c{next}/pattern/{c=N}' file

iii) あるパターンの後の N 個のレコードの表示:

    awk 'c&&c--;/pattern/{c=N}' file

iv) あるパターンの後の N 個のレコード以外の全てのレコードの表示:

    awk 'c&&c--{next}/pattern/{c=N}' file

この例では、空行はなく出力は全て左寄せの欄で揃えられていて、あるパターンを
含むレコードの次の 2 番目のレコードとして $0 を出力したいとします、例えばそ
れが 3 の場合には:

$ cat file
line 1
line 2
line 3
line 4
line 5
line 6
line 7
line 8
$ awk '/3/{getline;getline;print}' file
line 5

うまく動作します。次に getline を用いない簡潔な方法を示します:

$ awk 'c&&!--c;/3/{c=2}' file
line 5

何をやっているのか一瞬ではハッキリと分からないと思いますが、ほとんどの awk
プログラマが良く学習している慣用であるとともに短く getline の警告を全て除く
ことができます。

さて、2 行目に代わってパターンの後の 5 番目を表示したいとします。その場合は
以下のようにします:

$ awk '/3/{getline;getline;getline;getline;getline;print}' file
line 8
$ awk 'c&&!--c;/3/{c=5}' file
line 8

例えば、全ての追加した getline の呼び出しを getline の版に加えて、非 getline
版の 2 から 5 までのカウンターのみを変更して対抗させてみます。実際的には、
多分 getline 版を完全にループを使うように書き換えてしまいます:

$ awk '/3/{for (c=1;c<=5;c++) getline; print}' file
line 8

それでも非 getline 版のように簡潔ではありませんし、全ての getline の警告や
カウンターの変更によるコードの再デザインが要求されます。

さて、入力ファイルに 4 という数字が出現したら "Eureka" という単語を表示して
みます。getline 版であれば、以下のようになります:

$ awk '/3/{for (c=1;c<=5;c++) { getline; if ($0 ~ /4/) print "Eureka!" }
print}' file
Eureka!
line 8

一方、非 getline 版は以下のように書くことができるでしょう:

$ awk 'c&&!--c;/3/{c=5}/4/{print "Eureka!"}' file
Eureka!
line 8

例えば、getline 版では、通常の awk の動作ループの外で自分がレコードを読んで
処理しているような事実の上で処理させなければいけませんが、非 getline 版では
通常の位置に "4" を条件として配置させて awk にはいつもどおり普通のレコード
処理をさせます。

実際、前述のものをじっくり見れば、無意識に getline 版にバグを入れたことを気
づくでしょう。もし 3 や 4 が同時に同じ行に出現した時にそれぞれの版でどうな
るか考えてみてください。非 getline 版は正しく振舞いますが、getline 版を修正
することは条件をどこかにコピーしないといけません。例えば、多分以下のような
ものです:

$ awk '/3/{for (c=1;c<=5;c++) { if ($0 ~ /4/) print "Eureka!"; getline }
if ($0 ~ /4/) print "Eureka!"; print}' file
Eureka!
line 8

さて、上述のもので入力ファイルが 5 行ない場合や 3 と 4 の両方を含んだ最後の
行がある場合にどのように挙動を示すか考えてみてください。例えば、正しく動作
するようにまだデザインしていき、入力スペースの制限で現れるバグを入れ込んで
いくことになるでしょう。

getline プログラムのデバッグ時の議論ではこうしたことを意図していないので、
これらのバグは無視させてもらい、3 という数字の後の 5 番目のレコードを表示す
る必要はなく、4 が出現するところで Eureka を表示させるものとします。getline
版では、3 の評価と getline 部分を取り除いて以下のようにします:

$ awk '{if ($0 ~ /4/) print "Eureka!"}' file
Eureka!

多分以下のように書き換えるのではないでしょうか:

$ awk '/4/{print "Eureka!"}' file
Eureka!

これは非 getline 版で 3 の評価とカウンターを取り除いたものです (例えば
"c&&!--c;/3/{c=5}"):

$ awk '/4/{print "Eureka!"}' file
Eureka!

例えば、小さな要求で getline のコードのほとんどの部分を変更することを要求さ
れてしまいますが、非 getline 版では必要最小限の微調整で完成してしまいます。

つまり、getline のケースで見てきたものは全ての小さな要求変更のために要求さ
れた重要な再デザインでした。大量の手書きのコードが要求され、開発の隙間に潜
むバグや入力スペースの制限のデザインの問題に挑みましたが、一方で非 getline
版は入力スペースの制限での挙動も見ても、いつも少ないコードであり、要求変更
での変更がずっと容易であり、より明確で、予測可能で、正しいものでした。