Programming style & avoiding null values

Question:

So I’m working my way through Wentworth et al How to Think Like a Computer Scientist a Python 3 guidebook to try and teach myself more about programming. While it’s a fantastic resource, it has very little to say about style and "best practice" for writing in Python 3.

I’m working through one of practice questions in the chapter on conditionals that asks me to write a function that returns a string ‘grade’ when an int or float ‘mark’ is inputted.

My direct question here is about the repetition in the conditionals in the function and the value the function returns. Is it possible to use a loop instead somehow to make it more concise instead of just writing elif statements over and over? Also, the main grade function is returning a null None value; How I can make this function "fruitful" and not print None when it’s called?

Here’s what I’ve written:

def grade(mark):
    grds = ['First','Upper Second','Second','Third','F1 Supp.','F2','F3']
    
    if mark >= 75.0:
        print("Your grade is",grds[0])
    elif mark < 75.0 and mark >= 70.0:
        print("Your grade is",grds[1])
    elif mark < 70.0 and mark >= 60.0:
        print("Your grade is",grds[2])
    elif mark < 60.0 and mark >= 50.0:
        print("Your grade is",grds[3])
    elif mark < 50.0 and mark >= 45.0:
        print("Your grade is",grds[4])
    elif mark < 45.0 and mark >= 40.0:
        print("Your grade is",grds[5])
    elif mark < 40.0: 
        print("Your grade is",grds[6])

def finalmark():
    mark = float(input("Enter your mark"))
    fnlmark = grade(mark)
    return fnlmark

print(finalmark())    
Asked By: valuevillage

||

Answers:

Two very simple things:

  • You never return anything. By default, Python is going to return None. You can fix this by adding a return statement in addition to or in lieu of your print statement.

    def grade(mark):
        grds = ['First','Upper Second','Second','Third','F1 Supp.','F2','F3']
    
        if mark >= 75.0:
            print("Your grade is",grds[0])
            return grds[0]
        elif 75.0 > mark >= 70.0:
            print("Your grade is",grds[1])
            return grds[1]
    
  • You can simplify your expression. Python accepts ranges of expressions analogous to mathematical ranges (e.g. 0 <= x <= 100 is valid Python). You can see an example of that above; I leave cleaning it up and making it more readable as an exercise for the reader.

Answered By: Makoto

First : why does your function return None ?

Because you actually use print, which echoes the text to the user. What you want to use instead is return, which will leave the function and basically say “that’s what I have calculated”.

As there is no value returned, python automatically returns None. If you were in a stricter language, you would probably have an error.

Therefore, you should do this :

return "Your grade is " + grds[0]

Second : how to improve your code ?

The first thing to see is that if the first condition is valid (mark >= 75.0) then, in all the elifs, mark can’t be more (or equal) to 75, which mean that you can, in this case, get rid of every lower than condition in every elif.

Third : how to improve your code (2) ?

Now, as I have told you, return leaves the function. So you can use this to remove the elifs as such :

def grade(mark):
    grds = ['First','Upper Second','Second','Third','F1 Supp.','F2','F3']

    if mark >= 75.0:
        return ("Your grade is " + grds[0])
    if mark >= 70.0:
        return ("Your grade is " + grds[1])
    if mark >= 60.0:
        return ("Your grade is " + grds[2])
    if mark >= 50.0:
        return ("Your grade is " + grds[3])
    if mark >= 45.0:
        return ("Your grade is " + grds[4])
    if mark >= 40.0:
        return ("Your grade is " + grds[5])
    return ("Your grade is " + grds[6])

The issue now is that you have the same code repeated a lot of times. This mean that you can break it into a loop. I would recommend using an array of bounds as such :

def grade(mark):
    grds = ['First','Upper Second','Second','Third','F1 Supp.','F2','F3']
    bounds = [75.0, 70.0, 60.0, 50.0, 45.0, 40.0]
    for i in range(len(bounds)):
        if mark >= bounds[i]:
            return ("Your grade is " + grds[i])
    return ("Your grade is " + grds[-1])

I hope I have explained this clearly, if you have any questions, just ask them below.

Answered By: Alex van Vliet

Rather than using print() in the grade() function, return your result and have the caller print the resulting mark. The grade() function should only be used to return a grade:

def grade(mark):
    grds = ['First','Upper Second','Second','Third','F1 Supp.','F2','F3']

    if mark >= 75.0:
        return grds[0]
    # .. etc

def finalmark():
    mark = float(input("Enter your mark"))
    fnlmark = grade(mark)
    print("Your grade is", fnlmark)

finalmark()

Note that finalmark() is responsible for printing now; that’s the best place for it, as that same function also is responsible for printing the question on the screen and taking user input. Like your version, finalmark() returns None (because that’s the default), and I removed the print() from around the finalmark() call to avoid printing that return value. There’s no point in printing it, finalmark() will never return anything other than None.

