Anybatross in YAPC::Fukuoka 2025を開催しました

どうも谷脇です。お待たせいたしましたが、Anybatrossの講評です。

開催記事

techblog.kayac.com

Anybatrossの仕組みに関する解説記事

techblog.kayac.com

Anybatrossはいわゆるコードゴルフのコンテストです。与えられたお題を解くようなプログラムを書くのですが、このプログラムが短ければ短いほど良いです。この記事では上位回答者のコードについて解説しますが、Hole 1はコードゴルフの解き方について学ぶちょうどよい問題なので、入門という形でも解説していきます。

Hole 1. Counter Counter

問題文

アルファベットのAやBにあるような、文字の中にある閉じた空間のことをカウンターといいます。

0〜9までの10種と、アルファベット大文字のA〜Zの26種、合計36種の文字やその他の記号を利用した文字列が渡されるので、カウンターの数を数えてください。アルファベット小文字は来ません。その他の記号のカウンターは数えなくてよいです。

1行ずつ数えて、その行までの累積個数と、その行での出現個数を出力してください。

KAYAC
YAPC FUKUOKA
WHITE
POWAWA
*STRONG*: ZERO

2,2
6,4
6,0
10,4
14,4

複数行の例

SNAKE CASE
WHITE SPACE
ITERATOR
TOKEN GENERATION
COMPILE ERROR
HASH MAP
2025 NOVEMBER

2,2
4,2
8,4
12,4
18,6
21,3
26,5

参考: 文字ごとのカウンターの数

文字 個数 文字 個数 文字 個数 文字 個数
0 1 A 1 K 0 U 0
1 0 B 2 L 0 V 0
2 0 C 0 M 0 W 0
3 0 D 1 N 0 X 0
4 1 E 0 O 1 Y 0
5 0 F 0 P 1 Z 0
6 1 G 0 Q 1
7 0 H 0 R 1
8 2 I 0 S 0
9 1 J 0 T 0

与えられた36種の文字から構成される文字列のうち、文字の形の中で囲まれている部分(カウンター)の合計を数えるというものです。なんじゃそりゃと思うかもしれませんが、各言語の初期コードに各文字のカウンター個数を示した表があるので、それを見ると何が言いたいかわかるかもしれません。

参考: Perlの対応表

our $COUNTER_MAP = {
    '0' => 1,
    '1' => 0,
    '2' => 0,
    '3' => 0,
    '4' => 1,
    '5' => 0,
    '6' => 1,
    '7' => 0,
    '8' => 2,
    '9' => 1,
    'A' => 1,
    'B' => 2,
    'C' => 0,
    'D' => 1,
    'E' => 0,
    'F' => 0,
    'G' => 0,
    'H' => 0,
    'I' => 0,
    'J' => 0,
    'K' => 0,
    'L' => 0,
    'M' => 0,
    'N' => 0,
    'O' => 1,
    'P' => 1,
    'Q' => 1,
    'R' => 1,
    'S' => 0,
    'T' => 0,
    'U' => 0,
    'V' => 0,
    'W' => 0,
    'X' => 0,
    'Y' => 0,
    'Z' => 0,
};

初期コードを見てみると、バイト数の多くをこの対応表が占めています。なので、この対応表を圧縮したり、別の表現方法にしていくのが最初のステップです。例えば以下のようにしてみましょう。

our $COUNTER_MAP = {
    '0' => 1,
    '4' => 1,
    '6' => 1,
    '8' => 2,
    '9' => 1,
    'A' => 1,
    'B' => 2,
    'D' => 1,
    'O' => 1,
    'P' => 1,
    'Q' => 1,
    'R' => 1,
};

こうするとだいぶ圧縮できますよね。しかし、文字と数字のmapだと数字の部分が冗長に感じます。ハッシュではなく配列にしてしまうのはどうでしょうか。2個カウンターがある文字をどうするかという問題があります。具体的には8Bです。

配列中に2つ登場させるというアイデアはどうでしょうか。

@M = qw/0 4 6 8 8 9 A B B D O P Q R/;

これを使って、置き換えるコードを書いてみます。

for (<>) {
    $l=0;
    @s = split//;
    for $S (@s) {
        for (@M) {
            ++$t && ++$l if $S eq $_;
        }
    }
    print "$t,$l\n";
}

これで+54(154bytes)まできました。 さらに文法上問題がない程度に改行や空白を省くと+15(115bytes)です。

