1. OOP Basics#

1.1. Learning Objectives#

  • Provide some concepts of object oriented programming in Python and examples.

1.2. Python OOP Basics#

Object-oriented programming is a programming paradigm that provides a means of structuring programs so that properties and behaviors are bundled into individual objects.

OOP models real-world entities as software objects that have some data associated with them and can perform certain operations. OOP simplifies modeling real-world concepts in your programs and enables you to build systems that are more reusable and scalable.

Object-oriented programming (OOP) in Python helps you structure your code by grouping related data and behaviors into objects. You start by defining classes, which act as blueprints, and then create objects from them.

The four pillars of OOP are:

  1. Encapsulation allows you to bundle data (attributes) and behaviors (methods) within a class to create a cohesive unit. By defining methods to control access to attributes and its modification, encapsulation helps maintain data integrity and promotes modular, secure code.

  2. Inheritance enables the creation of hierarchical relationships between classes, allowing a subclass to inherit attributes and methods from a parent class. This promotes code reuse and reduces duplication.

  3. Abstraction focuses on hiding implementation details and exposing only the essential functionality of an object. By enforcing a consistent interface, abstraction simplifies interactions with objects, allowing developers to focus on what an object does rather than how it achieves its functionality.

  4. Polymorphism allows you to treat objects of different types as instances of the same base type, as long as they implement a common interface or behavior. Python’s duck typing make it especially suited for polymorphism, as it allows you to access attributes and methods on objects without needing to worry about their actual class.

A class is a blueprint (or template) of entities (things) of the same kind. An instance is a particular realization of a class.

Python supports both class objects and instance objects. In fact, everything in Python is object, including class object.

An object contains attributes: data attributes (or static attribute or variables) and dynamic behaviors called methods. In UML diagram, objects are represented by 3-compartment boxes: name, data attributes and methods, as shown below:

OOP_ThreeCompartment.png

To access an attribute, use “dot” operator in the form of class_name.attr_name or instance_name.attr_name_.

To construct an instance of a class, invoke the constructor in the form of instance_name = class_name(_args_).

OOP_Objects.png

1.2.1. Class Definition Syntax#

The syntax is:

class class_name(superclass_1, ...):
    """Class doc-string"""
   
    class_var_1 = value_1  # Class variables
    ......
   
    def __init__(self, arg_1, ...):
        """Initializer"""
        self.instance_var_1 = arg_1  # Attach instance variables by assignment
        ......
      
    def __str__(self):
        """For printf() and str()"""
        ......
      
    def __repr__(self):
        """For repr() and interactive prompt"""
        ......
      
    def method_name(self, *args, **kwargs):
        """Method doc-string"""
        ......

1.2.2. Polymorphism#

Polymorphism in OOP is the ability to present the same interface for differing underlying implementations. For example, a polymorphic function can be applied to arguments of different types, and it behaves differently depending on the type of the arguments to which they are applied.

Python is implicitly polymorphic, as type are associated with objects instead of variable references.

1.2.3. Example 1: Getting Started with a Circle class#

Write a module called circle (to be saved as circle.py), which contains a Circle class. The Circle class shall contain a data attribute radius and a method get_area(), as shown in the following class diagram.

Circle class diagram

"""
circle.py: The circle module, which defines a Circle class.
"""
from math import pi

class Circle:  
    """A Circle instance models a circle with a radius"""
    
    def __init__(self, radius=1.0):
        """Constructor with default radius of 1.0"""
        self.radius = radius  # Create an instance variable radius
        
    def __str__(self):
        """Return a descriptive string for this instance, invoked by print() and str()"""
        return 'This is a circle with radius of %.2f' % self.radius

    def __repr__(self):
        """Return a command string that can be used to re-create this instance, invoked by repr()"""
        return 'Circle(radius=%f)' % self.radius
    
    def get_area(self):
        """Return the area of this Circle instance"""
        return self.radius * self.radius * pi
 
# For Testing under Python interpreter
# If this module is run under Python interpreter, __name__ is '__main__'.
# If this module is imported into another module, __name__ is 'circle' 
# (the module name).
if __name__ == '__main__':
    c1 = Circle(2.1)      # Construct an instance
    print(c1)             # Invoke __str__()
    print(c1.get_area())
 
    c2 = Circle()         # Default radius
    print(c2)
    print(c2.get_area())  # Invoke member method
 
    c2.color = 'red'  # Create a new attribute for this instance via assignment
    print(c2.color)
    #print(c1.color)  # Error - c1 has no attribute color

    # Test doc-strings
    print(__doc__)                  # This module
    print(Circle.__doc__)           # Circle class
    print(Circle.get_area.__doc__)  # get_area() method
    
    print(isinstance(c1, Circle)) # True
    print(isinstance(c2, Circle)) # True
    print(isinstance(c1, str))    # False
This is a circle with radius of 2.10
13.854423602330987
This is a circle with radius of 1.00
3.141592653589793
red

circle.py: The circle module, which defines a Circle class.

A Circle instance models a circle with a radius
Return the area of this Circle instance
True
True
False

