gnuplotのusingの仕様がヒドいのでソースコードを読んでみた (ver 4.6.6)

注意

以下の文章は全てgnuplot version 4.6.6の仕様とソースコードに基づいている。version 5系列では事情が変わっている可能性があるが、そこに関しては未調査である。

背景

gnuplotの"using"キーワードの仕様はヒドい。

usingキーワードはデータファイルを読み込む時にプロットするカラムを指定したり、読み込んだデータに演算を加えたりする機能である。

例えば、

plot "datafile" using 1:(($2 + $3) / 2.0) with linespoints

ならば、datafileの第1カラムの値がx軸に、第2カラムと第3カラムの平均値がy軸に対応づき、折れ線グラフが描かれる。

ここまでなら問題ないが、gnuplotのプロットスタイルには多くの値を入力として要求するものがある。例えば、箱ひげ図を描くcandlesticksスタイルでは、5つの値をusingで与える必要がある。

plot "datafile" using <x>:<box_min>:<whisker_min>:<whisker_high>:<box_high> with candlesticks

覚えられるだろうか? 自分はこれを覚えることができないのでcandlesticksを使うたびにhelpを見るハメになる。そもそもbox_min, whisker_min ...という順番は納得がいかない。やるならwhisker_min, box_min, box_high, whisker_highだろう。値の大小はその順になるのだから。もっとも、この奇妙な順番はcandlesticksが株価のプロットを想定して導入されたからかもしれない。

ただ、問題はこれだけではない。


一部のプロットスタイルでは、usingで取り出した値を使ってポイント毎にプロットスタイルを変化させる機能を備えている。これを使うと、例えばデータの値に応じてポイントサイズを変えるようなプロットが可能となる。そのような場合、スタイルを設定するためのデータがusingの追加カラムからロードされる。

さて、では次のようなプロットコマンドを書いた時、

plot "datafile" using 1:2:3:4 with linespoints linecolor variable pointsize variable

カラム3とカラム4のどちらがlinecolorでどちらがpointsizeとなるか?隅々まで読んだわけではないが、この答えはドキュメントのどこにも書いていないと思う。ちなみに正解はpointsizeがカラム3でlinecolorがカラム4である。これはplotコマンド中のキーワードの出現順に依存しない。

後述のように、このケースはlinecolor variableが特殊ケースなので、仕組みを知ればそれほど深刻な問題ではない。ただ、将来的にpointtype variableとかlinewidth variableとかやりたくなったらどうするつもりなのだろうか。

ここまで、usingがヒドいことを述べたが、同様にeveryキーワードもヒドい。everyキーワードはデータファイルからプロット対象とするポイントを抜き出すための機能だが、以下のようなフォーマットを持つ。

plot "datafile" every <point_incr>:<block_incr>:<start_point>:<start_block>:<end_point>:<end_block>

やはりこれも覚えられない。

なお、デフォルト設定でよいものは適宜省略可能であるが、":"が終端に来てはいけないという謎の制約条件がある。

解決案

この問題はプログラミングでよくある「関数の引数が多すぎると呼び出し時の実引数の意味がよく分からなくなる」問題によく似ているので、よく似たアプローチで解決できる可能性が高い。つまり、named parameterを使えるようにするとよいと思う。

gnuplot本体にnamed parameterを導入するならば、例えばこんな感じで書けるといいだろう。

plot "datafile" using x(0):whisker_min(2):box_min(($3+$4)/2.0):box_high(($5+$6)/2.0):whisker_high(7):xtic(1)

この方法のいいところは既存の特殊カラムであるxticなどと同じフォーマットを使えるということだ。うまくいけばxticなどの指定と順番を混ぜても問題なくなるかもしれない。

ということで、現状のusingがどのように実装されているか、また、上記の方法が実現できるのかどうかを調べるために、gnuplotソースコードを読んでみた。

雑感

読んでみてまず気がついたのが、gnuplotでは非常に多くの状態変数がグローバル変数で管理されているということだ。一部の変数はstatic宣言されたグローバル変数なので正確にはファイルスコープ変数とでも言うべきものだが、それを除いてもなお多くの変数がヘッダファイルでextern宣言されており、どこからでも参照できるようになっている。

また、副作用を持つ関数も非常に多い。プロトタイプ宣言からではほとんど入出力がないように見える関数も、中でグローバル変数を操作したりする。慣れてくるとその関数がどういう副作用を持つのかなんとなく予想がつくようにはなるものの、これはなかなか心臓に悪い。

ただ、C言語のコードにありがちな、難解なマクロや入り組んだプリプロセッサスイッチは意外と少なく、初見でもそれなりに読める。

plotコマンドが実行されるまでの流れ

