LoginSignup
53
29

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-02-18

はじめに

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で僕の疑問点を質問してみました。

Bug #14891: Pathname#join has different behaviour to File.join - Ruby master - Ruby Issue Tracking System

そこで得られた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と同じメソッド名で、なおかつPathnameFileという、概念的にもよく似たクラスなのに挙動が異なる点かもしれません。
使い方によっては両者はほぼ同じようにパスを連結するので、そうした点がPathname#joinの仕様を勘違いしてしまう原因になっている気がします。
もしかすると、join以外の名前(たとえばPathname#mergeとか)を与えていたら、「単純に連結するだけじゃないのかも?」と予想しやすかったかもしれません。

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

あわせて読みたい

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

53
29
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
53
29