10
\$\begingroup\$

I'm working on a list practice project from the book "Automate the Boring Stuff with Python" which asks for this:

Write a function that takes a list value as an argument and returns a string with all the items separated by a comma and a space, with and inserted before the last item. For example, passing the previous spam list to the function would return 'apples, bananas, tofu, and cats'. But your function should be able to work with any list value passed to it. Be sure to test the case where an empty list [] is passed to your function.

So far I've come up with this:

def comma_code(iterable):
    '''
    Function that loops through every value in a list and prints it with a comma after it,
    except for the last item, for which it adds an "and " at the beginning of it.
    Each item is str() formatted in output to avoid concatenation issues.

    '''
    for i, item in enumerate(iterable):
        if i == len(iterable)-1 and len(iterable) != 1: # Detect if the item is the last on the list and the list doesn't contain only 1 item (BONUS) 
            print('and ' + str(iterable[-1]))           # Add 'and ' to the beginning
        elif len(iterable) == 1:                        # BONUS: If list contains only 1 item,
            print('Just ' + str(iterable[-1]))          # replace 'and ' with 'Just '
        else:                                           # For all items other than the last one
            print(str(iterable[i]) + ',', end=" ")      # Add comma to the end and omit line break in print

There's heavy commenting because I'm fairly new and I'm trying to leave everything as clear as possible for my future self.

Now I wonder if there's a better way of doing this and also (subjective question) if there is something in my code that I should change for better readability and/or style. As I said, I'm fairly new and I would like to pick good coding practices from the beginning.

These are a couple of lists I ran through the function:

spam = ['apples', 'bananas', 'tofu', 'cats']
bacon = [3.14, 'cat', 11, 'cat', True]
enty = [1]

And this is the working output:

apples, bananas, tofu, and cats
3.14, cat, 11, cat, and True
Just 1

Thanks in advance.

\$\endgroup\$
4
  • 7
    \$\begingroup\$ "returns a string" - You're not doing that at all. \$\endgroup\$ – superb rain Oct 31 '20 at 19:49
  • \$\begingroup\$ Care to explain? Is it because i'm printing the output rather than returning it? \$\endgroup\$ – Vaney Rio Oct 31 '20 at 19:53
  • 2
    \$\begingroup\$ Well... yeah... \$\endgroup\$ – superb rain Oct 31 '20 at 19:56
  • \$\begingroup\$ I fixed it, I just didn't know how to print a "return" value. Thanks for pointing it out, my logic was faulty. \$\endgroup\$ – Vaney Rio Oct 31 '20 at 23:19
9
\$\begingroup\$

An alternate approach

  • The routine is same for all types of inputs, except for when the len(list) == 1. This is why we can use a generator function to simplify the routine, and then a simple if statement to work with the exception
def comma_code(iterable):
    result = ', '.join(str(value) for value in iterable[:-1])
    return f"{result}, and {iterable[-1]}"

Now, since we have to deal with a special case, an if-statement can work

def comma_code(iterable):
    if not iterable: return None
    if len(iterable) == 1: return f"Just {iterable[0]}"

    result = ', '.join(str(value) for value in iterable[:-1])
    return f"{result}, and {iterable[-1]}"

The question hasn't stated what to do if the size of the container is 0, hence I have just returned None. As stated by @Stef in the comments, you can also return an empty string which is just ''.
I would prefer None as it becomes easy to trace back if an issue occurred.
Moreover, you can also design a simple function that would check if the container is empty. If it is then the function can print a custom warning which can help you in debugging your code

Explanation

result = ', '.join(str(value) for value in iterable[:-1])

This is called a generator function, we're basically appending str(value) + ', ' for every value in iterable.
iterable[:-1] so that we only iterate till the second last element.
to be fully verbose, :-1 says everything in iterable up till the last element


