Skip to content
wannacode

OOP principles

In short: Object-oriented programming rests on four principles: encapsulation, inheritance, polymorphism and abstraction. They guard the data, write what is shared once and replace the if-chains on the kind of object.

Problem

Payroll without principles

At the end of the month the company pays its people. The program was written in a hurry: one class with public fields, the role is text, and the pay is worked out in two places. The monthly_pay function makes the payslip, and transfer sends the money to the bank.

class Employee:
def __init__(self, name, role, salary, bonus):
self.name = name
self.role = role
self.salary = salary
self.bonus = bonus
def monthly_pay(e):
if e.role == "manager":
return e.salary + e.bonus
elif e.role == "intern":
return e.salary // 2
return e.salary
def transfer(staff):
total = 0
for e in staff:
amount = e.salary
if e.role == "manager":
amount += e.bonus
print(f"Transfer: {e.name} €{amount}")
total += amount
print(f"Total: €{total}")

Today accounting enters a raise for Ivan. But the form says -10 instead of 10:

staff = [
Employee("Ivan", "employee", 2000, 0),
Employee("Maria", "manager", 3000, 500),
Employee("Ana", "intern", 1600, 0),
]
percent = -10
staff[0].salary += staff[0].salary * percent // 100
for e in staff:
print(f"Payslip: {e.name} €{monthly_pay(e)}")
transfer(staff)
Output
Payslip: Ivan €1800
Payslip: Maria €3500
Payslip: Ana €800
Transfer: Ivan €1800
Transfer: Maria €3500
Transfer: Ana €1600
Total: €6900

The program does not stop and does not complain. But the output has three cracks:

  1. A bad write. Ivan's salary dropped to €1800. The field is open to every line in the program, and nobody asks whether -10% is a raise.
  2. Two copies of one rule. Ana's payslip says €800, and the bank sends her €1600. The rule "an intern gets half" is in the payslip, but someone forgot it in the transfer. The copy drifted away from the original, and nobody noticed.
  3. A new kind, many fixes. Tomorrow a freelancer joins. You need a new elif in the payslip, in the transfer, in tax, in leave, everywhere someone asks about the role. And every employee already carries a bonus field, though only the manager has a bonus.

Object-oriented programming is the answer to exactly these three things.

The idea

The four principles

Object-oriented programming (OOP) organizes a program around objects: data together with the methods that touch it. It rests on four principles. You have already worked with each of them in the modules "Classes and objects" and "Inheritance and polymorphism":

Principle What it says Which crack it fixes
Encapsulation The object guards its own data. From outside you reach it only through methods that check. The bad write: -10% is refused.
Inheritance What several classes share is written once, in a parent. The bonus lives only in Manager, and what is shared (name, salary, the check in the raise) is written once in Employee.
Polymorphism One call, and every object answers in its own way. The if on the role: every class works out its own pay, with no second copy.
Abstraction Code depends on what an object can do, not on how it does it. A new kind of payment joins without touching the transfer.

In short: encapsulation guards, inheritance shares, polymorphism chooses, abstraction promises.

From here on, each principle fixes one crack in the payroll program. At the end you will see them together, in the same program.

The idea

Encapsulation: the object guards its data

The salary belongs to the employee. Let only the employee change it. The field becomes internal, with an underscore, and the raise is a method that checks:

class Employee:
def __init__(self, name, salary):
self._name = name
self._salary = salary
@property
def name(self):
return self._name
@property
def salary(self):
return self._salary
def raise_salary(self, percent):
if percent > 0:
self._salary += self._salary * percent // 100
ivan = Employee("Ivan", 2000)
ivan.raise_salary(-10)
print(ivan.salary)
ivan.raise_salary(10)
print(ivan.salary)
Output
2000
2200

-10% is refused, 10% goes through. The check is in one place, the class, and any code that wants to change the salary goes through it. This is encapsulation: the data and the rules for it sit together in the object, and from outside you see only what the class chooses to show.

Python has no private. The underscore is an agreement between programmers: "internal, do not touch from outside". The language stops nobody. A line that writes straight into the field runs:

ivan._salary = -2000
print(ivan.salary)
Output
-2000

So in Python encapsulation is guarded by the people who read the code, and by tools like PyCharm, which underline such a line. From outside the salary is read through the salary property, which has no setter, and changed only with raise_salary().

