Lesson 05 / 16
Working with Strings
Because a string is immutable, every producing method returns a new object: joining seven parts with `+` builds 7 intermediate objects, `join` builds 1, and eight formatting notations call 3 distinct special methods.
Contents
The previous lesson measured that the + operator calls __add__, the += notation
calls __iadd__, and said that immutable types cannot define __iadd__. A string is
exactly such a type; every operation on it derives from this single constraint.
This lesson’s question is not the list of string methods. The question is: how does a type being immutable force the methods that can be defined on it, and how much does that forcing cost, counted?
There Is No Such Thing as Writing in Place
There is no way to change a single character of a string. The syntax exists — writing with square brackets — but no method backing it is defined.
- LF31 — A single starting string is used in the measurement, and four operations
are run on it in sequence; at every step, whether the result is the same object as
the previous one is asked with
is. No identity number is printed. - LF32 — The four chosen operations really change the content. Whether an operation
whose result is byte-for-byte identical to its input (
stripon a string with no whitespace, for instance) returns the same object or not is implementation-dependent and is not brought into the measurement. - LF33 — The methods in the second table do not transform the string, they ask it a question; what is measured is the type of the returned value.
"""A string is immutable: producing methods return a new object, query methods return another type.""" RAW = " 12; north; 18 " try: RAW[0] = "x" except TypeError as e: print("writing in place to a string:", type(e).__name__) STEPS = (("start", lambda s: s), ("strip()", lambda s: s.strip()), ("upper()", lambda s: s.upper()), ("replace(';', ',')", lambda s: s.replace(";", ",")), ("split(',')", lambda s: s.split(","))) print(f"\n{'step':<20s} {'type':<5s} {'same as previous':>17s} value") previous = RAW for name, operation in STEPS: current = operation(previous) print(f"{name:<20s} {type(current).__name__:<5s} " f"{str(current is previous):>17s} {current!r}") previous = current print("starting string after the measurement:", repr(RAW)) TEXT = "12; north; 18" QUERIES = (("startswith('12')", lambda s: s.startswith("12")), ("find('north')", lambda s: s.find("north")), ("count(';')", lambda s: s.count(";")), ("isdigit()", lambda s: s.isdigit()), ("len(s)", lambda s: len(s))) print(f"\n{'query method':<20s} {'return type':<5s} value") for name, function in QUERIES: value = function(TEXT) print(f"{name:<20s} {type(value).__name__:<5s} {value!r}") print("string after the queries:", repr(TEXT))
writing in place to a string: TypeError
step type same as previous value
start str True ' 12; north; 18 '
strip() str False '12; north; 18'
upper() str False '12; NORTH; 18'
replace(';', ',') str False '12, NORTH, 18'
split(',') list False ['12', ' NORTH', ' 18']
starting string after the measurement: ' 12; north; 18 '
query method return type value
startswith('12') bool True
find('north') int 4
count(';') int 2
isdigit() bool False
len(s) int 13
string after the queries: '12; north; 18'
The first line states the constraint by name: writing with square brackets gives
TypeError, because the string type has no method backing that operation. It is the
exact same result taken for the division operator in the previous lesson — the defect
is not a prohibition, it is an absence.
In all four steps of the chain, the “same as previous” column reads False. Every
method built a new object and did not touch its input; the last line confirms this —
after four operations, the starting string still stands as it was. It matters to see
that this is not a choice. A string method could not mutate its input even if it
wanted to; the only thing it can do is build and return a new object. Immutability
writes the method’s contract on its own, which is why the string type carries no
method that works in place, like a list’s append or sort.
A direct rule follows from this: if a string method’s return value is not used,
nothing has happened. The line text.strip() written alone builds a new object, does
not bind it to a name, and discards it; text stays as it was. This is the single most
common silent defect in code written by someone new to the language.
The lower table sets apart the second class of method. startswith, find, count,
isdigit, and len do not produce a string; they give a boolean, a position, a count,
in turn. These do not transform the string, they ask it a question — and so they sit
outside the new-object discussion. The find method returns -1 when it cannot find
the piece sought; it does not raise an exception. The index method, which does the
same job, raises ValueError when it cannot find it. The choice between the two
methods is not a matter of style: when not finding something is an expected situation,
the notation that works with a return value is chosen; when it is a situation that
should not happen, the notation that raises an exception is chosen. The counterpart of
this same distinction on the conversion side is the next lesson’s subject.
A String Is a Sequence
In the third lesson’s built-in type table, str filled all five columns: its length
could be asked, a loop could be built over it, membership could be tested, it could be
sliced, and it could be added. This means a string is not only text but also a
sequence — and being a sequence has two observable consequences.
- LF34 — In the slicing measurement, every result is compared to the source string
with
is; a slice that asks for the whole source is not used, because whether it returns the same object is implementation-dependent. - LF35 — In the escaping measurement, the character count of the three written notations is read; the number shows how many characters the notation produces, not how many characters were written in the source.
"""A string is a sequence: slicing, iteration, and membership rest on the same protocols.""" TEXT = "12; north; 18" SLICES = (("[0]", lambda s: s[0]), ("[-1]", lambda s: s[-1]), ("[4:9]", lambda s: s[4:9]), ("[:2]", lambda s: s[:2]), ("[::-1]", lambda s: s[::-1]), ("[::4]", lambda s: s[::4])) print(f"{'slice':<8s} {'same object as source':>22s} value") for name, function in SLICES: piece = function(TEXT) print(f"{name:<8s} {str(piece is TEXT):>22s} {piece!r}") print(f"\nlength {len(TEXT)}, first four items {list(TEXT)[:4]}") print("membership:", "'nor' in text ->", "nor" in TEXT, "| 'rth' in text ->", "rth" in TEXT, "| 'thr' in text ->", "thr" in TEXT) print("same question on a list:", "[1, 2] in [1, 2, 3] ->", [1, 2] in [1, 2, 3], "| 2 in [1, 2, 3] ->", 2 in [1, 2, 3]) ESCAPES = (("'a\\tb'", "a\tb"), ("r'a\\tb'", r"a\tb"), ("'''two\\nlines'''", "two\nlines")) print(f"\n{'notation':<18s} {'characters':>10s} actual content") for name, value in ESCAPES: print(f"{name:<18s} {len(value):10d} {value!r}")
slice same object as source value [0] False '1' [-1] False '8' [4:9] False 'north' [:2] False '12' [::-1] False '81 ;htron ;21' [::4] False '1nh8' length 13, first four items ['1', '2', ';', ' '] membership: 'nor' in text -> True | 'rth' in text -> True | 'thr' in text -> False same question on a list: [1, 2] in [1, 2, 3] -> False | 2 in [1, 2, 3] -> True notation characters actual content 'a\tb' 3 'a\tb' r'a\tb' 4 'a\\tb' '''two\nlines''' 9 'two\nlines'
All six slices give an object separate from the source. Slicing is the __getitem__
call measured in the first lesson, and it carries the same rule on a string: a new
object is built, the source stays as it is. A negative position counts from the end,
the third number sets the step; the [::-1] notation reversing the text is not a
separate method’s job, it is the consequence of the step being negative.
The iteration line shows that a string’s items are characters: a thirteen-character text gives thirteen items, and every item is itself a string. Python has no separate type representing a single character; a character is a string whose length is one.
The membership line, in turn, sets a string apart from other sequences. "nor" in text
gives True — even though what is being asked for is not one item but a sequence
of items. When the same question is asked of a list, [1, 2] in [1, 2, 3] gives
False, because in on a list only looks for an item. A string’s __contains__
method is deliberately written differently: it searches for a substring. The syntax is
the same across the two types, the contract is not.
The last table sets apart notation forms. Backslash escape sequences are written with
two characters in the source but produce a single character: the notation
'a\tb' is a three-character string. In the raw form, prefixed with r, the escape is
not resolved, and the same source gives four characters. The triple-quoted form carries
a line ending as-is. In all three, the character count written in the source and the
length of the string produced are different; this is the reason a raw notation is
preferred when writing a file path or a pattern string.
The Cost of Concatenation
Immutability’s measurable cost shows up when building text piece by piece. Because
every + operation builds a new object, a string grown inside a loop leaves behind
intermediate objects built and discarded along the way.
- LF36 — Two methods build the same text from the same seven parts; what is
measured is the number of intermediate objects built along the way, not the time
elapsed. In the incremental method, every step’s result is compared to the previous
one with
isand counted; because joining builds a single object, its count is written directly.
"""The cost of concatenation: how many intermediate objects get built?""" PARTS = ("north", "; ", "12", "; ", "slope", "; ", "18") def incremental(parts): text = "" intermediate = 0 for p in parts: previous = text text = text + p # a new string on every step intermediate += text is not previous return text, intermediate def joining(parts): text = "".join(parts) # one object in one step return text, 1 print(f"{'method':<18s} {'parts':>5s} {'intermediate objects':>21s} result") for name, function in (("incrementing with +", incremental), ("with join", joining)): result, intermediate = function(PARTS) print(f"{name:<18s} {len(PARTS):5d} {intermediate:21d} {result!r}") print("are the two results equal:", incremental(PARTS)[0] == joining(PARTS)[0])
method parts intermediate objects result incrementing with + 7 7 'north; 12; slope; 18' with join 7 1 'north; 12; slope; 18' are the two results equal: True
Seven parts build 7 intermediate objects with the incremental method; join builds
1. The results are byte-for-byte equal. The pattern grows linearly with the part
count: when the part count doubles, the number of objects the incremental method
builds doubles too, while join’s stays at 1.
The reason for the difference is not that one method is written better, it is that
it already has the information. join sees all the parts up front, can compute the
total length, and builds the result in one pass. The + operator, by contrast, only
sees two parts on each call; not knowing what comes at the next step, it has to fully
build that step’s result.
The same reasoning also explains why collecting into a list and joining at the end is a common pattern: a list is a mutable type, appending to it builds no new object. The text is produced only once, at the very end.
The join method has one condition that is easy to overlook: all the parts have to
be strings. If a number slips in among them, the method does not convert it to text
on its own — it raises TypeError. The string the method is called on is not one of
the parts either, it is the separator: in the notation "; ".join(...), the text
placed in between is the string the call is made on.
Which Protocol Formatting Calls
There is more than one notation for producing text, and they look alike. The course’s measure applies here too: each notation calls a different method, and the text each produces can therefore diverge.
- LF37 — The measured object takes part in all three text protocols:
__str__,__repr__, and__format__. All three produce different text, so which one was called can also be read from the output. - LF38 — The
__format__method itself calls no other protocol; if the spec is empty it builds its own text, if it is filled it applies the spec to the number it carries. This way, the log shows only what the notation called.
"""Which special method do formatting notations call?""" LOG = [] def record(name): LOG.append(name) class Measurement: """Takes part in all three text protocols and logs each participation.""" def __init__(self, value=12): self.value = value def __str__(self): record("__str__") return f"measurement {self.value}" def __repr__(self): record("__repr__") return f"Measurement({self.value})" def __format__(self, spec): record("__format__") return format(self.value, spec) if spec else f"measurement {self.value}" FORMS = ( ('f"{n}"', lambda n: f"{n}"), ('f"{n:>6}"', lambda n: f"{n:>6}"), ('f"{n!s}"', lambda n: f"{n!s}"), ('f"{n!r}"', lambda n: f"{n!r}"), ('"{}".format(n)', lambda n: "{}".format(n)), ('"%s" % n', lambda n: "%s" % n), ('str(n)', lambda n: str(n)), ('repr(n)', lambda n: repr(n)), ) unique = set() print(f"{'notation':<16s} {'protocol':<12s} text produced") for name, function in FORMS: LOG.clear() text = function(Measurement()) unique.update(LOG) print(f"{name:<16s} {' '.join(LOG):<12s} {text!r}") print(f"\n{len(FORMS)} notations, distinct protocols {len(unique)}: " f"{', '.join(sorted(unique))}")
notation protocol text produced
f"{n}" __format__ 'measurement 12'
f"{n:>6}" __format__ ' 12'
f"{n!s}" __str__ 'measurement 12'
f"{n!r}" __repr__ 'Measurement(12)'
"{}".format(n) __format__ 'measurement 12'
"%s" % n __str__ 'measurement 12'
str(n) __str__ 'measurement 12'
repr(n) __repr__ 'Measurement(12)'
8 notations, distinct protocols 3: __format__, __repr__, __str__
Eight notations spread across 3 distinct protocols. The curly-brace notation and
the format method call __format__; the percent-sign notation and the str built-in
call __str__; the repr built-in and the !r marker call __repr__. The same
object gives three different pieces of text across three notations, and none stands in
for another.
Two points deserve a closer read. First, __format__ also receives the spec: on
the second line, the spec >6 was passed to the method, and the method applied it to
the number it carries. Alignment, digit count, and width rules are handed to the
object itself this way. Second, the markers are not a shortcut, they are a protocol
selector: the !r notation switches the method called from __format__ to
__repr__.
The curly-brace notation has one more distinct feature: what goes inside the braces
does not have to be a value, it can be an expression, and the expression is
evaluated right there. This is why the second lesson’s statement/expression distinction
applies here too; an assignment cannot be placed inside the braces, because an
assignment is not an expression. The format method, by contrast, takes values as
arguments and keeps the spec separate.
The three methods’ contracts differ too. __str__ gives readable text; __repr__
gives text that identifies the object, usually written in a form that could rebuild
it — the measurement’s 'Measurement(12)' is an example of this. If an object defines
only __repr__, a str call falls to it too; this is the source of the behavior seen
on the bare object in the first lesson.
Summary
- Writing to a single character of a string gives
TypeError; no method backs that operation. - In all four producing methods, the result is a new object and the input stays as it is; if the return value is not used, nothing has happened.
- Query methods do not produce a string, they give a response of another type;
findreturns-1when it cannot find something, it does not raise an exception. - A string is a sequence: slicing calls
__getitem__and gives a new object, its items are characters, and unlike a list, theintest looks for a substring. - Concatenating seven parts incrementally builds 7 intermediate objects,
joinbuilds 1; the difference comes fromjoinseeing all the parts up front. - Eight formatting notations spread across 3 distinct protocols:
__format__,__str__, and__repr__; markers like!rchange which method gets called.
Next Step
This lesson measured that the notation str(n) calls __str__ — meaning turning an
object into text is itself a protocol call. The same question can be asked in the
reverse direction: what does int("12") call, what does float(n) ask of an object,
and what happens when a conversion with no counterpart, like int("north"), is
requested? The next lesson counts which special methods conversion rests on, and shows
that a failed conversion produces not a return value but an exception.
To keep your progress and take notes, Log in
My notes
Log in to take notes.