4  Programs and Functions

Programming is the art and science of instructing computers to perform specific tasks by writing sets of instructions, also known as code, using a programming language. It is a fundamental skill in today’s digital age, underpinning the software that powers everything from smartphones and websites to complex scientific simulations and artificial intelligence.

Show the code
import numpy as np
import matplotlib.pyplot as plt

def reaction_diffusion(num_iterations, width, height):
    feed = 0.055
    kill = 0.062
    diff_a = 1.0
    diff_b = 0.5

    a = np.ones((width, height))
    b = np.zeros((width, height))

    def initialize_pattern(a, b):
        mid_x, mid_y = width // 2, height // 2
        a[mid_x-10:mid_x+10, mid_y-10:mid_y+10] = 0.5
        b[mid_x-10:mid_x+10, mid_y-10:mid_y+10] = 0.25
    
    initialize_pattern(a, b)

    def laplacian(mat):
        return (
            -mat
            + 0.2 * (np.roll(mat, 1, axis=0) + np.roll(mat, -1, axis=0) + np.roll(mat, 1, axis=1) + np.roll(mat, -1, axis=1))
            + 0.05 * (np.roll(mat, (1, 1), axis=(0, 1)) + np.roll(mat, (1, -1), axis=(0, 1)) + np.roll(mat, (-1, 1), axis=(0, 1)) + np.roll(mat, (-1, -1), axis=(0, 1)))
        )

    for _ in range(num_iterations):
        a_diff = laplacian(a)
        b_diff = laplacian(b)
        reaction = a * b * b
        a += diff_a * a_diff - reaction + feed * (1 - a)
        b += diff_b * b_diff + reaction - (kill + feed) * b
        np.clip(a, 0, 1, out=a)
        np.clip(b, 0, 1, out=b)

    return a, b

num_iterations = 6000
width, height = 200, 200

a, b = reaction_diffusion(num_iterations, width, height)

plt.figure(figsize=(8, 4))

# Plot for a
plt.subplot(1, 2, 1)
plt.imshow(a, cmap='Spectral')
plt.title('$c_A(t)$')
plt.axis('off')

# Plot for b
plt.subplot(1, 2, 2)
plt.imshow(b, cmap='Spectral')
plt.title('$c_B(t)$')
plt.axis('off')

plt.show()
The Gray-Scott Model for Diffusion and Reaction

The Gray-Scott Model for Diffusion and Reaction

At its core, programming involves problem-solving. Programmers analyze problems, break them down into smaller, manageable parts, and then create algorithms, which are step-by-step instructions that tell a computer how to solve those problems. These instructions are written in a programming language, which acts as an intermediary between human-readable code and the computer’s binary language of 0s and 1s.

Programming languages come in various forms, from high-level languages like Python, Java, and C++ that are easier for humans to understand and write, to low-level languages like Assembly that are closer to the computer’s native language. Each language has its strengths and weaknesses, making it suitable for different types of tasks.

Once a program is written, it needs to be translated into machine code through a process called compilation or interpretation, depending on the language. The resulting executable program can then be run on a computer, automating various tasks or solving specific problems.

Programming is a versatile skill that can be applied in virtually every field, from web development and mobile app creation to data analysis, robotics, and artificial intelligence. It empowers individuals and organizations to automate tasks, improve efficiency, and innovate by creating software solutions to complex problems.

Learning to program is not only about mastering syntax and language features but also about developing problem-solving and critical-thinking skills. It’s a creative and empowering endeavor that allows individuals to turn their ideas into functional software, making programming an essential and continuously evolving discipline in the modern world.

4.1 Programming Paradigms

Programming paradigms are fundamental styles or approaches to writing computer programs. They represent a set of principles, concepts, and techniques that guide how software is structured, organized, and executed. Each programming paradigm has its own philosophy and methodology for solving problems and building software. Here are some of the most commonly recognized programming paradigms:

Imperative Programming:

