Formatting a list of strings - am I missing anything?

I recently wrote some Elixir to format a list of strings (eg, author names) for output. I think it’s reasonably idiomatic, but I probably missed a few tricks. Suggestions, anyone?

-r

It should work like this…

iex(0)> list_str []    
""
iex(1)> list_str [1]
1
iex(2)> list_str [1,2]
"1 and 2"
iex(3)> list_str [1,2,3] 
"1, 2, and 3"

And now, the code… (ducks)

@doc """
Join a list of strings into a (mostly) comma-delimited string.
"""

def list_str( [] ), do: ""

def list_str( [ one ] ), do: one

def list_str( [ one, two ] ), do: "#{ one } and #{ two }"

def list_str(inp_list) do
  [ last | base_list ]  = Enum.reverse(inp_list)

  base_str  = base_list
  |> Enum.reverse()
  |> Enum.join(", ")

  "#{ base_str }, and #{ last }"
end
1 Like

Should probably be:

def list_str( [ one ] ), do: to_string(one)

Otherwise, looks fine to me.

2 Likes

In case whatever is coming in isn’t already a string. Agreed, but is this any better than (say) “#{ one }”?

They should be the same. I personally tend to use to_string instead of interpolation, when it’s only the value that I’m converting to a string. I’d use interpolation when I want to convert the values to a string and concatenate with something else.

2 Likes

That seems to be clearer, in terms of expressing intent, but in this case one could argue for staying in the same style as the following clauses. Dunno…

When it comes to serial commas the comma before the “and” is optional - it just can’t be there if you have less than three items.

So I guess recursion isn’t idiomatic?

  def list_str([]),
    do: ""

  def list_str([one]),
    do: to_string(one)

  def list_str(list),
    do: list_str(list, [])

  defp list_str([], [h, n | t]),
    do: list_to_str(t, to_string(n) <> " and " <> to_string(h))

  defp list_str([h | t], rest),
    do: list_str(t, [h | rest])

  defp list_to_str([], str),
    do: str

  defp list_to_str([h | t], str),
    do: list_to_str(t, to_string(h) <> ", " <> str)

1 Like

Thank you for that tour de force, Peer. However, it doesn’t produce the requested result, so a picky client might complain and a picky test suite would certainly fail. Do you have a version that would pass?

As I’m sure you realize, omission of the Oxford comma can lead to ambiguity. To cite a well known example, “I’d like to thank my parents, God and Mother Teresa…” So, pedants like me don’t consider it to be optional. (Then again, I also use a lot of optional parentheses and spaces in my code…)

Leaving that issue aside, I wonder about the use of <>. Is this version

to_string(n) <> " and " <> to_string(h)

actually better for some reason (e.g., faster) than this version?

 "#{ n } and #{ h }"

Finally, although your recursive approach is most impressive, does it have any other benefits (aside from its stunning clarity and simplicity) that you can suggest? Inquiring gnomes need to mine…

1 Like

Consistent version

  def list_str([]),
    do: ""

  def list_str([one]),
    do: to_string(one)

  def list_str(list),
    do: list_str(list, [])

  defp list_str([], [h, n]),
    do: list_to_str([], to_string(n) <> " and " <> to_string(h))

  defp list_str([], [h, n | t]),
    do: list_to_str(t, to_string(n) <> ", and " <> to_string(h))

  defp list_str([h | t], rest),
    do: list_str(t, [h | rest])

  defp list_to_str([], str),
    do: str

  defp list_to_str([h | t], str),
    do: list_to_str(t, to_string(h) <> ", " <> str)

Easy version

  def list_str([]),
    do: ""

  def list_str([one]),
    do: to_string(one)

  def list_str([one, two]),
    do: to_string(one) <> " and " <> to_string(two)

  def list_str(list),
    do: list_str(list, [])

  defp list_str([], [h, n | t]),
    do: list_to_str(t, to_string(n) <> ", and " <> to_string(h))

  defp list_str([h | t], rest),
    do: list_str(t, [h | rest])

  defp list_to_str([], str),
    do: str

  defp list_to_str([h | t], str),
    do: list_to_str(t, to_string(h) <> ", " <> str)

actually better for some reason (e.g., faster) than this version?

Haven’t looked at it too deeply - my gut-response is that it likely should be faster but that doesn’t preclude some smarty-pants compile time magic from proving me wrong and even if there is runtime overhead it could be minimal - though I wouldn’t count on it without verification.

EEx uses “some smarty-pants compile time magic” to turn Phoenix templates into sets of functions; this form of string interpolation doesn’t seem all that different to this newbie…

The second versions is syntactic sugar for the second.

There’s also cldr_lists, which can do that for you. Especially if you might need multiple languages I’d look into that.

I’m not quite sure what you want to say by linking those 3 files…

I was just trying to make it easier to investigate the handling of interpolated strings for anyone reading the topic. I guess I should have suppressed the “quote/reply handling” of Discourse.

The difference is that interpolation invokes a protocol to convert to string, the concat operator does not.

From:

String.Chars

Now in this context we are explicitly using to_string/1 in combination with concatenation anyway.

FYI: to me (from the compiler perspective) “syntactic sugar” implies that there is no runtime (performance) difference between the “sugared” and “unsugared” expression. I’m starting to wonder whether some people are using the term in a much looser sense.

If you’re outputting this with e.g. IO.puts or send_resp in Phoenix, you don’t have to output interpolated strings as results, you can output iolists. That’s the main trick that I would use. The second trick is to try to unify the handling of ‘x and y’ and ‘x, y, and z’ by realizing that the latter case reduces to the former case if you see that ‘x, y,’ in the latter corresponds to ‘x’ in the former. My version:

defmodule Test do
  def list_str([]), do: ""
  def list_str([x]), do: Integer.to_string(x)
  def list_str([x1, x2]) when is_integer(x1) do
    list_str([[Integer.to_string(x1), " "], x2])
  end
  def list_str([x1, x2]), do: [x1, "and ", Integer.to_string(x2)]
  def list_str(xs) do
    {init, [last]} = Enum.split(xs, -1) # Traverses twice
    list_str([Enum.map(init, &append_comma/1), last])
  end

  defp append_comma(x), do: [Integer.to_string(x), ", "]
end

[] |> Test.list_str |> IO.puts
[1] |> Test.list_str |> IO.puts
[1, 2] |> Test.list_str |> IO.puts
[1, 2, 3] |> Test.list_str |> IO.puts
1 Like
def join([a, b]), do: "#{a} and #{b}"
def join([a, b, c]), do: "#{a}, #{b}, and #{c}"
def join([a, b | rest]), do: join(["#{a}, #{b}" | rest])
def join(list), do: to_string(list)

Using

def join([a, b]), do: "#{a} and #{b}"
def join([a, b, c]), do: join([[a, ', ', b, ','], c])
def join([a, b | rest]), do: join([[a, ', ', b] | rest])
def join(list), do: to_string(list)

with

[] |> Demo.join() |> IO.puts()
[1] |> Demo.join() |> IO.puts()
[1, 2] |> Demo.join() |> IO.puts()
[1, 2, 3] |> Demo.join() |> IO.puts()

produces



1 and 2
, , and 3
  def join([]), do: ""
  def join([a]), do: to_string(a)
  def join([a, b]), do: "#{a} and #{b}"
  def join(list), do: join(list, [])
  def join([last], strl), do: to_string([strl, 'and ', to_string(last)])
  def join([h | t], strl), do: join(t, [strl, to_string(h), ', '])

produces the desired result.


1
1 and 2
1, 2, and 3
1 Like

Didn’t realize the inputs weren’t strings. Edited to support integers.