LoginSignup
30
24
記事投稿キャンペーン 「Rails強化月間」

【ruby】値オブジェクトを使うと超読みやすくなるケース

Last updated at Posted at 2023-11-01

年月の範囲をDateクラスで扱うのがダルい...

  • 2023年1月、2023年2月...と月単位のラベルを持つグラフを作る
  • 2023年1月号、2023年2月号...と月単位で提供される雑誌を扱う

といった、年月を扱うケースでは、ただのDateクラスではちょっと役不足ですよね。例えば以下のようなコードを考えてみます。

require 'date'

start_year_month = Date.new(2023, 9, 1)
today = Date.today
latest_year_month = Date.new(today.year, today.month, 1)

#
# 期間でループしたいとき
#
tmp_month  = start_year_month
while tmp_month <= latest_year_month
    
    pp tmp_month.strftime('%Y%m')
    
    tmp_month = tmp_month.next_month
end

# => "202309"
# => "202310"
# => "202311"

これだけならまだwhileループで耐えているような感じがします。が、ちょっと仕様を複雑にすると、途端に扱いづらくなります。例えば、「雑誌の号数は10日で切り替える」という仕様を足してみましょう。

require 'date'

start_year_month = Date.new(2023, 9, 1)
today = Date.today
latest_year_month = if today.day >= 10
                        Date.new(today.year, today.month, 1)
                    else
                        Date.new(today.year, today.month - 1, 1)
                    end

こんな条件分岐が各所で生えだすと、もう手が付けられません。さらに、数を数えるのも一苦労です。

count = 0
while tmp_month <= latest_year_month
    
    count += 1
    
    tmp_month = tmp_month.next_month
end

pp count
# => 2とか3とか

# こっち↓でも書けるが冗長なのは変わらない
count = (latest_year_month.year - first_year_month.year) * 12 + (latest_year_month.month - first_year_month.month) + 1

範囲を扱えるクラスのシンプルさ

Date型をそのまま使うと、ループの中で「2023年9月号の次は2023年10月号」ということをわざわざ変数を使って宣言する必要があります。一方、「範囲を取れる」オブジェクトを考えてみると、どうなるでしょうか?

(start_year_month..end_year_month).each do |year_month|
    # 何かしらの処理
end

whileの条件式でループが表現されるより、「ここからここまでループする」というのが圧倒的にわかりやすくなっているのではないでしょうか?

さらに、個数を数えるときも

(start_year_month..end_year_month).to_a.length
# 次のようにも書ける
[*start_year_month..end_year_month].length

と、1行で書けたりします。(配列化して要素の数を取り出さないといけないのが若干手間ではありますが...)

つまり、範囲を扱えるようにすることで、年月の集合を操作するのがかなり簡単になるわけですね。

基本的な実装

リファレンスマニュアルでは、以下のように説明があります。

範囲オブジェクトは範囲を表しているので、基本的な機能として「ある値がその範囲に含まれるか否かを判定する」ということがあります。

p (1..5).cover?(6)  # => false
p (1..5).cover?(5)  # => true
p (1...5).cover?(5) # => false

Range#cover? メソッドでの判定には演算子 <=> が使われます。
当然、始端と終端は <=> メソッドで比較可能である(nil 以外を返す)必要があります。

ということで、まずはRange#cover?に応答できるようにクラスを作ってみましょう。

require 'date'

class IssueNumber
    def initialize(year, month)
        @date = Date.new(year, month, 1)
    end
    
    def to_date; @date; end
    
    def <=>(other)
        to_date <=> other.to_date
    end
end

first_issue_number = IssueNumber.new(2023, 9)
last_issue_number = IssueNumber.new(2023, 11)
inner_issue_number = IssueNumber.new(2023, 10)
outer_issue_number = IssueNumber.new(2023, 12)

pp (first_issue_number..last_issue_number).cover?(inner_issue_number)
# => true
pp (first_issue_number..last_issue_number).cover?(outer_issue_number)
# => false

