x = "I love cats." # <= x is a string... 

print(x.upper())             # converts string to upper case
print(x.replace("c", "b"))   # cats? I'm a bat kinda guy myself!
print(x.__add__(x))          # x.__add__(x) is EXACTLY the same as x + x. 
print(x.__mul__(3))          # Equivalent to x * 3

"""
Explanation: An Intuition for OOP
'OOP' stands for Object Orientated Programming. Today my aim to provide a quick overview of the topic which will help you develop an 'intuition' for what objects are and how methods work.
What is an Object?
In Python almost everything is an object! In real terms, what this means is that every 'item' in Python has a set of properties and a special set functions that only work on items of that type. 
What are methods?
Above I said that "every 'item' in Python has a set of properties and a special set of commands or functions that only work on objects of that same type." Well, the technical name for that special set of commands/properties is "methods". 
Let me give you a simple example:
End of explanation
"""


z = 711
print(z.replace(7, 1))

# With this said, integers do have some of the "same" methods as well.
# Please note however these methods are the "same" in name only, behind the scenes they work very differently from each other.

i = 5 
print(i.__add__(i)) # i + i => 5 + 5
print(i.__mul__(i)) # i * i => 5 * 5
 

"""
Explanation: In the above example we can see that we can replace the letter "c" with a "b" using the replace 'method'. What happens if I have the number 711 and want to change the 7 to a 9 to make 911?
End of explanation
"""


# Note the following code doesn't work, it is for demonstration purposes only!

class Ball():
    
    def get_speed():
        """The get_speed method returns the current speed of the ball."""
        # magic goes here...
        return speed
    
    def get_direction():
        """The get_direction method returns the direction the ball is currently traveling in (in 3 dimensions)."""
        # magic goes here...
        return direction
    
    def get_position():
        """The get_position method returns the current position of the ball."""
        # magic goes here...
        return position
    
    def bounce_off_of_object(other_object):
        """This method calculates a new speed and position of the ball after colliding with another object."""
        # magic goes here...
        return # something
    
wilson = Ball() # creates a ball called Wilson.

"""
Explanation: When we try to use replace with integers we get an error; "'int' object has no attribute 'replace'". Basically this is Python's way of telling us that 'replace' is not a method belonging to the integer class. 
In the next lecture I will explain how methods work in a bit more detail. However, the purpose of this lecture is to introduce to you the concepts of objects. So lets get started!
Let's think about balls...
X is a football. There is nothing special about X, its just a normal football. The question I want you to think about all various ways you might interact with a football. Go ahead, make a mental a list, I can wait...
<img src="https://www.maxim.com/.image/t_share/MTM1MTQ2MDg3MDYxNzU2Mzgy/placeholder-title.jpg" style="width:300px;height:213px;" ALIGN="right">
There are a number of 'operations' we could perform on or with a ball. For example, we could name it 'Wilson'. We could also kick the ball, bounce the ball and so on. 
Similarly, there are lot of operations that might make sense with other objects but not a ball. For example, it makes sense to 'plug in' a kettle or toaster, but it’s not clear what 'plugging in' a ball actually means.Likewise, we can make sense of "subtract 7 from 9", but it is not clear what is meant when somebody says "subtract 7 from a ball".
If you want to know why an object in Python has method ‘Y’ and another object (of a different type) does not have a method ‘Y’ the ball example above hopefully helps you make sense of it. In the case of strings, we can add them together because we have a clear idea how that should work. But we can’t subtract strings because it isn’t clear what should happen in a number of cases. For example, “AB” subtract “B” we can make some sense of, the result should probably just be “A”. But what about following cases:
 “AB” subtract “cat”?
 “AB” subtract “BA” ? 
 “A”  subtract “a”  ?

In each case it is NOT intuitively clear what should happen. The Python developers could have implemented subtraction for strings and handled all these cases in one way or another. But why would they do that? Surely the time and energy required would be better spent on other projects, such as implementing well-defined methods for other objects.  
Let's Design a Ball & Footballer UI
Okay so let's imagine we are implementing a ball object in a computer game. If it is a football game we probably do not want to name footballs but we probably do want player characters to be able to interact with the ball by kicking it. We also want the ball bounce off of the ground and other objects too.
After some thought, we might come up with a list of interactions we want to build into methods, we might also start thinking about what arguments these functions (methods) might require.
End of explanation
"""


# Note the following code doesn't work, it is for demonstration purposes only!
class Football_Player():
    
    def name(name):
        """This method gives the footballer a name."""
        # magic goes here...
        return name
    
    def get_position():
        """The get_position method returns the current position of the player."""
        # magic goes here...
        return position
    
    def get_speed():
        """The get_speed method returns the current speed of the player."""
        # magic goes here...
        return speed
    
    def move(direction, speed):
        """The move method makes the player run in X direction at Y speed."""
        # magic goes here...
        return # new_speed of self
    
    def kick_ball(ball, power, direction):
        """This method kicks ball Z with X power in Y direction."""
        # magic goes here...
        return # new_speed of ball, direction of ball.

"""
Explanation: The above code doesn't work, but it should give you a feel for what a "ball UI" could look like in Python. Now lets do something similar for a football player.
End of explanation
"""


# Note the following code doesn't work, it is for demonstration purposes only!

Messi = Football_Player().name("Messi") # This line creates a footballer called 'Messi'
football = Ball()                       # create a ball object.

if ball.get_position == Messi.get_position:  # This line asks if Messi and the football are at the same position in space.
    Messi.kick_ball(football, "100mph", direction="NorthWest") # Messi kicks the balls 100mph to the northwest. 
    
