Pathname#joinは単純に複数の文字列を連結しているだけではない、という話

## はじめに

Rubyの`Pathname#join`メソッドを使うと2つのパスを連結することができます。

```ruby
pathname = Pathname.new('/pen/pineapple')
pathname.join('apple/pen').to_s
#=> "/pen/pineapple/apple/pen"
```

上の結果を見ると、`Pathname#join`は2つのパスを`/`で連結してくれるメソッドのように見えます。

しかし、`join`メソッドの引数が`/`で始まっていると、少し直感に反した動きになります。

```ruby
pathname = Pathname.new('/pen/pineapple')
# "/"で始まるパスを指定すると、引数そのものを表すパスが戻り値になる
pathname.join('/apple/pen').to_s
#=> "/apple/pen"
```

ご覧のとおり、引数で渡した"/apple/pen"がそのまま戻り値になってしまいました。
（つまり、単純な文字列連結になっていない）

`join`メソッドに複数の引数を渡したときも同様です。

```ruby
pathname = Pathname.new('/pen')
# 複数の引数を渡す（"/"で始まるパスなし）
pathname.join('pineapple', 'apple', 'pen').to_s
#=> "/pen/pineapple/apple/pen"

# 複数の引数を渡す（2つ目の引数が"/"で始まると、その手前のパスが消える）
pathname.join('pineapple', '/apple', 'pen').to_s
#=> "/apple/pen"
```

ところで、`Pathname#join`と少し似たメソッドで`File.join`があります。
`File.join`はシンプルに複数のパスを連結してくれるので、人によっては「`File.join`の方が自分の欲しいメソッドだ」と思うかもしれません。

```ruby
# 以下のメソッド呼び出しはいずれも同じ結果が返る
# （"/"で始まる引数が2つ目以降に登場しても手前のパスが維持される）
File.join('/pen/pineapple', 'apple/pen')
#=> "/pen/pineapple/apple/pen"
File.join('/pen/pineapple', '/apple/pen')
#=> "/pen/pineapple/apple/pen"
File.join('/pen', 'pineapple', 'apple', 'pen')
#=> "/pen/pineapple/apple/pen"
File.join('/pen', 'pineapple', '/apple', 'pen')
#=> "/pen/pineapple/apple/pen"
```

では、`Pathname#join`メソッドはなぜ、このように直感に反する動きをするのでしょうか？
この点が気になったので、以下のissueで僕の疑問点を質問してみました。

[Bug \#14891: Pathname\#join has different behaviour to File\.join \- Ruby master \- Ruby Issue Tracking System](https://bugs.ruby-lang.org/issues/14891)

そこで得られたzverok氏の回答が「なるほど」と思ったので、その内容を以下にまとめます。

## Pathname#joinはシェルのcdコマンドのように振る舞う

zverok氏いわく、`Pathname#join`はシェルの`cd`コマンドのように考えるのが良いそうです。
すなわち、

```ruby
pathname = Pathname.new('/pen/pineapple')
pathname.join('apple/pen').to_s
```

が意味するところは、

```console
$ cd /pen/pineapple
$ cd apple/pen
$ pwd
/pen/pineapple/apple/pen
```

に近い、というわけです。
（「同じ」ではなく、「近い」と書いたのは、`cd`コマンドとは異なり、`Pathname#join`で指定するパスは`pen.jpg`のようなファイル名でも良いからです）

この理屈が頭に入っていれば、`/`で始まる引数を渡したときに、手前のパスが消える理由も納得がいきます。

```console 
# Pathname.new('/pen/pineapple').join('/apple/pen') のイメージ
$ cd /pen/pineapple
$ cd /apple/pen
$ pwd
/apple/pen

# Pathname.new('/pen').join('pineapple', '/apple', 'pen') のイメージ
$ cd /pen
$ cd pineapple
$ cd /apple
$ cd pen
$ pwd
/apple/pen
```

### cdコマンドのように"."や".."も渡せる

「`cd`コマンドに近い」ということがわかれば、`Pathname#join`メソッドに`.`や`..`が渡せることを知っても驚きは小さいかもしれません。

```ruby
pathname = Pathname.new('/pen/pineapple')
# 現在のパスのまま
pathname.join('.').to_s
#=> "/pen/pineapple"

# 1つ上のパスへ
pathname.join('..').to_s
#=> "/pen"

# 1つ上のパスに上がり、そこからappleというパスを指定
pathname.join('../apple').to_s
#=> "/pen/apple"
```

このように、`Pathname#join`は単純に複数のパス文字列を`/`で連結するのではなく、あたかもシェル上のパスを扱うかのように振る舞います。
単純な文字列連結だと思っていると、`/`で始まる引数が渡されたときの挙動が不自然に思えてしまうので注意してください。（って、かつての僕がそうだったんですが😅）

## Pathname#joinを使うと便利なユースケース（？）

zverok氏はPathname#joinを使うと便利なユースケースについても言及してくれました。
たとえば、設定によって「特定のディレクトリ以下を相対パスで指定したい場合」と、「特定のディレクトリに関係なく、絶対パスで指定したい場合」の2パターンがあるときに、`Pathname#join`を使えば条件分岐なしでこの挙動を実現できます。

zverok氏が示してくれたコード例とは異なりますが、説明用にサンプルコードを書くとしたらこんな感じです。
（注：これはあくまで説明用のサンプルコードです。実際にこんなメソッドがあるとかなり危険です💀）

```ruby
require 'pathname'
require 'fileutils'

# 指定されたパスでlsコマンドの実行結果を表示するメソッド
def ls(path)
  # 基準となるパスを/varとする
  base_path = Pathname.new('/var')

  # 引数pathの値に応じて、/var内、/var外のどちらのディレクトリも指定可能
  target_path = base_path.join(path)

  # 対象となるパスでlsコマンドを実行
  puts system("ls #{target_path}")
end

# /var/logが対象になる（相対パス指定）
ls('log')

# /binが対象になる（絶対パス指定）
ls('/bin')
```

実際にこういうコードが必要になる機会がどれくらい頻繁にあるのかはわかりませんが、たしかに`Pathname#join`ならではの使い方かもしれません。

## まとめ

というわけで、この記事では`Pathname#join`メソッドの挙動を正しく理解するための考え方を説明してみました。

ちょっと余談になりますが、`Pathname#join`がややこしいのは、`File#join`と同じメソッド名で、なおかつ`Pathname`と`File`という、概念的にもよく似たクラスなのに挙動が異なる点かもしれません。
使い方によっては両者はほぼ同じようにパスを連結するので、そうした点が`Pathname#join`の仕様を勘違いしてしまう原因になっている気がします。
もしかすると、`join`以外の名前（たとえば`Pathname#merge`とか）を与えていたら、「単純に連結するだけじゃないのかも？」と予想しやすかったかもしれません。

いずれにせよ、「`Pathname#join`は単純に文字列を連結するだけではない」ということをこの記事を読んで理解してもらえれば幸いです。

## あわせて読みたい

`Rails.root`メソッドが返すのもPathnameオブジェクトです。
RailsプログラマがPathnameオブジェクトを一番よく使うのはこのユースケースかもしれません。

- [Rails\.root\.join\("foo", "bar"\)よりも、Rails\.root\.join\("foo/bar"\)が良いのでは？というお話 \- Qiita](https://qiita.com/jnchito/items/418277508dce263cf4f0)