Skip to content
wannacode

Text up close

Problem

The report that drags

You already know that title.upper() does nothing on its own: a string is immutable, and the method returns a new string. This has a cost, and you can see it in a loop.

The online store's program builds the daily report line by line:

report = ""
for i in range(1, 4):
report += f"Order {i}: €12.50\n"
print(report, end="")
Output
Order 1: €12.50
Order 2: €12.50
Order 3: €12.50

With three orders it is instant. On Saturday there are 50,000 orders, and the same loop takes about 1 second. We measured it on the machine where we check the code of the lessons.

Why? Each report += ... does not add to the old string. It makes a new string, copies the whole report so far into it, and only then glues on the new line. Near the end, every iteration copies almost a million characters.

The idea

A list of pieces and join

Python has no string that changes in place. So you collect the pieces in a list, which does change in place. At the end, join glues them into one string and copies each character only once:

parts = []
for i in range(1, 4):
parts.append(f"Order {i}: €12.50")
report = "\n".join(parts)
print(report)
Output
Order 1: €12.50
Order 2: €12.50
Order 3: €12.50
  • parts = [] is an empty list. append adds a piece to the end without touching the others.
  • "\n".join(parts) makes one string from all the pieces, with the separator "\n" between them. The separator is the text before .join. To glue with no separator, you write "".join(parts).
  • The new line between the pieces comes from the separator, so here the pieces have no \n at the end.

With 50,000 orders this version takes about 6 milliseconds instead of 1 second.

Here is what happens in memory with each approach:

report = ""
for i in range(1, 4):
report += f"Order {i}: €12.50\n"
parts = []
for i in range(1, 4):
parts.append(f"Order {i}: €12.50\n")
joined = "".join(parts)
print(len(report), len(joined))
print(report == joined)
Step by step, in words
  1. report points to an empty string.
  2. The first += makes a new str with one line: 16 characters.
  3. The second += makes one more str. It copies the old line into it (16 characters) and only then glues on the new one. Nobody needs the old string anymore.
  4. The third copies 32 characters. Every line adds to the copying: with 50,000 lines it is almost a million characters per iteration.
  5. parts is an empty list: the pieces will go in it.
  6. append puts each piece in the list in place. The old pieces are not copied, and the list stays the same.
  7. join glues the pieces once: 48 characters. The text is the same. With += another 48 old characters were copied, and with join not a single one.
More precisely, if you are curious
  • The same += loop inside a function takes about 4 milliseconds. Python (CPython, the usual version) cheats: if nothing else points to the old string, it extends it in place. At the top level of the file that does not happen. The trick is not a rule of the language, so do not rely on it: for many pieces, use join.
  • A single expression like f"Order {i}: {price}" is one new string. The problem is only += in a loop, where every iteration is a separate new string.

See it

Changing with slices

A string does not change in place, so every change is a new string, built from parts of the old one. Here is a card number for printing on a receipt:

card = "4532015112830366"
card = card[:12] + " " + card[12:]
card = card[:8] + " " + card[8:]
card = card[:4] + " " + card[4:]
print(card)
card = "**** **** ****" + card[14:]
print(card)
phone = "0888-123-456"
phone = phone[:8] + phone[9:]
phone = phone[:4] + phone[5:]
print(phone)
print("stressed"[::-1])
Output
4532 0151 1283 0366
**** **** **** 0366
0888123456
desserts

card[:12] is a slice: the characters from the start up to index 12, without it. card[12:] is from index 12 to the end.

You want to You write
insert x before index i s[:i] + x + s[i:]
replace the characters from a to b (without b) with x s[:a] + x + s[b:]
remove the character at index i s[:i] + s[i + 1:]
remove the characters from a to b (without b) s[:a] + s[b:]
reverse the text, back to front s[::-1]
get the length, a character len(s), s[i]

The spaces in the card number go in from the back: first at 12, then at 8, then at 4. If you start at 4, the inserted space shifts everything after it, and 8 is no longer the place you want. The dashes in the phone number are removed from the back first for the same reason.

