はじめに
RubyのPathname#join
メソッドを使うと2つのパスを連結することができます。
pathname = Pathname.new('/pen/pineapple')
pathname.join('apple/pen').to_s
#=> "/pen/pineapple/apple/pen"
上の結果を見ると、Pathname#join
は2つのパスを/
で連結してくれるメソッドのように見えます。
しかし、join
メソッドの引数が/
で始まっていると、少し直感に反した動きになります。
pathname = Pathname.new('/pen/pineapple')
# "/"で始まるパスを指定すると、引数そのものを表すパスが戻り値になる
pathname.join('/apple/pen').to_s
#=> "/apple/pen"
ご覧のとおり、引数で渡した"/apple/pen"がそのまま戻り値になってしまいました。
(つまり、単純な文字列連結になっていない)
join
メソッドに複数の引数を渡したときも同様です。
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
の方が自分の欲しいメソッドだ」と思うかもしれません。
# 以下のメソッド呼び出しはいずれも同じ結果が返る
# ("/"で始まる引数が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で僕の疑問点を質問してみました。
そこで得られたzverok氏の回答が「なるほど」と思ったので、その内容を以下にまとめます。
Pathname#joinはシェルのcdコマンドのように振る舞う
zverok氏いわく、Pathname#join
はシェルのcd
コマンドのように考えるのが良いそうです。
すなわち、
pathname = Pathname.new('/pen/pineapple')
pathname.join('apple/pen').to_s
が意味するところは、
$ cd /pen/pineapple
$ cd apple/pen
$ pwd
/pen/pineapple/apple/pen
に近い、というわけです。
(「同じ」ではなく、「近い」と書いたのは、cd
コマンドとは異なり、Pathname#join
で指定するパスはpen.jpg
のようなファイル名でも良いからです)
この理屈が頭に入っていれば、/
で始まる引数を渡したときに、手前のパスが消える理由も納得がいきます。
# 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
メソッドに.
や..
が渡せることを知っても驚きは小さいかもしれません。
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氏が示してくれたコード例とは異なりますが、説明用にサンプルコードを書くとしたらこんな感じです。
(注:これはあくまで説明用のサンプルコードです。実際にこんなメソッドがあるとかなり危険です💀)
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オブジェクトを一番よく使うのはこのユースケースかもしれません。