Writing system software: code comments.

Salvatore Sanfilippo

システムソフトウェアを書く:コードコメント

原文は Salvatore Sanfilippo により に公開されました。 このブログを購読する

しばらくの間、YouTubeの「writing system software」シリーズでコードコメントについて語る新しい動画を撮りたいと思っていた。しかし、よく考えてみると、このテーマはブログ投稿の方が向いていると気づき、こうして記事を書くことになった。この投稿ではRedisのコメントを分析し、分類を試みる。その過程で、なぜ私の考えではコメントを書くことが、長期的に保守可能で、他人にも、そして修正やデバッグの際の自分自身にも理解可能な良いコードを生み出すために極めて重要なのかを示したい。

誰もが同じように考えているわけではない。コードが十分に堅牢であればコメントは不要だと考える人も多い。すべてがよく設計されていれば、コード自体が何をしているかを説明するのだから、コードコメントは余計だという考えだ。私はこの見方には、主に2つの理由から同意できない:

  1. 多くのコメントは、コードが何をしているかを説明しているわけではない。コードが何をしているかを見ただけでは分からないことを説明しているのだ。しばしば、この欠けている情報とは、なぜコードがある特定の動作をしているのか、あるいは、なぜより自然に思える別の方法ではなく、あえて明確なその方法で何かをしているのかというなぜである。
  2. コードが何をしているかを一行一行文書化することは、読めば分かるのだから一般的には有用ではないが、読みやすいコードを書く上での重要な目標の一つは、読者がコードを読む際に頭の中に抱えなければならない労力や詳細の量を減らすことだ。だから私にとって、コメントは読者の認知負荷を下げるための道具になりうる。

次のコードスニペットは、上記の2点目の良い例だ。なお、このブログ投稿に掲載されているすべてのコードスニペットはRedisのソースコードから抜粋したものである。各スニペットは、抽出元のファイル名を前置きして示している。使用したブランチは現在の「unstable」で、ハッシュは32e0d237だ。

scripting.c:
    /* Initial Stack: array */
    lua_getglobal(lua,"table");
    lua_pushstring(lua,"sort");
    lua_gettable(lua,-2);       /* Stack: array, table, table.sort */
    lua_pushvalue(lua,-3);      /* Stack: array, table, table.sort, array */
    if (lua_pcall(lua,1,0,0)) {
        /* Stack: array, table, error */

        /* We are not interested in the error, we assume that the problem is
         * that there are 'false' elements inside the array, so we try
         * again with a slower function but able to handle this case, that
         * is: table.sort(table, __redis__compare_helper) */
        lua_pop(lua,1);             /* Stack: array, table */
        lua_pushstring(lua,"sort"); /* Stack: array, table, sort */
        lua_gettable(lua,-2);       /* Stack: array, table, table.sort */
        lua_pushvalue(lua,-3);      /* Stack: array, table, table.sort, array */
        lua_getglobal(lua,"__redis__compare_helper");
        /* Stack: array, table, table.sort, array, __redis__compare_helper */
        lua_call(lua,2,0);
    }

LuaはスタックベースのAPIを使っている。上の関数内の各呼び出しを、Lua APIのリファレンスを手元に置きながら追えば、読者は各時点でのスタックの状態を頭の中で再現できるだろう。しかし、なぜ読者にそんな労力を強いる必要があるのか。コードを書く際に、元の作者はどのみちその心的労力を払わなければならなかったのだ。私がそこでやったのは、各呼び出しの後に現在のスタックの状態を一行ごとに注釈しただけだ。これでこのコードを読むことは、Lua API自体は決して自明ではないにもかかわらず、ごく簡単なことになった。

ここでの私の目的は、ソースコードの局所的な部分を読むだけでは明確に得られない背景を提供する道具としてのコメントの有用性について、私の見解を示すだけではない。歴史的に無用、あるいは有害とさえ見なされてきた種類のコメント、すなわちコードが何をしているかを述べるコメント(なぜ、ではない)が有用であることの証拠を示すことでもある。

