2

To be clear, I'm perfectly happy implementing this functionality as a custom class myself, but I want to make sure I'm not overlooking some bit of ruby or rails magic. I have googled every meaningful permutation of the keywords "ruby rails hash keys values immutable lock freeze". But no luck so far!


Problem: I need to give a Hash a set of keys, possibly at run time, and then lock the set of keys without locking their values. Something like the following:

to_lock = {}
to_lock[:name] = "Bill"
to_lock[:age] = 42

to_lock.freeze_keys     # <-- this is what I'm after, so that:

to_lock[:name] = "Bob"  # <-- this works fine,
to_lock[:height]        # <-- this returns nil, and
to_lock[:height] = 175  # <-- this throws some RuntimeError

Question: Is there a bit of ruby or rails tooling to allow this?


I know of Object#freeze and of Immutable::Hash, but both lock keys and values.

Sticking with out-of-the-box ruby, the use case could be mostly met by manipulating the methods or accessors of classes at runtime, as in this or this, then overriding #method_missing. But that feels quite a bit clunkier. Those techniques also don't really "lock" the set of methods or accessors, it's just awkward to add more. At that point it'd be better to simply write a class that exactly implements the snippet above and maintain it as needed.

Community
  • 1
  • 1
kdbanman
  • 10,161
  • 10
  • 46
  • 78
  • *Why* do you think you need this? – user229044 Sep 20 '16 at 21:14
  • @meagar good question! I'm integrating with an API that returns a set of required and optional parameters for a particular family of POSTs. The set is large and dependent upon time and location, so they need to be handled dynamically. For testing/proof of concept phase, I have a very thin, temporary UI over the returned parameter set to construct a POST, and I've found a perfect little slot for such a key-freezable hash to reduce maintenance on that UI! – kdbanman Sep 20 '16 at 21:21
  • Ok, then you really *don't* need this. What you need is a single line: `if to_lock.keys.include?(proposed_new_key)`. "Locking" the hash is a bad solution here. Just check whether the key exists, and refuse to insert it if it doesn't. – user229044 Sep 20 '16 at 21:25
  • I don't have enough control over the code that consumes the hash to guarantee it's used that way. Plus the exceptions would be a convenient-but-smelly way of handling illegal keys. This is really just a temp solution :-) – kdbanman Sep 20 '16 at 21:34

3 Answers3

3

You can achieve this by defining a custom []= for your "to-lock" instance of a hash, after you've added the allowed keys:

x = { name: nil, age: nil }

def x.[]=(key, value)
  # blow up unless the key already exists in the hash
  raise 'no' unless keys.include?(key)
  super
end

x[:name] # nil
x[:name] = "Bob" # "Bob"

x[:size] # nil
x[:size] = "large" # raise no

Note that this won't prevent you from inadvertently adding keys using something like merge!.

user229044
  • 232,980
  • 40
  • 330
  • 338
  • 1
    Tight and interesting. That run time override could even be monkey patched as a `#lock_keys` method on `Hash` to exactly fulfill my question's snippet. Great point on `merge!` and friends too. – kdbanman Sep 20 '16 at 21:29
  • 1
    `raise 'no' unless key?(key)` might be more efficient, e.g. O(1) for hash lookup vs. O(n) for linear search. (`Array#include?` may build a hash behind the scenes before performing the search as a performance optimization, but it's still be faster to use an existing hash instead of building a new one.) – mwp Sep 28 '16 at 20:31
1

@meagar has offered an interesting solution, but has pointed out that it only works when attempting to add a key-value pair using Hash#[]. Moreover, it does not prevent keys from being deleted.

Here's another way, but it's rather kludgy, so I think you should probably be looking for a different way to skin your cat.

class Hash
  def frozen_keys_create
    self.merge(self) { |*_,v| [v] }.freeze
  end

  def frozen_keys_get_value(k)
    self[k].first
  end

  def frozen_keys_put_value(k, new_value)
    self[k].replace [new_value]
    self
  end

  def frozen_keys_to_unfrozen
    self.merge(self) { |*_,v| v.first }
  end
end

Now let's put them to use.

Create a frozen hash with each value wrapped in an array

sounds = { :cat=>"meow", :dog=>"woof" }.frozen_keys_create
  #=> {:cat=>["meow"], :dog=>["woof"]}
sounds.frozen?
  #=> true

This prevents keys from being added:

sounds[:pig] = "oink"
  #=> RuntimeError: can't modify frozen Hash
sounds.update(:pig=>"oink")
  #=> RuntimeError: can't modify frozen Hash

or deleted:

sounds.delete(:cat)
  #=> RuntimeError: can't modify frozen Hash
sounds.reject! { |k,_| k==:cat }
  #=> RuntimeError: can't modify frozen Hash

Get a value

sounds.frozen_keys_get_value(:cat)
  #=> "meow"

Change a value

sounds.frozen_keys_put_value(:dog, "oooooowwwww")
  #=> {:cat=>["meow"], :dog=>["oooooowwwww"]}

Convert to a hash whose keys are not frozen

new_sounds = sounds.frozen_keys_to_unfrozen
  #=> {:cat=>"meow", :dog=>"oooooowwwww"} 
new_sounds.frozen?
  #=> false 

Add and delete keys

Maybe even add (private, perhaps) methods to add or delete key(s) to override the desired behaviour.

class Hash
  def frozen_keys_add_key_value(k, value)
    frozen_keys_to_unfrozen.tap { |h| h[k] = value }.frozen_keys_create
  end

  def frozen_keys_delete_key(k)
    frozen_keys_to_unfrozen.reject! { |key| key == k }.frozen_keys_create
  end
end

sounds = { :cat=>"meow", :dog=>"woof" }.frozen_keys_create
  #=> {:cat=>["meow"], :dog=>["oooowwww"]}
new_sounds = sounds.frozen_keys_add_key_value(:pig, "oink")
  #=> {:cat=>["meow"], :dog=>["woof"], :pig=>["oink"]} 
new_sounds.frozen?
  #=> true 
newer_yet = new_sounds.frozen_keys_delete_key(:cat)
  #=> {:dog=>["woof"], :pig=>["oink"]} 
newer_yet.frozen?
  #=> true 
Cary Swoveland
  • 106,649
  • 6
  • 63
  • 100
1

Sounds like a great use-case for the built-in Struct

irb(main):001:0> s = Struct.new(:name, :age).new('Bill', 175)
=> #<struct name="Bill", age=175>
irb(main):002:0> s.name = 'Bob'
=> "Bob"
irb(main):003:0> s.something_else
NoMethodError: undefined method `something_else' for #<struct name="Bob", age=175>
    from (irb):3
    from /home/jtzero/.rbenv/versions/2.3.0/bin/irb:11:in `<main>'
jtzero
  • 2,204
  • 2
  • 25
  • 44