FizzBuzz

学生時代に情報科学科の同級生たちと「FizzBuzz 問題というものがあるらしいが、この程度の基礎ができないプログラマなどいるはずがない」と会話していた。

それから四半世紀。小学生にはプログラミングの授業があり、非 IT 企業の一般社員にも「Python 書いて RPA しろ、HTML/CSS/JavaScript で社内サイト作って情報発信しろ」という圧が掛かってくる DX 社会。DX とはデジタル革命, IT, Web2.0, ICT に続く "企業経営者向け IT 売り込みバズワード" だと思っていたが、一般の人々にまでやれ!やれ!と煽り立てる世の中の圧が今回はすごい。

猫も杓子もコードを書けという時代になった *1 せいか、Quora などでも FizzBuzz 書けない問題をよく目にする。

ということで、自分なら FizzBuzz どう書くか、C#Perl でやってみた。

C# 9.0 (標準解)
Enumerable.Range(1, 30)
  .Select( i => (i % 3, i % 5) switch {
    (0, 0) => "FizzBuzz",
    (0, _) => "Fizz"    ,
    (_, 0) => "Buzz"    ,
    (_, _) => "" + i    ,
  } )
  .ToList().ForEach( x => Console.WriteLine(x) );

所要 1 分半。using, namespace, class, Main() メソッドの宣言が不要な C# スクリプトとして記述した。

普段使いの C# のため、コアロジックのコーディングに悩むこともリファレンスを参照することもなく、呼吸するようにタイピングする。純粋関数 (LINQ によるデータ変換) と I/O (WriteLine() による出力) をきれいに分離した上で後者をどう書くかというのが C# LINQ を使う上で最も時間を費やす悩みどころ。結局、WriteLine() を交えるなら List の .ForEach() を使うのが (開発効率的に) 早いだろうということで、メモリ効率を犠牲にしてこの形に。

数列のサイズが大きいか未知である場合は、メモリ効率を勘案し "データ変換 + 出力" を単位として遅延評価にする。その場合、最後を foreach {} 制御に置き換えるところなのだろうが、カーソル移動とブロックインデントが面倒なため、LINQ メソッドチェーンで次のように書いてしまう。ステートメント混じりである点とダミーデータを作って捨てるムダが美しくないが、デバッグなどではよく使う。

  .Select( x => { Console.WriteLine(x); return 1; } ).ToList();
Perl 5
map do {
  my $s = ($_ % 3 ? '' : 'Fizz') . ($_ % 5 ? '' : 'Buzz');

  print $s ? $s : $_, "\n";
}, (1 .. 30);

所要 3 分。同一内容の記述であるが、こちらの方がコーディングの所要時間が長くなった。

Perl は得意ではあるもののたまにしか使わないため、いかに簡便に書けるか条件判定と演算子との組み合わせ *2 を改めて意識する必要があり、また、map の対象をステートメントブロックにするか式にして手続を外に追い出すか試行錯誤したため、そこに時間を取られる。

Perl 5 (別解)
print map{(($s=($_%3?'':'Fizz').($_%5?'':'Buzz'))?$s:$_)."\n"}(1..30);

どれだけ短く書けるか競争なら、こうかな? 70 文字。読みにくい...

三項演算子を用いている部分をより短く書けないか *3 と久しぶりにリファレンスを参照してみて、 // 演算子や given 文といったものが Perl 5 に追加されていることを知った。given が文でなく式なら嬉しいのだが。

C# 9.0 (別解)
Enumerable.Range(1, 30)
  .Select( i => (i % 3 == 0 ? "Fizz" : "") + (i % 5 == 0 ? "Buzz" : "") is var s && s.Length > 0 ? s : "" + i )
  .ToList().ForEach( x => Console.WriteLine(x) );

標準解は素直な書き方だが、3, 5, 15 の倍数での場合分けと行数が多くなってしまうところに若干の負けた感が漂うため、別解も用意してみた。作成した Fizz/Buzz 文字列を (is var パターンマッチのちょっとした濫用により) 変数 s で受け、ワンラインの式としている。