コメントの分類

この作業を始めたとき、私はRedisのソースコードのランダムな部分を読み、異なる文脈でコメントが有用かどうか、なぜ有用なのかを確かめた。すぐに浮かび上がってきたのは、コメントは機能、文体、長さ、更新頻度が大きく異なるため、非常に異なる理由で有用だということだった。最終的に、この作業を分類のタスクへと発展させた。

調査の過程で、私は9種類のコメントを特定した:

  • 関数コメント
  • 設計コメント
  • Whyコメント
  • Teacherコメント
  • チェックリストコメント
  • ガイドコメント
  • 自明なコメント
  • 負債コメント
  • バックアップコメント

私の意見では、最初の6つはおおむね非常にポジティブなコメントの形態であり、最後の3つはやや問題がある。次のセクションでは、それぞれのタイプをRedisのソースコードからの例とともに分析していく。

関数コメント

関数コメントの目的は、そもそも読者がコードを読まずに済むようにすることだ。コメントを読んだ後は、そのコードを特定のルールに従うブラックボックスとして扱えるようにすべきなのだ。通常、関数コメントは関数定義の先頭に置かれるが、クラスやマクロ、あるいはインターフェースを定義するその他の機能的に独立したコードブロックを文書化するために、別の場所に置かれることもある。