Try it

Two names, one object

What will the program print?

note = ["Table 4"]
copy = note
copy.append(", 2 coffees")
print("".join(note))
text = "Table 4"
other = text
other += ", 2 coffees"
print(text)
Answer

The idea

In alphabetical order: < and >

== only tells you "are they the same". To sort, you need more: which string comes before the other. In Python you compare strings directly with < and >:

print("Andreev" < "Borisov")
print("Borisov" == "Borisov")
print("ivanova" < "Ivanova")
print("ivanova".casefold() == "Ivanova".casefold())
Output
True
True
False
True
  • a < b is True if a comes before b;
  • a == b: they are the same;
  • a > b: a comes after b.

Capital letters come before lowercase ones, so "ivanova" < "Ivanova" is False. casefold() makes the text lowercase for comparing, no matter how it is written. With > you sort a list of names, for example with the bubble sort from the lesson on arrays:

names = ["Petrova", "Andreev", "Ivanova", "Borisov"]
for i in range(len(names) - 1):
for j in range(len(names) - 1 - i):
if names[j] > names[j + 1]:
tmp = names[j]
names[j] = names[j + 1]
names[j + 1] = tmp
print(", ".join(names))
Output
Andreev, Borisov, Ivanova, Petrova

", ".join(names) glues the elements of the list with a separator between them.

More precisely, if you are curious
  • < and > compare character by character, by their codes in the character table. ord gives the code of a character: "i" and "I" differ by 32.
  • Python has no method that returns a negative number, 0 or a positive one, like compareTo in Java. You do not need it: <, == and > tell you the same thing.
  • names.sort() sorts the list by the same rule, without writing the bubble sort.
print(ord("i") - ord("I"))
names = ["Petrova", "Andreev", "Ivanova", "Borisov"]
names.sort()
print(", ".join(names))
Output
32
Andreev, Borisov, Ivanova, Petrova

Try it

Invoice 10 or 9?

The invoice numbers are text. Which one is earlier?

What will the program print?

a = "INV-10"
b = "INV-9"
print(a < b)
print(int(a[4:]) < int(b[4:]))
Answer

The idea

Search, cut, put together

Here is a line from a contacts file: name, email and city, separated with " ; ", with extra spaces at the ends:

line = " Elena Koleva ; elena.koleva@company.com ; Plovdiv "
parts = line.strip().split(" ; ")
email = parts[1]
at = email.find("@")
print(email[:at])
print(email[at + 1:])
print(email.find(".", at))
print(" | ".join(parts))
print("=" * 20)
Output
elena.koleva
company.com
20
Elena Koleva | elena.koleva@company.com | Plovdiv
====================
  • strip() removes the spaces at the front and at the back, and also the "wide" spaces from other languages.
  • find("@") gives the index of the first occurrence, and find(".", at) starts searching only from index at on. rfind searches from the back. If there is no such character, find returns -1.
  • email[:at] is a slice: from the start up to at, without it. email[at + 1:] is from at + 1 to the end. join is the opposite of split.
  • "=" * 20 repeats the text 20 times.

Empty and "blank" are not the same thing:

empty = ""
spaces = " "
print(empty == "", spaces == "")
print(empty.strip() == "", spaces.strip() == "")
Output
True False
True True

== "" is true only for text of length 0. strip() == "" is also true when there are only spaces. To check a field someone filled in, you usually want the second one.

More precisely, if you are curious
  • index searches like find, but when there is no such character it stops the program with ValueError instead of returning -1.
  • isspace() looks like the check you need, but for empty text it is False: it is true only when there is at least one character and all of them are spaces.
email = "elena.koleva@company.com"
print(email.find("#"))
print("".isspace(), " ".isspace())
try:
email.index("#")
except ValueError as e:
print(e)
Output
-1
False True
substring not found

The idea

Numbers and text

Numbers go into text and come out of text:

code = int("0042")
price = float("2.40")
label = str(code) + "-" + str(price)
print(label)
Output
42-2.4