When it hurts: getters and setters for everything. If every field has a getter and a setter that writes whatever you give it, the field is still open, only with more lines. Encapsulation is not "a hidden field plus get and set". It is: the object decides what may change and how. That is why Employee has no method that writes the salary, only a raise with a check. Start with everything hidden and open only what you must.

More precisely, if you are curious
  • With two underscores (self.__salary) Python renames the field to _Employee__salary. From outside ivan.__salary is gone, but the new name still reaches it:
class Employee:
def __init__(self, name, salary):
self.__salary = salary
ivan = Employee("Ivan", 2000)
try:
print(ivan.__salary)
except AttributeError as error:
print(error)
print(ivan._Employee__salary)
Output
'Employee' object has no attribute '__salary'
2000

This guards against an accidental name clash in child classes, not against someone touching the field on purpose. One underscore is usually enough.

Try it

What hides the salary?

The salary is internal to the class. What is the field called by agreement (with a single underscore)?
class Employee:
    def __init__(self, name, salary):
        self._name = name
        self. = salary

The idea

Inheritance: what is shared, once

A manager is an employee with a bonus. Instead of a copy of Employee with one more field, Manager inherits everything and adds only the bonus:

class Manager(Employee):
def __init__(self, name, salary, bonus):
super().__init__(name, salary)
self._bonus = bonus
@property
def bonus(self):
return self._bonus
maria = Manager("Maria", 3000, 500)
maria.raise_salary(-10)
maria.raise_salary(10)
print(f"{maria.name}: {maria.salary} + {maria.bonus}")
Output
Maria: 3300 + 500

The manager has no raise check of its own. It takes the one from Employee: -10% is refused, and 10% lifts 3000 to 3300. If the rule for raises changes tomorrow, you change it in one place and it holds for every child class. This is inheritance: what is shared sits in the parent, and the child writes only its own part.

super().__init__(name, salary) hands the name and the salary to the parent's __init__, and then Manager reads them through the salary property. In Python a class can have several parents (the lesson "Inheritance"), but it is best if only one of them has fields and code.

The test for whether inheritance makes sense is the sentence "X is a Y". "A manager is an employee" is true.

When it hurts: long chains and inheriting for ready-made code. Employee → Manager → SeniorManager → RegionalSeniorManager: to understand what the bottom class does, you read four classes, and a change at the top moves everything below it. Another common mistake is to inherit a class only to get a ready-made method: an invoice that extends Employee so that it has a name. "An invoice is an employee" is not true, and it would get a salary and a raise. What to do instead is in the card "What people mix up" below.

The idea

Polymorphism: each class answers for itself

Now the crack with the copies. Instead of an if on the role, every class works out its own monthly pay. The parent has a general version, and the child classes override it:

class Employee:
# ... __init__, salary and raise_salary() from before
def monthly_pay(self):
return self._salary
class Manager(Employee):
# ... __init__ and bonus
def monthly_pay(self):
return self.salary + self._bonus
class Intern(Employee):
def monthly_pay(self):
return self.salary // 2

Both the payslip and the transfer call the same method:

def transfer(staff):
total = 0
for e in staff:
print(f"Transfer: {e.name} €{e.monthly_pay()}")
total += e.monthly_pay()
print(f"Total: €{total}")
staff = [
Employee("Ivan", 2000),
Manager("Maria", 3000, 500),
Intern("Ana", 1600),
]
for e in staff:
print(f"Payslip: {e.name} €{e.monthly_pay()}")
transfer(staff)
Output
Payslip: Ivan €2000
Payslip: Maria €3500
Payslip: Ana €800
Transfer: Ivan €2000
Transfer: Maria €3500
Transfer: Ana €800
Total: €6300

Ana gets €800 both on the payslip and in the transfer. The intern rule lives only in Intern, and there is no second copy to drift away. A new kind of employee is a new class with its own method, and the loop and the transfer stay untouched. This is polymorphism: one call, e.monthly_pay(), and every object answers in its own way.

Python looks the method up by name: first in the object's class, then in its parent. Nothing is checked in advance. Any object that has a method with that name works for the loop, whatever its class. This is called duck typing: "if it walks like a duck and quacks like a duck, it is a duck".

When it hurts. Two cases that will never become three do not need a class hierarchy: one if is shorter and clearer. Also: a child class that overrides the method but breaks the parent's promise (a pay that comes out negative, or a method that stops the program) breaks every loop that trusts the parent.

Try it

How much will the bank send?

The classes are from the cards above, together with the payment contract from the next card: amount_due() is in Employee and returns self.monthly_pay().