Imperative programming is a paradigm that focuses on changing a program’s state through sequences of statements or commands. In this paradigm, programs maintain mutable state variables that can be modified during execution. Control structures like loops and conditionals are commonly used to manage program flow.

Mathematically, imperative programming can be abstractly represented as a sequence of commands that modify a program’s state. For example, you might have an initial state \(S\) and then update it with a command like \(S' = \text{updateState}(S)\), where \(S\) and \(S'\) represent the program state before and after executing a command, and \(\text{updateState}\) is a command that modifies the state.

Key concepts in imperative programming include variables for storing data, loops (e.g., for, while, do-while) for repetitive tasks, conditionals (e.g., if, else if, else) for decision-making, and procedures/functions for organizing code.

Functional Programming:

Functional programming is a paradigm that emphasizes immutability, meaning that once data is created, it cannot be changed. In functional programming, functions are pure, meaning they have no side effects and return consistent results for the same inputs. Functions are treated as first-class citizens, allowing them to be treated as values in the code.

Mathematically, functional programming can be represented as compositions of pure functions. For instance, you might have an equation like \(Y = f(g(X), h(Z))\), where \(Y\) represents the result, and \(f\), \(g\), and \(h\) are pure functions operating on inputs \(X\) and \(Z\).

Key concepts in functional programming include pure functions, immutability (data cannot be changed once created), higher-order functions (functions that take other functions as arguments or return them), and recursion (often used instead of loops).

Object-Oriented Programming (OOP):

Object-oriented programming is a paradigm that revolves around objects, which encapsulate both data (attributes) and behavior (methods). Objects are instances of classes, which define blueprints for creating objects with shared attributes and methods. OOP also incorporates concepts like inheritance, where classes can inherit attributes and methods from parent classes, and encapsulation, which involves hiding internal details and exposing a public interface.

Mathematically, OOP can be represented as interactions between objects and classes. For example, you might have an equation like \(Y = X.\text{method}()\), where \(Y\) represents the result, \(X\) is an object, and \(\text{method}()\) is a method of the object.

Key concepts in OOP include objects (instances of classes with attributes and methods), classes (blueprints for creating objects), inheritance (a mechanism for sharing attributes and methods between classes), and encapsulation (hiding internal implementation details and exposing a public interface).

4.2 Building blocks of a Program

The building blocks of programs are fundamental components and concepts that are used to create software and instruct computers to perform specific tasks. These building blocks can vary somewhat depending on the programming language, but they generally include the following key elements:

  1. Variables: Variables are used to store and manipulate data in a program. They can hold various types of information, such as numbers, text, or complex data structures. Variables have names and values that can change during the execution of a program.

  2. Data Types: Programming languages have different data types, such as integers, floating-point numbers, strings, booleans, and more. Data types define the kind of values that variables can hold and the operations that can be performed on them.

  3. Operators: Operators are symbols or keywords used to perform operations on data. Common operators include addition (+), subtraction (-), multiplication (*), division (/), comparison (==, !=, <, >, etc.), and logical operations (&&, ||, !).

  4. Arrays and Collections: These data structures allow you to group multiple pieces of data into a single variable. Arrays and collections make it easier to work with large sets of related data.

  5. Input and Output (I/O): Programs often need to interact with the outside world. Input mechanisms like keyboard input or reading from files, and output mechanisms like printing to the console or writing to files, enable programs to communicate with users and external devices.

  6. Control Structures: Control structures determine the flow of a program’s execution. They include:

    • Conditional Statements: These allow you to make decisions in your code. Common conditional statements include if, else if, else, and switch.
    • Loops: Loops, such as for, while, and do-while, enable you to repeat a block of code multiple times.
    • Branching: Branching mechanisms like break and continue can control the flow within loops and switch statements.
  7. Functions/Methods: Functions (or methods, depending on the language) are reusable blocks of code that perform a specific task. They encapsulate logic, accept input (parameters), and can return results. Functions help modularize code and make it more maintainable.

  8. Comments: Comments are not executed by the program but provide explanations and documentation for code. They are essential for making code more readable and understandable for developers and maintainers.

  9. Error Handling: Programs need to handle errors and exceptions gracefully. Exception handling constructs allow you to catch and manage errors to prevent crashes and provide user-friendly error messages.

  10. Classes and Objects: In object-oriented programming languages like Java and Python, classes and objects are fundamental. Classes define blueprints for creating objects, which are instances of those classes. They encapsulate data and behavior into reusable, organized structures.

  11. Libraries and APIs: Programs often leverage pre-built libraries and application programming interfaces (APIs) to access external functionality. These resources provide a wide range of capabilities, from working with graphics to handling network communications.

  12. Documentation: Proper documentation, including comments, inline documentation, and external documentation files, is crucial for making code understandable and maintainable.