<=>ってなんやねん

Rubyでのオブジェクト指向プログラミングに馴染みがないと、普通は#<=>(other)なんてメソッドを見かけることはまずないですが、これは値の大小を比較するのに使用されるメソッドです(宇宙船演算子と呼びます。)

このメソッドは、自分がotherよりも大きければ1、小さければ-1、同じなら0を返却します。

pp last_issue_number<=>(first_issue_number)
# => 1
pp first_issue_number<=>(last_issue_number)
# => -1
pp first_issue_number<=>(first_issue_number)
# => 0

宇宙船演算子の実装によって、引数で渡されたotherと始点・終点との大小関係からotherが範囲に含まれるかがわかるというカラクリです。

繰り返し処理を実装する

上で書いたままでは、まだ#each()などの繰り返し処理を実行することはできません。

(first_issue_number..last_issue_number).each do |issue_number|
    pp issue_number
end
# => `each': can't iterate from IssueNumber (TypeError)

リファレンスマニュアルにはこうあります。

繰り返しの範囲を表す範囲オブジェクトは、始端が「次の値」を返す succ メソッドを持たなければなりません。

つまり、first_issue_numberの「次の値=successor(後継)」はなにか?を判断できるように#succ()というメソッドを実装せよということになります。

class IssueNumber
    
    def initialize(year, month)
        @date = Date.new(year, month, 1)
    end
    
    def to_date; @date; end
    
    def <=>(other)
        to_date <=> other.to_date
    end
    
    def succ
        next = @date.next_month
        self.class.new(next.year, next.month)
    end
end

今回のケースでの「次」は「翌月号」なので、月をインクリメントさせた新しいIssueNumberインスタンスを返却させます。この実装によって、繰り返し処理を行えるようになりました。

class IssueNumber
    
    def initialize(year, month)
        @date = Date.new(year, month, 1)
    end
    
    # to_sメソッドなんかも自作して楽に文字列変換する
    def to_s; @date.strftime('%Y年%-m月号'); end
    
    def to_date; @date; end
    
    def <=>(other)
        to_date <=> other.to_date
    end
    
    def succ
        next = @date.next_month
        self.class.new(next.year, next.month)
    end
end

first_issue_number = IssueNumber.new(2023, 9)
last_issue_number = IssueNumber.new(2023, 11)

(first_issue_number..last_issue_number).each do |issue_number|
    pp issue_number.to_s
end
# => "2023年9月号"
# => "2023年10月号"
# => "2023年11月号"

比較できるようにする

これまでの実装によって、範囲の始点・終点として扱えるようにはなりましたが、値同士の比較はまだできていません。

first_issue_number = IssueNumber.new(2023, 9)
last_issue_number = IssueNumber.new(2023, 11)
pp first_issue_number < last_issue_number
# => undefined method `<'

#<=>()は実装しましたが、<<=>などの比較演算子は未実装だからです。そして、この実装を簡単に行ってくれるモジュールComparableがRubyの組み込みライブラリに存在しています(参考URL)。早速実装してみましょう。

require 'date'

class IssueNumber
    include Comparable
    
    def initialize(year, month)
        @date = Date.new(year, month, 1)
    end
    
    def to_date; @date; end
    
    def <=>(other)
        to_date <=> other.to_date
    end
    
    def succ
        next = @date.next_month
        self.class.new(next.year, next.month)
    end
end

first_issue_number = IssueNumber.new(2023, 9)
last_issue_number = IssueNumber.new(2023, 11)
same_issue_number = IssueNumber.new(2023, 9)

pp first_issue_number < last_issue_number
# => true
pp first_issue_number == same_issue_number
# => true

値オブジェクトとして実装しているので、これくらいは使わなくともマストで実装しておきたいですね。

配列内で重複削除できるようにする

値オブジェクトを重複ありで配列に入れてからArray#uniq()で重複を削除したいケースがあったとしましょう。また同じパターンですが、今の実装ではうまく重複判定してくれません。

