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

Node.jsのPathモジュールで定義されている`join`メソッドが10年以上の開発期間を経てどのように変化し、成長してきたかをコミット履歴をたどりながらコードリーディングを行って調べてみました。この前編では、まず現在の`join`メソッドのソースコードを読んで解説します。[後編](https://qiita.com/YY-EN40P/items/99a83b5676cfd222d071)では全34回のコミットを3つの時期に分けて、その成長の記録を追いかけていきます。

## Node.js Pathモジュールのjoinメソッドとは？
Pathモジュールの`join`メソッドは、引数を配列要素として受け取り、それらを文字列として連結してくれるメソッドです。[^1]

```js
const path = require('node:path');

path.join('/foo', 'bar', 'baz')
=> '/foo/bar/baz'
```

引数に文字列以外の値を入れると`TypeError`を吐き出します。`Node.js`を動作させるOSによって出力するパスを変えられるよう、ソースコード(`node/lib/path.js`)には`Windows`用と`POSIX`用の2つの`join`メソッドが実装されています。

ちなみに`POSIX`(`Portable Operating System Interface`)とは、主にUNIX系OSに共通する機能についてインターフェース等を定めた規格であり、ここでは`Windows`以外という意味で`Linux`や`macOS`用のメソッドであることを指しています。[^2][^3]

## POSIX用のjoinメソッドのソースコードリーディング
2022/12/7現在の最新のソースコードのうち、比較的短いコードで書かれている`POSIX`用の`join`メソッドの内部処理から説明していきます。

```js:node/lib/path.js
const posix = {

    /**
     * @param {...string} args
     * @returns {string}
     */
    join(...args) {
      if (args.length === 0)
        return '.';
      let joined;
      for (let i = 0; i < args.length; ++i) {
        const arg = args[i];
        validateString(arg, 'path');
        if (arg.length > 0) {
          if (joined === undefined)
            joined = arg;
          else
            joined += `/${arg}`;
        }
      }
      if (joined === undefined)
        return '.';
      return posix.normalize(joined);
    },

}
```

まず`posix`という定数に複数のメソッドが定義されており、`join`メソッドもその1つになっています。これが前述した`POSIX`用のメソッドという意味です。

メソッド定義1行目にある引数`...args`は、引数の数を指定せず拡張できる残余引数（可変長引数）[^4]です。`...`を使うと、`path.join('foo')`のように1つだけ引数を渡すこともできますし、`path.join('foo', 'bar', 'baz')`のように3つの引数を渡すこともできます。

次のif文は、`join`メソッドに引数が1つも渡されていなかった場合の処理です。`args.length`は配列要素の数を返す定番表現であり、条件を作る時によく使われます。配列要素が0、つまり引数がないため、パスの連結処理が不要となります。`.`という文字列を返して処理を終了させます。（`join`メソッドはパスを扱うメソッドですので、この戻り値`.`はカレントディレクトリに相当するパスです。）

if文の処理のあとは、`joined`という変数を宣言し、for文による繰り返し処理が行われています。`args.length`は繰り返し処理の回数上限を設定するために利用されています。このfor文では、`join`メソッドに渡された引数の数だけ処理を繰り返すという条件指定を行っています。

for文の中の処理に入っていきます。`arg`という定数を宣言し、`join`メソッドが受け取った引数の配列`args`から`i`で要素を指定して`arg`に代入しています。これを`validateString`メソッドに渡して引数が文字列であることを確認しています。`validateString`メソッドは、第1引数の値が文字列であるかどうかを確認し、文字列以外の場合は`TypeError`を返すメソッドです。[^5] `TypeError`となった場合は、この段階でエラーを吐き出して`join`メソッドの処理も終了となります。

少し脱線しますが、この`validateString`は`Node.js`の各モジュールで共通に使うために定義されたメソッドです。

```js:lib/internal/validators.js
/** @type {validateString} */
function validateString(value, name) {
  if (typeof value !== 'string')
    throw new ERR_INVALID_ARG_TYPE(name, 'string', value);
}
```

`join`メソッド内では第2引数として`path`という文字列を受け取っています（`validateString(arg, 'path')`）が、この`path`という文字列はさらに`ERR_INVALID_ARG_TYPE`へ第1引数として受け渡しされます。最終的に`ERR_INVALID_ARG_TYPE`が生成するエラーメッセージの一部として`path`が使われます。

再びfor文の中の処理に戻りましょう。`validateString`で引数が文字列であることをバリデーションできたら、if文にて定数`arg`に入っている文字列が空の文字列（""）かどうかを判別します。空の文字列なら何もせず、ここで1回分のfor文内の処理が完了となります。`arg`に何か文字列が入っていれば、それがパスを生成するための最初の文字列かどうかを判別します。

`if (joined === undefined)`の行から始まる処理が、`join`メソッドのメインの処理になっています。`join`メソッドは、`foo`, `bar`, `baz`という文字列を`foo/bar/baz`のように`/`をつけて連結したいだけなので、1つ目の引数は何もせず、2つ目以降の引数には先頭に`/`をつけて、すでに連結した文字列の後ろに連結するという処理を行います。1つ目の引数であるかどうかを判別するために、`joined`に何か値が入っているかどうか（＝`undefined`かどうか）を条件として利用しています。`/`の追加と前の文字列への連結は、バッククォートを使ったテンプレートリテラルとプレースホルダー`${}`を利用して実装してあります。

for文の処理を繰り返すと、たとえば`path.join('foo', 'bar', 'baz')`の場合には、`joined`に`foo/bar/baz`という文字列が入ります。仮に`path.join('', '', '')`のような空の文字列を3つ渡したとすると、`joined`には何も代入されず`undefined`のままになります。こういったケースを次のif文で検出して`.`を返すようにしています。

最後の`normalize`メソッド[^6]も同じく`node/lib/path.js`にて定義されており、パスに含まれている`.`や`..`を解決します。例えば`/foo/bar/..`となっている場合は、`/foo`を返します。こうしてパス生成の最後の仕上げまで行って、連結したパスを返すことが`join`の処理になります。

以上が`POSIX`用の`join`メソッドの内部処理です。

## Windows用のjoinメソッドのソースコードリーディング
`Windows`用のソースコードは以下の通りです。

```js:node/lib/path.js
/**
   * @param {...string} args
   * @returns {string}
   */
  join(...args) {
    if (args.length === 0)
      return '.';

    let joined;
    let firstPart;
    for (let i = 0; i < args.length; ++i) {
      const arg = args[i];
      validateString(arg, 'path');
      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 a UNC path then.
    //
    // This step is skipped when it is very clear that the user actually
    // intended to point at a 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 a 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\\')
    let needsReplace = true;
    let slashCount = 0;
    if (isPathSeparator(StringPrototypeCharCodeAt(firstPart, 0))) {
      ++slashCount;
      const firstLen = firstPart.length;
      if (firstLen > 1 &&
          isPathSeparator(StringPrototypeCharCodeAt(firstPart, 1))) {
        ++slashCount;
        if (firstLen > 2) {
          if (isPathSeparator(StringPrototypeCharCodeAt(firstPart, 2)))
            ++slashCount;
          else {
            // We matched a UNC path in the first part
            needsReplace = false;
          }
        }
      }
    }
    if (needsReplace) {
      // Find any more consecutive slashes we need to replace
      while (slashCount < joined.length &&
             isPathSeparator(StringPrototypeCharCodeAt(joined, slashCount))) {
        slashCount++;
      }

      // Replace the slashes if needed
      if (slashCount >= 2)
        joined = `\\${StringPrototypeSlice(joined, slashCount)}`;
    }

    return win32.normalize(joined);
  },
```

中央に長々とコメントがありますが、そこまでの内容は`POSIX`用とほぼ同じです。違いは、`firstPart`という変数を追加して第1引数の値を別途保持しているところ（`joined = firstPart = arg`）とパスの連結のために`/`ではなく`\\`を付加しているところです。`\\`は1つ目の`\`がエスケープ用、2つ目の`\`が実際のパス連結用です。

以降の処理をざっと眺めて最後まで見ると、最終行では`POSIX`用と同じ`normalize`メソッドへ`joined`に入れたパスの文字列を渡しています。

`POSIX`用と異なる部分は、コメントにも説明がありますが、`Windows`で使われているUNCパスのための`\\`（バックスラッシュ）に対応した処理になっています。コメントによると、最終行で呼び出す`normalize`メソッドは、UNCパスの特徴である「パスの先頭に2つのバックスラッシュが続く」パスの処理に対応していないため、`normalize`メソッドへ渡す前に`join`メソッド内で処理する必要があるとのことです。

長いコメントのあとに続くif文は、ネストが深くなっていてややこしいですが、`firstPart`に格納した第1引数の文字列の中にバックスラッシュがいくつあるかを数えて`slashCount`の値へ反映しています。この処理によってUNCパスであるかいなかを判別し、`\`の置き換え処理が必要かどうかを選別しています。

`StringPrototypeCharCodeAt`は、`firstPart`に格納された文字列の位置を指定して、その文字コードを返します。第1引数は`this`であり、`StringPrototypeCharCodeAt(firstPart, 0)`は`firstPart.charCodeAt(0)`と同じ意味になります。[^7] `isPathSeparator`はその文字コードが`/`または`\`に該当するかどうかを識別します。`\\server`という文字列が`firstPart`に入っていたとすると、`slashCount`が2となってUNCパスであることが確定し、`needsReplace`がfalseに設定され、以降のif文の処理がスキップされます。`\\\foo`のように3つのバックスラッシュがある場合は、`slashCount`が3となって、次のif文で置き換え処理が実行されます。

次に文字列を連結してパスを生成した状態で「パスの先頭に2つのバックスラッシュが続く」パスになっているかどうかを調べて、`slashCount`に結果を反映しています。`path.join('\', '\server')`のようなケースを想定した処理です。

最後に`slashCount`が2以上の場合に先頭のバックスラッシュの数を調整する処理を行っています。`StringPrototypeSlice(joined, slashCount)`は、`joined.slice(slashCount)`と同じ意味であり、`slashCount`の値だけ先頭のバックスラッシュを削った文字列を返しています。そこに2つのバックスラッシュを加えて`joined`に代入してパスを完成させています。

長くなりましたが、以上が`Node.js`の`Path`モジュールで定義されている`join`メソッドのソースコードの中身です。

## joinメソッドのコードリーディングを振り返って
`Windows`用、`POSIX`用の2つの定義をあわせて90行ほどのソースコードでしたが、いろんな知識を動員しなければきちんと読み解くことができないことがおわかりいただけたのではないでしょうか？今回のコードリーディングで必要となった知識について主なものを以下に挙げておきます。

- `POSIX`とは
- 残余引数（可変長引数とも呼ばれる）`...args`
- 現在のディレクトリへのパスが`.`で表記されること
- 繰り返し処理の回数上限として`args.length`のような配列要素の数を使う定番表現
- `Node.js`で定義されているメソッドの処理内容（`validateString`、`normalize`、`isPathSeparator`、`StringPrototypeCharCodeAt`、`StringPrototypeSlice`）
- `let joined`と変数宣言して何も値を代入していないときは`undefined`という値になるという`JavaScript`の基礎的な文法
- プレースホルダー`${}`を使った文字列への変数代入
- UNCパスとは

これらの知識や文法への理解が1つでも欠けていれば、その時点でコードリーディングは行き詰まります。頭の中にある知識だけですべてを理解することがそもそも難しい作業であることがわかります。実際のコードリーディングは、調べて、ソースコードに戻って、また調べて、の繰り返しです。

[後編](https://qiita.com/YY-EN40P/items/99a83b5676cfd222d071)では、このソースコードが10年以上の開発期間を経てどのように変化してきたかを28回分のコミット履歴をたどりながら見ていきます。

[^1]: https://nodejs.org/dist/latest-v18.x/docs/api/path.html#pathjoinpaths
[^2]: https://ja.wikipedia.org/wiki/POSIX
[^3]: https://e-words.jp/w/POSIX.html
[^4]: https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Functions/rest_parameters
[^5]: https://github.com/nodejs/node/blob/22c39b1ddd2f5f2ed13397b351b05836f7abea54/lib/internal/validators.js#L161
[^6]: https://nodejs.org/dist/latest-v18.x/docs/api/path.html#pathnormalizepath
[^7]: https://github.com/nodejs/node/blob/main/doc/contributing/primordials.md