<jupyter_start><jupyter_text>Chapter 02: Object-Oriented Programming 2.3 Class DefinitionsIn python, every data is an instance of some class.  Example: Credit Card Class<jupyter_code>print(list().__class__.__name__)
print(str().__class__.__name__)
print(dict().__class__.__name__)
print(list().__class__.__class__.__name__)<jupyter_output>type<jupyter_text>The `self` identifier explicitly identifies the instance that a method is invoked in class.<jupyter_code>class CreditCard:
    """A consumer credit card"""
    
    def __init__(self, customer, bank, acnt, limit):
        """Create a new credit card instance.
        
        The initial balance is zero.
        
        :param customer: the name of the customer 
        :param bank: the name of the bank
        :param acnt: the account identifier
        :param limit: credit limit
        """
        
        self._customer = customer
        self._bank = bank
        self._account = acnt
        self._limit = limit
        self._balance = 0
        
    def get_customer(self):
        """Return name of the customer"""
        return self._customer
    
    def get_bank(self):
        """Return the bank's name."""
        return self._bank
    
    def get_account(self):
        """Return the card identifying number (typically stored as a string."""
        return self._account
    
    def get_limit(self):
        """Return current credit limit."""
        return self._limit
    
    def get_balance(self):
        """Return current balance."""
        return self._balance
    
    def charge(self, price):
        """Charge given price to the card, assuming sufficient credit limit.
        
        Return True if charge was processed; False if charge was denied.
        """
        
        if price + self._balance > self._limit:
            return False
        else:
            self._balance += price
            return True
    
    def make_payment(self, amount):
        """Process customer payment that reduces balance."""
        self._balance -= amount<jupyter_output><empty_output><jupyter_text>Python interpreter automatically binds the instance upon which the method is invoked to the `self` parameter.<jupyter_code>cc = CreditCard('<NAME>', 'Bank', '1234 5678', 1000)
cc<jupyter_output><empty_output><jupyter_text>In class, `__init__` method works as the **constructor** of the class. Also, single leading underscore in the name of a data member, such as `_balance`, implies that it is intended as **nonpublic**. Users of a class should not directly access such members.For better encapsulation, it is mostly better to treat all data members as nonpublic and provide accessors, to provide a user of our class read-only access to a trait, and update methods for updating its memebers. Operator overloading and Python's Special MethodsBy default, operators can not work on classes unless special methods are defined. You can see detailed info at [here](https://docs.python.org/3/reference/datamodel.htmlspecial-method-names). Python also supports non-operator overloads. For example, `str` invokes `__str__()` and `bool` invokes `__bool__()`. However, we should not carelessly assume that Python will manage all the implications. Defining `__eq__` will support syntax `a == b`, but it does not affect the evaluation of a syntax `a != b`, which should be defined by `__ne__`. In similar context, defining `__eq__` and `__lt__` does not imply semantics for a `a <= b `. Example: Multidimensional Vector Class<jupyter_code>class Vector:
    """Represent a vector in a multidimensional space"""
    
    def __init__(self, d):
        """Create d-dimensional vector of zeros."""
        self._coords = [0] * d
        
    def __len__(self):
        """Return the dimension of the vector."""
        return len(self._coords)
    
    def __getitem__(self, j):
        """Return jth coordinate of vector."""
        return self._coords[j]
    
    def __setitem__(self, j, val):
        """Set jth coordinate of vector to given value."""
        self._coords[j] = val
    
    def __add__(self, other):
        """Return sum of two vectors."""
        if len(self) != len(other):
            raise ValueError('dimensions must agree')
        result = Vector(len(self))
        for j in range(len(self)):
            result[j] = self[j] + other[j]
        return result
    
    def __eq__(self, other):
        """Return True if vectgor has same coordinates as other."""
        return self._coords == other._coords
    
    def __ne__(self, other):
        """Return True if vector differs from other."""
        return not self == other  # This rely on existing __eq__ definition
    
    def __str__(self):
        """Produce string representation of vector."""
        return '<' + str(self._coords)[1:-1] + '>'
    
    def __repr__(self):
        """For representation."""
        return '<' + str(self._coords)[1:-1] + '>'