gnuplotスクリプトを読ませてすぐにplotコマンドを実行させる場合、以下のような処理をたどる。

  • main()関数 (plot.c)
  • load_file()関数 (misc.c)
  • do_line()関数 (command.c)
  • command()関数 (command.c)
  • plot_command()関数 (command.c)
  • plotrequest()関数 (plot2d.c)
  • eval_plots()関数 (plot2d.c)

load_file()関数はスクリプトファイルをゴリゴリ読み込む関数である。この関数は読んだデータをグローバル変数gp_input_lineに格納した後、scanner()関数で字句解析をしているように見える。その結果はグローバル変数tokenに格納される。

load_file()関数はステートメントを1つ読み込むとdo_line()を呼び、do_line()command()関数を呼ぶ。この時、グローバル変数c_tokenに現在読んでいるトークンの位置が保持される。

gnuplotスクリプトのコマンド名やキーワードはtables.cにリストアップされている。command()関数は現在のトークンでコマンドテーブルをlookupし、得られた関数を実行する。plotコマンドの場合、plot_command()関数が呼ばれ、最終的にeval_plots()関数が実行される。

eval_plots()関数はかなり長い関数であり、plotコマンドの処理を担当する。

plotコマンドとusingオプションのパース

eval_plots()関数はまずplotコマンドのトークン列をパースし、プロットの準備を行う。コメントにも書かれているが、この関数はトークン列を2度パースする。1度目ではデータファイルを読み込み、2度目でプロット関数を読み込む。これは、データファイルのデータを読まないとautoscaleが決まらないからだという。

プロットデータセットを表すオブジェクトはstruct curve_points構造体である。eval_plots()関数はこの構造体をアロケートし、df_open()関数(datafile.c)を呼ぶ。df_open()関数は、データファイル用のプロットオプションをパースし、データファイルをオープンする。

usingオプションのパースを行うのはdf_open()関数から呼ばれるplot_option_using()関数である。この関数はファイルスコープ変数use_spec配列にusingのパース結果を格納する。パースしたusingオプションのカラム数はグローバル変数df_no_use_specsに格納される。

use_spec配列の型はstruct use_spec_sであり、以下の構造を持つ。

struct use_spec_s {
    int column;
    int expected_type;
    struct at_type *at;
};

columnメンバはよく分からないが、usingスペックとして単純なカラム番号が使われた場合はそれが格納される。expected_typeメンバはこのカラムのデータ型などを示し、例えばxticlabelsで指定されたカラムはこのメンバが特別な値となる。このメンバはint型になっているが、正確にはenum COLUMN_TYPE型だと思う。

atメンバの型struct at_typeはかなり込み入ったデータ構造である。あまり詳しく調べてないが、usingスペックとして式を記述した場合、その構文木がこのメンバに格納されているように見える。なお、"at"とは"action table"の略のようだ。

plot_option_using()関数は以下の場合分けを行ってusingオプションのカラムを一つずつパースしていく。

  • カラムが省略の場合
  • カラムに()で式が書いてある場合
  • xticlabels()などの場合
  • 文字列リテラルでデータファイル中のカラム名が書いてある場合
  • 整数リテラルでカラムインデックスが書いてある場合

xticlabels()などを読んだ場合、df_no_use_specsはインクリメントされない。そのため、df_no_use_specsはこれらの特殊カラムを含まないし、これらの特殊カラムがusingスペックの途中に来た場合におかしな動作になる。

プロットデータの読み込み

df_open()関数が終わり、eval_plots()関数に戻ると、共通オプション(smooth, axes, title, withなど)がパースされる。その後、各種のスタイルのチェックとデフォルト値の生成を行った後、get_data()関数によってプロットデータを読み込む。

get_data()関数はまず、プロットスタイル(あとsmoothスタイル)とdf_no_use_specs変数の値を突き合わせて問題がないかをチェックする。この時点で何かがおかしかった場合、直ちにエラーとする。

チェックが終わると、df_readline()関数の呼び出しループに入る。この関数はdf_readascii()あるいはdf_readbinary()を呼び、1行分のデータをローカル配列vに格納する。

df_readascii()関数では、データファイルから1行ずつ読み込み、usingの演算元データを生成する。これはdatafile.cのファイルスコープ変数df_columnに格納される。

データファイルの読み込みは、usingオプションでscanfフォーマットが指定されていた場合、単純にsscanf()関数によってパースされる。さもなければdf_tokenize()関数で演算元データがロードされる。

その後、df_column変数にロードされたデータをuse_specを使って適宜演算しつつ、結果となるカラム配列vを作る。

