Project

General

Profile

Actions

Feature #19830

open

Allow `Array#transpose` to take an optional size argument

Added by tomstuart (Tom Stuart) over 1 year ago. Updated over 1 year ago.

Status:
Open
Assignee:
-
Target version:
-
[ruby-core:114342]

Description

One benefit of supplying an initial value to Enumerable#inject is that it avoids an annoying edge case when the collection is empty:

>> [1, 2, 3].inject(:+)
=> 6 # good

>> [].inject(:+)
=> nil # bad

>> [].inject(0, :+)
=> 0 # good

A similar edge case exists for Array#transpose:

>> [[1, :a], [2, :b], [3, :c]].transpose
=> [[1, 2, 3], [:a, :b, :c]] # good

>> [].transpose
=> [] # bad

Although no explicit nil is produced here, the subtle problem is that the caller may assume that the result array contains arrays, and that assumption leads to nils in the empty case:

>> [[1, :a], [2, :b], [3, :c]].transpose.then { _2.join }
=> "abc"

>> [].transpose.then { _2.join }
undefined method `join' for nil:NilClass (NoMethodError)

If we allow Array#transpose to take an optional argument specifying the size of the result array, we can use this to always return an array of the correct size:

>> [[1, :a], [2, :b], [3, :c]].transpose(2)
=> [[1, 2, 3], [:a, :b, :c]] # good

>> [].transpose(2)
=> [[], []] # good

By avoiding an unexpectedly empty result array, we also avoid unexpected downstream nils:

>> [[1, :a], [2, :b], [3, :c]].transpose(2).then { _2.join }
=> "abc"

>> [].transpose(2).then { _2.join }
=> ""

Here is a patch which adds an optional argument to Array#transpose to support the above usage: https://github.com/ruby/ruby/pull/8167

Something similar was requested eleven years ago in #6852. I believe this feature addresses the problem expressed in that issue without compromising backward compatibility with existing callers of #transpose.

Updated by nobu (Nobuyoshi Nakada) over 1 year ago

Why not ary.transpose[1]&.join?

Updated by tomstuart (Tom Stuart) over 1 year ago

nobu (Nobuyoshi Nakada) wrote in #note-1:

Why not ary.transpose[1]&.join?

That avoids the exception but returns nil instead of taking advantage of #join’s knowledge of the “correct” result for an empty array, i.e. the empty string. If the caller was expecting #transpose to return an array of arrays, they’ll also be expecting #join to return a string, so the safe navigation operator just pushes the nil problem even further downstream.

So why not ary.transpose[1]&.join || "" or (ary.transpose[1] || []).join or even ary.transpose[1].to_a.join? Yes, any of them will work. The optional size argument is intended to provide a more general and elegant solution so that these case-by-case nil workarounds can be avoided.

Here is an example which is closer to a problem I encounter in real programs:

>> ary = [[1, :a], [2, :b], [3, :c]]
>> ary.transpose.then { |numbers, letters| [numbers.sum, letters.join] }
=> [6, "abc"]

>> ary = []
>> ary.transpose.then { |numbers, letters| [numbers.sum, letters.join] }
undefined method `sum' for nil (NoMethodError)

How should we avoid the exception and get the result we want in this case, i.e. [0, ""]? I would like to avoid having to hardcode the correct “empty array result” value every time (e.g. [numbers&.sum || 0, letters&.join || ""]) because #sum and #join already have these built in, and it’s inconvenient to default each parameter to the empty array (e.g. [(numbers || []).sum, (letters || []).join]) if they’re used in multiple places.

I usually solve the problem on entry to the block by making its parameters optional:

>> ary.transpose.then { |numbers = [], letters = []| [numbers.sum, letters.join] }
=> [0, ""]

This allows the body of the block to avoid having to deal with the possibility of numbers and letters being nil; I know that #sum and #join will give the correct results automatically. But it’s verbose, and sometimes I forget to do it, and until recently it didn’t work with YJIT.

With the optional size argument to #transpose I only have to say how many array parameters the block expects:

>> ary.transpose(2).then { |numbers, letters| [numbers.sum, letters.join] }
=> [0, ""]

As well as being more concise, I think this is easier to get right every time.

Updated by nobu (Nobuyoshi Nakada) over 1 year ago

It doesn't feel elegant or concise to feed the element size to me.
For what use cases do you want it actually?

Actions

Also available in: Atom PDF

Like2
Like0Like1Like0