Project

General

Profile

Feature #16446

Enumerable#take_*, Enumerable#drop_* counterparts with positive conditions

Added by sawa (Tsuyoshi Sawada) 3 months ago. Updated 2 months ago.

Status:
Rejected
Priority:
Normal
Assignee:
-
Target version:
-
[ruby-core:96419]

Description

#16441 led me to think about the issue more generally. When we want to split a series of iterations by the first element that satisfies (or dissatisfies) a condition, we have three factors to consider.

(1) Whether we want the condition to work negatively or positively
(2) Whether we want the first element to satisfy (or dissatisfy) the condition to be included in the left side or the right side of the split
(3) Whether we want the left side or the right side in the returned output

This leads us to eight possible combinations to consider.

enum = [1, 1, 0, 3, 3, 0, 5, 5].to_enum
(1) (2) (3) method example
1 negatively left left take_while enum.foo1(&:nonzero?) # => [1, 1]
2 negatively left right drop_while enum.foo2(&:nonzero?) # => [0, 3, 3, 0, 5, 5]
3 negatively right left enum.foo3(&:nonzero?) # => [1, 1, 0]
4 negatively right right enum.foo4(&:nonzero?) # => [3, 3, 0, 5, 5]
5 positively left left enum.foo5(&:zero?) # => [1, 1]
6 positively left right enum.foo6(&:zero?) # => [0, 3, 3, 0, 5, 5]
7 positively right left enum.foo7(&:zero?) # => [1, 1, 0]
8 positively right right enum.foo8(&:zero?) # => [3, 3, 0, 5, 5]

Proposal #16441 asks for a method that corresponds to case 3 in the table above, but I think that would make the paradigm messy unless case 4 is also implemented. Either cases 3 and 4 should both be implemented, or both not. Actually, the current proposal is not about cases 3 and 4. I would leave that to #16641.

