Lecture 6 - Modular Coding


In the last class we learnt how to enclose a certain block of code inside a function. Now an important question arises. How frequently should we do this ?

A function extracts a computation out of your code, encapsulates it in a black box labeled by a name. Writing a function is like defining the meaning of a word before it is used in writing. Even if that word is used only once in your story you should surely write out it's definition if you think that makes writing more understandable.

Modular Coding


Modular coding is a software design technique which translates to decomposing the functionality of a larger program into independent parts. Each of those parts should more or less implement one aspect of the larger requisite functionality.

In other words, you're implementing the divide-and-conquer paradigm at the method level. Other divide-and-conquer you may be witnessing in Algorithmic Thinking, but let's leave that for now.

Examples

First off, let us see some examples.

Bank

If you visit a Bank to do an operation like a fund transfer then you'll have to interact with multiple entities such as the bank teller, clerk, account manager, maybe the ATM as well, etc. You get the point, right?

The important thing to notice is that the task of "fund transfer" is being divided into smaller subtasks which are being performed by different people. The collective subresults of all those people involved get added up which finally results in a successful transfer.

Comparing non-modular code with modular code

Consider the following code :


In [ ]:
from math import sqrt
import random

line_segments = [((random.uniform(0, 1), random.uniform(0, 1)),
                  (random.uniform(0, 1), random.uniform(0, 1))) 
                  for i in range(0, 1000)]
positive_slope = len([segment
                      for segment in line_segments
                      if (segment[1][1] - segment[0][1])/(segment[1][0] - segment[0][0]) > 0
                     ])
print("Line segments with positive slope : ", positive_slope)

Now let's make it modular :


In [ ]:
from math import sqrt
import random as r

def generate_point(x_range, y_range):
    return (r.uniform(*x_range), r.uniform(*y_range))

def slope(segment):
    if segment[1][0] == segment[0][0]:
        y_diff = segment[1][1] - segment[0][1]
        return float('inf') * y_diff if y_diff != 0 else 0
    else:
        return (segment[1][1] - segment[0][1])/(segment[1][0] - segment[0][0])

def generate_line_segments(x_range, y_range, count):
    return [
            (generate_point(x_range, y_range), generate_point(x_range, y_range))
            for i in range(0, count)
            ]

def get_positive_sloped_segments(line_segments):
    return [
            segment
            for segment in line_segments
            if slope(segment) > 0
           ]

x_range = (0, 1)
y_range = (0, 1)
line_segments = generate_line_segments(x_range, y_range, 1000)
positive_line_segments = get_positive_sloped_segments(line_segments)
print("Line segments with positive slope : ", len(positive_line_segments))

So, why is the above code better than the previous one in-spite of similar client level functionality ?

There are multple reasons :

Efficient Program Development/Debugging : Say if you had to re-design a certain aspect of the main functionality it's easier to do so in modular code. This is because that aspect would be referenced by a function name and if the code is well designed you would need to understand ONLY that function in order to improve it.

Re-use of the constituent modules : If you needed to compute the slope of a line segment in some other program the slope method defined here can be re-used again.

Now consider this implementation :


In [ ]:
from math import sqrt
import random as r

def generate_point(x_range, y_range):
    return (r.uniform(*x_range), r.uniform(*y_range))

def compute_direction_cosines(segment):
    dim = len(segment[0])
    diff_between_points = [segment[1][i] - segment[0][i]
                           for i in range(0, dim)]
    distance_between_points = sqrt(sum([diff_between_points[i] ** 2
                                        for i in range(0, dim)])) 
    return [i / distance_between_points
            for i in diff_between_points]

def is_positive_slope(segment):
    dir_cosines = compute_direction_cosines(segment)
    # 0 is positive, 1 is negative sign
    is_positive = all([i >= 0 for i in dir_cosines]) or all([i < 0 for i in dir_cosines])
    return is_positive
    
def generate_line_segments(count, x_range, y_range):
    return [
            (generate_point(x_range, y_range), generate_point(x_range, y_range))
            for i in range(0, count)
           ]

def get_positive_sloped_segments(line_segments):
    return [
            segment
            for segment in line_segments
            if is_positive_slope(segment)
           ]

x_range = (0, 1)
y_range = (0, 1)
line_segments = generate_line_segments(1000, x_range, y_range)
positive_line_segments = get_positive_sloped_segments(line_segments)
print("Line segments with positive slope : ", len(positive_line_segments))

