8つの言語で1つのツール(2つのコンソールアプリの出力を加工する類のツール)を書き比べた

id:eel3:20120116:1326675419 で書いたように、funclenというC言語用の行数計測ツールを使っている。単純にソースファイル中に定義されている関数名を列挙するだけにも使えるが、ある条件の時に誤った関数名を出力してしまう。なのでExuberant ctagsとgawkを組み合わせた関数名列挙用のツール(funcname)を作った。

funclenは以下のような出力を吐き出す。

D:\temp>funclen < cmd_parser.c
   26   eprintf
    1   cmd_parser_set_progname
   13   cmd_parser_init
    5   cmd_parser_reset
    8   iscmdsep
   11   skip_cmd_seps
   11   skip_comment
   16   iseol
  120   cmd_parser_read

D:temp>_

1行1関数で、行数に続いて関数名が出力される。

一方、funcname出力はこんな感じだ。

D:\temp>funcname cmd_parser.c
eprintf
cmd_parser_set_progname
cmd_parser_init
cmd_parser_reset
iscmdsep
skip_cmd_seps
skip_comment
iseol
cmd_parser_read

D:temp>_

この2つの出力は、どちらもファイル中に関数を定義した順番だ。なのでfunclenとfuncnameの出力を取得し、1行ごとに取り出してfunclenの出力中の関数名をfuncnameの出力で置き換えてしまえば、funclenが誤った関数名を出力してしまう問題が回避できそうだ。いささか乱暴だけど。

ただfunclenは関数名の問題以外にも色々と問題があって、関数を検出できなかったり出力すべきでないものを関数として出力してしまったりすることも多い。funcnameの内部で使用しているctagsの方が遥かに精度が高い。

なのでできれば出力を吐き出す前に両者の行数(つまり関数の数)が一致しているか否かぐらいは検証しておきたい。

方法は思いついたが、このツールの実装は/bin/shによる素のシェルスクリプトでは不可能ではなさそうなものの難しそうだ。/bin/shよりも強力なシェルやPerl等のスクリプト言語を使うべきだろう。外部ツールを実行しやすく、且つツールが標準出力に吐き出す結果を取得しやすく、取得した出力を一時的に保持するのが簡単で、更にテキスト処理に長けている言語だ。

言語の候補が幾つか思い浮かんだので、面白そうなので複数の言語で実装してみた。

bash 4.1.5

まずはbash*1。大概のLinuxディストリビューションのシェルも最近のMac OS XのシェルもcygwinやMSYSのシェルもbashなので、移植性とか深く考える必要がなければbashスクリプトを書いても罪にはならないと思いたい。

/bin/shでの実装が難しそうだと感じた理由の1つは、funclenやfuncnameの実行結果を一時的に保持していく方法に難があることだ。全出力を1つの文字列として変数に保持しておく方法ぐらいしか思い浮かばない。ちょっとしたハックで配列をエミュレートできるけど面倒だ。

bash 4.0なら配列もmapfile(readarray)もあるのでツールの実行結果を1行ずつ保持するのが容易だ。C言語風のforループもあるので2つのツールの実行結果に同時にアクセスするコードも簡単に書ける。

#!/bin/bash

usage() {
    echo "usage: `basename "$0"` <file>" > /dev/stderr
    exit $1
}

die() {
    echo "$1" > /dev/stderr
    exit 1
}

[ $# -eq 1 ] || usage 1
case "$1" in
    -h | --help)    usage 0 ;;
esac
[ -f "$1" -a -r "$1" ] || die "$1: invalid argument"

