Project

General

Profile

Feature #16511

Updated by Dan0042 (Daniel DeLorme) 9 months 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 

Back