[Wordy & Wordy Approaches]: Added 6 Additional Approaches & Modified the Instruction Append for Wordy. (#3783)

* Added 6 additional appraches and extended introduction for Wordy.

* Corrected slug for regex with operator module approach.
This commit is contained in:
BethanyG
2024-10-11 17:41:40 -07:00
committed by GitHub
parent 3dcbf4cb7c
commit fb1cb444ad
16 changed files with 1395 additions and 66 deletions

View File

@@ -1,10 +1,52 @@
{
"introduction": {
"authors": ["bobahop"],
"contributors": []
"authors": ["BethanyG"],
"contributors": ["bobahop"]
},
"approaches": [
{
"uuid": "4eeb0638-671a-4289-a83c-583b616dc698",
"slug": "string-list-and-dict-methods",
"title": "String, List, and Dictionary Methods",
"blurb": "Use Core Python Features to Solve Word Problems.",
"authors": ["BethanyG"]
},
{
"uuid": "d3ff485a-defe-42d9-b9c6-c38019221ffa",
"slug": "import-callables-from-operator",
"title": "Import Callables from the Operator Module",
"blurb": "Use Operator Module Methods to Solve Word Problems.",
"authors": ["BethanyG"]
},
{
"uuid": "61f44943-8a12-471b-ab15-d0d10fa4f72f",
"slug": "regex-with-operator-module",
"title": "Regex with the Operator Module",
"blurb": "Use Regex with the Callables from Operator to solve word problems.",
"authors": ["BethanyG"]
},
{
"uuid": "46bd15dd-cae4-4eb3-ac63-a8b631a508d1",
"slug": "lambdas-in-a-dictionary",
"title": "Lambdas in a Dictionary to Return Functions",
"blurb": "Use lambdas in a dictionary to return functions for solving word problems.",
"authors": ["BethanyG"]
},
{
"uuid": "2e643b88-9b76-45a1-98f4-b211919af061",
"slug": "recursion",
"title": "Recursion for iteration.",
"blurb": "Use recursion with other strategies to solve word problems.",
"authors": ["BethanyG"]
},
{
"uuid": "1e136304-959c-4ad1-bc4a-450d13e5f668",
"slug": "functools-reduce",
"title": "Functools.reduce for Calculation",
"blurb": "Use functools.reduce with other strategies to calculate solutions.",
"authors": ["BethanyG"]
},
{
"uuid": "d643e2b4-daee-422d-b8d3-2cad2f439db5",
"slug": "dunder-getattribute",
"title": "dunder with __getattribute__",

View File

@@ -1,5 +1,6 @@
# Dunder methods with `__getattribute__`
```python
OPS = {
"plus": "__add__",
@@ -33,70 +34,61 @@ def answer(question):
```
This approach begins by defining a [dictionary][dictionaries] of the word keys with their related [dunder][dunder] methods.
This approach begins by defining a [dictionary][dictionaries] of the word keys with their related [`dunder-methods`][dunder] methods.
Since only whole numbers are involved, the available `dunder-methods` are those for the [`int`][int] class/namespace.
The supported methods for the `int()` namespace can be found by using `print(dir(int))` or `print(int.__dict__)` in a Python terminal.
See [SO: Difference between dir() and __dict__][dir-vs-__dict__] for differences between the two.
~~~~exercism/note
They are called "dunder" methods because they have **d**ouble **under**scores at the beginning and end of the method name.
They are also called magic methods.
The built-in [`dir`](https://docs.python.org/3/library/functions.html?#dir) function returns a list of all valid attributes for an object.
The `dunder-method` [`<object>.__dict__`](https://docs.python.org/3/reference/datamodel.html#object.__dict__) is a mapping of an objects writable attributes.
~~~~
Since only whole numbers are involved, the dunder methods are those for [`int`][int].
The supported methods for `int` can be found by using `print(dir(int))`.
The `OPS` dictionary is defined with all uppercase letters, which is the naming convention for a Python [constant][const].
It indicates that the value should not be changed.
~~~~exercism/note
The built-in [`dir`](https://docs.python.org/3/library/functions.html?#dir) function returns a list of valid attributes for an object.
~~~~
Python doesn't _enforce_ having real constant values,
but the `OPS` dictionary is defined with all uppercase letters, which is the naming convention for a Python [constant][const].
It indicates that the value is not intended to be changed.
The input question to the `answer` function is cleaned using the [`removeprefix`][removeprefix], [`removesuffix`][removesuffix], and [`strip`][strip] methods.
The input question to the `answer()` function is cleaned using the [`removeprefix`][removeprefix], [`removesuffix`][removesuffix], and [`strip`][strip] string methods.
The method calls are [chained][method-chaining], so that the output from one call is the input for the next call.
If the input has no characters left,
it uses the [falsiness][falsiness] of an empty string with the [`not`][not] operator to return the [`ValueError`][value-error] for having a syntax error.
it uses the [falsiness][falsiness] of an empty string with the [`not`][not] operator to return a `ValueError("syntax error")`.
Next, the [`isdigit`][isdigit] method is used to see if all of the remaining characters in the input are digits.
Next, the [`isdigit`][isdigit] method is used to see if the remaining characters in the input are digits.
If so, it uses the [`int()`][int-constructor] constructor to return the string as an integer.
Next, the elements in the `OPS` dictionary are iterated.
If the key name is in the input, then the [`replace()`][replace] method is used to replace the name in the input with the dunder method value.
If none of the key names are found in the input, then a `ValueError` is returned for having an unknown operation.
Next, the elements in the `OPS` dictionary are iterated over.
If the key name is in the input, then the [`str.replace`][replace] method is used to replace the name in the input with the `dunder-method` value.
If none of the key names are found in the input, a `ValueError("unknown operation")` is returned.
At this point the input question is [`split()`][split] into a list of its words, which is then iterated while its [`len()`][len] is greater than 1.
At this point the input question is [`split()`][split] into a `list` of its words, which is then iterated over while its [`len()`][len] is greater than 1.
Within a [try][exception-handling], the list is [destructured][destructure] into `x, op, y, *tail`.
If `op` is not in the supported dunder methods, it raises `ValueError("syntax error")`.
If there are any other exceptions raised in the try, `except` raises `ValueError("syntax error")`
Within a [try-except][exception-handling] block, the list is [unpacked][unpacking] (_see also [Concept: unpacking][unpacking-and-multiple-assignment]_) into the variables `x, op, y, and *tail`.
If `op` is not in the supported `dunder-methods` dictionary, a `ValueError("syntax error")` is raised.
If there are any other exceptions raised within the `try` block, they are "caught"/ handled in the `except` clause by raising a `ValueError("syntax error")`.
Next, it converts `x` to an `int` and calls the [`__getattribute__`][getattribute] for its dunder method and calls it,
passing it `y` converted to an `int`.
Next, `x` is converted to an `int` and [`__getattribute__`][getattribute] is called for the `dunder-method` (`op`) to apply to `x`.
`y` is then converted to an `int` and passed as the second arguemnt to `op`.
It sets the list to the result of the dunder method plus the remaining elements in `*tail`.
~~~~exercism/note
The `*` prefix in `*tail` [unpacks](https://treyhunner.com/2018/10/asterisks-in-python-what-they-are-and-how-to-use-them/) the `tail` list back into its elements.
This concept is also a part of [unpacking-and-multiple-assignment](https://exercism.org/tracks/python/concepts/unpacking-and-multiple-assignment) concept in the syllabus.
~~~~
Then `ret` is redefined to a `list` containing the result of the dunder method plus the remaining elements in `*tail`.
When the loop exhausts, the first element of the list is selected as the function return value.
[dictionaries]: https://docs.python.org/3/tutorial/datastructures.html#dictionaries
[dunder]: https://www.tutorialsteacher.com/python/magic-methods-in-python
[int]: https://docs.python.org/3/library/stdtypes.html#typesnumeric
[const]: https://realpython.com/python-constants/
[removeprefix]: https://docs.python.org/3/library/stdtypes.html#str.removeprefix
[removesuffix]: https://docs.python.org/3/library/stdtypes.html#str.removesuffix
[strip]: https://docs.python.org/3/library/stdtypes.html#str.strip
[dictionaries]: https://docs.python.org/3/tutorial/datastructures.html#dictionaries
[dir-vs-__dict__]: https://stackoverflow.com/a/14361362
[dunder]: https://www.tutorialsteacher.com/python/magic-methods-in-python
[exception-handling]: https://docs.python.org/3/tutorial/errors.html#handling-exceptions
[falsiness]: https://www.pythontutorial.net/python-basics/python-boolean/
[getattribute]: https://docs.python.org/3/reference/datamodel.html?#object.__getattribute__
[int-constructor]: https://docs.python.org/3/library/functions.html?#int
[int]: https://docs.python.org/3/library/stdtypes.html#typesnumeric
[isdigit]: https://docs.python.org/3/library/stdtypes.html?#str.isdigit
[len]: https://docs.python.org/3/library/functions.html?#len
[method-chaining]: https://www.tutorialspoint.com/Explain-Python-class-method-chaining
[not]: https://docs.python.org/3/library/operator.html?#operator.__not__
[falsiness]: https://www.pythontutorial.net/python-basics/python-boolean/
[value-error]: https://docs.python.org/3/library/exceptions.html?#ValueError
[isdigit]: https://docs.python.org/3/library/stdtypes.html?#str.isdigit
[int-constructor]: https://docs.python.org/3/library/functions.html?#int
[removeprefix]: https://docs.python.org/3/library/stdtypes.html#str.removeprefix
[removesuffix]: https://docs.python.org/3/library/stdtypes.html#str.removesuffix
[replace]: https://docs.python.org/3/library/stdtypes.html?#str.replace
[split]: https://docs.python.org/3/library/stdtypes.html?#str.split
[len]: https://docs.python.org/3/library/functions.html?#len
[exception-handling]: https://docs.python.org/3/tutorial/errors.html#handling-exceptions
[destructure]: https://riptutorial.com/python/example/14981/destructuring-assignment
[getattribute]: https://docs.python.org/3/reference/datamodel.html?#object.__getattribute__
[strip]: https://docs.python.org/3/library/stdtypes.html#str.strip
[unpacking]: https://treyhunner.com/2018/10/asterisks-in-python-what-they-are-and-how-to-use-them/
[unpacking-and-multiple-assignment]: https://exercism.org/tracks/python/concepts/unpacking-and-multiple-assignment

View File

@@ -0,0 +1,126 @@
# Functools.reduce for Calculation
```python
from operator import add, mul, sub
from operator import floordiv as div
from functools import reduce
# Define a lookup table for mathematical operations
OPERATORS = {"plus": add, "minus": sub, "multiplied": mul, "divided": div}
def answer(question):
# Check for basic validity right away, and fail out with error if not valid.
if not question.startswith( "What is") or "cubed" in question:
raise ValueError("unknown operation")
# Using the built-in filter() to clean & split the question..
list(filter(lambda x:
x not in ("What", "is", "by"),
question.strip("?").split()))
# Separate candidate operators and numbers into two lists.
operations = question[1::2]
# Convert candidate elements to int(), checking for "-".
# All other values are replaced with None.
digits = [int(element) if (element.isdigit() or element[1:].isdigit())
else None for element in question[::2]]
# If there is a mis-match between operators and numbers, toss error.
if len(digits)-1 != len(operations) or None in digits:
raise ValueError("syntax error")
# Evaluate the expression from left to right using functools.reduce().
# Look up each operation in the operation dictionary.
return reduce(lambda x, y: OPERATORS[operations.pop(0)](x, y), digits)
```
This approach replaces the `while-loop` or `recursion` used in many solutions with a call to [`functools.reduce`][functools-reduce].
It requires that the question be separated into candidate digits and candidate operators, which is accomplished here via [list-slicing][sequence-operations] (_for some additional information on working with `lists`, see [concept: lists](/tracks/python/concepts/lists)_).
A nested call to `filter()` and `split()` within a `list` constructor is used to clean and process the question into an initial `list` of digit and operator strings.
However, this could easily be accomplished by either using [chained][method-chaining] string methods or a `list-comprehension`:
```python
# Alternative 1 is chaining various string methods together.
# The wrapping () invoke implicit concatenation for the chained functions
return (question.removeprefix("What is")
.removesuffix("?")
.replace("by", "")
.strip()).split() # <-- this split() turns the string into a list.
# Alternative 2 to the nested calls to filter and split is to use a list-comprehension:
return [item for item in
question.strip("?").split()
if item not in ("What", "is", "by")] #<-- The [] of the comprehension invokes implicit concatenation.
```
Since "valid" questions are all in the form of `digit-operator-digit` (_and so on_), it is safe to assume that every other element beginning at index 0 is a "number", and every other element beginning at index 1 is an operator.
By that definition, the operators `list` is 1 shorter in `len()` than the digits list.
Anything else (_or having None/an unknown operation in the operations list_) is a `ValueError("syntax error")`.
The final call to `functools.reduce` essentially performs the same steps as the `while-loop` implementation, with the `lambda-expression` passing successive items of the digits `list` to the popped and looked-up operation from the operations `list` (_made [callable][callable] by adding `()`_), until it is reduced to one number and returned.
A `try-except` is not needed here because the error scenarios are already filtered out in the `if` check right before the call to `reduce()`.
`functools.reduce` is certainly convenient, and makes the solution much shorter.
But it is also hard to understand what is happening if you have not worked with a reduce or foldl function in the past.
It could be argued that writing the code as a `while-loop` or recursive function is easier to reason about for non-functional programmers.
## Variation: 1: Use a Dictionary of `lambdas` instead of importing from operator
The imports from operator can be swapped out for a dictionary of `lambda-expressions` (or calls to `dunder-methods`), if so desired.
The same cautions apply here as were discussed in the [lambdas in a dictionary][approach-lambdas-in-a-dictionary] approach:
```python
from functools import reduce
# Define a lookup table for mathematical operations
OPERATORS = {"plus": lambda x, y: x + y,
"minus": lambda x, y: x - y,
"multiplied": lambda x, y: x * y,
"divided": lambda x, y: x / y}
def answer(question):
# Check for basic validity right away, and fail out with error if not valid.
if not question.startswith( "What is") or "cubed" in question:
raise ValueError("unknown operation")
# Clean and split the question into a list for processing.
question = [item for item in
question.strip("?").split() if
item not in ("What", "is", "by")]
# Separate candidate operators and numbers into two lists.
operations = question[1::2]
# Convert candidate elements to int(), checking for "-".
# All other values are replaced with None.
digits = [int(element) if (element.isdigit() or element[1:].isdigit())
else None for element in question[::2]]
# If there is a mis-match between operators and numbers, toss error.
if len(digits)-1 != len(operations) or None in digits:
raise ValueError("syntax error")
# Evaluate the expression from left to right using functools.reduce().
# Look up each operation in the operation dictionary.
result = reduce(lambda x, y: OPERATORS[operations.pop(0)](x, y), digits)
return result
```
[approach-lambdas-in-a-dictionary]: https://exercise.org/tracks/python/exercises/wordy/approaches/lambdas-in-a-dictionary
[callable]: https://treyhunner.com/2019/04/is-it-a-class-or-a-function-its-a-callable/
[functools-reduce]: https://docs.python.org/3/library/functools.html#functools.reduce
[method-chaining]: https://www.tutorialspoint.com/Explain-Python-class-method-chaining
[sequence-operations]: https://docs.python.org/3/library/stdtypes.html#common-sequence-operations

View File

@@ -0,0 +1,7 @@
OPERATORS = {"plus": add, "minus": sub, "multiplied": mul, "divided": div}
operations = question[1::2]
digits = [int(element) if (element.isdigit() or element[1:].isdigit())
else None for element in question[::2]]
...
return reduce(lambda x, y: OPERATORS[operations.pop(0)](x, y), digits)

View File

@@ -0,0 +1,93 @@
# Import Callables from the Operator Module
```python
from operator import add, mul, sub
from operator import floordiv as div
OPERATIONS = {"plus": add, "minus": sub, "multiplied": mul, "divided": div}
def answer(question):
if not question.startswith("What is") or "cubed" in question:
raise ValueError("unknown operation")
question = question.removeprefix("What is").removesuffix("?").strip()
if question.isdigit():
return int(question)
if not question:
raise ValueError("syntax error")
equation = [word for word in question.split() if word != 'by']
while len(equation) > 1:
try:
x_value, operation, y_value, *rest = equation
equation = [OPERATIONS[operation](int(x_value), int(y_value)),
*rest]
except:
raise ValueError("syntax error")
return equation[0]
```
This approach is nearly identical to the [string, list, and dict methods][approach-string-list-and-dict-methods] approach, so it is recommended to review that before going over this one.
The two major differences are the `operator` module, and the elimination of the `if-elif-else` block.
The solution begins by importing basic mathematical operations as methods from the [`operator`][operator] module.
These functions (_floordiv is [aliased][aliasing] to "div"_) are stored in a dictionary that serves as a lookup table when the problems are processed.
These operations are later made [callable][callable] by using `()` after the name, and supplying arguments.
In `answer()`, the question is first checked for validity, cleaned, and finally split into a `list` using [`str.startswith`][startswith], [`str.removeprefix`][removeprefix]/[`str.removesuffix`][removesuffix], [strip][strip], and [split][split].
Checks for digits and an empty string are done, and the word "by" is filtered from the equation `list` using a [`list-comprehension`][list-comprehension].
The equation `list` is then processed in a `while-loop` within a [try-except][handling-exceptions] block.
The `list` is [unpacked][unpacking] (_see also [concept: unpacking and multiple assignment](/tracks/python/concepts/unpacking-and-multiple-assignment)_) into `x_value`, `operation`, `y_value`, and `*rest`, and reduced by looking up and calling the mathematical function in the OPERATIONS dictionary and passing in `int(x_value)` and `int(y_value)` as arguments.
The processing of the equation `list` continues until it is of `len()` 1, at which point the single element is returned as the answer.
To walk through this step-by-step, you can interact with this code on [`pythontutor.com`][pythontutor].
Using a `list-comprehension` to filter out "by" can be replaced with the [`str.replace`][str-replace] method during question cleaning.
[Implicit concatenation][implicit-concatenation] can be used to improve the readability of the [chained][chaining-method-calls] method calls:
```python
question = (question.removeprefix("What is")
.removesuffix("?")
.replace("by", "")
.strip()) #<-- Enclosing () means these lines are automatically joined by the interpreter.
```
The call to `str.replace` could instead be chained to the call to `split` when creating the equation `list`:
```python
equation = question.replace("by", "").split()
```
[aliasing]: https://mimo.org/glossary/python
[approach-string-list-and-dict-methods]: https://exercise.org/tracks/python/exercises/wordy/approaches/string-list-and-dict-methods
[callable]: https://treyhunner.com/2019/04/is-it-a-class-or-a-function-its-a-callable/
[chaining-method-calls]: https://nikhilakki.in/understanding-method-chaining-in-python
[handling-exceptions]: https://docs.python.org/3.11/tutorial/errors.html#handling-exceptions
[implicit-concatenation]: https://docs.python.org/3/reference/lexical_analysis.html#implicit-line-joining
[list-comprehension]: https://docs.python.org/3/tutorial/datastructures.html#list-comprehensions
[operator]: https://docs.python.org/3/library/operator.html#module-operator
[pythontutor]: https://pythontutor.com/render.html#code=from%20operator%20import%20add,%20mul,%20sub%0Afrom%20operator%20import%20floordiv%20as%20div%0A%0AOPERATIONS%20%3D%20%7B%22plus%22%3A%20add,%20%22minus%22%3A%20sub,%20%22multiplied%22%3A%20mul,%20%22divided%22%3A%20div%7D%0A%0Adef%20answer%28question%29%3A%0A%20%20%20%20if%20not%20question.startswith%28%22What%20is%22%29%20or%20%22cubed%22%20in%20question%3A%0A%20%20%20%20%20%20%20%20raise%20ValueError%28%22unknown%20operation%22%29%0A%20%20%20%20%0A%20%20%20%20question%20%3D%20question.removeprefix%28%22What%20is%22%29.removesuffix%28%22%3F%22%29.strip%28%29%0A%0A%20%20%20%20if%20question.isdigit%28%29%3A%20%0A%20%20%20%20%20%20%20%20return%20int%28question%29%0A%20%20%20%20%0A%20%20%20%20if%20not%20question%3A%20%0A%20%20%20%20%20%20%20%20raise%20ValueError%28%22syntax%20error%22%29%0A%20%20%20%20%0A%20%20%20%20equation%20%3D%20%5Bword%20for%20word%20in%20question.split%28%29%20if%20word%20!%3D%20'by'%5D%0A%20%20%20%20%0A%20%20%20%20while%20len%28equation%29%20%3E%201%3A%0A%20%20%20%20%20%20%20%20try%3A%0A%20%20%20%20%20%20%20%20%20%20%20%20x_value,%20operation,%20y_value,%20*rest%20%3D%20equation%0A%20%20%20%20%20%20%20%20%20%20%20%20equation%20%3D%20%5BOPERATIONS%5Boperation%5D%28int%28x_value%29,%20int%28y_value%29%29,%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20*rest%5D%0A%20%20%20%20%20%20%20%20except%3A%0A%20%20%20%20%20%20%20%20%20%20%20%20raise%20ValueError%28%22syntax%20error%22%29%0A%20%20%20%20%0A%20%20%20%20return%20equation%5B0%5D%0A%20%20%20%20%0Aprint%28answer%28%22What%20is%202%20plus%202%20plus%203%3F%22%29%29&cumulative=false&curInstr=0&heapPrimitives=nevernest&mode=display&origin=opt-frontend.js&py=311&rawInputLstJSON=%5B%5D&textReferences=false
[removeprefix]: https://docs.python.org/3.9/library/stdtypes.html#str.removeprefix
[removesuffix]: https://docs.python.org/3.9/library/stdtypes.html#str.removesuffix
[split]: https://docs.python.org/3.9/library/stdtypes.html#str.split
[startswith]: https://docs.python.org/3.9/library/stdtypes.html#str.startswith
[str-replace]: https://docs.python.org/3/library/stdtypes.html#str.replace
[strip]: https://docs.python.org/3.9/library/stdtypes.html#str.strip
[unpacking]: https://treyhunner.com/2018/10/asterisks-in-python-what-they-are-and-how-to-use-them/

View File

@@ -0,0 +1,8 @@
OPERATIONS = {"plus": add, "minus": sub, "multiplied": mul, "divided": div}
while len(equation) > 1:
try:
x_value, operation, y_value, *rest = equation
equation = [OPERATIONS[operation](int(x_value), int(y_value)),*rest]
except:
raise ValueError("syntax error")
return equation[0]

View File

@@ -1,22 +1,367 @@
# Introduction
There are various ways to solve Wordy.
Using [`eval`][eval] is a [convenient but potentially dangerous][eval-danger] approach.
Another approach could replace the operation words with [dunder][dunder] methods.
The objective of the Wordy exercise is to parse and evaluate small/simple mathematical word problems, returning the result as an integer.
These problems do not require complex or [PEMDAS][PEMDAS]-based evaluation and are instead processed from left-to-right _in sequence_.
This means that for some of the test cases, the solution will not be the same as if the word problem was evaluated like a traditional math problem.
~~~~exercism/note
They are called "dunder" methods because they have **d**ouble **under**scores at the beginning and end of the method name.
They are also called magic methods.
~~~~
The dunder methods can be called by using the [`__getattribute__`][getattribute] method for [`int`][int].
## General Guidance
## General guidance
The key to a Wordy solution is to remove the "question" portion of the sentence (_"What is", "?"_) and process the remaining words between numbers as [operators][mathematical operators].
If a single number remains after removing the "question", it should be converted to an [`int`][int] and returned as the answer.
Any words or word-number combinations that do not fall into the simple mathematical evaluation pattern (_number-operator-number_) should [`raise`][raise-statement] a [`ValueError`][value-error] with a message.
This includes any "extra" spaces between numbers.
One way to reduce the number of `raise` statements/ `ValueError`s needed in the code is to determine if a problem is a "valid" question _before_ proceeding to parsing and calculation.
As shown in various approaches, there are multiple strategies for validating questions, with no one "canonical" solution.
One very effective approach is to check if a question starts with "What is", ends with "?", and includes only valid operations.
That could lead to future maintenance issues if the definition of a question ever changes or operations are added, but for the purposes of passing the current Wordy tests, it works well.
There are various Pythonic ways to go about the cleaning, parsing, and calculation steps of Wordy.
For cleaning the "question" portion of the problem, [`str.removeprefix`][removeprefix] and
[`str.removesuffix`][removesuffix] introduced in `Python 3.9` can be very useful:
```python
>>> 'Supercalifragilisticexpialidocious'.removeprefix('Super')
'califragilisticexpialidocious'
>>> 'Supercalifragilisticexpialidocious'.removesuffix('expialidocious')
'Supercalifragilistic'
#The two methods can be chained to remove both a suffix and prefix in one line.
>>> 'Supercalifragilisticexpialidocious'.removesuffix('expialidocious').removeprefix('Super')
'califragilistic'
```
You can also use [`str.startswith`][startswith] and [`str.endswith`][endswith] in conjunction with [string slicing][sequence-operations] for cleaning:
```python
>>> if 'Supercalifragilisticexpialidocious'.startswith('Super'):
new_string = 'Supercalifragilisticexpialidocious'[5:]
>>> new_string
'califragilisticexpialidocious'
>>> if new_string.endswith('expialidocious'):
new_string = new_string[:15]
>>> new_string
'califragilistic'
```
Different combinations of [`str.find`][find], [`str.rfind`][rfind], or [`str.index`][index] with string slicing could be used to clean up the initial word problem.
A [regex][regex] could also be used to process the question, but might be considered overkill given the fixed nature of the prefix/suffix and operations.
Finally, [`str.strip`][strip] and its variants are very useful for cleaning up any leftover leading or trailing whitespace.
Many solutions then use [`str.split`][split] to process the remaining "cleaned" question into a `list` for convenient iteration, although other strategies are also used.
For math operations, many solutions involve importing and using methods from the [operator][operator] module in combination with different looping, parsing, and substitution strategies.
Some solutions use either [lambda][lambdas] expressions or [dunder/"special" methods][dunder-methods] to replace words with arithmetic operations.
However, the exercise can be solved without using `operator`, `lambdas`, or `dunder-methods`.
Using [`eval`][eval] for the operations might seem convenient, but it is a [dangerous][eval-danger] and possibly [destructive][eval-destructive] approach.
It is also entirely unnecessary, as the other methods described here are safer and equally performant.
## Approach: String, List, and Dictionary Methods
```python
OPERATIONS = {"plus": '+', "minus": '-', "multiplied": '*', "divided": '/'}
def answer(question):
if not question.startswith("What is") or "cubed" in question:
raise ValueError("unknown operation")
question = question.removeprefix("What is").removesuffix("?").strip()
if not question:
raise ValueError("syntax error")
if question.isdigit():
return int(question)
formula = []
for operation in question.split():
if operation == 'by':
continue
else:
formula.append(OPERATIONS.get(operation, operation))
while len(formula) > 1:
try:
x_value = int(formula[0])
symbol = formula[1]
y_value = int(formula[2])
remainder = formula[3:]
if symbol == "+":
formula = [x_value + y_value] + remainder
elif symbol == "-":
formula = [x_value - y_value] + remainder
elif symbol == "*":
formula = [x_value * y_value] + remainder
elif symbol == "/":
formula = [x_value / y_value] + remainder
else:
raise ValueError("syntax error")
except:
raise ValueError("syntax error")
return formula[0]
```
This approach uses only data structures and methods (_[dict][dict], [dict.get()][dict-get] and [list()][list]_) from core Python, and does not import any extra modules.
It may have more lines of code than average, but it is clear to follow and fairly straightforward to reason about.
It does use a [try-except][handling-exceptions] block for handling unknown operators.
As an alternative to the `formula` loop-append, a [list-comprehension][list-comprehension] can be used to create the initial parsed formula.
For more details and variations, read the [String, List and Dictionary Methods][approach-string-list-and-dict-methods] approach.
## Approach: Import Callables from the Operator Module
```python
from operator import add, mul, sub
from operator import floordiv as div
OPERATIONS = {"plus": add, "minus": sub, "multiplied": mul, "divided": div}
def answer(question):
if not question.startswith("What is") or "cubed" in question:
raise ValueError("unknown operation")
question = question.removeprefix("What is").removesuffix("?").strip()
if question.isdigit():
return int(question)
if not question:
raise ValueError("syntax error")
equation = [word for word in question.split() if word != 'by']
while len(equation) > 1:
try:
x_value, operation, y_value, *rest = equation
equation = [OPERATIONS[operation](int(x_value), int(y_value)),
*rest]
except:
raise ValueError("syntax error")
return equation[0]
```
This solution imports methods from the `operator` module, and uses them in a dictionary/lookup map.
Like the first approach, it uses a [try-except][handling-exceptions] block for handling unknown operators.
It also uses a [list-comprehension][list-comprehension] to create the parsed "formula" and employs [concept: unpacking and multiple assignment](/tracks/python/concepts/unpacking-and-multiple-assignment).
For more details and options, take a look at the [Import Callables from the Operator Module][approach-import-callables-from-operator] approach.
## Approach: Regex and the Operator Module
```python
import re
from operator import add, mul, sub
from operator import floordiv as div
OPERATIONS = {"plus": add, "minus": sub, "multiplied by": mul, "divided by": div}
REGEX = {
'number': re.compile(r'-?\d+'),
'operator': re.compile(f'(?:{"|".join(OPERATIONS)})\\b')
}
def get_number(question):
pattern = REGEX['number'].match(question)
if not pattern:
raise ValueError("syntax error")
return [question.removeprefix(pattern.group(0)).lstrip(),
int(pattern.group(0))]
def get_operation(question):
pattern = REGEX['operator'].match(question)
if not pattern:
raise ValueError("unknown operation")
return [question.removeprefix(pattern.group(0)).lstrip(),
OPERATIONS[pattern.group(0)]]
def answer(question):
prefix = "What is"
if not question.startswith(prefix):
raise ValueError("unknown operation")
question = question.removesuffix("?").removeprefix(prefix).lstrip()
question, result = get_number(question)
while len(question) > 0:
if REGEX['number'].match(question):
raise ValueError("syntax error")
question, operation = get_operation(question)
question, num = get_number(question)
result = operation(result, num)
return result
```
This approach uses a dictionary of regex patterns for matching numbers and operators, paired with a dictionary of operations imported from the `operator` module.
It pulls number and operator processing out into separate functions and uses a while loop in `answer()` to evaluate the word problem.
It also uses multiple assignment for various variables.
It is longer than some solutions, but clearer and potentially easier to maintain due to the separate `get_operation()` and `get_number()` functions.
For more details, take a look at the [regex-with-operator-module][approach-regex-with-operator-module] approach.
## Approach: Lambdas in a Dictionary to return Functions
```python
OPERATIONS = {
'minus': lambda a, b: a - b,
'plus': lambda a, b: a + b,
'multiplied': lambda a, b: a * b,
'divided': lambda a, b: a / b
}
def answer(question):
if not question.startswith("What is") or "cubed" in question:
raise ValueError("unknown operation")
question = question.removeprefix("What is").removesuffix("?").strip()
if question.isdigit():
return int(question)
if not question:
raise ValueError("syntax error")
equation = [word for word in question.split() if word != 'by']
while len(equation) > 1:
try:
x_value, operation, y_value, *rest = equation
equation = [OPERATIONS[operation](int(x_value), int(y_value)),
*rest]
except:
raise ValueError("syntax error")
return equation[0]
```
Rather than import methods from the `operator` module, this approach defines a series of [`lambda expressions`][lambdas] in the OPERATIONS dictionary.
These `lambdas` then return a function that takes two numbers as arguments, returning the result.
One drawback of this strategy over using named functions or methods from `operator` is the lack of debugging information should something go wrong with evaluation.
Lambda expressions are all named `"lambda"` in stack traces, so it becomes less clear where an error is coming from if you have a number of lambda expressions within a large program.
Since this is not a large program, debugging these `lambdas` is fairly straightforward.
These "hand-crafted" `lambdas` could also introduce a mathematical error, although for the simple problems in Wordy, this is a fairly small consideration.
For more details, take a look at the [Lambdas in a Dictionary][approach-lambdas-in-a-dictionary] approach.
## Approach: Recursion
```python
from operator import add, mul, sub
from operator import floordiv as div
OPERATIONS = {"plus": add, "minus": sub, "multiplied": mul, "divided": div}
def answer(question):
return calculate(clean(question))
def clean(question):
if not question.startswith("What is") or "cubed" in question:
raise ValueError("unknown operation")
return (question.removeprefix("What is")
.removesuffix("?")
.replace("by", "")
.strip()).split()
def calculate(equation):
if len(equation) == 1:
return int(equation[0])
else:
try:
x_value, operation, y_value, *rest = equation
equation = [OPERATIONS[operation](int(x_value),
int(y_value)), *rest]
except:
raise ValueError("syntax error")
return calculate(equation)
```
Like previous approaches that substitute methods from `operator` for `lambdas` or `list-comprehensions` for `loops` that append to a `list` -- `recursion` can be substituted for the `while-loop` that many solutions use to process a parsed word problem.
Depending on who is reading the code, `recursion` may or may not be easier to reason about.
It may also be more (_or less!_) performant than using a `while-loop` or `functools.reduce` (_see below_), depending on how the various cleaning and error-checking actions are performed.
The dictionary in this example could use functions from `operator`, `lambdas`, `dunder-methods`, or other strategies -- as long as they can be applied in the `calculate()` function.
For more details, take a look at the [recursion][approach-recursion] approach.
## Approach: functools.reduce()
```python
from operator import add, mul, sub
from operator import floordiv as div
from functools import reduce
OPERATORS = {"plus": add, "minus": sub, "multiplied": mul, "divided": div}
def answer(question):
if not question.startswith( "What is") or "cubed" in question:
raise ValueError("unknown operation")
question = list(filter(lambda x:
x not in ("What", "is", "by"),
question.strip("?").split()))
operations = question[1::2]
digits = [int(element) if (element.isdigit() or
element[1:].isdigit()) else None for
element in question[::2]]
if len(digits)-1 != len(operations) or None in digits:
raise ValueError("syntax error")
result = reduce(lambda x, y: OPERATORS[operations.pop(0)](x, y), digits)
return result
```
This approach replaces the `while-loop` used in many solutions (_or the `recursion` strategy outlined in the approach above_) with a call to [`functools.reduce`][functools-reduce].
It also employs a lookup dictionary for methods imported from the `operator` module, as well as a `list-comprehension`, the built-in [`filter`][filter] function, and multiple string [slices][sequence-operations].
If desired, the `operator` imports can be replaced with a dictionary of `lambda` expressions or `dunder-methods`.
This solution may be a little less clear to follow or reason about due to the slicing syntax and the particular syntax of both `filter` and `fuctools.reduce`.
For more details and variations, take a look at the [functools.reduce for Calculation][approach-functools-reduce] approach.
Parsing should verify that the expression in words can be translated to a valid mathematical expression.
## Approach: Dunder methods with `__getattribute__`
```python
OPS = {
"plus": "__add__",
@@ -50,11 +395,52 @@ def answer(question):
```
For more information, check the [dunder method with `__getattribute__` approach][approach-dunder-getattribute].
This approach uses the [`dunder methods`][dunder-methods] / ["special methods"][special-methods] / "magic methods" associated with the `int()` class, using the `dunder-method` called [`<object>.__getattribute__`][getattribute] to find the [callable][callable] operation in the `int()` class [namespace][namespace] / dictionary.
This works because the operators for basic math (_"+, -, *, /, //, %, **"_) have been implemented as callable methods for all integers (_as well as floats and other number types_) and are automatically loaded when the Python interpreter is loaded.
[eval]: https://docs.python.org/3/library/functions.html?#eval
[eval-danger]: https://diveintopython3.net/advanced-iterators.html#eval
[dunder]: https://www.tutorialsteacher.com/python/magic-methods-in-python
[getattribute]: https://docs.python.org/3/reference/datamodel.html?#object.__getattribute__
[int]: https://docs.python.org/3/library/stdtypes.html#typesnumeric
As described in the first link, it is considered bad form to directly call a `dunder method` (_there are some exceptions_), as they are intended mostly for internal Python use, user-defined class customization, and operator overloading (_a specific form of class-customization_).
This is why the `operator` module exists - as a vehicle for providing callable methods for basic math when **not** overloading or customizing class functionality.
For more detail on this solution, take a look at the [dunder method with `__getattribute__` approach][approach-dunder-getattribute].
[PEMDAS]: https://www.mathnasium.com/math-centers/eagan/news/what-pemdas-e
[approach-dunder-getattribute]: https://exercism.org/tracks/python/exercises/wordy/approaches/dunder-getattribute
[approach-functools-reduce]: https://exercism.org/tracks/python/exercises/wordy/approaches/functools-reduce
[approach-import-callables-from-operator]: https://exercism.org/tracks/python/exercises/wordy/approaches/import-callables-from-operator
[approach-lambdas-in-a-dictionary]: https://exercise.org/tracks/python/exercises/wordy/approaches/lambdas-in-a-dictionary
[approach-recursion]: https://exercise.org/tracks/python/exercises/wordy/approaches/recursion
[approach-regex-with-operator-module]: https://exercise.org/tracks/python/exercises/wordy/approaches/regex-with-operator-module
[approach-string-list-and-dict-methods]: https://exercise.org/tracks/python/exercises/wordy/approaches/string-list-and-dict-methods
[callable]: https://treyhunner.com/2019/04/is-it-a-class-or-a-function-its-a-callable/
[dict-get]: https://docs.python.org/3/library/stdtypes.html#dict.get
[dict]: https://docs.python.org/3/library/stdtypes.html#dict
[dunder-methods]: https://www.pythonmorsels.com/what-are-dunder-methods/?watch
[endswith]: https://docs.python.org/3.9/library/stdtypes.html#str.endswith
[eval-danger]: https://softwareengineering.stackexchange.com/questions/311507/why-are-eval-like-features-considered-evil-in-contrast-to-other-possibly-harmfu
[eval-destructive]: https://nedbatchelder.com/blog/201206/eval_really_is_dangerous.html
[eval]: https://docs.python.org/3/library/functions.html?#eval
[find]: https://docs.python.org/3.9/library/stdtypes.html#str.find
[functools-reduce]: https://docs.python.org/3/library/functools.html#functools.reduce
[getattribute]: https://docs.python.org/3/reference/datamodel.html#object.__getattribute__
[handling-exceptions]: https://docs.python.org/3.11/tutorial/errors.html#handling-exceptions
[index]: https://docs.python.org/3.9/library/stdtypes.html#str.index
[int]: https://docs.python.org/3/library/stdtypes.html#typesnumeric
[lambdas]: https://docs.python.org/3/howto/functional.html#small-functions-and-the-lambda-expression
[list-comprehension]: https://docs.python.org/3/tutorial/datastructures.html#list-comprehensions
[list]: https://docs.python.org/3/library/stdtypes.html#list
[mathematical operators]: https://www.w3schools.com/python/gloss_python_arithmetic_operators.asp
[namespace]: https://docs.python.org/3/tutorial/classes.html#python-scopes-and-namespaces
[operator]: https://docs.python.org/3/library/operator.html#module-operator
[raise-statement]: https://docs.python.org/3/reference/simple_stmts.html#the-raise-statement
[regex]: https://docs.python.org/3/library/re.html#module-re
[removeprefix]: https://docs.python.org/3.9/library/stdtypes.html#str.removeprefix
[removesuffix]: https://docs.python.org/3.9/library/stdtypes.html#str.removesuffix
[rfind]: https://docs.python.org/3.9/library/stdtypes.html#str.rfind
[sequence-operations]: https://docs.python.org/3/library/stdtypes.html#common-sequence-operations
[special-methods]: https://docs.python.org/3/reference/datamodel.html#specialnames
[split]: https://docs.python.org/3.9/library/stdtypes.html#str.split
[startswith]: https://docs.python.org/3.9/library/stdtypes.html#str.startswith
[strip]: https://docs.python.org/3.9/library/stdtypes.html#str.strip
[value-error]: https://docs.python.org/3.11/library/exceptions.html#ValueError

View File

@@ -0,0 +1,100 @@
# Lambdas in a Dictionary to Return Functions
```python
OPERATIONS = {
'minus': lambda a, b: a - b,
'plus': lambda a, b: a + b,
'multiplied': lambda a, b: a * b,
'divided': lambda a, b: a / b
}
def answer(question):
if not question.startswith("What is") or "cubed" in question:
raise ValueError("unknown operation")
question = question.removeprefix("What is").removesuffix("?").strip()
if question.isdigit():
return int(question)
if not question:
raise ValueError("syntax error")
equation = question.replace("by", "").split()
while len(equation) > 1:
try:
x_value, operation, y_value, *rest = equation
equation = [OPERATIONS[operation](int(x_value), int(y_value)),
*rest]
except:
raise ValueError("syntax error")
return equation[0]
```
This approach is nearly identical to the [string, list, and dict methods][approach-string-list-and-dict-methods] and the [import callables from the operator][approach-import-callables-from-operator] approaches, so it is recommended that you review those before going over this one.
The major difference here is the use of [`lambda expressions`][lambdas] in place of `operator` methods or string representations in the OPERATIONS dictionary.
`lambda expressions` are small "throwaway" expressions that are simple enough to not require a formal function definition or name.
They are most commonly used in [`key functions`][key-functions], the built-ins [`map`][map] and [`filter`][filter], and in [`functools.reduce`][functools-reduce].
`lambdas` are also often defined in areas where a function is needed for one-time use or callback but it would be onerous or confusing to create a full function definition.
The two forms are parsed identically (_they are both function definitions_), but in the case of [`lambdas`][lambda], the function name is always "lambda" and the expression cannot contain statements or annotations.
For example, the code above could be re-written to include user-defined functions as opposed to `lambda expressions`:
```python
def add_(x, y):
return x + y
def mul_(x, y):
return x * y
def div_(x, y):
return x//y
def sub_(x, y):
return x - y
def answer(question):
operations = {'minus': sub_,'plus': add_,'multiplied': mul_,'divided': div_}
if not question.startswith("What is") or "cubed" in question:
raise ValueError("unknown operation")
question = question.removeprefix("What is").removesuffix("?").strip()
if question.isdigit():
return int(question)
if not question:
raise ValueError("syntax error")
equation = question.replace("by", "").split()
while len(equation) > 1:
try:
x_value, operation, y_value, *rest = equation
equation = [operations[operation](int(x_value), int(y_value)),
*rest]
except:
raise ValueError("syntax error")
return equation[0]
```
However, this makes the code more verbose and does not improve readability.
In addition, the functions need to carry a trailing underscore to avoid potential shadowing or name conflict.
It is better and cleaner in this circumstance to use `lambda expressions` for the functions - although it could be argued that importing and using the methods from `operator` is even better and clearer.
[approach-import-callables-from-operator]: https://exercism.org/tracks/python/exercises/wordy/approaches/import-callables-from-operator
[approach-string-list-and-dict-methods]: https://exercise.org/tracks/python/exercises/wordy/approaches/string-list-and-dict-methods
[filter]: https://docs.python.org/3/library/functions.html#filter
[functools-reduce]: https://docs.python.org/3/library/functools.html#functools.reduce
[key-functions]: https://docs.python.org/3/howto/sorting.html#key-functions
[lambda]: https://docs.python.org/3/reference/expressions.html#lambda
[lambdas]: https://docs.python.org/3/howto/functional.html#small-functions-and-the-lambda-expression
[map]: https://docs.python.org/3/library/functions.html#map

View File

@@ -0,0 +1,6 @@
OPERATIONS = {
'minus': lambda a, b: a - b,
'plus': lambda a, b: a + b,
'multiplied': lambda a, b: a * b,
'divided': lambda a, b: a / b
}

View File

@@ -0,0 +1,266 @@
# Recursion
[Any function that can be written iteratively (_with loops_) can be written using recursion][recursion-and-iteration], and [vice-versa][recursion-is-not-a-superpower].
A recursive strategy [may not always be obvious][looping-vs-recursion] or easy — but it is always possible.
So the `while-loop`s used in other approaches to Wordy can be re-written to use recursive calls.
That being said, Pyton famously does not perform [tail-call optimization][tail-call-optimization], and limits recursive calls on the stack to a depth of 1000 frames, so it is important to only use recursion where you are confident that it can complete within the limit (_or something close to it_).
[Memoization][memoization] and other strategies in [dynamic programming][dynamic-programming] can help to make recursion more efficient and "shorter" in Python, but it's always good to give it careful consideration.
Recursion works best with problem spaces that resemble trees, include [backtracking][backtracking], or become progressively smaller.
Some examples include financial processes like calculating [amortization][amortization] and [depreciation][depreciation], tracking [radiation reduction through nuclei decay][nuclei-decay], and algorithms like [biscetion search][bisection-search], [depth-firs search][dfs], and [merge sort][merge-sort]_).
Other algorithms such as [breadth-first search][bfs], [Dijkstra's algorithm][dijkstra], and [Bellman-Ford Algorithm][bellman-ford] lend themselves better to iteration.
```python
from operator import add, mul, sub
from operator import floordiv as div
# Define a lookup table for mathematical operations
OPERATIONS = {"plus": add, "minus": sub, "multiplied": mul, "divided": div}
def answer(question):
# Call clean() and feed it to calculate()
return calculate(clean(question))
def clean(question):
# It's not a question unless it starts with 'What is'.
if not question.startswith("What is") or "cubed" in question:
raise ValueError("unknown operation")
# Remove the unnecessary parts of the question and
# parse the cleaned question into a list of items to process.
# The wrapping () invoke implicit concatenation for the chained functions
return (question.removeprefix("What is")
.removesuffix("?")
.replace("by", "")
.strip()).split() # <-- this split() turns the string into a list.
# Recursively calculate the first piece of the equation, calling
# calculate() on the product + the remainder.
# Return the solution when len(equation) is one.
def calculate(equation):
if len(equation) == 1:
return int(equation[0])
else:
try:
# Unpack the equation into first int, operator, and second int.
# Stuff the remainder into *rest
x_value, operation, y_value, *rest = equation
# Redefine the equation list as the product of the first three
# variables concatenated with the unpacked remainder.
equation = [OPERATIONS[operation](int(x_value),
int(y_value)), *rest]
except:
raise ValueError("syntax error")
# Call calculate() with the redefined/partially reduced equation.
return calculate(equation)
```
This approach separates the solution into three functions:
1. `answer()`, which takes the question and calls `calculate(clean())`, returning the answer to the question.
2. `clean()`, which takes a question string and returns a `list` of parsed words and numbers to calculate from.
3. `calculate()`, which performs the calculations on the `list` recursively, until a single number (_the base case check_) is returned as the answer — or an error is thrown.
The cleaning logic is separate from the processing logic so that the cleaning steps aren't repeated over and over with each recursive `calculate()` call.
This separation also makes it easier to make changes in processing or calculating without creating conflict or confusion.
Note that `calculate()` performs the same steps as the `while-loop` from [Import Callables from the Operator Module][approach-import-callables-from-operator] and others.
The difference being that the `while-loop` test for `len()` 1 now occurs as an `if` condition in the function (_the base case_), and the "looping" is now a call to `calculate()` in the `else` condition.
`calculate()` can also use many of the strategies detailed in other approaches, as long as they work with the recursion.
`clean()` can also use any of the strategies detailed in other approaches, two of which are below:
```python
# Alternative 1 to the chained calls is to use a list-comprehension:
return [item for item in
question.strip("?").split()
if item not in ("What", "is", "by")] #<-- The [] of the comprehension invokes implicit concatenation.
# Alternative 2 is the built-in filter(), but it can be somewhat hard to read.
return list(filter(lambda x:
x not in ("What", "is", "by"),
question.strip("?").split())) #<-- The () in list() also invokes implicit concatenation.
```
## Variation 1: Use Regex for matching, cleaning, and calculating
```python
import re
from operator import add, mul, sub
from operator import floordiv as div
# This regex looks for any number 0-9 that may or may not have a - in front of it.
DIGITS = re.compile(r"-?\d+")
# These regex look for a number (x or y) before and after a phrase or word.
OPERATORS = {
mul: re.compile(r"(?P<x>-?\d+) multiplied by (?P<y>-?\d+)"),
div: re.compile(r"(?P<x>-?\d+) divided by (?P<y>-?\d+)"),
add: re.compile(r"(?P<x>-?\d+) plus (?P<y>-?\d+)"),
sub: re.compile(r"(?P<x>-?\d+) minus (?P<y>-?\d+)"),
}
# This regex looks for any digit 0-9 (optionally negative) followed by any valid operation,
# ending in any digit (optionally negative).
VALIDATE = re.compile(r"(?P<x>-?\d+) (multiplied by|divided by|plus|minus) (?P<y>-?\d+)")
def answer(question):
if (not question.startswith( "What is") or "cubed" in question):
raise ValueError("unknown operation")
question = question.removeprefix( "What is").removesuffix("?").strip()
# If after cleaning, there is only one number, return it as an int().
if DIGITS.fullmatch(question):
return int(question)
# If after cleaning, there isn't anything, toss an error.
if not question:
raise ValueError("syntax error")
# Call the recursive calculate() function.
return calculate(question)
# Recursively calculate the first piece of the equation, calling
# calculate() on the product + the remainder.
# Return the solution when len(equation) is one.
def calculate(question):
new_question = ""
for symbol, pattern in OPERATORS.items():
# Declare match variable and assign the pattern match as a value
if match := pattern.match(question):
# Attempt to calculate the first num symbol num trio.
# Convert strings to ints where needed.
first_calc = f"{symbol(int(match['x']), int(match['y']))}"
# Strip the pattern from the question
remainder = question.removeprefix(match.group())
# Create new question with first calculation + the remainder
new_question = first_calc + remainder
# Check if there is just a single number, so that it can be returned.
# This is the "base case" of this recursive function.
if DIGITS.fullmatch(new_question):
return int(new_question)
# Check if the new question is still a "valid" question.
# Error out if not.
elif not VALIDATE.match(new_question):
raise ValueError("syntax error")
# Otherwise, call yourself to process the new question.
else:
return calculate(new_question)
```
This variation shows how the dictionary of operators from `operator` can be augmented with [regex][re] to perform string matching for a question.
Regex are also used here to check that a question is a valid and to ensure that the base case (_nothing but digits are left in the question_) is met for the recursive call in `calculate()`.
The regex patterns use [named groups][named-groups] for easy reference, but it's not necessary to do so.
Interestingly, `calculate()` loops through `dict.items()` to find symbols, using a [walrus operator][walrus] to complete successive regex matches and composing an [f-string][f-string] to perform the calculation.
The question remains a `str` throughout the process, so `question.removeprefix(match.group())` is used to "reduce" the original question to form a remainder that is then concatenated with the `f-string` to form a new question.
Because each new iteration of the question needs to be validated, there is an `if-elif-else` block at the end that returns the answer, throws a `ValueError("syntax error")`, or makes the recursive call.
Note that the `for-loop` and VALIDATE use [`re.match`][re-match], but DIGITS validation uses [`re.fullmatch`][re-fullmatch].
## Variation 2: Use Regex, Recurse within the For-loop
```python
import re
from operator import add, mul, sub
from operator import floordiv as div
DIGITS = re.compile(r"-?\d+")
OPERATORS = (
(mul, re.compile(r"(?P<x>.*) multiplied by (?P<y>.*)")),
(div, re.compile(r"(?P<x>.*) divided by (?P<y>.*)")),
(add, re.compile(r"(?P<x>.*) plus (?P<y>.*)")),
(sub, re.compile(r"(?P<x>.*) minus (?P<y>.*)")),
)
def answer(question):
if not question.startswith( "What is") or "cubed" in question:
raise ValueError("unknown operation")
question = question.removeprefix( "What is").removesuffix("?").strip()
if not question:
raise ValueError("syntax error")
return calculate(question)
def calculate(question):
if DIGITS.fullmatch(question):
return int(question)
for operation, pattern in OPERATORS:
if match := pattern.match(question):
return operation(calculate(match['x']), calculate(match['y'])) #<-- the loop is paused here to make the two recursive calls.
raise ValueError("syntax error")
```
This solution uses a `tuple` of nested `tuples` containing the operators from `operator` and regex in place of the dictionaries that have been used in the previous approaches.
This saves some space, but requires that the nested `tuples` be unpacked as the main `tuple` is iterated over (_note the `for operation, pattern in OPERATORS:` in the `for-loop`_ ) so that operations can be matched to strings in the question.
The regex is also more generic than the example above (_anything before and after the operation words is allowed_).
Recursion is used a bit differently here from the previous variations — the calls are placed [within the `for-loop`][recursion-within-loops].
Because the regex are more generic, they will match a `digit-operation-digit` trio in a longer question, so the line `return operation(calculate(match['x']), calculate(match['y']))` is effectively splitting a question into parts that can then be worked on in their own stack frames.
For example:
1. "1 plus -10 multiplied by 13 divided by 2" would match on "1 plus -10" (_group x_) **multiplied by** "13 divided by 2" (_group y_).
2. This would then be re-arranged to `mul(calculate("1 plus -10"), calculate("13 divided by 2"))`
3. At this point the loop would pause as the two recursive calls to `calculate()` spawn
4. The loops would run again — and so would the calls to `calculate()`, until there wasn't any match that caused a split of the question or an error.
5. One at a time, the numbers would then be returned, until the main `mul(calculate("1 plus -10"), calculate("13 divided by 2"))` could be solved, at which point the answer would be returned.
For a more visual picture, you can step through the code on [pythontutor.com][recursion-in-loop-pythontutor].
[amortization]: https://www.investopedia.com/terms/a/amortization.asp
[approach-import-callables-from-operator]: https://exercism.org/tracks/python/exercises/wordy/approaches/import-callables-from-operator
[backtracking]: https://en.wikipedia.org/wiki/Backtracking
[bellman-ford]: https://www.programiz.com/dsa/bellman-ford-algorithm
[bfs]: https://en.wikipedia.org/wiki/Breadth-first_search
[bisection-search]: https://en.wikipedia.org/wiki/Bisection_method
[depreciation]: https://www.investopedia.com/terms/d/depreciation.asp
[dfs]: https://en.wikipedia.org/wiki/Depth-first_search
[dijkstra]: https://www.programiz.com/dsa/dijkstra-algorithm
[dynamic-programming]: https://algo.monster/problems/dynamic_programming_intro
[f-string]: https://docs.python.org/3.11/reference/lexical_analysis.html#formatted-string-literals
[looping-vs-recursion]: https://softwareengineering.stackexchange.com/questions/303242/is-there-anything-that-can-be-done-with-recursion-that-cant-be-done-with-loops
[memoization]: https://inventwithpython.com/recursion/chapter7.html
[merge-sort]: https://www.digitalocean.com/community/tutorials/merge-sort-algorithm-java-c-python
[named-groups]: https://docs.python.org/3/howto/regex.html#non-capturing-and-named-groups
[nuclei-decay]: https://courses.lumenlearning.com/suny-physics/chapter/31-5-half-life-and-activity/
[re-fullmatch]: https://docs.python.org/3/library/re.html#re.full-match
[re-match]: https://docs.python.org/3/library/re.html#re.match
[re]: https://docs.python.org/3/library/re.html
[recursion-and-iteration]: https://web.mit.edu/6.102/www/sp23/classes/11-recursive-data-types/recursion-and-iteration-review.html#:~:text=The%20converse%20is%20also%20true,we%20are%20trying%20to%20solve.
[recursion-in-loop-pythontutor]: https://pythontutor.com/render.html#code=import%20re%0Afrom%20operator%20import%20add,%20mul,%20sub%0Afrom%20operator%20import%20floordiv%20as%20div%0A%0ADIGITS%20%3D%20re.compile%28r%22-%3F%5Cd%2B%22%29%0AOPERATORS%20%3D%20%28%0A%20%20%20%20%28mul,%20re.compile%28r%22%28%3FP%3Cx%3E.*%29%20multiplied%20by%20%28%3FP%3Cy%3E.*%29%22%29%29,%0A%20%20%20%20%28div,%20re.compile%28r%22%28%3FP%3Cx%3E.*%29%20divided%20by%20%28%3FP%3Cy%3E.*%29%22%29%29,%0A%20%20%20%20%28add,%20re.compile%28r%22%28%3FP%3Cx%3E.*%29%20plus%20%28%3FP%3Cy%3E.*%29%22%29%29,%0A%20%20%20%20%28sub,%20re.compile%28r%22%28%3FP%3Cx%3E.*%29%20minus%20%28%3FP%3Cy%3E.*%29%22%29%29,%0A%20%20%20%20%29%0A%0Adef%20answer%28question%29%3A%0A%20%20%20%20if%20not%20question.startswith%28%20%22What%20is%22%29%20or%20%22cubed%22%20in%20question%3A%0A%20%20%20%20%20%20%20%20raise%20ValueError%28%22unknown%20operation%22%29%0A%20%20%20%20%0A%20%20%20%20question%20%3D%20question.removeprefix%28%20%22What%20is%22%29.removesuffix%28%22%3F%22%29.strip%28%29%0A%0A%20%20%20%20if%20not%20question%3A%0A%20%20%20%20%20%20%20%20raise%20ValueError%28%22syntax%20error%22%29%0A%20%20%20%20%0A%20%20%20%20return%20calculate%28question%29%0A%0Adef%20calculate%28question%29%3A%0A%20%20%20%20if%20DIGITS.fullmatch%28question%29%3A%0A%20%20%20%20%20%20%20%20return%20int%28question%29%0A%20%20%20%20%20%20%20%20%0A%20%20%20%20for%20operation,%20pattern%20in%20OPERATORS%3A%0A%20%20%20%20%20%20%20%20if%20match%20%3A%3D%20pattern.match%28question%29%3A%0A%20%20%20%20%20%20%20%20%20%20%20%20return%20operation%28calculate%28match%5B'x'%5D%29,%20calculate%28match%5B'y'%5D%29%29%20%23%3C--%20the%20loop%20is%20paused%20here%20to%20make%20the%20two%20recursive%20calls.%0A%20%20%20%20raise%20ValueError%28%22syntax%20error%22%29%0A%0Aprint%28answer%28%22What%20is%201%20plus%20-10%20multiplied%20by%2013%20divided%20by%202%3F%22%29%29&cumulative=false&curInstr=0&heapPrimitives=nevernest&mode=display&origin=opt-frontend.js&py=311&rawInputLstJSON=%5B%5D&textReferences=false
[recursion-is-not-a-superpower]: https://inventwithpython.com/blog/2021/09/05/recursion-is-not-a-superpower-an-iterative-ackermann/
[recursion-within-loops]: https://stackoverflow.com/questions/4795527/how-recursion-works-inside-a-for-loop
[tail-call-optimization]: https://neopythonic.blogspot.com/2009/04/tail-recursion-elimination.html
[walrus]: https://docs.python.org/3/reference/expressions.html#grammar-token-python-grammar-assignment_expression

View File

@@ -0,0 +1,8 @@
def calculate(equation):
if len(equation) == 1: return int(equation[0])
else:
try:
x_value, operation, y_value, *rest = equation
equation = [OPERATIONS[operation](int(x_value), int(y_value)), *rest]
except: raise ValueError("syntax error")
return calculate(equation)

View File

@@ -0,0 +1,98 @@
# Regex and the Operator Module
```python
import re
from operator import add, mul, sub
from operator import floordiv as div
OPERATIONS = {"plus": add, "minus": sub, "multiplied by": mul, "divided by": div}
REGEX = {
'number': re.compile(r'-?\d+'),
'operator': re.compile(f'(?:{"|".join(OPERATIONS)})\\b')
}
# Helper function to extract a number from the question.
def get_number(question):
# Match a number.
pattern = REGEX['number'].match(question)
# Toss an error if there is no match.
if not pattern:
raise ValueError("syntax error")
# Remove the matched pattern from the question, and convert
# that same pattern to an int. Return the modified question and the int.
return [question.removeprefix(pattern.group(0)).lstrip(),
int(pattern.group(0))]
# Helper function to extract an operation from the question.
def get_operation(question):
# Match an operation word
pattern = REGEX['operator'].match(question)
# Toss an error if there is no match.
if not pattern:
raise ValueError("unknown operation")
# Remove the matched pattern from the question, and look up
# that same pattern in OPERATIONS. Return the modified question and the operator.
return [question.removeprefix(pattern.group(0)).lstrip(),
OPERATIONS[pattern.group(0)]]
def answer(question):
prefix = "What is"
# Toss an error right away if the question isn't valid.
if not question.startswith(prefix):
raise ValueError("unknown operation")
# Clean the question by removing the suffix and prefix and whitespace.
question = question.removesuffix("?").removeprefix(prefix).lstrip()
# the question should start with a number
question, result = get_number(question)
# While there are portions of the question left, continue to process.
while len(question) > 0:
# can't have a number followed by a number
if REGEX['number'].match(question):
raise ValueError("syntax error")
# Call get_operation and unpack the result
# into question and operation.
question, operation = get_operation(question)
# Call get_number and unpack the result
# into question and num
question, num = get_number(question)
# Perform the calculation, using result and num as
# arguments to operation.
result = operation(result, num)
return result
```
This approach uses two dictionaries: one of operations imported from `operators`, and another that holds regex for matching digits and matching operations in the text of a question.
It defines two "helper" functions, `get_number()` and `get_operation`, that take a question and use the regex patterns to remove, convert, and return a number (_`get_number()`_) or an operation (_`get_operation()`_), along with a modified "new question".
In the `answer()` function, the question is checked for validity (_does it start with "What is"_), and a `ValueError("unknown operation")` it raised if it is not a valid question.
Next, the question is cleaned with [`str.removeprefix`][removeprefix] & [`str.removesuffix`][removesuffix], removing "What is" and "?".
Left-trailing white space is stripped with the help of [`lstrip()`][lstrip].
After that, the variable `result` is declared with an initial value from `get_number()`.
The question is then iterated over via a `while-loop`, which calls `get_operation()` and `get_number()` — "reducing" the question by removing the leading numbers and operator.
The return values from each call are [unpacked][unpacking] into a "leftover" question portion, and the number or operator.
The returned operation is then made [callable][callable] using `()`, with result and the "new" number (_returned from `get_number()`_) passed as arguments.
The `loop` then proceeds with processing of the "new question", until the `len()` is 0.
Once there is no more question to process, `result` is returned as the answer.
[callable]: https://treyhunner.com/2019/04/is-it-a-class-or-a-function-its-a-callable/
[lstrip]: https://docs.python.org/3/library/stdtypes.html#str.lstrip
[removeprefix]: https://docs.python.org/3.9/library/stdtypes.html#str.removeprefix
[removesuffix]: https://docs.python.org/3.9/library/stdtypes.html#str.removesuffix
[unpacking]: https://treyhunner.com/2018/10/asterisks-in-python-what-they-are-and-how-to-use-them/

View File

@@ -0,0 +1,8 @@
while len(question) > 0:
if REGEX['number'].match(question):
raise ValueError("syntax error")
question, operation = get_operation(question)
question, num = get_number(question)
result = operation(result, num)

View File

@@ -0,0 +1,161 @@
# String, List, and Dictionary Methods
```python
OPERATIONS = {"plus": '+', "minus": '-', "multiplied": '*', "divided": '/'}
def answer(question):
if not question.startswith("What is") or "cubed" in question:
raise ValueError("unknown operation")
question = question.removeprefix("What is").removesuffix("?").strip()
if not question:
raise ValueError("syntax error")
formula = []
for operation in question.split():
if operation == 'by':
continue
else:
formula.append(OPERATIONS.get(operation, operation))
while len(formula) > 1:
try:
x_value, y_value = int(formula[0]), int(formula[2])
symbol = formula[1]
remainder = formula[3:]
if symbol == "+":
formula = [x_value + y_value] + remainder
elif symbol == "-":
formula = [x_value - y_value] + remainder
elif symbol == "*":
formula = [x_value * y_value] + remainder
elif symbol == "/":
formula = [x_value / y_value] + remainder
else:
raise ValueError("syntax error")
except:
raise ValueError("syntax error")
return int(formula[0])
```
Within the `answer()` function, the question is first checked for "unknown operations" by validating that it starts with "What is" ([`str.startswith`][startswith], [`str.endswith`][endswith]) and does not include the word "cubed" (_which is an invalid operation_).
This eliminates all the [current cases][unknown-operation-tests] where a [`ValueError("unknown operation")`][value-error] needs to be [raised][raise-statement].
Should the definition of a question expand or change, this strategy would need to be revised.
The question is then "cleaned" by removing the prefix "What is" and the suffix "?" ([`str.removeprefix`][removeprefix], [`str.removesuffix`][removesuffix]) and [stripping][strip] any leading or trailing whitespaces.
If the question is now an empty string, a `ValueError("syntax error")` is raised.
Next, the question is [split][split] into a `list` and iterated over, with each element looked up and replaced from the OPERATIONS dictionary.
The [`dict.get`][dict-get] method is used for this, as it takes a default argument for when a [`KeyError`][keyerror] is thrown.
Here the default for `dict.get` is set to the element being iterated over, which is effectively _"if not found, skip it"_.
This avoids error handling, extra logic, or interruption when an element is not found.
One exception here is the word "by", which is explicitly skipped within the `for-loop`, so that it doesn't appear in the formula to be processed.
This filtering out could also be accomplished by using [`str.replace`][str-replace] in the cleaning step or during the `split` step.
The results of iterating through the question are then appended to a new formula `list`.
````exercism/note
There are a couple of common alternatives to the `loop-append`:
1. [`list-comprehensions`][list-comprehension] duplicate the same process in a more succinct and declarative fashion:
```python
formula = [OPERATIONS.get(operation, operation) for
operation in question.split() if operation != 'by']
```
2. The built-in [`filter()`][filter] and [`map()`][map] functions used with a [`lambda`][lambdas] to process the elements of the list.
This is identical in process to both the `loop-append` and the `list-comprehension`, but might be easier to reason about for those coming from a more functional programming language:
```python
formula = list(map(lambda x : OPERATIONS.get(x, x),
filter(lambda x: x != "by", question.split())))
```
[list-comprehension]: https://docs.python.org/3/tutorial/datastructures.html#list-comprehensions
[lambdas]: https://docs.python.org/3/howto/functional.html#small-functions-and-the-lambda-expression
[filter]: https://docs.python.org/3/library/functions.html#filter
[map]: https://docs.python.org/3/library/functions.html#map
````
After the formula `list` is composed, it is processed in a `while-loop`.
The processing within the `loop` is wrapped in a [try-except][handling-exceptions] block to trap any errors and raise them as `ValueError("syntax error")`.
While each type of error could be checked for individually, it is not necessary since only `ValueError("syntax error")` is required here.
1. `x_value` and `y_value` are assigned to the first element and third element of the list using [bracket notation][bracket-notation], and converted to integers.
- Rather than indexing and slicing, [concept: unpacking and multiple assignment](/tracks/python/concepts/unpacking-and-multiple-assignment) can be used to assign the variables.
This does require a modification to the returned formula `list`:
```python
x_value, operation, y_value, *remainder = formula # <-- Unpacking won't allow conversion to int() here.
...
if symbol == "+":
formula = [int(x_value) + int(y_value)] + remainder # <-- Instead, conversion to int() must happen here.
...
return int(formula[0])
```
2. `symbol` is assigned to the second element of the list.
3. `remainder` is assigned to a [slice][list-slice] of everything else in the `list`.
The `symbol` is then tested in the `if-elif-else` block and the formula `list` is modified by calculating the operation on `x_value` and `y_value` and then appending whatever part of the question remains.
Once the formula `list` is calculated down to a number, that number is converted to an `int` and returned as the answer.
````exercism/note
Introduced in Python 3.10, [structural pattern matching][structural-pattern-matching] can be used to replace the `if-elif-else` chain in the `while-loop`.
In some circumstances, this could be easier to read and/or reason about:
```python
while len(formula) > 1:
try:
x_value, symbol, y_value, *remainder = formula
match symbol:
case "+":
formula = [int(x_value) + int(y_value)] + remainder
case "-":
formula = [int(x_value) - int(y_value)] + remainder
case "*":
formula = [int(x_value) * int(y_value)] + remainder
case "/":
formula = [int(x_value) / int(y_value)] + remainder
case _:
raise ValueError("syntax error")
except:
raise ValueError("syntax error")
```
[structural-pattern-matching]: https://peps.python.org/pep-0636/
````
[bracket-notation]: https://docs.python.org/3/library/stdtypes.html#common-sequence-operations
[dict-get]: https://docs.python.org/3/library/stdtypes.html#dict.get
[endswith]: https://docs.python.org/3.9/library/stdtypes.html#str.endswith
[handling-exceptions]: https://docs.python.org/3.11/tutorial/errors.html#handling-exceptions
[keyerror]: https://docs.python.org/3/library/exceptions.html#KeyError
[list-slice]: https://www.pythonmorsels.com/slicing/
[raise-statement]: https://docs.python.org/3/reference/simple_stmts.html#the-raise-statement
[removeprefix]: https://docs.python.org/3.9/library/stdtypes.html#str.removeprefix
[removesuffix]: https://docs.python.org/3.9/library/stdtypes.html#str.removesuffix
[split]: https://docs.python.org/3.9/library/stdtypes.html#str.split
[startswith]: https://docs.python.org/3.9/library/stdtypes.html#str.startswith
[str-replace]: https://docs.python.org/3/library/stdtypes.html#str.replace
[strip]: https://docs.python.org/3.9/library/stdtypes.html#str.strip
[unknown-operation-tests]: https://github.com/exercism/python/blob/main/exercises/practice/wordy/wordy_test.py#L58-L68
[value-error]: https://docs.python.org/3.11/library/exceptions.html#ValueError

View File

@@ -0,0 +1,8 @@
try:
x_value, y_value, symbol, remainder = int(formula[0]), int(formula[2]), formula[1], formula[3:]
if symbol == "+": formula = [x_value + y_value] + remainder
elif symbol == "-": formula = [x_value - y_value] + remainder
elif symbol == "*": formula = [x_value * y_value] + remainder
elif symbol == "/": formula = [x_value / y_value] + remainder
else: raise ValueError("syntax error")
except: raise ValueError("syntax error")

View File

@@ -2,11 +2,12 @@
## Exception messages
Sometimes it is necessary to [raise an exception](https://docs.python.org/3/tutorial/errors.html#raising-exceptions). When you do this, you should always include a **meaningful error message** to indicate what the source of the error is. This makes your code more readable and helps significantly with debugging. For situations where you know that the error source will be a certain type, you can choose to raise one of the [built in error types](https://docs.python.org/3/library/exceptions.html#base-classes), but should still include a meaningful message.
Sometimes it is necessary to [raise an exception][raise-an-exception]. When you do this, you should always include a **meaningful error message** to indicate what the source of the error is. This makes your code more readable and helps significantly with debugging. For situations where you know that the error source will be a certain type, you can choose to raise one of the [built in error types][built-in-errors], but should still include a meaningful message.
This particular exercise requires that you use the [raise statement](https://docs.python.org/3/reference/simple_stmts.html#the-raise-statement) to "throw" a `ValueError` if the question passed to `answer()` is malformed/invalid, or contains an unknown operation. The tests will only pass if you both `raise` the `exception` and include a message with it.
This particular exercise requires that you use the [raise statement][raise-statement] to "throw" a `ValueError` if the question passed to `answer()` is malformed/invalid, or contains an unknown operation. The tests will only pass if you both `raise` the `exception` and include a message with it.
To raise a [`ValueError`][value-error] with a message, write the message as an argument to the `exception` type:
To raise a `ValueError` with a message, write the message as an argument to the `exception` type:
```python
# if the question contains an unknown operation.
@@ -15,3 +16,22 @@ raise ValueError("unknown operation")
# if the question is malformed or invalid.
raise ValueError("syntax error")
```
To _handle_ a raised error within a particular code block, one can use a [try-except][handling-exceptions] :
```python
while len(equation) > 1:
try:
x_value, operation, y_value, *rest = equation
...
except:
raise ValueError("syntax error")
```
[built-in-errors]: https://docs.python.org/3.11/library/exceptions.html#built-in-exceptions
[handling-exceptions]: https://docs.python.org/3.11/tutorial/errors.html#handling-exceptions
[raise-an-exception]: https://docs.python.org/3/tutorial/errors.html#raising-exceptions
[raise-statement]: https://docs.python.org/3/reference/simple_stmts.html#the-raise-statement
[value-error]: https://docs.python.org/3.11/library/exceptions.html#ValueError