first_issue_number = IssueNumber.new(2023, 9)
last_issue_number = IssueNumber.new(2023, 11)
same_issue_number = IssueNumber.new(2023, 9)

arr = [first_issue_number, last_issue_number, same_issue_number]
pp arr.uniq
# => [#<IssueNumber:0x00001490006e0100 @date=#<Date: 2023-09-01 ((2460189j,0s,0n),+0s,2299161j)>>, 
#     #<IssueNumber:0x00001490006e7d88 @date=#<Date: 2023-11-01 ((2460250j,0s,0n),+0s,2299161j)>>,
#     #<IssueNumber:0x00001490006e7bd0 @date=#<Date: 2023-09-01 ((2460189j,0s,0n),+0s,2299161j)>>]

これは、Array#uniq()が要素同士の同値性を判定するのに#eql?()というメソッドを要素に対して要求しているからです。

要素の重複判定は、Object#eql? により行われます。

(リファレンスマニュアルより)

さらに、Object#eql?のドキュメントには以下のような記述があります。

このメソッドを再定義した時には Object#hash メソッドも再定義しなければなりません。

難しいことは置いておいて、言われた通り実装してみましょう。

class IssueNumber
    include Comparable
    
    def initialize(year, month)
        @date = Date.new(year, month, 1)
    end
    
    def to_date; @date; end
    
    def <=>(other)
        to_date <=> other.to_date
    end
    
    def succ
        next = @date.next_month
        self.class.new(next.year, next.month)
    end
    
    def eql?(other)
        @date == other.to_date
    end
    
    def hash
        @date.hash
    end
end

first_issue_number = IssueNumber.new(2023, 9)
last_issue_number = IssueNumber.new(2023, 11)
same_issue_number = IssueNumber.new(2023, 9)

arr = [first_issue_number, last_issue_number, same_issue_number]
pp arr.uniq
# => [#<IssueNumber:0x00001490006e0100 @date=#<Date: 2023-09-01 ((2460189j,0s,0n),+0s,2299161j)>>, 
#     #<IssueNumber:0x00001490006e7d88 @date=#<Date: 2023-11-01 ((2460250j,0s,0n),+0s,2299161j)>>]

今度はうまく重複が取り除かれました。

ハッシュのキーとして使えるようにする

今の実装では、、、と天丼したいところですが、これはもう今の実装でできてしまいます。

first_issue_number = IssueNumber.new(2023, 9)
last_issue_number = IssueNumber.new(2023, 11)
same_issue_number = IssueNumber.new(2023, 9)

hash = {}
hash[first_issue_number] = '創刊号'
hash[last_issue_number] = '最新号'

pp hash[first_issue_number]
# => "創刊号"
pp hash[last_issue_number]
# => "最新号"
pp hash[same_issue_number]
# => "創刊号"

キーには任意の種類のオブジェクトを用いることができますが、以下の2つのメソッドが適切に定義してある必要があります。

・Object#hash ハッシュの格納に用いられるハッシュ値の計算
・Object#eql? キーの同一性判定

(リファレンスマニュアルより)

このように、ハッシュのキーとして使うには#hash()#eql?()をオブジェクトが備えている必要がありますが、配列の重複削除のパートですでに実装していましたね。

まとめ

範囲、集合を扱う上でプリミティブなオブジェクトでは冗長になってしまうようなケースは、値オブジェクトとして定義するとクライアント側の記述が超すっきり読みやすくなることがお分かりいただけたのではないでしょうか(特に年月は実務で頻出します。)

そして、

  • 宇宙船演算子の実装
  • #succ()の実装
  • Comparableモジュールのinclude
  • #eql?()#hash()の実装

これらを実装することで、値オブジェクトとしての基本の立ち回りができるようになりました。

プリミティブ型だけで頑張る地獄のようなループを何度か目にしたことがありますが、こういった値オブジェクトを用意するだけで劇的に読みやすくなります。

お試しあれ。

30
24
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
30
24