What is a Class

So in python you may have noticed that somtimes you can call a function on a data type for example

"hello".replace('h','m') --> "mello"

The string seems to have this built in function we can use to replace letter as well as many other functions to turn it into uppercase, lowercase etc. etc. You can imagine if we wanted to write this function ourselfs we would do

def replace(string, toReplace, whatToReplaceWith):
    # the replacement code
    return newString

You may notice this is essentially the same as above except with the above notation we don’t have to tell replace what string it is operating on, it already knows. And that’s because a string is a OBJECT.

A object is a collection of data and functions that are aware of one another and the context they exist in. When we do “hello”.upper() we are calling the “upper” function of the string object, and this string object knows that it’s representing the string “hello”

But you may notice tht although “hello” and “goodbye” are both string objects they arn’t the same, they represent different strings but any function you can call on hello you can also call on goodbye. This is because a object has a concept of state that is, you can have many objects of the same type (or better said within python class) i.e lots of string objects but each one is individual and can be updated, manipulated and changed independently.

What they have in common is they are of the same class that is the same basic blueprint of what the object stores and the functions it has available. The state the object is in, i.e the exact data it has in it’s store at any one time, is determined by the object. Classes are the blueprints for objects.

Example

Lets say we have a class called fruits which describes how a fruit behaves, fruits have a name, a number of calories and can be cut.

apple = fruit("apple", 120) # make a new fruit (ignore how this works for now)
orange = fruit("orange",300)
apple.calories      # returns 120
orange.calroes     # returns 300

apple.cut()
orange.cut()

Both apples and oranges, because they were created as instances of the fruit class, have a calories field,a name field and a cut function but what is in those fields is different.
In the same way all humans have a heart and a stomach etc but individually the size of those organs and their hair colour changes person to person. They have a defined set of things but the exact definition of those things varies. But because all humans are more or less the same internally then a drug can be designed that works for all humans. If every human was different with different organs and systems then you’d have to make a new drug for every individual.
Classes let us create objects which conform to some standard so we can interact with data in a defined manner. If something is a string object we know we can add it to another string object and we know we can uppercase it etc.

Why

Objects and classes don’t add anything special, they are just a way to reason and organise your code, so the question is why use them at all when you can code up anything with just if statements and loops. Partly it’s for convenience, it makes sense to do "hello".upper() and not have to specify what string you are attempting to manipulate. But often it makes a lot of sense to group code and functions and helps us write cleaner. more bug free and modular code, that is code that is split upneatly into fragements that can be shared, reused, updated and swapped out as needed. A car which has to be entirely replaced whenever one part breaks is just as awful as code which has to be entirely rewritten when one thing needs to change.

playerHealth = 100
playerMana = 100
playerAttacks = ["punch", "kick", "cry"]

player2Health = 100
player2Mana = 100
player2Attacks = ["punch", "kick", "cry"]

if player1 says use spell:
    player2Health -= 10
    player1Mana -=10

Can be better expressed as

player1 = Player()
player2 = Player()

if player1.selectedMove === "spell":
    player1.cast_spell(player2) # automatically dits player1 mana and player2 health

Creating a class

So the lifecycle of a class is creation, use and death. We won’t talk about death but objects are automatically killed by python whenever it detects they arn’t being used anymore or when your program ends.

apple = Fruit("apple") # creation
print(apple.name) # use

To define a class you do

class Fruit:
    <data>
    <functions>

Data is where you can declare what stuff is universal to this class, that is to say what every single object will have predefined. Note that unlike state this doesn’t change object to object. A example would be

class Human:
    organs = "yes"
    blood = "yes"
    <functions>

Your functions are how you would interact with this object, for example

class Human:
    organs = "yes"
    blood = "yes"
    def say_hi():
        print("Hello! I am a hooman")

Now this is nice but all we’ve done is grouped some variables and functions, lets add state, the first thing we need is a constructor, this is called when the object is created and basically sets it up for use

class Human:
    organs = "yes"
    blood = "yes"
    # __init__ is how you define a class's constuctor
    def __init__(self):
        print("I am now born!")
    def say_hi():
        print("Hello! I am a hooman")

If you then created the object

