Just as developing real proficiency in a natural language requires regular active practice, the same is true when learning a programming language. It is not enough to read about the language’s rules and study existing code; you also need to write as much code as possible yourself.
Obviously, if programming is someone’s profession, this kind of practice happens naturally. But for those learning a programming language whose daily activities do not require them to develop new programs, it is a good idea to come up with exercises of their own. These exercises can first be worked out conceptually and then implemented in the programming language being learned.
It is always a good idea to start with simple tasks so that the satisfaction of successfully solving them encourages you to take on increasingly difficult challenges. If you cannot think of a suitable exercise, another useful approach is to try creating your own versions of functions, methods, or classes that Python already provides. The goal is for our implementation to provide the same functionality as the existing one.
One advantage of this approach is that it is relatively easy to check whether our solution is correct. At the same time, we will discover that even solving a seemingly simple task like this requires quite a bit of theoretical and practical knowledge of the language. This turns what we have learned into active, usable knowledge. The mistakes we are almost certain to make along the way, and the process of correcting them, reinforce this knowledge even further.
With this in mind, let us practice by reimplementing, for example, the find() and count() string methods. In other words, we will write our own functions that provide the same functionality as these methods.
The exact behavior of these methods, the arguments they accept, and their return values can be found in the official Python documentation, or with a somewhat more detailed explanation and examples, in the e-book Python Knowledge Building Step by Step: From the Basics to The First Desktop Application. In brief, however, their purpose and usage are as follows:
If a variable named text refers to a string, the text.find(sub, start, end) method returns the index of the first occurrence of the substring specified by the sub argument. The search starts at the beginning of text. If sub is not found in the string, the method returns -1.
The text.count(sub, start, end) method accepts arguments similar to those accepted by find(). It returns the number of occurrences of the character sequence specified by sub.
For both methods, the optional start and end arguments can be used to limit the search to the range between the specified indices. If they are not provided, the entire string is searched.
Since negative indices can also be used for the start and end arguments, it is worth recalling how negative indexing works with sequences before implementing our own functions. The image below illustrates this:

You can see below the definitions of the find() and count() functions we implemented. The comments help explain how they work.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 |
# The functions below mimic the behavior of the str.find() and str.count() methods, except when the # search term is an empty string. def find(s: str, sub: str, start: int | None = None, end: int | None = None) -> int: """Return the lowest index where sub is found in s[start:end]. Raise ValueError if sub is empty and return -1 if sub is not found. """ if not sub: raise ValueError("The search string must not be empty.") # Normalize start and end indices according to slice notation. def normalize_index(index: int) -> int: """Normalize the specified index to the range from 0 to len(s).""" if 0 <= index <= len(s): return index # If the index is negative, first try to convert it to a non-negative index by adding len(s). Then: # - if it is still negative, use 0. # - if it is greater than len(s), use len(s). if index < 0: index += len(s) if index < 0: index = 0 elif index > len(s): index = len(s) return index _start = 0 if start is None else normalize_index(start) _end = len(s) if end is None else normalize_index(end) # The normalization above can also be expressed more concisely, though less readably, as: # _start = 0 if start is None else min(len(s), max(0, start if start >= 0 else len(s) + start)) # _end = len(s) if end is None else min(len(s), max(0, end if end >= 0 else len(s) + end)) # Iterate over the indices of s as long as a slice of the length of sub fits entirely within the search range. # That is why the stop argument of range() is _end - len(sub) + 1. for i in range(_start, _end - len(sub) + 1): _slice = s[i:i + len(sub)] # If the slice matches the search string, return the current index. if _slice == sub: return i # If no substring matching the search string was found in s within the # specified index range, return -1. return -1 def count(s: str, sub: str, start: int | None = None, end: int | None = None) -> int: """Return the number of non-overlapping occurrences of sub in s[start:end]. Return 0 if sub is not found. Raise ValueError if sub is empty. """ match_count = 0 # The current value indicates the number of matches found so far. # Keep searching between the start and end indices as long as the search string can be found. # Use our custom find() function for the search. i = start # Set the starting index of the search to the beginning of the specified range. while (i := find(s, sub, i, end)) != -1: # If a match is found, increment the match counter. match_count += 1 # Advance the next starting index by the length of the search string rather than # by one, so that overlapping occurrences are not counted. i += len(sub) return match_count |
The function bodies are not particularly complicated, yet even in a case like this, we need to be familiar with quite a few language features and constructs in order to design and write the appropriate code. We use a conditional expression, for and while loops, an assignment expression, conditional branching, slicing, augmented assignment, and exception handling.
But before we can use any of these language features, we first have to think about how to solve the problem and which algorithm to use. In fact, it is the algorithm that determines which language features and constructs we will subsequently use to solve the problem.
We test our functions by specifying various start and end indices and comparing their return values with those of the corresponding built-in methods. If the results differ, our function is incorrect and the test produces an error.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
# TEST texts = ['', 'a', 'aaaa', 'abcdef_dehi_depq'] # The strings in which we search. patterns = ['a', 'aa', 'e', 'de', 'de' * 100] # The search strings. for text in texts: for pattern in patterns: for start_index, end_index in [(None, None), (3, None), (4, None), (5, None), (8, None), (-7, None), (None, 4), (None, 5), (None, 8), (None, 20), (5, -7), (0, 0), (1, 1), (-16, -1), (-1, -16), (-160, -1), (0, 160)]: assert text.find(pattern, start_index, end_index) == find(text, pattern, start_index, end_index) assert text.count(pattern, start_index, end_index) == count(text, pattern, start_index, end_index) # An empty search string is intentionally rejected. try: find('abcdef', '', None, None) except ValueError: pass else: assert False try: count('abcdef', '', None, None) except ValueError: pass else: assert False # Result: No error messages are printed, so the custom functions produce the same results as the # built-in methods for all tested cases. |
Since none of our test cases produced an error, it appears that our functions behave as expected.