>
Python Notes : Classes
Classes
standard features of OOP
  • class inheritance mechanism allows multiple base classes
  • a derived class can override any methods of its base class or classes
  • a method can call the method of a base class with the same name
normally all class members are public
all functions are virtual
A Word About Names and Objects
an object can have multiple references aka aliases
argument passing is done by reference
an alias is essentially a pointer
Python Scopes and Namespaces
these notes are taken from ChatGPT
types of namespaces
  • Built-in Namespace - contains Python's built-in functions and exceptions
    namespace is available to all Python code
  • Global Namespace - is at the level of a script or module
    holds names that are defined at the top level of the script or module
  • Local Namespace - is created within functions or methods
    holds names that are locally defined within a function
  • Enclosing Namespace - This is a special type of namespace created for nested functions
    refers to the namespaces in the outer functions
When accessing a variable or function the interpreter follows the LEGB rule to resolve the name
  • L (Local) - first it looks in the local namespace (the current function or method)
  • E (Enclosing) - if not found, it looks in the enclosing namespaces (the namespaces of any enclosing functions)
  • G (Global) - then it looks in the global namespace (the module-level namespace)
  • B (Built-in) - finally it checks the built-in namespace (contains all built-in functions and exceptions)
below x is defined in the global namespace and y is defined in the local namespace inside the function my_function
x = 10  # Global namespace

def my_function():
    y = 20  # Local namespace
    print(x)  # Refers to global namespace, x = 10
    print(y)  # Refers to local namespace, y = 20

my_function()
The global Keyword
the global keyword is used to declare a variable as global within a function
the variable will refer to the variable in the global namespace, not a local one
allows modification of the value of a global variable from within a function
  • modifying global variables - if a value is assigned to a variable inside a function, a local variable for that function will be created
    to modify a global variable inside a function, need to declare it as global
    without the declaration, any assignment to that variable inside the function will create a new local variable and not affect the global variable
  • declaration syntax - use the global keyword followed by the variable name inside the function
    global variable_name
  • usage of global -
    • without global a function creates a local variable by default
    • with global the function modifies the global variable and not a local one
    x = 5  # Global variable
    
    def modify_global():
        global x  # Declare x as global
        x = 10    # Modify the global variable x
    
    modify_global()
    print(x)  # Output: 10
    
  • using global -
    • use global when needing to modify a variable from the global scope within a function
    • typically best to minimize the use of global because it can make code harder to understand and maintain
      use return values or use objects to manage state more cleanly.
Class Definition Syntax
class ClassName:
    <statement-1>
    .
    .
    .
    <statement-N>
Class Objects
class objects support two kinds of operations
  • Attribute references use the standard syntax used for all attribute references e.g. obj.name
    valid attribute names are all the names that were in the class's namespace when the class object was created
    class MyClass:
        """A simple example class""" # __doc__ is valid attribute reference returning the docstring
        i = 12345 # valid attribute reference returning an int
    
        def f(self):  # valid attribute reference returning a string
            return 'hello world'
  • class instantiation uses function notation
    x = MyClass()
    creates an empty object
    to initialize an object a class may define a special method named __init__()
    def __init__(self):
        self.data = []
    __init__ may be passed any number of arguments
    when an __init__() method is defined, class instantiation automatically invokes __init__() for the newly-created class instance
    >>>class Complex:
    ...    def __init__(self, realpart, imagpart):
    ...    self.r = realpart
    ...    self.i = imagpart
    ...
    >>> x = Complex(3.0, -4.5)
    >>> x.r, x.i
    (3.0, -4.5)
Instance Objects
data attributes are data members
data attributes do not have to be declared
they are created when they are first assigned to
class MyClass:
    """A simple example class"""
    x.counter = 1
    while x.counter < 10:
        x.counter = x.counter * 2
    print(x.counter)
    del x.counter

    i = 12345 

    def f(self): 
        return 'hello world'