zain = Human() # prints "I am now born!"
Human.say_hi() # prints "Hello! I am a hooman"

Now you may notice the constructor has to take in a paramter called self, you may also notice that we called say_hi on the class itself not a object. If you tried to call it on a object like zain you’d get

zain.say_hi()
>> TypeError: say_hi() takes 0 positional arguments but 1 was given

This is confusing but basically everytime you call a function on a object, the function gets given a reference to that objects state, that is everything the funcion needs to know about particular instance of the class.

class Human:
    organs = "yes"
    blood = "yes"
    # when creating a human you now have to give it a name
    def __init__(self, name):
        # this name is stored on the objects STATE not globally accross all humans
        self.name = name
        print("I am now born!")
    # say_hi will take in self which is our state and print out the objects name
    def say_hi(self):
        print("Hello! I am a "+self.name)
zain = Human("zain") # prints "I am now born!"
zain.say_hi() # prints "Hello! I am a zain"

self

Now a objects state SHOULD Be quite well defined, everything a object has which is variable should be set up in the constructor. But python likes to give us options even if we can shoot ourselves in the foot with it. So you can add things to your self at any point for any reason and update it at will.

zain = Human("zain") # prints "I am now born!"
zain.say_hi() # prints "Hello! I am a zain"
zain.name = "brain"
zain.say_hi() # prints "Hello! I am a brian"
class Human:
    organs = "yes"
    blood = "yes"
    # when creating a human you now have to give it a name
    def __init__(self, name):
        # this name is stored on the objects STATE not globally accross all humans
        self.name = name
        print("I am now born!")
    # say_hi will take in self which is out state and print out the objects name
    def say_hi(self):
        if self.hello_said:
            print("I've already said hi, bugger off")
        else:
            print("Hello! I am a "+self.name)
        self.hello_said = True
zain = Human("zain") # prints "I am now born!"
zain.say_hi() # prints "Hello! I am a zain"
zain.say_hi() # prints "I've already said hi, bugger off"
zain.hello_said = False
zain.say_hi() # prints "Hello! I am a zain"
del zain.name # delete the name field

The issue with this is that you can create things that didn’t exist at the start which means some functions will work down the life of a object but not at the start or vice versa, this leads to unreliable and complex bugs. So it’s good practice to set up everything you want to use and stick to that, if you want to add a new state variable declare it at the top and give it a initial value and have your object handle it not being ready to use.

magic

So there are magic functions you can define on your object, most of which look like init i.e __f__ the double underscores symbolises functions that are special and will interact with the python runtime.

the most simple of these is __str__, if python ever tries to print out your object or somebody does str(zain) for example. This function will be called so python can interpert your object as a string, for us we would do

class Human:
    organs = "yes"
    blood = "yes"
    def __init__(self, name):
        self.name = name
    def say_hi(self):
        print("Hello! I am a "+self.name)
    def __str__(self):
        print('This is a human with name '+self.name)

zain = Human("zain")
print(zain) # prints out 'This is a human with name zain'

There are heaps of more magic functions but we won’t go through them all, just know they exist.

Inheritance

Now it’s really common you want some sort of way to order classes in a top down structure of sorts. i.e granny smith is a type of apple which is a type of Fruit. All fruits have some common properties and all Apples have some common properties etc. Fruit is a super set of the features of a apple etc. An apple has all the features of a fruit and then some.

Fruits
    Apple
        Granny Smith
        Pink Lady
        ...

This is called inheritance and you can do this in python, what you do is you define your most generic class first

class Fruit:
    # all fruits have some calories
    def __init__(self, calories):
        self.calories = calories
    # all fruits can be cut
    def cut(self):
        print("i have been cut")

Now we can define a more specific class

# in the brackets we can say a Apple is subclass of Fruit
class Apple(Fruit):
    def __init__(self):
        # we have to build ourselfs as a fruit first, all apples lets say have 100 calories
        super(self).__init__(100)

    # all apples have a crunch function
    def crunch(self):
        print("[[ C R O N C H ]]")

apple = Apple()
apple.cut()            # works!
apple.crunch()         # works!
print(apple.calories)  # prints out 100

You can also have a class inherit from multiple other classes but that’s for another day.