あるいは次の通り。switch と () が増える分だけ長くなるかと思いきや同じコード長。ポイントはやはり、代入文を使わず、式の内部で変数 s を受けるというところ *4

Enumerable.Range(1, 30)
  .Select( i => ((i % 3 == 0 ? "Fizz" : "") + (i % 5 == 0 ? "Buzz" : "")) switch { "" => "" + i, var s => s } )
  .ToList().ForEach( x => Console.WriteLine(x) );

コード長 + 可読性 の観点からはこれが、実行速度 + 可読性の観点からは標準解が C# の最適解 *5 になるだろうか。


パラダイムオブジェクト指向から関数型プログラミングに移りつつあってプログラミング言語が乱立する時代でもあり、たまにこういう基礎問題に立ち返るのも悪くない。

関数型が志向されるようになると式をワンラインで記述することも許容されやすくなるだろう。ワンライナーが嫌われるのは、意味単位の異なる複数ステートメントを無理やり1行に収めようとするからだと思っている。

おまけ

C# 9.0 ("世界のナベアツ" バージョン)
using System.Text.RegularExpressions;

var dic = new [] {
  "/じゅう/にじゅう/さぁ~んじゅう/よんじゅう"   ,
  "/いち/に/さぁ~ん/し/ご/ろく/しち/はち/きゅう",
}
  .Select( str => 
            str.Split("/")
              .Select( (x, i) => new { Key = i, Value = x } )
              .ToDictionary( x => x.Key, x => x.Value ) )
  .ToList();

Enumerable.Range(1, 40)
  .Select( i => i % 3 == 0 || Regex.IsMatch($"{i}", "3") ? dic[0][i / 10] + dic[1][i % 10] : $"{i}" )
  .Append("オモロ~")
  .ToList().ForEach( x => Console.WriteLine(x) );
// 出力結果
// 
// 1
// 2
// さぁ~ん
// 4
// 5
// ろく
// ...
// 29
// さぁ~んじゅう
// さぁ~んじゅういち
// さぁ~んじゅうに
// さぁ~んじゅうさぁ~ん
// さぁ~んじゅうし
// さぁ~んじゅうご
// さぁ~んじゅうろく
// さぁ~んじゅうしち
// さぁ~んじゅうはち
// さぁ~んじゅうきゅう
// 40
// オモロ~

*1:にわかプログラマが適当に書いた、設計思想も可読性/保守性/拡張性もないコードが散在すると業務効率が低下し、DX (= 業務変革) や経済発展は却って阻害されると個人的には思うのだが、それを懸念する声はいまのところ聞こえてこない。自己流の業務プロセスにこだわり、パッケージよりもスクラッチ/アドオン開発を志向して出遅れたという日本企業の 2000 年ごろの ERP 導入の轍を踏む気がする。

*2:Perl では false のみならず undef, 空文字列, 0 も偽と判定されるため、これを利用して演算をうまく短絡表現できるか検討するのが面白いのだが、たいてい期待外れに終わる。

*3:A ? A : B の A が二度出現しないような短絡表現できる (例えば A ?: B のような) 演算子が導入されたら、代入による中間変数宣言が不要になり、(関数型プログラミング観点での) 表現力向上に資すると思う。

*4:LINQ to Entities でビジネスロジックをゴリゴリ書いていると、手続・文を用いずにいかに式のみで演算を記述するかという工夫を常にすることになるため、式内の変数受けは is var か switch 式を使う (あるいは .Select() メソッドを多段に連ねる) というのが定石として頭に入っている。残念ながら、パターンマッチは IL レイヤーで純粋関数ではない順次実行手続にコンパイルされる (= 式木 3.0 非対応) というのが .NET の仕様だと思われるため、is var も switch 式も LINQ to Entities では使えず LINQ to Object 限定になるのだが。

*5:結果をリストにするまでを 100 万回繰り返したときの所要時間は、標準解:別解 1:別解 2 = 625ms : 795ms : 835ms = 100%:127%:136% となった。オプティマイザ次第で揺らぐ要素が多々あるロジックであるため、実行速度の違いは無視できる水準ではある。