入出力(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段階にわけたいと思います。
- CSVファイルを読み込んで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 のモジュールを使うと思います。(^_^)