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 = bonusdef 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 = -10staff[0].salary += staff[0].salary * percent // 100for e in staff: print(f"Payslip: {e.name} €{monthly_pay(e)}")transfer(staff)Payslip: Ivan €1800Payslip: Maria €3500Payslip: Ana €800Transfer: Ivan €1800Transfer: Maria €3500Transfer: Ana €1600Total: €6900The program does not stop and does not complain. But the output has three cracks:
- 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.
- 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.
- A new kind, many fixes. Tomorrow a freelancer joins. You need a new
elifin the payslip, in the transfer, in tax, in leave, everywhere someone asks about the role. And every employee already carries abonusfield, 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 // 100ivan = Employee("Ivan", 2000)ivan.raise_salary(-10)print(ivan.salary)ivan.raise_salary(10)print(ivan.salary)20002200-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 = -2000print(ivan.salary)-2000So 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 outsideivan.__salaryis 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)'Employee' object has no attribute '__salary'2000This 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?
class Employee:
def __init__(self, name, salary):
self._name = name
self. = salary_salary: the underscore says "internal to the class, do not touch from outside". Python does not forbid it, but anyone reading the code sees it.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._bonusmaria = Manager("Maria", 3000, 500)maria.raise_salary(-10)maria.raise_salary(10)print(f"{maria.name}: {maria.salary} + {maria.bonus}")Maria: 3300 + 500The 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 // 2Both 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)Payslip: Ivan €2000Payslip: Maria €3500Payslip: Ana €800Transfer: Ivan €2000Transfer: Maria €3500Transfer: Ana €800Total: €6300Ana 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())The raise is from Employee: 1600 + 10% = 1760. amount_due() is also from Employee, but it calls monthly_pay() on the object, and the object is an intern. The Intern version runs: 1760 / 2 = 880. Three principles in three lines.
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._supplierpayments = [ Employee("Ivan", 2000), Manager("Maria", 3000, 500), Intern("Ana", 1600), Invoice("Power company", 340),]transfer(payments)Transfer: Ivan €2000Transfer: Maria €3500Transfer: Ana €800Transfer: Power company €340Total: €6640transfer 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)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}")
percent = -10staff[0].salary += staff[0].salary * percent // 100class 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 // 100class Manager(Employee): def __init__(self, name, salary, bonus): super().__init__(name, salary) self._bonus = bonus
@property def bonus(self): return self._bonusclass 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 // 2class 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}")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
- 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.
- Encapsulation: the salary is hidden, and raise_salary() refuses -10%. Ivan stays at €2000.
- 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.
- Polymorphism: every class works out its own monthly_pay(). The transfer calls the same method as the payslip, so Ana gets €800 in both.
- Abstraction: the transfer only knows the Payable contract. The power company invoice joins without being an employee.
- 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.
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 = 10staff[0].salary += staff[0].salary * percent // 100for e in staff: print(f"Payslip: {e.name} €{monthly_pay(e)}")transfer(staff)Payslip: Ivan €2200Payslip: Maria €3500Payslip: Ana €800Transfer: Ivan €2200Transfer: Maria €3500Transfer: Ana €800Total: €6500def 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)Payslip: Ivan €2200Payslip: Maria €3500Payslip: Ana €800Transfer: Ivan €2200Transfer: Maria €3500Transfer: Ana €800Total: €6500The 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)Payslip: Ivan €2000Payslip: Maria €3500Payslip: Ana €800Transfer: Ivan €2000Transfer: Maria €3500Transfer: Ana €800Transfer: Power company €340Total: €6640All 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._supplierTry 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.
class Manager(Employee):return self.salary + self._bonussuper().__init__(name, salary)def __init__(self, name, salary, bonus):def monthly_pay(self):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:
Payslip: Ivan €2200Payslip: Maria €3500Payslip: Ana €800Transfer: Ivan €2200Transfer: Maria €3000Transfer: Ana €1600Total: €6800Maria's payslip is €3500, and the bank sends her €3000. Which line is the bug on?
Pick the line with the bug.
amount_due() returns the salary directly instead of asking self.monthly_pay(). This is the second copy of the rule from the problem again, only shorter: the manager's bonus and the intern's half are left out. A parent method that calls an overridden method is where polymorphism works for you. The fix: return self.monthly_pay().
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 salaryit 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)2100That 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 -= discount2.
def area(s): if isinstance(s, Circle): return 3.14 * s.r * s.r if isinstance(s, Rectangle): return s.w * s.h return 03.
class Invoice(Employee): # gets name and payee() for free pass4.
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
- 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.
- 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. - 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
Payablecontract. - 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.
Transfer: Ivan €2000Transfer: Maria €3500Transfer: Ana €800Transfer: Peter €1000Transfer: Landlord €1800Total: €91002. 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 0Write 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.
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 touchingmonthly_pay()inManager. - A raise for everyone. Raise every employee's salary by 5% in one loop. Which classes do you touch?
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Polymorphism · Abstract classes · Interfaces as abstract classes · Type checks
- Tasks on this topic: Inheritance and polymorphism