These building blocks, when combined and structured effectively, allow programmers to design and create complex software solutions for a wide range of applications. The choice of programming language may influence how these building blocks are used, but the fundamental concepts remain consistent across most programming paradigms and languages.

4.3 Functions

In programming, a function is a reusable piece of code that performs a specific task. It can take input, perform operations on that input, and then return output. Functions are essential building blocks of any program, and they allow programmers to create modular, reusable code that can be called from different parts of a program.

In Python, functions are defined using the def keyword, followed by the function name and any input parameters in parentheses. The function body is then indented below the function definition. Here’s an example of a simple function that takes two numbers as input and returns their sum:

Task: Compute the area of circular disks for radius from 1mm to 20mm !
def area_of_disk(r=2, pressure=20): # r is the argument, default value is 2
    """
    This is a function for computing the area of a disk !
    """
    a = pressure * 3.14 * r**2
    return a
my_new_disk = area_of_disk(r=10, pressure=30)
my_new_disk
9420.0

Having specified the function, we can now use the function in a variety of ways ! This piece of code can be re-used multiple times by simply specifying the identifier i.e. the function name. We do not have the write the full code again.

# use the function defined above inside a loop
for r in range(1, 4, 1):
    print(r, area_of_disk(r))
1 62.800000000000004
2 251.20000000000002
3 565.2

We could have also directly put the formula inside the for loop. However, in case we want to use the formula again in our program, then we have to type the formula. By using functions, our formula becomes portable.

Parameters and variables defined inside a function are not available outside of the function. Hence, they have a local scope and the return statement is required to return the processed data !

Historically, the concept of functions can be traced back to the earliest days of computer programming. Early programming languages like FORTRAN and COBOL introduced the concept of subroutines, which were essentially functions that could be called from different parts of a program. Later languages like C and Pascal expanded on this concept, introducing functions that could take parameters and return values.

Python was first released in 1991 and quickly gained popularity due to its ease of use and readability. The concept of functions has been a fundamental part of the language since its inception. Python’s support for functions has made it a popular choice for scientific computing, web development, and many other domains.

In the future, the concept of functions will continue to be an important part of programming, as it allows developers to create modular, reusable code. As programming languages continue to evolve and new technologies emerge, the concept of functions will likely remain a fundamental building block of software development.

4.3.1 args and kwargs

In Python, args and kwargs are special parameters that allow you to pass a variable number of arguments to a function. args stands for “arguments” and is used to pass a variable number of non-keyword arguments to a function. It is represented by a tuple of values. kwargs stands for “keyword arguments” and is used to pass a variable number of keyword arguments to a function. It is represented by a dictionary of key-value pairs.

def my_function(*args, **kwargs):
    """
    a function that prints args and kwargs!
    """
    print(args) # args is a tuple with a list of elements
    print(kwargs) # kwargs is a dictionary with key value pairs
my_function(1, 2, 3, a='hello', b='world')
(1, 2, 3)
{'a': 'hello', 'b': 'world'}

In this example, *args collects any number of non-keyword arguments passed to the function and packs them into a tuple. **kwargs collects any number of keyword arguments passed to the function and packs them into a dictionary. Note that args and kwargs are just conventions, and you can use any other variable names instead. However, using these conventions makes it easier for other developers to understand your code.

