Rubyでコードゴルフ ~235bytes → 40bytesの過程を追う~

TL;DR

コードゴルフとは? その魅力は? の解説と、Rubyで書かれた普通(?)のコードがワンステップずつ縮んでいき、コードゴルフらしいコードになるまでの解説。

目次

  • まえがき
  • コードゴルフとは
  • 今回のお題
  • 普通のRubyコード
  • ステップごとの解説
  • おわりに

まえがき

コードゴルフとは、与えられたお題でどれだけコード長を縮められるか? を競う遊びです。

所属するサークルTSGの学祭(五月祭)企画でミニ・コードゴルフ大会というものを開催することになり、その実況担当になりました。→参加者のuraさんがWriteupをUPしてくれました:TSG LIVE! 3 ライブコードゴルフ大会 writeup - 何か書く

私はコードゴルフという遊びについて全く触れたことがなかったのですが、今回の企画のためにRubyでコードを(主に部員のみんなの助けにより)縮め、コードゴルフの一端を体験しました。せっかく説明用のスライドも作ったので、共有したいと思います。

なお、筆者はそもそもRubyを書くのすら初めてでした。Rubyistから見ると不思議な点が多々あると思います。間違いなどあれば指摘していただけると幸いです。

コードゴルフとは

先ほども書いたのですが、「与えられたお題でどれだけコード長を縮められるか(バイト数を減らせるか)」を競う遊びです。 基本的に、お題はかなり簡単なものが与えられます。Atcoder Beginner ContestのA問題をちょっと難しくしたくらいだと感じました。なので簡単に書けるのですが、そのコードをいかに縮められるか、というところで様々な知識が要求されます。

当然のことながら、「それの何が面白いの?」という疑問が出てくると思います。今回私が感じたのは以下の3点です。

  • 一見すると意味不明なコードがちゃんと動く

ゴルフコードは改行・空白等が基本的にない上、コード長を短くするために様々な工夫が施されています。ですから、一見しても(コードゴルフに慣れていなければ)何が何だかわかりません。でもちゃんと動くのです。そこにちょっと感動し、ああそういうことか! と仕組みが分かるとちょっとうれしくなれます。

  • コードを縮めるために細かい言語仕様が役に立つ

普段のコーディングではある処理に対しては私個人のやり方をひとつ覚えてさえいればどうにかなるため、複数の書き方を覚える必要に迫られません(もっとも、これは私が仕事として書いていないせいかもしれませんが)。

ですが、コードゴルフでは複数の書き方、すなわちより短くなる書き換え方を知っていることが強みになります。それは結局言語の仕様についての知識に直結し、深い知識によるコード短縮テクに「こんな書き換え方があったのか! 賢い!」と唸らされます。

世の中には「出来上がったコードが星空のように見える言語」や「絵文字でプログラミングする言語」、「人間にとって読みにくいコードを生成する言語」などのように、様々な非実用的な言語が存在します。

それらはある種のジョークや実験的言語として作られたもので、実用的でないので当然使われないわけですが、コードゴルフという遊びにおいてはこの難解プログラミング言語(esoteric programming language, esolang)によるプログラミングが普通のプログラミングにはない面白味を与えてくれます。しかし、この記事はRubyでのコードゴルフが主題なのでesolangには触れません。

今回のお題

f:id:Szkieletor:20190519181526p:plain

  • 上の画像のように、アルファベットと数字が(ほぼ)交互に並んだ文字列が与えられます。
  • 数字の数だけ直前のアルファベットを連続させた文字列を返してください。

というものです。

これは「連長圧縮」という技術をテーマにしており、実際にファクシミリで使われていた(使われている?)圧縮方法らしいです。

これだけだとあまりにも問題が簡単なので、制約が加わっています。

  • 連続回数が1回のときは、数字は省略される。

つまり、sssgwwに対応するのはs3g1w2ではなく、s3gw2になる、ということです。この制約により、前から2文字ずつ読んでいくやり方は不可能になります。

この制約をクリアする方針の一つが以下になります。

f:id:Szkieletor:20190519182542p:plain

今回はこの方針に基づいて実装したコードを縮めていきます。

普通のRubyコード

cipherText = gets
number = "23456789"

for i in 0..(cipherText.length - 1) do
    if number.index(cipherText[i]) == nil then
        print cipherText[i]
    else
        for j in 0..(cipherText[i].to_i - 2) do
            print cipherText[i - 1];
        end
    end
end

筆者はこのコードが初Rubyなので、まったくRubyらしいコードになっていないと思います。Rubyはふつうforを使わない、と聞いてびっくりしました。聞きかじっただけなので嘘を言っているかもしれませんが。

補足すると、変数numberに入力文字が数字かどうか判定するための文字列を格納し、indexで検索することで処理を分けています。

現在のコード長が235bytesなのですが、最終的に40bytesまで縮みました。そのコードがこちら。

$<.chars{|d|n=d.to_i-1;puts n<0?B=d:B*n}

ちなみに、解説のkurgmさんとプレイヤーのuraさんがちょっとずつ違う方法で36bytesまで縮めていました。Twitterでも「36が最短」というツイートを見つけたので多分これが一番短いと思います。

さて、ではコードを縮めていきます。

ステップごとの解説

最初の方は当たり前のことを書いているので飛ばしてもよさそうです。ちなみに書いている本人はどこまでがRubyistにとっての常識なのか全くわかっていません。

1. 変数名を1文字にする

コードゴルフではコードの短さが命なので、変数名は1文字にしてしまいます。もちろん悪いプラクティスなわけですが。

171B(-62)

2. 空白・改行をなくす

コーディング規約にもよりますが、-==の両隣にはスペースを挿入することが好ましく、また適宜改行を挿入することがよいプラクティス、のはず。これも取り除きます。

158B(-13)

c=gets
n="23456789"
for i in 0..(c.length-1) do
    if n.index(c[i])==nil then
        print c[i]
    else
        for j in 0..(c[i].to_i-2) do
            print c[i-1];
        end
    end
end

3. 後半のforはいらない

テクでも何でもないです。 後半のforは「今読み込んだ数字-1回、インデックスが一つ前の文字を出力する」という処理ですが、そもそも'c' * timesctimes回連続させた文字列を作れます。これをprintした方が短いです。

133B(-25)

c=gets
n="23456789"
for i in 0..(c.length-1) do
    if n.index(c[i])==nil then
        print c[i]
    else
        print c[i-1]*(c[i].to_i-1)
    end

準備:foreachに置き換え

Rubyにおけるforは特殊なので、do...end{}などができません。ということでeachに書き換えます。

c=gets
n="23456789"
(0..c.length-1).each do |i|
    if n.index(c[i])==nil then
        print c[i]
    else
        print c[i-1]*(c[i].to_i-1)
    end
end

4. (0..c.length-1).each を書き換え

入力として受け取った文字列の長さだけループを回していますが、これはc.length.timesというメソッドと同じです。 さらにlengthメソッドと同じ機能を果たすsizeメソッドがあり、こちらの方が文字数が短いです。

125B(-8)

c=gets
n="23456789"
c.size.times do |i|
    if n.index(c[i])==nil then
        print c[i]
    else
        print c[i-1]*(c[i].to_i-1)
    end
end

5. do...end

size.timesメソッドのdo...end{}で置き換えられます

変わっている部分がわずかなのでコードは次にまとめます。

120B(-5)

6. ifを条件演算子

条件演算子とはa ? b : cという書き方のことで、aがtrueならbが、falseならcが実行されます。ということで、ifを置き換えます。

この辺りから、表示されるコードには見やすさのために改行を入れています。

103B(-17)

c=gets
n="23456789"
c.size.times{|i|
    (n.index(c[i])==nil)?(print c[i]):(print c[i-1]*(c[i].to_i-1))
}