1.2.3.1. How It Works?#

  1. By convention, module names (and package names) are in lowercase (optionally joined with underscore if it improves readability). Class names are initial-capitalized (i.e., CamelCase). Variable and method names are also in lowercase.
    Following the convention, this module is called circle (in lowercase) and is to be saved as circle.py (the module name is the filename - there is no explicit way to name a module). The class is called Circle (in CamelCase). It contains a data attribute (instance variable) radius and a method get_area().

  2. class Circle: define the Circle class.

  3. self: The first parameter of all the member methods shall be an object called self (e.g., get_area(self), __init__(self, ...)), which binds to this instance (i.e., itself) during invocation.

  4. You can invoke a method via the dot operator, in the form of obj_name._method_name_(). However, Python differentiates between instance objects and class objects:

    • For class objects: You can invoke a method via:

      class_name.method_name(instance_name, …)

      where an _instance_name_ is passed into the method as the argument 'self'.

    • For instance objects: Python converts an instance method call from:

      instance_name.method_name(…)

      to

      class_name.method_name(instance_name, …)

      where the _instance_name_ is passed into the method as the argument 'self'.

  5. Constructor __init__(): You can construct an instance of a class by invoking its constructor, in the form of _class_name_(...), e.g.,

    c1 = Circle(1.2)
    c2 = Circle()      # radius default

Python first creates a plain Circle object. It then invokes the Circle’s __init__(self, radius) with self bound to the newly created instance, as follows:

    Circle.__init__(c1, 1.2)
    Circle.__init__(c2)       # radius default

Inside the __init__() method, the self.radius = radius creates and attaches an instance variable radius under the instances c1 and c2. Take note that:

  • __init__() is not really the constructor, but an initializer to create the instance variables.

  • __init__() shall never return a value.

  • __init__() is optional and can be omitted if there is no instance variables.

  1. There is no need to declare instance variables. The variable assignment statements in __init__() create the instance variables.

  2. Once instance c1 was created, invocation of instance method c1.get_area() is translated to Circle.getArea(c1) where self is bound to c1. Within the method, self.radius is bound to c1.radius, which was created during the initialization.

  3. You can dynamically add an attribute after an object is constructed via assignment, as in c2.color='red'.

  4. You can place doc-string for module, class, and method immediately after their declaration. The doc-string can be retrieved via attribute __doc__. Doc-strings are strongly recommended for proper documentation.

  5. There is no “private” access control. All attributes are “public” and visible to all.

1.2.3.2. Class Objects vs Instance Objects#

There are two kinds of objects in Python’s OOP model: class objects and instance objects.

Class objects provide default behavior and serve as factories for generating instance objects. Instance objects are the real objects created by your application. An instance object has its own namespace. It copies all the names from the class object from which it was created.

The class statement creates a class object of the given class name. Within the class definition, you can create class variables via assignment statements, which are shared by all the instances. You can also define methods, via the defs, to be shared by all the instances.

When an instance is created, a new namespace is created, which is initially empty. It clones the class object and attaches all the class attributes. The __init__() is then invoked to create (initialize) instance variables, which are only available to this particular instance.

1.2.3.3. __str__() vs. __repr__()#

The built-in functions print(_obj_) and str(_obj_) invoke _obj_.__str__() implicitly. If __str__() is not defined, they invoke obj.__repr__().

The built-in function repr(_obj_) invokes _obj_.__repr__() if defined; otherwise _obj_.__str__().

When you inspect an object (e.g., c1) under the interactive prompt, Python invokes _obj_.__repr__(). The default (inherited) __repr__() returns the _obj_’s address.

The __str__() is used for printing an “informal” descriptive string of this object. The __repr__() is used to present an “official” (or canonical) string representation of this object, which should look like a valid Python expression that could be used to re-create the object (i.e., eval(repr(_obj_)) == _obj_). In our Circle class, repr(c1) returns 'Circle(radius=2.100000)'. You can use “c1 = Circle(radius=2.100000)” to re-create instance c1.

__str__() is meant for the users; while __repr__() is meant for the developers for debugging the program. All classes should have both the __str__() and __repr__().

You could re-direct __repr__() to __str__() (but not recommended) as follows:

    def __repr__(self):
        """Return a formal string invoked by repr()"""
        return self.__str__()   # or Circle.__str__(self)

1.2.3.4. Import#

Importing the circle module

When you use “import circle”, a namespace for circle is created under the current scope. You need to reference the Circle class as circle.Circle.

Importing the Circle class of the circle module

When you import the Circle class via “from circle import Circle”, the Circle class is added to the current scope, and you can reference the Circle class directly.

1.2.4. Example 2: The Point class and Operator Overloading (point.py)#

In this example, we shall define a Point class, which models a 2D point with x and y coordinates. We shall also overload the operators '+' and '*' by overriding the so-called magic methods __add__() and __mul__().

Point class diagram

"""
point.py: The point module, which defines the Point class
"""
 
