Project

General

Profile

Feature #16183

Hash#with_default

Added by zverok (Victor Shepelev) about 2 months ago. Updated about 2 months ago.

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

Description

Reasons: there is no way, currently, to declaratively define Hash with default value (for example, to store it in constant, or use in an expression). Which leads to code more or less like this:

FONTS = {
  title: 'Arial',
  body: 'Times New Roman',
  blockquote: 'Tahoma'
}.tap { |h| h.default = 'Courier' }.freeze

# Grouping indexes:
ary.each_with_object(Hash.new { |h, k| h[k] = [] }).with_index { |(el, h), idx| h[el.downcase] << idx }

With proposed method:

FONTS = {
  title: 'Arial',
  body: 'Times New Roman',
  blockquote: 'Tahoma'
}.with_default('Courier').freeze

ary.each_with_object({}.with_default { [] }).with_index { |(el, h), idx| h[el.downcase] << idx }

About the block synopsys: I am not 100% sure, but I believe that most of the time when default_proc provided, it looks like { |h, k| h[k] = some_calculation }. So, I believe for this "declarative simplification" of defaults, it is acceptable to assume it as the only behavior (pass only key to block, and always store block's result); more flexible form would still be accessible with Hash.new.

History

Updated by shevegen (Robert A. Heiler) about 2 months ago

sawa has it all covered. ;)

The explanation by matz is interesting.

"Use tap. Methods with side-effect should be handled with care. Making it chainable
has little benefit."

Personally I think other names, be these default_set, `Hash#default_proc_set or
with_default, do not completely correlate towards #tap, in my opinion; but I think
the problem is of special methods that may each have special meaning in different
parts of ruby. So from this point of view, unifying via .tap is simpler.

On the other hand, "tap" itself, at the least to me, conveys a slightly different
meaning than #with_default does, so I am not sure the two use cases overlap 100%.

Not sure if this would warrant the addition of a new syntax + idiom, and I am not
really pro/con either, so that could be discussed.

The complexity of method chains should be considered too, though. This may be
an individual's style, but these huge chains may impose a cognitive load to
some ruby users possibly.

Updated by zverok (Victor Shepelev) about 2 months ago

Duplicate of #11761

I don't think it is a duplicate, though I haven't clarified an important behavior indeed:

h = {a: 1, b: 2}
h2 = h.with_default(3)
h.default # => nil
h2.default # => 3

So, I propose side-effect-less method, that is acceptable for chaining, I believe.

Updated by Eregon (Benoit Daloze) about 2 months ago

zverok (Victor Shepelev) wrote:

So, I propose side-effect-less method, that is acceptable for chaining, I believe.

That means a copy of the Hash is necessary on each call to #with_default.
I.e., it would be the same as:

h = {a: 1, b: 2}
h2 = h.dup.tap { _1.default = 3 }

I think it's better to keep a potentially expensive copy of the Hash explicit with the .dup call + default=.
But I agree there is some beauty to change the default value in a safe, non-mutable way with a single method call.

Updated by zverok (Victor Shepelev) about 2 months ago

That means a copy of the Hash is necessary on each call to #with_default.

Yes, the same way it is for, say, merge, and we still use it in a lot of cases even when source hash would be dropped -- for the sake of chainability:

FONTS = {body: 'Tahoma'}.merge(OS_FONTS.fetch(current_os)).merge(Settings.custom_fonts).freeze

↑ one may argue that it is tragically ineffective, but for the cases like this we just ignore it.

So I believe it is reasonable that something like:

def render(settings, values)
  render_widget(settings[:type], settings[:name], values.with_default('<empty>'))
end

I believe from this code it is obvious that values parameter is not altered (and so call site wouldn't be surprised), and passed further is its copy with some reasonable default value that makes sense for UI.

For a lot of features, we have both chainable (copying) and non-chainable (destructive, inplace) version, why not for one of the Hash's usefulest features?..

Updated by duerst (Martin Dürst) about 2 months ago

zverok (Victor Shepelev) wrote:

Eregon (Benoit Daloze) wrote:

That means a copy of the Hash is necessary on each call to #with_default.

Yes, the same way it is for, say, merge, and we still use it in a lot of cases even when source hash would be dropped -- for the sake of chainability:

Well, yes, but at least in my view, merge is a two-sided (symmetric) operation. There are two hashes, and you merge them. It would be strange if one of them is changed, but not the other. The fact that Ruby, because it's object-oriented, uses one of the hashes as a receiver is in first approximation just a syntax issue. Of course there are cases where merge is used in a asymmetric way (your examples are all of this nature), but that's not the original nature of merge.

with_default, on the other hand, similar to the current methods that set a default, is by nature asymmetric. Also, it's really rare (if such examples exist at all) that a new hash is needed because there's both a version with a default and a version without a default. So adding the default in place and not making a copy seems to be the natural thing to do.

I think that when you go through Ruby's builtin classes and standard library, there are many case that can easily explained in similar terms.

Also available in: Atom PDF