x = Vector(5)
x[2] = 10
x
x + x
x * x  # Gives error since it is not defined through `__mul__`<jupyter_output><empty_output><jupyter_text>IteratorsAn **Iterator** for a collection provides one key behavior: It supports a special method named `__next__` that returns the next element of the collection, if any, or raises a `StopIteration` exception to indicate that there are no further elements.Python automatically supports iterator implementation if both `__len__` and `__getitem__` is defined.<jupyter_code>class SequenceIterator:
    """An iterator for any of Python's sequence types."""
    
    def __init__(self, sequence):
        """Create an iterator for the given sequence."""
        self._seq = sequence  # keep a reference to the underlying data
        self._k = -1  # will increment to 0 on first call to next
    
    def __next__(self):
        """Return the next element, or else raise StopIteration error."""
        self._k += 1
        if self._k < len(self._seq):
            return(self._seq[self._k])
        else:
            raise StopIteration()
        
    def __iter__(self):
        """By convention, an iterator must return itself as an iterator."""
        return self
custom_iter = SequenceIterator([4, 2, 1])
iter(custom_iter)
for i in SequenceIterator([5, 2, 1, 2, 7]):
    print(i)<jupyter_output>5
2
1
2
7<jupyter_text>Example: Range ClassThis example mimics Pthon's built-in `range` class. There is a big difference between Python 3's `range` and Python 2's range. Basically, Python 3's `range` works like a Python 2's `xrange`.The core difference between them is that Python 3's range employs **lazy evaluation**, which does not create a new list instance, which might be expensive if the list is huge, effectively repesenting the desired range of elements without storing them in memory.<jupyter_code>class Range:
    """A class that mimic's the built-in range class."""
    
    def __init__(self, start, stop=None, step=1):
        """Initialize a Range Instance
        Semantics is similar to built-in range class
        """
        if step == 0:
            raise ValueError('step cannot be 0')
        
        if stop is None:
            start, stop = 0, start
            
        # calculate the effective length once
        self._length = max(0, (stop - start + step -1) // step)
        
        # nned knowledge of start and step (but not step) to support _-getitem__
        self._start = start
        self._step = step
        
    def __len__(self):
        """Return number of entries in the range."""
        return self._length
    
    def __getitem__(self, k):
        """Return entry at index k (using standard interpretation if negative.)"""
        if k < 0:
            k += len(self)
        
        if not 0 <= k < self._length:
            raise IndexError('index out of range')
        
        return self._start + k * self._step<jupyter_output><empty_output><jupyter_text>2.4 InheritanceIn object-oriented programming, the mechanism for a modular and hierarchical organization is a technique known as **inheritance**. This allows a new class to be defined based upon an existing class as the starting point. In object-oriented terminology, the existing class is typically described as the **base class**, **parent class**, or **superclass**, while the newly defined classs is known as the **subclass** or **child class**.  Extending the CreditCard Class<jupyter_code>class PredatoryCreditCard(CreditCard):
    """An extension to CreditCard that compounds interest and fees."""
    
    def __init__(self, customer, bank, acnt, limit, apr):
        """Create a new predatory credit card instance.
        
        The initial balance is zero.
        customer    the name of the customer
        bank        the name of the bank
        acnt        the acount identifier
        limit       credit limit
        apr         annual percentage rate
        """

        super().__init__(customer, bank, acnt, limit)
        self._apr = apr
        
    def charge(self, price):
        """Charge given price to the card, assuming sufficient credit limit.
        
        Return True if charge was processed.
        Return False and assess $5 fee if charge is denied.
        """
        
        success = super().charge(price)
        if not success:
            self._balance += 5
        return success
    
    def process_month(self):
        """Assess monthly interest on outstanding balance."""
        if self._balance > 0:
            # if positive balance, convert APR to monthly multiplicative factor
            monthly_factor = pow(1 + self._apr, 1/12)
            self._balance *= monthly_factor
predatory = PredatoryCreditCard('John', 'AA Bank', '1234 5678', 100000000, 0.03)
predatory.charge(1000)
predatory.get_balance()
predatory.process_month()
predatory.get_balance()<jupyter_output><empty_output><jupyter_text>Protected MembersIn Python, names beginning with a single underscore are conventionally akin to **protected**, which are accessible to subclasses, but not to the general public, while names beginning with a double underscore (other than special methods) are akin to **private**, which are not accessible to both subclasses and general public.However, this access control is not supported formally by Python. Keep in mind that it is just a convention. So if you are trying to use some attributes followed by an underscore, think twice that it could compromise the class's designer's intention.  Hierarchy of Numeric ProgressionsAs a second example of use of inheritance, we develop a hierarchy of classes for iterating numeric progressions.![Hierarchy of Progression Classes](../images/Fig2.7.png)<jupyter_code>class Progression:
    """ITerator producing a generic progression.
    
    Default iterator produces the whole numbers 0, 1, 2, ...
    """
    
    def __init__(self, start=0):
        """Initialize current to the first value of the progression."""
        self._current = start
    
    def _advance(self):
        """Update self._current to a new value.
        
        THis should be overridden by a subclass to customize progression.
        
        By convention, if current is set to None, this designates the end of a progression.
        """
        
        self._current +=1
        
    def __next__(self):
        """Return the next element, or else raise StopIteration error."""
        if self._current is None:
            raise StopIteration()
        else:
            answer = self._current
            self._advance()
            return answer
        
    def __iter__(self):
        """By convention, an iterator must return itself as an iterator."""
        return self
    
    def print_progression(self, n):
        """Print next n values of the progression."""
        print(' '.join(str(next(self)) for j in range(n)))<jupyter_output><empty_output><jupyter_text>An Arithmetic Progression Class<jupyter_code>class ArithmeticProgression(Progression):
    """Iterator producing an arithmetic progression."""
    
    def __init__(self, increment=1, start=0):
        """Create a new arithmetic progression.
        
        increment   the fixed constant to add to each term (default 1)
        start       the first term of the progression (default 0)
        """
        super().__init__(start)
        self._increment = increment
    
    def _advance(self):
        """Update current value by adding the fixed increment."""
        self._current += self._increment<jupyter_output><empty_output><jupyter_text>A Geometric Progression Class<jupyter_code>class GeometricProgression(Progression):
    """Iterator producing a geometric progression."""
    
    def __init__(self, base=2, start=1):
        """Create a new geometric progression.
        
        base    the fixed constant multiplied to each term (default 2)
        start   the first term of the progression (default 1)
        """
        
        super().__init__(start)
        self._base = base
    
    def _advance(self):
        """Update current value by multiplying it by the base value."""
        self._current *= self._base<jupyter_output><empty_output><jupyter_text>A Fibonacci Progression Class<jupyter_code>class FibonacciProgression(Progression):
    """Iterator producing a generalized Fibonacci progression."""
    
    def __init__(self, first=0, second=1):
        """Create a new fibonacci progression.
        
        first       the first term of the progression (default 0)
        second      the second term of the progression (default 1)
        """
        
        super().__init__(first)
        self._prev = second - first
    
    def _advance(self):
        """Update current value by taking sum of previous two."""
        self._prev, self._current = self._current, self._prev + self._current<jupyter_output><empty_output><jupyter_text>Testing progressions<jupyter_code>Progression().print_progression(10)
ArithmeticProgression(5).print_progression(10)
ArithmeticProgression(5, 2).print_progression(10)
GeometricProgression().print_progression(10)
GeometricProgression(3).print_progression(10)
FibonacciProgression().print_progression(10)
FibonacciProgression(4, 6).print_progression(10)<jupyter_output>0 1 2 3 4 5 6 7 8 9
0 5 10 15 20 25 30 35 40 45
2 7 12 17 22 27 32 37 42 47
1 2 4 8 16 32 64 128 256 512
1 3 9 27 81 243 729 2187 6561 19683
0 1 1 2 3 5 8 13 21 34
4 6 10 16 26 42 68 110 178 288<jupyter_text>Abstract Base ClassIn classic object-oriented terminology, we say a class is an **abstract base class** if its only purpose is to serve as a base class though inheritance. More formally, an abstract base class is one that cannot be directly instantiated, while **concrete class** is one that can be instantiated. By this definition, our `Progression` class is technically concrete, although we essentially designated it as an abstract base class.In statically typed languages such as Java and C++, an abstract base class serves as a formal type that may guarantee one or more **abstract methods**. This provides support for polymorphism, as a variable may have an abstract base class as its declared type, even though it refers to an instance of a concrete subclass. Because there are no declared types in Python, this kind of polymorphism can be accomplished without the need for a unifying abstract class. For this reason, there is not as strong a tradition of defining abstract base classes in Python, although Python's `abc` module provides support for defining a formal abstract base class.Our reason for focusing on abstract base classes in our study of data structures is that Python's `collections` module provides several abstract base classes that assist when defining custom data structures that share a common interface with some of Python's built-in data structures. THese rely on an object-oriented software design pattern known as the **template method pattern** The template method pattern is when an abstract base class provides concrete behaviors that rely upon calls to other abstract behaviours. In that way, as soon as a sublclass provides definitions for the missing abstract behaviors, the inherited concrete behaviors are well defined.Asa tangible example, the `collections.Sequence` abstract base class defines behaviors common to Python's `list`, `str`, and `tuple` classes, as sequences that support element access via an integer index. More so, the `collections.Sequence` class provides concrete implementations of methods, `count`, `index` and `__contains__` that can be inherited by any class that provides concrete implementations of both `__len__` and `__getitem__`.<jupyter_code>from abc import ABCMeta, abstractmethod


class Sequence(metaclass=ABCMeta):
    """Our own version of collections.Sequence abstract base class."""
    
    @abstractmethod
    def __len__(self):
        """Return the length of the sequence."""
        
    @abstractmethod
    def __getitem__(self, item):
        """Return the element at index item of the sequence."""
    
    def __contains__(self, val):
        """Retrun True if val found in the sequence; False otherwise."""
        for j in range(len(self)):
            if self[j] == val:
                return True
        return False

    def index(self, val):
        """REturn leftmost index at which val is found (or raise ValueError)."""
        for j in range(len(self)):
            if self[j] == val:
                return j
        raise ValueError('value not in sequence')
    
    def count(self, val):
        """Return the number of elements equal to given value."""
        k = 0
        for j in range(len(self)):
            if self[j] == val:
                k += 1
            return k
Sequence()<jupyter_output><empty_output>
{'fraction_non_alphanumeric': 0.08313552616706228, 'fraction_numerical': 0.01441166497117667, 'mean_word_length': 2.9427361853832443, 'pattern_counts': {'":': 0, '<': 58, '<?xml version=': 0, '>': 55, 'https://': 1, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 1, 'word_list_counts': {'cursed_substrings.json': 1, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'chain_length': '13', 'dir': 'jupyter-structured-clean-dedup', 'id': '106659', 'license': 'CC0-1.0', 'n_tokens_mistral': 4792, 'n_tokens_neox': 4489, 'n_words': 1969, 'path': '1.Chapter-Python/presentation/Chapter_02.ipynb', 'repo_name': 'zinebabercha/zineb-abercha', 'size': '17696'}