class Point:    # In Python 2, use: class Point(object):
    """A Point instance models a 2D point with x and y coordinates"""
 
    def __init__(self, x = 0, y = 0):
        """Initializer, which creates the instance variables x and y with default of (0, 0)"""
        self.x = x
        self.y = y
 
    def __str__(self):
        """Return a descriptive string for this instance"""
        return '({}, {})'.format(self.x, self.y)
 
    def __repr__(self):
        """Return a command string to re-create this instance"""
        return 'Point(x={}, y={})'.format(self.x, self.y)
 
    def __add__(self, right):
        """Override the '+' operator: create and return a new instance"""
        p = Point(self.x + right.x, self.y + right.y)
        return p
 
    def __mul__(self, factor):
        """Override the '*' operator: modify and return this instance"""
        self.x *= factor
        self.y *= factor
        return self
 
# Test
if __name__ == '__main__':
    p1 = Point()
    print(p1)      # (0.00, 0.00)
    p1.x = 5
    p1.y = 6
    print(p1)      # (5.00, 6.00)
    p2 = Point(3, 4)
    print(p2)      # (3.00, 4.00)
    print(p1 + p2) # (8.00, 10.00) Same as p1.__add__(p2)
    print(p1)      # (5.00, 6.00) No change
    print(p2 * 3)  # (9.00, 12.00) Same as p1.__mul__(p2)
    print(p2)      # (9.00, 12.00) Changed
(0, 0)
(5, 6)
(3, 4)
(8, 10)
(5, 6)
(9, 12)
(9, 12)

1.2.4.1. How It Works?#

  1. Python supports operator overloading. You can overload '+', '-', '*', '/', '//' and '%' by overriding member methods __add__(), __sub__(), __mul__(), __truediv__(), __floordiv__() and __mod__(), respectively. You can overload other operators too.

  2. In this example, the __add__() returns a new instance; whereas the __mul__() multiplies into this instance and returns this instance.

1.2.4.2. The getattr(), setattr(), hasattr() and delattr() Built-in Functions#

You can access an object’s attribute via the dot operator by hard-coding the attribute name, provided you know the attribute name in compile time.

For example, you can use:

  • obj_name.attr_name: to read an attribute

  • obj_name.attr_name = value: to write value to an attribute

  • del obj_name.attr_name: to delete an attribute

Alternatively, you can use built-in functions like getattr(), setattr(), delattr(), hasattr(), by using a variable to hold an attribute name, which will be bound during runtime.

  • hasattr(obj_name, attr_name): returns True if the obj_name contains the _atr_name_.

  • getattr(obj_name, attr_name[, default]): returns the value of the _attr_name_ of the _obj_name_, equivalent to _obj_name_._attr_name_. If the _attr_name_ does not exist, it returns the _default_ if present; otherwise, it raises AttributeError.

  • setattr(obj_name, attr_name, attr_value): sets a value to the attribute, equivalent to _obj_name_._attr_name_ = _value_.

  • delattr(obj_name, attr_name): deletes the named attribute, equivalent to del _obj_name_._attr_name_.

1.2.4.3. Class Variable vs. Instance Variables#

Class variables are shared by all the instances, whereas instance variables are specific to that particular instance.

class MyClass:
    count = 0  # Total number of instances
               # A class variable shared by all the instances

    def __init__(self):
        # Update class variable
        self.__class__.count += 1   # Increment count
                                    # or MyClass.count += 1
        # Create instance variable: an 'id' of the instance in running numbers
        self.id = self.__class__.count

    def get_id(self):
        return self.id

    def get_count(self):
        return self.__class__.count

if __name__ == '__main__':
    print(MyClass.count)                #0
    
    myinstance1 = MyClass()
    print(MyClass.count)                #1
    print(myinstance1.get_id())         #1
    print(myinstance1.get_count())      #1
    print(myinstance1.__class__.count)  #1
    
    myinstance2 = MyClass()
    print(MyClass.count)                #2
    print(myinstance1.get_id())         #1
    print(myinstance1.get_count())      #2
    print(myinstance1.__class__.count)  #2
    print(myinstance2.get_id())         #2
    print(myinstance2.get_count())      #2
    print(myinstance2.__class__.count)  #2
0
1
1
1
1
2
1
2
2
2
2
2

Note

Python does not support access control. In other words, all attributes are “public” and are accessible by ALL.

However, by convention:

  • Names begin with an underscore _ are meant for internal use, and are not recommended to be accessed outside the class definition.

  • Names begin with double underscores __ and not end with double underscores are further hidden from direct access through name mangling (or rename).

  • Names begin and end with double underscores (such as __init__, __str__, __add__) are special magic methods.

Example:

class MyClass:
    def __init__(self):
        self.myvar = 1       # public
        self._myvar = 2      # meant for internal use (private). 'Please' don't access directly
        self.__myvar = 3     # name mangling
        self.__myvar_ = 4    # name mangling
        self.__myvar__ = 5   # magic attribute

    def print(self):
        # All variables can be used within the class definition
        print(self.myvar)
        print(self._myvar)
        print(self.__myvar)
        print(self.__myvar_)
        print(self.__myvar__)