else:                                         # If Messi is NOT near the ball then...
    target_position = football.get_position() # Messi wants to know where the football is. 
    Messi.move(target_position, "12mph")      # Messi is now running at 12mph towards the ball. 

"""
Explanation: Let's suppose you are a developer on this project and a colleague of yours has implemented these methods already. The really cool thing is this UI adds a layer of abstraction that allows you to meaningfully interact with the ball/player without needing to actually understand exactly how this stuff works.
The reason why this is so damn cool is basically because we can now write some game logic, despite not knowing anything about the system the game is using to run physics, etc.
End of explanation
"""


class Time(object):
    
    # Do you remember in the variable names lecture I said that 
    # you shouldn't name a variable '__something' unless you know what you are doing?
    # Well, thats because double underscore is usually reserved for "hidden" class properties/functions.
    # In this particular case, __init__, __repr__, __add__, all have very special meanings in Python. 
    
    def __init__(self, hours, mins=0):
        assert isinstance(hours, int) and 0<= hours <=24  # Hours should be between 0 and 24 for a 24-hour clock. 
        assert isinstance(mins, int) and  0<= mins  <=60

        self.hours = hours
        self.mins = mins

    def __repr__(self): 
        return format_time(self.hours, self.mins)
        
    def __add__(self, other):
        """
        This function takes itself and another Time object and adds them together.
        """
        a = self.mins
        b = other.mins

        x = self.hours
        y = other.hours
        
        c = str( (a + b) % 60 ).zfill(2)   # Integer division and Modulo Arithmetic. You have seen this before! 
        z = str((((a+b) // 60) + x + y) % 25).zfill(2)
        
        return format_time(z, c)

def format_time(h, m):
    """
    This function takes hours (int) and mins(int) and returns them in a pretty format. 2, 59 => 02:59
    """
    return "{}:{}".format(str(h).zfill(2), str(m).zfill(2))      

"""
Explanation: So what does the above code do? Well, even though we haven't covered indentation and if/else statements (yet) my well-chosen variables names and comments were probably sufficient for you to figure out what is going on. 
Basically, this code creates two objects (a ball and a football player called "Messi"), we then check if Messi is close to the ball. If he is, he kicks it up the pitch. If he isn't near the ball Messi starts running towards it. 
In short, objects and object methods in Python allow us to write code at a 'high-level', we can leave all the 'low-level' stuff for another developer (or Python itself) to handle. And that leaves us all the time in the world to do the fun stuff!
Building A Time Object..
I am going to finish today's lecture with an actual example of Object Orientated Programming in practice; I'm going to quickly build a 'Time class'. It is going to be a class that allows us to add times together and print them. For example: 6:00 + 30 minutes should equal 6:30 and 10:59 + 1:01 should equal 12:00. 
Please be aware that I DO NOT expect you to understand the code below nor am I going to explain in detail how it works either. I just wanted to end this lecture on a real example, simple as that.
End of explanation
"""


a = Time(4,50)
b = Time(0, 1)
c = Time(24, 59)

print(a, b, c, sep="\n")

"""
Explanation: Okay, so what does this code to? Well, it creates an object which I call ‘Time’.  A Time object takes two integers as input (hours and minutes). Let’s create a Time object and see what happens when I print it.
End of explanation
"""


print( Time(10,40) + Time(0,20) )  # 10:40 + 00:20 => 11:00
print( Time(0,0)  +  Time(7,30) )  # 00:00 + 07:30 => 7:30

print( Time(20,20) + 10 ) # Error, can't add integer to a Time object

"""
Explanation: When we print time objects they are represented as a string that looks just like an alarm clock; Time(12, 30) returns "12:30" 
Now, the code above also defines a method "add". This method adds Time Y to Time X which is effectively asking:
“If the time now is X what time is in Y hours and Z minutes from now?”

Let's try adding some times together now:
End of explanation
"""


# Notice that, just like my "Time" object, integers, strings, floats, etc are all implemented in Python as classes.
print(type(Time(10,0)),type("hello"),type(10), type(5.67), sep="\n")

"""
Explanation: As a minor implementation detail my code adds Time to other Time objects, if we try to add an integer (e.g 4) to the time 10:30 we get an error. The reason for this is if we get handed an integer it is not entirely clear what should to happen; should we assume the integer is hours? But couldn’t that integer just as easily be minutes or total elapsed time (e.g 65 means 1:05) ? Since it's not clear, we don't guess. Instead we just yield an error.

“In the face of ambiguity, refuse the temptation to guess.” ~ Zen of Python

I could expand my Time object by adding more methods; maybe I could define subtraction, or a method for converting time-zones. But I wouldn’t add a ‘kick’ or a ‘bounce’ method because we can’t make sense of kicking or bouncing “12:30”. 
Conclusion
It is important to remember that more or less everything in Python is an object. Strings are an object, Integers are an object and so on. Objects have methods (which are usually, although not always, functions) that can be called on them. Moreover, as we have seen above we can use the "class" keyword in Python to build our own objects with a corresponding set of methods. 
Hopefully I have also demonstrated to you the power of OOP; by using objects we can think about code at a 'higher-level' of abstraction. And that's powerful stuff. 
In the rest of this lecture series I do not mention classes and that's because in my opinion classes are a better topic for an intermediate guide; Classes can be tricky to get working but there are an undeniably powerful tool you should endeavour to learn more about.
End of explanation
"""


