GAMR1520: Markup languages and scripting

GAMR1520

Markup languages and scripting

Object oriented python

Dr Graeme Stuart


Object-oriented python

In python we can use the class keyword to create a type of our own.

class MyClass:
    pass

my_instance_1 = MyClass()
my_instance_2 = MyClass()

We know that all values in python are objects. The values 1 and 'hello' are instances of int and str. Our instances are of type MyClass in exactly the same way.

flowchart TD; subgraph types ["Types"] MyClass str int end subgraph instances [Instances] hello["'hello'"] world["'world'"] my_instance_1 my_instance_2 1 2 end 1 --> int 2 --> int hello --> str world --> str my_instance_1 --> MyClass my_instance_2 --> MyClass

A class

We can refer directly to the MyClass class, for example, we can print it.

class MyClass:
    pass

print(MyClass)
print(MyClass())
<class '__main__.MyClass'>
<__main__.MyClass object at 0x7f656f5d15d0>

Classes are callable and when we call them, they return an instance.

In python the file you execute is known as the __main__ module. Double underscores indicate that __main__ is part of the python infrastructure.


Attributes

In python classes create a namespace which can be accessed using dot notation. The below code is setting the apples and data attributes on the MyClass class.

class MyClass:
    pass

MyClass.apples = 'hello'
print(MyClass.apples)

MyClass.data = [1, 2, 3]
print(MyClass.data)
hello
[1, 2, 3]

This is very similar to creating variables, though the attributes are only available within the class namespace as MyClass.apples and MyClass.data.


AttributeError

Attempting to access an attribute that has not been defined will raise an AttributeError.

class MyClass:
    pass

my_instance = MyClass()
print(my_instance.data)
Traceback (most recent call last):
  File "path/to/my_class.py", line 5, in <module>
    print(my_instance.data)
AttributeError: 'MyClass' object has no attribute 'data'

This can be circumvented by defining a __getattr__ method.


Instance attributes

Instances are also namespaces. If an attribute is not found on an instance, it will be looked up on the class. In this way, classes provide attributes that are shared across all instances.

class MyClass:
    pass

my_instance = MyClass()
another_instance = MyClass()

MyClass.apples = 'hello'
my_instance.apples = 'different value'

print(my_instance.apples)
print(another_instance.apples)
different value
hello

Default attributes

Classes can have attributes included in their definition. Anything we put inside the code block will be part of the class definition and will be available to all instances of the class.

class Coordinate:
    x = 0
    y = 0

c1 = Coordinate()
c2 = Coordinate()

c1.x = 2
c2.y = -3

print(f"{c1.x=}, {c1.y=}")
print(f"{c2.x=}, {c2.y=}")
c1.x=2, c1.y=0
c2.x=0, c2.y=-3

Methods

A function defined within a class is known as a method. What happens when we try to call Coordinate.my_method() from an instance?

class Coordinate:
    def my_method():
        pass

c1 = Coordinate()
c1.my_method()
TypeError: Coordinate.my_method() takes 0 positional arguments but 1 was given

Apparently our method was passed one argument, even though we passed nothing when we called it.

This is a copy of the instance and it is always automatically passed into methods.


What is this argument?

Let’s update our code to have a look at the argument. We’ll make two instances to see the impact.

Notice the my_method() function now takes one argument (arg) and prints it.

class Coordinate:
    def my_method(arg):
        print(arg)

c1 = Coordinate()
c2 = Coordinate()

c1.my_method()
c2.my_method()
<__main__.Coordinate object at 0x7fc3e3cd0310>
<__main__.Coordinate object at 0x7fc3e3cd02e0>

Always use self for this

By convention, python programmers always name this argument self to avoid confusion. Every method defined on a class should add self as the first argument. All subsequent arguments are treated as with normal functions.

class Coordinate:
    x = 10
    y = 5

    def invert(self):
        self.x, self.y = self.y, self.x

c1 = Coordinate()

print(c1.x, c1.y)   # default class attributes
c1.invert()
print(c1.x, c1.y)   # instance attributes
10 5
5 10

The constructor method __init__

This is awkward.

c1 = Coordinate()
c1.x = 10
c1.y = 5

We can define a custom constructor method Coordinate.__init__() to allow this, more convenient syntax.

class Coordinate:
    def __init__(self, x, y):
        self.x = x
        self.y = y

origin = Coordinate(0, 0)
c1 = Coordinate(3, 8)
c2 = Coordinate(-4, 2)

Again, we take an implicit self argument plus whatever else we need.


Printing instances

When we call print() on Coordinate instances, we get output like this.

print(Coordinate(0, 0))
<__main__.Coordinate object at 0x7fec1d6d4280>

The print() function calls str() which calls the __str__ method of any object which is passed to it. The object’s __str__ method must return a string representation of the object.

Remember, everything is an object.

print([i.__str__() for i in (1.1, True, Coordinate(0, 0))])
['1.1', 'True', '<__main__.Coordinate object at 0x7fabd3cafc10>']

Note this is i.__str__(), not str(i). But they are equivalent because str(i) calls i.__str__().


The default __str__ method

Our objects are responsible for telling the str() constructor what a string representation should be. So, we can define how our classes are converted into strings.

The default __str__ implementation is something like this:

def __str__(self):
    md = self.__class__.__module__
    cls = self.__class__.__name__
    return f"<{md}.{cls} object at {id(self):#x}>"

It provides enough information to work out what something is and where it is defined, but its not very convenient if we want to see the instance attributes of our Coordinate.

All instances have a __class__ attribute. All classes have a __module__ and a __name__.


A custom __str__ method

We can give our class a customised __str__ method to return a customised string.

class Coordinate:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __str__(self):
        return f'({self.x}, {self.y})'

for x in range(2):
    for y in range(2):
        print(Coordinate(x, y))
(0, 0)
(0, 1)
(1, 0)
(1, 1)

The __repr__ method

class Coordinate:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __str__(self):
        return f'({self.x}, {self.y})'

    def __repr__(self):
        return f"Coordinate(x={self.x}, y={self.y})"

for x in range(2):
    for y in range(2):
        print(repr(Coordinate(x, y)))
Coordinate(x=0, y=0)
Coordinate(x=0, y=1)
Coordinate(x=1, y=0)
Coordinate(x=1, y=1)

Operators

The exact same idea is used to implement operators. The following expressions are exactly equivalent.

a - b
a.__sub__(b)

Obviously, the first one is a neater way of doing it and is always preferred when we want to do subtraction. The key point here is that our Coordinate object can define how it interacts with operators. Currently, it cannot be part of a subtraction operation.

a = Coordinate(1, 2)
b = Coordinate(2, 1)

a - b
Traceback (most recent call last):
  File "path/to/coordinate.py", line 15, in <module>
    a - b
TypeError: unsupported operand type(s) for -: 'Coordinate' and 'Coordinate'

Implementing a __sub__ method

class Coordinate:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __sub__(self, other):
        return Coordinate(self.x - other.x, self.y - other.y)

    def __str__(self):
        return f'Coordinate({self.x}, {self.y})'

    def __repr__(self):
        return f"Coordinate(x={self.x}, y={self.y})"

c1 = Coordinate(1, 2)
c2 = Coordinate(3, 4)
c3 = c1 - c2
print(f"{c1} - {c2} = {c3}")
(1, 2) - (3, 4) = (-2, -2)

Thanks for listening

Any questions?

We should have plenty of time for questions and answers.

Just ask, you are probably not the only one who wants to know.

Dr Graeme Stuart