3
\$\begingroup\$

I've made a code in Python 3 which converts a polynomial with integer coefficients (in simplified integer form) from string form to a list of tuples of the form (coefficient, exponent, variable). It is assumed the variable name will be one character long, and will be a character in the alphabet.

For example:

>>> get_list_of_powers("3x^2 - x + 1")
[(3, 2, 'x'), (-1, 1, 'x'), (1, 0, None)]

>>> get_list_of_powers("t^3 - 3t^2 + t + 1")
[(1, 3, 't'), (-3, 2, 't'), (1, 1, 't'), (1, 0, None)]

>>> get_list_of_powers("t^3 - 3t^2 + t +") # the + doesn't have a term just after it, so this is invalid
...
ValueError: Invalid polynomial: t^3-3t^2+t+.

>>> get_list_of_powers("x + 2^5") # 2^5 is not in simplified integer form, so this is invalid
...
ValueError: Invalid polynomial: x+2^5.

>>> get_list_of_powers("t^3 - 3t^2 + x + 1")
[(1, 3, 't'), (-3, 2, 't'), (1, 1, 'x'), (1, 0, None)]

>>> get_list_of_powers("x + x + 1") # My output doesn't need to simplify the polynomial
[(1, 1, 'x'), (1, 1, 'x'), (1, 0, None)]

with each tuple (a, b, x) in the list representing an expression ax^b in the polynomial. Note that the constant term of the polynomial will always have None as the variable name.

When I say "simplified integer form", I mean an integer that is written explicitly in terms of only digits. So, for example, 1e5 is not in "simplified integer form", neither is 2^5, but 10000 would be.

My code for the function get_list_of_powers is shown below:

def get_list_of_powers(polynomial):

    def not_empty_match(match):
        return any(match)
    
    def parse_match(match):
        if not match[0] or match[0] == "+":
            coefficient = 1

        elif match[0] == "-":
            coefficient = -1

        else:
            coefficient = int(match[0])

        if match[1] and match[2] and match[3]:
            exponent = int(match[3])

        elif match[1] and not match[2] and not match[3]:
            exponent = 1

        elif match[0] and match[0] not in ("+", "-") and not match[1] and not match[2] and not match[3]:
            exponent = 0

        else:
            # Handles cases that my validation pattern doesn't spot e.g. x + 2^5
            raise ValueError(f"Invalid polynomial: {polynomial}.")

        return (coefficient, exponent, match[1] if match[1] else None)

    validation_pattern = re.compile(r"""(   
                                ([-+]?[0-9]*)   # get sign and value of coefficient
                                ([a-zA-Z]?)     # get letter
                                (\^?)           # get power symbol
                                ([0-9]*)        # get exponent
                                )*              # any amount of valid singular polynomial expressions
                                """, re.VERBOSE)
    
    pattern = re.compile(r"""   
                    ([-+]?[0-9]*)   # get sign and value of coefficient
                    ([a-zA-Z]?)     # get letter
                    (\^?)           # get power symbol
                    ([0-9]*)        # get exponent
                    """, re.VERBOSE)

    polynomial = polynomial.replace(" ", "")
    
    if not re.fullmatch(validation_pattern, polynomial):
        raise ValueError(f"Invalid polynomial: {polynomial}.")
    
    power_list = re.findall(pattern, polynomial)
    power_list = filter(not_empty_match, power_list)
    return list(map(parse_match, power_list))

I don't like the redundancy of the two patterns validation_pattern and pattern looking almost exactly the same, and I also don't like how my input validation is split in two places, since re.fullmatch(validation_pattern, polynomial) by itself doesn't work for all cases (e.g. the invalid polynomial x + 2^5). Is there a way for my code to repeat itself less?

I also think that my regular expression could also be improved, as my regular expression currently matches an empty string (and hence empty matches need to be explicitly filtered out). Is there a way for this to be prevented?

\$\endgroup\$
1
  • \$\begingroup\$ Just an observation: Your program accepts some polynomials in more than one variable (e.g. x+y), but for example not x*y. \$\endgroup\$
    – Martin R
    Commented Dec 8 at 10:34