if __name__ == '__main__':
    myinstance1 = MyClass()
    print(myinstance1.myvar)
    print(myinstance1._myvar)
    # Variables beginning with __ are not accessible outside the class except those ending with __
    #print(myinstance1.__myvar)    # AttributeError
    #print(myinstance1.__myvar_)   # AttributeError
    print(myinstance1.__myvar__)

    myinstance1.print()

    print(dir(myinstance1))
1
2
5
1
2
3
4
5
['_MyClass__myvar', '_MyClass__myvar_', '__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__getstate__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__module__', '__myvar__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '__weakref__', '_myvar', 'myvar', 'print']

1.2.5. Example 3: Class Circle with get set methods (get_set.py)#

In this example, we shall rewrite the Circle class to access the instance variable via the getter and setter. We shall rename the instance variable to _radius (meant for internal use only or private), with “public” getter get_radius() and setter set_radius(), as follows:

Circle class with getter and setter

"""circle.py: The circle module, which defines the Circle class"""
from math import pi
 
class Circle:
    """A Circle instance models a circle with a radius"""
 
    def __init__(self, _radius = 1.0):
        """Initializer with default radius of 1.0"""
        # Change from radius to _radius (meant for internal use)
        # You should access through the getter and setter.
        self.set_radius(_radius)   # Call setter
 
    def set_radius(self, _radius):
        """Setter for instance variable radius with input validation"""
        if _radius < 0:
            raise ValueError('Radius shall be non-negative')
        self._radius = _radius
 
    def get_radius(self):
        """Getter for instance variable radius"""
        return self._radius
 
    def get_area(self):
        """Return the area of this Circle instance"""
        return self.get_radius() * self.get_radius() * pi  # Call getter
 
    def __repr__(self):
        """Return a command string to recreate this instance"""
        # Used by str() too as __str__() is not defined
        return 'Circle(radius={})'.format(self.get_radius())  # Call getter
 
if __name__ == '__main__':
    c1 = Circle(1.2)        # Constructor and Initializer
    print(c1)               # Invoke __repr__(). Output: Circle(radius=1.200000)
    print(vars(c1))         # Output: {'_radius': 1.2}
    print(c1.get_area())    # Output: 4.52389342117
    print(c1.get_radius())  # Run Getter. Output: 1.2
    c1.set_radius(3.4)      # Test Setter
    print(c1)               # Output: Circle(radius=3.400000)
    c1._radius = 5.6        # Access instance variable directly (NOT recommended but permitted)
    print(c1)               # Output: Circle(radius=5.600000)
 
    c2 = Circle()  # Default radius
    print(c2)      # Output: Circle(radius=1.000000)
 
#    c3 = Circle(-5.6)  # ValueError: Radius shall be non-negative
Circle(radius=1.2)
{'_radius': 1.2}
4.523893421169302
1.2
Circle(radius=3.4)
Circle(radius=5.6)
Circle(radius=1.0)

1.2.5.1. How It Works?#

  1. We rewrite the Circle class with “public” getter/setter. This is often done because the getter and setter need to carry out certain processing, such as data conversion in getter, or input validation in setter.

  2. We renamed the instance variable _radius, with a leading underscore to denote it “private” (but it is still accessible to all). According to Python naming convention, names beginning with a underscore are to be treated as “private”, i.e., it shall not be used outside the class. We named our “public” getter and setter get_radius() and set_radius(), respectively.

  3. In the constructor, we invoke the setter to set the instance variable, instead of assign directly, as the setter may perform tasks like input validation. Similarly, we use the getter in get_area() and __repr__().

1.2.6. Magic Methods#

A magic method is an object’s member methods that begins and ends with double underscore, e.g., __init__(), __str__(), __repr__(), __add__(), __len__().

In Python, built-in operators and functions invoke the corresponding magic methods. For example, operator '+' invokes __add__(), built-in function len() invokes __len__(). Even though the magic methods are invoked implicitly via built-in operators and functions, you can also call them explicitly, e.g., 'abc'.__len__() is the same as len('abc').

The following table summarizes the commonly-used magic methods and their invocation.

Magic Method

Invoked Via

Invocation Syntax

lt(self, right)
gt(self, right)
le(self, right)
ge(self, right)
eq(self, right)
ne(self, right)

Comparison Operators

self < right
self > right
self <= right
self >= right
self == right
self != right

add(self, right)
sub(self, right)
mul(self, right)
truediv(self, right)
floordiv(self, right)
mod(self, right)
pow(self, right)

Arithmetic Operators

self + right
self - right
self * right
self / right
self // right
self % right
self ** right

and(self, right)
or(self, right)
xor(self, right)
invert(self)
lshift(self, n)
rshift(self, n)

Bitwise Operators

self & right
self

str(self)
repr(self)
sizeof(self)

Built-in Function Call

str(self), print(self)
repr(self)
sizeof(self)

len(self)
contains(self, item)
iter(self)
next(self)
getitem(self, key)
setitem(self, key, value)
delitem(self, key)

Sequence Operators & Built-in Functions

len(self)
item in self
iter(self)
next(self)
self[key]
self[key] = value
del self[key]

int(self)
float(self)
bool(self)
oct(self)
hex(self)

Type Conversion Built-in Function Call

int(self)
float(self)
bool(self)
oct(self)
hex(self)

init(self, *args)
new(cls, *args)

Constructor / Initializer

x = ClassName(*args)

del(self)

Operator del

del x

index(self)

Convert this object to an index

x[self]

radd(self, left)
rsub(self, left)

RHS (Reflected) addition, subtraction, etc.

left + self
left - self

iadd(self, right)
isub(self, right)

In-place addition, subtraction, etc

self += right
self -= right

pos(self)
neg(self)

Unary Positive and Negate operators

+self
-self

round(self)
floor(self)
ceil(self)
trunc(self)

Function Call

round(self)
floor(self)
ceil(self)
trunc(self)

getattr(self, name)
setattr(self, name, value)
delattr(self, name)

Object’s attributes

self.name
self.name = value
del self.name

call(self, *args, **kwargs)

Callable Object

obj(*args, **kwargs);

enter(self), exit(self)

Context Manager with-statement

 

1.2.6.1. Operator Overloading#

Python supports operators overloading via overriding the corresponding magic functions.

1.2.7. Example 4: Circle with operator overloading (operator_overload.py)#

To Override the '==' operator for the Circle class:

class Circle:
    def __init__(self, radius):
        self.radius = radius

    def __eq__(self, right):
        """Override operator '==' to compare the two radius"""
        if self.__class__.__name__ == right.__class__.__name__:
            return self.radius == right.radius
        raise TypeError("not a 'Circle' object")

if __name__ == '__main__':
    print(Circle(8) == Circle(8))   # True
    print(Circle(8) == Circle(88))  # False
#    print(Circle(8) == 'abc')       # TypeError
True
False

1.3. Inheritance and Polymorphism#

1.3.1. Example 5: The Cylinder class as a subclass of Circle class (cylinder.py)#

In this example, we shall define a Cylinder class, as a subclass of Circle. The Cylinder class shall inherit attributes radius and get_area() from the superclass Circle, and add its own attributes height and get_volume().

Cylinder and Circle classes

from circle import Circle  # Using the Circle class in the circle module

class Cylinder(Circle):
    """The Cylinder class is a subclass of Circle"""

    def __init__(self, radius = 1.0, height = 1.0):
        """Constructor"""
        super().__init__(radius)  # Invoke superclass' constructor (Python 3)
            # OR
            # super(Cylinder, self).__init__(radius)   (Python 2)
            # Circle.__init__(self, radius)            Explicit superclass class
        self.height = height

    def __str__(self):
        """Self Description for print()"""
        # If __str__ is missing in the subclass, print() will invoke the superclass version!
        return 'Cylinder(radius=%.2f,height=%.2f)' % (self.radius, self.height)

    def get_volume(self):
        """Return the volume of the cylinder"""
        return self.get_area() * self.height  # Inherited get_area()
 
# For testing
if __name__ == '__main__':
    cy1 = Cylinder(1.1, 2.2)  # Output: Cylinder(radius=1.10,height=2.20)
    print(cy1)
    print(cy1.get_area())     # Use inherited superclass' method
    print(cy1.get_volume())   # Invoke its method
 
    cy2 = Cylinder()          # Default radius and height
    print(cy2)                # Output: Cylinder(radius=1.00,height=1.00)
    print(cy2.get_area())
    print(cy2.get_volume())

    print(dir(cy1))
        # ['get_area', 'get_volume', 'height', 'radius', ...]
    print(Cylinder.get_area)
        # 
        # Inherited from the superclass
    print(Circle.get_area)
        # 

    c1 = Circle(3.3)
    print(c1)    # Output: This is a circle with radius of 3.30
        
    print(issubclass(Cylinder, Circle))  # True
    print(issubclass(Circle, Cylinder))  # False
    print(isinstance(cy1, Cylinder))     # True
    print(isinstance(cy1, Circle))       # True (A subclass object is also a superclass object)
    print(isinstance(c1, Circle))        # True
    print(isinstance(c1, Cylinder))      # False (A superclass object is NOT a subclass object)
    print(Cylinder.__base__)             # Show superclass: 
    print(Circle.__subclasses__())       # Show a list of subclasses: []
Cylinder(radius=1.10,height=2.20)
3.8013271108436504
8.362919643856031
Cylinder(radius=1.00,height=1.00)
3.141592653589793
3.141592653589793
['__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__getstate__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '__weakref__', 'get_area', 'get_volume', 'height', 'radius']
<function Circle.get_area at 0x000002DE8B4CBEC0>
<function Circle.get_area at 0x000002DE8B4CBEC0>
This is a circle with radius of 3.30
True
False
True
True
True
False
<class 'circle.Circle'>
[<class '__main__.Cylinder'>]

1.3.1.1. How It Works?#

  1. When you construct a new instance of Cylinder via:

    cy1 = Cylinder(1.1, 2.2)

Python first creates a plain Cylinder object and invokes the Cylinder’s __init__() with self binds to the newly created cy1, as follows:

    Cylinder.__init__(cy1, 1.1, 2.2)

Inside the __init__(), the super().__init__(radius) invokes the superclass’ __init__(). This creates a superclass instance with radius. The next statement self.height = height creates the instance variable height for cy1.

Take note that Python does not call the superclass' constructor automatically.
  1. If __str__() or __repr__() is missing in the subclass, str() and repr() will invoke the superclass version.

1.3.1.2. super()#

There are two ways to invoke a superclass method:

  1. via explicit classname: e.g.,

Circle.__init__(self)
Circle.get_area(self)
  1. via super(): e.g.,

super().__init__(radius)      
super().get_area()               

The super() method returns a proxy object that delegates method calls to a parent or sibling class. This is useful for accessing inherited methods that have been overridden in a class.

1.3.1.3. Example 6: Method Overriding#

In this example, we shall override the get_area() method to return the surface area of the cylinder. We also rewrite the __str__() method, which also overrides the inherited method. We need to rewrite the get_volume() to use the superclass’ get_area(), instead of this class.

Cylinder Override

"""cylinder.py: The cylinder module, which defines the Cylinder class"""
from math import pi
from circle import Circle  # Using the Circle class in the circle module
 
class Cylinder(Circle):
    """The Cylinder class is a subclass of Circle"""
 
    def __init__(self, radius = 1.0, height = 1.0):
        """Initializer"""
        super().__init__(radius)  # Invoke superclass' initializer
        self.height = height
 
    def __str__(self):
        """Self Description for print() and str()"""
        return 'Cylinder({}, height={})'.format(super().__repr__(), self.height)
                # Use superclass' __repr__()
 
    def __repr__(self):
        """Formal Description for repr()"""
        return self.__str__()   # re-direct to __str__() (not recommended)
 
    # Override
    def get_area(self):
        """Return the surface area the cylinder"""
        return 2.0 * pi * self.radius * self.height
 
    def get_volume(self):
        """Return the volume of the cylinder"""
        return super().get_area() * self.height  # Use superclass' get_area()
 
# For testing
if __name__ == '__main__':
    cy1 = Cylinder(1.1, 2.2)
    print(cy1)              # Invoke __str__(): Cylinder(Circle(radius=1.1), height=2.2)
    print(cy1.get_area())   # Invoke overridden version
    print(cy1.get_volume()) # Invoke its method
    print(cy1.radius)
    print(cy1.height)
    print(str(cy1))         # Invoke __str__()
    print(repr(cy1))        # Invoke __repr__()
 
    cy2 = Cylinder()        # Default radius and height
    print(cy2)              # Invoke __str__(): Cylinder(Circle(radius=1.0), height=1.0)
    print(cy2.get_area())
    print(cy2.get_volume())
 
    print(dir(cy1))
    print(Cylinder.get_area)
    print(Circle.get_area)
Cylinder(Circle(radius=1.100000), height=2.2)
15.205308443374602
8.362919643856031
1.1
2.2
Cylinder(Circle(radius=1.100000), height=2.2)
Cylinder(Circle(radius=1.100000), height=2.2)
Cylinder(Circle(radius=1.000000), height=1.0)
6.283185307179586
3.141592653589793
['__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__getstate__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '__weakref__', 'get_area', 'get_volume', 'height', 'radius']
<function Cylinder.get_area at 0x000002DE8B5384A0>
<function Circle.get_area at 0x000002DE8B4CBEC0>
  1. In Python, the overridden version replaces the inherited version.

  2. To access superclass’ version of a method, use:

    • super()._method_name_(*_args_), e.g., super().get_area()

    • Explicitly via the class name: _superclass_._method-name_(self, *_args_), e.g., Circle.get_area(self)

1.3.2. Example 7: Shape and its subclasses#

Shape and subclasses

from math import pi

class Shape:    
    """The superclass Shape with a color"""
    def __init__(self, color = 'red'):  # Constructor
        self.color = color
    def __str__(self):  # For print() and str()
        return 'Shape(color=%s)' % self.color

class Circle(Shape):
    """The Circle class: a subclass of Shape with a radius"""
    def __init__(self, radius = 1.0, color = 'red'):  # Constructor
        super().__init__(color)
        self.radius = radius
    def __str__(self):  # For print() and str()
        return 'Circle(%s, radius=%.2f)' % (super().__str__(), self.radius)
    def get_area(self):
        return self.radius * self.radius * pi

class Rectangle(Shape):
    """The Rectangle class: a subclass of Shape wit a length and width"""
    def __init__(self, length = 1.0, width = 1.0, color = 'red'):  # Constructor
        super().__init__(color)
        self.length = length
        self.width = width
    def __str__(self):  # For print() and str()
        return 'Rectangle(%s, length=%.2f, width=%.2f)' % (super().__str__(), self.length, self.width)
    def get_area(self):
        return self.length * self.width

class Square(Rectangle):
    """The Square class: a subclass of Rectangle having the same length and width"""
    def __init__(self, side = 1.0, color = 'red'):  # Constructor
        super().__init__(side, side, color)
    def __str__(self):  # For print() and str()
        return 'Square(%s)' % super().__str__()

# For Testing
if __name__ == '__main__':
    s1 = Shape('orange')
    print(s1)                # Shape(color=orange)
    print(s1.color)
 
    c1 = Circle(1.2, 'orange')
    print(c1)                # Circle(Shape(color=orange), radius=1.20)
    print(c1.get_area())
 
    r1 = Rectangle(1.2, 3.4, 'orange')
    print(r1)                # Rectangle(Shape(color=orange), length=1.20, width=3.40)
    print(r1.get_area())
 
    sq1 = Square(5.6, 'orange')
    print(sq1)               # Square(Rectangle(Shape(color=orange), length=5.60, width=5.60))
    print(sq1.get_area())
Shape(color=orange)
orange
Circle(Shape(color=orange), radius=1.20)
4.523893421169302
Rectangle(Shape(color=orange), length=1.20, width=3.40)
4.08
Square(Rectangle(Shape(color=orange), length=5.60, width=5.60))
31.359999999999996

1.3.3. Multiple Inheritance#

Python supports multiple inheritance, which is defined in the form of “class _class_name_(_base_class__1, _base_class__2,...):”.

1.3.3.1. Diamond Problem#

Suppose that two classes B and C inherit from a superclass A, and D inherits from both B and C. If A has a method called m(), and m() is overridden by B and/or C, then which version of m() is inherited by D?

Diamond problem

Let’s look at Python’s implementation.

1.3.3.1.1. Example 1#
class A:
    def m(self):
        print('in Class A')

class B(A):
    def m(self):
        print('in Class B')
    
class C(A):
    def m(self):
        print('in Class C')

# Inherits from B, then C. It does not override m()
class D1(B, C):  
    pass

# Different order of subclass list
class D2(C, B):
    pass

# Override m()
class D3(B, C):
    def m(self):
        print('in Class D3')

if __name__ == '__main__':
    x = D1()
    x.m()   # 'in Class B' (first in subclass list)

    x = D2()
    x.m()   # 'in Class C' (first in subclass list)
    
    x = D3()
    x.m()   # 'in Class D3' (overridden version)
in Class B
in Class C
in Class D3
1.3.3.1.2. Example 2#

Suppose the overridden m() in B and C invoke A’s m() explicitly.

class A:
    def m(self):
        print('in Class A')

class B(A):
    def m(self):
        A.m(self)
        print('in Class B')
    
class C(A):
    def m(self):
        A.m(self)
        print('in Class C')

class D(B, C):
    def m(self):
        B.m(self)
        C.m(self)
        print('in Class D')

if __name__ == '__main__':
    x = D()
    x.m()
in Class A
in Class B
in Class A
in Class C
in Class D
1.3.3.1.3. Example 3 Using super()#
class A:
    def m(self):
        print('in Class A')

class B(A):
    def m(self):
        super().m()
        print('in Class B')
    
class C(A):
    def m(self):
        super().m()
        print('in Class C')

class D(B, C):
    def m(self):
        super().m()
        print('in Class D')

if __name__ == '__main__':
    x = D()
    x.m()
in Class A
in Class C
in Class B
in Class D

With super(), A’s m() is only run once. This is because super() uses the so-called Method Resolution Order (MRO) to linearize the superclass. Hence, super() is strongly recommended for multiple inheritance, instead of explicit class call.

1.3.3.1.4. Example 4 Let’s look at init()#
class A:
    def __init__(self):
        print('init A')
    
class B(A):
    def __init__(self):
        super().__init__()
        print('init B')
    
class C(A):
    def __init__(self):
        super().__init__()
        print('init C')

class D(B, C):
    def __init__(self):
        super().__init__()
        print('init D')

if __name__ == '__main__':
    d = D()
    # init A
    # init C
    # init B
    # init D
    
    c = C()
    # init A
    # init C
    
    b = B()
    # init A
    # init B
init A
init C
init B
init D
init A
init C
init A
init B

Each superclass is initialized exactly once, as desired.

1.3.4. Example 8: Electric potencial at a point#

Write a class Charge for calculation of electric potential at a point due to a given charged particle. The method uses Coulomb’s law in two-dimensional model, so the electric potential at a point due to a given charged particle is represented by \(V = kq/r\), where \(q\) is the charge value, r is the distance from the point to the charge, and \(k = 8.99 × 10^{9} N m^2/C^2\) is the electrostatic constant, or Coulomb’s constant. Using SI (Systeme International d’Unites) in this formula, N designates newtons (force), m designates meters (distance), and C represent coulombs (electric charge). When there are multiple charged particles, the electric potential at any point is the sum of the potentials due to each charge.

#-----------------------------------------------------------------------
# charge.py
#-----------------------------------------------------------------------
import sys
import math
#-----------------------------------------------------------------------

class Charge:

    # Construct self centered at (x, y) with charge q.
    def __init__(self, x0, y0, q0):
        self._rx = x0  # x value of the query point
        self._ry = y0  # y value of the query point
        self._q = q0   # Charge

    # Return the potential of self at (x, y).
    def potentialAt(self, x, y):
        COULOMB = 8.99e09
        dx = x - self._rx
        dy = y - self._ry
        r = math.sqrt(dx*dx + dy*dy)
        if r == 0.0: # Avoid division by 0
            if self._q >= 0.0:
                return float('inf')
            else:
                return float('-inf')
        return COULOMB * self._q / r

    # Return a string representation of self.
    def __str__(self):
        result = str(self._q) + ' at ('
        result += str(self._rx) + ', ' + str(self._ry) + ')'
        return result

#-----------------------------------------------------------------------

# For testing.
# Accept floats x and y as command-line arguments. Create a Charge
# objects with fixed position and electrical charge, and write to
# standard output the potential at (x, y) due to the charge.

def main():
    x = float(sys.argv[1])
    y = float(sys.argv[2])
    c = Charge(.51, .63, 21.3)
    print(c)
    print(c.potentialAt(x, y))

if __name__ == '__main__':
    main()

To run in Spyder use the next configuration: Ejecutar (Run) -> Configuración por archivo (Configuration per file) -> Ejecutar en una terminal de comandos del sistema (Execute in an external system terminal) -> Opciones de línea de comandos (Command line options).

Client that uses charge to calculate the potential due to several charges:

#-----------------
# chargeclient.py
#-----------------
import sys
from charge import Charge
# Acepta floats x e y como argumentos en la línea de comandos. Crea dos objetos 
# Charge con posición y carga. Imprime el potencial en (x, y) en la salida estandard
x = float(sys.argv[1])
y = float(sys.argv[2])
c1 = Charge(.51, .63, 21.3)
c2 = Charge(.13, .94, 81.9)
v1 = c1.potentialAt(x, y)
v2 = c2.potentialAt(x, y)
print(f'potential at ({x:.2f}, {y:.2f}) due to')
print('  ' + str(c1) + ' and')
print('  ' + str(c2))
print(f'is {v1+v2:.3e}')

1.3.5. Example 8: Complex Numbers#

A complex number is a number of the form \(x + yi\), where x and y are real numbers and \(i\) is the square root of -1. The number \(x\) is known as the real part of the complex number, and the number \(y\) is known as the imaginary part.

Although Python provides a complex data type (with a lowercase c), complex class development (with a capital C) is presented.

The operations on complex numbers that are needed for basic computations are to add and multiply them by applying the commutative, associative, and distributive laws of algebra (along with the identity \(i^2 = -1\)); to compute the magnitude; and to extract the real and imaginary parts, according to the following equations:

Addition: \((x+yi) + (v+wi) = (x+v) + (y+w)i\)
Multiplication: \((x + yi) * (v + wi) = (xv - yw) + (yv + xw)i\)
Magnitude: \(|x + yi| = (x^{2}+y^{2})^{\frac{1}{2}}\)
Real part: \(Re(x + yi) = x\)
Imaginary part: \(Im(x + yi) = y\)

The API that specifies the data-type operations is:

Client operation

Special method

Description

Complex(x,y)

init(self,re,im)

new Complex object with value x + yi

a.re()

real part of a

a.im()

imaginary part of a

a + b

add(self,other)

sum of a and b

a * b

mul(self,other)

product of a and b

abs(a)

abs(self)

magnitud of a

str(a)

str(self)

x + yi (string representation of a)

#-----------------------------------------------------------------------
# complex.py
#-----------------------------------------------------------------------

import math

# A Complex object is a complex number.

# A Complex object is immutable.  So once you create and initialize
# a Complex object, you cannot change it.

class Complex:

    # Construct self with real part real and imaginary
    # part imag. real defaults to 0.0. imag also defaults to 0.0.
    def __init__(self, re=0.0, im=0.0):
        self._re = re
        self._im = im

    # Return the real part of self.
    def re(self):
        return self._re

    # Return the imaginary part of self.
    def im(self):
        return self._im

    # Return the conjugate of self.
    def conjugate(self):
        return Complex(self._re, -self._im)

    # Return a new Complex object which is the sum of self and 
    # Complex object other.
    def __add__(self, other):
        re = self._re + other._re
        im = self._im + other._im
        return Complex(re, im)

    # Return a new Complex object which is the product of self and
    # Complex object other.
    def __mul__(self, other):
        re = self._re * other._re - self._im * other._im
        im = self._re * other._im + self._im * other._re
        return Complex(re, im)

    # Return True if self and Complex object other are equal, and
    # False otherwise.
    def __eq__(self, other):
        return (self._re == other._re) and (self._im == other._im)

    # Return True if self and Complex object other are unequal, and
    # False otherwise.
    def __ne__(self, other):
        return not self.__eq__(other)

    # Return the absolute value of self.
    def __abs__(self):
        return math.sqrt(self._re*self._re + self._im*self._im)
        # Alternative: return math.hypot(self._re, self._im)
        # Alternative: return self.__mul__(self.conjugate())

    # Return a string representation of self.
    def __str__(self):
        return str(self._re) + ' + ' + str(self._im) + 'i'

#-----------------------------------------------------------------------

# For testing.
# Create and use some Complex objects.

def main():
    z0 = Complex(1.0, 1.0)
    z = z0
    z = z * z + z0
    z = z * z + z0
    print(z)

if __name__ == '__main__':
    main()
-7.0 + 7.0i

References: