PHPでコードゴルフ - Base32

2024年3月7日～9日、[PHPerKaigi 2024](https://phperkaigi.jp/2024/)が開催されました。
私はオンライン参加勢だったのですが、メインであるセッション・LT以外に、PHPerチャレンジやコードゴルフといった企画も用意されていて非常に楽しませてもらいました。

コードゴルフは、与えられた仕様を満たすコードをいかに短く実装できるかを競うゲームで、今回は3問出題されました。
これが楽しかったのでいろいろやっていたところ、第2問、第3問で1位を取ってしまいました。
せっかくなので、どんなコードを書いたのか紹介しておきます。

なお、問題および私を含めた回答者のコードはこちらで公開されています。
https://fortee.jp/phperkaigi-2024/go/golf

## 第2問：Base32
https://t.nil.ninja/phperkaigi/2024/golf/q/base32/

Base64ならぬ[Base32](https://ja.wikipedia.org/wiki/%E4%B8%89%E5%8D%81%E4%BA%8C%E9%80%B2%E6%B3%95#Base32)の変換を実装するという問題です。
そして、私のコードがこちらです。

```php
while($l=fgets(STDIN)){$b='';for($i=0;$i<strlen($l)-1;)$b.=sprintf('%08b',ord($l[$i++]));foreach(str_split($b,5)as$j)echo[...range('A','Z'),...range(2,7)][bindec(str_pad($j,5,0))];echo str_repeat('=',[0,6,4,3,1][$i%5]),"\n";}
```

さすがに何を書いてるかさっぱりだと思うので、改行やインデントを挿入すると以下のようになります。

```php
while ($l = fgets(STDIN)) {
  $b = '';
  for ($i = 0; $i < strlen($l) - 1;)
    $b .= sprintf('%08b', ord($l[$i++]));
  foreach (str_split($b, 5) as $j)
    echo [...range('A', 'Z'), ...range(2, 7)][bindec(str_pad($j, 5, 0))];
  echo str_repeat('=', [0, 6, 4, 3, 1][$i % 5]), "\n";
}
```

これは問題ページにある実装例を変形していった結果出来上がったものです。どのように変形したのか、以下に紹介していきます。

### 配列の短縮
実装例を見ると、`TABLE`配列が明らかに文字数を食っています。
連番の配列といえば[`range`](https://www.php.net/manual/ja/function.range.php)関数ですが、数値の連番だけでなく、文字列の連番も生成することができます。
ということで、`TABLE`は以下のように短縮できます。
```php
const TABLE = [...range('A', 'Z'), ...range('2', '7')];
```

### 変数、定数の削減
上で`TABLE`定数の定義を短くしましたが、そもそもこの定数は1箇所でしか使われていないので、定数を定義せずに配列リテラルをそのまま埋め込むと文字数が削れます。

```diff_php
- const TABLE = [...range('A', 'Z'), ...range('2', '7')];
  // ...
- $base32 .= TABLE[base_convert(str_pad($b, 5, '0'), 2, 10)];
+ $base32 .= [...range('A', 'Z'), ...range('2', '7')][base_convert(str_pad($b, 5, '0'), 2, 10)];
```

また、`PHP_EOL`は`"\n"`に置き換えました。他の人の回答では、`\n`の2文字の代わりに改行そのもの (LF) を文字列に入れている人も多かったですね。

さらに、エンコード結果を`$base32`変数に格納せず、echoで標準出力に都度出すだけにすると、`$base32`変数が完全に不要になります。
```php
foreach (str_split($bits, 5) as $b) {
  echo [...range('A', 'Z'), ...range('2', '7')][base_convert(str_pad($b, 5, '0'), 2, 10)];
}
echo match (strlen($bits) % 40) {
  8 => '======',
  16 => '====',
  24 => '===',
  32 => '=',
  default => '',
}, "\n";
```

### 類似の関数の利用
[`base_convert`](https://www.php.net/manual/ja/function.base-convert.php)は任意のn進数→m進数 (2<=n,m<=36) の変換ができる便利な関数です。
ただ、2進数⇔10進数の変換は頻繁に使われることもあってか、専用の関数が用意されている[^1]ため、置き換えることができます。
- [bindec](https://www.php.net/manual/ja/function.bindec.php)
- [decbin](https://www.php.net/manual/ja/function.decbin.php)

[^1]:8進数⇔10進数、16進数⇔10進数の変換も同様の関数があります

さらに、for文の中では4つもの関数が呼ばれていますが、何をやっているか丁寧に見ると、
- 入力中の1文字を取得
- ASCIIコードに変換
- 2進数に変換
- 0埋め8桁の文字列に変換

という処理になっています。
このうち「2進数に変換 + 0埋め8桁の文字列に変換」の部分は、[`sprintf`](https://www.php.net/manual/ja/function.sprintf.php)を使えば一発です。

```diff_php
- $bits .= str_pad(base_convert(strval(ord($line[$i])), 10, 2), 8, '0', STR_PAD_LEFT);
+ $bits .= sprintf('%08b', ord($line[$i]))
```

### 暗黙の型変換の利用
`str_pad`の第3引数はstring型ですが、今回`declare(strict_types=1)`は付いていないので、数値の`0`を渡しても文字列の`"0"`に変換されます。
またechoで出力する値も数値→文字列に変換されるので、シングルクォーテーションをいくつか削ることができます。

```diff_php
- echo [...range('A', 'Z'), ...range('2', '7')][bindec(str_pad($b, 5, '0'))];
+ echo [...range('A', 'Z'), ...range(2, 7)][bindec(str_pad($b, 5, 0))];
```

後から気づきましたが、ここまで来ると`...range(2,7)`より`2,3,4,5,6,7`の方が短かったですね。

### `rtrim`をやめる
`fgets`で読み込んだ結果から、末尾の改行を取るために`rtrim`を呼んでいました。
今回に関しては、入力文字列はforで1文字ずつループするだけなので、改行を取るのをやめて代わりにループ回数を`strlen($line) - 1`にしました。

厳密には、改行コードがCRLFの2バイトだったり改行無しで入力が終わったりしていたら成り立たないのですが、今回のテストケースにはそのようなパターンはないようでした。

### `=`パディングのmatchを削る
実装例では、`strlen($bits) % 40`の結果に応じて末尾に`=`のパディングが0～6文字入るようになっています。

> ```php
> $bits .= sprintf('%08b', ord($line[$i]))
> ```

先程`$bits .= sprintf('%08b', ord($line[$i]))`というコードがあった通り、`$bits`の文字数は8の倍数になります。
割る数の40も8の倍数なので、全体を8で割って、以下のように変形できます。
```php
match ((strlen($bits) / 8) % 5) {
  1 => '======',
  2 => '====',
  3 => '===',
  4 => '=',
  0 => '',
}
```
すると、`=>`の左側が0～4の連番なので、配列を使って書き換えられます。
```php
['', '======', '====', '===', '='][(strlen($bits) / 8) % 5]
```
同じ文字の繰り返しなので、[`str_repeat`](https://www.php.net/manual/ja/function.str-repeat.php)を利用できます。
```php
str_repeat('=', [0, 6, 4, 3, 1][(strlen($bits) / 8) % 5])
```

ここで、`strlen($bits) / 8`の部分に注目すると、これは最初に`$bits`文字列を組み立てるときのfor文のループ回数と同じです。
PHPではループが終了した後もループ変数はそのまま生き残るので、`strlen($bits) / 8 === $i`です。
最終的に、match式の部分は以下のように書けます。
```php
str_repeat('=', [0, 6, 4, 3, 1][$i % 5])
```

### 変数の参照とインクリメントをまとめる
通常for文のカッコの最後にあるインクリメントを、ループの中の`$i`を使っている部分で一緒にやってしまいます。
普段はあまり意識しないであろう[前置インクリメントと後置インクリメントの違い](https://www.php.net/manual/ja/language.operators.increment.php)を考えて使う必要があります。
```diff_php
- for ($i = 0; $i < strlen($line) - 1; $i++) {
-   $bits .= sprintf('%08b', ord($line[$i]));
+ for ($i = 0; $i < strlen($line) - 1;) {
+   $bits .= sprintf('%08b', ord($line[$i++]));
  }
```

---
あとは細かい修正をすれば冒頭のスクリプトとなります。

- 変数の1文字化
- 冗長な波括弧の削除

## 最後に
本当は第3問も書こうと思ったんですが、長くなったので一旦ここまでにします。

他の方の回答も見てみましたが、以下2点をやっている人はあまりいなさそうでした。

- `strlen($bits) / 8`の代わりに`$i`を使う
- `rtrim()`をやめる

関数呼び出しはそれなりに文字数を食うので減らせると大きいですね。