Project

General

Profile

Feature #14869

Proposal to add Hash#===

Added by osyo (manga osyo) 23 days ago. Updated 3 days ago.

Status:
Open
Priority:
Normal
Assignee:
-
Target version:
-
[ruby-core:87632]

Description

概要

Hash#=== を追加する提案になります。

仕様

レシーバのキーの要素と引数のハッシュのキーの要素を #=== で比較して、全てが真なら true を返し、そうでないなら false を返す。
また、レシーバが空のハッシュの場合、引数が空のハッシュなら true を返し、そうでないなら false を返す。

user = { id: 1, name: "homu", age: 14 }

# name 要素が data にあるので true
p ({ name: "homu" } === user)
# => true

# 複数の要素があっても OK
p ({ id: 1, name: "homu", age: 14 } === user)
# => true

# name 要素が user にあるが、値が違うので false
p ({ name: "name" } === user)
# => false

# キーの要素が引数にないので false
p ({ number: 42 } === user)
# => false

# 1つでもキーの要素がない場合も false
p ({ id: 1, name: "homu", number: 42 } === user)
# => false

# レシーバが空のハッシュなら false
p ({} == user)
# => false

# 空のハッシュ同士なら true
p ({} == {})
# => true

# 引数がハッシュ以外なら false
p ({ id: 42 } == 42)
# => false

# #=== で比較しているのでこういうこともできる
p ({ name: /^h/ } === user)
# => true

p ({ age: (1..20) } === user)
# => true

p ({ age: Integer } === user)
# => true

ユースケース

バリデーション

case-when では === を使用して値を比較しているので、Hash#=== を利用することで次のように条件分岐を行うことが出来る。

def validation user
    case user
    # name に対するバリデーション
    when { name: /^[a-z]/ }
        raise "名前の先頭が小文字の場合は登録できません"
    # age に対するバリデーション
    when { age: (0..20) }
        raise "0〜20歳は登録できません"
    # 各要素が任意のクラスのインスタンスかどうかのバリデーション
    when { id: Integer, name: String, age: Integer }
        true
    else
        false
    end
end

# 条件を満たしているので OK
mami = { id: 1, name: "Mami", age: 21 }
validation mami
# => true

# name が小文字から始まっているので NG
mado = { id: 2, name: "mado", age: 13 }
validation mado
# => 名前の先頭が小文字の場合は登録できません (RuntimeError)

# age が 0〜20歳以内なので NG
homu = { id: 3, name: "Homu", age: 14 }
validation homu
# => 0〜20歳は登録できません (RuntimeError)

Enumerable#grep

Enumerable#grep は内部で === を使用した比較を行っているので、次のように任意の Hash のキーの要素に対して検索を行うことが出来る。

data = [
    { id: 1, name: "Homu", age: 13 },
    { id: 2, name: "mami", age: 14 },
    { id: 3, name: "Mado", age: 21 },
    { id: 4, name: "saya", age: 14 },
]

# 特定の要素が含まれている Hash のみを絞り込む
p data.grep(name: /m/)
# => [{:id=>1, :name=>"Homu", :age=>13}, {:id=>2, :name=>"mami", :age=>14}]

p data.grep(age: (1..20))
# => [{:id=>1, :name=>"Homu", :age=>13}, {:id=>2, :name=>"mami", :age=>14}, {:id=>4, :name=>"saya", :age=>14}]

補足1: == ではなくて === で比較する理由

  • === を使用することでより細かい・抽象的な条件を指定することが出来る
    • ClassRegexpProc などで比較することが出来る
  • 内部で === を使用している場合、 == で比較したい場合は obj.method(:==) を渡せば実現出来るが、その逆は出来ない
    • 内部で == を使用している場合、 === で比較ししたくても出来ない

補足2: 空のハッシュの比較に関して

  • Object#=== の場合だと {} === 42 が例外ではなくて false を返していたので、Hash#===false を返すようにした
    • {} === {}true を返すのも同様の理由になります
    • これにより以下のような既存のコードも互換性を壊すことなく動作するかと思います
def check n
    case n
    when {}
        "Empty Hash"
    when []
        "Empty Array"
    when 0
        "Zero"
    else
        "Not empty"
    end
end

p check({})   # => "Empty Hash"
p check([])   # => "Empty Array"
p check(0)    # => "Zero"
p check({ name: "mado" })   # => "Not empty"

以上、Hash#=== に関する提案になります。
挙動に関して疑問点や意見などございましたらコメント頂けると助かります。

hash_eqq.patch (3.54 KB) hash_eqq.patch osyo (manga osyo), 07/16/2018 04:35 AM

Related issues

Related to Ruby trunk - Feature #14916: Proposal to add Array#===Open