rax.c:

    /* Seek the grestest key in the subtree at the current node. Return 0 on
     * out of memory, otherwise 1. This is an helper function for different
     * iteration functions below. */
    int raxSeekGreatest(raxIterator *it) {
    ...

関数コメントは実際には、インラインのAPIドキュメントの一形態だ。関数コメントが十分によく書かれていれば、利用者はほとんどの場合、関数やクラス、マクロなどの実装を読むことなく、自分が読んでいたコード(そのAPIを呼び出しているコード)へと戻ることができるはずだ。

あらゆる種類のコメントの中でも、これらはプログラミングコミュニティ全体で必要だと最も広く受け入れられているものだ。唯一検討すべき点は、 largely APIリファレンスとしての文書であるコメントをコード自体の中に置くことが良い考えかどうかということだ。私にとって答えはシンプルだ。APIドキュメントはコードと完全に一致していてほしい。コードが変更されれば、ドキュメントも変更されるべきだ。この理由から、関数コメントを関数やその他の要素の前置きとして使うことで、APIドキュメントをコードの近くに置き、3つの成果を得られる:

  • コードが変更されても、APIリファレンスが古くなるリスクなく、ドキュメントを同時に簡単に変更できる。
  • このアプローチにより、変更を最もよく理解しているはずの変更の作者自身が、APIドキュメントの変更も行う確率が最大化される。
  • コードを読む際に、関数やメソッドのドキュメントを定義されている場所で直接見つけられるため、コードとドキュメントの間でコンテキストスイッチすることなく、コードだけに集中できる。

設計コメント

「関数コメント」が通常関数の先頭に置かれるのに対し、設計コメントはより頻繁にファイルの先頭に置かれる。設計コメントは基本的に、あるコード片がどのようなアルゴリズム、技術、トリック、実装を、どのように、なぜ使っているかを述べるものだ。それはコードで実装されている内容の、より高レベルな概要である。そうした背景があれば、コードを読むのはより簡単になる。さらに、私は設計メモが見つかるコードをより信頼する傾向がある。少なくとも開発プロセスのどこかの時点で、明示的な設計フェーズがあったことが分かるからだ。

私の経験では、実装が提案する解決策が少し単純すぎるように見える場合に、どのような競合する解決策があり、なぜ非常にシンプルな解決策が手元のケースには十分だと考えられたのかを述べるためにも、設計コメントは非常に有用だ。設計が正しければ、読者はその解決策が適切であり、そのシンプルさが怠惰や基本的なことしかコーディングできないことから来たのではなく、プロセスから生まれたものだと納得するだろう。

bio.c:
     * DESIGN
     * ------
     *
     * The design is trivial, we have a structure representing a job to perform
     * and a different thread and job queue for every job type.
     * Every thread waits for new jobs in its queue, and process every job
     * sequentially.
     ...

Whyコメント

Whyコメントは、コードが何をしているかが極めて明確な場合でも、なぜコードがあることをしているのかという理由を説明する。Redisのレプリケーションコードからの次の例を見てほしい。

replication.c:

    if (idle > server.repl_backlog_time_limit) {
	/* When we free the backlog, we always use a new
	 * replication ID and clear the ID2. This is needed
	 * because when there is no backlog, the master_repl_offset
	 * is not updated, but we would still retain our replication
	 * ID, leading to the following problem:
	 *
	 * 1. We are a master instance.
	 * 2. Our replica is promoted to master. It's repl-id-2 will
	 *    be the same as our repl-id.
	 * 3. We, yet as master, receive some updates, that will not
	 *    increment the master_repl_offset.
	 * 4. Later we are turned into a replica, connect to the new
	 *    master that will accept our PSYNC request by second
	 *    replication ID, but there will be data inconsistency
	 *    because we received writes. */
	changeReplicationId();
	clearReplicationId2();
	freeReplicationBacklog();
	serverLog(LL_NOTICE,
	    "Replication backlog freed after %d seconds "
	    "without connected replicas.",
	    (int) server.repl_backlog_time_limit);
    }

関数呼び出しだけを見れば、疑問に思うことはほとんどない。タイムアウトに達したら、メインのレプリケーションIDを変更し、セカンダリIDをクリアし、最後にレプリケーションバックログを解放する。しかし、バックログを解放する際に、なぜレプリケーションIDを変更する必要があるのかは、必ずしも明確ではない。

これは、ソフトウェアがある程度の複雑さに達すると、継続的に起こる類のことだ。関わるコードが何であれ、レプリケーションプロトコル自体がある程度複雑なので、他の悪いことが起こらないようにするために、ある種のことを行う必要がある。おそらくこの種のコメントは、ある意味でシステムについて推論し、そうした複雑さがもはや必要なくなり、コメント自体を削除できるように改善すべきかどうかを検討する機会でもある。しかし、しばしば何かをシンプルにすることが、他の何かをより困難にしたり、単に実行不可能だったり、後方互換性を壊す将来の作業を必要としたりする。

もう一つ例を挙げよう。

replication.c:

    /* SYNC can't be issued when the server has pending data to send to
     * the client about already issued commands. We need a fresh reply
     * buffer registering the differences between the BGSAVE and the current
     * dataset, so that we can copy to other replicas if needed. */
    if (clientHasPendingReplies(c)) {
        addReplyError(c,"SYNC and PSYNC are invalid with pending output");
        return;
    }

過去のコマンドによる未送信の出力がクライアントに残っている状態でSYNCを実行すると、このコマンドは失敗すべきだ。というのも、レプリケーションハンドシェイクの間、クライアントの出力バッファは変更を蓄積するために使われ、最初のレプリカとのフル同期のためにRDBファイルを作成している最中に接続してきた他のレプリカにサービスを提供するために、後で複製される可能性があるからだ。これが私たちがそうする理由(why)だ。何をしているかは自明で、保留中の応答があればエラーを返すだけだ。コメントがなければ、なぜそうするのかはかなり分かりにくい。

こうしたコメントは、レプリケーションのような複雑なプロトコルや相互作用を説明する際にのみ必要だと思うかもしれない。果たしてそうだろうか。ファイルも目的もまったく変えてみても、やはりそうしたコメントは至るところに見られる。

expire.c:

    for (j = 0; j < dbs_per_call && timelimit_exit == 0; j++) {
        int expired;
        redisDb *db = server.db+(current_db % server.dbnum);

        /* Increment the DB now so we are sure if we run out of time
         * in the current DB we'll restart from the next. This allows to
         * distribute the time evenly across DBs. */
        current_db++;
        ...

これは興味深い例だ。時間が許す限り、異なるDBからキーを期限切れにしたい。しかし、現在のデータベースを処理するループの最後で次に処理する「データベースID」をインクリメントするのではなく、別の方法を取っている。変数dbで現在のDBを選択するが、その直後に次の呼び出しで処理する次のデータベースのIDを即座にインクリメントするのだ。こうすることで、1回の呼び出しで労力を使いすぎたためにこの関数が終了した場合でも、同じデータベースから再び開始してしまい、同じデータベースの処理に集中するあまり、他のデータベースで論理的に期限切れになったキーが蓄積してしまうという問題を避けられる。

こうしたコメントによって、なぜその段階でインクリメントするのかを説明すると同時に、次にコードを修正する人がその性質を保つべきことを示している。コメントがなければ、このコードはまったく無害に見えることに注意してほしい。選択して、インクリメントして、作業を行う。インクリメントをより自然に見えるループの末尾に移さない明確な理由はないように見えるのだ。

余談だが、ループのインクリメントは元々のコードでは実際に末尾にあった。修正の際にそこへ移動され、同時にコメントが追加されたのだ。だからこれは一種の「リグレッションコメント」と言えるだろう。

Teacherコメント

Teacherコメントは、コード自体や注意すべき副作用を説明しようとするものではない。代わりに、コードが動作するドメイン(例えば数学、コンピュータグラフィックス、ネットワーキング、統計、複雑なデータ構造など)について教えるものであり、それは読者のスキルセットの範囲外であったり、単に詳細が多すぎてすべてを記憶から思い出すことが困難だったりするものだ。

バージョン5のLOLWUTコマンドは、画面に回転した正方形を表示する必要がある(http://antirez.com/news/123)。そのためにいくつかの基本的な三角法を使っている。使われている数学は単純であるにもかかわらず、Redisのソースコードを読む多くのプログラマは数学的背景を持っていないかもしれないため、関数の先頭にあるコメントが、関数内部で何が起こるかを説明している。

lolwut5.c:

    /* Draw a square centered at the specified x,y coordinates, with the specified
     * rotation angle and size. In order to write a rotated square, we use the
     * trivial fact that the parametric equation:
     *
     *  x = sin(k)
     *  y = cos(k)
     *
     * Describes a circle for values going from 0 to 2*PI. So basically if we start
     * at 45 degrees, that is k = PI/4, with the first point, and then we find
     * the other three points incrementing K by PI/2 (90 degrees), we'll have the
     * points of the square. In order to rotate the square, we just start with
     * k = PI/4 + rotation_angle, and we are done.
     *
     * Of course the vanilla equations above will describe the square inside a
     * circle of radius 1, so in order to draw larger squares we'll have to
     * multiply the obtained coordinates, and then translate them. However this
     * is much simpler than implementing the abstract concept of 2D shape and then
     * performing the rotation/translation transformation, so for LOLWUT it's
     * a good approach. */

このコメントには、関数自体のコードやその副作用、関数に関連する技術的な詳細に関わることは何も含まれていない。説明は、ある目標を達成するために関数内部で使われている数学的概念にのみ限定されている。

私はTeacherコメントは非常に価値があると思う。読者がそうした概念を知らない場合に何かを教え、少なくともさらなる調査のための出発点を提供するからだ。しかしこれは、Teacherコメントが特定のコードパスを読めるプログラマの数を増やすことを意味する。多くのプログラマに読めるコードを書くことは、私の大きな目標の一つだ。数学のスキルはなくても、素晴らしい修正や最適化に貢献できる非常に優秀なプログラマもいる。そして一般に、コードは実行されるだけでなく読まれるべきものだ。なぜならコードは人間が他の人間のために書くものだからだ。

まともなコードを書くためにTeacherコメントがほぼ不可避な場合もある。良い例がRedisの基数木(radix tree)の実装だ。基数木は複雑なデータ構造である。Redisの実装では、データ構造の理論全体を実装しながら改めて記述し、異なるケースや、ノードのマージや分割のためにアルゴリズムが何をするかなどを示している。各コメントセクションの直後には、直前に書かれた内容を実装するコードが続く。基数木を実装するファイルに何ヶ月も触れていなかった後でも、私はそれを開き、数分でバグを修正し、また別の作業を続けることができた。説明とコード自体が一体となって混ざり合っているため、基数木がどのように動作するかを改めて学び直す必要がないのだ。

コメントは長すぎるので、ここでは一部の抜粋だけを示す。

rax.c:

    /* If the node we stopped at is a compressed node, we need to
     * split it before to continue.
     *
     * Splitting a compressed node have a few possible cases.
     * Imagine that the node 'h' we are currently at is a compressed
     * node contaning the string "ANNIBALE" (it means that it represents
     * nodes A -> N -> N -> I -> B -> A -> L -> E with the only child
     * pointer of this node pointing at the 'E' node, because remember that
     * we have characters at the edges of the graph, not inside the nodes
     * themselves.
     *
     * In order to show a real case imagine our node to also point to
     * another compressed node, that finally points at the node without
     * children, representing 'O':
     *
     *     "ANNIBALE" -> "SCO" -> []

     ... snip ...

     * 3a. IF $SPLITPOS == 0:
     *     Replace the old node with the split node, by copying the auxiliary
     *     data if any. Fix parent's reference. Free old node eventually
     *     (we still need its data for the next steps of the algorithm).
     *
     * 3b. IF $SPLITPOS != 0:
     *     Trim the compressed node (reallocating it as well) in order to
     *     contain $splitpos characters. Change chilid pointer in order to link
     *     to the split node. If new compressed node len is just 1, set
     *     iscompr to 0 (layout is the same). Fix parent's reference.

     ... snip ...

        if (j == 0) {
            /* 3a: Replace the old node with the split node. */
            if (h->iskey) {
                void *ndata = raxGetData(h);
                raxSetData(splitnode,ndata);
            }
            memcpy(parentlink,&splitnode,sizeof(splitnode));
        } else {
            /* 3b: Trim the compressed node. */
            trimmed->size = j;
            memcpy(trimmed->data,h->data,j);
            trimmed->iscompr = j > 1 ? 1 : 0;
            trimmed->iskey = h->iskey;
            trimmed->isnull = h->isnull;
            if (h->iskey && !h->isnull) {
                void *ndata = raxGetData(h);
                raxSetData(trimmed,ndata);
            }
            raxNode **cp = raxNodeLastChildPtr(trimmed);
        ...

見ての通り、コメント内の説明はコード内で同じラベルと対応している。この形式ですべてを示すのは難しいので、全体像を把握したい場合は、以下の完全なファイルを参照してほしい:

https://github.com/antirez/redis/blob/unstable/src/rax.c

このレベルのコメントがすべてに必要なわけではないが、基数木のようなものは本当に細かな詳細やコーナーケースに満ちている。それらは思い出すのが難しく、特定の詳細は特定の実装に固有のものだ。もちろん、連結リストに対してこれを行うのはあまり意味がない。やる価値があるかどうかを見極めるのは、個人の感性による問題だ。

チェックリストコメント

これは非常に一般的でありながら奇妙なものだ。言語の制限や設計上の問題、あるいは単にシステムに生じる自然な複雑さのために、ある概念やインターフェースを一箇所に集約することが不可能な場合があり、コードのある場所で、コードの別の場所で何かを行うことを忘れないように指示することがある。一般的な概念は次のようなものだ:

    /* Warning: if you add a type ID here, make sure to modify the
     * function getTypeNameByID() as well. */

理想的な世界ではこれは決して必要ないはずだが、実際にはそこから逃れられないこともある。例えばRedisの型は「オブジェクト型」構造を使って表現でき、各オブジェクトは自分が属する型にリンクできるため、次のように書けるかもしれない:

    printf("Type is %s\n", myobject->type->name);

しかしどうだろう? Redisのオブジェクトは次のように表現されているため、これではコストが高すぎるのだ:

    typedef struct redisObject {
        unsigned type:4;
        unsigned encoding:4;
        unsigned lru:LRU_BITS; /* LRU time (relative to global lru_clock) or
                                * LFU data (least significant 8 bits frequency
                                * and most significant 16 bits access time). */
        int refcount;
        void *ptr;
    } robj;

型を表すのに64ビットではなく4ビットを使っている。これは、なぜ物事が本来あるべきほど集中的かつ自然ではないことがあるのかを示すための例に過ぎない。状況がそうである場合、あるコードセクションに触れた際に、コードの他の部分も修正する必要があることを思い出させるために、防御的なコメントを使うことが役立つことがある。具体的には、チェックリストコメントは次のいずれか、あるいは両方を行う:

  • 何かが修正されたときに実行すべき一連のアクションを伝える。
  • 特定の変更をどのように行うべきかについて警告する。

blocked.cにある別の例は、新しいブロッキングタイプが導入されたときのものだ。

blocked.c:

     * When implementing a new type of blocking opeation, the implementation
     * should modify unblockClient() and replyToBlockedClientTimedOut() in order
     * to handle the btype-specific behavior of this two functions.
     * If the blocking operation waits for certain keys to change state, the
     * clusterRedirectBlockedClientIfNeeded() function should also be updated.

チェックリストコメントは、特定の「Whyコメント」が使われる文脈と似た状況でも有用だ。つまり、なぜあるコードがある場所で、何かの前や後に実行されなければならないのかが自明でない場合である。しかし、Whyコメントがある文がなぜそこにあるのかを教えてくれるのに対し、同じケースで使われるチェックリストコメントは、コードの挙動を壊さずに修正したい場合に従うべきルール(この場合は、特定の順序に従うこと)を伝えることに、より重きを置いている。

cluster.c:

    /* Update our info about served slots.
     *
     * Note: this MUST happen after we update the master/replica state
     * so that CLUSTER_NODE_MASTER flag will be set. */

チェックリストコメントは、特定の操作の順序が極めて重要であるLinuxカーネル内部では非常に一般的だ。

ガイドコメント

私はガイドコメントを乱用しており、おそらくRedisのコメントの大半はガイドコメントだ。しかもガイドコメントは、まさに多くの人が完全に無用だと信じているコメントそのものだ。

  • コードから明確でないことを述べるわけではない。
  • ガイドコメントには設計上のヒントはない。

ガイドコメントがすることは一つだけだ。ソースコードに書かれていることを処理する間、明確な区切りやリズムを提供し、これから読む内容を紹介することで、読者に付き添い、手助けするのだ。

ガイドコメントが存在する唯一の理由は、コードを読むプログラマの認知負荷を下げることだ。

rax.c:

    /* Call the node callback if any, and replace the node pointer
     * if the callback returns true. */
    if (it->node_cb && it->node_cb(&it->node))
	memcpy(cp,&it->node,sizeof(it->node));

    /* For "next" step, stop every time we find a key along the
     * way, since the key is lexicographically smaller compared to
     * what follows in the sub-children. */
    if (it->node->iskey) {
	it->data = raxGetData(it->node);

	return 1;
    }

上のコメントがコードに付け加えているものは何もない。上のガイドコメントはコードを読むのを助け、さらに自分が正しく理解できていることを確認させてくれる。さらに例を挙げよう。

networking.c:

    /* Log link disconnection with replica */
    if ((c->flags & CLIENT_SLAVE) && !(c->flags & CLIENT_MONITOR)) {
        serverLog(LL_WARNING,"Connection with replica %s lost.",
            replicationGetSlaveName(c));
    }

    /* Free the query buffer */
    sdsfree(c->querybuf);
    sdsfree(c->pending_querybuf);
    c->querybuf = NULL;

    /* Deallocate structures used to block on blocking ops. */
    if (c->flags & CLIENT_BLOCKED) unblockClient(c);
    dictRelease(c->bpop.keys);

    /* UNWATCH all the keys */
    unwatchAllKeys(c);
    listRelease(c->watched_keys);

    /* Unsubscribe from all the pubsub channels */
    pubsubUnsubscribeAllChannels(c,0);
    pubsubUnsubscribeAllPatterns(c,0);
    dictRelease(c->pubsub_channels);
    listRelease(c->pubsub_patterns);

    /* Free data structures. */
    listRelease(c->reply);
    freeClientArgv(c);

    /* Unlink the client: this will close the socket, remove the I/O
     * handlers, and remove references of the client from different
     * places where active clients may be referenced. */
    unlinkClient(c);

Redisは文字通りガイドコメントだらけで、基本的にどのファイルを開いても大量に含まれている。なぜわざわざそうするのか? このブログ投稿でこれまで分析してきたすべてのコメントタイプの中で、これが最も主観的なものであることは認めよう。このようなコメントがないコードを劣っているとは思わないが、それでも人々がRedisのコードを読みやすいと評価するなら、その理由の一部は間違いなくガイドコメントのおかげだと固く信じている。

ガイドコメントには、述べてきたもの以外にも有用性がある。コードを明確に分離されたセクションに分けるため、コードへの追加がランダムな場所ではなく、適切なセクションに挿入される可能性が非常に高くなる。関連する文が近くにあることは、可読性にとって大きな利点だ。

また、unlinkClient()関数が呼び出される直前のガイドコメントにも注目してほしい。このガイドコメントは関数が何をしようとしているかを簡潔に読者に伝え、大局的な把握だけに関心がある場合に、わざわざ関数の中までジャンプして確認する必要をなくしてくれる。

自明なコメント

ガイドコメントは非常に主観的な道具だ。好き嫌いが分かれるだろう。私は大好きだ。しかし、ガイドコメントは非常に悪いコメントへと堕落する可能性がある。容易に「自明なコメント」になってしまうのだ。自明なコメントとは、コメントを読む認知負荷が、関連するコードを読む場合と同じか、それ以上であるようなガイドコメントのことだ。次のような自明なコメントの形は、多くの書籍が避けるべきだと説くものとまさに一致する。

    array_len++;	/* Increment the length of our array. */

だから、ガイドコメントを書くなら、自明なものを書かないように注意してほしい。

負債コメント

負債コメントは、ソースコード自体の中にハードコードされた技術的負債の表明だ:

t_stream.c:

    /* Here we should perform garbage collection in case at this point
     * there are too many entries deleted inside the listpack. */
    entries -= to_delete;
    marked_deleted += to_delete;
    if (entries + marked_deleted > 10 && marked_deleted > entries/2) {
	/* TODO: perform a garbage collection. */
    }

上のスニペットはRedisストリームの実装から抜粋したものだ。RedisストリームではXDELコマンドを使って中間から要素を削除できる。これはさまざまな場面で有用になりうるが、特にどのようなデータ構造やシステムを使って保存しているかに関わらず特定のデータを保持してはならないというプライバシー規制の文脈では有用だ。大部分が追記専用(append only)のデータ構造にとっては非常に奇妙なユースケースだが、ユーザーが中間のアイテムの50%以上を削除し始めると、ストリームは「マクロノード」で構成されることで断片化し始める。エントリは単に削除済みとしてフラグが立てられるだけで、あるマクロノード内のすべてのエントリが解放されたときにのみ再利用される。したがって、大量の削除はストリームのメモリ挙動を変えることになる。

今のところ、ユーザーがストリーム内の履歴の大部分を削除するとは想定していないため、これは問題には見えない。しかし将来的にはガベージコレクションを導入したいと思うかもしれない。削除されたエントリと既存のエントリの比率がある水準に達したときにマクロノードをコンパクトにしたり、さらにガベージコレクションの後に近接するノード同士を結合したりするのだ。後になってガベージコレクションを行うためのエントリーポイントがどこだったかを思い出せなくなることを少し恐れたので、TODOコメントを置き、トリガー条件まで書いておいた。

これはおそらくあまり良いやり方ではない。より良い考えは、ファイル先頭の設計コメントの中で、なぜ現在GCを実行していないのか、そして後で追加したい場合のGCのエントリーポイントはどこなのかを書いておくことだっただろう。

FIXME、TODO、XXX、「This is a hack」などは、すべて負債コメントの形態だ。これらは一般的にあまり良いものではなく、私は避けるようにしているが、常に可能なわけではない。そして問題を永遠に忘れてしまうよりは、ソースコードの中に覚え書きを残しておく方を好むこともある。少なくとも定期的にそうしたコメントをgrepして、メモをより良い場所に移せないか、あるいは問題がもはや関係なくなっていないか、すぐに修正できないかを確かめるべきだ。

バックアップコメント

最後に、バックアップコメントとは、開発者が新しいコードでの変更に自信が持てず、あるコードブロックや関数全体の古いバージョンをコメントアウトするものだ。不可解なのは、Gitがある今でもこれが起こることだ。数年前のコミットの中にある、より健全あるいは安定していると考えられたコード断片を失うことに対して、人々は不安を感じるのだろうと思う。

しかしソースコードはバックアップを取るための場所ではない。関数やコードの一部の古いバージョンを保存したいのであれば、あなたの作業はまだ終わっておらず、コミットすべきではない。新しい関数が過去のものより優れていることを確かめるか、確信が持てるまで自分の開発ツリーの中だけに留めておくべきだ。

これで私の分類におけるバックアップコメントの説明は終わりだ。結論を試みよう。

分析ツールとしてのコメント

コメントは、ステロイドを打ったラバーダック・デバッグのようなものだ。ただし相手はラバーダックではなく、コードの未来の読者であり、その読者はラバーダックよりもはるかに手ごわく、Twitterを使うこともできる。だからその過程で、あなたは自分が述べていることが許容できるものか、立派か、十分に良いものかを本気で理解しようとする。もしそうでなければ、宿題をやり直し、よりまともなものを考え出すのだ。

これはドキュメントを書くときに起こるのと同じプロセスだ。書き手は、あるコード片が何をするのか、どのような保証や副作用があるのかという要点を提供しようとする。これはしばしばバグ発見の機会となる。何かを説明している最中に、それに穴があることに気づくのは非常に簡単だ……。ある挙動について確信が持てないために、すべてをきちんと説明できないのだ。その挙動は複雑さからランダムに浮かび上がってきたものに過ぎない。そんなことは本当に望まないので、戻ってすべてを修正することになる。私はこれを、コメントを書く素晴らしい理由だと考えている。

良いコメントを書くことは、良いコードを書くことよりも難しい

コメントを書くことは、より高尚でない仕事だと思うかもしれない。なにしろあなたはコードが書けるのだから! しかしこう考えてみてほしい。コードとは文や関数呼び出しの集合であり、あるいはあなたのプログラミングパラダイムが何であれその集合だ。時には、コードが良くなければ、そうした文は正直あまり意味をなさないこともある。コメントを書くには、常に何らかの設計プロセスが進行中であることが求められ、自分が書いているコードをより深い意味で理解する必要がある。その上、良いコメントを書くためには、文章を書くスキルを磨かなければならない。その同じ文章スキルは、メールやドキュメント、設計書、ブログ記事、コミットメッセージを書く際にも役立つだろう。

私がコードを書くのは、何よりも共有し伝えたいという切迫した思いがあるからだ。コメントはコードを補い、助け、私たちの努力を描写する。そして結局のところ、私はコード自体を書くのと同じくらい、コメントを書くことが大好きなのだ。

(このブログ投稿の執筆中にフィードバックをくれたMichel Martens氏に感謝する)

この記事は「muse-spark-1.2-contributor」を使用して翻訳されました。

コメント