入出力(3) CSVファイルを表として表示

昨日、ファイルの読み書きの基礎を勉強したので、今度は少し応用的な事をやってみます。具体的には CSV ファイルの読み込みです。
CSV とは「Commma Separated Values」の略で、データを「,」(カンマ)区切りで行列として並べたものです。例えば、次のようなものです。

apple,red,sweet
lemon,yellow,sour
melon,green,sweet

1行にカンマ区切りで複数列のデータが含まれています。この場合、「apple は red で sweet」「lemon は yellow で sour」「melon は green で sweet」という3つの果物のデータです。
厳密な意味での CSV 構造は、列の途中でカンマを含める為に「"」(ダブルクォテーション)で囲ったりできますが、とりあえず練習ですので、列の途中にカンマは含めない事にします。
上の3行を fruits.txt として保存しました。


さてさて、CSV を読み込むだけだとちょっと簡単すぎるので、読み込んだデータを表にして標準出力に出力したいと思います。表組みには「+」「-」「|」という記号を使う事にします。
どうやって作るかですが、次の2段階にわけたいと思います。

  1. CSVファイルを読み込んで2次元配列にする
  2. 2次元配列を元に表を表示する

これらをそれぞれ関数で作れば良さそうですね。

CSV ファイルの読み込み

昨日勉強した open 関数、行入力演算子、close 関数を使って、ファイルを開き、1行ずつ処理していきます。シンプルな CSV なので、配列への変換は split 関数を使えば良さそうです。
作った関数は、このようになりました。

sub load_csv($) {
  my ($filename) = @_;
  my @rows = ();
 
  open CSV, $filename or die "Can't open $filename : $!";
  while (<CSV>) {
    chomp;
    my @columns = split /,/;
    push @rows, \@columns;
  }
  close CSV;
 
  return @rows;
}

load_csv 関数は引数としてファイル名($filename)を渡します。@rows は「行」という意味で、その名の通り各行を要素とする配列変数です。最初は空にしておきます。load_csv 関数は返り値として、この @rows を返します。
渡されたファイル名のファイルを open 関数で開き、CSV ファイルハンドルに関連付けます。
次に while と行入力演算子(山カッコ演算子)を組み合わせて、開いたファイルを1行ずつ処理していきます。while(<ファイルハンドル>) と書いた場合は、唯一の例外として暗黙的に while(defined($_ = <ファイルハンドル>)) として解釈されるのでしたね。
「split /,/」はどういう意味か。これは「split /,/, $_」と等価です。つまり読み込んだ1行を「,」(カンマ)で区切ってリストに変換します。リストは配列変数 @columns (列)として保存します。そして、@rows に @columns のリファレンスを push します。Perl での2次元配列はリファレンスを使うのでしたね。
こうして全ての行を処理し終えたら CSV ファイルハンドルを close 関数で閉じます。後始末終わり。
fruits.txt を load_csv 関数で読み込んだ場合、返ってくる @rows はこのようになっています。

my @rows = (
  ["apple", "red", "sweet"],
  ["lemon", "yellow", "sour"],
  ["melon", "green", "sweet"]
);

[…] は無名配列へのリファレンスですね。(^_^)

2次元配列を元に表を表示する

これはなかなか難題でしたが、文字列の繰り返しを行なう「x」演算子を思い出せれば、意外と簡単そうです。(^_^) 文字列の繰り返しを行なう演算子は珍しい気がします。
文字列の長さを調べるには length 関数を使います。ただ、注意しなくてはいけないのは、Unicode 文字列を扱う場合、length 関数は「文字数」を返す、という事です。「バイト数」ではありません。
「表」というからには、各列の文字列から一番長い文字列を探し出し、それを「各列の幅」としなくてはなりませんね。
この作業はさらにいくつかの細かいステップにわけられます。ステップをそれぞれ別の関数として切り分けていますが、この辺りは好みの問題ですね。
まず、2次元配列の各列について幅を求める必要があります。こんな関数を作りました。

sub calc_columns_width(\@) {
  my ($table_ref) = @_;
  my @width = ();
 
  for (my $y = 0; $y < @$table_ref; $y++) {
    my @row = @{$table_ref->[$y]};
    for (my $x = 0; $x < @row; $x++) {
      my $w = length $row[$x];
      if (!defined($width[$x]) || $w > $width[$x]) { $width[$x] = $w; }
    }
  }
 
  return @width;
}

引数として幅を計算する2次元配列を渡します。関数プロトタイプを使って、配列のリファレンスとして受け取るようにしています($table_ref)。リファレンスを格納している変数には、わかりやすいように「_ref」を付けています。
@width に各列の幅を入れます。calc_columns_width はこの配列を返します。
$y がカウンタとなっている外側の for ループは、各行を処理する為のものです。@row に処理する行をコピーしています。$table_ref の要素は配列のリファレンスとなっているので、@{ } でデリファレンスしています。
$x がカウンタとなっている内側の for ループは、各列を処理する為のものです。$w に現在のセル(データ)の文字列長を入れてから、@width に記録されている幅と比べ、現在の文字列の方が長ければ改めてセットします。
この関数に fruits.txt から読み込んだ二次元配列を渡すと、(5, 6, 5) が返ってきます。単純な処理ですが、二重ループ以外の方法を思いつきませんでした。(^_^;)


