Rails(ActiveSupport)の*_since、*_agoはダサい!


結論

  • *_since*_ago は使うな
  • 代わりに from_now/ago/after/before +/- を適切に使おう

はじめに

Rails便利ですね。
個人的にRailsの中ではActiveRecordが最高のツールですが、ActiveSupportも変態拡張が多くて大好きです。
current_user.authorized_at <= 1.day.ago # 1.day.before(Time.now) とか気持ちいいですね。

あるPR

user.expires_on = Date.today.months_since(6)

これを見たときにすっと思考に入ってきますか?
僕はこれが未来を指すのか過去を指すのか即座に判断できません。

英語の例文を考えてみましょう。

「ユーザの権限は6ヶ月後に期限切れとなる」
The user's permission will expire after 6 months.

どこにもsinceは現れません。

sinceを使った例文「ユーザの権限は1ヶ月前から切れている」
The user's permission has expired since 1 month ago.

なので私は、PRで months_since を見ると

user.expires_on = 6.months.after
# 他にもこんな書き方ができるよ!
# user.expires_on = Date.today + 6.months
# user.expires_on = 6.months.after(Date.today) # 丁寧な英語っぽく
# user.expires_on = 6.months.from_now.to_date

という指摘をすることがあります。

何にこだわっているのか

コードを書くときには読み手に負担が少ないことが大事です。
Railsガイドにも *_since(ago) の記載があり、Rails開発者の中ではメジャーな存在となっていると思いますが、読み心地が極端に悪いと僕は考えています。
すべてのコードが英語としてリーダブルであることを求める必要はありませんが、別の書き方で読みやすくなるならそちらで記述した方がいいでしょう。

sinceのもたらす語感

英語のsinceは、起点を示してそこから何かが継続しているイメージを想起させます。


引用元: https://www.english-speaking.jp/difference-between-from-and-since/

しかしmonths_since(6)と出てくるとその感覚がバカになるのです。1

念のために原点を確認してみた

古いCHANGELOGを見ると、すでにその姿を見つけられました。1.0.0のころの記述ですね。

* Added Time::Calculations to ask for things like Time.now.tomorrow, Time.now.yesterday, Time.now.months_ago(4) #580 [DP|Flurin]. Examples:

    "Later today"         => now.in(3.hours),
    "Tomorrow morning"    => now.tomorrow.change(:hour => 9),
    "Tomorrow afternoon"  => now.tomorrow.change(:hour => 14),
    "In a couple of days" => now.tomorrow.tomorrow.change(:hour => 9),
    "Next monday"         => now.next_week.change(:hour => 9),
    "In a month"          => now.next_month.change(:hour => 9),
    "In 6 months"         => now.months_since(6).change(:hour => 9),
    "In a year"           => now.in(1.year).change(:hour => 9)

正直なところ、sinceの使い方に関して6.months.since(user.created_at)とかの例が出てくるかなと思ってましたが拍子抜けです。
非ネイティブだからこその無駄なこだわりかもしれません。

since/agoについて

プレフィクスを伴わないsince/agoは、加算(減算)のシンタックスシュガーです。

どこかに起点を置いてそこからの経過を取得するという場合には有用なメソッドです。
ただ、やっぱりTime拡張のsince/agoは使いどころが見つかりません。

Duration#since/agoは使い勝手がいいので使い方を間違わずに使っていきたいと考えます。
また、それぞれ after/from_now before/until というエイリアスを持っているので、文脈に合わせてメソッドを選びたいですね。

追記
@ktroutner さんの指摘から、sinceの自然な使い方は難しそうです。
エイリアスのafter/before/from_now、引数を伴わないagoをうまく使っていくのが良さそうです。

結局*_sinceを使うか?

先述のように1.0当初から*_sinceがあり現在の使い方を想定されていたとはいえ、私はやはり可読性を損なう記述に関しては書き換えを推奨していきます。

6.months.from_now # 6ヶ月後 追記:6.month.after よりも自然 / thx @ktroutner さん
1.month.after(Time.new(2020,1,31)) # 2020/1/31の1ヶ月後: ちゃんと2020/2/29が作れる
Time.new(2020,1,31) + 1.month # 2020/1/31に1ヶ月足す: ちゃんと2020/2/29が作れる

# 追記:atがあることでやはり語感を損なうとのこと / thx @ktroutner さん
2.weeks.since(user.created_at) # ユーザの作成日から2週間: 無料期間とか作るときこんな形だとその間というのがわかりやすい

などが結果を損なわず、文意もわかりやすいものだと考えます。

逆に推奨しない書き方

Time.now.months_since(6) # 本稿の主題。months_sinceは語感からずれる
Time.now.months_ago(6) # sinceがagoに変わっただけで本質は同じ。
Time.now.since(6.month) # 同様にsinceにDurationを持ってきて加算するのは気持ち悪い。
Time.now.ago(6.month) # これもagoの語感と語順が気持ち悪い
Time.now.since(user.created_at) # 機能的にはこんなことができるけどもはや意味不明

  1. 念のために社内の英語ネイティブスピーカー(非エンジニア)にも確認してみたところ、同様の感覚で答えてくれました。