The single star * and double star ** in front of args and kwargs, respectively, are known as the “unpacking operators” in Python. They are used to unpack sequences and dictionaries, respectively, into separate arguments when calling a function.

When you use *args in a function definition, it tells Python to accept any number of positional arguments (i.e., arguments that are not passed as key-value pairs) and pack them into a tuple. Similarly, when you use **kwargs, it tells Python to accept any number of keyword arguments (i.e., arguments that are passed as key-value pairs) and pack them into a dictionary.

When you call a function that uses *args and **kwargs, you can use the unpacking operators to pass a sequence or dictionary as separate arguments to the function. Here’s an example:

def my_function(a, b, *args, **kwargs):
    """
    a function that prints two non-keyword parameters and the args and kwargs
    """
    print(f'a={a}, b={b}')
    print(f'args={args}')
    print(f'kwargs={kwargs}')
my_tuple = (10, 20, 30)
my_dict = {'x': 4, 'y': 5, 'z': 6}
my_function(0, 9, *my_tuple, **my_dict)
a=0, b=9
args=(10, 20, 30)
kwargs={'x': 4, 'y': 5, 'z': 6}
def my_function_1(parameter_1=2, parameter_2=100, parameter_3=30):
    """
    this function takes in two parameters and computes the product and sum of the parameters
    """
    product_of_parameters = parameter_1 * parameter_2 * parameter_3
    sum_of_parameters = parameter_1 + parameter_2 + parameter_3
    return (product_of_parameters, sum_of_parameters)
variable_1 = my_function_1(parameter_1=1650, parameter_2=1420, parameter_3=50)
variable_1
(117150000, 3120)

4.4 Lambda and special functions

Lambda functions are a way to create small functions in Python. They are defined using the lambda keyword and are often used as a quick way to define a function that will only be used once.

Here’s an example of a lambda function that takes a single argument and returns its square:Lambda functions are one-line functions ! Also called anonymous functions. Often used to specify functions within another function !

import math
area = lambda r : math.pi * r * r  # function to generate the area of a circle given the area
print(area(2))
12.566370614359172

4.4.1 Map

The map() function in Python takes a function and an iterable as input, and returns a new iterable where each element has been transformed by the function. Here’s an example:

numbers = [1, 2, 3, 4, 5]
def my_function_test(x):
    """
    a function to return the square of the input scalar
    """
    return x**2
squares_function = map(my_function_test, numbers)
list(squares_function)
[1, 4, 9, 16, 25]
squares_lambda = map(lambda x: x**2, numbers)
list(squares_lambda)
[1, 4, 9, 16, 25]

In this example, numbers is an iterable containing the numbers 1 through 5. The map() function applies the lambda function lambda x: x**2 to each element in the iterable, returning a new iterable containing the squares of each number. The squares variable in this example will contain the iterable [1, 4, 9, 16, 25].

4.4.2 Filter

The filter() function in Python takes a function and an iterable as input, and returns a new iterable containing only the elements for which the function returns True. Here’s an example:

numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 1000, 1200, 1201]
# demonstrate the functionality of the filter function ! filter(condition, data structure)
even_numbers = filter(lambda x: x % 2 == 0, numbers)
list(even_numbers)
[2, 4, 6, 8, 10, 1000, 1200]

In this example, numbers is an iterable containing the numbers 1 through 5. The filter() function applies the lambda function lambda x: x % 2 == 0 to each element in the iterable, returning a new iterable containing only the even numbers. The even_numbers variable in this example will contain the iterable [2, 4].

4.4.3 Reduce

The reduce() function in Python takes a function and an iterable as input, and returns a single value that is the result of applying the function to the iterable in a cumulative way. Here’s an example:

# demonstrate the functionality of the reduce function
from functools import reduce

numbers = [1, 2, 3, 4, 5]
difference = reduce(lambda x, y: x - y, numbers) # (((1 - 2) - 3) - 4) - 5
print(difference)
-13

In this example, numbers is an iterable containing the numbers 1 through 5. The reduce() function applies the lambda function lambda x, y: x * y to the first two elements in the iterable (1 and 2), then applies the same function to the result and the next element in the iterable (3), and so on, until all elements in the iterable have been processed.

4.5 Decorators

Decorators in Python are a powerful feature that allow you to modify the behavior of a function or method without changing its code. They are widely used in scientific and engineering applications for purposes such as timing functions, caching results, or enforcing type checks. Here’s a practical example related to scientific computing where decorators can be particularly useful:

  • These are higher-order functions that take a function as an argument and return a new function with enhanced functionality.

  • Decorators wrap a function, modifying its behavior.

  • Defined with the @ symbol, followed by the decorator name, placed above the function definition.

  • A decorator is just a callable Python object that is used to modify a function or a class.

  • Example of a simple decorator:

def my_decorator(func):
    def wrapper():
        print("Something is happening before the function is called.")
        func()
        print("Something is happening after the function is called.")
    return wrapper

@my_decorator
def say_hello():
    print("Hello!")

say_hello()
Something is happening before the function is called.
Hello!
Something is happening after the function is called.
import time

# Define the decorator
def log_execution_time(func):
    def wrapper(*args, **kwargs):
        start_time = time.time()  # Record the start time
        result = func(*args, **kwargs)  # Call the original function
        end_time = time.time()  # Record the end time
        execution_time = end_time - start_time  # Calculate execution time
        print(f"{func.__name__} executed in {execution_time:.4f} seconds")
        return result
    return wrapper

# Example usage with the decorator
@log_execution_time
def my_expensive_computation(n):
    # Example expensive operation: calculate the sum of squares up to n
    return sum(i**2 for i in range(n))

# Now calling the decorated function will display its execution time
result = my_expensive_computation(1000000)
my_expensive_computation executed in 0.0354 seconds

4.5.1 Finding Square Root

The square-root of a number \(S\) can be computed by iteration as follows:

def square_root(n, iterations=10):
    x = n
    for _ in range(iterations):
        x = 0.5 * (x + n / x)
    return x

# Example usage
n = 500
print("Square root of", n, ":", square_root(n))
Square root of 500 : 22.360679774997898

4.6 Classes

Python is an object oriented programming language. Everything in Python is an object.

  • A class in Python defines the structure and behavior of an object. It’s like a blueprint from which individual objects are created.

  • Each object created from a class is known as an instance of that class.

  • A class encapsulates, or bundles together, data and the functions that operate on that data.

  • The data in a class is held in attributes, which are variables that belong to the class.

  • The functions inside a class are called methods. They define the behavior of the class and can manipulate the class attributes or perform operations relevant to the class.

  • In Python, you define a class using the class keyword, followed by the class name and a colon. For example:

class Experiment:
    pass
  • Once a class is defined, you can create objects (instances) from it. For the Experiment class, creating an instance would look like this:
my_experiment = Experiment()
  • Attributes and Methods:
    • Attributes can be added to a class to store information about the state of an instance. For example, a Experiment class might have attributes like name and type.
    • Methods are functions defined within a class and are used to define the behaviors of an instance. For instance, an Experiment class might have methods like analyse or visualize.
    • Class attributes and methods are crucial to Python classes, with attributes being shared variables among all instances, and methods as functions that act on these attributes to define object behaviors. Class attributes, set within the class but outside methods, are accessible by both the class and its instances, ideal for constants or default values shared by all instances. In contrast, instance attributes, defined within the __init__ method, are specific to each object, allowing for customization. Methods, needing at least one parameter (self), manipulate both class and instance attributes, highlighting the class design’s flexibility to accommodate shared and unique object data. This distinction enables both commonality through class attributes and individuality via instance attributes, with methods facilitating interactions and behaviors across this framework.

4.6.1 Inheritance in Python

Inheritance is a fundamental concept in object-oriented programming (OOP). It allows us to define a class that inherits all the methods and properties from another class.

  • Inheritance enables new classes, known as subclasses, to receive and extend the properties and methods of existing classes, known as parent or base classes.

  • It’s used to create a new class with little or no modification to an existing class. The new class can add new methods and properties or modify existing ones.

  • Creating a Subclass: In Python, you create a subclass by simply declaring a new class and putting the name of the parent class in parentheses.

class BaseClass:
    pass

class SubClass(BaseClass):
    pass
  • Inheriting Features: The subclass inherits all methods and properties from the parent class.

  • Python supports multiple inheritance, where a subclass can inherit from multiple parent classes.

  • Method Resolution Order (MRO): In the case of multiple inheritance, Python uses a specific order (MRO) to look up methods. This order is defined in a tuple called __mro__ in the class.

  • If you want to change the behavior of a method in your subclass that is defined in its parent class, you simply redefine it. This is known as method overriding.

class BaseClass:
    def print_message(self):
       print("This is a message from the Base Class")

class SubClass(BaseClass):
    def print_message(self):
       print("This is a message from the Sub Class")
  • Sometimes you want to extend rather than entirely replace a parent class method. You can do this using super() to call the parent class method.
class SubClass(BaseClass):
    def print_message(self):
        super().print_message()
        print("This is an additional message from the Sub Class")
test = SubClass()
test.print_message()
This is a message from the Base Class
This is an additional message from the Sub Class

4.6.2 Class Decorators

@staticmethod is used to declare a method within a class as a static method, which means it doesn’t require a reference to a class or instance.

class MyClass:
    @staticmethod
    def my_static_method():
        print("This is a static method")

MyClass.my_static_method()
This is a static method
Decorator Meaning
@classmethod Converts a method into a class method, which means it receives the class as the first argument instead of an instance. It can access class attributes but not instance attributes.
@staticmethod Converts a method into a static method, which does not receive an implicit first argument (neither self nor cls). It’s a way to namespace functions in a class.
@property Converts a method into a property, allowing you to access it as an attribute instead of calling it as a method. Useful for defining getters.
@<property>.setter Used in conjunction with @property to define the setter method for a property, allowing you to set the value of a property.
@<property>.deleter Used alongside @property to define the deleter method of a property, enabling you to delete a property.
@functools.wraps Used in decorator definitions to preserve the wrapped function’s metadata, such as its name and docstring.
@functools.lru_cache Caches the results of a function, storing the result of expensive function calls and using the cached value when the same inputs occur again.
@functools.singledispatch Transforms a function into a single-dispatch generic function, allowing it to have different behaviors based upon the type of the first argument.

4.6.3 __ Double Underscore methods

Magic methods in Python, also known as dunder (double underscore) methods, are special methods with fixed names that Python calls in certain circumstances. They allow us to define how instances of a class behave under different operations like when they’re printed, added together, or have their length checked.

4.6.3.1 The __init__ Method

The __init__ method in Python is a special method used for initializing new objects of a class. It’s often referred to as a constructor in other programming languages. Here’s a brief description:

  • The __init__ method is automatically invoked when a new instance of a class is created. Its primary role is to assign values to the object’s properties or perform any other necessary initialization.

  • It is defined within a class, and its first parameter is always self, which represents the instance of the class. Additional parameters can be added to pass data to initialize the object.

  • Example:

class Experiment:
    def __init__(self, name, date):
        self.name = name  # instance attribute
        self.date = date  # instance attribute

# Creating an instance 
my_Experiment = Experiment('Temperature_flux', '5/March/2050')

In this example, when Experiment('Temperature_flux', '5/March/2050') is executed, the __init__ method is called with self being the newly created object and name, date being ‘Temperature_flux’, 5/March/2050 respectively.

  • While __init__ is commonly used, it’s not mandatory. If not defined, Python provides a default __init__ that does nothing.

  • The __init__ method cannot return anything other than None. Its purpose is purely initialization.

Here’s an overview of some common magic methods:

