class Car():
    """ A simple representation of a car. """
    pass

"""
Explanation: Lecture 11: Objects and Classes
CSCI 1360: Foundations for Informatics and Analytics
Overview and Objectives
In this lecture, we'll delve into the realm of "object-oriented programming," or OOP. This is a programming paradigm in which concepts and actions are "packaged" using the abstraction of objects: modeling the system after real-world phenomena, both to aid our own understanding of the program and to enforce a good design paradigm. By the end of this lecture, you should be able to:

Understand the core concepts of encapsulation and abstraction that make object-oriented programming so powerful
Implement your own class hierarchy, using inheritance to avoid redundant code
Explain how Python's OOP mechanism differs from that in other languages, such as Java

Part 1: Object-oriented Programming
Poll: how many people have programmed in Java or C++?
How many have heard of object-oriented programming?
Up until now (and after this lecture), we've stuck mainly with procedural programming: focusing on the actions.
Object-oriented programming, by contrast, focuses on the objects.

Sort of a "verbs" (procedural programming) versus "nouns" (object-oriented programming) thing.
Objects versus Classes
Main idea: you design objects, usually modeled after real-world constructs, that interact with each other.
These designs are called classes. Think of them as a blueprint that detail out the various properties and capabilities of your object.

From the designs (classes), you can create an object by instantiating the class, or creating an instance of the class.
If the class is the blueprint, the object is the physical manifestation from the blueprint.
A car can have several properties--steering column, fuel injector, brake pads, airbags--but an instantiation of car would be a 2015 Honda Accord. Another instantiation would be a 2016 Tesla Model S. These are instances of a car.
In some [abstract] sense, these both derive from a common blueprint of a car, but their specific details differ. This is precisely how to think of the difference between classes and objects.
Part 2: Objects in Python
Every object in Python has certain things in common.


Methods: Remember when we covered the difference between functions and methods? This is where that difference comes into play. Methods are the way the object interacts with the outside world. They're functions, but they're attached directly to object instances.


Constructors: These are specialized methods that deal specifically with how an object instance is created. Every single object has a constructor, whether you explicitly write one or not.


Attributes: These are the physical properties of the object; maybe they change, maybe they don't. For a car, this could be color, make, model, or name. These are the things that distinguish one instance of the class from another.


Inheritance: This is where the power of object-oriented programming really comes into play. Quite often, our understanding of physical objects in the world is hierarchical: there are cars; then there are race cars, sedans, and SUVs; then there are gas-powered sedans, hybrid sedans, and electric sedans; then there are 2015 Honda Accords and 2016 Honda Accords. Wouldn't it be great if our class design reflected this hierarchy?


Defining Classes
Let's start with the first step of designing a class: its actual definition. We'll stick with the car example.
End of explanation
"""


my_car = Car()
print(my_car)

"""
Explanation: To define a new class, you need a class keyword, followed by the name (in this case, Car). The parentheses are important, but for now we'll leave them empty. Like loops and functions and conditionals, everything that belongs to the class--variables, methods, etc--are indented underneath.
We can then instantiate this class using the following:
End of explanation
"""


class Car():
    
    def __init__(self):
        print("This is the constructor!")

my_car = Car()

"""
Explanation: Now my_car holds an instance of the Car class! It doesn't do much, but it's a valid object.
Constructors
The first step in making an interesting class is by creating a constructor. It's a special kind of function that provides a customized recipe for how an instance of that class is built.
It takes a special form, too:
End of explanation
"""


    def __init__(self):
        pass

"""
Explanation: Let's look at this method in more detail.
End of explanation
"""


class Car():
    
    def __init__(self, year, make, model):
        
        # All three of these are class attributes.
        self.year = year
        self.make = make
        self.model = model

my_car = Car(2015, "Honda", "Accord")  # Again, note that we don't specify something for "self" here.
print(my_car.year)

"""
Explanation: The def is normal: the Python keyword we use to identify a function definition.


__init__ is the name of our method. It's an interesting name for sure, and turns out this is a very specific name Python is looking for: whenever you instantiate an object, this is the method that's run. If you don't explicitly write a constructor, Python implicitly supplies a "default" one (where basically nothing really happens).


The method argument is strange; what is this mysterious self, and why--if an argument is required--didn't we supply one when we executed my_car = Car()?


A note on self
This is how the object refers to itself from inside the object. We'll see this in greater detail once we get to attributes.
Every method in a class must have self as the first argument. Even though you don't actually supply this argument yourself when you call the method, it still has to be in the function definition.
Otherwise, you'll get some weird error messages:

Attributes
Attributes are variables contained inside a class, and which take certain values when the class is instantiated.
The most common practice is to define these attributes within the constructor of the class.
End of explanation
"""