e = Intern("Ana", 1600)
e.raise_salary(10)
print(e.amount_due())
Answer

The idea

Abstraction: what, not how

Accounting wants the suppliers' invoices in the same transfer. An invoice is not an employee. But the transfer does not care what kind of thing each one is. It needs two things: to whom, and how much. That is the contract:

class Payable(ABC):
@abstractmethod
def amount_due(self):
pass
@abstractmethod
def payee(self):
pass
class Employee(Payable):
# ... everything from before
def amount_due(self):
return self.monthly_pay()
def payee(self):
return self._name
def transfer(payments):
total = 0
for p in payments:
print(f"Transfer: {p.payee()} €{p.amount_due()}")
total += p.amount_due()
print(f"Total: €{total}")
class Invoice(Payable):
def __init__(self, supplier, amount):
self._supplier = supplier
self._amount = amount
def amount_due(self):
return self._amount
def payee(self):
return self._supplier
payments = [
Employee("Ivan", 2000),
Manager("Maria", 3000, 500),
Intern("Ana", 1600),
Invoice("Power company", 340),
]
transfer(payments)
Output
Transfer: Ivan €2000
Transfer: Maria €3500
Transfer: Ana €800
Transfer: Power company €340
Total: €6640

transfer knows nothing about salaries, bonuses and invoices. It knows only the contract. This is abstraction: you show what an object can do and hide how it does it. The transfer depends on the abstraction, so anything that fulfills it joins without a change to the transfer.

Python has no interface. The contract is an abstract class (ABC) in which every method has @abstractmethod. The check happens when an object is created: a class that puts Payable in its parentheses but has not written amount_due() cannot have objects. transfer itself checks nothing: any object with these two methods goes through it.

Here the contract is an abstract class with no code. An abstract class can also carry shared code: Shape promises an area, and each shape says how it is worked out ("Abstract classes"). When to use which is in the lesson "Interfaces".

When it hurts: abstraction too early. A contract with a single implementation, "just in case", is one more name, one more file and one more jump while you read. Abstraction pays off when there are at least two implementations, or when you need to swap the one you have, for example in a test. Payable appeared when the invoice came, not before.

See it

The four together

The scene walks through the program from the start: each principle fixes its own crack, and the numbers below come from the same program.

staff = [
Employee("Ivan", 2000),
Manager("Maria", 3000, 500),
Intern("Ana", 1600),
]
staff[0].raise_salary(-10)
for e in staff:
print(f"Payslip: {e.name} €{e.monthly_pay()}")
payments = [staff[0], staff[1], staff[2], Invoice("Power company", 340)]
transfer(payments)
Step by step, in words
  1. Before: the role is text, the pay rule is copied into two chains of if, and anyone can write the salary. Ivan gets €1800, and Ana's payslip and transfer disagree.
  2. Encapsulation: the salary is hidden, and raise_salary() refuses -10%. Ivan stays at €2000.
  3. Inheritance: Manager takes the name, the salary and the raise check from Employee and adds only the bonus. The objects now have classes, not a role in text.
  4. Polymorphism: every class works out its own monthly_pay(). The transfer calls the same method as the payslip, so Ana gets €800 in both.
  5. Abstraction: the transfer only knows the Payable contract. The power company invoice joins without being an employee.
  6. All four together: the same -10% is refused, every payslip matches its transfer, and the total is €6640.

Here is the program before and after, with 10% for Ivan. The procedure "before" has been fixed by hand: the intern rule is added to the transfer as well.

Before
def monthly_pay(e):
if e.role == "manager":
return e.salary + e.bonus
elif e.role == "intern":
return e.salary // 2
return e.salary
def transfer(staff):
total = 0
for e in staff:
amount = e.salary
if e.role == "manager":
amount += e.bonus
elif e.role == "intern":
amount = e.salary // 2
print(f"Transfer: {e.name} €{amount}")
total += amount
print(f"Total: €{total}")
staff = [
Employee("Ivan", "employee", 2000, 0),
Employee("Maria", "manager", 3000, 500),
Employee("Ana", "intern", 1600, 0),
]
percent = 10
staff[0].salary += staff[0].salary * percent // 100
for e in staff:
print(f"Payslip: {e.name} €{monthly_pay(e)}")
transfer(staff)
Output
Payslip: Ivan €2200
Payslip: Maria €3500
Payslip: Ana €800
Transfer: Ivan €2200
Transfer: Maria €3500
Transfer: Ana €800
Total: €6500
After
def transfer(payments):
total = 0
for p in payments:
print(f"Transfer: {p.payee()} €{p.amount_due()}")
total += p.amount_due()
print(f"Total: €{total}")
staff = [
Employee("Ivan", 2000),
Manager("Maria", 3000, 500),
Intern("Ana", 1600),
]
staff[0].raise_salary(10)
for e in staff:
print(f"Payslip: {e.name} €{e.monthly_pay()}")
transfer(staff)
Output
Payslip: Ivan €2200
Payslip: Maria €3500
Payslip: Ana €800
Transfer: Ivan €2200
Transfer: Maria €3500
Transfer: Ana €800
Total: €6500

