Project

General

Profile

Actions

Feature #21386

closed

Introduce `Enumerable#join_map`

Added by matheusrich (Matheus Richard) about 2 months ago. Updated about 17 hours ago.

Status:
Rejected
Assignee:
-
Target version:
-
[ruby-core:122345]

Description

Problem

The pattern .map { ... }.join(sep) is extremely common in Ruby codebases:

users.map(&:name).join(", ")

It’s expressive but repetitive (both logically and computationally). This pattern allocates an intermediate array and does two passes over the collection.

Real-world usage is widespread:

Proposal

Just like filter_map exists to collapse a common map + compact, this
proposal introduces Enumerable#join_map, which maps and joins in a single
pass.

users.join_map(", ", &:name)

A Ruby implementation could look like this:

module Enumerable
  def join_map(sep = "")
    return "" unless block_given?

    str = +""
    first = true

    each do |item|
      str << sep unless first
      str << yield(item).to_s
      first = false
    end

    str
  end
end

The name join_map follows the precedent of filter_map, emphasizing the final
operation (join) over the intermediate (map).

Prior Art

Some other languages have similar functionality, but with different names or implementations:

Elixir

Elixir has this via the Enum.map_join/3 function:

Enum.map_join([1, 2, 3], &(&1 * 2))
"246"

Enum.map_join([1, 2, 3], " = ", &(&1 * 2))
"2 = 4 = 6"

Crystal

Crystal, on the other hand, uses Enumerable#join with a block:

[1, 2, 3].join(", ") { |i| -i } # => "-1, -2, -3"

Kotlin

Kotlin has a similar function called joinToString that can take a transformation function:

val chars = charArrayOf('a', 'b', 'c')
println(chars.joinToString() { it.uppercaseChar().toString() }) // A, B, C 

Related issues 1 (0 open1 closed)

Related to Ruby - Feature #21455: Add a block argument to Array#joinRejectedActions
Actions #1

Updated by mame (Yusuke Endoh) 19 days ago

Updated by nobu (Nobuyoshi Nakada) 12 days ago

(Prateek Choudhary) wrote in #note-2:

PR: https://github.com/ruby/ruby/pull/13792

This difference is intentional?

[1,2,3].map {|n|[n]}.join(",") #=> "1,2,3"
[1,2,3].join_map(",") {|n|[n]} #=> "[1],[2],[3]"

Updated by nobu (Nobuyoshi Nakada) 12 days ago

This code would show the difference more clearly.

[[1,2],3].map {|n|[n]}.join("|") #=> "1|2|3"
[[1,2],3].join_map("|") {|n|[n]} #=> "[[1, 2]]|[3]"

Updated by matheusrich (Matheus Richard) 11 days ago

My expectation is that join_map would behave like map + join

Updated by prateekkish@gmail.com (Prateek Choudhary) 11 days ago · Edited

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

This code would show the difference more clearly.

[[1,2],3].map {|n|[n]}.join("|") #=> "1|2|3"
[[1,2],3].join_map("|") {|n|[n]} #=> "[[1, 2]]|[3]"

Hmm. I missed considering that behavior. In this example though, just the map would return wrapped arrays:

[[1,2],3].map {|n|[n]} #=> [[[1, 2]], [3]]

So somehow doing a map and join is doing more of a flat_map + join.
I can correct the PR to mimic that behavior.

EDIT: The PR has been updated.

Updated by Dan0042 (Daniel DeLorme) 7 days ago · Edited

I am against this.

Ergonomics: Adding a special "X_Y" method for every common pattern of "X followed by Y" is truly horrible for the ergonomics of the language. It only multiplies the number of useless details the programmer should remember, without adding any expressiveness. Just because "map + join" is a common pattern doesn't mean it benefits from being expressed as a single method.

Performance: This appears intended to improve performance, but is there any benchmark? Is this really a measurable cost in any program, anywhere? It seems to me like a good JIT with escape analysis might already handle this for you, without having to remember the "special method for performance". Sorry but it looks entirely like premature micro-optimization to me.

Updated by mame (Yusuke Endoh) 7 days ago

https://github.com/ruby/dev-meeting-log/blob/master/2019/DevMeeting-2019-03-11.md

[Feature #15323] Proposal: Add Enumerable#filter_map
...
mame: if this method is accepted, other methods with map will be also requested.
matz: filter_map is not simply combination of filter and map. so mame’s concern is needless fears.
matz: ok, accepted filter_map.

That's why I was against it 🤦

Updated by matz (Yukihiro Matsumoto) 7 days ago

  • Status changed from Open to Rejected

I reject this proposal. Simply combine join and map at the moment.
I hope JIT inlining will remove intermediate objects in the future.

Matz.

Updated by knu (Akinori MUSHA) 7 days ago

FWIW, this function is called mapconcat in Emacs: https://www.gnu.org/software/emacs/manual/html_node/elisp/Mapping-Functions.html#index-mapconcat

I once wanted this when I implemented shelljoin() and chose to just call map and join. I convinced myself that you shouldn't worry too much about performance.
https://github.com/ruby/ruby/blob/65a0f46880ecb13994d3011b7a95ecbc5c61c5a0/lib/shellwords.rb#L209

Updated by Eregon (Benoit Daloze) 1 day ago

matz (Yukihiro Matsumoto) wrote in #note-9:

I hope JIT inlining will remove intermediate objects in the future.

FWIW, that's not really feasible, at least for cases where the input array has variable size.
Unless both Array#map and Array#join are intrinsified and handled as a compound operation of sort, but that's not really reasonable as Array#join is far from trivial.

That said, I think the cost of the map is only a small fraction of the cost of the join, and so not worth having a new core method for this.

Updated by zverok (Victor Shepelev) about 17 hours ago

Just a thought: shouldn't we add #join to Enumerator::Lazy? It wouldn't solve "logical repetitiveness" of the pattern, but might be a good and idiomatic way to optimize the pattern when necessary.

(Lazy enumerators are underused in the community, as I understand. And I personally would like to see them more popular.)

Actions

Also available in: Atom PDF

Like1
Like0Like0Like0Like0Like1Like0Like2Like1Like0Like0Like0Like0