Time to put the pieces together. You will build a small but real tool: a text analyzer that counts words and finds the most common one. It reuses everything you have learned — functions, dicts, and the engineering habit of decomposing first.
The plan, as milestones:
count_words(text)— build a word-frequency dict.top_word(freq)— find the most common word.word_count(text)— total number of words.analyze(text)— assemble them into a finished report.
Build each piece, test it, then wire them together. That is how real software gets written. No single function is longer than five lines, but together they do something useful. This lesson is about composition: making small, testable pieces and snapping them together.
flowchart LR T["raw text"] --> S["split into words"] S --> D["build freq dict"] D --> R["report: total + top word"] style D fill:#3776ab,color:#fff style R fill:#3776ab,color:#fff
Why decomposition matters
A beginner writes one long function that does everything. When it breaks, you have no idea which part failed. A professional breaks the job into single-purpose functions, tests each one, then composes them.
The test for a good function is simple: can you describe what it does in one sentence without using the word 'and'? count_words builds a frequency table. top_word finds the maximum. analyze calls both and formats the result. Each has one reason to change, one job to do, and one set of tests that prove it works.
This habit pays off immediately. If your frequency table is wrong, you know the bug lives in count_words. If the report format changes, only analyze needs editing.
When to stop decomposing
Not every line deserves its own function. The right size is the size you can test in your head. word_count is one line, but it is still worth extracting because it has a clear name and a clear test. If a function is two lines and those lines are inseparable, leave them inline.
The warning sign of over-decomposition is a function whose name is longer than its body. The warning sign of under-decomposition is a function you cannot describe in one sentence. When in doubt, split — it is easier to inline a function later than to untangle a monster.
Milestone 1 — count the words
Split the text on whitespace and tally each word into a dict. This is the frequency table everything else reads from.
def count_words(text):
freq = {}
for word in text.split():
freq[word] = freq.get(word, 0) + 1
return freq
text.split() breaks on any whitespace and returns a list of words. The loop walks that list and uses freq.get(word, 0) + 1 to increment the count. get is the safe way to read a dict: if the key is missing, it returns 0 instead of raising a KeyError. Without get, you would need an if word in freq: check every time, which is longer and harder to read.
def count_words(text):
freq = {}
for word in text.split():
freq[word] = freq.get(word, 0) + 1
return freq
print(count_words('the cat the dog the bird'))
print(count_words('hello world hello'))
print(count_words(''))
flowchart LR
T["'the cat the'"] --> S["split"]
S --> D["freq = {}"]
D --> A["'the' → 1"]
A --> B["'cat' → 1"]
B --> C["'the' → 2"]
style D fill:#3776ab,color:#fff
Milestone 2 — find the top word
Given the frequency dict, walk it once and keep track of the word with the highest count. On a tie, keep the first one you found.
def top_word(freq):
best = None
for word, count in freq.items():
if best is None or count > freq[best]:
best = word
return best
The loop uses freq.items() to get both the word and its count at the same time. best starts as None so the first word always wins the initial comparison. The strict greater-than count > freq[best] means that when two words have the same count, the first one stays in best. This is a small detail, but determinism matters in tests and in user-facing output.
def top_word(freq):
best = None
for word, count in freq.items():
if best is None or count > freq[best]:
best = word
return best
print(top_word({'a': 2, 'b': 1}))
print(top_word({'x': 3, 'y': 3, 'z': 1}))
print(top_word({}))
Milestone 3 — total word count
A one-liner: the total number of words is just how many items text.split() produces.
def word_count(text):
return len(text.split())
Milestone 4 — assemble the tool
Now compose the pieces. analyze calls the helpers you just built and returns a short human-readable summary. This is the payoff of decomposition: the final function reads like the spec.
def analyze(text):
freq = count_words(text)
total = sum(freq.values())
top = top_word(freq)
return f'{total} words; most common: {top}'
Notice that total is computed with sum(freq.values()) rather than calling word_count. Both are correct, but reusing the dict you already built is slightly more efficient and keeps the data flow clear.
flowchart LR C["count_words"] --> D["freq dict"] D --> A["analyze"] A --> R["report string"] style C fill:#3776ab,color:#fff style A fill:#3776ab,color:#fff
def count_words(text):
freq = {}
for word in text.split():
freq[word] = freq.get(word, 0) + 1
return freq
def top_word(freq):
best = None
for word, count in freq.items():
if best is None or count > freq[best]:
best = word
return best
def analyze(text):
freq = count_words(text)
total = sum(freq.values())
top = top_word(freq)
return f'{total} words; most common: {top}'
print(analyze('the cat the dog the bird'))
Handling edge cases
Real tools must behave when given empty or unusual input. An empty string has no words, so count_words('') returns {} and top_word({}) returns None. A string with only whitespace splits into an empty list, so word_count(' ') returns 0.
These cases are easy to forget and painful to debug later, because they only appear in production when a user submits a blank form. The defensive approach is to write a test for the empty case the moment you define the function. If your code handles {} and '' correctly, it will handle almost everything else.
Write count_words(text) that splits text on whitespace and returns a dict mapping each word to the number of times it appears.
def count_words(text):
# split text, then build a word -> count dict
pass
Loop over text.split().
freq.get(word, 0) + 1 gives the new count, whether or not the word is new.
def count_words(text):
freq = {}
for word in text.split():
freq[word] = freq.get(word, 0) + 1
return freq
Write top_word(freq) that takes the dict from count_words and returns the word with the highest count. Return None for an empty dict. On a tie, return whichever word comes first.
def top_word(freq):
# track the best word seen so far
pass
Start with best = None and update it as you scan.
Use count > freq[best] so the first word wins ties (strict greater-than).
def top_word(freq):
best = None
for word, count in freq.items():
if best is None or count > freq[best]:
best = word
return best
Write word_count(text) that returns the total number of words in text (the length of text.split()).
def word_count(text):
# one expression is enough
pass
text.split() returns the list of words.
len(...) gives the count.
def word_count(text):
return len(text.split())
What does this print? It counts words and sums their frequencies.
def count_words(text):
freq = {}
for word in text.split():
freq[word] = freq.get(word, 0) + 1
return freq
print(sum(count_words('one two two three three three').values()))
'one two two three three three' has 1 + 2 + 3 words.
sum of the dict's values is the total word count: 6.
Bring the pieces together. count_words and top_word are given to you below — each exercise runs on its own, so a lesson's exercises can't see each other's code. Write analyze_with_stats(text) that uses them to return a dict with keys 'total', 'top_word', and 'unique_count'. For empty text return {'total': 0, 'top_word': None, 'unique_count': 0}.
def count_words(text):
freq = {}
for word in text.split():
freq[word] = freq.get(word, 0) + 1
return freq
def top_word(freq):
best = None
for word, count in freq.items():
if best is None or count > freq[best]:
best = word
return best
def analyze_with_stats(text):
# your code here
pass
count_words(text) gives you the frequency dict; sum(freq.values()) is the total.
unique_count is just how many distinct keys the dict has: len(freq).
Empty text gives an empty dict, and the code above already returns None/0 for that.
def count_words(text):
freq = {}
for word in text.split():
freq[word] = freq.get(word, 0) + 1
return freq
def top_word(freq):
best = None
for word, count in freq.items():
if best is None or count > freq[best]:
best = word
return best
def analyze_with_stats(text):
freq = count_words(text)
return {
'total': sum(freq.values()),
'top_word': top_word(freq),
'unique_count': len(freq),
}
Recap
- Decompose a project into single-purpose functions that each do one job.
- Use a dict with
getfor frequency tables — it is fast and readable. - Compose small functions into larger ones; the final code reads like the spec.
- Handle edge cases like empty strings explicitly.
- Test each piece in isolation before wiring them together.
You have now written a real tool. The same process — decompose, implement, test, compose — scales to programs thousands of lines long.