when MyClass is instantiated, the counter attribute is created and set to 1
the code will print 16 and the variable is deleted

a method is an instance attribure that is a function
all attributes of a class that are function objects define corresponding methods of its instances
above

  • x.f is a valid method reference since MyClass.f is a function object
  • x.i is not because MyClass.i is not a function
  • MyClass.f is a method object, not a function object
Method Objects
usually a method is called after it's bound
x.f is a method object and can be storedand called later
xf = x.f
while True:
    print(xf())xf = x.f
while True: # print "hello world" forever
    print(xf())
the two calls below are identical
x.f()
MyClass.f(x)
the first argument to a function is an instance object
when a non-data attribute of an instance is referenced, the instance's class is searched
if the name denotes a valid class attribute (function object), references to both the instance object and the function object are packed into a method object
method object{
    instance object
    function object
}
when the method object is called with an argument list, a new argument list is constructed from the instance object and the argument list
then the function object is called with this new argument list
Class and Instance Variables
instance variables are for data unique to each instance
class variables are for attributes and methods shared by all instances of the class
class Dog:

    kind = 'canine'         # class variable shared by all instances

    def __init__(self, name):
        self.name = name    # instance variable unique to each instance

>>> d = Dog('Fido')
>>> e = Dog('Buddy')
>>> d.kind                  # shared by all dogs
'canine'
>>> e.kind                  # shared by all dogs
'canine'
>>> d.name                  # unique to d
'Fido'
>>> e.name                  # unique to e
'Buddy'
shared data can have side effects with mutable objects such as lists and dictionaries
example of shared list
class Dog:

    tricks = []             # mistaken use of a class variable

    def __init__(self, name):
        self.name = name

    def add_trick(self, trick):
        self.tricks.append(trick)

>>> d = Dog('Fido')
>>> e = Dog('Buddy')
>>> d.add_trick('roll over')
>>> e.add_trick('play dead')
>>> d.tricks                # unexpectedly shared by all dogs
['roll over', 'play dead']
corrected design
class Dog:

    def __init__(self, name):
        self.name = name
        self.tricks = []    # creates a new empty list for each dog

    def add_trick(self, trick):
        self.tricks.append(trick)

>>> d = Dog('Fido')
>>> e = Dog('Buddy')
>>> d.add_trick('roll over')
>>> e.add_trick('play dead')
>>> d.tricks
['roll over']
>>> e.tricks
['play dead']
Random Remarks
if the same attribute name occurs in both an instance and in a class, then attribute lookup prioritizes the instance
>>> class Warehouse:
...   purpose = 'storage'
...   region = 'west'

>>> w1 = Warehouse()
>>> print(w1.purpose, w1.region)
storage west
>>> w2 = Warehouse()
>>> w2.region = 'east'
>>> print(w2.purpose, w2.region)
storage east
no interfaces or abstract classes with Python
no enforced data hiding, all based on convention
clients can add data attributes to class instances
>>> w3 = Warehouse()
>>> w3.someValue = 123
>>> print(w3.someValue)
123
methods may call other methods by using method attributes of the self argument
class Bag:
    def __init__(self):
        self.data = []

    def add(self, x):
        self.data.append(x)

    def addtwice(self, x):
        self.add(x)
        self.add(x)
Inheritance
syntax for a derived class definition
class DerivedClassName(BaseClassName):
    <statement-1>
    .
    .
    .
    <statement-N>
BaseClassName must be defined in a namespace accessible from the derived class definition's scope
class DerivedClassName(modname.BaseClassName):
method references are resolved by searching the corresponding class attribute
descends down the chain of base classes if necessary
the method reference is valid if this yields a function object

derived classes can override base class methods
a base class method that calling another base class method may end up calling a method of a derived class
to call the base class method directly use

