|
| 1 | +# About |
| 2 | + |
| 3 | +Recursion is a way to repeatedly execute code inside a function through the function calling itself. |
| 4 | +Functions that call themselves are know as _recursive_ functions. |
| 5 | +Recursion can be viewed as another way to loop/iterate. |
| 6 | +And like looping, a Boolean expression or `True/False` test is used to know when to stop the recursive execution. |
| 7 | +_Unlike_ looping, recursion without termination in Python cannot not run infinitely. |
| 8 | +Values used in each function call are placed in their own frame on the Python interpreter stack. |
| 9 | +If the total amount of function calls takes up more space than the stack has room for, it will result in an error. |
| 10 | + |
| 11 | +## Looping vs Recursive Implementation |
| 12 | + |
| 13 | +Looping and recursion may _feel_ similar in that they are both iterative. |
| 14 | +However, they _look_ different, both at the code level and at the implementation level. |
| 15 | +Looping can take place within the same frame on the call stack. |
| 16 | +This is usually managed by updating one or more variable values to progressively maintain state for each iteration. |
| 17 | +This is an efficient implementation, but it can be somewhat cluttered when looking at the code. |
| 18 | + |
| 19 | +Recursion, rather than updating _variable state_, can pass _updated values_ directly as arguments to the next call (iteration) of the same function. |
| 20 | +This declutters the body of the function and can clarify how each update happens. |
| 21 | +However, it is also a less efficient implementation, as each call to the same function adds another frame to the stack. |
| 22 | + |
| 23 | +## Recursion: Why and Why Not? |
| 24 | + |
| 25 | +If there is risk of causing a stack error or overflow, why would anyone use a recursive strategy to solve a problem? |
| 26 | +_Readability, traceability, and intent._ |
| 27 | +There may be situations where a solution is more readable and/or easier to reason through when expressed through recursion than when expressed through looping. |
| 28 | +There may also be program constraints with using/mutating data, managing complexity, delegating responsibility, or organizing workloads. |
| 29 | +Problems that lend themselves to recursion include complex but repetitive problems that grow smaller over time, particularly [divide and conquer][divide and conquer] algorithms and [cumulative][cumulative] algorithms. |
| 30 | +However, due to Python's limit for how many frames are allowed on the stack, not all problems will benefit from a fully recursive strategy. |
| 31 | +Problems less naturally suited to recursion include ones that have a steady state, but need to repeat for a certain number of cycles, problems that need to execute asynchronously, and situations calling for a great number of iterations. |
| 32 | + |
| 33 | +## Looping vs Recursive Strategy: Indira's Insecurity |
| 34 | + |
| 35 | +Indira has her monthly social security auto-deposited in her bank account on the **_second Wednesday_** of every month. |
| 36 | +Indira is concerned about balancing her check book. |
| 37 | +She is afraid she will write checks before her money is deposited. |
| 38 | +She asks her granddaughter Adya to give her a list of dates her money will appear in her account. |
| 39 | + |
| 40 | +Adya, who is just learning how to program in Python, writes a program based on her first thoughts. |
| 41 | +She wants to return a `list` of the deposit dates so they can be printed. |
| 42 | +She wants to write a function that will work for _any year_. |
| 43 | +In case the schedule changes (_or in case other relatives want Adya to calculate their deposit schedules_), she decides the function needs to take an additional parameter for the _weekday_. |
| 44 | +Finally, Adya decides that the function needs a parameter for _which weekday_ of the month it is: the first, second, etc. |
| 45 | +For all these requirements, she decides to use the `date` class imported from `datetime`. |
| 46 | +Putting all of that together, Adya comes up with: |
| 47 | + |
| 48 | +``` |
| 49 | +from datetime import date |
| 50 | +
|
| 51 | +
|
| 52 | +def paydates_for_year(year, weekday, ordinal): |
| 53 | + """Returns a list of the matching weekday dates. |
| 54 | + |
| 55 | + Keyword arguments: |
| 56 | + year -- the year, e.g. 2022 |
| 57 | + weekday -- the weekday, e.g. 3 (for Wednesday) |
| 58 | + ordinal -- which weekday of the month, e.g. 2 (for the second) |
| 59 | + """ |
| 60 | + output = [] |
| 61 | +
|
| 62 | + for month in range(1, 13): |
| 63 | + for day_num in range(1, 8): |
| 64 | + if date(year, month, day_num).isoweekday() == weekday: |
| 65 | + output.append(date(year, month, day_num + (ordinal - 1) * 7)) |
| 66 | + break |
| 67 | + return output |
| 68 | +
|
| 69 | +# find the second Wednesday of the month for all the months in 2022 |
| 70 | +print(paydates_for_year(2022, 3, 2)) |
| 71 | +``` |
| 72 | + |
| 73 | +This first iteration works, but Adya wonders if she can refactor the code to use fewer lines with less nested looping. |
| 74 | +She's also read that it is good to minimize mutating state, so she'd like to see if she can avoid mutating some of her variables such as `output`, `month`, and `day_num` . |
| 75 | + |
| 76 | +She's read about recursion, and thinks about how she might change her program to use a recursive approach. |
| 77 | +The variables that are created and mutated in her looping function could be passed in as arguments instead. |
| 78 | +Rather than mutating the variables _inside_ her function, she could pass _updated values as arguments_ to the next function call. |
| 79 | +With those intentions she arrives at this recursive approach: |
| 80 | + |
| 81 | +``` |
| 82 | +from datetime import date |
| 83 | +
|
| 84 | +
|
| 85 | +
|
| 86 | +def paydates_for_year_rec(year, weekday, ordinal, month, day_num, output): |
| 87 | + """Returns a list of the matching weekday dates |
| 88 | + |
| 89 | + Keyword arguments: |
| 90 | + year -- the year, e.g. 2022 |
| 91 | + weekday -- the weekday, e.g. 3 (for Wednesday) |
| 92 | + ordinal -- which weekday of the month, e.g. 2 (for the second) |
| 93 | + month -- the month currently being processed |
| 94 | + day_num -- the day of the month currently being processed |
| 95 | + output -- the list to be returned |
| 96 | + """ |
| 97 | + if month == 13: |
| 98 | + return output |
| 99 | + if date(year, month, day_num).isoweekday() == weekday: |
| 100 | + return paydates_for_year_rec(year, weekday, ordinal, month + 1, 1, output |
| 101 | + + [date(year, month, day_num + (ordinal - 1) * 7)]) |
| 102 | + return paydates_for_year_rec(year, weekday, ordinal, month, day_num + 1, output) |
| 103 | +
|
| 104 | + # find the second Wednesday of the month for all the months in 2022 |
| 105 | + print(paydates_for_year_rec(2022, 3, 2, 1, 1, [])) |
| 106 | + |
| 107 | +``` |
| 108 | + |
| 109 | +Adya is happy that there are no more nested loops, no mutated state, and 2 fewer lines of code! |
| 110 | + |
| 111 | +She is a little concerned that the recursive approach uses more steps than the looping approach, and so is less "performant". |
| 112 | +But re-writing the problem using recursion has definitely helped her deal with ugly nested looping (_a performance hazard_), extensive state mutation, and confusion around complex conditional logic. |
| 113 | +It also feels more "readable" - she is sure that when she comes back to this code after a break, she will be able to read through and remember what it does more easily. |
| 114 | + |
| 115 | +In the future, Adya may try to work through problems recursively first. |
| 116 | +She may find it easier to initially walk through the problem in clear steps when nesting, mutation, and complexity are minimized. |
| 117 | +After working out the basic logic, she can then focus on optimizing her initial recursive steps into a more performant looping approach. |
| 118 | + |
| 119 | +Even later, when she learns about `tuples`, Adya could consider further "optimizing" approaches, such as using a `list comprehension` with `Calendar.itermonthdates`, or memoizing certain values. |
| 120 | + |
| 121 | +## Recursive Variation: The Tail Call |
| 122 | + |
| 123 | +A tail call is when the last statement of a function only calls itself and nothing more. |
| 124 | +This example is not a tail call, as the function adds 1 to the result of calling itself |
| 125 | + |
| 126 | +```python |
| 127 | +def print_increment(step, max_value): |
| 128 | + if step > max_value: |
| 129 | + return 1 |
| 130 | + print(f'The step is {step}') |
| 131 | + return 1 + print_increment(step + 1, max_value) |
| 132 | + |
| 133 | + |
| 134 | +def main(): |
| 135 | + retval = print_increment(1, 2) |
| 136 | + print(f'retval is {retval} after recursion') |
| 137 | + |
| 138 | +if __name__ == "__main__": |
| 139 | + main() |
| 140 | + |
| 141 | +``` |
| 142 | + |
| 143 | +This will print |
| 144 | + |
| 145 | +``` |
| 146 | +The step is 1 |
| 147 | +The step is 2 |
| 148 | +retval is 3 after recursion |
| 149 | +``` |
| 150 | + |
| 151 | +To refactor it to a tail call, make `retval` a parameter of `print_increment` |
| 152 | + |
| 153 | +```python |
| 154 | +def print_increment(step, max_value, retval): |
| 155 | + if step > max_value: |
| 156 | + return retval |
| 157 | + print(f'The step is {step}') |
| 158 | + return print_increment(step + 1, max_value, retval + 1) |
| 159 | + |
| 160 | + |
| 161 | +def main(): |
| 162 | + retval = print_increment(1, 2, 1) |
| 163 | + print(f'retval is {retval} after recursion') |
| 164 | + |
| 165 | +if __name__ == "__main__": |
| 166 | + main() |
| 167 | + |
| 168 | +``` |
| 169 | + |
| 170 | +You may find a tail call even easier to reason through than a recursive call that is not a tail call. |
| 171 | +However, it is always important when using recursion to know that there will not be so many iterations that the stack will overflow. |
| 172 | + |
| 173 | +## Recursion Limits in Python |
| 174 | + |
| 175 | +Some languages are able to optimize tail calls so that each recursive call reuses the stack frame of the first call to the function (_similar to the way a loop reuses a frame_), instead of adding an additional frame to the stack. |
| 176 | +Python is not one of those languages. |
| 177 | +To guard against stack overflow, Python has a recursion limit that defaults to one thousand frames. |
| 178 | +A [RecursionError](https://docs.python.org/3.8/library/exceptions.html#RecursionError) exception is raised when the interpreter detects that the recursion limit has been exceeded. |
| 179 | +It is possible to use the [sys.setrecursionlimit](https://docs.python.org/3.8/library/sys.html#sys.setrecursionlimit) method to increase the recursion limit, but doing so runs the risk of having a runtime segmentation fault that will crash the program, and possibly the operating system. |
| 180 | + |
| 181 | +## Resources |
| 182 | + |
| 183 | +To learn more about using recursion in Python you can start with |
| 184 | +- [python-programming: recursion][python-programming: recursion] |
| 185 | +- [Real Python: python-recursion][Real Python: python-recursion] |
| 186 | +- [Real Python: python-thinking-recursively][Real Python: python-thinking-recursively] |
| 187 | + |
| 188 | +[python-programming: recursion]: https://www.programiz.com/python-programming/recursion |
| 189 | +[Real Python: python-recursion]: https://realpython.com/python-recursion/ |
| 190 | +[Real Python: python-thinking-recursively]: https://realpython.com/python-thinking-recursively/ |
| 191 | +[RecursionError]: https://docs.python.org/3.8/library/exceptions.html#RecursionError |
| 192 | +[setrecursionlimit]: https://docs.python.org/3.8/library/sys.html#sys.setrecursionlimit |
| 193 | +[divide and conquer]: https://afteracademy.com/blog/divide-and-conquer-approach-in-programming |
| 194 | +[cumulative]: https://www.geeksforgeeks.org/sum-of-natural-numbers-using-recursion/ |
0 commit comments