The output is the same. The difference is in what happens tomorrow:

  • Someone enters -10 → raise_salary() refuses it (encapsulation).
  • A new rule for raises → it changes in one place, Employee (inheritance).
  • A new kind of employee → a new class with its own monthly_pay(); the payslip and the transfer stay untouched (polymorphism).
  • A new thing to pay → a new class that fulfills the contract; the transfer stays untouched (abstraction).

The same program with the wrong -10% from the problem and with the invoice:

staff = [
Employee("Ivan", 2000),
Manager("Maria", 3000, 500),
Intern("Ana", 1600),
]
staff[0].raise_salary(-10)
for e in staff:
print(f"Payslip: {e.name} €{e.monthly_pay()}")
payments = [staff[0], staff[1], staff[2], Invoice("Power company", 340)]
transfer(payments)
Output
Payslip: Ivan €2000
Payslip: Maria €3500
Payslip: Ana €800
Transfer: Ivan €2000
Transfer: Maria €3500
Transfer: Ana €800
Transfer: Power company €340
Total: €6640
All the classes in one place
from abc import ABC, abstractmethod
class Payable(ABC):
@abstractmethod
def amount_due(self):
pass
@abstractmethod
def payee(self):
pass
class Employee(Payable):
def __init__(self, name, salary):
self._name = name
self._salary = salary
@property
def name(self):
return self._name
@property
def salary(self):
return self._salary
def raise_salary(self, percent):
if percent > 0:
self._salary += self._salary * percent // 100
def monthly_pay(self):
return self._salary
def amount_due(self):
return self.monthly_pay()
def payee(self):
return self._name
class Manager(Employee):
def __init__(self, name, salary, bonus):
super().__init__(name, salary)
self._bonus = bonus
@property
def bonus(self):
return self._bonus
def monthly_pay(self):
return self.salary + self._bonus
class Intern(Employee):
def monthly_pay(self):
return self.salary // 2
class Invoice(Payable):
def __init__(self, supplier, amount):
self._supplier = supplier
self._amount = amount
def amount_due(self):
return self._amount
def payee(self):
return self._supplier

Try it

Put the manager in order

The lines of Manager are shuffled. Put them in the usual order: the constructor, which calls the parent first, then the pay method.

  1. class Manager(Employee):
  2. return self.salary + self._bonus
  3. super().__init__(name, salary)
  4. def __init__(self, name, salary, bonus):
  5. def monthly_pay(self):
  6. self._bonus = bonus

Try it

Find the bug

A colleague "simplified" the contract in Employee. The program is the one from "The four together", and it prints:

Output
Payslip: Ivan €2200
Payslip: Maria €3500
Payslip: Ana €800
Transfer: Ivan €2200
Transfer: Maria €3000
Transfer: Ana €1600
Total: €6800

Maria's payslip is €3500, and the bank sends her €3000. Which line is the bug on?

Pick the line with the bug.

The idea

What people mix up

Abstraction or encapsulation? Both hide something, but different things. Encapsulation hides the data of one object so that nobody outside can break it: the salary is hidden, the raise checks. Abstraction hides the differences between many classes behind a shared promise: the transfer sees only amount_due() and payee(). Encapsulation looks at the object from inside, abstraction looks at the code that uses it from outside.

Inheritance or composition? Inheritance says "is a": a manager is an employee. Composition says "has a": an employee has a bank account. If you make the employee a child of the account from the lesson "Encapsulation" to get deposit() for free,

class Employee(Account):
# ... name and salary

it will also get withdrawals, a balance and everything the account learns tomorrow. And "an employee is an account" is not true. Instead, the account is a field of the employee:

class Employee:
def __init__(self, name, salary, account):
self._name = name
self._salary = salary
self._account = account
def payday(self):
self._account.deposit(self._salary)
account = Account(100)
ivan = Employee("Ivan", 2000, account)
ivan.payday()
print(account.balance)
Output
2100