BaseClassName.methodname(self, arguments)
two built-in functions which work with inheritance
  • isinstance() checks an instance's type
    isinstance(obj, int) will be True only if obj.__class__ is int or some class derived from int
  • issubclass() checks class inheritance
    issubclass(bool, int) is True since bool is a subclass of int
    issubclass(float, int) is False since float is not a subclass of int
Multiple Inheritance
Python supports mutual inheritance
class DerivedClassName(Base1, Base2, Base3):
    <statement-1>
    .
    .
    .
    <statement-N>
search for inherited attributesis essentially left-to-right
some magic under the hoods makes certain that each base class is searched only once
Private Variables
convention used for private variables in Python
a name prefixed with an underscore (e.g. _spam) should be treated as a non-public part of the API
this convention suggests the variable should be considered an implementation detail and subject to change without notice

limited support for name mangling
a variable named with two leading underscores and one trailing underscore is textually replaced with _classname__variable
useful for letting subclasses override methods without breaking intraclass method calls

class Mapping:
    def __init__(self, iterable):
        self.items_list = []
        self.__update(iterable)

    def update(self, iterable):
        for item in iterable:
            self.items_list.append(item)

    __update = update   # private copy of original update() method

class MappingSubclass(Mapping):

    def update(self, keys, values):
        # provides new signature for update()
        # but does not break __init__()
        for item in zip(keys, values):
            self.items_list.append(item)
Odds and Ends
Python uses dataclass to decorate classes with attributes
classes similar to struct or record in other languages
from dataclasses import dataclass

@dataclass
class Employee:
    name: str
    dept: str
    salary: int
Iterators
most container objects can be looped over using a for statement
under the hood the for statement calls iter() on the container object
function returns an iterator object that defines the method __next__()
iterator access container elements one at a time
when no more elements are available, raises a StopIteration exception
>>> s = 'abc'
>>> it = iter(s)
>>> it
<str_iterator object at 0x10c90e650>
>>> next(it)
'a'
>>> next(it)
'b'
>>> next(it)
'c'
>>> next(it)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
    next(it)
StopIteration
to add iterator behavior to a class define an __iter__() method which returns an object with a __next__() method
if the class defines __next__(), then __iter__() can just return self
class Reverse:
    """Iterator for looping over a sequence backwards."""
    def __init__(self, data):
        self.data = data
        self.index = len(data)

    def __iter__(self):
        return self

    def __next__(self):
        if self.index == 0:
            raise StopIteration
        self.index = self.index - 1
        return self.data[self.index]
usage
>>> rev = Reverse('spam')
>>> iter(rev)
<__main__.Reverse object at 0x00A1DB50>
>>> for char in rev:
...    print(char)
...
m
a
p
s
Generators
generators are a simple and powerful tool for creating iterators
written like regular functions but use the yield statement whenever they want to return data
def reverse(data):
    for index in range(len(data)-1, -1, -1):
        yield data[index]
the __iter__() and __next__() methods are created automatically
>>> for char in reverse('golf'):
...    print(char)
...
f
l
o
g
local variables and execution state are automatically saved between calls
when generators terminate a StopIteration is automatically raised
Generator Expressions
some simple generators can be coded succinctly as expressions using a syntax similar to list comprehensions but with parentheses instead of square brackets
expressions are designed for situations where the generator is used right away by an enclosing function
generator expressions are more compact but less versatile than full generator definitions
tend to be more memory friendly than equivalent list comprehensions
>>> sum(i*i for i in range(10))                 # sum of squares
285

>>> xvec = [10, 20, 30]
>>> yvec = [7, 5, 3]
>>> sum(x*y for x,y in zip(xvec, yvec))         # dot product
260

>>> unique_words = set(word for line in page  for word in line.split())

>>> valedictorian = max((student.gpa, student.name) for student in graduates)

>>> data = 'golf'
>>> list(data[i] for i in range(len(data)-1, -1, -1))
['f', 'l', 'o', 'g']
previous    index    next