1 Answer 1

2
\$\begingroup\$

problem name

Names strongly influence how we think about a problem. I'm not happy with the one you chose. Consider your \$-3t^2 + x\$ example. Prefer to use single_variable_polynomial, or spell out that restriction in a """docstring""", as that seems to be the focus of the problem statement. After seeing t used, we might want the code to raise when it then sees x. Alternatively, change the implementation so it will accept a term containing multiple variables, e.g. \$-3t^2x^3\$.

Given a "single variable" restriction, consider reporting results in this format:

>>> get_list_of_powers("3x^2 - x + 2")
('x', [(3, 2), (-1, 1), (2, 0)])

That lets us get rid of the awkward None. Notice that \$x^0\$ is unity, which is easily multiplied by two.

In the following I will retain your original problem statement, including the odd (unstated) "single variable per term" restriction.

nested functions

def get_list_of_powers(polynomial):

    def not_empty_match(match):
        ...
    
    def parse_match(match):

Summary advice: don't do it! Don't nest.

Nesting can lead to the same coupling issues that global variables cause. Now, sometimes a shared namespace (for parameters and locals defined within a function) is helpful and the technique makes sense. But parse_match() is not e.g. accessing the polynomial parameter -- there's just no reason for the nesting. Use modules, or class methods, if you feel a need to organize the code. Use an _ underscore prefix to mark a helper as _private, if you're trying to reduce the Public API footprint or otherwise make the code more coherent. My concern is for the scope of variables like power_list or the parameters.

The nested OP functions sadly are inaccessible to an automated test suite that you or a subsequent maintenance engineer might eventually create.

If a python function is used in only one place, should it be buried / nested to hide it? No. We hope it will have a second caller, when a unit test is authored. Even if there's only a single caller, the function typically should still appear at top level, perhaps as a private def _parse_match(). PEP 8 explains that _single_leading_underscore should be used to indicate "this is not part of my Public API". Polite, well behaved callers will not import such symbols from your module, and if they do then linters will call them out on it. Organize your code using modules, so if we create some more unrelated functions we'll put them in another file, another module.

Notice that python "global" symbols are just at module scope. So a calling module will naturally keep its namespace clean by defining and importing only what it needs to accomplish its task.

type annotations

Annotations are optional. But they would go a long way toward making those three signatures more self explanatory. And even with annotations, the occasional docstring wouldn't hurt.

regex details

I have my doubts about your regex. I feel it would be better to carefully define what a valid term is, and then later worry about summing terms. Consider \$x^3 - -2x\$, which is not recognized. We could of course write it as \$x^3 + 2x\$. The OP regex conflates "unary minus" with "combine the terms". The problem statement should tackle the notion of "converting to canonical form". And then it could tell us what non-canonical inputs are still acceptable, such as admitting not strictly monotonic exponents, as with your \$x + x\$ example.

Given that everything is optional, you're not exactly "validating" against a grammar. Consider writing a generator function which yields valid terms or raises a diagnostic.

BTW the filter() and map() calls look great -- kudos!

named matching groups

Expressions like int(match[3]) are a little on the cryptic side. Consider replacing comments like "get exponent" with group names:

                                (?P<exponent>[0-9]*)

validation at different levels

You already have some perfectly nice error checking code:

            raise ValueError(f"Invalid polynomial: {polynomial}.")

But you seem to view it as a band-aid fix for what the upper level had a tough time validating. I say, "embrace it!" This seems to be the right level, since the parsed out details are available. Don't even worry about validation at upper level.

Instead, have the upper level focus just on parsing and distinguishing terms.

Many authors of Recursive Descent parsers find an EBNF grammar helpful, both when designing / testing their implementation, and as end-user documentation. You might want one here.

\$\endgroup\$
1
  • \$\begingroup\$ Thanks for the answer! I'd heard that nesting functions made code more coherent, as functions only used in one place shouldn't have a global scope. You've compared local functions to global variables, but I feel like global functions (which are your alternative) are more alike global variables and should lead to more coupling issues. \$\endgroup\$
    – Sam
    Commented Dec 8 at 22:53

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.