@M=qw/0 4 6 8 8 9 A B B D O P Q R/;for(<>){$l=0;@s=split//;for$S(@s){for(@M){++$t&&++$l if$S eq$_;}}print"$t,$l
";}

このように、データに依存するプログラムであれば、それを圧縮して表現します。また言語機能を用いて圧縮して書くというのもあります。また、高度なコードゴルフのプログラムでは、プログラムの中で2つの箇所に分かれている処理を一気に行うのもコード圧縮のために必須の考え方です。

では、各言語の一位の回答を見ていきましょう。

PHP

PHPでの1位はtakaramさんの回答でした。

+8(108bytes) でした。おめでとうございます。

<?for(;$l=fgets(STDIN);){for($i=$s=0;$i<14;)$s+=substr_count($l,'046889ABBDOPQR'[$i++]);echo@$t+=$s,",$s
";}

最初のfor(;$l=fgets(STDIN);)で1行ずつ標準入力から読んでいます。

その後のさらに内側のforループで$i$sを初期化し、$i<14という終了条件があります。14というマジックナンバーはこのコードにおけるカウンターの対応表046889ABBDOPQRの文字数です。

substr_count関数で入力行の$lにあるカウンター文字の数を調べています。$iはここで文字列スライスのインデックスの形で登場しています。

$sに行ごとのカウンター数が加算されていき、最後にechoで出力しています。@$t+=$s@がついているのは、最初のループの時点では$tが初期化前の変数のため、Warningが出てしまいます。その抑制のために@がついています。Warningが出るだけなら無視できるのでは?と思うかもしれませんが、今回の環境ではWarningが標準出力に出てしまうため、回答に混ざってしまいます。なので抑制する必要がありました。

なお、社内の事前回答ではもっと短いものもあったので紹介します。どこが違うか調べてみてくださいね。

<?for(;$l=fgets(STDIN);)echo@$t+=$c=($f='preg_match_all')('/[04689ABDO-R]/',$l)+$f('/[B8]/',$l),",$c
";

JavaScript

JavaScriptでの1位はdo7beさんの回答でした。

-10(90bytes) でした。おめでとうございます。

for(s=0;h=0,l=std.in.getline();print([s+=h,h]))for(k of l)for(c of'046889ABBDOPQR')h+=k==c

JavaScriptはQuickJS(厳密にはNG版)で動作しています。なので、標準入力はstd.in.getline()という形で使うようになっています。

最も外側のループでは、s, h を0に初期化し、lを標準入力から得られた1行、そして行ごとに実行される式としてprintがあります。ここで累積と各行のカウンター数を出しているんですね。

JavaScriptは文字列(ここではl)をofでループさせると、一文字ずつに分解してループできます。1

私が最初に書いたPerl版のように二重ループで文字の一致を行なっていき、hに行ごとのカウンター個数を加算しています。

Python

Pythonでの1位はalceaさんの回答でした。

-23(77bytes) でした。おめでとうございます。

c=0
while 1:s=sum(map('046889ABBDOPQR'.count,input()));c+=s;print(f'{c},{s}')

いきなり無限ループwhile 1:があるのですが、終了条件はどうなっているかというと、実行ログを見るとなるほどとなります。

Traceback (most recent call last):
  File "//code.py", line 2, in <module>
    while 1:s=sum(map('046889ABBDOPQR'.count,input()));c+=s;print(f'{c},{s}')
                                             ~~~~~^^
EOFError: EOF when reading a line
/var/task/script/../judge_local/test.t ..

EOFになった時にエラーになるのでそれで強制終了するようになっていました。今回のjudgeサーバーではexit statusを見ていなかったので、これが通ってしまっていたようです。

さて、カウント方法等は今まで紹介した言語と同様です。'046889ABBDOPQR'.count で関数オブジェクトが返ってきます。input()で得た標準入力の文字列がイテレータブルオブジェクトと解釈され、一文字ずつ'046889ABBDOPQR'に何回登場するかという処理がされています。その合計をsumでまとめて変数sに入れています。

Ruby

Rubyでの1位はmameさんの回答でした。

-36(64bytes) でした。おめでとうございます。

puts$<.map{|s|[$.+=~-n=%w(04689ABDO-R 8B).sum{s.count it},n]*?,}

一気にやってることがわからなくなってきましたね! コードゴルフは極めていくとこのようにどこまでが何の処理だかぱっと見でわからなくなるのが醍醐味です。

いきなり標準出力への出力putsをしていますが、これは読み飛ばして、まずは$<ですね。これは標準入力を表す仮想ファイルです。2 なのでこのあとのmapは、標準入力に対してのメソッドです。mapのブロック内に渡される要素sは1行を表します。

$.は読んだファイルの行番号3なのですが、行番号としては使わず、0に初期化されている変数として用いています。つまりこの右辺はカウンターを数える計算をしているはず。

~-は最後にかかっているのでこれは一旦飛ばします。nは行ごとのカウンター数ですね。問題の%w(046789ABDO-R 8B)ですが、%w(...)は空白区切りの文字列の配列を作る記法です。つまりこの場合は、["04689ABDO-R", "8B"]と等価です。その配列のsumメソッドでs.count itとしています。sは標準入力の行です。itは配列の要素が入ります。ちなみにこのitは2024年12月にリリースされたRuby 3.4で実装されたものです4。最新機能ですね! さて、s.countですが、countの引数には面白い機能があります。O-R のように-で繋げられた文字列が入力された場合、辞書順で文字を並べた際の間の文字を補完するように働きます。O-ROPQRと同じ意味です。一文字削減になりますね。2つ目の8Bはカウンターが2つあるので、二重にカウントするためにこうなっています。

さてこれで1行のカウンター数が導けたので、nに代入します。Rubyは代入の結果が返ってくるので、それも$.に入れたいというか、実際そうなっているのですが、ここで注意点です。$.は初期状態では1が入っています。これに+=してしまうと、累計文字数が1多くなってしまいます。次の行にいく時にもインクリメントされてしまうので、各行でデクリメントが必要です。そこで~-の効果があります。~はビット反転です。整数をビット反転させた場合、-(<元々の整数>) - 1という整数となります。ここではさらに-で符号反転させてますから、~-がかかった整数は実質的には<元々の整数> - と等価です。デクリメントをすると1つの式で表現できず文字数が嵩むので、文字数を少なくするためにこのようなテクニックが使われているんですね。

そして、配列に累計カウンター数と1行のカウンター数を収めて、*?,しています。?,,一文字を表します。クオートで囲むのに比べて一文字削減です。Rubyは便利だなあ。配列に*演算子で文字列をかけるとjoinと同じ効果です。Rubyは便利だなあ。というわけで、これがちゃんと通る回答になります。素晴らしいですね。

Perl

Perlでの1位はmoznionさんの回答でした。

-55(45bytes)でした。おめでとうございます。その他-55の方は多数いますが、最初に-55に到達したのはmoznionさんでした。

print$t+=$d=y/B8/A/+y/ADO-R0469//,",$d
"for<>

Rubyの技巧的なコードゴルフに比べるとPerlは「それでいけるの?」という感じですね。解説しましょう。

$tを累計カウンター数とし、$dを1行のカウンター数にするのはRubyと同様。Rubyと違いPerlは未定義の変数に加算代入が使えてしまうというのは有利な点だと言えます。

飛んで末尾のfor<>ですが、後置forで、標準入力を1行ずつ読んでいます。<>で標準入力を読む時の省略記法です。読まれた行は変数$_に暗黙的に入ります。あと後置forの文は最初のprintまでかかっているので、1行ごとにこの式全体が実行されることになっています。

問題のカウント部分です。y/B8/A/+y/ADO-R0469//ですが、2回にわたって文字列置換をしています。y/B8/A/ の部分と y/ADO-R0469// の部分です。y/../../tr/../../と等価です。yでやる方が一文字少なくてお得ですね。y/../../は基本的には$str =~ y/../../という形で置換対象文字列を=~で指定して使うのですが、省略することができます。省略した場合の置換対象文字列は$_が暗黙的に使われます。Perlって便利だなあ。y/../../を実行した際に返り値は置換が行われた文字数が返されます。最初のy/B8/A/で2つカウンターがある文字列をカウントした上で、もう一度カウントしたいのでAに変換します。そして2度目の文字列置換でカウントのみを行います。y/...//の場合は置換を行わず文字数カウントだけ行うような挙動を示します。Rubyのcountと同様、範囲演算子が使用できるのでO-ROPQRになります。

また、改行を伴うprint関数sayuse feature qw/say/;だとかuse v5.42;だとかをしないといけないので、文字数が嵩みます。そこで、print "$t,$d\n";のような形にするのが普通なのですが、この回答のように改行自体をダブルクオートに含めてしまうこともできます。これで一文字削減です。

こんな短いコードでもテクニックは潜んでいます。コードゴルフはいろんなことを教えてくれますね。

というわけで、Hole 1はPerlで解いたmoznionさんが最短でした。おめでとうございます。

Perl 1位タイの別解

もう一つ1位タイの別解のコードも共有します。

print$a+=$\=y/8B/0/+y/0469ADO-R//.$/,","for<>

nsfisisさんや、sugyanさんがこの回答をしています。

他の方の回答との違いは$\を使っている点です。特殊変数$\は「print 演算子のための出力レコードセパレータ」で、printを行う際の末尾にこの変数に入っている中身が一緒に出力されます。つまり、カウンター個数に関しては$\に入れて末尾に出力しています。また紛らわしいですが、$/という特殊変数も入れてます。これは入力レコードセパレータと呼ばれるもので、本来は入力に対して行の末尾の文字が何かを定義している変数ですが、ここでは改行文字をくっつけるために使用しています。

$\にはカウンター個数と改行文字が入るわけですが、じゃあ$aに対して+=しているのはどうなるのかというと、Perlは文字列を数値として解釈するとき、数値っぽい文字までを使用し、あとは捨てるという挙動を利用しています。$\"1\n"が入っていたとしても、$aに加算されるのは1です。この式は$aの値が返ってくるため、累計文字数がちゃんとプリントされます。print","で終わっていますが、$\に行ごとのカウンター個数+改行が含まれているためそれも一緒に末尾に出力されます。これもまた味わいがありますね。

Hole 2. BPEagle

問題文

アルファベット小文字と半角スペースを用いて分かち書きされたテキストが標準入力で与えられます。このテキストを以下のルールに従って圧縮してください。

1. テキストを半角スペースで分割し、単語にわけます

入力例: launchpad launchsite launchcrew

処理例: ["launchpad", "launchsite", "launchcrew"]

2. それぞれの単語の中で、隣り合う文字についてペアを作ります

処理例: ["la", "au", "un", "nc", "ch", "hp", "pa", "ad", "la", "au", "un", "nc", "ch", "hs", "si", "it", "te", "la", "au", "un", "nc", "ch", "hc", "cr", "re", "ew"]

3. それぞれのペアの出現頻度を調べます

処理例: { "la": 3, "au": 3, "un": 3, "nc": 3, "ch": 3, "hp": 1, "pa": 1, "ad": 1, "hs": 1, "si": 1, "it": 1, "te": 1, "hc": 1, "cr": 1, "re": 1, "ew": 1 }

4. 最も多いペアを1つ見つけてそれをAに置き換えて元の文に戻します。また置き換えたペアを記録します

最も出現頻度が多いペアが複数ある場合は、文章中で最初に出現したものを選びます。

また、置き換えたペアは辞書の形で記録します。辞書のキーはA, B, C...の順に割り当てます。

処理例: ["Aunchpad", "Aunchsite", "Aunchcrew"], { "A": "la" }

5. 2〜4を繰り返します。全ての頻度が1になるまで繰り返します

  1. ["Aunchpad", "Aunchsite", "Aunchcrew"], { "A": "la" }
  2. ["Bnchpad", "Bnchsite", "Bnchcrew"], { "A": "la", "B": "Au" }
  3. ["Cchpad", "Cchsite", "Cchcrew"], { "A": "la", "B": "Au", "C": "Bn" }
  4. ["Dhpad", "Dhsite", "Dhcrew"], { "A": "la", "B": "Au", "C": "Bn", "D": "Cc" }
  5. ["Epad", "Esite", "Ecrew"], { "A": "la", "B": "Au", "C": "Bn", "D": "Cc", "E": "Dh" }

6. 最終的に圧縮されたテキストと置き換えたペアを以下の形で出力します

1行目が置き換えたペア(辞書)をカンマ区切りで表したもの、2行目が圧縮されたテキストになります。

出力例:

A:la,B:Au,C:Bn,D:Cc,E:Dh
Epad Esite Ecrew

これもややこいなあと思うかもしれませんが、実際ややこしいです。やりたいことは文字列の圧縮です。登場頻度が高い2つの文字からなるペアの部分文字列を見つけて、それを一文字に置き換えていくというものです。

題名からわかるかもしれませんが、生成AIに使われるByte Pair Encodingという手法のアルゴリズムです。

このコードはナイーブに書くと問題文の通り5つのフェーズに分けれます。コードゴルフとしてはそれぞれのフェーズをコンパクトに書き、2つ以上のフェーズを1回の処理でまとめられるかどうかというのがポイントになっていきます。

Ruby

Hole 2は全体の最短から解説します。

Rubyおよび全体での1位はnsfisisさんの回答でした。

-148(102 bytes)です。おめでとうございます。驚異的ですね。

#!ruby -p
?A.upto(?Z){$_.scan(/(?=(.\B.))/).tally.max_by{_2}in[b],1or$*<<it+?:+b%gsub(b,it)}
puts$**?,

まずshebangを使ったコマンドラインスイッチを使用しています。

#!ruby -p

Perlでも利用できるテクニックなんですが、Rubyの-pは変数$_を最後にprintする挙動になります。今回はWebAssemblyで動かしていたので厳密にはシェルのようなものはないはずですが、動くんですね。ちなみにPerlでも動作します。

まず一番外側のループです。

?A.upto(?Z){...}

与えられる入力はアルファベットの範囲を超えないようになっているため、理にかなった指定と言えます。また、終了条件の判定をしなくて良いのもメリットですね。

$_.scan(/(?=(.\B.))/)

次に正規表現で単語を跨がずに隣り合うアルファベットのペアを抜き出しています。lorem ipsumという入力がある場合は、["lo", "or", "re", "em", "ip", "ps", "su", "um"] という配列を返しています。

.tally

このメソッドは配列内で同じ要素が出現した回数を数えてHashを返します。["ab", "bc", "ca", "ab"]という配列にtallyをすれば{:ab => 2, :bc => 1, :ca => 1}となります。便利だなあRuby。

.max_by{_2}

これはハッシュの値を使ってソートを行うことを示しています。これで、{:ab => 2, :bc => 1, :ca => 1}というハッシュに関しては[:ab, 2]という2つの要素が入った配列が返ります。

ちなみに今回の問題では同じ回数だけ出たアルファベットのペアは先に登場したものを優先するというルールがあります。これで満たせるのかな?と思ったのですが、Rubyのハッシュは入れた順番が保持され、ループ時も入れた順番で回ります。この効果で.tally.max_by{_2}でも問題がなくなっていると思われれます。

in[b],1or

さてここからが問題です。いきなりinが出てきます。分かりにくいですがパターンマッチです。直前のmax_by{_2}の結果、最大となるキー[[b], 1] に当てはまらない場合はfalseを返す式となります。そして当てはまらないケースでは、後続のor以降が走るという挙動です。ちなみに当てはまるケース、つまり[[b], 1]がマッチするケースというのは、単語中に登場するアルファベットのペアの頻度が最大でも1回、つまり重複するものがなく圧縮する必要がないケースです。過剰に圧縮するのを防いでいる部分と言えます。

しかし、後続の[[b], 1]にマッチしないケースではあたかもbにハッシュのキーが代入されている前提で書かれているように見えますし、実際これで動いています。これってRubyの仕様なの?と調べたところ、「付記B: 未定義の振る舞いの例」5 に書かれていました。「マッチしなかったパターンに指定した変数を使う」の部分ですね。パターンマッチの変数バインディングがローカルマッチにしか適用できない点を議論する際にも、このような副作用が現状あるためサポートしていない旨が話されていました。6 コードゴルフにこういうの持ち込んでくるのだいぶテクいですね!

$*<<it+?:+b%gsub(b,it)

あとは仕上げの部分ですね。$*は本来はRubyスクリプトを起動する際に与えられた引数を表す配列ですが、コードゴルフではしばしば初期化済みの配列として扱われます。この部分はアルファベットのペアを大文字のアルファベットに置換した際の辞書を作る部分です。<<はRubyではpush/appendで、itには?A.upto(?Z)でループ回している部分のアルファベットが入ってきます。?::一文字を表す文字列です。b % gsub(b, it)%sprintfのような挙動をする文字列に使える演算子です。しかしbには%sなどのフォーマットに使われるような構文が入ってこないのが問題文から保証されているので、実際には何も起きません。gsub(b, it)という部分を$*に追加したい文字列に影響を与えずに実行したい、bが使えるスコープで実行するために式としてまとめる必要があるために使われています。ゴルフですね〜〜。

Rubyなのにgsubがレシーバなしで(s.gsubのような形式ではなく)呼び出されているのはなぜ? と思った方、鋭いですね。これはKernelモジュールで定義されているgsubモジュール関数です。7 コマンドラインオプションで-pもしくは-nを指定した時のみ定義されます。最初のshebangでの指定がここで効いてきます。$_.gsubと挙動は同様ですが、$_の内容を置き換えます。これで、次のループでは圧縮済みの文字列を対象にまた同じ処理が走ります。

puts$**?,

最後に辞書をカンマ区切りで出力し、そしてruby -pの効果で大文字アルファベットに置換された圧縮済み文字列も次の行に出力されます。これで特定の条件のみですがBPEが動きました。

と、ここまで書いて、そういえば回答していただいたnsfisisさんがWrite Up記事を書いていだたいてました。こちらにもっと詳しい途中の回答などの解説も載っているのでぜひご覧ください。

blog.nsfisis.dev

Perl

Perlでの一位はmasiuchiさんの回答でした。

-126(124bytes)です。おめでとうございます。

$_=<>;for$c(A..Z){$f=$i=$_;(@b=$i=~/$_/g)>$f&&($f=@b,$b=$_)for/(?=(\S\S))/g;s/$b/$c/g,$d.="$c:$b,"if$f>1}chop$d;print"$d
$_"

ぎゅっと圧縮されてどこが切れ目かわからないと思うので整理するとこう。

$_ = <>;
for $c (A..Z) {
  $i = $_;
  $f = $i;
  for (/(?=(\S\S))/) {
    if ((@b = $i =~ /$_/g) > $f) {
      $f = @b;
      $b = $_;
    }
  }
  if ($f > 1) {
    $_ =~ s/$b/$c/g;
    $d .= "$c:$b,";
  }
}
chop$d;
print"$d
$_"

スペースや改行入れる他には、後置forや後置ifを普通のfor, ifに置き換えたりしました。式にするために&&などを使っている箇所も、文に分けて置き換えています。

$_ = <>;
for $c (A..Z) {

標準入力から取り込んで、A..Zまでループする部分です。Rubyと同じ戦略ですね。PerlもRubyと同様shebangを使って$_ = <>;と同様のことができるんですが、<>が短すぎてshebangを書いた方がむしろ文字数が嵩んでしまいます。

  $i = $_;
  $f = $i;
  for (/(?=(\S\S))/) {

後のforループで$_/(?=(\S\S))/の結果が入ってきてしまうので、$iに入力を待避させています。この/(?=(\S\S))/はRubyで使われているのと同様、隣接する空白文字ではない文字のペアをとる正規表現です。

    if ((@b = $i =~ /$_/g) > $f) {
      $f = @b;
      $b = $_;
    }

このif文の中がテクいです。$f$f = $iの通り、ループの最初では文字列が入っています。一方左辺値は@b = $i =~ /$_/gなので、入力文字列中に含まれているアルファベットのペアが個数分@bに入ってきます。そして>なので@bはスカラコンテキストで評価され、配列のスカラコンテキストは要素の個数が返ります。

つまり、このif文は数値と文字列を数値比較演算子(Perlは文字列用の比較演算子が別であります)で比較しているわけですが、これは一体どうなってしまうのか?

結論を言うと、このケースと入力文字列では文字列が入っている場合は右辺は0と解釈されます。Perlは数字っぽい文字は数字として解釈しようとします。なので、"1"という文字列が変数に入っていたとしても、数値比較演算子で正しく扱えます。この問題の場合、入力はアルファベットに限られているため、数字が入ってくることはありません。そのケースでは0と等価です。なので、初期状態では、

if (<アルファベットのペアがマッチした個数> > 0) {

となり、この個数は必ず1以上のため、ifの中に入ります。そして次の行の$f = @bはスカラコンテキストで配列を評価しているので$fには個数が入ります。そして$bにはアルファベットのペアが入ります。このif文全体で「アルファベットのペアのうち最も登場するものを$bに入れる。というコードです。また、比較演算子が >=ではなく > なので、最も頻度が多いペアのうち最初に登場したペアを置き換えるルールも実現できています。

  if ($f > 1) {
    $_ =~ s/$b/$c/g;
    $d .= "$c:$b,";
  }

$fつまり頻度が1以上、圧縮する必要がある場合は、置換を行います。ちなみに提出しているコードは $_ =~が省略されています。単にs/.../.../g と書くと $_に置換を行うコードになります。

そして$dに辞書となる文字列を連結していきます。

chop$d;
print"$d
$_"

$dには必ず末尾に , をつけながら連結していくため余計な , が末尾に含まれているはずです。これをchopで切り飛ばします。

そして、結果を出力するという形です。

以上です。整理するとちゃんと読めるのではないのでしょうか?

Python

Pythonでの1位はalceaさんの回答でした。

-96(154bytes)です。おめでとうございます。

s=input()
d=e='@'
while(f:=(g:=s.replace)(*' $').count)(c:=max(map(str.__add__,e+s,s),key=f))>1:s=g(c,e:=chr(ord(e)+1));d+=','+e+':'+c
print(d[2:]+'\n'+s)

これもギュッとなってますね。Pythonでもこんなに切れ目がわからないコードが書けるんですね。バラしていきましょう。

s = input()
e = '@'
d = e
while (
    (f := (g := s.replace)(*' $').count)(
        c := max(
            map(str.__add__, e + s, s),
            key=f,
        )
    )
) > 1:
    s = g(c, e := chr(ord(e) + 1))
    d += ',' + e + ':' + c

print(d[2:] + '\n' + s)

気になる点を見ていきましょう。:=はウォルラス演算子というやつで、Pythonの普通の代入と違い、式として解釈されます。なのでwhile (f := ...) > 1... の部分で返した数値を1を比較して...の部分が1より大きい間はループを続けます。今まで見た通り、fはアルファベットのペアのうち最も多く登場するペアの登場頻度ですね。

    (g := s.replace)(*' $')

(g := s.replace) は一旦 gs.replace というメソッド自体を入れてます。よく見るとwhileの中でgを使っていますね。replaceを2回書くより短くなるためこうなっていますね。

またここでウォルラス演算子を使って代入したばかりのs.replaceを呼び出しています。(*' $')もなかなかテクいのですが、これは(' ', '$')と等価です。つまり文字列を2つに分解して引数にわたすことをしています。これは何をやっているかというと、空白、つまり単語の区切りを$に変換しています。lorem ipsumという文字列が入力されたら、lorem$ipsumという文字列が返ってきます。

    (f := (g := s.replace)(*' $').count)(
        c := max(
            map(str.__add__, e + s, s),
            key=f,
        )
    )

さらにこの文字列のcountfに代入しつつ呼び出しています。countは引数に渡された文字列が自身に何回登場するかを調べるメソッドです。この中でもウォルラス演算子を使用していますね。maxはここでは2つの引数を受け取っています。一つ目はこちら

            map(str.__add__, e + s, s),

mapは2つ目の引数に指定されたイテレータに対して1つ目の引数に指定された関数を適用します。しかし3つ目にイテレータを入れると2つ目と3つ目のイテレータから1つずつ要素を取り出して、1つ目の関数の引数に適用します。入力がlorem isumであれば、

map(str.__add__, '@lorem ipsum', 'lorem ipsum')
# => ['@l', 'lo', 'or', ...]

というイテレータを返すことになります。つまりRubyやPerlが正規表現でやったようなことをPythonはこのようにやっています。

maxのキーワード引数keyは比較用の関数を渡しています。この場合、比較用の関数は、

g := s.replace)(*' $').count

の部分で、つまり出現回数を数えています。そしてcには最も多く出現したペアが入ります。つまり、再掲しますが、

    (f := (g := s.replace)(*' $').count)(
        c := max(
            map(str.__add__, e + s, s),
            key=f,
        )
    )

この部分全体では、「アルファベットのペアのうち最も多く出現するペアの頻度を返す」式となっています。ちなみにRubyとPerlでは正規表現で除外されていた単語の区切りを跨いだり、空白を含んだペアですが、それは空白を$で置換することで実現しています。出現回数を調べるときには、$で置換された文字列を対象に、$で置換していない文字列から作ったイテレータを使って検査しています。よって、aのようなペアがあっても、対象の文字列にはa$で置換されているため、マッチしません。巧妙ですね〜〜!

あと言うべき点としては、

e = '@'
...
    s = g(c, e := chr(ord(e) + 1))

ここですね。Aの前の文字列はascii順で言うと@なので、常に加算した値を使うために@を入れています。

以上です。Pythonでもこんなに技巧的なコードが書けるんだと感動してしまいました。

PHP

PHPでの1位はtakaramさんの回答です。

-25(225bytes)でした。おめでとうございます。

<?$s=fgets(STDIN);for($n=64,$d='';;$s=strtr($s,[$k=>$c=chr(++$n)]),$d.="$c:$k,",$p=[]){for($i=0;$i<999;)strstr($t=substr($s,$i++,2),' ')||!$t||@$p[$t]++;if(arsort($p)&$p[$k=array_keys($p)[0]]<2)break;}echo trim($d,','),"
$s";

整理するとこうです。

<?php
$s = fgets(STDIN);
for (
    $n = 64, $d = '';
    ;
    $s = strtr($s, [ $k => $c = chr(++$n) ]),
    $d .= "$c:$k,",
    $p = []
) {
    for ($i = 0; $i < 999; ) {
        $t = substr($s, $i++, 2);
        strstr($t, ' ') || !$t || @$p[$t]++;
    }
    arsort($p);
    $k = array_keys($p)[0];
    if ($p[$k] < 2) {
        break;
    }
}
echo trim($d, ','), "\n", $s;

要点を見ていきましょう。

    $n = 64, $d = '';

64というマジックナンバーはasciiコードでいう@を表します。Pythonの際の@と同じ効果です。

    for ($i = 0; $i < 999; ) {
        $t = substr($s, $i++, 2);
        strstr($t, ' ') || !$t || @$p[$t]++;
    }

substrは文字列から位置を指定して文字列を抜き出します。1文字ずつスライドしながら2文字取り出しているのでアルファベットのペアを作っている部分ですね。strstr($t, ' ')は空白を置換していますが、置換ができなかった場合はfalseを返します。つまり、空白が含まれてない場合は!$t || @$p[$t]++;の部分が実行されるということですね。!$tですが、substr($s, $i++, 2)$i$sの文字列長を超えた場合は、substrは空白が返ってくるため、そのガードのためにあります。これらのガードを乗り越えると連想配列$pのアルファベットのペアのキーに対してインクリメントされます。@は未初期化変数に対してインクリメントする際のWarningを出さないためですね。

    arsort($p);
    $k = array_keys($p)[0];
    if ($p[$k] < 2) {
        break;
    }

arsortは連想配列用のソート関数です。これで最も多く登場するアルファベットのペア順に並びます。array_keysで連想配列のキーのみの配列を作り、その先頭を取ることで、最も多く登場するアルファベットのペアを$kに入れます。PHPもまた連想配列を入れた順序で保持するので、最も頻度が高く最初に登場したキーを取り出すルールを満たせています。

以上です。ちなみに、社内の予備回答ではもっと短い回答があったので参考までに載せておきます。どこか違うか検討してみてください。

-51(199bytes)です。

<?for($s=fgets(STDIN),$b='A';;$b++){preg_match_all('/(?=(\w\w))/',$s,$m);$c=array_count_values($m[1]);arsort($c);if($c[$k=key($c)]<2)die(@substr($e,1)."\n$s");$s=str_replace($k,$b,$s);@$e.=",$b:$k";}

JavaScript

JavaScriptでの1位はdo7beさんの回答でした。

-27(223bytes)です。おめでとうございます。

l=std.in.getline()
for(w=[],j=65;m={},l.split` `.map(s=>{for(i=h=0;s[i+1];)m[v=s[i]+s[++i]]=-~m[v]}),eval("for(e in m)m[e]>h&&(h=m[e],b=e)"),h>1;)w.push((k=String.fromCharCode(j++))+':'+b),l=l.replaceAll(b,k)
print(w+`
`+l)

こちらも整理します。

l = std.in.getline()

for (
  w = [], j = 65;
  m = {},
  l.split` `.map(s => {
    for (i = h = 0; s[i + 1]; ) {
      m[v = s[i] + s[++i]] = -~m[v]
    }
  }),
  eval("for(e in m)m[e]>h&&(h=m[e],b=e)"),
  h > 1;
) {
  w.push((k = String.fromCharCode(j++)) + ':' + b)
  l = l.replaceAll(b, k)
}

print(w + `\n` + l)

まずここを解説しましょう。

  l.split` `.map(s => {
    for (i = h = 0; s[i + 1]; ) {
      m[v = s[i] + s[++i]] = -~m[v]
    }
  }),

lは入力文字列ですが、これをまずsplitで空白区切りにします。ちなみにsplitの引数に括弧がないですが、タグ付きテンプレートリテラルとして呼んでいるのでl.split([" "])と等価です。

そのあとmapで単語ごとに処理をしているわけですが、forループを使用しています。s[i + 1]なので、末尾に到達してundefinedを返すまでループを繰り返します。m[v = s[i] + s[++i]]は技巧的です。隣接するアルファベットのペアを作っていますが、2つ目は++iを使うことで、forループのiのインクリメントとインデックスアクセスを同時にしています。

-~m[v]m[v] + 1と等価です。しかし + 1ではなくあえてこうしているのには理由があります。最初はm[v]は未定義です。未定義に+ 1をしてもうまくいきません。一方で-~は未定義であっても1を返します。

というわけで、mにアルファベットのペアの登場頻度を表すobjectが作られました。

  eval("for(e in m)m[e]>h&&(h=m[e],b=e)"),
  h > 1;

そして突然のevalですが、これは式じゃないと置けない部分にループを入れるために使われています。このeval部分で最も登場するアルファベットのペアがbに入り、その頻度がhに入ります。そのhを使って、ループの終了条件を決めています。

以上です。evalやタグ付きテンプレートリテラルを使うのは面白いアイデアですね。

まとめ

全体の優勝はnsfisisさんでした。おめでとうございます。

またご参加していただいた皆様もありがとうございます。今回は前回に比べて多くの方に参加していただきました。盛り上がりも観測できたかと思います。

参加していただいた方にアンケートもさせていただきました。一部ピックアップして紹介させていただきます。紹介させていただく設問は「生成AIは使用しましたか?」と「問題を解く際に生成AIを使い始めたのはいつぐらいからですか?」です。

今回のコンテストではLLMの使用は明示的に許可していました。というのも、急速に発展したLLMによるコーディングですが、コードゴルフにも適用できるかどうかに興味があったからです。事前に社内では単純なプロンプトでは人間で書くより長くなってしまうので、まだ人間は負けてないぞと思いましたが、プロンプトがうまければ短くできるのではないか?とも思っていました。

アンケート結果がこちら。

また、「もし生成AIを使用した場合にコードゴルフを行う上で困難だったことを教えてください」という自由回答の設問も用意しましたが、「最初に参考回答を短くする程度には役立つものの、トップ5を狙うぐらいになってくると使えない」という回答でした。また、LLMは文字数ではなくトークン単位で文章を認識しているため、文字数を短くするというタスクは苦手ではないかという考察もWrite Up記事で見かけしました。

これ以外にも改善点などももらっております。次回以降の開催でもっと面白く、もっと多くの方にコードゴルフに興味を持っていただけたらなと思います。

レッツコードゴルフライフ! 皆様お疲れ様でした〜。良いお年を!

AIエージェントにSOLID原則を叩き込んでやろうじゃないか

こんにちは!カヤックボンドの鈴木です。こちらは面白法人グループAdvent Calendar 202524日目の記事です。

サッカーを見ることが好きなのですが、僕の愛する柏レイソルが優勝を逃してしまい、悔しい、歯痒い気持ちを抱えながら記事を書きます。

今回はバイブコーディングにおけるプログラミング原則を徹底させることの影響について書いていきたいと思います。

はじめに

言うまでもないですが、近年AIが見せている凄まじい成長は、常に我々を驚かせ、そして脅かしています。

AIを利用して生産性を向上させる取り組みも業界に限らず見られ、一種の社会現象とも言えるでしょう。

直近だと、我々エンジニアという仕事に最も影響を与えているサービスは、AIによるコーディングエージェントだと思います。

プロンプトを投げるだけでそれに沿ってタスクを進めてくれる技術は、エンジニアが1日で終えることのできる仕事量を大幅に増やしました。

そんな中、僕のような新卒1年目のエンジニアはこうも思うわけです。

「んー、俺がいる意味ってなくね?」

仕様通りに機能が実装されるようにプロンプトを投げて実装してもらい、ある程度動作確認を済ませ、ソースコードを自らレビューし、PRを作成して先輩にレビューしてもらう。

この流れで僕が行なっていると言えるのは「AIの出力をレビューすること」です。

  • ただAIの出力に二重でレビューしているだけ
  • 僕の後にレビューしてもらう先輩は僕の知識や考えのほとんどをカバーしているため、僕が確認する意味がない

こういった感想を抱きました。将来完全に淘汰されるであろうエンジニアの典型ですね。

こんな風に下地のないジュニアが技術の進歩に甘えてAIに依存していると、生産性は向上するけど自分自身は何も変わらないだろうな、と感じました。

そこで、「ソフトウェアエンジニアをやっている限りどんな場面でも基本的に活かすことができるだろう設計思想を学ぼう」という考えに至りました。

自分も学びつつ、アウトプットの質も上げてもらいたいということで、これからエージェントに叩き込んでいこうと思います。

原則を遵守させるとどう変わるか

弊社の開発業務で利用しているClaude Codeを使いながら、試しにSOLID原則を叩き込んでいこうと思います。

SOLID原則についての詳しい説明は省きますが、一言で表すなら

「オブジェクト指向プログラミングにおいて、保守性・拡張性・再利用性の高いソフトウェア設計をするための、5つの基本原則」

です。以下の記事がとても分かりやすいです。

マリオで学ぶSOLID原則

本記事では、ラーメン屋の営業を例に、SOLID原則をClaudeに遵守させてみようと思います。

まずはClaudeに叩き込んでいきましょう。

読み込ませるコンテキスト

SOLID原則に対応して5つのファイルにそれぞれの原則について記しました。

S (Single Responsibility):単一責任の原則

SingleResponsibility.md
# Single Responsibility Principle (SRP) - 厳守ガイドライン

## 基本原則

各コンポーネントは1つの明確な責任のみを持ち、その責任を完全に果たすことに集中する。
複数の責任を持つコードは、必ず分割すること。

## 実装時の厳守ルール

### 1. クラス設計の原則
- 1クラス = 1つの責任
- クラス名は責任を明確に表現する
- 「そして」「また」「さらに」という説明が必要なクラスは分割対象

### 2. 関数・メソッドの原則
- 1関数 = 1つのタスク
- 関数名が動詞+目的語の形で明確に表現できること
- 5-10行を目安とし、最大でも20行以内

### 3. 違反を検知する警告サイン
- クラスに複数の public メソッドグループが存在する
- 異なる理由で変更される可能性がある
- テストが複雑になる
- 「Manager」「Handler」「Processor」などの汎用的な名前

O (Open-Closed):オープン・クローズドの原則

OpenClosed.md
# Open-Closed Principle (OCP) - 厳守ガイドライン

## 基本原則
**ソフトウェアのエンティティ(クラス、モジュール、関数)は拡張に対して開いており(Open)、
修正に対して閉じている(Closed)べきである。**

新機能を追加する際は、既存のコードを変更するのではなく、新しいコードを追加することで実現する。

## 実装時の厳守ルール

### 1. 絶対に避けるべきパターン
- switch文やif-elseチェーンでの型判定
- 既存クラスへの新メソッド追加による機能拡張
- 条件分岐の追加による振る舞いの変更
- 既存メソッドの内部ロジック修正

### 2. 推奨する実装パターン
- 抽象化(インターフェース/抽象クラス)の利用
- 継承よりコンポジション
- Strategy、Template Method、Decoratorパターンの活用
- プラグインアーキテクチャの採用

### 3. 違反を検知する警告サイン
- 新機能追加時に既存ファイルを編集している
- 型やenumでの分岐が増えている
- "もう一つケースを追加"というコメント
- テストケースが既存のものを修正する必要がある

L (Liskov Substitution): リスコフの置換原則

LiskovSubstitution.md
# Liskov Substitution Principle (LSP) - 厳守ガイドライン

## 基本原則
**派生クラスは、その基底クラスと置換可能でなければならない。**

サブタイプ(派生クラス)のオブジェクトは、プログラムの正しさを損なうことなく、
その基底タイプ(親クラス)のオブジェクトと置き換えられるべきである。


## 実装時の厳守ルール

### 1. 契約の保持
- **事前条件を強化してはいけない**(派生クラスは基底クラスより厳しい入力制約を課してはならない)
- **事後条件を弱めてはいけない**(派生クラスは基底クラスが保証する出力を保証しなければならない)
- **不変条件を維持する**(基底クラスの不変条件は派生クラスでも保持される)

### 2. 禁止事項
- 派生クラスで例外を投げる条件を追加しない
- 基底クラスのメソッドを空実装やNotImplementedErrorで上書きしない
- 基底クラスが期待する振る舞いを変更しない
- 基底クラスのメソッドの意味を変えない

### 3. 違反を検知する警告サイン
- 派生クラスで基底クラスのメソッドを使わない/使えない
- instanceof や type() での型チェックが必要
- 派生クラスで「このメソッドは使用しないでください」というコメント
- オーバーライドしたメソッドが全く異なる振る舞いをする

I (Interface Segregation):インターフェイス分離の原則

InterfaceSegregation.md
# Interface Segregation Principle (ISP) - 厳守ガイドライン

## 基本原則
**クライアントは、自分が使用しないメソッドに依存することを強制されるべきではない。**

大きな汎用インターフェースよりも、小さな特定目的のインターフェースを複数作る。
「太った」インターフェースは「痩せた」複数のインターフェースに分割すべきである。

- インターフェースは、実装者の都合ではなく、**使用者(クライアント)の視点**で設計する
- 1つのインターフェース = 1つの役割/責任
- 必要なメソッドだけを公開し、不要な依存を作らない

## 実装時の厳守ルール

### 1. インターフェース設計の原則
- **役割ベースの分離**: 各インターフェースは明確な1つの役割を表現
- **クライアント特化**: 使用者が必要とするメソッドのみを含む
- **高凝集性**: インターフェース内のメソッドは密接に関連
- **依存の最小化**: 不要なメソッドへの依存を排除

### 2. 禁止事項
- 「なんでもできる」万能インターフェースの作成
- 実装を強制するだけの空メソッド
- 異なる責任を持つメソッドの混在
- NotImplementedErrorやUnsupportedOperationExceptionの使用

### 3. 違反を検知する警告サイン
- インターフェースに5つ以上のメソッドがある
- 実装クラスで空実装やエラーを投げるメソッドがある
- 「〜Manager」「〜Handler」などの汎用的な名前
- クライアントが一部のメソッドしか使わない
- メソッド間の関連性が低い

D (Dependency Inversion) 依存性逆転の原則

DependencyInversion.md
# Dependency Inversion Principle (DIP) - 厳守ガイドライン

## 基本原則
**1. 上位モジュールは下位モジュールに依存してはならない。どちらも抽象に依存すべきである。**
**2. 抽象は詳細に依存してはならない。詳細が抽象に依存すべきである。**

依存関係の方向を逆転させ、具象実装ではなく抽象(インターフェース)に依存することで、疎結合で柔軟なシステムを構築する。

- **従来の依存**: ビジネスロジック → データベース(具象に直接依存)❌
- **逆転した依存**: ビジネスロジック → Repository(抽象)← データベース実装 ✅
- 依存の方向は「安定」に向かうべき(抽象は安定、具象は不安定)

## 実装時の厳守ルール

### 1. 依存関係の原則
- **具象クラスを直接インスタンス化しない**(newやコンストラクタ呼び出しを避ける)
- **抽象(インターフェース/抽象クラス)を通じて依存を注入**
- **依存は外部から注入される**(コンストラクタ注入推奨)
- **フレームワーク/ライブラリへの直接依存を避ける**

### 2. 禁止事項
- ビジネスロジック内でのnew演算子使用
- 具象クラスの型を引数や戻り値に使用
- 静的メソッドへの直接呼び出し
- グローバル変数やシングルトンへの直接アクセス
- 外部ライブラリの直接import(ラッパーなしで)

### 3. 違反を検知する警告サイン
- importが具象クラスを指している
- コンストラクタ内でnewを使用している
- テストでモックが困難
- 実装を変更するとビジネスロジックも変更が必要
- 循環依存が発生している

少し長くなってしまいましたが、Claudeに与えるコンテキストにこれら5ファイルを準備しました。 記事に載せる都合もあり、なるべくシンプルにしています。また、それぞれの原則への解釈について、正確ではない表現も含まれていると思いますが、ご容赦ください🙇🏾

CLAUDE.mdにこれら5ファイルを読み込むよう指示を出しています。

タスク依頼時に投げるプロンプトは以下にしました。

# 言語
TypeScript

# 依頼したいタスク
ラーメン屋の営業をオブジェクト指向プログラミングで表現して欲しいです。
客が入店してから、食べ終わって店を出るまでの考えられるユースケースを実装してください。

# 補足
srcディレクトリ内に実装を進めてください。
クラス設計は任せます。

コンテキストによる違いをよりはっきりさせるために、プロンプトに情報をあまり入れていません。

特に遵守させない場合

まずは、コンテキストは何も用意せず、このプロンプトだけを投げてみました。

すると、以下のようなディレクトリで出力してくれました。

src/
├── RamenItem.ts (ラーメンメニュー項目クラス - 名前、価格、説明を管理)
├── Menu.ts (メニュー管理クラス - メニュー項目の追加・検索・表示)
├── Table.ts (テーブル/席管理クラス - 空席確認・着席・退席処理)
├── Customer.ts (顧客クラス - 所持金管理・支払い・食事処理)
├── Order.ts (注文管理クラス - 注文項目・状態管理・合計金額計算)
└── RamenShop.ts (ラーメン屋メインクラス - 店舗全体の営業フロー管理)

適当なプロンプトしか投げていないのだから当然なのですが、いくつか問題点が分かってきました。

まずは単一責任の原則についてです。例えばRamenShopクラスですが、様々な振る舞いを持たされています。お店側が主語となるユースケースを全てこのRamenShopで表現できるようロジックが集約されていました。

RamenShop.ts

setStatus(status: OrderStatus): void {
    // 遷移ルールをここに追加
    if (this.status === OrderStatus.COOKING && status === OrderStatus.CANCELLED) {
        throw new Error('調理中はキャンセルできません');
    }
    if (this.status === OrderStatus.READY && status === OrderStatus.ORDERED) {
        throw new Error('この遷移は不正です');
    }
    // ... 他にも条件が増えていく
    this.status = status;
}

他にも、Orderクラスにある注文状態を設定するsetStatusメソッドを見てみると、条件が増えるたびにifによるチェックが増えていく、OpenClosedの原則に反したものも見られました。

全体的に、「レビューで指摘するほどじゃないけど、ここはこうしてほしいな...」というコードが多く見られました。

リスコフの置換原則、インターフェース分離の原則については、インターフェースもなく継承なども特にしていないため、これらに反するところはないように見えました。

実装の規模からしても、正直なところ原則をしっかりと守る必要はありません。ですが今回は学ぶことが目的なので、原則を含めたコンテキストを読み込ませるとどうなるか、下で見ていきます。

しっかりと遵守させる場合

以下のようなディレクトリ構造で出力してくれました。(ここまでする必要はないというご意見はご容赦ください)

src/
├── domain/ (ドメイン層 - ビジネスロジックの中核)
│   ├── exceptions/ (ドメイン例外 - ビジネスルール違反を表現)
│   ├── models/ (ドメインモデル - エンティティと値オブジェクト)
│   │   └── values/ (値オブジェクト - 不変の値を表現)
│   ├── repositories/ (リポジトリインターフェース - 永続化の抽象)
│   └── services/ (ドメインサービス - 複数エンティティにまたがるロジック)
│       ├── implementations/ (ドメインサービス実装 - ビジネスロジックの実装)
│       └── interfaces/ (ドメインサービスインターフェース - サービスの抽象)
│
├── application/ (アプリケーション層 - ユースケースの実行)
│   ├── dto/ (データ転送オブジェクト - リクエスト/レスポンスの型定義)
│   └── usecases/ (ユースケース - ユーザーの操作フロー)
│       ├── implementations/ (ユースケース実装 - 具体的なフロー処理)
│       └── interfaces/ (ユースケースインターフェース - ユースケースの抽象)
│
└── infrastructure/ (インフラ層 - 技術的な実装詳細)
    ├── factories/ (ファクトリ - オブジェクト生成の責務)
    └── repositories/ (リポジトリ実装 - データ永続化の具体実装)

ぱっと見ですが、めちゃめちゃしっかりしているように見えます。

では原則が徹底されているかどうか、一部を取り上げてみます。

context-EnterStoreUserCase.ts

客が入店するユースケースを表現したクラスについて取り上げます。このクラスは以下の点において、単一責任の原則(Single Responsibility)を果たしています。

  • 単一の責任: 「入店から座席割り当てまで」という1つの明確な責任のみ
  • 変更理由は1つ: 入店フローの仕様変更時のみ変更される
  • 他の責任は委譲:

    • 顧客の生成 → customerFactory
    • データ永続化 → customerRepository
    • 座席割り当てロジック → seatAllocationService
  • 関数は1タスク: execute() メソッドは「入店ユースケースの実行」のみ

context-PaymentService.ts

次に支払いを表現したサービスクラスを取り上げます。このクラスは以下の点において、OpenClosedの原則を果たしています。

  • 拡張に開いている: 新しい支払い方式(クレジットカード決済、電子マネーなど)を追加する場合、IPaymentServiceを実装した新しいクラスを作るだけ
  • 修正に閉じている: 既存のPaymentServiceを変更せずに機能拡張可能
  • switch文を避ける: 支払い方法による分岐は不要。DIコンテナで注入する実装を切り替えるだけ

また、リスコフの置換原則(Liskov Substitution)においても、以下の点で原則を果たしていると言えます。

  • PaymentService の任意の実装と置き換え可能
  • 入力: OrderId, Money → 出力: Payment または例外

    • 例外や事前条件、事後条件についてはインターフェースで契約していないが、守られてはいる
  • 支払い方法が変わっても(現金、クレジットカードなど)、この契約は変わらない

context-ProcessPaumentUseCase.ts

ProcessPaymentUseCaseは、シンプルながらインターフェイス分離の原則と依存性逆転の原則を果たしています。

  • インターフェース分離の原則

    • たった1メソッドの極めて小さなインターフェースに依存
    • 使用しない機能への依存が一切ない
    • クライアント(ProcessPaymentUseCase)の視点で設計されたインターフェース
  • 依存性逆転の原則

    • 具象クラスを直接インスタンス化せず、抽象(IPaymentService)に依存
    • コンストラクタインジェクションで依存を外部から注入
    • 実装の差し替えが容易で、テストが簡単

まとめ

コンテキストにSOLID原則について含めるだけで、コードの品質はだいぶ良くなったと思います。他にもClaude Codeにはagent skillsというコンテキストを圧迫しない方法もあるため、多くの情報を組み込みたい場合に有効です。

途中から参画した案件のコードベースを変えることは難しいかもしれませんが、新規開発が始まってまもない案件、そこまで大きくない案件などでは、自分から既存のコードベースを変える提案、またそれを実行するという意味でも役立つのではないのでしょうか。

コーディングをAIに任せすぎると技術負債が溜まっていくことは危惧されていることではありますが、プロンプトやコンテキストの設計をめんどくさがらずに取り組むことで、ある程度は負債が溜まるリスクを軽減できるでしょう。

また、本記事前半で「自分の成長に繋げたい」と述べました。僕は現在の現場で実際に、上述のコンテキストを用いて実装を進めています。現場のソースコードをベースに、好ましい場合やアンチパターン、またClaudeの出力が本当に正しいかどうかを考えることも含めて、学びに非常に役立っていると実感しています。

kayac.bond