\$\endgroup\$
17
  • 3
    \$\begingroup\$ A gift introducing the generator & f-strings and explaining so gentle 👍 Applied pythonic conciseness like a pro. \$\endgroup\$ – hc_dev Oct 31 '20 at 20:53
  • \$\begingroup\$ result = ', '.join(str(value) for value in iterable[:-1]) Is this applying the concept of list comprehension? I'm familiar with f-string formatting and understand what generator code does for memory optimization, but i'm yet to recognize easily the format for list comprehensions. \$\endgroup\$ – Vaney Rio Oct 31 '20 at 21:46
  • \$\begingroup\$ I tried this, it works great and it's really compact. I only had to figure out how to print the function return values. Thanks. \$\endgroup\$ – Vaney Rio Oct 31 '20 at 22:46
  • \$\begingroup\$ However, I just found an issue in the format. Output shows a comma after every item, including the next to last so it ends up looking like "Item[1], Item[2], ... Item[-2] , and Item[-1]". Simply removing the ',' after {result} in return f"{result}, and {iterable[-1]}" seems to fix the issue (In case you want to update your code). \$\endgroup\$ – Vaney Rio Oct 31 '20 at 23:17
  • 1
    \$\begingroup\$ @VaneyRio Hey, sorry for the late reply, don't you need a , before and? That's how I remember my English to be😁, if not its simply to remove it, as you said just remove it from the f-string \$\endgroup\$ – Aryan Parekh Nov 1 '20 at 3:11
6
\$\begingroup\$

Well done

First I like your attitude "for the future me". Keep that clean-coding practice. It will truely help.

Handle edge cases aside

I would extract the junction-part (", ", " and ") and particularly the single case ("Just "):

def print_joined(iterable):
  # in unlikely case of nothing (fail-fast)
  if len(iterable) == 0:
    # raise an error or print sorry instead of returning silently
    return
  # special intro if single
  elif len(iterable) == 1:
    print('Just ' + str(iterable[0]))
    return

  ## multiples concatenated
  for i, item in enumerate(iterable):
      join = ', ' # default separator

  ## multiples concatenated
  joined_items = ''
  for i, item in enumerate(iterable):
      junction = ', ' # default separator

      if i == len(iterable)-2:
        junction = ' and ' # special separator before last
      elif i == len(iterable)-1:
        junction = ''  # last item ends without separator

      joined_items += str(item) + junction  # you defined item on loop, so use it

  print(joined_items)

This alternative is just making the different cases clear. Although a bit verbose and not very pythonic (no generator, no template based string-formatting used), it focuses on robustness.

To give the lengthy method body structure and separate different parts for readability I inserted empty lines (vertical spacing) - individual preference of style.

All edge-cases are caught in the beginning in order to return/fail fast (avoid obsolete loop). This is best-practice when it comes to validation.

The core purpose can be easily read from the last line (the return line): printing items joined. The side amenities like case-based junction strings are put literally as topping. Refinement can be introduced in later iterations (extensibility), depending on your experience, e.g.:

  • enhanced formatting/printing (using Python's convenience functions)
  • returning a string (making the function a formatter, independent from output interface)
  • parameterize junction-strings (for customisation/localisation instead of hard-coded)

Usability: Grammar for using comma before and in lists

I wondered about the comma before and and did research. The phenomenon called serial comma or Oxford comma, is described in the Grammarly Blog post Comma Before And. It essentially says:

  • You usually put a comma before and when it’s connecting two independent clauses.
  • It’s almost always optional to put a comma before and in a list.
  • You should not use a comma before and if you’re only mentioning two qualities.

Since I assume the function should list pure items (names, nouns, verbs) - not clauses - I implemented a simple and (without serial comma) to also handle 2 items grammatically correctly.

Thus as long as the different junctions can't be parameterized yet, you should clarify (implicitly) applied junction-rules in a comment (doc-string).

\$\endgroup\$
3
  • \$\begingroup\$ I tried this because I like the idea of testing for errors before computing anything else, however, there's a problem with this code. Last item (iterable[-1]) shows in the output as: 'iterable[-1] and' rather than 'and iterable[-1]'. Inverting the print statement also changes the order of the ',' to the beginning of every item printed. \$\endgroup\$ – Vaney Rio Oct 31 '20 at 21:42
  • \$\begingroup\$ @VaneyRio Thanks! I forgot to test my solution. Now fixed and updated the code. Note: comparison with 0-based index i to check for last i == len(iterable)-1. \$\endgroup\$ – hc_dev Nov 1 '20 at 14:41
  • \$\begingroup\$ Did some grammar research on Oxford comma and SO suggests related review on Refactor Oxford Comma function (and other functions) \$\endgroup\$ – hc_dev Nov 1 '20 at 15:21

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.