OOP, Encapsulation, @Propeties

In python all attributes are public.

[5]:
class P:
    def __init__(self,x):
        self.x = x # self.x -> public attribute

p = P(10)
p.x # accessing the public attribute
[5]:
10

You will sometimes see an attribute with a single or a double underscore prior to the name

self._x = x

When you read about this it will be described as a private “like” variable, and then a long explanation about that there is not realy anything private in python and so on.

In reality it is all just public attributes and some of them with a funny looking syntax.

[9]:
class P:
    def __init__(self,x):
        self._x = x

p = P(10)
p._x
[9]:
10

In order to work with attributes the right way in python you need to understand that all attributes are public, and you need to accept and understand that this is actually a good thing, and it is NOT a limitation Forget all about the Java approach with private attributes and getters and setters. There is another logic behind that approach.

@Property

Read about : Properties vs. Getters and Setters

[4]:
class Number:

    def __init__(self, value):
            self.x = value

[5]:
num = Number(12)
[7]:
num.x
[7]:
12

Q: What if vaalue should be between 0 and 100?

What about encapsulation?

Problem could be that data is not encapsulated

Using the getter and setter and having encapsulated data looks like this

The Java style approach

[8]:
class P:
    def __init__(self, x):
        self.set_x(x)

    def get_x(self):
        return self._x

    def set_x(self, x):

        if x > 1000:
            self._x = 1000
        elif x < 0:
            self._x = 0
        else:
            self._x = x
[9]:
p1 = P(3)
p2 = P(1000)
[10]:
p1.set_x(p1.get_x() + p2.get_x())
[11]:
p1.get_x()
[11]:
1000

But, This is much cleaner and more pythonic

p1.x = p1.x + p2.x

than this:

p1.set_x(p1.get_x() + p2.get_x())

@properties solves the problem

[13]:
class P:
    def __init__(self, x):
        self.x = x # self.x is now the @x.setter

    @property
    def x(self):
        return self._x # this is a private variable

    @x.setter
    def x(self, x):
        if x > 1000:
            self._x = 1000
        elif x < 0:
            self._x = 0
        else:
            self._x = x
[14]:
p1 = P(-1)
p2 = P(1001)
[15]:
p1.x
[15]:
0
[16]:
p2.x
[16]:
1000
[17]:
p1.x = 101
p1.x = p1.x + p2.x
p1.x
[17]:
1000

Datamodel

Read about this: A Guide to Python’s Magic Methods

Protocol:

Top-level function or top-level syntax has a corosponding __method__()

When you initialize and object, there is a corosponding __init__() method that is called

[1]:
class S:
    def __init__(self):
        pass

s = S()

If you want a string representation of the object (the objects state), you call the top-level function repr() which has a corosponding implementation of __repr__

[20]:
class S:
    def __init__(self, name):
        self.name = name
    def __repr__(self):
        return f'{self.__dict__}'

s = S('Claus')
repr(s)
[20]:
"{'name': 'Claus'}"

Read about this: str() vs repr() in Python

Python has 2 options of printing a string representation. repr() and str().
They do the same thing, but str() gives an informal string representation of the object, repr() gives a formal.
This means that str() is used for creating output for end user, repr() is mainly used for debugging and development.
[2]:
class S:
    def __init__(self, name):
        self.name = name
    def __repr__(self):
        return f'{self.__dict__}'
    def __str__(self):
        return f'Name: {self.name}'

s = S('Anna')
str(s)
[2]:
'Name: Anna'
[5]:
print(s)
Name: Anna
[4]:
repr(s)
[4]:
"{'name': 'Anna'}"
[6]:
s
[6]:
{'name': 'Anna'}
+, -, *, /

2 lists can be added together by using the + operator

[3]:
l1 = [1, 2, 3]
l2 = [4, 5, 6]

l1 + l2
[3]:
[1, 2, 3, 4, 5, 6]
this is because the list class has implemented the **__add__()** method.
You can do the same in your custorm objects.
[4]:
class S:
    def __init__(self, name):
        self.name = name
    def __repr__(self):
        return f'{self.__dict__}'
    def __str__(self):
        return f'Name: {self.name}'
    def __add__(self, other):
        return S(f'{self.name} {other.name}')

s = S('Claus')
s2 = S('Anna')
str(s + s2)
[4]:
'Name: Claus Anna'

A list in python can be accessed like with this syntax:

[5]:
l1[2]
[5]:
3

This is because a list implements the method

[6]:
def __getitem__(self):
    pass

Your own object, if implementing this method can have the same behaviour

[1]:
class Deck:
    def __init__(self):
        self.cards = ['A', 'K', 4, 7]

    def __getitem__(self, key):
        return self.cards[key]

    def __repr__(self):
        return f'{self.__dict__}'

    def __len__(self):
        return len(self.cards)

[2]:
d = Deck()
d[3]
[2]:
7
[ ]: