I decided to try and make a code that can calculate the n-th term of any sequence (any sequence that only contains n^a
, sequences like a^n
and n!
won't work) and calculate the next numbers of the sequence to further my understanding of Python, here it is:
from math import factorial
from fractions import Fraction
class nthTerm:
"""
Calculates the nth term (or stores it) with reasonable accuracy, and allows you
to enter a 'n' value and get the value based on the nth term.
If 'sequence' is set to False, you can input a list with an nth term instead of
a sequence. The list needs to be written the same way as self.calc is if
sequence is set to false so [[3, 2], [1, 1], [2, 0]] would be 3n^2 + n + 2 for example
and the input of that to __init__ would look like: nthTerm([3, 2], [1, 1], [2, 0], sequence = False
"""
def __init__(self, *values, sequence = True):
self.sequence = sequence
self.values = []
if sequence:
#if a sequence was entered
oldValues = values
self.values = values
self.fraction = True # changes the output type in str(self) from fraction to decimal - not implemented yet
self.calc = [] # the n-th term pattern will be saved here
difference = []
while [values[0]] * len(values) != values:
pattern = False
iterCount = 1
while not pattern: # while the differences of the values aren't all the same
differenceLen = len(values) - 1
for n in range(differenceLen):
difference.append(Fraction(values[n+1]) - Fraction(values[n])) # gets the difference of each of the values
pattern = [difference[0]] * differenceLen == difference # checks if all of the values in 'pattern' are equal (if so, then the pattern is found)
if not pattern: # didn't find a pattern with n^x, lets raise the iterCount and try with n^x+1
values = difference
difference = []
iterCount += 1
coefficientOfN = Fraction(difference[0], factorial(iterCount)) # this is essentially difference[0] / factorial(iterCount)
# print(f"{str(coefficientOfN)}n^{iterCount} found in the sequence!") <- for testing
self.calc.append([coefficientOfN, iterCount]) # adds the found pattern to self.calc so it can be used in calculations later on
patternFound = [coefficientOfN * (n ** iterCount) for n in range(1, len(self.values)+1)] # makes a list of the nth term of the found pattern and...
values = []
difference = []
for n in range(len(self.values)):
values.append(Fraction(self.values[n]) - Fraction(patternFound[n])) # ...takes that away from the original values
self.values = values
self.values = list(oldValues)
if values[0] != 0: # if there's a common number in the list that isn't 0 (eg [1,1,1,1,1])
# print(f"{values[0]} found in the equation!") <- for testing
self.calc.append([values[0], 0]) # takes that value away from the list and adds it to the nth term
else:
# sequence not entered, the nth term was entered instead
# the nth term should've been entered in the same way as self.calc stores it:
# as a 2D list: [[3, 2], [1, 1], [2, 0]] for example would be 3n^2 + n + 2
for value in values:
# all of the lists in 'values' should have a length of 2, since they only contain the
# coefficient of n and the power of n
if len(value) != 2:
raise ValueError(f"'{value}' does not have a length of 2, which is required as an input when 'sequence' is set to False.")
powerList = [values[n][1] for n in range(len(values))] #makes a list of all of the powers
# there shouldn't be any repeats in 'powerlist', check for that is below:
# we can do this by converting the list into a set (which gets rid of any duplicate values) and check if the length is erqual to the original list
if len(powerList) != len(set(powerList)):
raise ValueError("Many lists with the same power value inputted.")
# input verification above
self.calc = list(values)
def __repr__(self):
if self.values == [] and not self.sequence: #if sequence was set to False, pretty much.
return self.__str__() #if there aren't any values to output, copy the nth term output from __str__
else:
addString = [str(value) for value in self.values] # turns all values in self.values into strings -
return f"nthTerm({', '.join(addString)})" # - so ''.join can be used on them here
def __str__(self):
# nthTermData[0] = a (coefficient of n)
# nthTermData[1] = b (power of n)
# data is presented like: an^b
addString = ' + '.join(((f"{str(nthTermData[0])}" if nthTermData[0] != 1 or nthTermData[1] == 0 else "") +
("n" if nthTermData[1] > 0 else "") +
(f"^{nthTermData[1]}" if nthTermData[1] > 1 else ""))
for nthTermData in self.calc)
return f"nthTerm({addString})".replace("+ -", "- ")
def calculate(self, n):
"""
Calculates the nth value of the sequence of numbers provided.
"""
if not isinstance(n, int): # if the value of n isn't an integer - since it has to be one
raise ValueError("nthTerm.calculate(n) only accepts integer values of 'n'.")
if n < 1:
raise ValueError(f"'{n}' is not a non-negative integer higher than 0.")
if n - 1 < len(self.values): # value of n being looked for is already in the list values - can just return that
return self.values[n - 1]
else: # value isn't in self.values - calculate it instead
valueOfN = 0
# nthTermData[0] = a (coefficient of n)
# nthTermData[1] = b (power of n)
# data is presented like: an^b
for nthTermData in self.calc:
valueOfN += nthTermData[0] * (n ** nthTermData[1]) # valueOfN += an^b, thats all this is
return int(valueOfN) if valueOfN.denominator == 1 else valueOfN # turns the value into an integer if the denominator is 1
I've tried to make the code look readable by adding spacing and comments, although I'm not sure if I've overdone it with the comments & spacing. Also, I feel as if there are a lot of optimisations that could be make with this code, but I can't find any.
As for the code itself, I don't think there's any bugs with it (however, my testing wasn't extensive), although I have used some questionable coding practices that I might've been able to avoid.
Is there any optimisation (in terms of readability, efficiency and pythonicity?) that could've been implemented?
EDIT:
here's some ways to use the class:
>>> a = nthTerm(2, 6, 12, 20, 30)
>>> a
nthTerm(2, 6, 12, 20, 30)
>>> str(a)
nthTerm("n^2 + n")
>>> a.calculate(6) #calculates the next term
42
>>> # for adding the nth term directly:
>>> b = nthTerm([4, 2], [3, 1], sequence = False)
>>> # would give an nth term of 4n^2 + 3n^1 (or just 3n)
-
2\$\begingroup\$ Can you show typical invocation and expected results? \$\endgroup\$Reinderien– Reinderien2022年07月09日 16:20:51 +00:00Commented Jul 9, 2022 at 16:20
2 Answers 2
So, let's unpack this a bit.
You have a class called 'NthTerm', you can use that to make an nth_term object, which can be used to calculate (with n) the... nth term?
It sounds like your class is mis-named, perhaps it should be 'Sequence', or in this case 'Polynomial'.
Continuing with that renaming, things get a bit easier.
You have two ways of creating a Polynomial, you can fit it to a sequence of values, or you can give the coefficients for the polynomial term directly. You switch between these behaviours by using an optional argument.
That's okay, but why don't we simplify that down a bit more by using an alternate constructor?
We can make the init method always take coefficients, and drop the 'sequence' flag. And then create an alternate constructor 'from_sequence', which works out the coefficients based on the sequence and then gives you a polynomial (by calling the constructor with those coefficients).
@classmethod
def from_sequence(cls, *values):
fitted_coefficients = fit_coefficients(values) # A function with your logic from before
return cls(*fitted_coefficients)
And on the topic of renaming, 'calculate' is very vague, why don't we try calling it 'nth_term'?
Now we have something that looks more like this:
>>> a = Polynomial.from_sequence(2, 6, 12, 20, 30)
>>> a
Polynomial([1, 2], [1, 1])
>>> str(a)
Polynomial("n^2 + n")
>>> a.nth_term(6) #calculates the next term
42
>>> # for making the polynomial directly:
>>> b = Polynomial([4, 2], [3, 1])
>>> # would give a polynomial of 4n^2 + 3n^1 (or just 3n)
I've tried to make the code look readable by adding spacing and comments
Comments are in my opinion a double edged sword. They give an easy excuse to say "my code is readable, it has lots of comments!", when in truth it just bloats up the code. An example of a comment that does nothing is the following from your code:
if sequence:
#if a sequence was entered
The comment describes what the python code already describes, and in my opinion is therefore unnecessarily bloating up the code. Which brings us to my first point:
I belive that code should be self documenting.
If the code already describes its intent, we don't need comments anymore because readers can understand the intent by looking at the code. That word intent is important, because naming things after how they work usually achieves the opposite of understandability. This is because it requires the reader to keep in mind all kind of different things in the current scope, and our brain is not equipped to do so.
So how do I name appropriately?
- I usually always write out abbreviations unless they are well known in my context. Sorry C devs.
- I never name my variables tmp if they live longer than two lines of code.
- It doesn't matter which naming convention you use: camelCase, PascalCase, lowercase, lowercase_separated_by_underscores, ... or whatever, as long as you do it consistently. And your team agrees on it, if you're working in a team.
And probably most importantly:
- I name my variables after their purpose.
- Variables are objects, their names are nouns.
- I name my functions after their intent, not their implementation details.
- Functions do stuff, their names are verbs.
A small exception to the noun / verb thing are booleans: I usually write them as is_condition
, e.g. user_is_logged_in
. I try to avoid negative terms in these conditions e.g. user_is_not_logged_in
or user_is_logged_out
- although user_is_logged_out
sometimes is justifiable.
Long methods
Coming back to the quote from the beginning
I've tried to make the code look readable by adding spacing and comments
and my statement that they are a double edged sword: Sometimes, comments can be helpful. Sometimes, some whacky mechanic can just not be expressed in proper names and a comment is needed - this is usually a very rare case though.
Where they are more helpful though, is with determining when blocks of code can be extracted to new functions. We can look at an example from your code (I've cut out the unnecessary parts for this example):
# all of the lists in 'values' should have a length of 2, since they only contain the
# coefficient of n and the power of n
if len(value) != 2:
raise ValueError(f"'{value}' does not have a length of 2[...]")
This is a very short example, but it is sufficient to illustrate my point.
The comment describes the intent of the code block: check that each value is a tuple with 2 elements - a pair. If we extract that code block to a function
def validateIsPair(pair: list):
if len(pair) != 2:
raise ValueError(f"'{pair}' does not have a length of 2[...]")
and we call that function in the original code, we know understand why it is there, without having to know how it works - which is a release on brain capacity.
We can make this work for us, and achieve understandable code by keeping functions short - in most cases 5 or less lines. This forces us to name blocks of code, and if we're doing it properly, they are named after their intent and make it easy to understand the code.
Now, how do I make already messy code less messy? The answer is refactoring.
In regards to messy and long methods, I like to use these general rules of thumb:
- Is the method longer than 5 lines? -> Extract some of it.
- Does the method have more than one level of indention? E.g. nested ifs or loops? -> Extract until it only has one level indention.
If you want to read some more about code understandability, here's my post about the most common anti patterns I see and how to solve them. Refactoring.guru is also a very good resource to start, and if you want to dive deep I can recommend Uncle Bobs Clean Code.