ブログページのロゴマーク猫が綴る雑多なブログ

800行のJavaスタイルガイドを書いた話

「なんとなくこっちのほうが居心地がいい」を言語化していったら、コードの意味構造を字面に投影するためのルールが見えてきました。

はじめに

本稿は、GitHub[yui-Kitamura/lint]の話。 そして、それは手書きして積み重ねてきたJavaコードから逆算した、私の個人的なコーディング方言の話。

lintといえば通常は機械的な検査を連想するが、ここでは「こっちのほうが居心地がいい」が先にあり、その感覚を後から意味構造として言語化したものである。

私のlint

  1. 最上位原則:意味構造を字面へ投影する
    空白・改行・インデントは装飾ではなく、「何が同格か」「何が包含されるか」「どこで意味が閉じるか」を可視化するための句読点。

    私のコーディングスタイルを言語化していくと、多くの規則がここに行き着いた。

    空白や改行、インデントは、単にコードをきれいに並べるためのものではない。コードが持っている意味上の構造を、そのまま字面にも現すために使っている。同じ意味階層にあるものは同じ位置に置き、包含関係があれば一段下げ、ひとまとまりの意味が終われば改行や空白行で区切る。

    自然言語を書くときに読点や句点、段落を使うように、コードを書くときにも空白や改行を使っている、と考えると近い。コンパイラにとっては大部分が無視してよい空白文字でも、人間がコードを読むときには意味を持っている。

  2. java.mdの骨格
    • bracesは省略しない
    • guard clause優先
    • 論理条件は並列関係を同じ深さに揃える
        if( conditionA() ||
            conditionB() ){
      
    • 行継続は前行末で示す
    • 空白行は意味上の段落区切り

    個々の規則だけを取り出せば、珍しくないものも多い。bracesを省略しない、guard clauseを優先する、といった規則は、それだけなら一般的なコーディング規約にも見られる。

    一方で、私のlintでは「なぜその字面にするのか」をかなり細かく書いている。たとえば複数行にまたがる論理条件では、条件同士が同格なら字面でも同じ深さに置く。行を継続するなら、その事実は次の行を読む前に分かるよう、前の行の末尾に演算子を置く。

    こうした規則を積み重ねた結果が、現在のjava.mdである。

Javaのlintを噛み砕く

具体的にいくつか例を挙げてみる。

同格のものは同じ深さに置く

複数の条件を論理演算子でつなぐ場合、それぞれの条件が同格なら、字面でも同じ深さに置く。

Yes🐈
if( conditionA ||
    conditionB){
   return false;
}
NG💢
if(conditionA
    || conditionB){
   return false;
}

conditionAとconditionBは論理的には同格である。 それなら、片方だけが一段深いところにいるような字面にはしたくない。

また、||を前の行の末尾に置くことで、その行を読んだ時点で「この式はまだ続く」と分かる。 次の行まで読んで初めて前の行が継続していたことに気付く、という書き方にはしない。

括弧は意味上の「項」を見せる

演算子の優先順位によって正しく評価されることと、式の意味上のまとまりが一目で見えることは別である。

Yes🐈
int i = 22;
boolean valid = i < (size - 1);
NG💢寄り
boolean valid = i < size - 1;

後者でもJavaとして評価される結果は同じである。しかし、私がここで見たい構造はiとsize - 1という二つの項の比較である。 そこで右辺を括弧で囲み、左項 < 右項という構造を字面にも出す。

論理演算子が混在するときも同じで、演算子優先順位だけに意味構造を預けず、括弧で条件群を明示する。

if( ((condA || condB) && condC) ||
    condD){
   run();
}

condA || condBがひとまとまりで、そのまとまりとcondCがAND、 さらにその全体とcondDがORである。 コードを読む側が演算子優先順位から意味木を復元するのではなく、意味木そのものを字面に置いておきたい。

for文にも意味上の区切りがある

for文は、複数の構造が一行の中に詰め込まれる例として分かりやすい。 一般的なfor文には、初期化、継続条件、更新という3つの意味上の項がある。 Javaの文法ではそれらを;で区切ってひとつの括弧の中に書くが、 私がコードを読むときにも、この3つを単なる文字列の並びではなく、それぞれ独立した項として見ている。

Yes🐈
for(int idx=0; idx<collection.size(); idx++){
   run(idx);
}

この一行には、初期化のint idx=0、継続条件のidx<collection.size()、 更新のidx++があり、それぞれ;で区切られている。 さらに、その制御によって繰り返されるrun(idx)は、forに包含されるものとして一段深い位置に置かれる。

一方で、短いfor文では次のような書き方も許容している。

Yes🐈
for(int i=1; i<size-1; i+=2){ run(arr, i); }

ここでの空白は、単に演算子の前後を見やすくするためだけのものではない。;で区切られた初期化、継続条件、更新を、それぞれ意味上のまとまりとして読めるようにする。

ここで重要なのは、あらゆる場所に決まった数の空白を置くことそのものではない。短い式を一行のまとまりとして読めるなら圧縮してもよく、構造が複雑になれば、その意味上の境界に従って展開する。

つまり、改行は「何文字を超えたから折る」ためだけのものではない。一行では読み取りにくくなった意味構造を、もともと存在していた境界に沿って二次元の字面へ展開するためにも使う。

包含されるものは一段深くする

この原則は、多引数のメソッド呼び出しにも現れる。

alotOfArgs(
   first,
   second,
   third,
   "にゃーー"+
      "にゃ。"
);

first、second、third、そして文字列式は、いずれもメソッドの引数なので同じ深さに置く。 一方、"にゃ。"は最後の引数を構成する式の一部なので、その一段内側に置く。

最後の);も独立した行に置いている。 開始したメソッド呼び出しという意味単位が、ここで閉じたことを字面でも示す。