That is why the common advice is "prefer composition over inheritance". Inherit only when "X is a Y" is true and will stay true. Otherwise let the object have the other one as a field.

Overloading or overriding? In Java and C# both are sometimes called polymorphism. Overloading is several methods with one name and different parameters. Python does not have it: a second function with the same name replaces the first, and instead there are default values (the lesson on them). Polymorphism in OOP is overriding: the choice is made by the object, while the program runs.

More precisely, if you are curious
  • Some books list three pillars: encapsulation, inheritance and polymorphism. Abstraction is then the shared idea under them. In an interview, four is what is usually expected.
  • The principles are not rules to follow "100%". Each has a cost: hidden fields mean more methods, hierarchies mean more classes, contracts mean one more name. You use them where the crack is real.
  • After these four come the five SOLID principles for code that survives change, and design patterns: ready-made solutions built from the same four. They are in the "Software engineering" course: SOLID and Design patterns.

Exercise

Which principle is broken?

A classic interview question. For each snippet, answer out loud: which principle is broken, what will break, and how you fix it.

1.

class Cart:
def __init__(self):
self.total = 0
# ... in 12 places in the program:
cart.total -= discount

2.

def area(s):
if isinstance(s, Circle):
return 3.14 * s.r * s.r
if isinstance(s, Rectangle):
return s.w * s.h
return 0

3.

class Invoice(Employee):
# gets name and payee() for free
pass

4.

class SalaryProvider(ABC):
@abstractmethod
def provide(self, e):
pass
# the only implementation:
class DefaultSalaryProvider(SalaryProvider):
def provide(self, e):
return e.monthly_pay()
The answers
  1. Encapsulation. The total is open and gets reduced from 12 places. A discount bigger than the total makes it negative, and nobody asks. The total becomes _total, and the discount is a method of the cart that checks.
  2. Polymorphism (and abstraction). An if on the kind of shape: a new shape means a new line here and everywhere someone asks about the kind. The area becomes an abstract method of Shape, and each shape writes its own.
  3. Inheritance in the wrong place. "An invoice is an employee" is not true: it gets a salary, a raise and a monthly pay. What they share is that they get paid: a Payable contract.
  4. Abstraction too early. A contract with one implementation that only forwards to monthly_pay() is one more name and one more jump while reading, and it guards against nothing. Remove it and call the method directly. The contract will make sense when a second implementation appears.

Exercise

A contractor and a terrace

Solve these in your editor.

1. A contractor. Contractor (Peter, 40 hours at €25) is not an employee: there is no payslip, but he must be in the transfer. Add him so that transfer does not change by a single line. Put a rent invoice in the transfer as well, €1800 for the landlord.

Output
Transfer: Ivan €2000
Transfer: Maria €3500
Transfer: Ana €800
Transfer: Peter €1000
Transfer: Landlord €1800
Total: €9100

2. The terrace without if. This procedure works out the area by the kind of shape:

def area(kind, a, b):
if kind == "rectangle":
return a * b
if kind == "triangle":
return a * b // 2
if kind == "square":
return a * a
return 0

Write it with an abstract Shape and the classes Rectangle, Triangle and Square. The terrace is a 4 × 3 rectangle, a triangle with base 2 and height 3, and a square with side 2. A square is a rectangle with equal sides: write it as a child of Rectangle, with no area formula of its own.

Output
Rectangle: 12 m²
Triangle: 3 m²
Square: 4 m²
Total: 19 m²

More tasks

  • A payslip for the contractor too. If the contractor wants a payslip after all, without becoming an employee: what has to be in the contract, and who fulfills it?
  • Pay into an account. Give the employee an account (composition, as in "What people mix up") and a method that, on payday, deposits monthly_pay(), not the salary. The manager must get the bonus too, without touching monthly_pay() in Manager.
  • A raise for everyone. Raise every employee's salary by 5% in one loop. Which classes do you touch?

Test

Check yourself

1. The salary is hidden, and it changes only through raise_salary(), which refuses a percentage below zero. Which principle is this?
2. The payslip and the transfer work out the pay with two copies of the if on the role, and one of them forgot the intern. Which principle removes the second copy?
3. How do abstraction and encapsulation differ?
4. An invoice needs a name and an amount. A colleague suggests that Invoice extends Employee to get them for free. Why not?
5. What does an object need in order to go through transfer()?

Made by Ivo Gergov as part of Evo Tech's campaign for accessible programming education ·