Classes
=======
Classes are just another (more advanced) way to store data. The good thing about them is that they can store a lot of information
within only one variable (like :ref:`lists:Lists` or :ref:`dictionaries:Dictionaries`). The advantages are it is easier to access the data and classes
can also define custom functions (called methods) which are sometimes refered to as behaviours that each object will possess. More on :ref:`methods<classes:Instance method>` later.


Classes vs. objects
-------------------
The most common confusion is the distinction between a class and an object.
They are intimately tied together, however they are not the same thing.

- **Class**: the *general* thing. e.g., "Human"

  - In our programs, we get to define what a Human is. We get to say, 
    "every human, in general, will have attributes like eye colour, height, weight,
    and a name". 
  - For every Human object we create, we want to store that same information
    for all of them.
  - We can also define a set of behaviours (functions called methods) that every Human 
    object we create will be able to perform.
  

- **Object**: A *specific* instance of a thing. e.g., "Jeff"

  - In our program we get to create Humans and modify (set) their attributes
    according to their specific eye colour, height, weight and name.

Jeff (object) **is a** Human (class)

Attributes and Behaviours
-------------------------
Classes are defined to give their objects *attributes* and *behaviours*.

Object Attributes
^^^^^^^^^^^^^^^^^
These are really just *variables* stored for each instance of a class (each object).
We can also call these attributes *fields*.

So for a particular object, the specific information we store about it like age and eye-color, is called:

- an attribute
- a variable
- a field

You can use these terms interchangably.

Object Behaviours
^^^^^^^^^^^^^^^^^
Every object of a particular class is given abilities or "behaviours". These are called "methods".
Methods are just functions that belong to every instance (object) of a class.

Here are some examples of some methods::

    p = Person("Jeff", 56)
    p.jump()
    p.laugh()
    p.throw(ball)

Those could all be "behaviours" that the ``Person`` named Jeff can do by virtue of being a ``Person`` object, if we decided
to create those methods in the class.

Unified Modeling Language (UML)
-------------------------------
::

    User                 # Name of the class
    ----
    name: str            # list the attributes first
    password: str
    email: str
    ----
    post(message: str) -> None  # behaviours (methods) 

.. image:: https://online.visual-paradigm.com/images/features/main/01-online-class-diagram-example.png


__init__ method
---------------
The init method is what is called when you create an object. It will attach values and data to a single object using the ``self`` pointer.

.. code-block:: python

    class User:
        def __init__(self, username, password, email):
            self.username = username
            self.password = password
            self.email = email


Accessing Object Attributes
---------------------------
.. code-block:: python

    user1 = User("EraserMan", "password123", "blah@gmail.com")
    print(user1.username)
    print(user1.password)
    print(user1.email)

    user1.username = "MrBlah"

    print()
    print(user1.username)
    print(user1.password)
    print(user1.email)

::

    Output:

    EraserMan
    password123
    blah@gmail.com

    MrBlah
    password123
    blah@gmail.com

**Another example:**

.. code-block:: python

    class Person:
        def __init__(self, name: str, age: int):
            self.name = name
            self.age = age


    jeff = Person("Jeff", 35)
    print(f"This is {jeff.name}, they are {jeff.age} years old!")



Object pointers
---------------

For **primitive datatypes**, literal values are stored in variables.

.. code-block:: python

    a = 5
    b = a  # COPY the VALUE of `a` and store it in `b`

    print(a)  # 5
    print(b)  # 5

    a += 3

    print(a)  # 8
    print(b)  # 5

For, **objects** the memory location (pointer) is stored in variables.

.. code-block:: python

    class Person:
        pass

    a = Person()  # create a person object, store the pointer in variable `a`
    a.name = "Frank"

    print(a.name)  # "Frank"

    b = a          # Copies the POINTER, not the data
    print(b.name)  # "Frank"

    print(a)  # <__main__.Person object at 0x0000016E2BE44550>
    print(b)  # <__main__.Person object at 0x0000016E2BE44550> (same memory location)

    a.name = "Sally"
    print(a.name)  # "Sally"
    print(b.name)  # "Sally"

Both variables ``a`` and ``b`` *point* to the same object.


