コードリーディング速度を計測してみる

時間があればじっくりコードリーディングしたいと思ったことはありませんか？しかし、現実の世界は常に時間的制約があり、締切に追われているので、何の束縛もなくコードリーディングに取り組める機会は少ないです。場合によっては、納期間際でお祭り騒ぎになっているプロジェクトへヘルプとして投入され、時間が全くないうえに読まなければいけないコードは膨大にあるという絶望的な状況に立たされることもあります。

コードリーディングによって、どのくらいの時間でどのくらいの量のコードが読めるのかをある程度つかんでいれば、こうした時間的制約ともうまく付き合えるかもしれません。今回は実際に時間を計りながらコードリーディングを進め、コードリーディングの速度を算出してみました。

## コードリーディング速度を計測する
Node.jsのPathモジュールをコードリーディングの対象として、1時間あたり何行くらい読めるのかという速度を計測してみました。

### 事前の背景知識について
コードリーディングは事前に持っている背景知識の量によって理解度が変わってきます。（参考：[コードリーディングを楽にしてくれる背景知識](https://qiita.com/YY-EN40P/items/ef6761f9c4c979bceb59)）理解しやすければ当然読むスピードも上がってきます。今回のPathモジュールのコードリーディングを行う前の背景知識は以下のとおりでした。

- （JavaScriptの基本的な文法は理解している）
- Node.jsのPathモジュールには[公式ドキュメント](https://nodejs.org/api/path.html)が用意されている
- Pathモジュールには、パスの生成や抽出などの処理を行うメソッドが定義されている
- 渡した文字列を結合してパスを生成する`join`メソッドはコードリーディング済みであり、`join`メソッド内に出てくるメソッドの一部は理解している
- Pathモジュールには、Windows用とそれ以外（主にmacOSとLinux）用として同じメソッドがそれぞれ分けて定義されている

`join`メソッドのコードリーディングについては、[【コードリーディング】Node.js Pathモジュールのjoinメソッド成長記録（2010年〜2022年）前編](https://qiita.com/YY-EN40P/items/4c1ef279498d34126802)で行いました。JavaScriptの文法知識は背景知識ではありませんが、コードリーディングに必須のものなので一緒に記載しておきました。

### Pathモジュールのコードリーディング開始から1時間経過
今回は時間を計りながらコードリーディングを進めて速度を計測することが目的だったので、とりあえずPathモジュールが定義されている`node/lib/path.js`の先頭の行から順番に読み始めました。1時間経過したところで最初の大きなメソッドである`normalizeString`までコードリーディングによって理解することが出来ました。

<details><summary>Pathモジュールの1行目から128行目（normalizeString）まで</summary>

```js:node/lib/path.js
// Copyright Joyent, Inc. and other Node contributors.
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the
// "Software"), to deal in the Software without restriction, including
// without limitation the rights to use, copy, modify, merge, publish,
// distribute, sublicense, and/or sell copies of the Software, and to permit
// persons to whom the Software is furnished to do so, subject to the
// following conditions:
//
// The above copyright notice and this permission notice shall be included
// in all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
// USE OR OTHER DEALINGS IN THE SOFTWARE.

'use strict';

const {
  FunctionPrototypeBind,
  StringPrototypeCharCodeAt,
  StringPrototypeIndexOf,
  StringPrototypeLastIndexOf,
  StringPrototypeReplace,
  StringPrototypeSlice,
  StringPrototypeToLowerCase,
} = primordials;

const {
  CHAR_UPPERCASE_A,
  CHAR_LOWERCASE_A,
  CHAR_UPPERCASE_Z,
  CHAR_LOWERCASE_Z,
  CHAR_DOT,
  CHAR_FORWARD_SLASH,
  CHAR_BACKWARD_SLASH,
  CHAR_COLON,
  CHAR_QUESTION_MARK,
} = require('internal/constants');
const {
  validateObject,
  validateString,
} = require('internal/validators');

const platformIsWin32 = (process.platform === 'win32');

function isPathSeparator(code) {
  return code === CHAR_FORWARD_SLASH || code === CHAR_BACKWARD_SLASH;
}

function isPosixPathSeparator(code) {
  return code === CHAR_FORWARD_SLASH;
}

function isWindowsDeviceRoot(code) {
  return (code >= CHAR_UPPERCASE_A && code <= CHAR_UPPERCASE_Z) ||
         (code >= CHAR_LOWERCASE_A && code <= CHAR_LOWERCASE_Z);
}

// Resolves . and .. elements in a path with directory names
function normalizeString(path, allowAboveRoot, separator, isPathSeparator) {
  let res = '';
  let lastSegmentLength = 0;
  let lastSlash = -1;
  let dots = 0;
  let code = 0;
  for (let i = 0; i <= path.length; ++i) {
    if (i < path.length)
      code = StringPrototypeCharCodeAt(path, i);
    else if (isPathSeparator(code))
      break;
    else
      code = CHAR_FORWARD_SLASH;

    if (isPathSeparator(code)) {
      if (lastSlash === i - 1 || dots === 1) {
        // NOOP
      } else if (dots === 2) {
        if (res.length < 2 || lastSegmentLength !== 2 ||
            StringPrototypeCharCodeAt(res, res.length - 1) !== CHAR_DOT ||
            StringPrototypeCharCodeAt(res, res.length - 2) !== CHAR_DOT) {
          if (res.length > 2) {
            const lastSlashIndex = StringPrototypeLastIndexOf(res, separator);
            if (lastSlashIndex === -1) {
              res = '';
              lastSegmentLength = 0;
            } else {
              res = StringPrototypeSlice(res, 0, lastSlashIndex);
              lastSegmentLength =
                res.length - 1 - StringPrototypeLastIndexOf(res, separator);
            }
            lastSlash = i;
            dots = 0;
            continue;
          } else if (res.length !== 0) {
            res = '';
            lastSegmentLength = 0;
            lastSlash = i;
            dots = 0;
            continue;
          }
        }
        if (allowAboveRoot) {
          res += res.length > 0 ? `${separator}..` : '..';
          lastSegmentLength = 2;
        }
      } else {
        if (res.length > 0)
          res += `${separator}${StringPrototypeSlice(path, lastSlash + 1, i)}`;
        else
          res = StringPrototypeSlice(path, lastSlash + 1, i);
        lastSegmentLength = i - lastSlash - 1;
      }
      lastSlash = i;
      dots = 0;
    } else if (code === CHAR_DOT && dots !== -1) {
      ++dots;
    } else {
      dots = -1;
    }
  }
  return res;
}
```
</details>

`normalizeString`メソッドは、グローバルスコープに定義されており、他のメソッドから呼び出して使われています。引数として受け取った文字列を1文字ずつ処理していき、変数`dots`によるドットカウンターを使ってパス内の`.`や`..`を解決します。

64行のメソッドなので極端に長いわけではありませんが、分岐が多く、どういう方針で`.`や`..`を解決する処理を実現しようとしているのかを把握するのに苦労しました。コードリーディング開始からの1時間の大半を`normalizeString`メソッドの理解に費やしたのですが、もっとたくさん読めるはずなのにというもどかしさもありました。

### 1時間40分経過
そこから40分ほど読み続けました。グローバルスコープに定義された残り2つのメソッド（`formatExt`, `_format`）を読んだあとで、前述の背景知識を活用し、Windows以外に向けた（POSIX用）メソッドを読み進めるように方針を変え、`node/lib/path.js`の後半部分に定義されたコードを読みました。Windows専用の特別な処理は後回しにして、より理解しやすいコードを先に読むためです。また何のためのメソッドかわかりにくいものに関しては、Node.jsの公式ドキュメントも参考にしました。

<details><summary>グローバルスコープに定義された残り2つのメソッド（formatExt, _format）</summary>

```js:node/lib/path.js
function formatExt(ext) {
  return ext ? `${ext[0] === '.' ? '' : '.'}${ext}` : '';
}

/**
 * @param {string} sep
 * @param {{
 *  dir?: string;
 *  root?: string;
 *  base?: string;
 *  name?: string;
 *  ext?: string;
 *  }} pathObject
 * @returns {string}
 */
function _format(sep, pathObject) {
  validateObject(pathObject, 'pathObject');
  const dir = pathObject.dir || pathObject.root;
  const base = pathObject.base ||
    `${pathObject.name || ''}${formatExt(pathObject.ext)}`;
  if (!dir) {
    return base;
  }
  return dir === pathObject.root ? `${dir}${base}` : `${dir}${sep}${base}`;
}
```
</details>

<details><summary>POSIX用のresolve, normalize, isAbsolute, relativeメソッド</summary>

```js:node/lib/path.js
  /**
   * path.resolve([from ...], to)
   * @param {...string} args
   * @returns {string}
   */
  resolve(...args) {
    let resolvedPath = '';
    let resolvedAbsolute = false;

    for (let i = args.length - 1; i >= -1 && !resolvedAbsolute; i--) {
      const path = i >= 0 ? args[i] : posixCwd();

      validateString(path, 'path');

      // Skip empty entries
      if (path.length === 0) {
        continue;
      }

      resolvedPath = `${path}/${resolvedPath}`;
      resolvedAbsolute =
        StringPrototypeCharCodeAt(path, 0) === CHAR_FORWARD_SLASH;
    }

    // At this point the path should be resolved to a full absolute path, but
    // handle relative paths to be safe (might happen when process.cwd() fails)

    // Normalize the path
    resolvedPath = normalizeString(resolvedPath, !resolvedAbsolute, '/',
                                   isPosixPathSeparator);

    if (resolvedAbsolute) {
      return `/${resolvedPath}`;
    }
    return resolvedPath.length > 0 ? resolvedPath : '.';
  },

  /**
   * @param {string} path
   * @returns {string}
   */
  normalize(path) {
    validateString(path, 'path');

    if (path.length === 0)
      return '.';

    const isAbsolute =
      StringPrototypeCharCodeAt(path, 0) === CHAR_FORWARD_SLASH;
    const trailingSeparator =
      StringPrototypeCharCodeAt(path, path.length - 1) === CHAR_FORWARD_SLASH;

    // Normalize the path
    path = normalizeString(path, !isAbsolute, '/', isPosixPathSeparator);

    if (path.length === 0) {
      if (isAbsolute)
        return '/';
      return trailingSeparator ? './' : '.';
    }
    if (trailingSeparator)
      path += '/';

    return isAbsolute ? `/${path}` : path;
  },

  /**
   * @param {string} path
   * @returns {boolean}
   */
  isAbsolute(path) {
    validateString(path, 'path');
    return path.length > 0 &&
           StringPrototypeCharCodeAt(path, 0) === CHAR_FORWARD_SLASH;
  },

  /**
   * @param {string} from
   * @param {string} to
   * @returns {string}
   */
  relative(from, to) {
    validateString(from, 'from');
    validateString(to, 'to');

    if (from === to)
      return '';

    // Trim leading forward slashes.
    from = posix.resolve(from);
    to = posix.resolve(to);

    if (from === to)
      return '';

    const fromStart = 1;
    const fromEnd = from.length;
    const fromLen = fromEnd - fromStart;
    const toStart = 1;
    const toLen = to.length - toStart;

    // Compare paths to find the longest common path from root
    const length = (fromLen < toLen ? fromLen : toLen);
    let lastCommonSep = -1;
    let i = 0;
    for (; i < length; i++) {
      const fromCode = StringPrototypeCharCodeAt(from, fromStart + i);
      if (fromCode !== StringPrototypeCharCodeAt(to, toStart + i))
        break;
      else if (fromCode === CHAR_FORWARD_SLASH)
        lastCommonSep = i;
    }
    if (i === length) {
      if (toLen > length) {
        if (StringPrototypeCharCodeAt(to, toStart + i) === CHAR_FORWARD_SLASH) {
          // We get here if `from` is the exact base path for `to`.
          // For example: from='/foo/bar'; to='/foo/bar/baz'
          return StringPrototypeSlice(to, toStart + i + 1);
        }
        if (i === 0) {
          // We get here if `from` is the root
          // For example: from='/'; to='/foo'
          return StringPrototypeSlice(to, toStart + i);
        }
      } else if (fromLen > length) {
        if (StringPrototypeCharCodeAt(from, fromStart + i) ===
            CHAR_FORWARD_SLASH) {
          // We get here if `to` is the exact base path for `from`.
          // For example: from='/foo/bar/baz'; to='/foo/bar'
          lastCommonSep = i;
        } else if (i === 0) {
          // We get here if `to` is the root.
          // For example: from='/foo/bar'; to='/'
          lastCommonSep = 0;
        }
      }
    }

    let out = '';
    // Generate the relative path based on the path difference between `to`
    // and `from`.
    for (i = fromStart + lastCommonSep + 1; i <= fromEnd; ++i) {
      if (i === fromEnd ||
          StringPrototypeCharCodeAt(from, i) === CHAR_FORWARD_SLASH) {
        out += out.length === 0 ? '..' : '/..';
      }
    }

    // Lastly, append the rest of the destination (`to`) path that comes after
    // the common path parts.
    return `${out}${StringPrototypeSlice(to, toStart + lastCommonSep)}`;
  },
```
</details>

この間のコードリーディングの実績を表にまとめると以下のようになりました。

| メソッド                  | 行数 | 分  |
| ------------------------- |:----:|:---:|
| `formatExt`, `_format`    |  24  |  7  |
| `resolve`                 |  24  | 15  |
| `normalize`, `isAbsolute` |  37  |  2  |
| `relative`                |  75  | 12  |

最初の1時間は、64行の`normalizeString`メソッドを読むのに大半を費やしましたが、それ以降は明らかにコードリーディング速度が増していました。

### コードリーディング速度の測定結果
最初の1時間は、定数定義やコメントのような読み飛ばしに近い行を除けば、ほぼ`normalizeString`のみだったため、64[lines/hour]というコードリーディング速度でした。そして続く36分は6つのメソッドを読み、267[lines/hour]となりました。

| 経過時間                  | [lines/hour] |
| ------------------------- |:------------:|
| 0:00から1:00（1時間経過） |      64      |
| 1:00から1:36              |     267      |

最後の`relative`メソッドを読んだときには、当初より明らかにPathモジュールへの理解が深まり、背景知識となるような他のメソッドの動作も把握できていたため、3つ前に読んだ`resolve`メソッドよりもさらに速く読めているという実感がありました。体感的には10分で50行くらいは読んで理解できるペースだと感じたので、300[lines/hour]まで上がっていたと思われます。

今回の検証は数値で計測しているものの、「コードを読んで理解できた」という判断が入るため主観評価の域を出ず、個人差は大きいと思います。しかし、コードリーディングを進めていく過程でコードリーディング速度が上がっていったという体験がある方は結構いるのではないでしょうか。その1例として、 **コードリーディング速度に4倍近い差が出た** という今回の検証結果は、興味深いです。

## まとめ
実際にコードリーディングしながら時間と行数を計り、コードリーディング速度を算出してみました。この検証により、コードリーディングを進めていくにつれて、コードリーディング速度が上がっていることが観測できました。これは背景知識の増加や理解度の向上に伴ったスピードアップだと考えられます。

時間的制約のなかでコードリーディングをすると、読み始めはかなり苦戦すると思います。しかし、徐々にそのコードへの「土地勘」のようなもの（背景知識の増加や理解度の向上）がついてきてコードを読むスピードも上がってきます。個人差があるため、今回の検証結果は参考にすぎませんが、ぜひ余裕のあるときに試しにコードリーディング速度を計ってみてはいかがでしょうか？切羽詰まった状況でも「あと2時間あれば〇〇行は読める」と思えるような参考値を把握しておけば、むやみに焦ったり落ち込んだりしてしまうことを避けられるかもしれません。