Is it better than the previous ones ?

Yes it is....Why though ?

When writing modular code, one needs to keep in mind two models of software development:

1. Scalability
2. Robustness.

Robustness means that your code can deal with incorrect input and runtime errors. This we haven't covered as of yet. Scalability means that your code should scale decently with increasing amount of work. Now this can be interpreted in multiple ways. It can mean than when amount of input increases then code should also scale correctly with it (i.e. not take unexpected amount of time). It can also mean that adding new features which depend on most/all of the submodules shouldn't be too much difficult.

So, in the above code we can see the second characteristic of scalability in action.

It can be seen that with some subtle changes the above code can be made to work for the 3D use-case as well.

Such a thing is not possible with the code in Cells 70 and 71, because they use the restricted concept of slope of a line. The scalable concept of slope is direction cosines here and hence to make the code more modular, direction cosines are used.

Let us try to scale it to 3D


In [ ]:
from math import sqrt
import random as r


def generate_point(x_range, y_range, z_range=None):
    if z_range is None:
        return (r.uniform(*x_range), r.uniform(*y_range))
    return (r.uniform(*x_range), r.uniform(*y_range), r.uniform(*z_range))


def compute_direction_cosines(segment):
    # No change at all !
    dim = len(segment[0])
    diff_between_points = [segment[1][i] - segment[0][i]
                           for i in range(0, dim)]
    distance_between_points = sqrt(sum([diff_between_points[i] ** 2
                                        for i in range(0, dim)])) 
    return [i / distance_between_points
            for i in diff_between_points]


def is_positive_slope(segment):
    # No change at all !
    dir_cosines = compute_direction_cosines(segment)
    # 0 is positive, 1 is negative sign
    is_positive = all([i >= 0 for i in dir_cosines]) or all([i < 0 for i in dir_cosines])
    return is_positive


def generate_line_segments(count, x_range, y_range, z_range=None):
    return [
             (generate_point(x_range, y_range, z_range),
             generate_point(x_range, y_range, z_range))
             for i in range(0, count)
           ]


def get_positive_sloped_segments(line_segments):
    # No change at all !
    return [
            segment
            for segment in line_segments
            if is_positive_slope(segment)
           ]


x_range = (0, 1)
y_range = (0, 1)
z_range = (0, 1)
line_segments = generate_line_segments(1000, x_range, y_range, z_range)
positive_line_segments = get_positive_sloped_segments(line_segments)

print("Line segments with positive slope : ", len(positive_line_segments))

How to apply the divide and conquer paradigm effectively is something which can only be achieved through practice. Please do go through the practice problems on the repository and more importantly do the mini-project for the next week as well as you can.

Object Oriented Programming


Object-oriented programming (OOP) refers to a software design practice in which the programmer defines both data type of a data structure as well as methods that applied to it.

Consider a character in a video game(say Skyrim). That character you control as well as other non-playable ones in the game are internally represented by objects. When you strike a wild wolf in the game with your sword you are actually interacting with that character and affecting one of it's attributes, namely it's health.

So, you can say that an object is an entity which has a current state described by some attributes and can perform some methods.

Attributes : An attribute of an object is something that partly or wholly describes it's state.

Methods : Methods of an object are the actions which the object can perform.

Let us consider a living organism such as the simplest cell.

Attributes :

  1. Living (Boolean) : Can only be alive or dead.
  2. In Motion (Boolean) : Can either be moving or not.
  3. Speed (Float) : Can move at a specific speed at a given instant of time.
    .......

Methods :

  1. Ingest (void) : Ingest food that it finds.
  2. Move (void) : Moves at a certain velocity.
    .......

From next lecture we will begin to transform our abstract ideas about objects, their attributes and methods into concrete code.

While and do-while


In [ ]:
while expression:
    execute statement(s)

In [2]:
i = 5
while i > 0:
    print(i, end=" : ")
    i -= 1


5 : 4 : 3 : 2 : 1 : 

In most languages a do-while loop means that the loop body will be executed at least once. This is because the body is executed first and then the test condition is checked. Python doesn't natively have do-while loop. However it can be implemented in a certain fashion.


In [ ]:
while True:
  execute statement(s)
  if fail_condition:
    break