Dunder Method Meaning
init(self, …) Constructor method for initializing a new instance of a class.
del(self) Called when an instance is about to be destroyed. Useful for cleanup operations.
repr(self) Called by the repr() built-in function and by string conversions to produce a string representation of an object for debugging.
str(self) Called by the str() function and by the print statement to produce a more user-friendly string representation of an object.
call(self, …) Allows an instance of a class to be called as a function.
getattr(self, name) Called when an attribute lookup has not found the attribute in the usual places.
setattr(self, name, value) Called when an attribute assignment is attempted.
delattr(self, name) Called when an attribute deletion is attempted.
lt(self, other) Defines behavior for the less than operator <.
le(self, other) Defines behavior for the less than or equal to operator <=.
eq(self, other) Defines behavior for the equality operator ==.
ne(self, other) Defines behavior for the inequality operator !=.
gt(self, other) Defines behavior for the greater than operator >.
ge(self, other) Defines behavior for the greater than or equal to operator >=.
add(self, other) Defines behavior for the addition operator +.
sub(self, other) Defines behavior for the subtraction operator -.
mul(self, other) Defines behavior for the multiplication operator *.
truediv(self, other) Defines behavior for the division operator /.
floordiv(self, other) Defines behavior for the floor division operator //.
mod(self, other) Defines behavior for the modulus operator %.
pow(self, other) Defines behavior for the exponent operator **.
getitem(self, key) Called to retrieve an item from the object for the given key (like in a dictionary or list).
setitem(self, key, value) Called to set an item on the object for the given key.
delitem(self, key) Called to delete an item from the object for the given key.
iter(self) Should return an iterator for the object.
next(self) Called to produce the next value in a sequence during iteration.

4.6.4 Encapsulation

In Python, private and protected members are used to enforce encapsulation, a fundamental concept in object-oriented programming. Encapsulation refers to the bundling of data with the methods that operate on that data, and restricting direct access to some of the object’s components. This concept is not enforced by the language itself but by a convention respected among programmers. Here’s how private and protected members work in Python:

  • Indicated by a double underscore __ prefix (e.g., __privateAttribute).
  • Not actually private; it’s a convention to treat them as non-public parts of the API.
  • Python performs name mangling: any identifier with two leading underscores is textually replaced with _classname__identifier, making it harder (but not impossible) to access from outside the class.
class MyClass:
    def __init__(self):
        self.__privateAttribute = 42

    def __privateMethod(self):
        print("This is a private method")

obj = MyClass()
#obj.__privateAttribute  # This will raise an AttributeError

4.7 A functioning program:

import datetime
class Experiment:
    university = 'TUM'  # Class attribute
    
    def __init__(self, name):
        """
        A constructor initialized with name!
        """
        self.name = name
        self.measurements = []
        self.location = None
        self.person = None
        self.cost = None
    
    
    def update_measurements(self, data):
        """
        this function updates the measurements !
        """
        print('data updated !')
        current_time =  datetime.datetime.now()
        self.measurements.append([data, current_time.strftime("%c")])
        
    def update_location(self, location_name):
        """
        this function updates the location
        """
        self.location = location_name 
        
    def cost_in_dollars(self, currency_exchange_rate):
        return self.cost * currency_exchange_rate 

Having implemented the class Experiment, we can start constructing objects of the type Experiment. This objects can be manipulated / updated using the functions defined int he class!

e1 = Experiment('XVBjhjN1')
e1
<__main__.Experiment at 0x13ddf3d90>
print(e1.person)
None
e2 = Experiment('new_exp')
e2.update_location('Arcisstr 21')

Now let us update the location of the object e1

e1.update_location('Pasing')
print(e2.location)
Arcisstr 21

In case we have measurements, then we can put these measurements in an array and then insert this array into the object using the update_measurements function.

new_data = [1, 2, 4, 8]
e1.update_measurements(new_data)
print(e1.measurements)
data updated !
[[[1, 2, 4, 8], 'Sun Sep 22 23:02:13 2024']]
e1.update_measurements([4, 5, 6, 7])
data updated !
print(e1.measurements)
[[[1, 2, 4, 8], 'Sun Sep 22 23:02:13 2024'], [[4, 5, 6, 7], 'Sun Sep 22 23:02:13 2024']]
print(e1.location)
Pasing
print(e2.location)
Arcisstr 21