このように見ていくと、「演算子はどちら側に置く」「括弧を付ける」「何スペース下げる」といった規則は、それぞれ独立したものではない。同格なら同じ深さ、包含されるなら一段深く、続くなら続くことを示し、閉じるなら閉じたことを示す。個々の書式は、その意味構造をコードの字面に投影した結果である。

他の言語への応用としてSQLのlint

この考え方はJavaだけに適用しているものでもない。Javaと同じように、既存の手書きSQLやMyBatis Mapperから書き方を逆算して、sql.mdも作った。

SQLでも最上位原則は同じく、「意味構造を字面へ投影する」である。SQL版では、字面を構成する要素を次のように整理している。

space       = 項・局所的な意味境界
indent      = 包含深度
blank line  = 意味段落・SQL文の境界
()          = 部分式・条件群の明示
keyword     = SQL節の開始

例えばWHERE節では、複数のconditionは同格の要素として並ぶ。

Yes🐈
WHERE
   tst.stream_id = tstr.stream_id AND
   tst.streamer_id = tstr.streamer_id AND
   tst.stream_id = #{stream_id} AND
   tst.streamer_id = #{streamer_id}
NG💢
WHERE
   tst.stream_id = tstr.stream_id
   AND tst.streamer_id = tstr.streamer_id

ここでもJavaの論理条件と同じで、condition同士が同格なら同じ深さに置く。ANDも前行末に置き、その行を読み終えた時点で条件式がまだ続くことを示す。

一方で、Javaの書式をそのままSQLへ持ってきているわけではない。SELECTには列のリストがあり、FROMやJOINではrelationを組み立て、 WHEREには条件群があり、サブクエリやCASEにはさらに内側の構造がある。 SQLが持っている意味構造を読み、それを字面に投影する。

そのためSQL版では、インデントについてindent depth = semantic containment depthと書いている。 インデントの深さは、構文上たまたま改行した回数ではなく、意味上いくつの構造に包含されているかで決める。

JavaとSQLでは文法も、コードの見た目も大きく異なる。それでも自分にとって「居心地のいい」書き方を言語化すると、同格、包含、継続、意味境界といった同じ概念が出てきた。どうやら私が決めていたのはJavaの細かな書式だけではなく、コードをどう二次元の字面として読むか、という部分だったらしい。

なぜlintを自然言語で800行書いたのか

このlintは、機械的に適用するための規則集として書き始めたものではない。自分がコードを書くときに何を見て、なぜその字面を選んでいるのかを、思想と標本として残すために自然言語で書いている。

「インデントは3スペース」「論理演算子は行末」とだけ書くなら、それぞれ1行で済む。しかし、それだけではまだ書いていないケースに遭遇したときにどうするかが分からない。なぜそう書くのかまで残しておけば、未知のケースでも同じ原則から書き方を決められる。そのため、規則だけでなく理由とYes・NGの標本まで書いていった結果、java.mdは800行を超えた。

結果として、これはAIにコードを書かせるときにも都合がよかった。単にformatterやlinterを通して完成後のコードを矯正するのではなく、あらかじめこの文書を読ませておくことで、最初から私の書き方に近いコードを生成させられる。

ただし、AIに食わせるためだけの文書ではない。本質的には、私の「なんとなくこっちのほうが居心地がいい」と感じていたものを、他人にも、自分自身にも説明できる形にした読み物である。

3スペースによるインデント
2でも4でもない。第三の選択肢、3 spaces。3だけに第三勢力。

インデント幅は3スペース

ところで私の好きなインデント幅は3スペースだ。

2スペースでは意味階層の差が弱く、4スペースではネストが深くなったときに右方向の消費が大きい。コードを書く領域の横幅、つまり右マージンまでの距離は有限資源なので、その中間として3スペースに落ち着いた。

baseLine(){
   space with three;
   its good to WATCH, not only for reading;
}

日本語を書く環境では、全角空白と3スペースの幅が明確に異なるのも都合がいい。

とはいえ、3スペースそのものが最上位の原則ではない。同格の要素なのにインデントの深さが違って見えることのほうが、インデント幅が3スペースでないことより重大である。また、チームに既存の規約があるなら、もちろんそちらを優先する。

了




コメントはこちらからお寄せください