class Car():
    
    def __init__(self, year, make, model):
        self.year = year
        self.make = make
        self.model = model
        self.mileage = 0
        
    def drive(self, mileage = 0):
        if mileage == 0:
            print("Driving!")
        else:
            self.mileage += mileage
            print("Driven {} miles total.".format(self.mileage))

my_car = Car(2016, "Tesla", "Model S")
my_car.drive(100)
my_car.drive()
my_car.drive(50)

"""
Explanation: These attributes are accessible from anywhere inside the class, but direct access to them from outside (as did in the print(my_car.year) statement) is heavily frowned upon.
Instead, good object-oriented design stipulates that these attributes be treated as private variables to the class.
To be modified or otherwise used, the classes should have public methods that expose very specific avenues for interaction with the class attributes.
This is the concept of encapsulation: restricting direct access to attributes, and instead encouraging the use of class methods to interact with the attributes in very specific ways.

Methods
Methods are functions attached to the class, but which are accessible from outside the class, and define the ways in which the instances of the class can interact with the outside world.
Whereas classes are usually nouns, the methods are typically the verbs. For example, what would a Car class do?
End of explanation
"""


class GasCar():
    def __init__(self, make, model, year, tank_size):
        # Set up attributes.
        pass
    
    def drive(self, mileage = 0):
        # Driving functionality.
        pass

class ElectricCar():
    def __init__(self, make, model, year, battery_cycles):
        # Set up attributes.
        pass
    
    def drive(self, mileage = 0):
        # Driving functionality, probably identical to GasCar.
        pass

"""
Explanation: Classes can have as many methods as you want, named whatever you'd like (though usually named so they reflect their purpose).
Methods are what are ultimately allowed to edit the class attributes (the self. variables), as per the concept of encapsulation. For example, the self.mileage attribute in the previous example that stores the total mileage driven by that instance.
Like the constructor, all the class methods must have self as the first argument in their headers, even though you don't explicitly supply it when you call the methods.
Inheritance
Inheritance is easily the most complicated aspect of object-oriented programming, but is most certainly where OOP derives its power for modular design.
When considering cars, certainly most are very similar and can be modeled effectively with one class, but eventually there are enough differences to necessitate the creation of a separate class. For example, a class for gas-powered cars and one for EVs.
But considering how much overlap they still share, it'd be highly redundant to make wholly separate classes for both.
End of explanation
"""


class Car():  # Parent class.
    def __init__(self, make, model, year):
        self.make = make
        self.model = model
        self.year = year
        self.mileage = 0
    
    def drive(self, mileage = 0):
        self.mileage += mileage
        print("Driven {} miles.".format(self.mileage))

class EV(Car):  # Child class--explicitly mentions "Car" as the parent!
    def __init__(self, make, model, year, charge_range):
        Car.__init__(self, make, model, year)
        self.charge_range = charge_range
        
    def charge_remaining(self):
        if self.mileage < self.charge_range:
            print("Still {} miles left.".format(self.charge_range - self.mileage))
        else:
            print("Battery depleted! Find a SuperCharger station.")

tesla = EV(2016, "Tesla", "Model S", 250)
tesla.drive(100)
tesla.charge_remaining()
tesla.drive(150)
tesla.charge_remaining()

"""
Explanation: Enter inheritance: the ability to create subclasses of existing classes that retain all the functionality of the parent, while requiring the implementation only of the things that differentiate the child from the parent.
End of explanation
"""


class Hybrid(Car):
    
    def drive(self, mileage, mpg):
        self.mileage += mileage
        print("Driven {} miles at {:.1f} MPG.".format(self.mileage, mpg))

hybrid = Hybrid(2015, "Toyota", "Prius")
hybrid.drive(100, 35.5)

"""
Explanation: Hopefully you noticed--we could call tesla.drive() and it worked as it was defined in the parent Car class, without us having to write it again!
This is the power of inheritance: every child class inherits all the functionality of the parent class.
With ONE exception: if you override a parent attribute or method in the child class, then that takes precedence.
End of explanation
"""


class DerivedClassName(EV, Hybrid):
    pass

"""
Explanation: Using inheritance, you can build an entire hierarchy of classes and subclasses, inheriting functionality where needed and overriding it where necessary.

This illustrates the concept of polymorphism (meaning "many forms"): all cars are vehicles; therefore, any functions a vehicle has, a car will also have.
All transporters are vehicles--and also cars--and have all the associated functionality defined in those classes.
However, it does NOT work in reverse: not all vehicles are motorcycles! Thus, as you move down the hierarchy, the objects become more specialized.
Multiple Inheritance
Just a quick note on this, for all the Java converts--
Python does support multiple inheritance, meaning a child class can directly inherit from multiple parent classes.
End of explanation
"""


