【コードリーディング】Node.js Pathモジュールのjoinメソッド成長記録（2009年〜2022年）後編

[前編](https://qiita.com/YY-EN40P/items/4c1ef279498d34126802)では、`join`メソッドの最新版のソースコードを読んで内部処理の実装を見ていきました。後編ではいよいよコミット履歴をたどりながら`join`メソッドがどのように成長していったのかを見ていきます。

## joinメソッドの全コミット履歴
| 日付         | メッセージ                                                  | 修正内容                                     |  行数  |
|:-------------|:------------------------------------------------------------|:---------------------------------------------|:------:|
| Apr 15, 2009 | everything is changed. i've waited much too long to commit. | src/main.jsに実装                            |   16   |
| May 14, 2009 | Rename main.js to node.js.                                  | src/main.jsからsrc/node.jsへリネーム         |   16   |
| Jun 8, 2009  | Module system refactor                                      | 引数`.`および`./`を空白文字へ置換            |   20   |
| Jul 17, 2009 | Move node.inherit, node.path, node.cat to new file: util.js | src/node.jsからsrc/util.jsへ移動             |   20   |
| Nov 1, 2009  | Module refactor - almost CommonJS compatible now            | src/util.jsからsrc/node.jsへ移動             |   20   |
| Dec 23, 2009 | Fix require("../blah") issues                               | normalizeArray新設によるリファクタリング     |   3    |
| Apr 22, 2010 | refactor path module to lib/path.js                         | node/src/node.jsからnode/lib/path.jsへ独立   |   3    |
| Nov 15, 2010 | Implement new path.join behavior                            | ワンライナーから多様なケースへ対応           |   12   |
| Dec 2, 2010  | more lint                                                   | 表記統一などの修正                           |   11   |
| Jan 6, 2011  | Remove keepBlanks flag from path functions                  | 状態管理に使っていたkeepBlanks廃止           |   4    |
| Jan 7, 2011  | Path.resolve, path module windows compatibility             | windowsとposixに分けて2つ実装                | 16, 7  |
| Jan 7, 2011  | Lint                                                        | Lint。表記等の修正                           | 18, 8  |
| Aug 10, 2012 | path: small speed improvements                              | パフォーマンス改善のためのメソッド置き換え   | 18, 7  |
| Nov 21, 2012 | windows: fix normalization of UNC paths                     | UNCパス対応                                  | 28, 7  |
| Feb 22, 2013 | path: join throws TypeError on non-string args              | TypeErrorの処理を追加                        | 31, 10 |
| Feb 22, 2013 | Throw TypeError on non-string args to path.resolve          | TypeErrorメッセージ修正、リファクタリング    | 31, 10 |
| Jul 25, 2013 | lib: macro-ify type checks                                  | 型確認の処理を新設したマクロ関数へ置き換え   | 31, 10 |
| Aug 2, 2013  | src: Replace macros with util functions                     | マクロ関数をutilモジュールのメソッドへ置換   | 31, 10 |
| Jan 22, 2014 | path: improve POSIX path.join() performance                 | posixバージョンのパフォーマンス改善          | 31, 18 |
| Nov 21, 2014 | path: allow calling platform specific methods               | path.win32, path.posixとしてエクスポート     | 30, 18 |
| Nov 23, 2014 | Merge remote-tracking branch 'joyent/v0.12' into v0.12      | 内容的には上記コミットと同じ                 | 30, 18 |
| Feb 1, 2015  | lib: reduce util.is*() usage                                | utilモジュールの型確認関数をtypeofへ置換     | 30, 18 |
| Jul 4, 2015  | path: refactor for performance and consistency              | パフォーマンス改善と一貫性のリファクタリング | 31, 18 |
| Feb 10, 2016 | path: performance improvements on all platforms             | パフォーマンス改善に伴う大規模修正           | 71, 18 |
| Feb 13, 2018 | path: replace "magic" numbers by readable constants         | ハードコーディングの文字コードを定数へ置換   | 71, 18 |
| Feb 17, 2018 | path: replace duplicate conditions by functions             | 繰り返し登場する判別処理を関数へ置換         | 66, 18 |
| Dec 7, 2018  | path: replace assertPath() with validator                   | 引数のバリデーション関数を共通化のため置換   | 66, 18 |
| Mar 1, 2019  | path: minor refactoring                                     | 細かいリファクタリング                       | 66, 18 |
| Mar 1, 2019  | path: more small refactorings                               | 同じく細かいリファクタリング                 | 66, 18 |
| Mar 1, 2019  | path: refactor more path code for simplicity                | ifによるネストを1階層減らすリファクタリング  | 64, 18 |
| Nov 10, 2019 | path: replace var with let in lib/path.js                   | varをletに修正（修正漏れの修正）             | 64, 18 |
| Apr 29, 2020 | path: fix comment grammar                                   | コメントの文法（英語）の間違いを修正         | 64, 18 |
| Dec 3, 2020  | path: refactor to use more primordials                      | premordialsを使ったリファクタリング          | 65, 18 |
| Apr 15, 2021 | typings: add JSDoc types to lib/path                        | JSDoc用コメント追加                          | 69, 22 |

2009/4/15に初めてコミットされてから現在に至るまでの34回分のコミットをまとめてみました。前編でも見たとおり、`join`メソッドはWindows用とPOSIX用の2つの実装があるため、行数の欄にもWindows用、POSIX用の順に記載しています。

1つのメソッドも13年以上の開発期間となると非常に多くの改善が行われています。それぞれのソースコードを見ていくと、4つの期間に分けることができそうです。古い方から順番に紹介していきます。

## 創成期 （2009/4/15-2011/1/6）
```js src/main.js
node.path = new function () {
    this.join = function () {
        var joined = "";
        for (var i = 0; i < arguments.length; i++) {
            var part = arguments[i].toString();
            if (i === 0) {
                part = part.replace(/\/*$/, "/");
            } else if (i === arguments.length - 1) {
                part = part.replace(/^\/*/, "");
            } else {
                part = part.replace(/^\/*/, "")
                           .replace(/\/*$/, "/");
            }
            joined += part;
        }
        return joined;
    };

};
```

`join`メソッドの一番最初の実装が上記のコードになっています。[前編](https://qiita.com/YY-EN40P/items/4c1ef279498d34126802)で最新版の`join`メソッドのソースコードを紹介しましたが、受け取った引数をパスとして連結して返すというシンプルな機能は変わりないものの、ソースコードの中身はかなりの違いがあります。

引数の扱いには`...args`のような残余引数（可変長引数）ではなく、`arguments`オブジェクト[^1]を使っています。これは2019/3/1の`path: more small refactorings`というコミットまで続きます。主な処理内容は、引数に含まれる特殊文字の置き換えになっています。

| date         | message                                                     | 修正内容                                 | 行数 |
|:-------------|:------------------------------------------------------------|:-----------------------------------------|:----:|
| Apr 15, 2009 | everything is changed. i've waited much too long to commit. | src/main.jsに実装                        |  16  |
| May 14, 2009 | Rename main.js to node.js.                                  | src/main.jsからsrc/node.jsへリネーム     |  16  |
| Jun 8, 2009  | Module system refactor                                      | 引数`.`および`./`を空白文字へ置換        |  20  |
| Jul 17, 2009 | Move node.inherit, node.path, node.cat to new file: util.js | src/node.jsからsrc/util.jsへ移動         |  20  |
| Nov 1, 2009  | Module refactor - almost CommonJS compatible now            | src/util.jsからsrc/node.jsへ移動         |  20  |
| Dec 23, 2009 | Fix require("../blah") issues                               | normalizeArray新設によるリファクタリング |  3   |
| Apr 22, 2010 | refactor path module to lib/path.js                         | `src/node.js`から`lib/path.js`へ独立     |  3   |
| Nov 15, 2010 | Implement new path.join behavior                            | ワンライナーから多様なケースへ対応       |  12  |
| Dec 2, 2010  | more lint                                                   | 表記統一などの修正                       |  11  |
| Jan 6, 2011  | Remove keepBlanks flag from path functions                  | 状態管理に使っていた`keepBlanks`廃止     |  4   |

10回のコミットが行われた2009/4/15から2011/1/6(11回目のコミットの直前)までを創成期としました。初めて`node/src/main.js`というファイルが出来た2009/4/15から、`node/src/main.js` -> `node/src/node.js` -> `node/src/util.js` -> `node/src/node.js` -> `node/lib/path.js`と4回の移動（またはファイルのリネーム）を繰り返し、現在と同じ`node/lib/path.js`へたどり着いています。ちなみに2009/4/15に生成された`main.js`の先頭に定義されているメソッドがこの`join`メソッドでした。

```js node/lib/path.js
exports.join = function() {
  var args = Array.prototype.slice.call(arguments);
  return exports.normalizeArray(args).join('/');
};
```

創成期の最後には、2009/12/23に導入された`normalizeArray`メソッドに大半の処理を預け、`arguments`オブジェクトを配列に置き換えてから処理を行うように変更されて、4行という非常に短いコードになっています。

## OS適応期 （2011/1/7-2016/2/9）
```js node/lib/path.js
if (isWindows) {

  // windows version
  exports.join = function() {
    var paths = Array.prototype.slice.call(arguments, 0).filter(function(p) {
          return p && typeof p === 'string';
        }),
        joined = paths.join('\\');

    // Make sure that the joined path doesn't start with two slashes
    // - it will be mistaken for an unc path by normalize() -
    // unless the paths[0] also starts with two slashes
    if (/^[\\\/]{2}/.test(joined) && !/^[\\\/]{2}/.test(paths[0])) {
      joined = joined.slice(1);
    }

    return exports.normalize(joined);
  }

} else /* posix */ {

  // posix version
  exports.join = function() {
    var paths = Array.prototype.slice.call(arguments, 0);
    return exports.normalize(paths.filter(function(p, index) {
      return p && typeof p === 'string'
    }).join('/'));
  }
}
```

2011/1/7のコミット`Path.resolve, path module windows compatibility`から始まり、2016/2/9まで続く期間をOS適応期とします。この時期の最大の変化は、Windows用とPOSIX用に分けた2つの実装が誕生したことです。その要因となるのは、WindowsにおけるUNCパス[^2][^3]への対応です。先頭に2つのバックスラッシュが続くパスという特徴を考慮した処理がWindowsバージョンの実装にのみ追加されています。

| date         | message                                                | 修正内容                                     |  行数  |
|:-------------|:-------------------------------------------------------|:---------------------------------------------|:------:|
| Jan 7, 2011  | Path.resolve, path module windows compatibility        | windowsとposixに分けて2つ実装                | 16, 7  |
| Jan 7, 2011  | Lint                                                   | Lint。表記等の修正                           | 18, 8  |
| Aug 10, 2012 | path: small speed improvements                         | パフォーマンス改善のためのメソッド置き換え   | 18, 7  |
| Nov 21, 2012 | windows: fix normalization of UNC paths                | UNCパス対応                                  | 28, 7  |
| Feb 22, 2013 | path: join throws TypeError on non-string args         | TypeErrorの処理を追加                        | 31, 10 |
| Feb 22, 2013 | Throw TypeError on non-string args to path.resolve     | TypeErrorメッセージ修正、リファクタリング    | 31, 10 |
| Jul 25, 2013 | lib: macro-ify type checks                             | 型確認の処理を新設したマクロ関数へ置き換え   | 31, 10 |
| Aug 2, 2013  | src: Replace macros with util functions                | マクロ関数をutilモジュールのメソッドへ置換   | 31, 10 |
| Jan 22, 2014 | path: improve POSIX path.join() performance            | posixバージョンのパフォーマンス改善          | 31, 18 |
| Nov 21, 2014 | path: allow calling platform specific methods          | path.win32, path.posixとしてエクスポート     | 30, 18 |
| Nov 23, 2014 | Merge remote-tracking branch 'joyent/v0.12' into v0.12 | 内容的には上記コミットと同じ                 | 30, 18 |
| Feb 1, 2015  | lib: reduce util.is*() usage                           | utilモジュールの型確認関数をtypeofへ置換     | 30, 18 |
| Jul 4, 2015  | path: refactor for performance and consistency         | パフォーマンス改善と一貫性のリファクタリング | 31, 18 |

OS適応期は13回のコミットが行われ、UNCパス対応以外にもパフォーマンス改善や引数のバリデーションなどの修正が繰り返されています。コードの行数もWindowsバージョンは16行から31行へ、POSIXバージョンは7行から18行へと増加しています。OS適応期の最終形は、以下のような実装となりました。

```js node/lib/path.js
var win32 = {};

win32.join = function() {
  var paths = [];
  for (var i = 0; i < arguments.length; i++) {
    var arg = arguments[i];
    assertPath(arg);
    if (arg) {
      paths.push(arg);
    }
  }

  var joined = paths.join('\\');

  // Make sure that the joined path doesn't start with two slashes, because
  // normalize() will mistake it for an UNC path then.
  //
  // This step is skipped when it is very clear that the user actually
  // intended to point at an UNC path. This is assumed when the first
  // non-empty string arguments starts with exactly two slashes followed by
  // at least one more non-slash character.
  //
  // Note that for normalize() to treat a path as an UNC path it needs to
  // have at least 2 components, so we don't filter for that here.
  // This means that the user can use join to construct UNC paths from
  // a server name and a share name; for example:
  //   path.join('//server', 'share') -> '\\\\server\\share\')
  if (!/^[\\\/]{2}[^\\\/]/.test(paths[0])) {
    joined = joined.replace(/^[\\\/]{2,}/, '\\');
  }

  return win32.normalize(joined);
};

var posix = {};

// posix version
posix.join = function() {
  var path = '';
  for (var i = 0; i < arguments.length; i++) {
    var segment = arguments[i];
    assertPath(segment);
    if (segment) {
      if (!path) {
        path += segment;
      } else {
        path += '/' + segment;
      }
    }
  }
  return posix.normalize(path);
};
```

Windowsバージョンのコード行数が大きく変化した要因は、UNCパスに関する実装説明の長いコメントの追加です。POSIXバージョンのコード行数が大きく変化した要因は、2014/1/22のコミット`path: improve POSIX path.join() performance`によるパフォーマンス改善のためであり、`Array.prototype.join`によって配列要素の文字列を`/`で結合するという処理をfor文内の`+=`に置き換えています。4倍(1.5us)相当の改善効果があったとのことです。

またこの時期は引数として受け取った文字列（パスの一部）のバリデーションの手段が、`typeof` -> `IS_STRING`（マクロ関数） -> `utils.isString`（utilモジュールのメソッド） -> `typeof`と何度も変更になっています。最終的に`assertPath`というバリデーション用の関数の内部で`typeof`を使う方法に落ち着いています。

## パフォーマンス向上期 （2016/2/10-2022/12現在）
```js node/lib/path.js
const win32 = {

  join: function join() {
    if (arguments.length === 0)
      return '.';

    var joined;
    var firstPart;
    for (var i = 0; i < arguments.length; ++i) {
      var arg = arguments[i];
      assertPath(arg);
      if (arg.length > 0) {
        if (joined === undefined)
          joined = firstPart = arg;
        else
          joined += '\\' + arg;
      }
    }

    if (joined === undefined)
      return '.';

    // Make sure that the joined path doesn't start with two slashes, because
    // normalize() will mistake it for an UNC path then.
    //
    // This step is skipped when it is very clear that the user actually
    // intended to point at an UNC path. This is assumed when the first
    // non-empty string arguments starts with exactly two slashes followed by
    // at least one more non-slash character.
    //
    // Note that for normalize() to treat a path as an UNC path it needs to
    // have at least 2 components, so we don't filter for that here.
    // This means that the user can use join to construct UNC paths from
    // a server name and a share name; for example:
    //   path.join('//server', 'share') -> '\\\\server\\share\\')
    //var firstPart = paths[0];
    var needsReplace = true;
    var slashCount = 0;
    var code = firstPart.charCodeAt(0);
    if (code === 47/*/*/ || code === 92/*\*/) {
      ++slashCount;
      const firstLen = firstPart.length;
      if (firstLen > 1) {
        code = firstPart.charCodeAt(1);
        if (code === 47/*/*/ || code === 92/*\*/) {
          ++slashCount;
          if (firstLen > 2) {
            code = firstPart.charCodeAt(2);
            if (code === 47/*/*/ || code === 92/*\*/)
              ++slashCount;
            else {
              // We matched a UNC path in the first part
              needsReplace = false;
            }
          }
        }
      }
    }
    if (needsReplace) {
      // Find any more consecutive slashes we need to replace
      for (; slashCount < joined.length; ++slashCount) {
        code = joined.charCodeAt(slashCount);
        if (code !== 47/*/*/ && code !== 92/*\*/)
          break;
      }

      // Replace the slashes if needed
      if (slashCount >= 2)
        joined = '\\' + joined.slice(slashCount);
    }

    return win32.normalize(joined);
  },

};

const posix = {

  join: function join() {
    if (arguments.length === 0)
      return '.';
    var joined;
    for (var i = 0; i < arguments.length; ++i) {
      var arg = arguments[i];
      assertPath(arg);
      if (arg.length > 0) {
        if (joined === undefined)
          joined = arg;
        else
          joined += '/' + arg;
      }
    }
    if (joined === undefined)
      return '.';
    return posix.normalize(joined);
  },

}
```

2016/2/10のコミット`path: performance improvements on all platforms`から現在に至るまでをパフォーマンス向上期としました。2016/2/10のコミットによって上記のようにコードが大幅にリニューアルされています。前述したOS適応期の2014/1/22のコミット`path: improve POSIX path.join() performance`に続いてWindowsバージョンでもパフォーマンス改善の取り組みが行われています。

Windowsバージョンのパフォーマンス改善のために、POSIXバージョンと同じく`Array.prototype.join`を使った`\\`による文字列の結合をfor文と`+=`を組み合わせた文字列結合に変更しています。またUNCパス対応の処理も、正規表現によって`\\`を検出する処理から、`/`や`\`を文字コードで比較して判別する処理（コミットコメントではmanual parsersと表記）に変更しています。

| date         | message                                             | 修正内容                                    |  行数  |
|:------------ |:--------------------------------------------------- |:------------------------------------------- |:------:|
| Feb 10, 2016 | path: performance improvements on all platforms     | パフォーマンス改善に伴う大規模修正          | 71, 18 |
| Feb 13, 2018 | path: replace "magic" numbers by readable constants | ハードコーディングの文字コードを定数へ置換  | 71, 18 |
| Feb 17, 2018 | path: replace duplicate conditions by functions     | 繰り返し登場する判別処理を関数へ置換        | 66, 18 |
| Dec 7, 2018  | path: replace assertPath() with validator           | 引数のバリデーション関数を共通化のため置換  | 66, 18 |
| Mar 1, 2019  | path: minor refactoring                             | 細かいリファクタリング                      | 66, 18 |
| Mar 1, 2019  | path: more small refactorings                       | 同じく細かいリファクタリング                | 66, 18 |
| Mar 1, 2019  | path: refactor more path code for simplicity        | ifによるネストを1階層減らすリファクタリング | 64, 18 |
| Nov 10, 2019 | path: replace var with let in lib/path.js           | varをletに修正（修正漏れの修正）            | 64, 18 |
| Apr 29, 2020 | path: fix comment grammar                           | コメントの文法（英語）の間違いを修正        | 64, 18 |
| Dec 3, 2020  | path: refactor to use more primordials              | premordialsを使ったリファクタリング         | 65, 18 |
| Apr 15, 2021 | typings: add JSDoc types to lib/path                | JSDoc用コメント追加                         | 69, 22 |

パフォーマンス向上期は現在までに11回のコミットが行われています。大きなパフォーマンス改善がなされて、やるべきことをほぼやりきったのか、この時期のコミット内容はコメント内の英語の文法ミスの修正や変数宣言の`var`を`let`に変える修正など、実は単純なものが多かった印象です。　

## おわりに
非常に長くなってしまいましたが、`Node.js`のPathモジュールで定義されている`join`メソッドの13年を超える成長を見てきました。ここ1年半ほどは特に変更がありませんが、13年を超える開発期間を経て、単に引数として渡された文字列をパスとして結合するというシンプルな機能であっても、たくさんの改善がなされてきたことがわかります。2022/12時点の最新版のソースコードは[前編](https://qiita.com/YY-EN40P/items/4c1ef279498d34126802)で詳しく紹介していますので、気になる方はそちらもご確認ください。

[^1]: https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Functions/arguments
[^2]: https://e-words.jp/w/UNC.html
[^3]: https://learn.microsoft.com/ja-jp/dotnet/standard/io/file-path-formats#unc-paths