Project

General

Profile

Feature #14869

Proposal to add Hash#===

Added by osyo (manga osyo) 3 months ago. Updated about 1 month 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
hash_eqq.patch (4.43 KB) hash_eqq.patch to_hash のサポート osyo (manga osyo), 08/09/2018 02:08 AM

Related issues

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

History

#1 [ruby-core:87640] Updated by nobu (Nobuyoshi Nakada) 3 months 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) 3 months 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) 3 months ago

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

#4 [ruby-core:87718] Updated by osyo (manga osyo) 3 months 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) 3 months ago

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

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

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

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

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

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

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

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

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

#8 Updated by mrkn (Kenta Murata) 2 months ago

#9 [ruby-core:88289] Updated by baweaver (Brandon Weaver) about 2 months ago

I would agree with === being more useful than <=, as case, all?, grep, and other methods use it implicitly.

This would be an amazing addition for Ruby, and would bring us closer to pattern matching syntax.

The great part about this is your implementation uses === to compare values as well. This makes it very flexible, and extremely useful.

Aside / Offtopic

This may be unrelated, and if so feel free to tell me to open another issue, but what if it worked on Objects:

Person = Struct.new(:id, :name, :age)
people = [{:id=>1, :name=>"Homu", :age=>13}, {:id=>2, :name=>"mami", :age=>14}].map { |p| Person.new(*p.values) }

people.grep(age: 10..13) # => [#<struct Person id=1, name="Homu", age=13>]

#10 [ruby-core:88301] Updated by baweaver (Brandon Weaver) about 2 months ago

I recently got permission to repurpose the Any gem, which gives us this:

require 'any'

case {id: 1, name: 'foo', age: 42}
when {id: Any, name: /^f/, age: Any} then true
else false
end
# => true

case {id: 1, name: 'foo'}
when {id: Any, name: /^f/, age: Any} then true
else false
end
# => false

That should make this even more flexible.

https://github.com/baweaver/any

#11 [ruby-core:88358] Updated by baweaver (Brandon Weaver) about 1 month ago

I had mentioned this in the Array#=== topic for consideration: https://bugs.ruby-lang.org/issues/14916#note-6

In the comparison, we are returning false if the other value is not a Hash:

(line 4011)

if (!RB_TYPE_P(hash2, T_HASH)) return Qfalse;

Much like the suggestion in the Array topic, would it be a good idea to leverage to_hash? In Ruby this means that an object behaves like a hash, allowing us duck-typing and more flexibility.

Consider an object type that responds to to_hash with its instance properties, we could use the same method of querying with a minimal speed penalty for coercion.

We could first check if something responds to to_hash if it is not already a Hash. If it does, we can coerce and operate on that value, if not we've not incurred much of a speed penalty because Hash === Hash would never go into that conditional.

#12 [ruby-core:88359] Updated by osyo (manga osyo) about 1 month ago

Much like the suggestion in the Array topic, would it be a good idea to leverage to_hash? In Ruby this means that an object behaves like a hash, allowing us duck-typing and more flexibility.

Support call to_hash.

o = Object.new
def o.to_hash
  { name: "homu", age: 14 }
end
{ name: /^h/ } === o
# => true

#13 [ruby-core:88361] Updated by timriley (Tim Riley) about 1 month ago

This is looking like a really positive improvement, thank you!

Would you consider taking this one step further and supporting the explicit converter, #to_h instead of (or as well as, if required) the implicit #to_hash converter? This would allow using Hash#=== to match against e.g. Struct instances (which have #to_h but not #to_hash) plus any other kind of object that doesn't want to pretend to "be" a hash, but rather provide the interface for converting to one.

There are penalties for implementing #to_hash, like implicit destructuring when an object is passed to a method with kwrest params, so its fair to expect that not every class would want to do it. #to_h, on the other hand, is much more common (just like we see in the example of Ruby's own Struct), so supporting that would make this matcher even more flexible, usable, and powerful.

Also available in: Atom PDF