In many use cases (including the first example in #16641), we want to detect the "marker element" by which we split the iterations. In the cases above, that can be the element 0. In such use cases, it is more natural to describe the condition in positive terms (i.e., zero?) rather than negative terms (i.e., nonzero?). (And in other use cases, it might be the other way around.) So I would like to propose methods that correspond to cases 5, 6, 7, 8 above.

Naming of the methods should be done systematically. As a candidate, I came up with the following:

method
5 take_before
6 drop_before
7 take_upto
8 drop_upto

Related issues

Related to Ruby master - Feature #16441: Enumerable#take_while_afterRejectedActions

Updated by shevegen (Robert A. Heiler) 3 months ago

I have not thought about the proposal here quite much so far, sorry (and admittedly I may need
to do so on a fresh morning as I am a bit tired).

Just two brief comments:

  • Some of the examples remind me a bit of .select/.reject/.filter. Personally I try
    to favour .select whenever possible, largely because I like positive selection/filtering.

  • To your statement "In such use cases, it is more natural to describe the condition
    in positive terms (i.e., zero?) rather than negative terms (i.e., nonzero?)".

Yup, completely agree with you there. We may have to include cases that do not have a
clear decisive difference, though, such as .odd? and .even? for numbers, and perhaps
other elements similar to this.

Updated by wishdev (John Higgins) 3 months ago

The answer here is simplicity (declarative) as opposed to wordsmithing.

We need to add an drop_until and take_until to handle 5-8 and drop_while and take_while handle 1-4

All 4 methods get a new parameter "bucket" which determines where the element that triggers the split gets placed :left or :right bucket

Your table looks something like this to me - I apologize but I prefer simpler examples

ary = [-1, 0, 1]

ary.take_while{ |x| x.nonzero? } # => [-1]
ary.take_while(:right){ |x| x.nonzero? } # => [-1]
ary.take_while(:left){ |x| x.nonzero? } # => [-1, 0]

ary.drop_while{ |x| x.nonzero? } # => [0, 1]
ary.drop_while(:right){ |x| x.nonzero? } # => [0, 1]
ary.drop_while(:left){ |x| x.nonzero? } # => [1]

ary.take_until{ |x| x.zero? } # => [-1]
ary.take_until(:right){ |x| x.zero? } # => [-1]
ary.take_until(:left){ |x| x.zero? } # => [-1, 0]

ary,drop_until{ |x| x.zero? } # => [0, 1]
ary,drop_until(:right){ |x| x.zero? } # => [0, 1]
ary.drop_until(:left){ |x| x.zero? } # => [1]

#take_* gets the left bucket and #drop_* gets the right bucket - the default would be to place the trigger in the right bucket (take_while and drop_while current behave with :right as their option in this scenario).

John

Updated by sawa (Tsuyoshi Sawada) 3 months ago

wishdev (John Higgins) wrote:

I prefer simpler examples
ary = [-1, 0, 1]

My intention of including two 0s in the example was to show that there remains an asymmmetry; the search is done from the left, not from the right. In principle, we can still extend the paradigm, but I am not asking for that.

Updated by wishdev (John Higgins) 3 months ago

sawa (Tsuyoshi Sawada) wrote:

wishdev (John Higgins) wrote:

I prefer simpler examples
ary = [-1, 0, 1]

My intention of including two 0s in the example was to show that there remains an asymmmetry; the search is done from the left, not from the right. In principle, we can still extend the paradigm, but I am not asking for that.

Nothing wrong with that at all - but I think adding a :reverse options to the methods would accomplish a right to left search feature.

As I said - declaring what you want to do as opposed to trying to come up with some group of 2 words descriptions seems the wiser path. Especially looking at concepts like reversing the search direction.........

#5

Updated by Dan0042 (Daniel DeLorme) 3 months ago

I think the positive counterparts of take_while/drop_while should be take_until/drop_until. And although take_upto and drop_upto are nicely intuitive and descriptive, I can't think of any good names for their negative counterparts (3) and (4). That may be an indication that we'd be better with a parameter as wishdev suggests. However I find that :left and :right do not read naturally at all. Maybe a number to indicate how many extra items to take or drop:

enum.take_while(&:nonzero?) # => [1, 1]
enum.drop_while(&:nonzero?) # => [0, 3, 3, 0, 5, 5]
enum.take_while(+1, &:nonzero?) # => [1, 1, 0]
enum.drop_while(+1, &:nonzero?) # => [3, 3, 0, 5, 5]

It's worth noting that there's several other succint ways to accomplish the same thing, so I'm not convinced it's worth adding that many methods. Not everything has to be a one-liner.

i = ary.index(&:zero?) || ary.size
ary[0...i]     # => [1, 1]
ary[0..i]      # => [1, 1, 0]
ary[i..-1]     # => [0, 3, 3, 0, 5, 5]
ary[i+1..-1]   # => [3, 3, 0, 5, 5]

n = enum.find_index(&:zero?) || enum.count
enum.take(n)   # => [1, 1]
enum.take(n+1) # => [1, 1, 0]
enum.drop(n)   # => [0, 3, 3, 0, 5, 5]
enum.drop(n+1) # => [3, 3, 0, 5, 5]

BTW you could also classify the three factors as:

(1) Whether we want to express the items to include (take) or exclude (drop)
(2) Whether we want the left side or the right side in the returned output
(3) Whether we want the items to be inclusive or exclusive of the boundary element

(1) (2) (3) method example
1 take left exclusive take_before enum.take_before(&:zero?) # => [1, 1]
1 take left inclusive take_upto enum.take_upto(&:zero?) # => [1, 1, 0]
1 take right inclusive take_from enum.take_from(&:zero?) # => [0, 3, 3, 0, 5, 5]
1 take right exclusive take_after enum.take_after(&:zero?) # => [3, 3, 0, 5, 5]
1 drop left exclusive drop_before enum.drop_before(&:zero?) # => [0, 3, 3, 0, 5, 5]
1 drop left inclusive drop_upto enum.drop_upto(&:zero?) # => [3, 3, 0, 5, 5]
1 drop right inclusive drop_from enum.drop_from(&:zero?) # => [1, 1]
1 drop right exclusive drop_after enum.drop_after(&:zero?) # => [1, 1, 0]

This is what I'd call a declarative style, as it describes what the method does, not how. take_while feels more like an imperative style to me since it describes the how, the control flow.

Ultimately there's so many different ways to think about and express any given operation... there's More Than One Way To Do It but they don't all have to be in the ruby core.

#6

Updated by sawa (Tsuyoshi Sawada) 3 months ago

I understand some people are thinking that adding all these methods is doing too much.

I came up with an idea of using just one method: cut. It works like String#partition when it does not take an argument (but unfortunately, the method name Enumerable#partition is already used for a different purpose). I took the terminology from Dedekind cut.

enum.cut(&:zero?) # # =>  [[1, 1], [0], [3, 3, 0, 5, 5]]

It would take optional keyword argument :take or :drop.

enum.cut(take: :left, &:zero?) # => [1, 1]
enum.cut(drop: :left, &:zero?) # => [0, 3, 3, 0, 5, 5]
enum.cut(drop: :right, &:zero?) # => [1, 1, 0]
enum.cut(take: :right, &:zero?) # => [3, 3, 0, 5, 5]
#7

Updated by akr (Akira Tanaka) 3 months ago

Updated by matz (Yukihiro Matsumoto) 2 months ago

  • Status changed from Open to Rejected

I don't think this many methods only introduce confusion. I feel negative. Besides that, I see the cut method is too difficult to use.

Matz.

Also available in: Atom PDF