Loop through a list of objects
------------------------------
.. code-block:: python

    class Person:
        def __init__(self, name: str, age: int):
            self.name = name
            self.age = age

    # --- OK ---
    # Store each person object in a vriable, then add them to a list.
    # This has its limitations.
    jeff = Person("Jeff", 35)
    sally = Person("Sally", 30)
    ben = Person("Ben", 22)

    people = [jeff, sally, ben]

    # --- BETTER ---
    # Create the person objects directly in a list
    people = [Person("Jeff", 35), Person("Sally", 30), Person("Ben", 22)]

    for p in people:
        print(f"This is {p.name}, they are {p.age} years old!")


Instance method
---------------
Each instance (object) of a class (with instance methods) will have certain "behaviours" or actions that they will be able to perform. This is different
from using a normal function *on* an object. With instance methods, we think of the object *itself* performing the action.

.. code-block:: python

    p = Person("Jeff")

    # Fundtion: THe object being acted UPON
    greeting = get_introduction(p)

    # Method: The object ITESELF performing the action
    greeting = p.get_introduction()

The end result is the same, it's just a matter of organizing our code. The function will be *outside* the class, where the method
will be contained *within* the class. It may be best to write all functions related as methods within the class itself. This helps keep all
related code in the same place.

Two examples of instance methods. One without parameters, one with.

.. code-block:: python

    class Person:
        def __init__(self, name, height, strength):
            self.name = name
        
        def introduce(self):
            print(f"Hello, my name is {self.name}.")
        
        def complement(self, person):
            print(f"{person.name}, I love your hair!")

    # Usage
    p1 = Person("Jeff")
    p2 = Person("Sally")

    p1.introduce()
    p2.complement(p1)

    # output:
    # "Hello, my name is Jeff."
    # "Jeff, I love your hair!"

.. note:: Technically *all* instance methods have at least one parameter. The ``self`` pointer. In Python, we must explicitly
    define each instance method to include it.

Encapsulation
-------------

.. warning:: Encapsulation section under construction. Proceed at your own risk.

What is an API?
^^^^^^^^^^^^^^^
At some point in your career you will create code, not for a general end-user, but for **other software developers**.

Imagine you love coding in Python but you wish you could write 2D games. There are no easy ways to do it, but you set out to create
to create a clone of `space invaders <https://en.wikipedia.org/wiki/Space_Invaders>`_. You notice some common patterns like drawing circles and rectangles and create some functions
to be able to draw a rectangle with ease. `Pygame is born <https://www.pygame.org/>`_. As time goes on, you add more features and functions that make creating
a game as easy as possible. You upload your source code to `GitHub <https://github.com>`_, and now hundreds of people are using the code you wrote.

All the functions that people are using to draw shapes, create sprites, and perform collision detection **IS** the API.
The API hides all the complicated and unsightly details of drawing a polygon. If you wanted to draw a polygon, it would
require roughly `30 lines of code <https://github.com/pygame/pygame/blob/6a69cddc4f7cd94e81dd83d15f452471c44a090c/src_py/draw_py.py#L529>`_.
The API allowes people to simply use ``pygame.draw.polygon(surface, color, points, width)`` when they want to draw a polygon.

What makes a good API?
^^^^^^^^^^^^^^^^^^^^^^
A good API:

- hides complex code and complex processes from the developer.
- minimizes the number of lines of code someone needs to write to accomplish something.
- ensures the code looks as close to english as possible.


Building an API
^^^^^^^^^^^^^^^
Pick a particular **thing** that operates within your program and bring yourself through the following steps.
For illustrative purposes, we will consider a simple ``WaterBottle``.

State
"""""
- What about the object *can* change?
- What about the object *cannot* change?

Every ``WaterBottle`` will have the following attributes.

.. code-block:: text

    capacity: int
    contents: int

**Capicity** will refer to the maximum volume the bottle can hold and **contents** refers to what volume it is *currently* holding.
For example, a bottle can have a maximum capacity of ``350 mL``, but only currently holding ``90 mL`` of water.

Some attributes throughout the life of the bottle can be changed (``contents``), and somethings should not be (``capacity``).
At the end of the day, the developer makes the decision concerning what attributes can change or not.


Behaviours
""""""""""
- What does the object do?
- What can be done to the object?
- How do these behaviours modify or alter the object's state?
- Do any of these behaviours depend on outside information?
- Do they return a value?

