この記事はTech KAYAC Advent Calendar 2020の9日目の記事です
技術部1年目サーバサイドエンジニアのkoluku(コルク)です。
この記事ではPerlでコードを書くための思考過程や実践的なテクニック(やっちゃだめなテクニック)を織り交ぜながらいかにしてコードを短くできるかを紹介してみたいと思います。
FizzBuzz問題
みなさんはFizBuzz問題をご存知でしょうか。 FizzBuzz問題とは、
1〜100までの数字を順に出力を行い、その数が3で割り切れるならFizz、5で割り切れるならBuzz、そのどちらでも割り切れるならFizzBuzzと代わりに標準出力(文字表示)する
という問題です。プログラムを書くことがある人は一度は書いたことはあると思います。
この問題では出力が改行ありなしの指定が無いのでここでは(都合がいいので)改行ありということにしておきます。
入門編
この問題を解いて見るために具体的な方針を立ててみると以下の点が考えられると思います。
- 1〜100までの数字の入力
- 割り切れる数によって変わる条件分岐
- 数字か文字を標準出力
1番はループ文が妥当でしょう。Perlだとfor
、while
などが該当しますね。
2番は条件分岐なのでif
が該当します。
3番は標準出力なのでprint
、say
が該当します。
方針を立てたのであとは「100回for文を回して中でifで条件分岐してprintすればよさそう」となりそうです。 一度もFizzBuzz問題を解いたことがない人はここで読むのをやめてコードを書いてみることをおすすめします。
解けました? では、人にもよりますが上記の内容で素直にコードを書くとこんな感じになるかと思います。
use 5.024; use strict; use warnings; use utf8; for (my $i = 1; $i <= 100; $i++) { if ($i % 3 == 0 && $i % 5 == 0) { # 1 print "FizzBuzz\n"; } elsif ($i % 5 == 0) { # 2 print "Buzz\n"; } elsif ($i % 3 == 0) { print "Fizz\n"; } else { print "$i\n"; } }
これを実行してみると
1 2 Fizz 3 4 Buzz 5 Fizz 6 7 8 Fizz 9 Buzz 10 11 Fizz 12 13 14 FizzBuzz Buzz Fizz 15 16 17 Fizz (中略) 97 98 Fizz 99 Buzz 100
とコンソール上に出力されます。 期待通りに標準出力されましたか?うまく行った?やりましたね!
さて、このコードの注意点は1点あり、それは条件分岐の優先順位です。 例えばFizzBuzzを判定する条件ブロック(#1)とBuzzを判定する条件ブロック(#2)を入れ替えるとどうなるでしょうか。
1 2 Fizz 4 Buzz Fizz 7 8 Fizz Buzz 11 Fizz 13 14 Buzz 16 17 Fizz (省略)
はい、この通りFizzBuzzが標準出力されなくなりました。 これは「3かつ5で割れる」というのは「3で割り切れる」と「5で割り切れる」のどちらにも属しているがために、先に5で割り切れるという条件ブロックで終了してしまうことで起きる問題です。 条件分岐が次に続く条件分岐に影響しないかを考慮しましょう。
初級編
では、ここからコードを短くしていきます。
不必要な宣言を消す
とりあえず無くても動くコードを消すところから始めましょう。
use warnings
やuse utf8
は実際の開発ではエラーの確認やcharsetを合わせるために必要ですが、今回はそれらの心配は不要なので消しちゃいます。
use strict
も今回は文法の厳格さはいらないので取り除きます。
バージョン情報も必要じゃないと思いますが次に説明するsayのために宣言する必要があります。
printの代わりにsayを使う
printはそのままでは改行されないため末尾に改行コードを含める必要があります。 またprintという関数の名前自体もちょっと長い(えっ)です。
Perl 5.10からはsay
という関数が追加され、暗黙に改行が行われるようになります。
使用するためには明示的にバージョンを宣言する必要があります。
これで名前と改行コードの分だけ短縮できます。
forを短くする
次にforを短くできないか考えてみます。
今回1から100まで連続した正の整数を順に出しているだけなので、1から100までのリストを順に取り出すことでも目的は果たせそうです。
なので..
(範囲演算子)を使ってリストを作り、foreach
で取り出します。
foreach my $i (1..100) {
これでちょっとだけ短くなりました。 しかし最近のPerlではforeachはあまり書きません。なぜならforの言い換えなのでforで書くことができるからです。
foreach は実際には for の同義語なので、どちらでも使えます。
foreachをforで代替すると以下のようになります。
for my $i (1..100) {
VAR が省略された場合には、$_ に値が設定されます。
ここもうまく利用してみましょう。
for (1..100) {
初期のforと比べてかなり短くなりました。
条件分岐を短くする
条件分岐も短くできそうな気がします。
3かつ5で割り切れるという条件は要は「3と5の公倍数で割り切れる」ということであり、15の倍数ということでもあります。
これで論理積(&&
)でつなげていた分のコードが短くなります。
if ($i % 15 == 0) {
最終的なコード
いままでの省略を足し合わせると以下のようになります。
use 5.024; for (1..100) { if ($_ % 15 == 0) { say 'FizzBuzz'; } elsif ($_ % 5 == 0) { say 'Buzz'; } elsif ($_ % 3 == 0) { say 'Fizz'; } else { say $_; } }
少しスッキリしましたね。
中級編
ここからはPerlの機能にもう少し触れて短いコードを目指してみます。
forの代わりに…
初級編ではforは最終的にこうなりました。
for (1..100) {
しかし、100までのリストがあるのに()
で括るのが少し無駄(えっ)に見えます。
リストから取り出して処理しているということに着目してmap
を使ってみます。
mapの第1引数はコードブロックなのでこの中では自由にコードを書くことができます。
map {} 1..100;
()
が外れてほんの少しだけ短くなりました。
ですが、そもそもmapとはなんでしょう。 mapとはリストからすべての要素を取り出して変換して返す関数です。
共同作業でコードを書く場合は写像を作る目的ではない使い方はやめましょう。
文字列結合
ちょっとルールを読み返すと何かに気が付きます。
- 3の倍数のときにFizz
- 5の倍数のときにBuzz
- 15の倍数のときにFizzBuzz
先にFizz後ろにBuzzという並びになっています。 つまり、15の倍数のときは3の倍数なのでFizzを置き、5の倍数でもあるのでその後ろにBuzzを置くことでも目的は果たせそうです。
Perlでは文字列を.
演算子で結合します。また.=
で左辺に結合しながら代入することもできます。
my $str = 'Hello' . ', world!'; # Hello, world! $str .= 'Perl'; # Hello, world!Perl
if ($_ % 3 == 0) { $m .= 'Fizz'; }
また、文字列結合をする際にundefは暗黙的に''
(空白)と見なして結合します。
my $str = 'whats' . undef . 'happen!?' # whatshappen!?
これを利用して予め変数を宣言しておいて、結合していくことに利用できそうです。
後置if
ifはコードブロックの中が1行だけなら後置することでコードブロックを省略することができます。
if ($a eq 'kayac') { say "$a.com"; } # 後置if say "$a.com" if $a eq 'kayac';
後置ifを使って省略しました
$m .= 'Fizz' if $_ % 3 == 0;
後置ifは頻出する書き方で、特にガード節を書くのに使われます。 ガード節とは例外ケースなどを関数の先頭に集める方法で、ネストが浅くなるため読みやすくなります。
# オンラインかつ会議中でないという判定をする if ($self->is_online) { if ($self->is_meeting($now) { return 0; } else { return 1; } } else { return 0; } # ガード節ならネストが浅いので読みやすい return 0 if !$self->is_online; # オフラインなら関数から抜ける return 0 if $self->is_meeting($now); # 会議中なら関数から抜ける return 1;
三項演算子
一行でifを制御したい場合に先程説明した後置ifの他に三項演算子と呼ばれるifとelseを同時に行う演算子があります。
条件式 ? 真のとき : 偽のとき
というように?と:で記述します。
if ($num == 10) { say 'Yes'; } else { say 'No'; } # 三項演算子 say $num == 10 ? 'Yes' : 'No';
if-elseもコードブロックもなくなるのでかなり短くなりました。 今回は上で文字列結合をおこなっているのでそれに対して条件分岐を行うのに使います。
# $mに文字列があるならそのまま返して、undefなら偽として$_を返す say $m ? $m : $_;
三項演算子に三項演算子を重ねることもできますが、可読性が著しく落ちるため避けましょう。
# ややこしさが増す例 say $num % 2 == 0 ? $num % 5 == 0 ? '10の倍数' : '2の倍数' : 'それ以外';
最終的なコード
完成したコードがこちらになります。 初級編と比べて一段とスッキリしてきましたね。
use 5.024; map { $m; $m .= 'Fizz' if $_ % 3 == 0; $m .= 'Buzz' if $_ % 5 == 0; say $m ? $m : $_; } 1..100;
上級編
上級編です! とは言っても今まで説明したことがほとんどなので新しいことは一つだけです。
後置for
ちょっと視点を変えてみるとループブロックもいらない気がしてきました。 ループブロックの中でsayしているのではなく、say自体を100回ループしているという解釈もできます。
つまりsayを100回実行してsayの中で文字を書き換えれるという選択肢もあるということです。 これを実現するには後置forを使います。
for (1..100) { say $_; } # 後置for say $_ for 1..100;
なお、後置forはループ条件が後ろに付くため可読性が低いです。 共同作業でコードを書く場合は素直にforを使いましょう。
最終的なコード
1行だけになりこざっぱりしましたね。入門編と比べて見間違えるくらいになりました。
use 5.024; say ($_ % 3 ? '': 'Fizz').($_ % 5 ? '': 'Buzz') || $_) for 1..100;
完成したコードで説明していないsayの中を解説するとFizz、Buzzのそれぞれの条件分岐に対して三項演算子で結合を行い、
($_ % 3 ? '': 'Fizz').($_ % 5 ? '': 'Buzz')
左辺の文字列が空であれば数字を返すようになっています
|| $_)
Perlなのでせっかくですからワンライナー化してみましょう。
perlコマンドの-e
もしくは-E
オプションを有効に知ると引数をperl scriptとして実行できるようになります。
-Eはいくつかの機能が有効になるうち、-eでは使えないsay関数が使えるようになります。
$ perl -E 'say(($_%3?"":"Fizz").($_%5?"":"Buzz")||$_)for 1..100'
追記 2020-12-09 13:00 公開当時括弧が足りず動作しないコードとなっていたため修正いたしました。
コピーしてターミナルで実行してみましょう。無事に動きましたか? これであなたはワンライナーの楽しみの一片を知ることができちゃいましたね!
余談
この記事を書いたあとでPerlのFizzBuzzの回答集を見てみたときにほぼ全く同じコードが出てきてびっくりしました。
ま、まぁみんな同じこと考えるよね…。
終わりに
Perlでコードを書いてみていかがだったでしょうか? 省略を進めていく過程を読んでいくと意外と最後のコードも読めるようになったのではないでしょうか。
もしこの記事が面白くてPerlに興味を持っていただいたなら、今度はズンドコキヨシ問題を短く解いてみましょう!
Javaの講義、試験が「自作関数を作り記述しなさい」って問題だったから
— テ (@kumiromilk) 2016年3月9日
「ズン」「ドコ」のいずれかをランダムで出力し続けて「ズン」「ズン」「ズン」「ズン」「ドコ」の配列が出たら「キ・ヨ・シ!」って出力した後終了って関数作ったら満点で単位貰ってた