# A small script that analyses a sentence

I came up with 2 solutions for this small problem exercise - the script should take a sentence from the user and return 4 things - the number of lower case letters, the number of uppercase letters, the number of punctuation characters and the total number of characters (i.e. anything except spaces). Spaces are ignored. Would appreciate to know what could be improved and which one of them reads better or makes more sense, if at all?

The output looks like this:

Type a sentence: See u 2morrow!
Upper case: 1
Lower case: 9
Punctuation: 1
Total chars: 12


Solution 1

sentence = input("Type a sentence: ")

# Initialize a dictionary to store the counts
counts = {}

# Loop through each character in the sentence
counts["Upper case"] = len([char for char in sentence if char.isupper()])
counts["Lower case"] = len([char for char in sentence if char.islower()])
counts["Punctuation"] = len([char for char in sentence if not char.isalnum() and not char.isspace()])
counts["Total chars"] = len([char for char in sentence if not char.isspace()])

# Print the results
for k, v in counts.items():
print(f"{k}: {v}")


Solution 2

sentence = input("Type a sentence: ")

# Initialize a dictionary to store the counts
counts = {"Upper case": 0, "Lower case": 0, "Punctuation": 0, "Total chars": 0}

# Loop through each character in the sentence
for char in sentence:
if not char.isspace():
counts["Total chars"] += 1
if not char.isalnum():
counts["Punctuation"] += 1
elif char.isalpha():
if char.isupper():
counts["Upper case"] += 1
else:
counts["Lower case"] += 1

# Print the results
for k, v in counts.items():
print(f"{k}: {v}")


len([char for char in sentence if char.isupper()])


You create a list of $$\O(n)\$$ size. We can instead change the algorithm to use $$\O(1)\$$ memory by using sum(1 for ...). Similar to how you're summing in Solution 2.

We can DRY (Don't Repeat Yourself) the code by moving the line I highlighted into a function. We can then use lambda to make anonymous functions. The functions will take a char and return whether we want to count the value.

from typing import Callable

def count(chars: str, wanted: Callable[[str], bool]) -> int:
return sum(
1
for char in chars
if wanted(char)
)

...

counts["Upper case"] = count(sentence, str.isupper)
counts["Lower case"] = count(sentence, str.islower)
counts["Punctuation"] = count(sentence, lambda char: not char.isalnum() and not char.isspace())
counts["Total chars"] = count(sentence, lambda char: not char.isspace())


Solution 2 contains the 'Arrow anti-pattern'. You may be able to see; the start of each line is starting to look like an arrow head. The effect is harder to see in Python. As Python uses white space for indentation we don't have a lot of } to complete the arrow head. To resolve the 'Arrow anti-pattern' you can normally use break, continue or return statements to use less indentation.

We can flatten all the ifs onto the same indentation. However, doing so makes the groupings you have made readable far harder to read. So I'd use the following:

for char in sentence:
if char.isspace():
continue
counts["Total chars"] += 1
if not char.isalnum():
counts["Punctuation"] += 1
elif char.isalpha():
if char.isupper():
counts["Upper case"] += 1
else:
counts["Lower case"] += 1


However, we can change what you define the groupings to be to make the ifs fit on one indentation and still be readable.

for char in sentence:
if char.isspace():
continue
counts["Total chars"] += 1
if char.isupper():
counts["Upper case"] += 1
elif char.islower():
counts["Lower case"] += 1
elif not char.isalnum():
counts["Punctuation"] += 1


I find both to be quite readable in two very different ways. The first shows us what each value is quite easily. The second shows us the groupings quite easily. However neither succeed at doing both.
For the challenge, since we're not really grouping chars, Solution 1 is likely easier to understand.

• Thanks very much for your comments @Peilonrayz! Is it correct to say that by using sum(1 for ...) we save memory because it generates the item as it iterates so it wouldn't generate a whole list which list comprehension would, but in terms of time complexity both expressions have the same runtime of O(n) as they both involve iterations? Apr 5, 2023 at 14:55
• @isadoramoon Yes, you are correct. We convert from a list comprehension to a generator expression. Another form of generator is a function which has the yield command - tutorial. Generators only perform the work of one item at a time. Making the memory usage of a generator $O(1)$. The time complexity is the same as you noted. Because of the overhead of constantly asking generators for more items, generators can have a slightly lower performance. Which is largely irrelative unless you're playing around with microoptimizing. Apr 5, 2023 at 15:17

Put code in functions. There are many benefits. One of them is that you can implement a solution in different ways, putting each in a separate function, and then compare them easily -- for example, for correctness or speed.

Make those functions free of side effects. One of the keys to effective programming is to put most of the logic in functions or methods that are purely data oriented: they take data as input, return data as output, and never cause side effects that can fail or otherwise complicate things. In your case, that means doing user interaction and printing somewhere else -- not in the algorithmic code in charge of analyzing the characters in a string.

When doing an accounting, make it exhaustive whenever feasible. By excluding whitespace, you are making the logic more complex than it needs to be. You may not want to report whitespace and digits in the tally to users (that's fine), but by keeping the residual categories alive in your analysis you will usually simplify the coding logic (there are exceptions, of course) and keep your program flexible to support future needs should they evolve over time. There are various ways to manage a classification scheme in Python. A common one is to use an Enum, as shown in the CharType class below.

Simplify coding logic by thinking in terms of data conversion. We can include a method in our Enum class to take some other kind of data (in this case a single character) and return the corresponding instance of our Enum class. Writing a conversion method like this is super easy because we have narrowed the problem space considerably. Because we are dealing with a single character, we don't have to iterate over the entire sentence. Because our classification scheme is exhaustive, we don't have to write logical tests with multiple conditions (such as if not char.isalnum() and not char.isspace()); instead, the test for each character type hinges on a single check. Viewed more broadly, the example below has very little algorithmic complexity precisely because it is built from two simple data conversions: (a) character to CharType, (b) text to tally.

When you are counting things, consider using a Counter. It does the tallying for you and gives you a dict-like object.

import sys
from enum import Enum
from collections import Counter

def main(args):
if args:
text = ' '.join(args)
else:
text = input('Enter some text: ')
tally = character_tally(text)
print(tally)

def character_tally(text):
return Counter(
CharType.from_char(c).value
for c in text
)

class CharType(Enum):
UPPER = 'uppercase'
LOWER = 'lowercase'
DIGIT = 'digit'
PUNCTUATION = 'punctuation'
WHITESPACE = 'whitespace'

@classmethod
def from_char(cls, c):
return (
cls.UPPER if c.isupper() else
cls.LOWER if c.islower() else
cls.DIGIT if c.isdigit() else
cls.WHITESPACE if c.isspace() else
cls.PUNCTUATION
)

if __name__ == '__main__':
main(sys.argv[1:])


You could also use a variation of sum, map, and the .islower, .isupper, not .isalnum or .isspace functions, for example if your text input is assigned to variable name 'text', then for lowercase

return sum(map(str.islower, text))