Feature #16511
Updated by Dan0042 (Daniel DeLorme) almost 5 years ago
As an alternative to #16463 and #16494 I'd like to propose this approach, which I believe allows a **much** more flexible path for migration of keyword arguments. The idea is to have a subclass of Hash (let's name it "KwHash") which provides a clean, object-oriented design with various benefits. I'll try to describe the idea by breaking it down into figurative steps. Imagine starting with ruby 2.6 and then: ### Step 1 When a double-splat or a brace-less hash is used, instead of a Hash it creates a KwHash. ```ruby def foo(x) x end foo(k:1).class #=> KwHash foo(**hash).class #=> KwHash [k:1].last.class #=> KwHash [**hash].last.class #=> KwHash {**hash}.class #=> Hash ``` At this point we haven't introduced any real change. Everything that worked before is still working the same way, with the ONLY exception being code like `kw.class == Hash` which now returns false. But no one actually writes code like that; it's always `kw.is_a?(Hash)`, which still returns true. ### Step 2 When there is ambiguity due to optional vs keyword argument, we rely on the last argument being Hash or KwHash to disambiguate. ```ruby def foo(x=nil, **kw) [x,kw] end foo({k:1}) #=> [{k:1},{}] foo(k:1) #=> [nil,{k:1}] ``` This is the _minimum_ amount of incompatibility required to solve ALL bugs previously reported with keyword arguments. (#8040, #8316, #9898, #10856, #11236, #11967, #12104, #12717, #12821, #13336, #13647, #14130, etc.) ### Step 3 Introduce additional incompatibility to improve clarity of design. Here we deprecate the automatic conversion of Hash to keyword argument; only KwHash is accepted. And always use the last KwHash argument if the method supports keyword arguments. With a deprecation/warning phase, of course. The "automatic" promotion of a KwHash to a keyword argument follows the same rules as a Hash in 2.6; since the KwHash is conceptually intended to represent keyword arguments, this conversion makes sense in a way that a normal data Hash doesn't. We've taken the "last positional hash" concept and split it into "conceptually a hash" and "conceptually keyword arguments". _Most importantly_, But importantly, all the changes required to silence these warnings are _compatible with 2.6_. ```ruby def foo(x, **kw); end foo(k:1) # ArgumentError because x not specified foo(1, {k:1}) # ArgumentError because too many arguments; Hash cannot be converted to KwHashs opts = [k:1].first foo(opts) # opts is a KwHash therefore used as keyword argument; ArgumentError because x not specified foo(1, opts) # opts is a KwHash therefore used as keyword argument ``` At this point we have achieved _almost-full_ _full_ **dynamic** keyword separation, as opposed to the current _almost-full_ **static** approach. I want to make the point here that, yes, keyword arguments **are** separated, it's just a different paradigm. With static separation, a keyword argument is defined lexically by a double-splat. With dynamic separation, a keyword argument is when the last argument is a KwHash. {{Note: I'm saying "almost-full" because KwHash is not promoted to keywords in `def foo(a,**kw);end;foo(x:1)` and because static keywords are auto-demoted to positional in `def foo(a);end;foo(x:1)`] Any form of delegation works with no change required. This preserves the behavior of 2.6 but only for KwHash objects. This is similar to having 2.7 with `ruby2_keywords` enabled by default. But also different in some ways; most notably ways. _Most importantly_, it allows the case shown in #16494 to work by default: ```ruby array = [x:1] array.push(x:2) array.map{ |x:| x } #=> [1,2] ``` The current approach does not allow this to work at all. The solution proposed in #16494 has all the same flaws as Hash-based keyword arguments; what happens to `each{ |x=nil,**kw| }` ? The subclass-based solution allows a KwHash to be converted to... keywords. Very unsurprising. Given that ruby is a dynamically-typed language I feel that dynamic typing of keywords if a more natural fit than static typing. But I realize that many disagree with that, which is why we continue to... ### Step 4 Introduce additional incompatibility to reach static/lexical separation of keyword arguments. Here we require that even a KwHash should be passed with a double-splat in order to qualify as a keyword argument. ```ruby def bar(**kw) end def foo(**kw) bar(kw) #=> error; KwHash passed without ** bar(**kw) #=> ok end ``` At this point we've reached the same behavior as 2.7. Delegation needs to be fixed, but as we know the changes required to silence these warnings are **not** compatible with 2.6. So here we introduce a way to _silence **only** these "Step 4" warnings_, for people who need to remain compatible with 2.6. And we keep them as warnings instead of errors until ruby 2.6 is EOL. So instead of having to update a bunch of places with `ruby2_keywords` right now, it's a single flag like `Warning[:ruby3_keywords]`. Once ruby 2.6 is EOL these become controlled by `Warning[:deprecated]` which tells people they **have** to fix their code. Which is just like the eventual deprecation of `ruby2_keywords`, just without the busy work of adding `ruby2_keywords` statements in the first place. The question remains of how to handle #16494 here. Either disallow it entirely, but I think that would be a shame. Or just like #16494 suggests, allow hash unpacking in non-lambda Proc. Except that now it can be a KwHash instead of a Hash, which at least preserves dynamic keyword separation. ## Putting it all together The idea is _not_ to reimplement keyword argument separation; all that is needed is to implement the things above that are not in 2.7: * Create a KwHash object when a double-splat is used. * If a warning is due to a KwHash instead of a Hash, make it a different kind of warning that can be toggled off separately from the Hash warnings (and that will stay as warnings until 2.6 is EOL) I think that's all, really... ### Pros * Cleaner way to solve #16494 * Better compatibility (at least until 2.6 is EOL) * delegation * storing an argument list that ends with a KwHash * destructuring iteration (#16494) * We can avoid the "unfortunate corner case" as described in the [release notes](https://www.ruby-lang.org/en/news/2019/12/12/separation-of-positional-and-keyword-arguments-in-ruby-3-0/) * in 2.7 only do not output "Step 4" warnings, leave delegation like it was * in 2.8 the "Step 3" warnings have been fixed and a Hash will not be converted to keyword arguments * delegation can now safely be fixed to use the `**` syntax * ruby2_keywords is not required, which is desirable because * it's a hidden flag _hack_ * it requires to change the code now, and change it _again_ when ruby2_keywords is deprecated; twice the work; twice the gem upgrades * it was supposed to be used only for people who need to support 2.6 or below, but it's being misunderstood as an acceptable way to fix delegation in general * there's the non-zero risk that ruby2_keywords will never be removed, leaving us with a permanent "hack mode" * dynamic keywords are by far preferable to supporting ruby2_keywords forever * Likely _better performance_, as the KwHash class can be optimized specifically for the characteristics of keyword arguments. * More flexible migration * Allow more time to upgrade the hard stuff in Step 4 * Can reach the _same_ goal as the current static approach * Larger "support zone" https://xkcd.com/2224/ * Instead of wide-ranging incompatibilities all at once, there's the _possibility_ of making it finer-grained and more gradual * rubyists can _choose_ to migrate all at once or in smaller chunks * It hedges the risks by keeping more possibilities open for now. * It allows to cop-out at Step 3 if Step 4 turns out too hard because it breaks too much stuff ### Cons * It allows to cop-out at Step 3 if Step 4 turns out too hard because it breaks too much stuff