Common pitfalls:

  1. Overuse of classes: It’s important to use classes judiciously, as overusing them can lead to code that is difficult to understand and maintain. If a simpler approach is sufficient, it’s often better to use functions or modules instead of classes.
  2. Inconsistent naming conventions: It’s important to follow consistent naming conventions when defining classes and their attributes and methods. This can help to improve code readability and maintainability.
  3. Tight coupling: Classes can become tightly coupled, which means that changes to one class can have unintended consequences on other parts of the code. It’s important to design classes with loose coupling in mind, which means that they should be independent and not rely too much on other classes.
  4. Poor design: Poorly designed classes can be difficult to use and maintain. It’s important to design classes with a clear purpose and a well-defined public interface. Additionally, classes should be tested thoroughly to ensure that they work as intended.

Benefits:

  1. Object-oriented programming: Python is an object-oriented programming (OOP) language, and classes are the building blocks of OOP. Classes allow you to define objects with their own attributes and methods, which makes it easier to organize and structure your code.
  2. Code reusability: With classes, you can create objects that can be reused in different parts of your code, which can save time and effort.
  3. Inheritance: Classes in Python support inheritance, which means that you can create a new class by inheriting from an existing class. This can save time and effort, as you can reuse the existing code and only modify or add what is necessary.
  4. Encapsulation: Classes allow you to encapsulate data and methods, which means that you can hide implementation details and only expose a public interface. This can help to reduce complexity and improve code maintainability.

4.8 A Program in three Paradigms

A simple program that calculates the factorial of a number using three different programming paradigms: procedural, object-oriented, and functional programming.

# Procedural Programming:

def factorial_procedural(n):
    result = 1
    for i in range(1, n + 1):
        result *= i
    return result

num = 25
print(f"Factorial (Procedural): {factorial_procedural(num)}")
Factorial (Procedural): 15511210043330985984000000
# Object-Oriented Programming:

class FactorialCalculator:
    def __init__(self, n):
        self.n = n

    def calculate(self):
        result = 1
        for i in range(1, self.n + 1):
            result *= i
        return result

num = 25
calculator = FactorialCalculator(num)
print(f"Factorial (Object-Oriented): {calculator.calculate()}")
Factorial (Object-Oriented): 15511210043330985984000000
# Functional Programming:


from functools import reduce

def factorial_functional(n):
    return reduce(lambda x, y: x * y, range(1, n + 1), 1)

num = 25
print(f"Factorial (Functional): {factorial_functional(num)}")
Factorial (Functional): 15511210043330985984000000

These programs all calculate the factorial of a number entered by the user, but they do so using different programming paradigms. The procedural version uses a loop, the object-oriented version encapsulates the functionality in a class, and the functional version uses the reduce function to calculate the factorial.

4.9 Exercises

4.9.1 Theory

  1. What is the difference between a function and a method in Python?
  2. What is the purpose of the “self” parameter in a class method?
  3. What is the difference between the “map” and “filter” functions in Python?
  4. What is the purpose of the “reduce” function in Python?
  5. What is a lambda function, and how is it different from a regular function?

4.9.2 Coding

  1. Write a Python function that takes a list of integers as input and returns the sum of all the even numbers in the list.
  2. Write a Python class that represents a rectangle, with methods for calculating its area and perimeter.
  3. Use the “map” function to create a new list that contains the squares of all the numbers in an existing list.
  4. Use the “filter” function to create a new list that contains only the numbers smaller than 4 from a list that also has numbers larger than 4.
  5. Use the “reduce” function to calculate the cumulative differences of all the numbers in an existing list.

4.10 Further Reading

  1. https://docs.python.org/3/tutorial/classes.html
  2. https://docs.python.org/3/howto/functional.html?highlight=lambda
  3. https://rszalski.github.io/magicmethods/