Our ``WaterBottle`` will need the ability to be filled up and poured out. Those can be made into methods.

.. code-block:: text

    fill()
    pour()

How would these abilities alter or modify the bottle's state? They would change the current capacity. 

Do these methods require outside information? Yes, we need to know how much to fill and how much to pour out.

.. code-block:: text

    fill(amount: int)
    pour(amount: int)

Do these behaviours return anything? Perhaps. If you wanted to, you could pour out some water into a cup.

.. code-block:: python

    # hypothetical, speculative code
    bottle = Bottle()
    cup = Cup()
    cup.contents = bottle.pour(50)

    # or
    cup.fill(bottle.pour(50))


Verification
""""""""""""
- How is someone supposed to use your code?
- Come up with a few lines of code showing how to interact with the API.
    - Create an object
    - Use some methods
    - What is the result supposed to be?
- Do you like the way the code looks? Is is easy to use and remember?
- If you need to modify the API, do so.

.. note:: older stuff below.

When writing code professionally, one person
may write a class or a function and many other
people will use it. This is why we need to
document our code properly.

Additionally, the person who creates the class
(or function) is the one who knows about all
the little behind-the-scenes details of how
things work. They will also know certain methods
can only work with a specific set of data.

Let's imagine we have a ``Person`` class with a ``name`` and an ``age``::

    class Person:
        def __init__(self, name, age):
            self.name = name
            self.age = age
        
        def greet(self):
            return f"Hello, I am {self.name} and I'm {self.age} years old."
        
        def can_vote(self):
            return self.age >= 18

Once we have a ``Person`` object created,
whoever uses our ``Person`` class will be able to change
the age to whatever they want. They can change it to something
silly at best or break the program at worst.

.. code-block:: python
    :linenos:
    :emphasize-lines: 6

    person = Person("Johnny", 34)
    person.name     # "Johnny"
    person.age      # 34
    person.greet()  # "Hello, I am Johnny and I'm 34 years old."

    person.age = "Blah Blah Blah"

    # silly
    person.greet()  # "Hello, I am Johnny and I'm Blah Blah Blah years old."

    # broken
    person.can_vote()  # TypeError!!!!!!!

The problem is **NOT** with the ``can_vote()`` method which was called on line ``12``. The problem is that
the data for ``age`` was allowed to be changed to something *invalid* in the first place (line ``6``).

For Java, this specific problem would not happen, but, other bad data can sneak in. For example, if we try to
set the age to a negative number, this would not be valid data for someone's age. So even in Java, we would need
to **encapsulate** the attribute to ensure it never is allowed to be given something invalid.

So how do we fix this? There are three steps:

- Make the attribute private
- Make a getter and setter method
- In the setter method, validate the data

Making attributes private
^^^^^^^^^^^^^^^^^^^^^^^^^
The first thing to do is "hide" the atribute or make it **private**. In Python, we simply underscore the object
attribute's name:

.. code-block:: python
    :emphasize-lines: 4

    class Person:
        def __init__(self, name, age):
            self.name = name
            self._age = age

        # The internal methods that use the age attribute
        # need to be modified because we put an underscore.
        def greet(self):
            return f"Hello, I am {self.name} and I'm {self._age} years old."
        
        def can_vote(self):
            return self._age >= 18

In Python **nothing** is truly ever private, be we can do things to indicate to other programmers
that they should not touch the attribute without potential negative consequences. In Java, declaring a
fieid "private" absoultely hides it from other programmers.

Make getters and setters
^^^^^^^^^^^^^^^^^^^^^^^^
Once we have "hidden" the attribute, we need to provide other programmers access to it with an intermediary
methods called **setters** and **getters**. Setters "set" the attribute value for the programmer,
and Getters fetch and return the attribute value to the programmer.

- All *setters* start with ``set_``
- All *getters* start with ``get_``

.. code-block:: python
    :emphasize-lines: 6, 9

    class Person:
        def __init__(self, name, age):
            self.name = name
            self._age = age
        
        def get_age(self):
            return self._age
        
        def set_age(self, new_value):
            self._age = new_value

Now we can access the attribute by using the following methods:

