可読性を高める2つの大きな方向性 - 脳内メモリに優しいコードを書く -

commits8 min read読了の目安(約7500字 5

TL;DR

  • 「可読性が高い」とは、「理解するために使う脳内メモリの量が少ない」ということである。
  • 理解するために使う脳内メモリの量 = コードを理解するのに必要な情報量 - コードについての予備知識
  • そのコード 1 行を単独で理解するのに必要な、追加の情報を意識する。その追加で必要な情報が減るようなコードにする」というのが可読性を高めるための一つの大きなの指針としてアリなのではないか。

本記事は、可読性という曖昧な概念について、脳内メモリとコンテキストという切り口で説明を試たものです。冗長な例もあるかと思いますが何卒ご容赦ください。

はじめに

可読性についての細かい Tips は様々ありますが、「結局」何をどうすればいいのか?という漠然とした問いには答えられていない気がします。細かい Tips を包括する大きな指針をがないかと考え、まとめたのがこの記事です。

可読性の高いコードとは?

脳内メモリの消費を抑える

可読性とは、コードが頭に入ってくる時のスムーズさと言えると思います。すなわち、いかに直感的に、余計なことを考えず、脳内メモリの消費を抑えてコードを理解できるか。脳内メモリを使わずに読めるほど可読性が高いと言えそうです。

日本語での例

一つ、日本語で例えてみます。

彼女ってアレだから、ちょっと難しいかも

これだけだと意味不明ですね。

私が用意した正解は下記です。

彼女(花子)ってアレ(元彼が野球部で思い出しちゃいそう)だから、ちょっと難しいかも(一緒に野球観戦に行くのは無理です)

…絶対わからないですね。

まず「彼女」が花子かどうかこの文だけでは分からないですし、「アレ」は、全く想像つかないです。「ちょっと難しい」は本当にちょっと難しいだけかもしれないです。野球観戦なんて言葉は微塵も出てきていないですね。

もちろん文章を遡ったり、この言語圏の文化を知っていれば、適切に解釈できそうですが、労力のいる作業になりますね。つまりこの文章は、知識や文化的な価値観が前提にあり、ハイコンテキストな文章になっているのです。

文章はいつの時代も趣きが大事なのでこれが許されますが、プログラミングコードはそうはいきません。
何を指すのか、何をしているのか、何故そうするのか、一瞬で頭に入ってきて欲しいです。

つまり、読むのに必要な情報(依存や参照、コンテキスト)をいかに減らし、脳内メモリの消費を抑えて読めるコードにするか、が可読性に直結すると言えるでしょう。


みなさんも身に覚えがあるかもしれません、

「この変数って何を表しているの?」
「この変数の型って何?」
「この関数は結局何をしているの?」

といったコードを読む時の疑義
これらの疑義が私たちの脳内メモリを食い滅ぼし、散らばった参照が私たちを疲弊させるのです。

可読性はハイコンテキストさと予備知識(事前情報)で決まる

と、ここまでざっくりと可読性とコンテキスト(依存や参照)について触れました。
ここで私が提唱するのが、以下の式です。

脳内メモリの消費量 = コードを理解するのに必要な情報量(コードのハイコンテキストさ) - コードについての予備知識

「コードを理解するのに必要な情報」とは

例えば、

a = b / c

このコードを理解するのに必要な情報は

  • a は何を表すのか
  • 変数 b とは何か(何を表すものか、その型は何か)
  • 変数 c とは何か(何を表すものか、その型は何か)
  • なぜ b を c で割るのか

などですね。
上記の情報がこのコードには含まれていないので、理解するのに必要な情報が多いコードになっています。自分でその情報をどこかから入手する必要があるため、脳内メモリを消費するのです。その情報源とは、まわりのコードや他のファイルのことです。もしかするとチームメンバーに聞く必要すらあるかもしれません。

そこで、上記は

average = total / count

と書けば、追加で必要な情報が減り、脳内メモリの消費を抑えられます。

「コードについての予備知識」とは

自分のコードってある程度読みやすいですよね。それは、コードの処理内容や、自分のコードの癖を事前に知っているからです。これが、コードについての予備知識です。

逆に、時たま取り沙汰される、

[...Array(5).keys()];
// [0, 1, 2, 3, 4]

という書き方。これは、Array の keys メソッドの挙動を知らないと、理解しづらいです。つまり、言語知識コーディング規約などの予備知識が乏しければ、コードが読みづらくなるのです。

読みながらコード内のコンテキストを汲むのが脳内メモリなら、コード外のこういったメタ的な予備知識は、事前準備された脳内ストレージと言えるかもしれません。

可読性を高める二つの方法

よって、コンテキストの側面からは、脳内メモリ消費を抑えて可読性を高める方法が、以下の 2 つあると言えそうです。

  1. コードをローコンテキスト(低依存)にする
  2. 予備知識を活用する

ではそれぞれ、具体的に説明していきます。

1. コードをローコンテキスト(低依存)にする

他の行への依存(参照)を減らす

  • ハイコンテキストパターン(NG)

    if (age == 11 or age % 2 == 0) and sex == "male":
    

    条件を理解するのがちょっと面倒ですね。脳内メモリを消費します。また、周りのコードを見ないと、条件分岐の意図がわからないパターンです。行いたい処理とコードがあっているか確信が持てないという問題もあります(本当に age が 11 か偶数の時かつ男性の時という条件にしたいのか?)。処理が宙ぶらりんすぎて、答え合わせができない状態です。このコードで何がしたいのか、他のいろんな箇所を見て情報を集めなくてはなりません。つまりハイコンテキストな状態です。

  • ちょっとハイコンテキストパターン(NG)

    is_valid = (age == 11 or age % 2 == 0) and sex == "male"
    if is_valid:
    

    条件分岐の意図はわかるようになりましたが、is_valid の条件が依然としてわかりづらいです。is_valid の条件を見返すたびに、毎回脳内メモリを消費しますね。

  • ローコンテキストパターン(OK)

    is_11_or_even = (age == 11 or age % 2 == 0)
    is_male = sex == "male"
    is_valid = is_11_or_even and is_male
    if is_valid:
    

    それぞれの行で情報が完結しており、次の行に進む時に脳内メモリを捨てられます。見返す時も、情報が変数名に圧縮されており、必要なところまで遡ればいいので脳内メモリに優しいです。上記の is_11_or_even や is_male みたいな変数を要約変数や説明変数と言ったりしますね。

もちろん、本当に「その 1 行だけ」を見てその行を理解できる必要があるか、といえばそうではないと思います。やり過ぎると冗長な表現になり、かえって可読性が落ちたり、パフォーマンスを損ねる可能性もありますね。指針はあくまで指針なので、その他の基準とのバランスを取って欲しいと思います。

例えば以下はおそらく冗長です(age 以外の数値がある場合には、age_is_11_or_even は必要十分と言えないこともないですが…)。

  age_is_11_or_even = (age == 11 or age % 2 == 0)
  sex_is_male = sex == "male"

「その 1 行だけ」を見て理解できるかどうかと言いましたが、実際にはそのスコープの処理内容や、前後の行の情報は脳内に持っているかと思います。したがって、is_male と言えば、sex 変数のことを指しているとわかります。しかし、それを言い出すとなし崩し的に指針の意味がなくなるので、「その 1 行だけ読んだ時に」としています。

スコープ外の参照(依存)を減らす

コードを適切に分割できれば、読むために必要なコンテキストも小分けにされ、省メモリで済みます。ただし、分けすぎると、スコープ間の依存が増えて逆効果であることに注意が必要です。

例えば以下、a, b, c ,d の4つの関数があるとします。このとき

  • 参照が多いパターン(NG)

    def try_foo(a, b, c, d):
        if success:
          a()
          b()
        else:
          c()
          d()
    

    try_foo 側を理解するためには、a, b, c, d すべてについての情報も持つ必要があります。つまり、参照(依存)が多く、必要な情報が多い状態(コードがハイコンテキスト)です。

  • 参照が少ないパターン(OK)

    def on_success():
        a()
        b()
    
    def on_failure():
        c()
        d()
    
    def try_foo(on_success, on_failure):
        if success:
            on_success()
        else:
            on_failure()
    

    try_foo 側では、on_success, on_failure の中身を知らなくて良いです。それでいて、on_success、on_failure と言う名前だけでどんな役割のものかわかりますね。つまり、コンテキストを小分けにすることで、依存を分散させ、ローコンテキストなコードに分割しているのです。

上記二つは、ハイコンテキストなコードに、変数や関数を挟みながら、ローコンテキストな複数のコードに分割しているわけです。チェックポイントとなる変数や関数を間に挟むことで、直前のチェックポイントだけを知っていればいい状態なので、脳内メモリを軽くできます。カプセル化と似ていますが、スコープ外の細かい内容は隠蔽して、次の行を読むのに必要な情報だけ圧縮して残しているのです。

参照先のコードを近くする

上記二つは、参照を減らすことを考えました。しかし、どうしても依存関係自体は変えられない場合もありますね。その際は、できるだけ依存関係のあるもののキョリを近づけることで、メモリの消費を減らします。

  • 参照先が遠いパターン(NG)

    a = foo
    # 処理
    # 処理
    # 処理
    if a:
    

    参照先が遠すぎて、a の存在をずっと脳内メモリに保持する必要があります

  • 参照先が近いパターン(OK)

    a = foo
    if a:
    

    参照先が近いため、a を脳内メモリに保持するコストが低いです

2. 予備知識を活用する

自由度の低い(情報量の多い)構文を使う

自由度が低い構文とはつまり、使える場所が限られた構文。使われ方の選択肢が少なく、この構文はこういう場合に使われるよねーという共通認識が存在します。したがって、構文自体に情報が詰め込まれているため情報の密度が高く、お得です。
英語でたとえると、Do you 〜 ?と聞いた方が You 〜 ?と聞くよりわかりやすい的な感じですね。
Do から始まっているから疑問文だとすぐに確信を持ってわかります。

  • 自由度が高く、構文からの情報が少ないパターン(NG)

    a = a + 1
    

    何を意図したコードかわからないです。a = b + 1 の間違いかな?と疑ってしまいかねないので脳内メモリに優しくないです。

  • 自由度が適切に制限され、構文からの情報が多いパターン(OK)

    a += 1
    

    += はインクリメント時にしか使わないので、インクリメントしたい旨が伝わります。

共通認識通りの使い方をする

共通認識と違う使い方をすれば、逆に誤った方向に解釈を絞ってしまう(ミスリードになる)場合もあります。誤った解釈からの復帰が無駄です。

  • 共通認識からズレた使い方パターン(NG)

    let a = 10;
    // 処理
    // 処理
    return a;
    

    let だから後で書き換えるのかな、と思ったら書き換えない。書き換えに対応するための脳内メモリが無駄になりました。

  • 共通認識通りの使い方パターン(OK)

    const a = 10;
    // 処理
    // 処理
    return a;
    

    a は未来永劫 10 である、という安心感のもと、a に関する脳内メモリの負荷が軽減できます

他にも、冗長な命名や慣習外の命名等、予想を裏切るコードは脳内メモリに優しくないです。

記事の最初の方で、予備知識にはコードの癖や、コーディング規約も含まれると言いました。従って、例えばチームに合った規約を増やすことで、脳内メモリの消費を減らすことができるのです。例えば、チームのコード規約に、「メモ化された計算結果には〜Memo と変数の最後につける」みたいなものを加えるだけでも、コードから得られる情報量は増えます。これも予備知識の活用と言えるでしょう。もちろん、増やし過ぎた規約や直感的でないものは逆効果になり得るので注意です。

この「予備知識」もコンテキストと言えると思います。そう考えると、可読性を高める指針は、読み手の知らないコンテキストを減らし、知っているコンテキストを増やすとも言い換えられるでしょう。ただ、コード内のコンテキストと、コード外の一つメタ的なコンテキストは毛色が若干違うものです。これが逆に混乱を来たすかと思い、この切り口での説明は諦めました。

まとめ

みんなが知っているソースに情報を押し込めよう

これらでやったことを抽象化すると、できるだけコードを読むのに必要な情報は、読み手に依存しない、共通のソースに押し込めよう という考えです。ここでいう「読み手に依存しない、共通のソース」とは、

  • そのとき読んでいるコードそのもの
  • 構文やコーディング規約
  • その他フレームワークのお作法等

のことです。誰もがもともと持っている共通のソースだけで、コードを読むのに必要な情報を満たせられれば、個々人が情報を絞り出さないで済むので、それは良いコードであると思います。静的型付の言語が読みやすいのは、「そのとき読んでいるコードそのもの」が持つ情報が、型情報によって格段に増えるので、余計な参照をしなくてもいいからという面も強いでしょう。

みんなが知っているソースに沿った書き方は、内容を推測しやすいため、直感的に、頭を使わずに理解できます。

細かな Tips との関係性

ここまで読んでいただくと、世の中にある細かな Tips も、「ローコンテキスト化」「予備知識の活用」のいずれかに大体当てはまることがわかると思います。例えばよくある「ネストを減らす」は、条件分岐やループ処理を脳内メモリにできるだけ置かずに済む ⇒ ローコンテキスト化に貢献しています。

コメントについて

ただ、可読性の高いコードがいつでも優先されるわけではありません。ときには、パフォーマンスや変更への強さなどとトレードオフになることがあります。そんな時、やむなく削ってしまった可読性を補うようなコメントを書くのは理にかなっていると思います。まず可読性やパフォーマンスに優れた「良い」コードを書き、それでも足りない情報をコメントするということです。もしくは、TODO やコーディングの意図など、通常はコードからは推し量れないような、「そのコードでおこなっていること以外」の、ややメタ的な情報を含めるのがコメントの役割なのかと思います。

まとめのまとめ

コードを書く時には「このコード 1 行を理解するのに必要な、追加の情報」ってどれくらいだろう、と自問してみましょう。「この変数の定義箇所は実はめちゃくちゃ遠い」「この配列の中身はあの関数の実装を知らないと類推できない」そういったコードのハイコンテキストさが浮き彫りになると思います。書いている自分しか知らない前提知識や暗黙の了解に気づけ、客観的にコードの可読性を推し量ることができるでしょう。

あなたが今書いているそのコード、あと何行参照すれば理解できますか?