次に、表の枠を表示する為の関数を作りました。

sub print_border(\@) {
  my ($width_ref) = @_;
 
  print "+";
  print "-" x $_ . "+" for (@$width_ref);
  print "\n";
}

引数として受け取る配列(のリファレンス)は、calc_columns_width から返される各列の幅の配列です。そして、"-" を幅の長さ分繰り返して print します。
例えば (5, 6, 5) を渡すと

+-----+------+-----+

が出力されます。これは簡単ですね。(^_^)


さらに今度は表の「1行」を表示する為の関数です。

sub print_row(\@\@) {
  my ($width_ref, $row_ref) = @_;
 
  print "|";
  for (my $x = 0; $x < @$row_ref; $x++) {
    my ($w, $col) = ($width_ref->[$x], $row_ref->[$x]);
    my $space = " " x ($w - length($col));
    print "$col$space|";
  }
  print "\n";
  print_border(@$width_ref);
}

引数に、calc_columns_width で得た各列の幅と、1行のデータ(配列)を渡します。for ループで各列のデータを表示します。

    my ($w, $col) = ($width_ref->[$x], $row_ref->[$x]);

は、$w に現在の列の幅、$col に現在の列のデータがそれぞれ入ります。リスト代入ですね。(^_^)
その次の $space では、列の幅とデータの文字列長の差の分を埋める為の空白文字を生成しています。これを使って、各列が揃うようにするわけです。printf でも良かったのですが、「x」演算子を使いたかったので、このようにしています。
1行表示したら、ついでに print_border を呼び出して表の区切りを表示するようにします。


必要な関数がそろったので、いよいよ表を出力する関数を作ります。

sub print_table(\@) {
  my ($table_ref) = @_;
  my @width = calc_columns_width(@$table_ref);
 
  print_border(@width);
  print_row(@width, @$_) for (@$table_ref);
}

まず表となる2次元配列を受け取って、各列の幅を計算し @width に代入した後、print_border で最初の枠を表示し、後は全ての行に対して print_row を呼び出します。
いくつかの小さな部品を組み合わせて、大きな部品を作るのは楽しいですね。

まとめ

作った関数を使って fruits.txt を読み込んで表として表示します。
今までに書いたコードと一緒にまとめると、こうなりました。ちょっと長くなってしまいました。(^_^;)

use strict;
 
sub load_csv($) {
  my ($filename) = @_;
  my @rows = ();
 
  open CSV, $filename or die "Can't open $filename : $!";
  while (<CSV>) {
    chomp;
    my @columns = split /,/;
    push @rows, \@columns;
  }
  close CSV;
 
  return @rows;
}
 
sub calc_columns_width(\@) {
  my ($table_ref) = @_;
  my @width = ();
 
  for (my $y = 0; $y < @$table_ref; $y++) {
    my @row = @{$table_ref->[$y]};
    for (my $x = 0; $x < @row; $x++) {
      my $w = length $row[$x];
      if (!defined($width[$x]) || $w > $width[$x]) { $width[$x] = $w; }
    }
  }
 
  return @width;
}
 
sub print_border(\@) {
  my ($width_ref) = @_;
 
  print "+";
  print "-" x $_ . "+" for (@$width_ref);
  print "\n";
}
 
sub print_row(\@\@) {
  my ($width_ref, $row_ref) = @_;
 
  print "|";
  for (my $x = 0; $x < @$row_ref; $x++) {
    my ($w, $col) = ($width_ref->[$x], $row_ref->[$x]);
    my $space = " " x ($w - length($col));
    print "$col$space|";
  }
  print "\n";
  print_border(@$width_ref);
}
 
sub print_table(\@) {
  my ($table_ref) = @_;
  my @width = calc_columns_width(@$table_ref);
 
  print_border(@width);
  print_row(@width, @$_) for (@$table_ref);
}
 
 
# メイン処理
 
my @table = load_csv("fruits.txt");
print_table(@table);

load_csv で fruits.txt を読み出して2次元配列として保存し、print_table で表として出力します。
実行結果はいかのようになりました。

 +-----+------+-----+
 |apple|red   |sweet|
 +-----+------+-----+
 |lemon|yellow|sour |
 +-----+------+-----+
 |melon|green |sweet|
 +-----+------+-----+

ちゃんと動きました! 表組みもできていますね。感動。(^_^)


ところで、今回は手続き型プログラミングで作成しましたが、オブジェクト指向プログラミングで作るのも楽しそうですね。
また、CPAN を探せば、CSV ファイルを扱うモジュールはたくさん見つかりますので、本当は自前で読み込み処理を作る必要はありません。「車輪の再発明」になってしまいます。今回はあくまで入出力の練習の為に読み込み処理を作りましたが、今後 CSV ファイルを扱う場合は CPAN のモジュールを使うと思います。(^_^)