7. printはまとめられる

a ? print b : print cprint (a ? b : c) と書き換えられます。

97B(-6)

c=gets
n="23456789"
c.size.times{|i|
    print (n.index(c[i])==nil)?(c[i]):(c[i-1]*(c[i].to_i-1))
}

8. n.index(c[i]==nil)

(x == nil) ? b : cx ? b : c と書き換えられます。実はx = false となると結果が違ってしまいますが、今回のケースではindexメソッドが返すのは数値かnilなので、大丈夫です。

さらに、n.index(c[i])n[c[i]]と書き換えられます。xに(文字列型としての)数字が入っていればn[x]n.index(x)と同じ働きをします。たとえば、n['2'] = '2' となり、n['t']のように存在しない文字の場合はnilを返します。

78B(-19)

c=gets
n="23456789"
c.size.times{|i|
    print(n[c[i]]?c[i-1]*(c[i].to_i-1):c[i])}

準備:i-1をくくりだす

現状size.times{|i|}の中で配列のインデックスとしてiが使われている箇所の中に、1か所だけi-1が使われています。これを別の変数としてくくりだすことで別のメソッドに置き換えができ、さらなる短縮が可能になります。

86B(+8)

c=gets
n="23456789"
b=nil
c.size.times{|i|
    print(n[c[i]]?b*(c[i].to_i-1):c[i])
    b=c[i]}

9. size.timesc[i]の置き換え

先ほどの準備により、size.times{|i|chars{|d|に、c[i]dに置き換えられます。

69B(-17)

c=gets
n="23456789"
b=nil
c.chars{|d|
    print(n[d]?b*(d.to_i-1):d)
    b=d}

10. b=nil

rubyの変数は初期化なしで宣言すると初期値がnilになります。この際大文字で宣言してスコープをグローバルにしないといけません。小文字のローカル変数だとループの中で2回目以降参照できなくなってしまいます。

ちなみに大文字の宣言はグローバル定数なので再代入はwarningが出ますが、warningだけなので無視します

63B(-6)

c=gets
n="23456789"
c.chars{|d|
    print(n[d]?B*(d.to_i-1):d)
    B=d}

11. printputs

改行が入るかどうかが違います。今回はルール上改行が無視されるので置き換えられ、1バイト短くなります。コードは次にまとめます。

62B(-1)

12. nを消す

数字かどうか判定するためにnを用意していましたが、実はn=d.to_i - 1とすると、d=nild.to_i = 0d.to_i - 1 = -1となるので、n<0trueならアルファベット、falseなら数字と判定できます。

47B(-15)

c=gets
c.chars{|d|
    n=d.to_i-1
    puts n<0?B=d:B*n}

13. cを消す

入力のために変数を用意する必要はなく、$<でOKです。

$<.chars{|d|
    n=d.to_i-1
    puts n<0?B=d:B*n}

最後に、見やすさのために挿入していた改行を取り除きます。

$<.chars{|d|n=d.to_i-1;puts n<0?B=d:B*n}

完成です。40バイトになりました。

ちなみに最短である36バイトのコードでは、gsubメソッドを使います。

おわりに

当然私はRubyを何もわかっていないので、上の短縮はTSG部員のみんなにSlackで訊ねた結果を載せています。強い人がすぐそばにいるって素晴らしい。教えてくれた@kcz, @hakatashi, @kurgmさんに感謝です。

コードゴルフ場(?)というか、コードゴルフが楽しめるサイトとして anarchy golf があります。

コードゴルフ大会はライブ企画でしたが、YouTubeでストリーミング視聴できます。実況できていませせんが……(画面を追えていない)。2日目にもコードゴルフ大会があり、そちらはちゃんとプレイヤーの画面を追ってくれていました。

スライドも作ったのでリンクを載せておきます。

面白いesolangが多かったので、その紹介記事も余裕があれば書きたいです。

Szkieletor( @gh_end_)