mapfile -t fl < <(funclen < "$1")
mapfile -t fn < <(funcname "$1")
[ ${#fl[@]} -eq ${#fn[@]} ] || die 'error: funclen may be failed'
for ((i = 0; i < ${#fl[@]}; i++)); do
    echo "${fl[$i]}" | sed 's/\w\+$/'"${fn[$i]}/"
done

ツールの実行結果をmapfileで保持する部分の書き方はネットから拾ってきた。普通にパイプでmapfileに流し込もうとすると、bashではパイプで結合した先がサブシェルで実行されるという罠に嵌ってしまう。というか嵌って時間を無駄にしてしまった。

この方法はProcess Substitutionといい、乱暴に言うとツールの出力を名前付きパイプ経由で取得するようなものらしい。

さて、bash 4.0以降の機能を使うことで比較的簡単に目的を達成できたのだが、1つ大きな問題がある。作業用PCのWindowsに入っているbashは3.1.17なのでmapfileは使えないのだort

Windows PowerShell 2.0

bashで書けたのならPowerShellならどうか? シェルであり且つスクリプト言語でもある点で両者はある意味競合していると言える*2。なので色々と気になる所だ。

Set-StrictMode -version Latest

function Show-Usage ($status = 0) {
    "usage: $(Split-Path $MyInvocation.ScriptName -leaf) <file>"
    exit $status
}

if ($Args.Length -ne 1) {
    Show-Usage(1)
}
switch ($Args[0]) {
    -help {Show-Usage(0)}
}
if (-not (Test-Path -literalPath $Args[0] -pathType Leaf)) {
    "$($Args[0]): invalid argument"
    exit 1
}

$fl = Get-Content $Args[0] | funclen
$fn = funcname $Args[0]
if ($fl.Length -ne $fn.Length) {
    'error: funclen may be failed'
    exit 1
}
for ($i = 0; $i -lt $fl.Length; $i++) {
    $fl[$i] -replace "\w+$", $fn[$i]
}

結構コンパクトに書けた。もっとも必要な機能のコマンドレットを探すのに時間がかかってしまったので、実装時間はコードの見た目から想像できる時間よりも遥かに長い。

WSHJScriptVBScript)と比べると外部ツールの実行や結果の取得を簡単に書けて便利だ。多分、この手の手軽さが欠けていたのがWSHの敗因なんだろう。JScriptVBScriptプログラミング言語としては悪くないのだけど、どう考えてもシェルスクリプト的ではないので。

gawk 3.1.7

bashPowerShellシェルスクリプトが続いたので、そろそろスクリプト言語で書いてみようと思う。トップバッターは元祖Unixスクリプト言語だと個人的に思っているawkなのだけど、処理系の都合でgawkだ。手元にgawk以外の処理系が無いので、気づかない内に独自拡張の機能を使っているかもしれない。

最初のバージョン。一応nawk相当で書いたつもり。

#!/usr/bin/gawk -f

function usage(status) {
    print "usage: funclen2.awk <file>" > "/dev/stderr"
    exit status
}

function die(msg) {
    print msg > "/dev/stderr"
    exit 1
}

BEGIN {
    opts["-h"] = 0
    opts["--help"] = 0

    if (ARGC != 2)
        usage(1)
    if (ARGV[1] in opts)
        usage(0)
    if (system("test -f \"" ARGV[1] "\" -a -r \"" ARGV[1] "\"") != 0)
        die(ARGV[1] ": invalid argument")

    cmd = "funclen < " ARGV[1]
    for (fl_size = 0; (cmd | getline) > 0; ++fl_size)
        fl[fl_size] = $0
    close(cmd)

    cmd = "funcname " ARGV[1]
    for (fn_size = 0; (cmd | getline) > 0; ++fn_size)
        fn[fn_size] = $0
    close(cmd)

    if (fl_size != fn_size)
        die("error: funclen may be failed")
    for (i = 0; i < fl_size; ++i) {
        sub(/[a-zA-Z0-9_]+$/, fn[i], fl[i])
        print fl[i]
    }
}

ここではtest(1)の力を借りて引数に指定されたファイルにアクセスできるか調べている。しかしよく考えたらtest(1)を使わなくても何とかできそうだ。

#!/usr/bin/gawk -f

function usage(status) {
    print "usage: funclen2.gawk <file>" > "/dev/stderr"
    exit status
}

function die(msg) {
    print msg > "/dev/stderr"
    exit 1
}

BEGIN {
    opts["-h"] = 0
    opts["--help"] = 0

    if (ARGC != 2)
        usage(1)
    if (ARGV[1] in opts)
        usage(0)
    if ((getline < ARGV[1]) < 0)
        die(ARGV[1] ": invalid argument")
    close(ARGV[1])

    cmd = "funclen < " ARGV[1]
    for (fl_size = 0; (cmd | getline) > 0; ++fl_size)
        fl[fl_size] = $0
    close(cmd)

    cmd = "funcname " ARGV[1]
    for (fn_size = 0; (cmd | getline) > 0; ++fn_size)
        fn[fn_size] = $0
    close(cmd)

    if (fl_size != fn_size)
        die("error: funclen may be failed")
    for (i = 0; i < fl_size; ++i) {
        sub(/\w+$/, fn[i], fl[i])
        print fl[i]
    }
}

という訳でtest(1)を使う代わりに「getlineで1行読み出せるか?」でテストするように書き換えてみた。あと地味に正規表現gawk拡張のものにしてある。

gawkでも比較的簡単に目的を達成できたが、文法上BEGIN節が必要だったりgetlineを使ったりという点で無理をしている感が漂う気がする。なるほど、汎用的なスクリプト言語の地位をPerlを始めとする後発の言語が占めている訳だ。gawkの本分はテキストレコードの処理だということか。

2012/05/29追記

そういえばコマンドの実行と結果の取得部分は関数化できる。

#!/usr/bin/gawk -f

function usage(status) {
    print "usage: funclen2.gawk <file>" > "/dev/stderr"
    exit status
}

function die(msg) {
    print msg > "/dev/stderr"
    exit 1
}

function exec(cmd, lines,   size) {
    for (size = 0; (cmd | getline) > 0; ++size)
        lines[size] = $0
    close(cmd)

    return size
}

BEGIN {
    opts["-h"] = 0
    opts["--help"] = 0

    if (ARGC != 2)
        usage(1)
    if (ARGV[1] in opts)
        usage(0)
    if ((getline < ARGV[1]) < 0)
        die(ARGV[1] ": invalid argument")
    close(ARGV[1])

    fl[0] = ""
    fl_size = exec("funclen < " ARGV[1], fl)

    fn[0] = ""
    fn_size = exec("funcname " ARGV[1], fn)

    if (fl_size != fn_size)
        die("error: funclen may be failed")
    for (i = 0; i < fl_size; ++i) {
        sub(/\w+$/, fn[i], fl[i])
        print fl[i]
    }
}

どうもawkの関数の引数は配列だけ取り扱いが異なるようだ。C言語の配列と同様に、引数で渡された配列に対して関数内で操作を行うと、呼び出し元に戻った時にその操作が反映されている*3

Perl 5.14

gawkの次はUnixの代表的スクリプト言語の座をawkから掻っ攫っていったPerlだ。相変わらずPerlは苦手で且つ殆ど使っていないので、Perlの素人のコードだと明言しておく。

#!/usr/bin/perl

use strict;
use warnings;

use 5.10.0;
use File::Basename;

sub usage {
    my $bn = basename($0);
    say "usage: $bn <file>";
    exit $_[0];
}

my %opt = ('-h' => 0, '--help' => 0);

scalar(@ARGV) == 1 or usage 1;
exists $opt{$ARGV[0]} and usage 0;
(-f $ARGV[0] && -r $ARGV[0]) or die "$ARGV[0]: invalid argument, stopped";

my $tmp;
chomp($tmp = `funclen < $ARGV[0]`); my @fl = split(/\n/, $tmp);
chomp($tmp = `funcname $ARGV[0]`);  my @fn = split(/\n/, $tmp);
$#fl == $#fn or die 'error: funclen may be failed, stopped';

for (0 .. $#fl) {
    $fl[$_] =~ s/\w+$/$fn[$_]/;
    say $fl[$_];
}

いつも思うのだけど、Perlのコードはawk的でありC言語的でありシェルスクリプト的だ。今回発見したファイル操作関数の -f とか -r *4ってどう見てもtest(1)だよなあというかこの名前で演算子じゃなく関数という所に意表を衝かれたというか。

あと初めてsayを使ったのだけど、これの源流ってどこだろう? 出力にsayを使う言語というとREXXぐらいしか知らない。

Ruby 1.9.3

gawkPerlときたら次はRuby。実は今回のツールを最初に実装した時の言語はRubyだ。リファレンスと睨めっこして書いているとはいえ、最初にRubyを選択する程度には馴染みがある言語だ。

#!/usr/bin/ruby -w

def usage(status)
  warn "usage: #{File.basename($PROGRAM_NAME)} <file>"
  exit status
end

usage false unless ARGV.size == 1
usage true  if ['-h', '--help'].include?(ARGV[0])
unless File.file?(ARGV[0]) && File.readable?(ARGV[0])
  abort "#{ARGV[0]}: invalid argument"
end

fl = `funclen < #{ARGV[0]}`.chomp.split("\n")
fn = `funcname #{ARGV[0]}`.chomp.split("\n")
unless fl.size == fn.size
  abort 'error: funclen may be failed'
end
fl.each_index do |i|
  puts fl[i].sub(/\w+$/, fn[i])
end

多分に慣れの影響もあると思うけど、Rubyで書いたコードが最も短い。それでいてシンプルで読みやすい(短くするために読みやすさが犠牲になってない)気がするが、まあ読み慣れた言語だからそう感じるだけだろう。

Tcl 8.4

gawkPerlRubyときたら次はPythonだと常識的に考えて思うのだけど、残念ながらPythonのコードを書いたこともなければ作業用のWindowsPythonの処理系を入れてもいない。多分Ubuntuには依存関係の都合で処理系が入っていると思うけど。

なのでTclで書いた。後悔はしていない。

#/usr/bin/tclsh

set auto_noexec 1

proc usage {status} {
    global argv0
    puts stderr "usage: [file tail $argv0] <file>"
    exit $status
}

proc die {msg} {
    puts stderr $msg
    exit 1
}

if {$argc != 1} {usage 1}
set opt [lindex $argv 0]
switch -- $opt {
    -h     {usage 0}
    --help {usage 0}
}
if {! ([file isfile $opt] && [file readable $opt])} {
    die "$opt: invalid argument"
}

set fl [split [string trimright [exec funclen < $opt]] \n]
set fn [split [string trimright [exec funcname $opt]] \n]
if {[llength $fl] != [llength $fn]} {
    die "error: funclen may be failed"
}
foreach i $fl j $fn {
    puts [regsub {\w+$} $i $j]
}

Tclはシェルスクリプト並みに「何でも文字列」で且つLisp程でないけどリスト処理に長けていて、しかも「何でもコマンド」な為に前置記法的なシンタックスが多い所がまたLisp的な雰囲気を微妙に感じさせるという癖のある言語だけど、慣れれば意外と悪くない。

今回の目的に適した機能も持っていた。execとか、foreachで複数のリストを同時に舐めることができるとか。

ごく小規模なツールならTclで書くのも有りな気がしてきた。でもマイナーなんだよなあ。

Gauche 0.9.2

Tclで書いたので、リスト操作の本家であるLispでも書くべきだろう。

LispといってもどのLisp?」という所から始まり、処理系も山ほどある中でどれを選択するべきか――私の乏しい知識に基づく結論は「テキスト処理ならGaucheだろう」だった。

GaucheというかLispCommon LispScheme、その他)は少ししか触ったことがない。当然ながら小規模とはいえ実用的なツールを書くのも初めてだ。

#!/usr/bin/gosh

(use gauche.process)
(use srfi-1)
(use file.util)

(define (usage status)
  (format (current-error-port)
          "usage: ~a <file>\n" *program-name*)
  (exit status))

(define (die msg)
  (display msg (current-error-port))
  (newline (current-error-port))
  (exit 1))

(define (main args)
  (if (not (= (length args) 2))
      (usage 1)
      (let1 opt (second args)
            (cond ((member opt '("-h" "--help")) (usage 0))
                  ((or (not (file-is-regular? opt))
                       (not (file-is-readable? opt)))
                   (die (format "~a: invalid argument" opt)))
                  (else (let ((fl (process-output->string-list (string-append "funclen < " opt)))
                              (fn (process-output->string-list (string-append "funcname " opt))))
                          (if (not (= (length fl) (length fn)))
                              (die "error: funclen may be failed")
                              (for-each print (map (lambda (x y) (regexp-replace #/\w*$/ x y)) fl fn))))))
            0)))

リファレンスマニュアルと睨めっこしながら書いた最初のバージョン。探せば結構便利な関数が用意されているのだと感心した。

最初のバージョンは随分と右に長くなった気がするので、途中で処理を打ち切る部分が多いことを利用して少し書き直してみた。

#!/usr/bin/gosh

(use gauche.process)
(use srfi-1)
(use file.util)

(define (usage status)
  (format (current-error-port)
          "usage: ~a <file>\n" *program-name*)
  (exit status))

(define (die msg)
  (display msg (current-error-port))
  (newline (current-error-port))
  (exit 1))

(define (main args)
  (or (= (length args) 2) (usage 1))
  (let1 opt (second args)
        (and (member opt '("-h" "--help")) (usage 0))
        (or (and (file-is-regular? opt)
                 (file-is-readable? opt))
            (die (format "~a: invalid argument" opt)))
        (let ((fl (process-output->string-list (string-append "funclen < " opt)))
              (fn (process-output->string-list (string-append "funcname " opt))))
          (or (= (length fl) (length fn))
              (die "error: funclen may be failed"))
          (for-each print (map (lambda (x y) (regexp-replace #/\w*$/ x y)) fl fn))
          0)))

うーん、もしかして手続き型っぽくなった?

初めてGaucheでツールを書いてみたけど、なるほど、Lisp系の言語に慣れているのならPerlRubyの代わりにGaucheという選択肢は十分あるような気がする。

2012/05/29追記

色々と為になる情報を頂いたので少しだけ手直し。

#!/usr/bin/gosh

(use gauche.process)
(use srfi-1)
(use file.util)

(define (usage status)
  (exit status "usage: ~a <file>" *program-name*))

(define-syntax die
  (syntax-rules ()
    [(die fmt opt ...)
     (exit 1 fmt opt ...)]))

(define (main args)
  (unless (= (length args) 2) (usage 1))
  (let1 opt (second args)
        (when (member opt '("-h" "--help")) (usage 0))
        (unless (and (file-is-regular? opt)
                     (file-is-readable? opt))
                (die "~a: invalid argument" opt))
        (let ([fl (process-output->string-list '(funclen) :input opt)]
              [fn (process-output->string-list `(funcname ,opt))])
          (unless (= (length fl) (length fn))
                  (die "error: funclen may be failed"))
          (for-each print (map (^[x y] (regexp-replace #/\w*$/ x y)) fl fn))))
  0)

let-argsを使うと他の言語のコードもオプション解析用のライブラリ(Rubyのgetoptlongとか)を使って書き直したくなるので保留。使い方は何となく分かったので今度使おう。

ちなみにdieの実装がマクロになった理由は特にない。単に試しにマクロを書いてみたかっただけ。

REXX-Regina 3.5

おまけ。

個人的にシェルスクリプトスクリプト言語の中間に位置するような気がしているREXXなんぞ使ってみた。

処理系はRegina。Open Object REXXWindows以外のOSに処理系を入れるのが辛いし*5、NetREXXはまだ動かすのに一苦労な状態なので、消去法でReginaを選んでいる。

#!/usr/bin/rexx -a

select
  when arg() ~= 1 then
    call usage 1
  when arg(1) == '-h' | arg(1) == '--help' then
    call usage 0
  when linein(arg(1)) == '' then
      call die arg(1)': invalid argument'
  otherwise
    file = arg(1)
end

i = 1
'funclen <' file '| rxqueue'
do while queued() > 0
  parse pull fl.i
  i = i + 1
end
fl.0 = i - 1

i = 1
'funcname' file '| rxqueue'
do while queued() > 0
  parse pull fn.i
  i = i + 1
end
fn.0 = i - 1

if fl.0 ~= fn.0 then
  call die 'error: funclen may be failed'
do i = 1 to fl.0
  parse var fl.i size name
  say changestr(name, fl.i, fn.i)
end

return 0

usage: procedure
  parse arg status
  say 'usage: funclen2.rexx <file>'
  exit status

die: procedure
  parse arg msg
  say msg
  exit 1

他の言語で書くよりも長くなったけど、それでも比較的簡単に実装できたと思う。正規表現が使えなくても大丈夫だった。

REXXは悪くない言語だとおもうけど、しかし最近のスクリプト言語に慣れた人にはちょっと辛い気がする。ジェネレーションギャップというかカルチャーショックというか、他の言語で培われた常識から外れた部分があるので。

総評(という名の一言感想)

  • bash侮り難し。
  • PowerShellには屈折した愛情を抱いてしまう。
  • gawkは奥が深い。
  • やっぱりPerlは苦手。
  • Rubyは書きやすい。
  • Tclも意外とイケる?
  • Gaucheもいいね。
  • REXX(というかRegina)の世界に中々慣れない私。

*1:といっても実は一番最初に実装したのはbashではなくRubyによる版だけど……。

*2:もっともOSが違うのだけど。bashPowerShellOSを操作するシェルなので、OSが違う時点で単純に両者を比較することはできないと思う。

*3:但しC言語では文字列でも配列と同様の結果となるけどawkではそうはならない。C言語では「文字列 == ヌル終端された文字の配列」だが、awkでは文字列と配列は別物だ。

*4:この時点で私のPerl素人っぷりが分かると思う。

*5:Linuxで使おうにもディストリのリポジトリには入ってないし、公式サイトのバイナリは最新バージョンのディストリに対応していない。最近のバージョンではMac OS XSolaris用のバイナリも無くなった。