.. code-block:: python
    :emphasize-lines: 3,6

    person = Person("Johnny", 34)
    person.name       # "Johnny"
    person.get_age()  # 34
    person.greet()    # "Hello, I am Johnny and I'm 34 years old."

    person.set_age("Blah Blah Blah")

    # silly
    person.greet()  # "Hello, I am Johnny and I'm Blah Blah Blah years old."

    # broken
    person.can_vote()  # TypeError STILL!!!!!!!

As you can see, we provided encapsulation but we still haven't ensured that only *valid* data
is saved to the attribute.

Provide data validation
^^^^^^^^^^^^^^^^^^^^^^^
So the problem is now that the ``.set_age()`` method allowed the age to be set to an
*invalid* value. We can fix that:

.. code-block:: python
    :emphasize-lines: 7

    class Person:
        def __init__(self, name, age):
            self.name = name
            self._age = age
        
        def set_age(self, new_value):
            assert new_value >= 0, "The age value cannot be negative."
            self._age = new_value

If we call the method with bad data like ``person.set_age(-20)``, the assertion fails,
the program will break and will display::

    AssertionError: The age value cannot be negative.

Additionally if we call the method with a non integer (``person.set_age("twelve")``), the program will raise a ``TypeError`` for us::

    TypeError: '>=' not supported between instances of 'str' and 'int'

So you might be thinking, "So what? There's still is an error. Now it just happens sooner".
The key distinction is now the error happens at the point of the *actual* error,
which was setting the value to some invalid data.

In reality, the ``.can_vote()`` method can possibly be called three months after the bad data was set.
This is a code time-bomb ready to go off. If we ever write software for a bank or some other
critical business, we can't afford to write code that will explode at some unforseen time or
have undesired side-effects or consequences in the future.

Aggregate class
---------------
An *Aggregate class* is one which contains, or is made up of another class.
The aggregate class contains components that are other classes.


.. code-block:: python

    class Engine:
        def __init__(self, size: float):
            self.size = size

    class Car:
        """Aggregate class. It contains an Engine object."""
        def __init__(self, color: str, engine_size: float):
            self.color = color
            self.engine = Engine(engine_size)
    
    car = Car("blue", 4.0)
    print(car.color)  # "blue"
    print(car.engine.size)  # 4.0


Class field (variable)
----------------------
.. code-block:: python
    :emphasize-lines: 5,11,20

    from typing import List


    class Pizza:
        num_pizzas = 0  # class field (variable)

        def __init__(self, name: str, toppings: List[str]):
            self.name = name
            self.toppings = toppings
            self.id = Pizza.num_pizzas
            Pizza.num_pizzas += 1  # update the class field
        
        def __str__(self) -> str:
            return f"{self.name}, toppings: {self.toppings}, id: {self.id}"
        

    def main():
        pepperoni = Pizza("Pepperoni", ["cheese", "pepperoni"])
        print(pepperoni)
        print(Pizza.num_pizzas)  # 1


    if __name__ == "__main__":
        main()


Class method
------------
.. code-block:: python
    :linenos:
    :emphasize-lines: 16,20,26,29

    from typing import List


    class Pizza:
        num_pizzas = 0

        def __init__(self, name: str, toppings: List[str]):
            self.name = name
            self.toppings = toppings
            self.id = Pizza.num_pizzas
            Pizza.num_pizzas += 1
        
        def __str__(self) -> str:
            return f"{self.name}, toppings: {self.toppings}, id: {self.id}"
        
        @classmethod
        def pepperoni(cls):
            return cls("Pepperoni", ["cheese", "pepperoni"])
        
        @classmethod
        def cheese(cls):
            return cls("Cheese", ["cheese"])
        

    def main():
        pepperoni = Pizza.pepperoni()
        print(pepperoni)

        cheese = Pizza.cheese()
        print(cheese)

        cheese_another = Pizza.cheese()
        print(cheese_another)

        print(Pizza.num_pizzas)


    if __name__ == "__main__":
        main()


Inheritance
-----------
.. code-block:: python
    :linenos:
    :emphasize-lines: 12,24,29

    class Animal:
        def __init__(self, name: str):
            self.name = name
        
        def __str__(self):
            return self.name
        
        def make_sound(self):
            print("<generic animal sound>")


    class Dog(Animal):  # Dog inherits from Animal
        def __init__(self, name: str, breed: str):
            super().__init__(name)
            self.breed = breed
        
        def __str__(self):
            return f"{self.name} the {self.breed}"
        
        def make_sound(self):
            print("Woof!")


    class Squirrel(Animal):  # Squirrel inherits from Animal
        def make_sound(self):
            print("Squeek")


    class Cat(Animal):  # Cat inherits from Animal
        pass


    def main():
        d = Dog("Rover", "Dalmatian")
        s = Squirrel("Sammy")
        c = Cat("Bella")

        print(d)  # Rover the Dalmatian
        print(s)  # Sammy
        print(c)  # Bella

        d.make_sound()  # Woof!
        s.make_sound()  # Squeek
        c.make_sound()  # <generic animal sound>


    if __name__ == "__main__":
        main()


Polymorphism
------------
*Polymorphism* is when a sub-class overrides an inherited method.

.. code-block:: python
    :linenos:
    :emphasize-lines: 17,20,25

    class Animal:
        def __init__(self, name: str):
            self.name = name
        
        def __str__(self):
            return self.name
        
        def make_sound(self):
            print("<generic animal sound>")


    class Dog(Animal):
        def __init__(self, name: str, breed: str):
            super().__init__(name)
            self.breed = breed
        
        def __str__(self):  # overrides inherited method
            return f"{self.name} the {self.breed}"
        
        def make_sound(self):  # overrides inherited method
            print("Woof!")


    class Squirrel(Animal):
        def make_sound(self):  # overrides inherited method
            print("Squeek")


    class Cat(Animal):
        pass


    def main():
        d = Dog("Rover", "Dalmatian")
        s = Squirrel("Sammy")
        c = Cat("Bella")

        print(d)  # Rover the Dalmatian
        print(s)  # Sammy
        print(c)  # Bella

        d.make_sound()  # Woof!
        s.make_sound()  # Squeek
        c.make_sound()  # <generic animal sound>


    if __name__ == "__main__":
        main()



Refactor multiple classes
-------------------------


Function/Method Overloading
---------------------------
.. code-block:: python

    # Method overloading (Python version)

    # optional/default parameters
    # Key-word arguments

    # Allows us to create one function or method with different
    # ways to call it.


    def draw_rectangle(x, y, w=100, h=100, color=arcade.color.BLUE):
        pass


    draw_rectangle(10, 50)  # draws rectangle at 50, 50 of width 100, height 100
    draw_rectangle(100, 200, 5)
    draw_rectangle(500, 200, 50, 50)
    draw_rectangle(10, 10, 5, 5, arcade.color.WHITE)
    draw_rectangle(75, 10, color=arcade.color.WHITE)


Docstrings (Classes)
--------------------
For more information on Docstrings in general, check out :ref:`function docstrings <functions:docstrings>`.

For classes, this is the way we will be wrirting our docstrings::

    from typing import List, Optional

    
    class ExampleClass:
        """The summary line for a class docstring should fit on one line.

        If the class has public attributes, they may be documented here
        in an ``Attributes`` section and follow the same formatting as a
        function's ``Args`` section. Alternatively, attributes may be documented
        inline with the attribute's declaration (see __init__ method below).

        Attributes:
            attr1 (str): Description of `attr1`.
            attr2 (:obj:`int`, optional): Description of `attr2`.
        """

    def __init__(self, param1: str, param2: Optional[int] = None, param3: List[str]):
        """Example of docstring on the __init__ method.
        The __init__ method may be documented in either the class level
        docstring, or as a docstring on the __init__ method itself.
        Either form is acceptable, but the two should not be mixed. Choose one
        convention to document the __init__ method and be consistent with it.
        Note:
              Do not include the `self` parameter in the ``Args`` section.
        Args:
              param1: Description of `param1`.
              param2 (optional): Description of `param2`. Multiple
                 lines are supported.
              param3: Description of `param3`.
        """
        self.attr1 = param1
        self.attr2 = param2
        self.attr3 = param3

    def example_method(self, param1: int, param2: str) -> bool:
        """Class methods are similar to regular functions.
        Note:
              Do not include the `self` parameter in the ``Args`` section.
        Args:
              param1: The first parameter.
              param2: The second parameter.
        Returns:
              True if successful, False otherwise.
        """
        return True