You already know int() and float(). str() is the opposite: it makes text from a number or a bool.

For a table with columns you need an f-string with a width for each column. Here is a receipt from a café:

names = ["Coffee", "Croissant", "Mineral water"]
prices = [2.40, 3.10, 1.50]
qty = [2, 1, 3]
total = 0
for i in range(len(names)):
amount = prices[i] * qty[i]
total += amount
print(f"{names[i]:<16}{qty[i]:2d} x {prices[i]:5.2f} = {amount:6.2f}")
print("-" * 35)
print("{:<29}€{:5.2f}".format("Total", total))
Output
Coffee 2 x 2.40 = 4.80
Croissant 1 x 3.10 = 3.10
Mineral water 3 x 1.50 = 4.50
-----------------------------------
Total €12.40
  • {names[i]:<16} is text in 16 characters, aligned left. {prices[i]:5.2f} is a number in 5 characters with 2 after the point, aligned right. {qty[i]:2d} is a whole number in 2 characters.
  • "template".format(...) does the same with empty {} in the template: the values come from outside, in order. This is handy when the template is ready beforehand and the values come later.
More precisely, if you are curious
  • The width is at least that many characters. Longer text is not cut. It pushes the column to the right.
  • float("2,40") stops with ValueError: the decimal mark is always a point. And the f-string always writes a point, no matter what the computer's settings are.
try:
float("2,40")
except ValueError as e:
print(e)
print(f"{2.4:.2f}")
Output
could not convert string to float: '2,40'
2.40

Try it

Find the bug

"Kayak" reads the same backwards, but the program says "kayak is not a palindrome". Which line has the bug?

Pick the line with the bug.

Exercise

Text you put together

Solve the tasks in your editor. Do not print prompts.

1. Palindrome. Read a line and say whether it is a palindrome, ignoring capital letters, spaces, commas and dashes. This time reverse the text with the slice [::-1] instead of a loop.

Input
Never odd or even
Output
Palindrome
Input
Never odd, never even
Output
Not a palindrome

2. A receipt with columns. The first line has the number of items. Each line after it has a name, a price and a quantity, separated with ;. Print a receipt with aligned columns and the total at the end.

Input
3
Bread;1.20;2
Cheese;9.80;1
Eggs;0.35;10
Output
Bread 2 x 1.20 = 2.40
Cheese 1 x 9.80 = 9.80
Eggs 10 x 0.35 = 3.50
------------------------------------
Total 15.70

3. Words in reverse. Read a sentence and print its words in reverse order. There may be more than one space between the words, and the result must have exactly one. Put it together with a list and join.

Input
Tomorrow at 9 there is a meeting
Output
meeting a is there 9 at Tomorrow

split() with no argument splits on every run of spaces and gives no empty parts. Try split(" ") too, to see the difference.

More tasks

  • Initials. Read a full name ("Maria Ivanova Petrova") and print the initials with dots: "M. I. P." Put them together with a list and join.
  • Hidden email. From elena.koleva@company.com make e***********@company.com: the first letter, then as many asterisks as there are other characters before the @. Use find and *.
  • A tour of str. Create one string with quotes ("Practice text") and another glued from two parts with "".join(["Practice", " text"]). Compare them with == and with is, take the slice [0:8], split the second one on the space and print the parts, turn "100" into a number and print it with f"Value: {...}", and finally add " and more" to a list with the first string and glue them with join. Predict every line before you run the program.
  • Sort ignoring case. Change the bubble sort from the comparing card to compare the casefold() of the two strings, and try it with "ivan", "Boris" and "ana".

Test

Check yourself

1. Why is report += line slow in a loop with 50,000 iterations at the top level of the file?
2. What does "Borisov" > "Andreev" give?
3. a = ["12"]; b = a; b.append("3"). What does "".join(a) give?
4. parts is a list of strings. How do you make one string from it?
5. What is the difference between f"{total:6.2f}" and "{:6.2f}".format(total)?

Made by Ivo Gergov as part of Evo Tech's campaign for accessible programming education ·