History

#1 [ruby-core:87640] Updated by nobu (Nobuyoshi Nakada) 23 days ago

osyo (manga osyo) wrote:

仕様

レシーバのキーの要素と引数のハッシュのキーの要素を #=== で比較して、全てが真なら true を返し、そうでないなら false を返す。

キーに対応する値同士を比較するということですね。

  • Object#=== の場合だと {} === 42 が例外ではなくて false を返していたので、Hash#===false を返すようにした

空でないハッシュをハッシュ以外のオブジェクトと比較しようとするとSEGVします。
to_hash() で変換するか、 Check_Type(hash2, T_HASH) でエラーにするか、空のハッシュ同様 false を返すかしてください。

#2 [ruby-core:87643] Updated by osyo (manga osyo) 22 days ago

  • File hash_eqq.patch added

返信ありがとうございます!!

レシーバのキーの要素と引数のハッシュのキーの要素を #=== で比較して、全てが真なら true を返し、そうでないなら false を返す。

キーに対応する値同士を比較するということですね。

はい、その認識で問題ありません。

空でないハッシュをハッシュ以外のオブジェクトと比較しようとするとSEGVします。
to_hash() で変換するか、 Check_Type(hash2, T_HASH) でエラーにするか、空のハッシュ同様 false を返すかしてください。

ご指摘ありがとうございます。
{ id: 1 } === 42 のように『空でないハッシュをハッシュ以外のオブジェクトと比較』場合は false を返すように修正しました。
また、 { id: nil } === {} というような比較も true を返していたのでこちらも合わせて false を返すように修正しました。
以下、真・偽になるケースをまとめてみました。

真のケース

  • レシーバと引数が空のハッシュの場合
  • レシーバの要素と引数の要素をキーごとに === で比較して全て真の場合

偽のケース

  • 引数がハッシュ以外の場合
  • レシーバが空のハッシュで引数が空の Hash でない場合
  • レシーバの要素と引数の要素をキーごとに === で比較して一つでも偽がある場合
  • レシーバのキーが引数のハッシュにない場合

#3 [ruby-core:87689] Updated by znz (Kazuhiro NISHIYAMA) 20 days ago

ほとんどの用途は Hash#<= で足りているようにみえます。
{} <= usertrue になるので、{} === usertrue の方が <= の値の比較を === で行うだけのものということで、わかりやすいのではないかと思いました。

#4 [ruby-core:87718] Updated by osyo (manga osyo) 19 days ago

ご意見ありがとうございます。

ほとんどの用途は Hash#<= で足りているようにみえます。

機能としては Hash#<= と類似していますが、『Hash#=== を定義する事で case-when などで使用することが出来る』というのが主な提案理由となっております。

{} <= usertrue になるので、{} === usertrue の方が <= の値の比較を === で行うだけのものということで、わかりやすいのではないかと思いました。

確かに『Hash#===Hash#<=#=== で比較する版』みたいな説明だと理解しやすそうですね。
ただ、 {} === usertrue にしてしまうと次のような case-when で互換性が壊れてしまうので、互換性を考えて false を返すのが妥当かと思います。
互換性を壊してまで #<= の挙動に合わせる必要性はなかな、と。

def check n
    case n
    when {}
        "空だよ〜"
    else
        "空じゃないよ〜"
    end
end

p check({})   # => "空だよ〜"

# {} === user を true にしてしまうと結果が変わってしまう…
p check({ name: "mado" })   # => "空じゃないよ〜" と期待する

#5 [ruby-core:87742] Updated by nobu (Nobuyoshi Nakada) 17 days ago

空の場合は Enumerable#all? と類似の話なので、 true のほうがいいんじゃないでしょうか。
また、再帰的なハッシュに対して使用したときに無限再帰にならないようにする必要がありそうです。

#6 [ruby-core:87765] Updated by osyo (manga osyo) 16 days ago

空の場合は Enumerable#all? と類似の話なので、 true のほうがいいんじゃないでしょうか。

なるほど、参考になります。

また、再帰的なハッシュに対して使用したときに無限再帰にならないようにする必要がありそうです。

あーこれは確かに問題になりそうですね。対策を考えてみたいと思います。
ありがとうございます。

#7 [ruby-core:87954] Updated by osyo (manga osyo) 3 days ago

また、再帰的なハッシュに対して使用したときに無限再帰にならないようにする必要がありそうです。

Hash#== を参考に rb_exec_recursive_paired を使用して再帰チェックするようにしてみました。
rb_exec_recursive_paired の挙動に関して詳細に理解していないのでそのあたりをレビューして頂けると助かります。

#8 Updated by mrkn (Kenta Murata) about 2 hours ago

Also available in: Atom PDF