Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
26
Help us understand the problem. What is going on with this article?
@jnchito

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

More than 1 year has passed since last update.

はじめに

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オブジェクトを一番よく使うのはこのユースケースかもしれません。

26
Help us understand the problem. What is going on with this article?
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
jnchito
株式会社ソニックガーデンのRubyプログラマ。 「プロを目指す人のためのRuby入門( https://bit.ly/3wmJheK )」の著者。 および「Everyday Rails - RSpecによるRailsテスト入門 ( https://bit.ly/3rKqrKX )」の翻訳者。 プログラミングスクール「フィヨルドブートキャンプ」のメンターでもある。
sonicgarden
「お客様に無駄遣いをさせない受託開発」と「習慣を変えるソフトウェアのサービス」に取り組んでいるソフトウェア企業

Comments

No comments
Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account Login
26
Help us understand the problem. What is going on with this article?