usingオプションでxticlabelsなどが指定されていた場合、その処理はここで行われる。まずticlabelエントリに書かれていた式を評価してラベル文字列を取得する。次に、軸(x,x2,y,y2,z,cb)に応じてそのラベルを置く座標を設定する。これはxpos = v[axcol]という形で評価済みの値から取り出している。このaxcol変数は軸ごとにハードコードされており、例えばxticなら0, yticなら1となっている。最後に、add_tic_user()関数を呼び出してラベルをセットする。

df_readline()関数は正常に1行分のデータを読み込むと、戻り値として読み込んだカラムの数を返す。呼び出し元のget_data()関数では、これをローカル変数jで受ける。なお、ローカル変数iは現在読み込んでいるpointのインデックス、だと思う。

ここで、linecolor variableが有効である場合、配列vの最後尾の値をこっそり抜き出してcolor値にしている。その後、変数jの値(読み込んだカラムの数)をswitchにかけ、エラーチェックと値の取り出しを行う。ここの処理では、プロットスタイルごとに配列vの値をゴリゴリ参照して計算している。最終的な計算結果はstore2d_point()関数によってcurrent_plot構造体に格納されているようだ。

store2d_point()関数のプロトタイプはこんな感じ。

static void store2d_point(
  struct curve_points *current_plot,
  int i,                      /* point number */
  double x, double y,
  double xlow, double xhigh,
  double ylow, double yhigh,
  double width)               /* BOXES widths: -1 -> autocalc, 0 ->  use xlow/xhigh */

スタイルによってはxlowxhighは必要ないが、ここではスタイルと無関係にフルスペックの値を登録する。

引数widthはスタイルによってかなり場当たり的に使い回されている。例えば、pointsスタイルでpointsize variableが有効の場合、widthはpoint sizeの意味になり、vectorsスタイルでarrowstyle variableが有効の場合、widthはarrowstyleの意味となっている。

さらに、store2d_point()関数内では、width引数の値はstruct coordinate型のzメンバに格納される。

このように、ポイントの拡張フィールドwidthはプロットスタイルによって全く異なる意味で使われており、かなり危険な匂いを感じる。

プロットの描画

最終的にeval_plots()関数はgraphics.cのdo_plot()関数を呼び出し、プロットを描画する。do_plot()関数は軸、枠、タイトルなどを描画した後、スタイルごとのプロット関数を呼び出す。

例えばplot_points()関数では、プロットがpointsize variableな場合にstruct TERMENTRY構造体のpointsizeメソッド(正確には関数ポインタのメンバ変数)を呼び出してポイントサイズの変更を行っている。この時、z座標を参照している。struct TERMENTRYはいわばterminalの共通APIであり、実際の処理は各種terminalドライバが実装する。

なお、linecolor variableはcheck_for_variable_color()関数を呼んで処理しているようだ。

everyオプションのパースと処理

ところで、everyオプションはdatafile.cのdf_open()関数内でパースされている。これを行うのがplot_option_every()関数である。

plot_option_every()関数は、everyのエントリを一つずつパースしてファイルスコープ変数everypoint, everyline, firstpoint, firstline, lastpoint, lastlineに保存する。

考察

さて、ではusingオプションやeveryオプションにnamed parameterを導入できるだろうか?

everyオプションの場合はplot_option_every()関数を改変するだけで良さそうなので、比較的簡単にnamed parameterにできるだろう。

usingオプションの場合も不可能ではないだろうが、影響範囲が大きいのでかなり大変そうである。やるとしたら以下のような流れになると思う。

  • struct use_spec_snameメンバを加える。
  • df_no_use_specs変数は削除する。
  • グローバル変数use_specを素の配列からオブジェクトに切り替える。このオブジェクトはpostionalエントリとnamedエントリの両方を持つ。
  • plot_option_using()関数を改変し、named parameterをパースできるようにする。
  • df_readline()df_readascii()が扱うデータ配列vも配列からオブジェクトへ変える。こちらのオブジェクトはuse specによる演算結果を持つ。
  • get_data()関数内の各種処理はこのオブジェクトに対して名前やインデックスでデータを問い合わせて必要なデータを集め、適宜演算してstore2d_point()に渡す。

このように、struct use_spec_sdf_no_use_specsなど、グローバルに参照可能なシンボルを変更することになるので、かなり影響範囲が大きいと考えられる。特に、binaryやmatrixやsplotでもうまく動くかどうか、あまり自信がない。

ではどうするか?

gnuplot本体の改造で解決するのが難しいのであれば、外部のプログラムで解決すればよい。

自分はもうgnuplotGnuplot::Builder経由でしか使う気が起きないので、Perlモジュールとしてnamed parameterを実現してやればいいという気になっている。