You can also remove half of your tests; only the first matching if or elif branch is picked, the rest are skipped. So you can remove tests for what a previous branch already covered:

def grade(mark):
    grds = ['First','Upper Second','Second','Third','F1 Supp.','F2','F3']

    if mark >= 75.0:
        return grds[0]
    elif mark >= 70.0:
        return grds[1]
    elif mark >= 60.0:
        return grds[2]
    elif mark >= 50.0:
        return grds[3]
    elif mark >= 45.0:
        return grds[4]
    elif mark >= 40.0:
        return grds[5]
    else:
        return grds[6]

If the first if mark >= 75.0: test did not match, then there is no need to test for mark < 75.0 anymore, because we have tested for the inverse. Testing for mark >= 70.0 is enough for the next grade. If that fails to match, we know the mark is definitely smaller than 70, so the next test only needs to test if it is larger than 60.0, etc.

Now a pattern emerges that you could build a loop on. You test for a lower bound, and if it matches, you know which index to return. Build a separate list to store the lower bounds:

def grade(mark):
    grds = ['First','Upper Second','Second','Third','F1 Supp.','F2','F3']
    bounds = [75.0, 70.0, 60.0, 50.0, 45.0, 40.0]

    for grade, bound in zip(grds, bounds):
        if mark >= bound:
            return grade

    # there is no lower bound for F3; if the loop didn't find a match,
    # we end up here and can assume the lowest grade.
    return grds[6]

I used the zip() function here to pair up the grade names and the bounds, pairwise. You could also have used the enumerate() function to generate an index along with each grade name, or a for index in range(len(grds)): loop, but I find zip() to work cleaner here.

Next, we can start being clever with the algorithm. The above still tests each grade, from high to low, one by one. That can take up to N steps, for N grades. That’s a linear algorithm, it takes as many steps as there are inputs.

But the grades are sorted, so we could use bisection here; jump to the middle and see if the mark is lower or higher than the current bound. Then pick either half, and test again, until you find a best match. Bisection takes at most Log(N) steps. Python has a very fast implementation included; it assumes values in increasing order, so reverse the grades and boundaries:

import bisect

def grade(mark):
    grds = ['F3', 'F2', 'F1 Supp.', 'Third', 'Second', 'Upper Second', 'First']
    bounds = [40.0, 45.0, 50.0, 60.0, 70.0, 75.0]
    return grds[bisect.bisect_right(bounds, mark)]    

bisect.bisect_right() bisects into bounds to find the ‘insertion point’ for mark, which will be to the right of the same value in the list. So 35.0 would be inserted at 0, 50.0 at 3 (as it is equal or higher), 74.0 at 5 and anything at 75.0 or higher at 6. And those happen to be the exact indices for the matching grades.

Answered By: Martijn Pieters

Below are two pythonic solutions. As a learning problem, there are a few interesting bits to understand: dictionaries with tuple keys, iterating dictionary items, generator expressions, class inheritance.

This isn’t the only way to structure your code. Another way is to set a sequence of boundary scores, as in @MartijnPeter’s answer. However, these are readable and reasonably performant solutions.

In both instances note the importance of the return statement, which is missing in your code. By default, Python returns None.

Functional

def grade(mark):

    grds = {(75, 100.1): 'First',
            (70, 75): 'Upper Second',
            (60, 70): 'Second',
            (50, 60): 'Third',
            (45, 50): 'F1 Supp.',
            (40, 45): 'F2',
            (0, 40): 'F3'}

    return next(v for k, v in grds.items() if k[0] <= mark < k[1])

Object-oriented

The beauty of python is it marries, to some degree, object-oriented and functional programming. Consider the below solution. Efficiency will be similar to the above, but it introduces a construct, subclass dict_range inheriting from dict, which can be reused easily in other scenarios.

class dict_range(dict):
    def __getitem__(self, value):
        return next(self.get(k) for k in self.keys() if k[0] <= value < k[1])

def grade(mark):

    grds = dict_range({(75, 100.1): 'First',
                       (70, 75): 'Upper Second',
                       (60, 70): 'Second',
                       (50, 60): 'Third',
                       (45, 50): 'F1 Supp.',
                       (40, 45): 'F2',
                       (0, 40): 'F3'})

    return grds[mark]
Answered By: jpp

Slightly different approach to Makoto. Again, just for a snippet.

if mark >= 75: 
    # limit the religion here by just saving an index
    idx = 0
# you don't need to check its below 75, as that was confirmed by above failing
elif mark >= 70:
    idx = 0
# now consolidate the repetitions here.
return grds[idx]
Answered By: Guy